diff --git a/.env.sample b/.env.sample index 7283f259..02b80816 100644 --- a/.env.sample +++ b/.env.sample @@ -1,29 +1,52 @@ +# PUPPETEER CONFIG +PUPPETEER_ARGS = + # HIGHCHARTS CONFIG HIGHCHARTS_VERSION = latest -HIGHCHARTS_CDN_URL = https://code.highcharts.com/ +HIGHCHARTS_CDN_URL = https://code.highcharts.com +HIGHCHARTS_FORCE_FETCH = false +HIGHCHARTS_CACHE_PATH = .cache +HIGHCHARTS_ADMIN_TOKEN = HIGHCHARTS_CORE_SCRIPTS = HIGHCHARTS_MODULE_SCRIPTS = HIGHCHARTS_INDICATOR_SCRIPTS = -HIGHCHARTS_FORCE_FETCH = false -HIGHCHARTS_CACHE_PATH = -HIGHCHARTS_ADMIN_TOKEN = +HIGHCHARTS_CUSTOM_SCRIPTS = # EXPORT CONFIG +EXPORT_INFILE = +EXPORT_INSTR = +EXPORT_OPTIONS = +EXPORT_SVG = +EXPORT_BATCH = +EXPORT_OUTFILE = EXPORT_TYPE = png EXPORT_CONSTR = chart +EXPORT_B64 = false +EXPORT_NO_DOWNLOAD = false +EXPORT_HEIGHT = +EXPORT_WIDTH = +EXPORT_SCALE = EXPORT_DEFAULT_HEIGHT = 400 EXPORT_DEFAULT_WIDTH = 600 EXPORT_DEFAULT_SCALE = 1 +EXPORT_GLOBAL_OPTIONS = +EXPORT_THEME_OPTIONS = EXPORT_RASTERIZATION_TIMEOUT = 1500 # CUSTOM LOGIC CONFIG CUSTOM_LOGIC_ALLOW_CODE_EXECUTION = false CUSTOM_LOGIC_ALLOW_FILE_RESOURCES = false +CUSTOM_LOGIC_CUSTOM_CODE = +CUSTOM_LOGIC_CALLBACK = +CUSTOM_LOGIC_RESOURCES = +CUSTOM_LOGIC_LOAD_CONFIG = +CUSTOM_LOGIC_CREATE_CONFIG = # SERVER CONFIG SERVER_ENABLE = false SERVER_HOST = 0.0.0.0 SERVER_PORT = 7801 +SERVER_UPLOAD_LIMIT = 3 SERVER_BENCHMARKING = false # SERVER PROXY CONFIG @@ -61,12 +84,12 @@ POOL_BENCHMARKING = false # LOGGING CONFIG LOGGING_LEVEL = 4 LOGGING_FILE = highcharts-export-server.log -LOGGING_DEST = log/ +LOGGING_DEST = log LOGGING_TO_CONSOLE = true LOGGING_TO_FILE = true # UI CONFIG -UI_ENABLE = true +UI_ENABLE = false UI_ROUTE = / # OTHER CONFIG @@ -78,7 +101,7 @@ OTHER_BROWSER_SHELL_MODE = true # DEBUG CONFIG DEBUG_ENABLE = false -DEBUG_HEADLESS = true +DEBUG_HEADLESS = false DEBUG_DEVTOOLS = false DEBUG_LISTEN_TO_CONSOLE = false DEBUG_DUMPIO = false diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 6ce17cf8..5f7e4135 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -16,4 +16,4 @@ - + diff --git a/CHANGELOG.md b/CHANGELOG.md index c6e8ac1f..7fff4d22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,146 @@ +# 5.0.0 + +_Breaking Changes:_ + +- Renamed and refined from `mapToNewConfig` to `mapToNewOptions`. +- Renamed the `certPath` CLI equivalent from `certPath` to `sslCertPath`. +- The `setCliOptions` function (renamed from the `setOptions`) updates additional options only for CLI exports now and it is replaced by the `updateOptions` function. +- Removed following API functions: `initPool`, `setOptions`, `mapToNewConfig`, `manualConfig`, `printLogo`, `printUsage` (some removed due to refactoring). + +_New Features:_ + +- Introduced redefined `getOptions` and `updateOptions` functions to retrieve and update the original global options or a copy of global options, allowing flexibility in export scenarios. +- Added a new option called `uploadLimit` to control the maximum size of a request's payload body. +- Added the possibility to return a Base64 version of the chart using any export method (not only through requests). +- Added support for displaying CLI usage (`-h`, `--h`, `-help`, `--help`) and version information with license details (`-v`, `--v`). +- Introduced `validation` middleware to check `Content-Type` headers and validate request bodies. + +_Enhancements:_ + +- Completely redefined, refactored, and optimized options initialization, updating, processing, and management. +- Simplified and optimized the `./lib/schemas/config.js` module, to export only the default configuration object (`defaultConfig`). +- Gathered previously scattered meta-information about options into the `defaultConfig` object. +- Gathered and enhanced logic from multiple modules (mainly from the `./lib/schemas/config.js`) and placed it in the `./lib/config.js` module. +- Added missing environment variables for all options along with corresponding validators. +- Global options now initialize with default values from the `defaultConfig` and potential values from the `.env` file. +- Created a new internal function `_initOptions` (combined from `initOptions` and `updateDefaultConfig` functions) to initialize default values at startup. +- Adjusted the options loading sequence: `default config -> environment variables` at initialization, `custom JSON -> CLI arguments` when using `setCliOptions` (CLI exports only). +- The `getOptions` function can now return either a direct reference to the `globalOptions` or a copy (by setting the `getCopy` flag). +- The `updateOptions` function can now update and return either a direct reference to the `globalOptions` or a copy (by setting the `getCopy` flag). +- The `_mergeOptions` (renamed from the `mergeConfigOptions`) modifies the first object directly now and is used internally. +- Replaced the fixed `absoluteProps` array (previously in `./lib/schemas/config.js`) with dynamic generation via `_createAbsoluteProps` function. +- Enhanced the `isAllowedConfig` (renamed from the `isCorrectJSON`) and `_optionsStringify` functions to better handle stringified options in JSON. +- Moved options previously used only in the request-oriented export method to the `export` section (`svg`, `b64`, and `noDownload`). +- Updated default values for certain options in the `.env` file (`HIGHCHARTS_CDN_URL`, `HIGHCHARTS_CACHE_PATH`, `LOGGING_DEST`, `UI_ENABLE`, and `DEBUG_HEADLESS`). +- Enhanced the `_loadConfigFile` to check if a file can be loaded via `allowFileResources`, for internal use in the `setCliOptions` function. +- Removed the `initExportSettings` function (no longer required). +- Major refactor, overhaul, and optimization of the main export functions (`singleExport`, `batchExport`, and `startExport`). +- The main export functions, such as `batchExport`, `singleExport`, and `startExport`, now accept an object as a parameter, which should include settings from the `export` and `customLogic` sections instead of the full options object. +- Improved how options are passed, validated, updated, and processed within `startExport`. +- Allowed partial exports in batch export operations and corrected the corresponding logs. +- Introduced `_exportFromSvg` and `_exportFromOptions` to handle specific export scenarios with proper validation. +- Split and enhanced logic of the `doExport` into multiple functions and created `_prepareExport` to gather and calling all these functions responsible for different export preparations (listed below). +- Created `_fixConstr` for finding correct chart constructor. +- Created `_fixType` for finding correct type. +- Created `_fixOutfile` for finding correct outfile. +- Created `_handleCustomLogic` for handling `customLogic` options. +- Created `_handleResources` for handling custom logic `resources` option. +- Created `_handleGlobalAndTheme` for handling `globalOptions` and `themeOptions` options. +- Created `_handleSize` for handling the `height`, `width`, and `scale` options. +- Created `_checkDataSize` for handling the data size validation. +- Optimized `initExport`, utilizing `updateOptions` for global option updates. +- The `initExport` now have its options parameters set as optional, using global option values if not provided. +- Updated exported API functions for module usage. +- Adjusted imports to get functions from corresponding modules rather than `index.js`. +- Server functions are now directly exported (rather than within a `server` object) as API functions. +- The `logger` API functions that modify options now update global options. +- Added following API functions: `getOptions`, `updateOptions`, `mapToNewOptions`, `enableConsoleLogging`. +- Small corrections of the `_attachProcessExitListeners` (renamed from the `attachProcessExitListeners`). +- Refactored logic for initial configuration, startup, and HTTP/HTTPS server management. +- Optimized `startServer`, utilizing `updateOptions` for global option updates. +- The `startServer` now have its options parameters set as optional, using global option values if not provided. +- Optimized logic and statistics display in `health.js` router. +- Optimized logic and corrected the url (from `version/change` to `version_change`) in the `versionChange.js` router. +- Refactored `exportHandler` (to `requestExport`) by moving some logic to the `validaion` middleware and refactoring the rest. +- Removed unnecessary `doCallbacks` from the export router. +- Moved `./lib/server/error.js` and `./lib/server/rateLimiting.js` to `./lib/server/middlewares/`, optimizing error handling and rate limiting. +- The `nodeEnv` option is now obtained from `getOptions`, not directly from the environment variables in the `error.js` middleware. +- Refactored and optimized the entire logic for checking the cache and fetching scripts. +- Refactored and split the `_updateCache` function into smaller, more manageable parts. +- The `updateHcVersion` function now correctly updates the global options. +- The new `_configureRequest` function is responsible for setting the proxy agent. +- Passing `version` as the first argument to `_saveConfigToManifest`. +- Removed the `fetchAndProcessScript` and `fetchScripts` functions, replacing them with a single function, `_fetchScript`, for the same purpose. +- Renamed the `checkAndUpdateCache` function to `checkCache`, the `updateVersion` function to `updateHcVersion`, the `version` function to `getHcVersion`, the `saveConfigToManifest` function to `_saveConfigToManifest`, the `extractModuleName` function to `_extractModuleName`, the `extractVersion` function to `extractHcVersion`, and the `cdnURL` property to `cdnUrl` in the `cache.js` module. +- Named the default exported function of the `export.js` module, `puppeteerExport`. +- Optimized `puppeteerExport` by simplifying processing, reducing the size of data passed to the browser, and improving performance and readability. +- Merged the logic from `createSVG` and `setAsConfig` into the `puppeteerExport` function. +- Extracted `_getChartSize` from part of the `puppeteerExport` logic to determine the final exported image size. +- Simplified error handling by removing the `HttpError` class and using only the `ExportError` class throughout the code. +- Removed forced modification of pool worker numbers for batch exports. +- Optimized the `initPool` and `postWork` functions. +- Moved the `factory` object into a separate function, optimizing pool resource functions. +- Extended and corrected the `poolStats` object. +- The `newPage` function now accepts the `poolResource` object and throws errors if the `browser` or `page` is incorrect. +- The `clearPage` function now accepts the `poolResource` object (instead of `page`) and correctly sets `workCount` to an exceeding number in case of issues with clearing the page. +- The `addPageResources` function now accepts the `customLogicOptions` object instead of an object containing all options. +- Set the `launchOptions.userDataDir` property to 'tmp'. +- Secured the `launchOptions.args` by setting it to [] if `puppeteerArgs` is not found in the `createBrowser` function. +- Renamed the `get` function to `getBrowser`, the `create` function to `createBrowser`, and the `close` function to `closeBrowser`. +- Renamed `triggerExport` to `createChart`, optimizing the options passed and processed in the `highcharts.js` module. +- Improved the module's overall logic, optimizing logging functions and the initialization process. +- Added `pathToLog` to the module's logging options to remember the full path to the log file. +- Added the `enableConsoleLogging` function. +- Removed `listen` function and `listeners` array from the module's logging options. +- Created a new module, `prompts.js`, to manage enhanced and refactored logic related to creating and loading options from prompts, previously handled in `config.js`. +- Added the `allowCodeExecution` flag as the second argument in the `manualConfig` function to check whether a file is allowed to be loaded. +- Added `isAllowedConfig` check to the options loaded with the `manualConfig` function. +- Created a new module, `info.js`, to group togheter and enhance logic (functions like `printInfo` and `printUsage`) previously in `utils.js` related to processing and displaying information such as license, version, and option usage. +- Created a new internal function `_cycleCategories` from the `printUsage` logic. +- Minor corrections in the `fetch.js` module, including changing the `fetch` function to `get`. +- Renamed the `getProtocol` function to `_getProtocolModule` (for internal use). +- Renamed `intervals.js` to `timer.js`. +- Added functionality to manage both intervals and timeouts. +- The default `exitCode` value in the `shutdownCleanUp` function is now set to `0`. +- Revised all utility functions. +- Added or moved the following functions: `getAbsolutePath`, `getBase64`, `getNewDate`, `getNewDateTime`. +- Removed or moved the following functions: `fixType`, `handleResources`, `isCorrectJSON`, `optionsStringify`, `printLogo`, `printUsage`, `wrapAround`. +- Changed the notation of most functions from `() =>` to `function()` for consistency and readability. +- Corrected and optimized the data passed to each function. +- Redefined and corrected which functions and properties should be exported. +- Fixed and standardized property and function names and values. +- Optimized dependencies between modules. +- Reduced unnecessary exports of modules. +- Marked all internal functions with a `_` prefix. +- Improved logging and error messages. +- Improved error handling. +- Corrections in error status codes. +- Enhanced all files with improved JSDoc tags, descriptions, and comments, adding extra tags such as `@overview`, `@async`, and `@function`. +- Corrected function descriptions, parameter types, return values, and documented errors. +- Fixed all tests, samples, and scenario runners. +- Created, renamed, or removed various tests, samples, and scenario runners. +- Removed separate test runner scripts. +- Made a minor correction in the `build` script. +- Updated package versions. +- Corrected the description of options prioritization order in the `Configuration` section. +- Added explanations of overall option handling, management, and processing, along with descriptions for each export method (`Options Handling` section and subsections). +- Added, updated, corrected, or redefined descriptions and values of options in the following sections: `Default JSON Config`, `Environment Variables`, `Custom JSON Config`, `Command Line Arguments`, `HTTP Server POST Arguments`. +- Fixed an incorrect version change endpoint description in the `Switching Highcharts Version at Runtime` section. +- Corrected example and added description of the `Node.js Module` section. +- Refreshed, expanded, and completely redefined the API documentation. +- Added a note on help and version information (`Note About Version and Help Information` section). +- Added a note about path interpretation for properties requiring path settings (`Note About Paths` section). +- Corrected the `Note About Chart Size` section. +- Various other fixes, optimizations and minor stylistic and formatting improvements. + +_Fixes:_ + +- Fixed a recurring issue with images not loading by implementing a mechanism that waits for images for a certain period. +- Corrected values, types, and other data for each property in the `defaultConfig` object. +- Corrected the `createConfig` and `loadConfig` options to allow usage with or without the `.json` extension. +- Fixed the `uiEnabled` by enabling its usage in `ui.js` router. +- Fixed issues with relative paths when used as a Node.js module. + # 4.1.0 _Enhancements:_ @@ -12,7 +155,7 @@ _Enhancements:_ # 4.0.2 -_Hotfix_: +_Hotfixes:_ - Fixed missing `msg` and `public` folders for a bundle in `v4.0.1` on NPM. @@ -22,7 +165,7 @@ _Fixes:_ # 4.0.1 -_Hotfix_: +_Hotfixes:_ - Fixed missing `dist` folder for a bundle in `v4.0.0` on NPM. @@ -67,7 +210,7 @@ _Enhancements:_ - Made corrections for gracefully shutting down resources, including running servers, ongoing intervals, browser instance, created pages, and workers pool. - Updated `createImage` and `createPDF` functions with faster execution options including `optimizeForSpeed` and `quality`. - Set `waitUntil` to `'domcontentloaded'` for `setContent` and `goto` functions to improve performance. -- Replaced browser's deprecated `isConnected()` with the `connected` property. +- Replaced browser's deprecated `isConnected` function with the `connected` property. - Added information on all available pool resources. - Numerous minor improvements for performance and stability. - Moved the `listenToProcessExits` from the `pool` to the `other` section of the options. @@ -92,7 +235,7 @@ _Enhancements:_ - Updated the `killPool` function. - The `uncaughtException` handler now kills the pool, browser, and terminates the process with exit code 1, when enabled. - The browser instance should be correctly closed now when an error occurs during pool creation. -- Corrected error handling and response sending in the `/change_hc_version.js` route. +- Corrected error handling and response sending in the `./lib/server/routes/change_hc_version.js` route. - Corrected the `handleResources` function. - Corrected samples, test scenarios, and test runners. - Bumped versions of most packages, with an updating deprecated `Puppeteer` from `v21.1.1` to latest. @@ -180,7 +323,7 @@ _Fixes:_ # 3.0.0 -_Fixes and enhancements:_ +_Enhancements and Fixes:_ - Replaced `PhantomJS` with `Puppeteer`. - Updated the config handling system to optionally load JSON files, and improved environment var loading. @@ -192,7 +335,7 @@ _Fixes and enhancements:_ - Lots of smaller bugfixes and tweaks. - Transitioned our public server (export.highcharts.com) from HTTP to HTTPS. -_New features:_ +_New Features:_ - Added `/health` route to server to display basic server information. - Added a UI served on `/` to perform exports from JSON configurations in browser. @@ -201,7 +344,7 @@ _New features:_ This version is not backwards compatible out of the box! -_Breaking changes:_ +_Breaking Changes:_ - Log destinations must now exist before starting file logging. - When running in server mode, the following options are now disabled by default: @@ -211,7 +354,7 @@ _Breaking changes:_ Disabled options can be enabled by adding the `--allowCodeExecution` flag when starting the server. Using this flag is not recommended, and should not be done unless the server is sandboxed and not reachable on the public internet. -_Changelog:_ +_Enhancements:_ - Added the `--allowCodeExecution` flag which is now required to be set when exporting pure JavaScript, using additional external resources, or using callback when running in server mode. - Removed the `mkdirp` dependency. @@ -367,7 +510,7 @@ _Changelog:_ # 1.0.11 - Fixed an issue with `themeOptions` when using CLI mode. -- Added `listenToProcessExits` option to `pool.init()`. +- Added `listenToProcessExits` option to `pool.init` function. - Exposed `listenToProcessExits` in CLI mode. - Fixed issue with `--callback` when the callback was a file. diff --git a/LICENSE b/LICENSE index f236833f..0ba67eaa 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/README.md b/README.md index 92b1154d..2fa44897 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Significant changes have been made to the API for using the server as a Node.js An important note is that the Export Server now requires `Node.js v18.12.0` or a higher version. -Additionally, with the v3 release, we transitioned from HTTP to HTTPS for export.highcharts.com, so all requests sent to our public server now must use the HTTPS protocol. +Additionally, with the v3 release, we transitioned from HTTP to HTTPS for `export.highcharts.com`, so all requests sent to our public server now must use the HTTPS protocol. ## Changelog @@ -82,27 +82,57 @@ highcharts-export-server There are four main ways of loading configurations: -- By loading default options from the `lib/schemas/config.js` file. -- By loading options from a custom JSON file. +- By loading default options from the `./lib/schemas/config.js` file. - By providing configurations via environment variables from the `.env` file. +- By loading options from a custom JSON file. - By passing arguments through command line interface (CLI). -...or any combination of the four. In such cases, the options from the later step take precedence (config file -> custom JSON -> envs -> CLI arguments). +...or any combination of the four. In such cases, the options from the later step take precedence (config file -> envs -> custom JSON -> CLI arguments). + +## Options Handling + +A description of how options are handled and processed when using a specific export method. + +### Server And CLI Usage + +When starting a server or performing single or batch exports via the CLI, the server's global options are initialized from the `defaultConfig` object located in `./lib/schemas/config.js`, along with values from the `.env` file. These options can be extended with additional values provided through CLI arguments, which are internally set using the `setCliOptions()` function. + +### Node.js Module Usage + +For `Node.js Module` usage, the process differs slightly because API functions are called manually. By default, global options are initialized from the `defaultConfig` object and the `.env` file at startup, similar to the server and CLI scenarios. However, to include additional options, you need to explicitly call the `updateOptions()` function. If the `updateOptions()` function is not invoked, the system will rely on the default values previously set in the global options object. + +### Options Management + +All API functions accept specific sets of options that, if provided, extend the global options. When such options are not provided, the default global option values are used. Additionally, the `updateOptions()` function allows you to extend global options in advance, eliminating the need to provide options to each function individually. However, `singleExport()`, `batchExport()`, and `startExport()` must still be provided with export-related options. + +This flexibility is particularly useful for deciding whether global options should remain static during subsequent exports or be updated dynamically. In either case, new options from various sources are merged into the configuration, and the resulting options are applied in API functions. + +### Export Functions + +The `singleExport()`, `batchExport()`, and `startExport()` functions must be provided with at least partial options that include one of the following options from the `export` section: `infile`, `instr`, `svg`, or `batch`. Any missing values in the provided options object will automatically default to those specified in the global options object. Unlike other API functions, options provided to the export functions will not be merged into the global options object, as these options represent a specific export process. To make the export options global, you can use the `updateOptions()` function before initiating the export. + +### Options Setting + +Essentially, all options can be configured through `.env`, the CLI, and prompts, with one exception: the `HIGHCHARTS_ADMIN_TOKEN`, which is only available as an environment variable. ## Default JSON Config -The JSON below represents the default configuration stored in the `lib/schemas/config.js` file. If no `.env` file is found (more details on the file and environment variables below), these options will be used. +The JSON below represents the default configuration stored in the `./lib/schemas/config.js` file. If no `.env` file is found (more details on the file and environment variables below), these options will be used. The configuration is not recommended to be modified directly, as it can typically be managed through other sources. -The format, along with its default values, is as follows (using the recommended ordering of core and module scripts below): +The format, along with its default values, is as follows (using the recommended ordering of core and module scripts below). + +_Available default JSON config:_ ``` { "puppeteer": { - "args": [] + "args": [/* See the `./lib/schemas/config.js` file */] }, "highcharts": { "version": "latest", - "cdnURL": "https://code.highcharts.com/", + "cdnUrl": "https://code.highcharts.com", + "forceFetch": false, + "cachePath": ".cache" "coreScripts": [ "highcharts", "highcharts-more", @@ -182,42 +212,44 @@ The format, along with its default values, is as follows (using the recommended "customScripts": [ "https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.30.1/moment.min.js", "https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.45/moment-timezone-with-data.min.js" - ], - "forceFetch": false, - "cachePath": ".cache" + ] }, "export": { - "infile": false, - "instr": false, - "options": false, - "outfile": false, + "infile": null, + "instr": null, + "options": null, + "svg": null, + "batch": null, + "outfile": null, "type": "png", "constr": "chart", - "height": 400, - "width": 600, - "scale": 1, - "globalOptions": false, - "themeOptions": false, - "batch": false, + "b64": false, + "noDownload": false, + "height": null, + "width": null, + "scale": null, + "globalOptions": null, + "themeOptions": null, "rasterizationTimeout": 1500 }, "customLogic": { "allowCodeExecution": false, "allowFileResources": false, - "customCode": false, - "callback": false, - "resources": false, - "loadConfig": false, - "createConfig": false + "customCode": null, + "callback": null, + "resources": null, + "loadConfig": null, + "createConfig": null }, "server": { "enable": false, "host": "0.0.0.0", "port": 7801, + "uploadLimit": 3, "benchmarking": false, "proxy": { - "host": "", - "port": 8080, + "host": null, + "port": null, "timeout": 5000 }, "rateLimiting": { @@ -226,14 +258,14 @@ The format, along with its default values, is as follows (using the recommended "window": 1, "delay": 0, "trustProxy": false, - "skipKey": "", - "skipToken": "" + "skipKey": null, + "skipToken": null }, "ssl": { "enable": false, "force": false, "port": 443, - "certPath": "" + "certPath": null } }, "pool": { @@ -251,7 +283,7 @@ The format, along with its default values, is as follows (using the recommended "logging": { "level": 4, "file": "highcharts-export-server.log", - "dest": "log/", + "dest": "log", "toConsole": true, "toFile": true }, @@ -268,7 +300,7 @@ The format, along with its default values, is as follows (using the recommended }, "debug": { "enable": false, - "headless": true, + "headless": false, "devtools": false, "listenToConsole": false, "dumpio": false, @@ -278,51 +310,73 @@ The format, along with its default values, is as follows (using the recommended } ``` -## Custom JSON Config +## Environment Variables -To load an additional JSON configuration file, use the `--loadConfig ` option. This JSON file can either be manually created or generated through a prompt triggered by the `--createConfig` option. +These variables are set in your environment and take precedence over options from the `./lib/schemas/config.js` file. They can be set in the `.env` file (refer to the `.env.sample` file). If you prefer setting these variables through the `package.json`, use `export` command on Linux/Mac OS X and `set` command on Windows. -## Environment Variables +_Available environment variables:_ + +### Puppeteer Config -These variables are set in your environment and take precedence over options from the `lib/schemas/config.js` file. They can be set in the `.env` file (refer to the `.env.sample` file). If you prefer setting these variables through the `package.json`, use `export` command on Linux/Mac OS X and `set` command on Windows. +- `PUPPETEER_ARGS`: A stringified version of additional Puppeteer arguments sent during browser initialization. The string can be enclosed in _[_ and _]_, and the arguments must be separated by the _;_ character (defaults to ``). ### Highcharts Config - `HIGHCHARTS_VERSION`: Highcharts version to use (defaults to `latest`). -- `HIGHCHARTS_CDN_URL`: Highcharts CDN URL of scripts to be used (defaults to `https://code.highcharts.com/`). +- `HIGHCHARTS_CDN_URL`: Highcharts CDN URL of scripts to be used (defaults to `https://code.highcharts.com`). +- `HIGHCHARTS_FORCE_FETCH`: The flag that determines whether to refetch all scripts after each server rerun (defaults to `false`). +- `HIGHCHARTS_CACHE_PATH`: A directory path where the fetched Highcharts scripts should be placed (defaults to `.cache`). Since v4.0.3 can be either absolute or relative path. +- `HIGHCHARTS_ADMIN_TOKEN`: An authentication token that is required to switch the Highcharts version on the server at runtime (defaults to ``). - `HIGHCHARTS_CORE_SCRIPTS`: Highcharts core scripts to fetch (defaults to ``). - `HIGHCHARTS_MODULE_SCRIPTS`: Highcharts module scripts to fetch (defaults to ``). - `HIGHCHARTS_INDICATOR_SCRIPTS`: Highcharts indicator scripts to fetch (defaults to ``). -- `HIGHCHARTS_FORCE_FETCH`: The flag that determines whether to refetch all scripts after each server rerun (defaults to `false`). -- `HIGHCHARTS_CACHE_PATH`: In which directory should the fetched Highcharts scripts be placed (defaults to `.cache`). -- `HIGHCHARTS_ADMIN_TOKEN`: An authentication token that is required to switch the Highcharts version on the server at runtime (defaults to ``). +- `HIGHCHARTS_CUSTOM_SCRIPTS`: Additional custom scripts or dependencies to fetch (defaults to ``). ### Export Config -- `EXPORT_TYPE`: The format of the file to export to. Can be **jpeg**, **png**, **pdf** or **svg** (defaults to `png`). -- `EXPORT_CONSTR`: The constructor to use. Can be **chart**, **stockChart**, **mapChart** or **ganttChart** (defaults to `chart`). -- `EXPORT_DEFAULT_HEIGHT`: The default height of the exported chart. Used when not found any value set (defaults to `400`). -- `EXPORT_DEFAULT_WIDTH`: The default width of the exported chart. Used when not found any value set (defaults to `600`). -- `EXPORT_DEFAULT_SCALE`: The default scale of the exported chart. Ranges between **0.1** and **5.0** (defaults to `1`). +- `EXPORT_INFILE`: The input file should include a name and a type (**.json** or **.svg**) and must contain a correctly formatted JSON or SVG (defaults to ``). +- `EXPORT_INSTR`: An input in a form of a stringified JSON. +- `EXPORT_OPTIONS`: An alias for the `instr` option (defaults to ``). +- `EXPORT_SVG`: A string containing SVG representation to render as a chart (defaults to ``). +- `EXPORT_BATCH`: Initiates a batch job with a string containing input/output pairs: **"in=out;in=out;.."** (defaults to ``). +- `EXPORT_OUTFILE`: The output filename, accompanied by a type (**jpeg**, **png**, **pdf**, or **svg**). Ignores the `type` option (defaults to ``). +- `EXPORT_TYPE`: The format of the file to export to. Can be **jpeg**, **png**, **pdf**, or **svg** (defaults to `png`). +- `EXPORT_CONSTR`: The constructor to use. Can be **chart**, **stockChart**, **mapChart**, or **ganttChart** (defaults to `chart`). +- `EXPORT_B64`: Boolean flag, set to **true** to receive the chart in the _base64_ format instead of the _binary_ (defaults to `false`). +- `EXPORT_NO_DOWNLOAD`: Boolean flag, set to **true** to exclude attachment headers from the response (defaults to `false`). +- `EXPORT_HEIGHT`: The height of the exported chart. Overrides the option in the chart settings (defaults to ``). +- `EXPORT_WIDTH`: The width of the exported chart. Overrides the option in the chart settings (defaults to ``). +- `EXPORT_SCALE`: The scale of the exported chart. Ranges between **0.1** and **5.0** (defaults to ``). +- `EXPORT_DEFAULT_HEIGHT`: The default fallback height for exported charts if not set explicitly (defaults to `400`). +- `EXPORT_DEFAULT_WIDTH`: The default fallback width for exported charts if not set explicitly (defaults to `600`). +- `EXPORT_DEFAULT_SCALE`: The default fallback scale for exported charts if not set explicitly. Ranges between **0.1** and **5.0** (defaults to `1`). +- `EXPORT_GLOBAL_OPTIONS`: Either a stringified JSON or a filename containing global options to be passed into the `Highcharts.setOptions()` (defaults to ``). +- `EXPORT_THEME_OPTIONS`: Either a stringified JSON or a filename containing theme options to be passed into the `Highcharts.setOptions()` (defaults to ``). - `EXPORT_RASTERIZATION_TIMEOUT`: The specified duration, in milliseconds, to wait for rendering a webpage (defaults to `1500`). ### Custom Logic Config - `CUSTOM_LOGIC_ALLOW_CODE_EXECUTION`: Controls whether the execution of arbitrary code is allowed during the exporting process (defaults to `false`). - `CUSTOM_LOGIC_ALLOW_FILE_RESOURCES`: Controls the ability to inject resources from the filesystem. This setting has no effect when running as a server (defaults to `false`). +- `CUSTOM_LOGIC_CUSTOM_CODE`: Custom code to execute before chart initialization. It can be a function, code wrapped within a function, or a filename with the _.js_ extension (defaults to ``). +- `CUSTOM_LOGIC_CALLBACK`: JavaScript code to run during construction. It can be a stringified function or a filename with the _.js_ extension that contains a correct Highcharts callback (defaults to ``). +- `CUSTOM_LOGIC_RESOURCES`: Additional resources in the form of a stringified JSON. It may contain `files` (array of JS filenames), `js` (stringified JS), and `css` (stringified CSS) sections (defaults to ``). +- `CUSTOM_LOGIC_LOAD_CONFIG`: A file containing a pre-defined configuration to use (defaults to ``). +- `CUSTOM_LOGIC_CREATE_CONFIG`: Enables setting options through a prompt and saving them in a provided config file (defaults to ``). ### Server Config - `SERVER_ENABLE`: If set to **true**, the server starts on 0.0.0.0 (defaults to `false`). - `SERVER_HOST`: The hostname of the server. Additionally, it starts a server listening on the provided hostname (defaults to `0.0.0.0`). - `SERVER_PORT`: The port to be used for the server when enabled (defaults to `7801`). +- `SERVER_UPLOAD_LIMIT`: The maximum request body size in MB (defaults to `3`). - `SERVER_BENCHMARKING`: Indicates whether to display a message with the duration, in milliseconds, of specific actions that occur on the server while serving a request (defaults to `false`). ### Server Proxy Config - `SERVER_PROXY_HOST`: The host of the proxy server to use, if it exists (defaults to ``). - `SERVER_PROXY_PORT`: The port of the proxy server to use, if it exists (defaults to ``). -- `SERVER_PROXY_TIMEOUT`: The timeout for the proxy server to use, if it exists (defaults to ``). +- `SERVER_PROXY_TIMEOUT`: The timeout, in milliseconds, for the proxy server to use, if it exists (defaults to `5000`). ### Server Rate Limiting Config @@ -356,15 +410,15 @@ These variables are set in your environment and take precedence over options fro ### Logging Config -- `LOGGING_LEVEL`: The logging level to be used. Can be **0** - silent, **1** - error, **2** - warning, **3** - notice, **4** - verbose or **5** benchmark (defaults to `4`). +- `LOGGING_LEVEL`: The logging level to be used. Can be **0** - silent, **1** - error, **2** - warning, **3** - notice, **4** - verbose or **5** - benchmark (defaults to `4`). - `LOGGING_FILE`: The name of a log file. The `logToFile` and `logDest` options also need to be set to enable file logging (defaults to `highcharts-export-server.log`). -- `LOGGING_DEST`: The path to store log files. The `logToFile` option also needs to be set to enable file logging (defaults to `log/`). +- `LOGGING_DEST`: The path to store log files. The `logToFile` option also needs to be set to enable file logging (defaults to `log`). - `LOGGING_TO_CONSOLE`: Enables or disables showing logs in the console (defaults to `true`). - `LOGGING_TO_FILE`: Enables or disables creation of the log directory and saving the log into a .log file (defaults to `true`). ### UI Config -- `UI_ENABLE`: Enables or disables the user interface (UI) for the Export Server (defaults to `true`). +- `UI_ENABLE`: Enables or disables the user interface (UI) for the Export Server (defaults to `false`). - `UI_ROUTE`: The endpoint route to which the user interface (UI) should be attached (defaults to `/`). ### Other Config @@ -378,58 +432,106 @@ These variables are set in your environment and take precedence over options fro ### Debugging Config - `DEBUG_ENABLE`: Enables or disables debug mode for the underlying browser (defaults to `false`). -- `DEBUG_HEADLESS`: Controls the mode in which the browser is launched when in the debug mode (defaults to `true`). +- `DEBUG_HEADLESS`: Controls the mode in which the browser is launched when in the debug mode (defaults to `false`). - `DEBUG_DEVTOOLS`: Decides whether to enable DevTools when the browser is in a headful state (defaults to `false`). - `DEBUG_LISTEN_TO_CONSOLE`: Decides whether to enable a listener for console messages sent from the browser (defaults to `false`). - `DEBUG_DUMPIO`: Redirects browser process stdout and stderr to process.stdout and process.stderr (defaults to `false`). - `DEBUG_SLOW_MO`: Slows down Puppeteer operations by the specified number of milliseconds (defaults to `0`). - `DEBUG_DEBUGGING_PORT`: Specifies the debugging port (defaults to `9222`). +## Custom JSON Config + +To load an additional JSON configuration file, use the `--loadConfig ` option. This JSON file can either be manually created or generated through a prompt triggered by the `--createConfig ` option. The `` value does not need a _.json_ extension, but the file's content must be valid JSON when using the `--loadConfig` option. + ## Command Line Arguments To supply command line arguments, add them as flags when running the application: `highcharts-export-server --flag1 value --flag2 value ...` -_Available options:_ +For readability reasons, many options passed as CLI arguments have slightly different names, which combine the option and the section it comes from. For example, to set `server.enable`, use `--enableServer`. This way, it is clear which option is being set. The full listing of CLI options can be seen by typing the `highcharts-export-server --help` command in the console. + +_Available CLI arguments:_ + +### Puppeteer Config + +- `--puppeteerArgs`: A stringified version of additional Puppeteer arguments sent during browser initialization. The string can be enclosed in _[_ and _]_, and the arguments must be separated by the _;_ character (defaults to [Default JSON Config](#default-json-config)). -- `--infile`: The input file should include a name and a type (**.json** or **.svg**) and must be a correctly formatted JSON or SVG file (defaults to `false`). -- `--instr`: An input in a form of a stringified JSON or SVG file. Overrides the `--infile` option (defaults to `false`). -- `--options`: An alias for the `--instr` option (defaults to `false`). -- `--outfile`: The output filename, accompanied by a type (**jpeg**, **png**, **pdf**, or **svg**). Ignores the `--type` flag (defaults to `false`). +### Highcharts Config + +- `--version`: Highcharts version to use (defaults to `latest`). +- `--cdnUrl`: Highcharts CDN URL of scripts to be used (defaults to `https://code.highcharts.com`). +- `--forceFetch`: The flag that determines whether to refetch all scripts after each server rerun (defaults to `false`). +- `--cachePath`: A directory path where the fetched Highcharts scripts should be placed (defaults to `.cache`). Since v4.0.3 can be either absolute or relative path. +- `--coreScripts`: Highcharts core scripts to fetch (defaults to [Default JSON Config](#default-json-config)). +- `--moduleScripts`: Highcharts module scripts to fetch (defaults to [Default JSON Config](#default-json-config)). +- `--indicatorScripts`: Highcharts indicator scripts to fetch (defaults to [Default JSON Config](#default-json-config)). +- `--customScripts`: Additional custom scripts or dependencies to fetch (defaults to [Default JSON Config](#default-json-config)). + +### Export Config + +- `--infile`: The input file should include a name and a type (**.json** or **.svg**) and must contain a correctly formatted JSON or SVG (defaults to `null`). +- `--instr`: An input in a form of a stringified JSON. +- `--options`: An alias for the `instr` option (defaults to `null`). +- `--svg`: A string containing SVG representation to render as a chart (defaults to `null`). +- `--batch`: Initiates a batch job with a string containing input/output pairs: **"in=out;in=out;.."** (defaults to `null`). +- `--outfile`: The output filename, accompanied by a type (**jpeg**, **png**, **pdf**, or **svg**). Ignores the `type` option (defaults to `null`). - `--type`: The format of the file to export to. Can be **jpeg**, **png**, **pdf**, or **svg** (defaults to `png`). -- `--constr`: The constructor to use. Can be **chart**, **stockChart**, **mapChart** or **ganttChart** (defaults to `chart`). -- `--height`: The height of the exported chart. Overrides the option in the chart settings (defaults to `400`). -- `--width`: The width of the exported chart. Overrides the option in the chart settings (defaults to `600`). -- `--scale`: The scale of the exported chart. Ranges between **0.1** and **5.0** (defaults to `1`). -- `--globalOptions`: Either a stringified JSON or a filename containing global options to be passed into the `Highcharts.setOptions` (defaults to `false`). -- `--themeOptions`: Either a stringified JSON or a filename containing theme options to be passed into the `Highcharts.setOptions` (defaults to `false`). -- `--batch`: Initiates a batch job with a string containing input/output pairs: **"in=out;in=out;.."** (defaults to `false`). +- `--constr`: The constructor to use. Can be **chart**, **stockChart**, **mapChart**, or **ganttChart** (defaults to `chart`). +- `--b64`: Boolean flag, set to **true** to receive the chart in the _base64_ format instead of the _binary_ (defaults to `false`). +- `--noDownload`: Boolean flag, set to **true** to exclude attachment headers from the response (defaults to `false`). +- `--height`: The height of the exported chart. Overrides the option in the chart settings (defaults to `null`). +- `--width`: The width of the exported chart. Overrides the option in the chart settings (defaults to `null`). +- `--scale`: The scale of the exported chart. Ranges between **0.1** and **5.0** (defaults to `null`). +- `--defaultHeight`: The default fallback height for exported charts if not set explicitly (defaults to `400`). +- `--defaultWidth`: The default fallback width for exported charts if not set explicitly (defaults to `600`). +- `--defaultScale`: The default fallback scale for exported charts if not set explicitly. Ranges between **0.1** and **5.0** (defaults to `1`). +- `--globalOptions`: Either a stringified JSON or a filename containing global options to be passed into the `Highcharts.setOptions()` (defaults to `null`). +- `--themeOptions`: Either a stringified JSON or a filename containing theme options to be passed into the `Highcharts.setOptions()` (defaults to `null`). - `--rasterizationTimeout`: The specified duration, in milliseconds, to wait for rendering a webpage (defaults to `1500`). + +### Custom Logic Config + - `--allowCodeExecution`: Controls whether the execution of arbitrary code is allowed during the exporting process (defaults to `false`). - `--allowFileResources`: Controls the ability to inject resources from the filesystem. This setting has no effect when running as a server (defaults to `false`). -- `--customCode`: Custom code to execute before chart initialization. It can be a function, code wrapped within a function, or a filename with the _.js_ extension (defaults to `false`). -- `--callback`: JavaScript code to run during construction. It can be a function or a filename with the _.js_ extension (defaults to `false`). -- `--resources`: Additional resources in the form of a stringified JSON. It may contain `files` (array of JS filenames), `js` (stringified JS), and `css` (stringified CSS) sections (defaults to `false`). -- `--loadConfig`: A file containing a pre-defined configuration to use (defaults to `false`). -- `--createConfig`: Enables setting options through a prompt and saving them in a provided config file (defaults to `false`). +- `--customCode`: Custom code to execute before chart initialization. It can be a function, code wrapped within a function, or a filename with the _.js_ extension (defaults to `null`). +- `--callback`: JavaScript code to run during construction. It can be a stringified function or a filename with the _.js_ extension that contains a correct Highcharts callback (defaults to `null`). +- `--resources`: Additional resources in the form of a stringified JSON. It may contain `files` (array of JS filenames), `js` (stringified JS), and `css` (stringified CSS) sections (defaults to `null`). +- `--loadConfig`: A file containing a pre-defined configuration to use (defaults to `null`). +- `--createConfig`: Enables setting options through a prompt and saving them in a provided config file (defaults to `null`). + +### Server Config + - `--enableServer`: If set to **true**, the server starts on 0.0.0.0 (defaults to `false`). - `--host`: The hostname of the server. Additionally, it starts a server listening on the provided hostname (defaults to `0.0.0.0`). - `--port`: The port to be used for the server when enabled (defaults to `7801`). -- `--serverBenchmarking`: Indicates whether to display the duration, in milliseconds, of specific actions that occur on the server while serving a request (defaults to `false`). -- `--proxyHost`: The host of the proxy server to use, if it exists (defaults to `false`). -- `--proxyPort`: The port of the proxy server to use, if it exists (defaults to `false`). -- `--proxyTimeout`: The timeout for the proxy server to use, if it exists (defaults to `5000`). +- `--uploadLimit`: The maximum request body size in MB (defaults to `3`). +- `--serverBenchmarking`: Indicates whether to display a message with the duration, in milliseconds, of specific actions that occur on the server while serving a request (defaults to `false`). + +### Server Proxy Config + +- `--proxyHost`: The host of the proxy server to use, if it exists (defaults to `null`). +- `--proxyPort`: The port of the proxy server to use, if it exists (defaults to `null`). +- `--proxyTimeout`: The timeout, in milliseconds, for the proxy server to use, if it exists (defaults to `5000`). + +### Server Rate Limiting Config + - `--enableRateLimiting`: Enables rate limiting for the server (defaults to `false`). - `--maxRequests`: The maximum number of requests allowed in one minute (defaults to `10`). - `--window`: The time window, in minutes, for the rate limiting (defaults to `1`). - `--delay`: The delay duration for each successive request before reaching the maximum limit (defaults to `0`). - `--trustProxy`: Set this to **true** if the server is behind a load balancer (defaults to `false`). -- `--skipKey`: Allows bypassing the rate limiter and should be provided with the `--skipToken` argument (defaults to ``). -- `--skipToken`: Allows bypassing the rate limiter and should be provided with the `--skipKey` argument (defaults to ``). +- `--skipKey`: Allows bypassing the rate limiter and should be provided with the `skipToken` argument (defaults to `null`). +- `--skipToken`: Allows bypassing the rate limiter and should be provided with the `skipKey` argument (defaults to `null`). + +### Server SSL Config + - `--enableSsl`: Enables or disables the SSL protocol (defaults to `false`). - `--sslForce`: If set to **true**, the server is forced to serve only over HTTPS (defaults to `false`). - `--sslPort`: The port on which to run the SSL server (defaults to `443`). -- `--certPath`: The path to the SSL certificate/key file (defaults to ``). +- `--sslCertPath`: The path to the SSL certificate/key file (defaults to `null`). + +### Pool Config + - `--minWorkers`: The number of minimum and initial pool workers to spawn (defaults to `4`). - `--maxWorkers`: The number of maximum pool workers to spawn (defaults to `8`). - `--workLimit`: The number of work pieces that can be performed before restarting the worker process (defaults to `40`). @@ -439,21 +541,33 @@ _Available options:_ - `--idleTimeout`: The duration, in milliseconds, after which an idle resource is destroyed (defaults to `30000`). - `--createRetryInterval`: The duration, in milliseconds, to wait before retrying the create process in case of a failure (defaults to `200`). - `--reaperInterval`: The duration, in milliseconds, after which the check for idle resources to destroy is triggered (defaults to `1000`). -- `--poolBenchmarking`: Indicate whether to show statistics for the pool of resources or not (defaults to `false`). +- `--poolBenchmarking`: Indicates whether to show statistics for the pool of resources or not (defaults to `false`). + +### Logging Config + - `--logLevel`: The logging level to be used. Can be **0** - silent, **1** - error, **2** - warning, **3** - notice, **4** - verbose or **5** - benchmark (defaults to `4`). - `--logFile`: The name of a log file. The `logToFile` and `logDest` options also need to be set to enable file logging (defaults to `highcharts-export-server.log`). -- `--logDest`: The path to store log files. The `logToFile` option also needs to be set to enable file logging (defaults to `log/`). +- `--logDest`: The path to store log files. The `logToFile` option also needs to be set to enable file logging (defaults to `log`). - `--logToConsole`: Enables or disables showing logs in the console (defaults to `true`). - `--logToFile`: Enables or disables creation of the log directory and saving the log into a .log file (defaults to `true`). + +### UI Config + - `--enableUi`: Enables or disables the user interface (UI) for the Export Server (defaults to `false`). - `--uiRoute`: The endpoint route to which the user interface (UI) should be attached (defaults to `/`). -- `--nodeEnv`: The type of Node.js environment (defaults to `production`). + +### Other Config + +- `--nodeEnv`: The type of Node.js environment. The value controls whether to include the error's stack in a response or not. Can be development or production (defaults to `production`). - `--listenToProcessExits`: Decides whether or not to attach _process.exit_ handlers (defaults to `true`). - `--noLogo`: Skip printing the logo on a startup. Will be replaced by a simple text (defaults to `false`). - `--hardResetPage`: Determines whether the page's content should be reset from scratch, including Highcharts scripts (defaults to `false`). - `--browserShellMode`: Decides whether to enable older but much more performant _shell_ mode for the browser (defaults to `true`). + +### Debugging Config + - `--enableDebug`: Enables or disables debug mode for the underlying browser (defaults to `false`). -- `--headless`: Controls the mode in which the browser is launched when in the debug mode (defaults to `true`). +- `--headless`: Controls the mode in which the browser is launched when in the debug mode (defaults to `false`). - `--devtools`: Decides whether to enable DevTools when the browser is in a headful state (defaults to `false`). - `--listenToConsole`: Decides whether to enable a listener for console messages sent from the browser (defaults to `false`). - `--dumpio`: Redirects browser process stdout and stderr to process.stdout and process.stderr (defaults to `false`). @@ -464,14 +578,14 @@ _Available options:_ Apart from using as a CLI tool, which allows you to run one command at a time, it is also possible to configure the server to accept POST requests. The simplest way to enable the server is to run the command below: -`highcharts-export-server --enableServer 1` +`highcharts-export-server --enableServer true` ## Server Test To test if the server is running correctly, you can send a simple POST request, e.g. by using Curl: ``` -curl -H "Content-Type: application/json" -X POST -d '{"infile":{"title": {"text": "Chart"}, "xAxis": {"categories": ["Jan", "Feb", "Mar"]}, "series": [{"data": [29.9, 71.5, 106.4]}]}}' 127.0.0.1:7801 -o chart.png +curl -H "Content-Type: application/json" -X POST -d '{"options":{"title": {"text": "Chart"}, "xAxis": {"categories": ["Jan", "Feb", "Mar"]}, "series": [{"data": [29.9, 71.5, 106.4]}]}}' 127.0.0.1:7801 -o chart.png ``` The above should result in a chart being generated and saved in a file named `chart.png`. @@ -485,19 +599,21 @@ To enable SSL support, add `--certPath ` when running the serve ## HTTP Server POST Arguments -The server accepts the following arguments in a POST request body: +The server accepts the following arguments in a POST request body. + +_Available request arguments:_ -- `infile`: Chart options in the form of JSON or stringified JSON. -- `options`: An alias for the `infile` option. -- `data`: Another alias for the `infile` option. +- `instr`: Chart options in the form of JSON or stringified JSON. +- `options`: An alias for the `instr` option. - `svg`: A string containing SVG representation to render as a chart. +- `outfile`: The output filename, accompanied by a type (**jpeg**, **png**, **pdf**, or **svg**). Ignores the `type` option. - `type`: The format of an exported chart (can be **png**, **jpeg**, **pdf** or **svg**). Mimetypes can also be used. - `constr`: The constructor to use (can be **chart**, **stockChart**, **mapChart** or **ganttChart**). - `height`: The height of the exported chart. - `width`: The width of the exported chart. - `scale`: The scale factor of the exported chart. Use it to improve resolution in PNG and JPEG, for example setting scale to 2 on a 600px chart will result in a 1200px output. -- `globalOptions`: Either a JSON or a stringified JSON with global options to be passed into `Highcharts.setOptions`. -- `themeOptions`: Either a JSON or a stringified JSON with theme options to be passed into `Highcharts.setOptions`. +- `globalOptions`: Either a JSON or a stringified JSON with global options to be passed into `Highcharts.setOptions()`. +- `themeOptions`: Either a JSON or a stringified JSON with theme options to be passed into `Highcharts.setOptions()`. - `resources`: Additional resources in the form of a JSON or a stringified JSON. It may contain `files` (array of JS filenames), `js` (stringified JS), and `css` (stringified CSS) sections. - `callback`: Stringified JavaScript function to execute in the Highcharts constructor. - `customCode`: Custom code to be executed before the chart initialization. This can be a function, code wrapped within a function, or a filename with the _.js_ extension. Both `allowFileResources` and `allowCodeExecution` must be set to **true** for the option to be considered. @@ -516,15 +632,16 @@ It is recommended to run the server using [pm2](https://www.npmjs.com/package/pm - `/`: An endpoint for exporting charts. - `/:filename` - An endpoint for exporting charts with a specified filename parameter to save the chart to. The file will be downloaded with the _{filename}.{type}_ name (the `noDownload` must be set to **false**). - - `/change_hc_version/:newVersion`: An authenticated endpoint allowing the modification of the Highcharts version on the server through the use of a token. + - `/version_change/:newVersion`: An authenticated endpoint allowing the modification of the Highcharts version on the server through the use of a token. - GET + - `/`: An endpoint to perform exports through the user interface the server allows it. - `/health`: An endpoint for outputting basic statistics for the server. -## Switching Highcharts Version at Runtime +## Switching Highcharts Version At Runtime -If the `HIGHCHARTS_ADMIN_TOKEN` is set, you can use the `POST /change_hc_version/:newVersion` route to switch the Highcharts version on the server at runtime, ie. without restarting or redeploying the application. +If the `HIGHCHARTS_ADMIN_TOKEN` is set, you can use the `POST /version_change/:newVersion` route to switch the Highcharts version on the server at runtime, ie. without restarting or redeploying the application. A sample request to change the version to 10.3.3 is as follows: @@ -548,8 +665,8 @@ Finally, the Export Server can also be used as a Node.js module to simplify inte // Import the Highcharts Export Server module const exporter = require('highcharts-export-server'); -// Export options correspond to the available CLI/HTTP arguments described above -const options = { +// Options correspond to the available CLI/HTTP arguments described above +const customOptions = { export: { type: 'png', options: { @@ -573,23 +690,25 @@ const options = { } }; -// Initialize export settings with your chart's config -const exportSettings = exporter.setOptions(options); +// Logic must be triggered in an asynchronous function +(async () => { + // Must initialize exporting before being able to export charts + await exporter.initExport(); -// Must initialize exporting before being able to export charts -await exporter.initExport(exportSettings); + // Perform an export + await exporter.startExport(options, async (error, data) => { + // The export result is now in the `data` (it will be Base64 encoded) + console.log(data.result); -// Perform an export -await exporter.startExport(exportSettings, async (error, info) => { - // The export result is now in info - // It will be base64 encoded (info.data) - - // Kill the pool when we are done with it - await exporter.killPool(); -}); + // Kill the pool when we are done with it + await exporter.killPool(); + }); +})(); ``` -## CommonJS support +In order for exporting to work as intended, the `initExport()` function must be called before running any export-related functions (`singleExport()`, `batchExport()`, or `startExport()`). This initializes all required mechanisms, such as script fetching, cache setting, resource pooling, and browser startup. + +## CommonJS Support This package supports both CommonJS and ES modules. @@ -597,106 +716,134 @@ This package supports both CommonJS and ES modules. **highcharts-export-server module** -- `server`: The server instance which offers the following functions: +- `async function startServer(serverOptions)`: Starts an HTTP and/or HTTPS server based on the provided configuration. The `serverOptions` object contains server-related properties (refer to the `server` section in the `./lib/schemas/config.js` file for details). + + - `@param {Object} serverOptions` - The configuration object containing `server` options. This object may include a partial or complete set of the `server` options. If the options are partial, missing values will default to the current global configuration. + + - `@returns {Promise}` A Promise that resolves when the server is either not enabled or no valid Express app is found, signaling the end of the function's execution. + + - `@throws {ExportError}` Throws an `ExportError` if the server cannot be configured and started. + +- `function closeServers()`: Closes all servers associated with Express app instance. + +- `function getServers()`: Get all servers associated with Express app instance. + + - `@returns {Array}` Servers associated with Express app instance. + +- `function getExpress()`: Get the Express instance. - - `async startServer(serverConfig)`: The same as `startServer` described below. + - `@returns {Express}` The Express instance. - - `{Object} serverConfig`: The server configuration object. +- `function getApp()`: Get the Express app instance. - - `closeServers()`: Closes all servers associated with Express app instance. + - `@returns {Express}` The Express app instance. - - `getServers()`: Get all servers associated with Express app instance. +- `function enableRateLimiting(rateLimitingOptions)`: Enable rate limiting for the server. - - `enableRateLimiting(limitConfig)`: Enable rate limiting for the server. + - `@param {Object} rateLimitingOptions` - The configuration object containing `rateLimiting` options. This object may include a partial or complete set of the `rateLimiting` options. If the options are partial, missing values will default to the current global configuration. - - `{Object} limitConfig`: Configuration object for rate limiting. +- `function use(path, ...middlewares)`: Apply middleware(s) to a specific path. - - `getExpress()`: Get the Express instance. + - `@param {string} path` - The path to which the middleware(s) should be applied. + - `@param {...Function} middlewares` - The middleware function(s) to be applied. - - `getApp()`: Get the Express app instance. +- `function get(path, ...middlewares)`: Set up a route with GET method and apply middleware(s). - - `use(path, ...middlewares)`: Apply middleware(s) to a specific path. + - `@param {string} path` - The path to which the middleware(s) should be applied. + - `@param {...Function} middlewares` - The middleware function(s) to be applied. - - `{string} path`: The path to which the middleware(s) should be applied. - - `{...Function} middlewares`: The middleware functions to be applied. +- `function post(path, ...middlewares)`: Set up a route with POST method and apply middleware(s). - - `get(path, ...middlewares)`: Set up a route with GET method and apply middleware(s). + - `@param {string} path` - The path to which the middleware(s) should be applied. + - `@param {...Function} middlewares` - The middleware function(s) to be applied. - - `{string} path`: The route path. - - `{...Function} middlewares`: The middleware functions to be applied. +- `function getOptions(getCopy = true)`: Retrieves a copy of the global options object or an original global options object, based on the `getCopy` flag. - - `post(path, ...middlewares)`: Set up a route with POST method and apply middleware(s). - - `{string} path`: The route path. - - `{...Function} middlewares`: The middleware functions to be applied. + - `@param {boolean} [getCopy=true]` - Specifies whether to return a copied object of the global options (`true`) or a reference to the global options object (`false`). The default value is `true`. -- `async startServer(serverConfig)`: Starts an HTTP server based on the provided configuration. The `serverConfig` object contains all server related properties (see the `server` section in the `lib/schemas/config.js` file for a reference). + - `@returns {Object}` A copy of the global options object, or a reference to the global options object. - - `{Object} serverConfig`: The server configuration object. +- `function updateOptions(newOptions, getCopy = false)`: Updates and returns the global options object or a copy of the global options object, based on the `getCopy` flag. -- `async initExport(options)`: Initializes the export process. Tasks such as configuring logging, checking cache and sources, and initializing the pool of resources happen during this stage. Function that is required to be called before trying to export charts or setting a server. The `options` is an object that contains all options. + - `@param {Object} newOptions` - An object containing the new options to be merged into the global options. + - `@param {boolean} [getCopy=false]` - Determines whether to merge the new options into a copy of the global options object (`true`) or directly into the global options object (`false`). The default value is `false`. - - `{Object} options`: All export options. + - `@returns {Object}` The updated options object, either the modified global options or a modified copy, based on the value of `getCopy`. -- `async singleExport(options)`: Starts a single export process based on the specified options. Runs the `startExport` underneath. +- `function mapToNewOptions(oldOptions)`: Maps old-structured configuration options (PhantomJS-based) to a new format (Puppeteer-based). This function converts flat, old-structured options into a new, nested configuration format based on a predefined mapping provided in the `nestedProps` object. The new format is used for Puppeteer, while the old format was used for PhantomJS. - - `{Object} options`: The options object containing configuration for a single export. + - `@param {Object} oldOptions` - The old, flat configuration options to be converted. -- `async batchExport(options)`: Starts a batch export process for multiple charts based on the information in the batch option. The batch is a string in the following format: `"infile1.json=outfile1.png;infile2.json=outfile2.png;..."`. Runs the `startExport` underneath. + - `@returns {Object}` A new object containing options structured according to the mapping defined in the `nestedProps` object or an empty object if the provided `oldOptions` is not a correct object. - - `{Object} options`: The options object containing configuration for a batch export. +- `async function initExport(initOptions = {})`: Initializes the export process. Tasks such as configuring logging, checking the cache and sources, and initializing the resource pool occur during this stage. -- `async startExport(settings, endCallback)`: Starts an export process. The `settings` contains final options gathered from all possible sources (config, env, cli, json). The `endCallback` is called when the export is completed, with an error object as the first argument and the second containing the base64 respresentation of a chart. + This function must be called before attempting to export charts or set up a server. - - `{Object} settings`: The settings object containing export configuration. - - `{function} endCallback`: The callback function to be invoked upon finalizing work or upon error occurance of the exporting process. + - `@param {Object} [initOptions={}]` - The `initOptions` object, which may be a partial or complete set of options. If the options are partial, missing values will default to the current global configuration. The default value is an empty object. -- `async initPool(config)`: Initializes the export pool with the provided configuration, creating a browser instance and setting up worker resources. +- `async function singleExport(options)`: Starts a single export process based on the specified options and saves the resulting image to the provided output file. - - `{Object} config`: Configuration options for the export pool along with custom puppeteer arguments for the puppeteer.launch function. + - `@param {Object} options` - The `options` object, which should include settings from the `export` and `customLogic` sections. It can be a partial or complete set of options from these sections. The object must contain at least one of the following `export` properties: `infile`, `instr`, `options`, or `svg` to generate a valid image. -- `async killPool()`: Kills all workers in the pool, destroys the pool, and closes the browser instance. + - `@returns {Promise}` A Promise that resolves once the single export process is completed. -- `setOptions(userOptions, args)`: Initializes and sets the general options for the server instace, keeping the principle of the options load priority. It accepts optional userOptions and args from the CLI. + - `@throws {ExportError}` Throws an `ExportError` if an error occurs during the single export process. - - `{Object} userOptions`: User-provided options for customization. - - `{Array} args`: Command-line arguments for additional configuration (CLI usage). +- `async function batchExport(options)`: Starts a batch export process for multiple charts based on information provided in the `batch` option. The `batch` is a string in the following format: "infile1.json=outfile1.png;infile2.json=outfile2.png;...". Results are saved to the specified output files. -- `async shutdownCleanUp(exitCode)`: Clean up function to trigger before ending process for the graceful shutdown. + - `@param {Object} options` - The `options` object, which should include settings from the `export` and `customLogic` sections. It can be a partial or complete set of options from these sections. It must contain the `batch` option from the `export` section to generate valid images. - - `{number} exitCode`: An exit code for the process.exit() function. + - `@returns {Promise}` A Promise that resolves once the batch export processes are completed. -- `log(...args)`: Logs a message. Accepts a variable amount of arguments. Arguments after `level` will be passed directly to console.log, and/or will be joined and appended to the log file. + - `@throws {ExportError}` Throws an `ExportError` if an error occurs during any of the batch export process. - - `{any} args`: An array of arguments where the first is the log level and the rest are strings to build a message with. +- `async function startExport(exportingOptions, endCallback)`: Starts an export process. The `exportingOptions` parameter is an object that should include settings from the `export` and `customLogic` sections. It can be a partial or complete set of options from these sections. If partial options are provided, missing values will be merged with the current global options. -- `logWithStack(newLevel, error, customMessage)`: Logs an error message with its stack trace. Optionally, a custom message can be provided. + The `endCallback` function is invoked upon the completion of the export, either successfully or with an error. The `error` object is provided as the first argument, and the `data` object is the second, containing the Base64 representation of the chart in the `result` property and the complete set of options in the `options` property. - - `{number} newLevel`: The log level. - - `{Error} error`: The error object. - - `{string} customMessage`: An optional custom message to be logged along with the error. + - `@param {Object} exportingOptions` - The `exportingOptions` object, which should include settings from the `export` and `customLogic` sections. It can be a partial or complete set of options from these sections. If the provided options are partial, missing values will be merged with the current global options. + - `@param {Function} endCallback` - The callback function to be invoked upon finalizing the export process or upon encountering an error. The first argument is the `error` object, and the second argument is the `data` object, which includes the Base64 representation of the chart in the `result` property and the full set of options in the `options` property. -- `setLogLevel(newLevel)`: Sets the log level to the specified value. Log levels are (0 = no logging, 1 = error, 2 = warning, 3 = notice, 4 = verbose or 5 = benchmark). + - `@returns {Promise}` This function does not return a value directly. Instead, it communicates results via the `endCallback`. - - `{number} newLevel`: The new log level to be set. + - `@throws {ExportError}` Throws an `ExportError` if there is a problem with processing input of any type. The error is passed into the `endCallback` function and processed there. -- `enableFileLogging(logDest, logFile)`: Enables file logging with the specified destination and log file. +- `async function killPool()`: Terminates all workers in the pool, destroys the pool, and closes the browser instance. - - `{string} logDest`: The destination path for log files. - - `{string} logFile`: The log file name. + - `@returns {Promise}` A Promise that resolves once all workers are terminated, the pool is destroyed, and the browser is successfully closed. -- `mapToNewConfig(oldOptions)`: Maps old-structured (PhantomJS) options to a new configuration format (Puppeteer). +- `async function shutdownCleanUp(exitCode = 0)`: Performs cleanup operations to ensure a graceful shutdown of the process. This includes clearing all registered timeouts/intervals, closing active servers, terminating resources (pages) of the pool, pool itself, and closing the browser. - - `{Object} oldOptions`: Old-structured options to be mapped. + - `@param {number} [exitCode=0]` - The exit code to use with `process.exit()`. The default value is `0`. -- `async manualConfig(configFileName)`: Allows manual configuration based on specified prompts and saves the configuration to a file. +- `function log(...args)`: Logs a message with a specified log level. Accepts a variable number of arguments. The arguments after the `level` are passed to `console.log` and/or used to construct and append messages to a log file. - - `{string} configFileName`: The name of the configuration file. + - `@param {...unknown} args` - An array of arguments where the first is the log level and the remaining are strings used to build the log message. -- `printLogo(noLogo)`: Prints the Highcharts Export Server logo and version information. + - `@returns {void}` Exits the function execution if attempting to log at a level higher than allowed. - - `{boolean} noLogo`: If **true**, only prints version information without the logo. +- `function logWithStack(newLevel, error, customMessage)`: Logs an error message along with its stack trace. Optionally, a custom message can be provided. -- `printUsage()`: Prints the usage information for CLI arguments. If required, it can list properties recursively. + - `@param {number} newLevel` - The log level. + - `@param {Error} error` - The error object containing the stack trace. + - `@param {string} customMessage` - An optional custom message to be included in the log alongside the error. + + - `@returns {void}` Exits the function execution if attempting to log at a level higher than allowed. + +- `function setLogLevel(level)`: Sets the log level to the specified value. Log levels are (`0` = no logging, `1` = error, `2` = warning, `3` = notice, `4` = verbose, or `5` = benchmark). + + - `@param {number} level` - The log level to be set. + +- `function enableConsoleLogging(toConsole)`: Enables console logging. + + - `@param {boolean} toConsole` - The flag for setting the logging to the console. + +- `function enableFileLogging(dest, file, toFile)`: Enables file logging with the specified destination and log file name. + + - `@param {string} dest` - The destination path where the log file should be saved. + - `@param {string} file` - The name of the log file. + - `@param {boolean} toFile` - A flag indicating whether logging should be directed to a file. # Examples @@ -704,7 +851,15 @@ Samples and tests for every mentioned export method can be found in the `./sampl # Tips, Tricks & Notes -## Note about Deprecated Options +## Note About Version And Help Information + +Typing `highcharts-export-server --v` will display information about the current version of the Export Server, and `highcharts-export-server --h` will display information about available CLI options. + +## Note About Paths + +All path-related options (such as those for loading additional resources and logic) can be either relative or absolute. If they are relative, they will be resolved based on the current working directory (the directory from which the Node.js process was started). This is especially important to remember when running a custom script in your application that imports the `highcharts-export-server` npm package and uses provided exporting API functions. + +## Note About Deprecated Options At some point during the transition process from the `PhantomJS` solution, certain options were deprecated. Here is a list of options that no longer work with the server based on `Puppeteer`: @@ -718,13 +873,13 @@ Additionally, some options are now named differently due to the new structure an - `fromFile` -> `loadConfig` - `sslOnly` -> `force` or `sslForce` -- `sslPath` -> `certPath` +- `sslPath` -> `sslCertPath` - `rateLimit` -> `maxRequests` - `workers` -> `maxWorkers` -If you depend on any of the above options, the optimal approach is to directly change the old names to the new ones in the options. However, you don't have to do it manually, as there is a utility function called `mapToNewConfig` that can easily transfer the old-structured options to the new format. For an example, refer to the `./samples/module/optionsPhantom.js` file. +If you depend on any of the above options, the optimal approach is to directly change the old names to the new ones in the options. However, you don't have to do it manually, as there is a utility function called `mapToNewOptions` that can easily transfer the old-structured options to the new format. For an example, refer to the `./samples/module/optionsPhantom.js` file. -## Note about Chart Size +## Note About Chart Size If you need to set the `height` or `width` of the chart, it can be done in two ways: @@ -742,28 +897,30 @@ The latter is preferred, as it allows you to set separate sizing when exporting Like previously mentioned, there are multiple ways to set and prioritize options, and the `height`, `width` and `scale` are no exceptions here. The priority goes like this: -1. Options from the `export` section of the provided options (CLI, JSON, etc.). +1. The `height`, `width`, and `scale` options from the `export` section of the provided options (CLI, JSON, envs). 2. The `sourceHeight`, `sourceWidth` and `scale` from the `chart.exporting` section of chart's Highcharts options. 3. The `height` and `width` from the `chart` section of chart's Highcharts options. 4. The `sourceHeight`, `sourceWidth` and `scale` from the `chart.exporting` section of chart's Highcharts global options, if provided. 5. The `height` and `width` from the `chart` section of chart's Highcharts global options, if provided. -6. If no options are found to this point, the default values will be used (`height = 400`, `width = 600` and `scale = 1`). +6. The `sourceHeight`, `sourceWidth` and `scale` from the `chart.exporting` section of chart's Highcharts theme options, if provided. +7. The `height` and `width` from the `chart` section of chart's Highcharts theme options, if provided. +8. If no options are found to this point, the default values will be used (`height = 400`, `width = 600` and `scale = 1`). -## Note about Event Listeners +## Note About Event Listeners The Export Server attaches event listeners to `process.exit`, `uncaughtException` and signals such as `SIGINT`, `SIGTERM` and `SIGHUP`. This is to make sure that there are no memory leaks or zombie processes if the application is unexpectedly terminated. Listeners are also attached to handle `uncaught exceptions`. If an exception occurs, the entire pool and browser instance are terminated, and the application is shut down. -If you do not want this behavior, start the server with `--listenToProcessExits 0` or `--listenToProcessExits false`. +If you do not want this behavior, start the server with `--listenToProcessExits false`. Be aware though, that if you disable this and you do not take great care to manually kill the pool of resources along with a browser instance, your server will bleed memory when the app is terminated. -## Note about Resources +## Note About Resources If `--resources` argument is not set and a file named `resources.json` exists in the folder from which the CLI tool was ran, it will use the `resources.json` file. -## Note about Worker Count & Work Limit +## Note About Worker Count & Work Limit The Export Server utilizes a pool of workers, where each worker is a Puppeteer process (browser instance's page) responsible for the actual chart rasterization. The pool size can be set with the `--minWorkers` and `--maxWorkers` options, and should be tweaked to fit the hardware on which you are running the server. @@ -773,13 +930,13 @@ Each of the workers has a maximum number of requests it can handle before it res # Usage -## Injecting the Highcharts Dependency +## Injecting The Highcharts Dependency In order to use the Export Server, Highcharts needs to be injected into the export template (see the `./templates` folder for reference). Since version 3.0.0, Highcharts is fetched in a Just-In-Time manner, making it easy to switch configurations. It is no longer required to explicitly accept the license, as in older versions. **However, the Export Server still requires a valid Highcharts license to be used**. -## Using in Automated Deployments +## Using In Automated Deployments Since version 3.0.0, when using in automated deployments, the configuration can be loaded either using environment variables or a JSON configuration file. @@ -809,7 +966,7 @@ On Windows: ## Library Fetches -When fetching the built Highcharts library, the default behaviour is to fetch them from `code.highcharts.com`. +When fetching the built Highcharts library, the default behaviour is to fetch them from `https://code.highcharts.com`. ## Installing Fonts @@ -841,7 +998,7 @@ Copy or move the TTF file to `C:\Windows\Fonts\`: copy yourFont.ttf C:\Windows\Fonts\yourFont.ttf ``` -### Google fonts +### Google Fonts If you need Google Fonts in your custom installation, they can be had here: https://github.com/google/fonts. @@ -869,7 +1026,7 @@ There are two main ways to debug code: - By adding a `debugger` statement within any client-side code (e.g., inside a `page.evaluate` callback). With the `--devtools` option set to **true**, the code execution will stop automatically. -- By running the export server with the `--inspect-brk=` flag, and adding a `debugger` statement within any server-side code. Subsequently, navigate to `chrome://inspect/`, input the server's IP address and port (e.g., `localhost:9229`) in the Configure section. Clicking 'inspect' initiates debugging of the server-side code. +- By running the Export Server with the `--inspect-brk=` flag, and adding a `debugger` statement within any server-side code. Subsequently, navigate to `chrome://inspect/`, input the server's IP address and port (e.g., `localhost:9229`) in the Configure section. Clicking 'inspect' initiates debugging of the server-side code. The `npm run start:debug` script from the `package.json` allows debugging code using both methods simultaneously. In this setup, client-side code is accessible from the devTools of a specific Puppeteer browser's page, while server-side code can be debugged from the devTools of `chrome://inspect/`. diff --git a/bin/cli.js b/bin/cli.js index 916cfb0d..099be3fc 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -3,7 +3,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -13,99 +13,137 @@ See LICENSE file in root for details. *******************************************************************************/ -import main from '../lib/index.js'; +/** + * @overview This module serves as the entry point for the Highcharts Export + * Server. It provides functionality for starting the server, performing batch + * or single chart exports, managing manual configuration options through + * the prompts functionality, and updating options with values from the passed + * CLI arguments. It supports both CLI and server-based usage, enabling + * integration for generating Highcharts exports in various environments. + */ + +import { batchExport, singleExport } from '../lib/chart.js'; +import { setCliOptions } from '../lib/config.js'; +import { initExport } from '../lib/index.js'; +import { printInfo, printUsage } from '../lib/info.js'; +import { log, logWithStack } from '../lib/logger.js'; +import { manualConfig } from '../lib/prompt.js'; +import { shutdownCleanUp } from '../lib/resourceRelease.js'; + +import { startServer } from '../lib/server/server.js'; import ExportError from '../lib/errors/ExportError.js'; /** - * The primary function to initiate the server or perform the direct export. + * The primary function to initiate the server or perform the direct CLI export. + * Logs an error if it occurs during the execution and gracefully shuts down + * the process. + * + * @async + * @function start * - * @throws {ExportError} Throws an ExportError if no valid options are provided. - * @throws {Error} Throws an Error if an unexpected error occurs during - * execution. + * @returns {Promise} A Promise that resolves when the function execution + * ends after displaying the logo with license and version information, showing + * the CLI usage details, or triggering manual configuration using the prompts + * functionality. + * + * @throws {ExportError} Throws an `ExportError` if no valid options + * are provided. */ -const start = async () => { +async function start() { try { // Get the CLI arguments const args = process.argv; - // Print the usage information if no arguments supplied + // If no arguments are supplied if (args.length <= 2) { - main.log( + // Print logo with version and license information + printInfo(); + log( 2, - '[cli] The number of provided arguments is too small. Please refer to the section below.' + '[cli] The number of provided arguments is too small. Please refer to the help section (use --h or --help command).' ); - return main.printUsage(); + return; + } + + // Display version and license information if requested + if (['-v', '--v'].some((a) => args.includes(a))) { + // Print logo with version and license information + printInfo(); + return; + } + + // Display help information if requested + if (['-h', '--h', '-help', '--help'].some((a) => args.includes(a))) { + // Print CLI usage information + printUsage(); + return; } - // Set the options, keeping the priority order of setting values: - // 1. Options from the lib/schemas/config.js file - // 2. Options from a custom JSON file (loaded by the --loadConfig argument) - // 3. Options from the environment variables (the .env file) - // 4. Options from the CLI - const options = main.setOptions(null, args); + // Set the options from CLI, keeping the priority order of setting values + const options = setCliOptions(args); - // If all options correctly parsed + // If all options are correctly parsed if (options) { - // Print initial logo or text - main.printLogo(options.other.noLogo); + // Print initial logo or text with the version information + printInfo(options.other.noLogo, true); // In this case we want to prepare config manually if (options.customLogic.createConfig) { - return main.manualConfig(options.customLogic.createConfig); + manualConfig( + options.customLogic.createConfig, + options.customLogic.allowCodeExecution + ); + return; } // Start server if (options.server.enable) { // Init the export mechanism for the server configuration - await main.initExport(options); + await initExport(); // Run the server - await main.startServer(options.server); + await startServer(); } else { // Perform batch exports if (options.export.batch) { - // If not set explicitly, use default option for batch exports - if (!args.includes('--minWorkers', '--maxWorkers')) { - options.pool = { - ...options.pool, - minWorkers: 2, - maxWorkers: 25 - }; - } - - // Init a pool for the batch exports - await main.initExport(options); + // Init the export mechanism for batch exports + await initExport(); // Start batch exports - await main.batchExport(options); + await batchExport({ + export: options.export, + customLogic: options.customLogic + }); } else { // No need for multiple workers in case of a single CLI export - options.pool = { - ...options.pool, - minWorkers: 1, - maxWorkers: 1 - }; + options.pool.minWorkers = 1; + options.pool.maxWorkers = 1; - // Init a pool for one export - await main.initExport(options); + // Init the export mechanism for a single export + await initExport(); // Start a single export - await main.singleExport(options); + await singleExport({ + export: options.export, + customLogic: options.customLogic + }); } } } else { throw new ExportError( - '[cli] No valid options provided. Please check your input and try again.' + '[cli] No valid options found. Please check your input and try again.', + 400 ); } } catch (error) { // Log the error with stack - main.logWithStack(1, error); + logWithStack(1, error); // Gracefully shut down the process - await main.shutdownCleanUp(1); + await shutdownCleanUp(1); } -}; +} +// Start the Export Server process start(); diff --git a/lib/browser.js b/lib/browser.js index 866be4f9..1c0dd88d 100644 --- a/lib/browser.js +++ b/lib/browser.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,8 +12,16 @@ See LICENSE file in root for details. *******************************************************************************/ +/** + * @overview This module provides functions for managing Puppeteer browser + * instance, creating and clearing pages, injecting custom JS and CSS resources, + * and setting up Highcharts for server-side rendering. The module ensures + * that the browser and pages are correctly managed and can handle failures + * during operations like launching the browser or creating new pages. + */ + import { readFileSync } from 'fs'; -import path from 'path'; +import { join } from 'path'; import puppeteer from 'puppeteer'; @@ -21,27 +29,32 @@ 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 { __dirname, getAbsolutePath } from './utils.js'; import ExportError from './errors/ExportError.js'; -// Get the template for the page -const template = readFileSync(__dirname + '/templates/template.html', 'utf8'); +// Get the template for pages +const pageTemplate = readFileSync( + join(__dirname, 'templates', 'template.html'), + 'utf8' +); -let browser; +// To save the browser +let browser = null; /** * Retrieves the existing Puppeteer browser instance. * - * @returns {Promise} A Promise resolving to the Puppeteer browser - * instance. + * @function getBrowser + * + * @returns {Object} The Puppeteer browser instance. * - * @throws {ExportError} Throws an ExportError if no valid browser has been - * created. + * @throws {ExportError} Throws an `ExportError` if no valid browser + * has been created. */ -export function get() { +export function getBrowser() { if (!browser) { - throw new ExportError('[browser] No valid browser has been created.'); + throw new ExportError('[browser] No valid browser has been created.', 500); } return browser; } @@ -49,25 +62,31 @@ export function get() { /** * Creates a Puppeteer browser instance with the specified arguments. * - * @param {Array} puppeteerArgs - Additional arguments for Puppeteer launch. + * @async + * @function createBrowser * - * @returns {Promise} A Promise resolving to the Puppeteer browser - * instance. + * @param {Array} puppeteerArgs - Additional arguments for Puppeteer + * browser's launch. * - * @throws {ExportError} Throws an ExportError if max retries to open a browser - * instance are reached, or if no browser instance is found after retries. + * @returns {Promise} A Promise that resolves to the created 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 +export async function createBrowser(puppeteerArgs) { + // Get `debug` and `other` options const { debug, other } = getOptions(); - // Get the debug options + // Get the `debug` options const { enable: enabledDebug, ...debugOptions } = debug; + // Launch options for the browser instance const launchOptions = { headless: other.browserShellMode ? 'shell' : true, - userDataDir: './tmp/', - args: puppeteerArgs, + userDataDir: 'tmp', + args: puppeteerArgs || [], handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false, @@ -78,14 +97,16 @@ export async function create(puppeteerArgs) { // Create a browser if (!browser) { + // A counter for the browser's launch retries let tryCount = 0; - - const open = async () => { + const openBrowser = async () => { try { log( 3, - `[browser] Attempting to get a browser instance (try ${++tryCount}).` + `[browser] Attempting to launch and get a browser instance (try ${++tryCount}).` ); + + // Launch the browser browser = await puppeteer.launch(launchOptions); } catch (error) { logWithStack( @@ -97,8 +118,10 @@ export async function create(puppeteerArgs) { // Retry to launch browser until reaching max attempts if (tryCount < 25) { log(3, `[browser] Retry to open a browser (${tryCount} out of 25).`); + + // Wait for a 4 seconds before trying again await new Promise((response) => setTimeout(response, 4000)); - await open(); + await openBrowser(); } else { throw error; } @@ -106,7 +129,8 @@ export async function create(puppeteerArgs) { }; try { - await open(); + // Try to open a browser + await openBrowser(); // Shell mode inform if (launchOptions.headless === 'shell') { @@ -119,87 +143,104 @@ export async function create(puppeteerArgs) { } } catch (error) { throw new ExportError( - '[browser] Maximum retries to open a browser instance reached.' + '[browser] Maximum retries to open a browser instance reached.', + 500 ).setError(error); } + // No correct browser if (!browser) { - throw new ExportError('[browser] Cannot find a browser to open.'); + throw new ExportError('[browser] Cannot find a browser to open.', 500); } } - // Return a browser promise + // Return a browser instance return browser; } /** * Closes the Puppeteer browser instance if it is connected. * - * @returns {Promise} A Promise resolving to true after the browser - * is closed. + * @async + * @function closeBrowser */ -export async function close() { - // Close the browser when connnected - if (browser?.connected) { +export async function closeBrowser() { + // Close the browser when connected + if (browser && browser.connected) { await browser.close(); } + browser = null; log(4, '[browser] Closed the browser.'); } /** - * Creates a new Puppeteer Page within an existing browser instance. + * Creates a new Puppeteer page within an existing browser instance. + * The function creates a new page, disables caching, sets content using + * the `_setPageContent()`, and returns the created Puppeteer page. * - * If the browser instance is not available, returns false. + * @async + * @function newPage * - * The function creates a new page, disables caching, sets content using - * setPageContent(), and returns the created Puppeteer Page. + * @param {Object} poolResource - The pool resource that contains `id`, + * `workCount`, and `page`. * - * @returns {(boolean|object)} Returns false if the browser instance is not - * available, or a Puppeteer Page object representing the newly created page. + * @throws {ExportError} Throws an `ExportError` if no valid browser + * has been connected or if a page is invalid or closed. */ -export async function newPage() { - if (!browser) { - return false; +export async function newPage(poolResource) { + // Error in case of no connected browser + if (!browser || !browser.connected) { + throw new ExportError(`[browser] Browser is not yet connected.`, 500); } // Create a page - const page = await browser.newPage(); + poolResource.page = await browser.newPage(); // Disable cache - await page.setCacheEnabled(false); + await poolResource.page.setCacheEnabled(false); // Set the content - await setPageContent(page); + await _setPageContent(poolResource.page); // Set page events - setPageEvents(page); + _setPageEvents(poolResource.page); - return page; + // Check if the page is correctly created + if (!poolResource.page || poolResource.page.isClosed()) { + throw new ExportError('[browser] The page is invalid or closed.', 400); + } } /** - * Clears the content of a Puppeteer Page based on the specified mode. + * Clears the content of a Puppeteer page based on the specified mode. Logs + * thrown error if clearing of a page's content fails. + * + * @async + * @function clearPage * - * @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. + * @param {Object} poolResource - The pool resource that contains page and id. + * @param {boolean} [hardReset=false] - 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. The default value is `false`. * - * @throws {Error} Logs thrown error if clearing the page content fails. + * @returns {Promise} A Promise that resolves to `true` when page + * is correctly cleared and `false` when it is not. */ -export async function clearPage(page, hardReset = false) { +export async function clearPage(poolResource, hardReset = false) { try { - if (page && !page.isClosed()) { + if (poolResource.page && !poolResource.page.isClosed()) { if (hardReset) { - // Navigate to about:blank - await page.goto('about:blank', { waitUntil: 'domcontentloaded' }); + // Navigate to `about:blank` + await poolResource.page.goto('about:blank', { + waitUntil: 'domcontentloaded' + }); // Set the content and and scripts again - await setPageContent(page); + await _setPageContent(poolResource.page); } else { // Clear body content - await page.evaluate(() => { + await poolResource.page.evaluate(() => { document.body.innerHTML = '
'; }); @@ -210,30 +251,36 @@ export async function clearPage(page, hardReset = false) { logWithStack( 2, error, - '[browser] Could not clear the content of the page.' + `[pool] Pool resource [${poolResource.id}] - Content of the page could not be cleared.` ); - } + // Set the `workLimit` to exceeded in order to recreate the resource + poolResource.workCount = getOptions().pool.workLimit + 1; + } return false; } /** - * Adds custom JS and CSS resources to a Puppeteer Page based on the specified + * 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. + * @async + * @function addPageResources + * + * @param {Object} page - The Puppeteer page object to which resources will + * be added. + * @param {Object} customLogicOptions - The configuration object containing + * `customLogic` options. * - * @returns {Promise>} - Promise resolving to an array of injected - * resources. + * @returns {Promise>} A Promise that resolves to an array + * of injected resources. */ -export async function addPageResources(page, options) { +export async function addPageResources(page, customLogicOptions) { // Injected resources array const injectedResources = []; - // Use resources - const resources = options.customLogic.resources; + // Use the content of the `resources` + const resources = customLogicOptions.resources; if (resources) { const injectedJs = []; @@ -247,13 +294,13 @@ export async function addPageResources(page, options) { // Load scripts from all custom files if (resources.files) { for (const file of resources.files) { - const isLocal = !file.startsWith('http') ? true : false; + const isLocal = file.startsWith('http') ? false : true; // Add each custom script from resources' files injectedJs.push( isLocal ? { - content: readFileSync(file, 'utf8') + content: readFileSync(getAbsolutePath(file), 'utf8') } : { url: file @@ -262,11 +309,12 @@ export async function addPageResources(page, options) { } } + // The actual injection of collected scripts for (const jsResource of injectedJs) { try { injectedResources.push(await page.addScriptTag(jsResource)); } catch (error) { - logWithStack(2, error, `[export] The JS resource cannot be loaded.`); + logWithStack(2, error, `[browser] The JS resource cannot be loaded.`); } } injectedJs.length = 0; @@ -274,7 +322,7 @@ export async function addPageResources(page, options) { // Load CSS const injectedCss = []; if (resources.css) { - let cssImports = resources.css.match(/@import\s*([^;]*);/g); + const cssImports = resources.css.match(/@import\s*([^;]*);/g); if (cssImports) { // Handle css section for (let cssImportPath of cssImports) { @@ -293,9 +341,9 @@ export async function addPageResources(page, options) { injectedCss.push({ url: cssImportPath }); - } else if (options.customLogic.allowFileResources) { + } else if (customLogicOptions.allowFileResources) { injectedCss.push({ - path: path.join(__dirname, cssImportPath) + path: getAbsolutePath(cssImportPath) }); } } @@ -307,11 +355,16 @@ export async function addPageResources(page, options) { content: resources.css.replace(/@import\s*([^;]*);/g, '') || ' ' }); + // The actual injection of collected CSS for (const cssResource of injectedCss) { try { injectedResources.push(await page.addStyleTag(cssResource)); } catch (error) { - logWithStack(2, error, `[export] The CSS resource cannot be loaded.`); + logWithStack( + 2, + error, + `[browser] The CSS resource cannot be loaded.` + ); } } injectedCss.length = 0; @@ -321,118 +374,118 @@ export async function addPageResources(page, options) { } /** - * 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. + * Clears out all state set on the page with `addScriptTag` and `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 + * @async + * @function clearPageResources + * + * @param {Object} page - The Puppeteer page object from which resources will * be cleared. * @param {Array} injectedResources - Array of injected resources * to be cleared. */ export async function clearPageResources(page, injectedResources) { - for (const resource of injectedResources) { - await resource.dispose(); - } + try { + 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(); + // 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, 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(); - } - }); + // 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(); + } + }); + } catch (error) { + logWithStack(2, error, `[browser] Could not clear page's resources.`); + } } /** - * 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. + * Sets the content for a Puppeteer page using a predefined template + * and additional scripts. + * + * @async + * @function _setPageContent * - * @param {Object} page - The Puppeteer Page object for which the content + * @param {Object} page - The Puppeteer page object to which the content * is being set. */ -async function setPageContent(page) { - await page.setContent(template, { waitUntil: 'domcontentloaded' }); +async function _setPageContent(page) { + // Set the initial page content + await page.setContent(pageTemplate, { waitUntil: 'domcontentloaded' }); // Add all registered Higcharts scripts, quite demanding - await page.addScriptTag({ path: `${getCachePath()}/sources.js` }); + await page.addScriptTag({ path: join(getCachePath(), 'sources.js') }); - // Set the initial animObject + // Set the initial `animObject` for Highcharts await page.evaluate(setupHighcharts); } /** - * Set events for a Puppeteer Page. + * Set events (like `pageerror` and `console`) for a Puppeteer page in order + * to catch and display errors and console logs from the window context. + * + * @function _setPageEvents * - * @param {Object} page - The Puppeteer Page object to set events to. + * @param {Object} page - The Puppeteer page object to which the listeners + * are being set. */ -function setPageEvents(page) { - // Get debug options +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) => { + // Set the `pageerror` listener + page.on('pageerror', async () => { // It would seem like this may fire at the same time or shortly before // a page is closed. if (page.isClosed()) { return; } - - // 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; - } - }, - `

Chart input data error:

${error.toString()}` - ); }); + + // Set the `console` listener, if needed + if (debug.enable && debug.listenToConsole) { + page.on('console', (message) => { + console.log(`[debug] ${message.text()}`); + }); + } } export default { - get, - create, - close, + getBrowser, + createBrowser, + closeBrowser, newPage, clearPage, addPageResources, diff --git a/lib/cache.js b/lib/cache.js index cd712a15..8afb54b6 100644 --- a/lib/cache.js +++ b/lib/cache.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,134 +12,395 @@ 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 +/** + * @overview The cache manager is responsible for handling and managing + * the Highcharts library along with its dependencies. It ensures that these + * resources are stored and retrieved efficiently to optimize performance + * and reduce redundant network requests. The cache is stored in the `.cache` + * directory by default, which serves as a dedicated folder for keeping cached + * files. + */ 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 { getOptions, updateOptions } from './config.js'; +import { get } from './fetch.js'; import { log } from './logger.js'; -import { __dirname } from './utils.js'; +import { getAbsolutePath } from './utils.js'; import ExportError from './errors/ExportError.js'; +// The initial cache template const cache = { - cdnURL: 'https://code.highcharts.com/', + cdnUrl: 'https://code.highcharts.com', activeManifest: {}, sources: '', hcVersion: '' }; /** - * Extracts and caches the Highcharts version from the sources string. + * Checks the cache for Highcharts dependencies, updates the cache if needed, + * and loads the sources. * - * @returns {string} The extracted Highcharts version. + * @async + * @function checkCache + * + * @param {Object} highchartsOptions - The configuration object containing + * `highcharts` options. + * @param {Object} serverProxyOptions- The configuration object containing + * `server.proxy` options. */ -export const extractVersion = (cache) => { - return cache.sources - .substring(0, cache.sources.indexOf('*/')) - .replace('/*', '') - .replace('*/', '') - .replace(/\n/g, '') - .trim(); -}; +export async function checkCache(highchartsOptions, serverProxyOptions) { + try { + let fetchedModules; + + // Get the cache path + const cachePath = getCachePath(); + + // 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, { recursive: true }); + + // Fetch all the scripts either if the `manifest.json` does not exist + // or if the `forceFetch` option is enabled + if (!existsSync(manifestPath) || highchartsOptions.forceFetch) { + log(3, '[cache] Fetching and caching Highcharts dependencies.'); + + // The initial cache update + fetchedModules = await _updateCache( + highchartsOptions, + serverProxyOptions, + sourcePath + ); + } else { + let requestUpdate = false; + + // Read the manifest JSON + const manifest = JSON.parse(readFileSync(manifestPath), 'utf8'); + + // 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; + } + + // Get the actual number of scripts to be fetched + const { coreScripts, moduleScripts, indicatorScripts } = + highchartsOptions; + 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 !== highchartsOptions.version) { + // Check the 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 + ) { + // Check the number of modules + 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; + } + }); + } + + // Update cache if needed + if (requestUpdate) { + fetchedModules = await _updateCache( + highchartsOptions, + serverProxyOptions, + 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; + + // Extract and save version of currently used Highcharts + cache.hcVersion = _extractHcVersion(cache.sources); + } + } + + // Finally, save the new manifest, which is basically our current config + // in a slightly different format + await _saveConfigToManifest(highchartsOptions.version, fetchedModules); + } catch (error) { + throw new ExportError( + '[cache] Could not configure cache and create or update the config manifest.', + 500 + ).setError(error); + } +} /** - * Extracts the Highcharts module name based on the scriptPath. + * Gets the version of Highcharts from the cache. + * + * @function getHcVersion + * + * @returns {string} The cached Highcharts version. */ -export const extractModuleName = (scriptPath) => { - return scriptPath.replace( - /(.*)\/|(.*)modules\/|stock\/(.*)indicators\/|maps\/(.*)modules\//gi, - '' - ); -}; +export function getHcVersion() { + return cache.hcVersion; +} + +/** + * Updates the Highcharts version in the applied configuration and checks + * the cache for the scripts of a new version. + * + * @async + * @function updateHcVersion + * + * @param {string} newVersion - The new Highcharts version to be applied. + */ +export async function updateHcVersion(newVersion) { + // Update to the new version + const options = updateOptions({ + highcharts: { + version: newVersion + } + }); + + // Check if cache needs to be updated + await checkCache(options.highcharts, options.server.proxy); +} + +/** + * Retrieves the current cache object. + * + * @function getCache + * + * @returns {Object} The cache object containing various cached data. + */ +export function getCache() { + return cache; +} + +/** + * Gets the cache path for Highcharts. + * + * @function getCachePath + * + * @returns {string} The absolute path to the cache directory for Highcharts. + */ +export function getCachePath() { + return getAbsolutePath(getOptions().highcharts.cachePath, 'utf8'); // #562 +} /** * 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. + * @async + * @function _saveConfigToManifest * - * @throws {ExportError} Throws an ExportError if an error occurs while writing - * the cache manifest. + * @param {number} version - The currently used Highcharts version. + * @param {Object} [fetchedModules={}] - An object which tracks which modules + * have been fetched. The default value is an empty object. + * + * @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 || {} - }; - +async function _saveConfigToManifest(version, fetchedModules = {}) { // Update cache object with the current modules - cache.activeManifest = newManifest; + cache.activeManifest = { + version, + modules: fetchedModules + }; log(3, '[cache] Writing a new manifest.'); try { writeFileSync( - join(__dirname, config.cachePath, 'manifest.json'), - JSON.stringify(newManifest), + join(getCachePath(), 'manifest.json'), + JSON.stringify(cache.activeManifest), 'utf8' ); } catch (error) { - throw new ExportError('[cache] Error writing the cache manifest.').setError( - error + throw new ExportError( + '[cache] Error writing the cache manifest.', + 500 + ).setError(error); + } +} + +/** + * Updates the local cache with Highcharts scripts content and information, + * and used Highcharts version. + * + * @async + * @function _updateCache + * + * @param {Object} highchartsOptions - The configuration object containing + * `highcharts` options. + * @param {Object} serverProxyOptions - The configuration object containing + * `server.proxy` options. + * @param {string} sourcePath - The path to the source file in the cache. + * + * @returns {Promise} A Promise that resolves to an object representing + * the fetched modules. + * + * @throws {ExportError} Throws an `ExportError` if there is an issue updating + * the local Highcharts cache. + */ +async function _updateCache(highchartsOptions, serverProxyOptions, sourcePath) { + try { + // Get Highcharts version for scripts + const hcVersion = + highchartsOptions.version === 'latest' + ? null + : `${highchartsOptions.version}`; + + log( + 3, + `[cache] Updating cache version to Highcharts: ${hcVersion || 'latest'}.` ); + + // Get the CDN url for scripts + const cdnUrl = highchartsOptions.cdnUrl || cache.cdnUrl; + + // Prepare options for a request + const requestOptions = _configureRequest(serverProxyOptions); + + // An object to record which scripts are fetched + const fetchedModules = {}; + + // Join all fetched scripts and save in the manifest's sources + cache.sources = ( + await Promise.all([ + // Highcharts core scripts fetch + ...highchartsOptions.coreScripts.map((cs) => + _fetchScript( + hcVersion ? `${cdnUrl}/${hcVersion}/${cs}` : `${cdnUrl}/${cs}`, + requestOptions, + fetchedModules, + true + ) + ), + // Highcharts module scripts fetch + ...highchartsOptions.moduleScripts.map((ms) => + _fetchScript( + ms === 'map' + ? hcVersion + ? `${cdnUrl}/maps/${hcVersion}/modules/${ms}` + : `${cdnUrl}/maps/modules/${ms}` + : hcVersion + ? `${cdnUrl}/${hcVersion}/modules/${ms}` + : `${cdnUrl}/modules/${ms}`, + requestOptions, + fetchedModules + ) + ), + // Highcharts indicator scripts fetch + ...highchartsOptions.indicatorScripts.map((is) => + _fetchScript( + hcVersion + ? `${cdnUrl}/stock/${hcVersion}/indicators/${is}` + : `${cdnUrl}/stock/indicators/${is}`, + requestOptions, + fetchedModules + ) + ), + // Custom scripts fetch + ...highchartsOptions.customScripts.map((cs) => + _fetchScript(`${cs}`, requestOptions) + ) + ]) + ).join(';\n'); + + // Extract and save version of currently used Highcharts + cache.hcVersion = _extractHcVersion(cache.sources); + + // Save the fetched modules into caches' source JSON + writeFileSync(sourcePath, cache.sources); + + // Return the fetched modules + return fetchedModules; + } catch (error) { + throw new ExportError( + '[cache] Unable to update the local Highcharts cache.', + 500 + ).setError(error); } -}; +} /** - * Fetches a single script and updates the fetchedModules accordingly. + * Fetches a single script and updates the `fetchedModules` accordingly. + * + * @async + * @function _fetchScript * * @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} requestOptions - Additional requests options. * @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. + * @param {boolean} [shouldThrowError=false] - A flag to indicate if the error + * should be thrown. This should be used only for the core scripts. The default + * value is `false`. * - * @returns {Promise} A Promise resolving to the text representation + * @returns {Promise} A Promise that resolves to the text representation * of the fetched script. * - * @throws {ExportError} Throws an ExportError if there is a problem with - * fetching the script. + * @throws {ExportError} Throws an `ExportError` if there is a problem + * with fetching the script. */ -export const fetchAndProcessScript = async ( +async function _fetchScript( 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); + const response = await get(`${script}.js`, requestOptions); // If OK, return its text representation if (response.statusCode === 200 && typeof response.text == 'string') { if (fetchedModules) { - const moduleName = extractModuleName(script); + const moduleName = _extractModuleName(script); fetchedModules[moduleName] = 1; } - return response.text; } + // Based on the `shouldThrowError` flag, decide how to serve error message if (shouldThrowError) { throw new ExportError( - `Could not fetch the ${script}.js. The script might not exist in the requested version (status code: ${response.statusCode}).` + `[cache] Could not fetch the ${script}.js. The script might not exist in the requested version (status code: ${response.statusCode}).`, + 404 ).setError(response); } else { log( @@ -147,260 +408,95 @@ export const fetchAndProcessScript = async ( `[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. + * Configures a proxy agent for outgoing HTTP requests based on the provided + * `server.proxy` options. If a valid `host` and `port` are specified, it tries + * to create an `HttpsProxyAgent`. If the creation fails, an `ExportError` + * is thrown. If no proxy is configured, an empty object is returned. * - * @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. + * @function _configureRequest + * + * @param {Object} serverProxyOptions- The configuration object containing + * `server.proxy` options. + * + * @returns {Object} The request options, including the proxy agent if created, + * or an empty object if no proxy configuration is provided. * - * @returns {Promise} The fetched scripts content joined. + * @throws {ExportError} Throws an `ExportError` if the proxy agent creation + * fails. */ -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 +function _configureRequest(serverProxyOptions) { + // Get the `host` and `port` of the proxy + const proxyHost = serverProxyOptions.host; + const proxyPort = serverProxyOptions.port; + + // Try to create a proxy agent if (proxyHost && proxyPort) { try { - proxyAgent = new HttpsProxyAgent({ + // Create the agent + const proxyAgent = new HttpsProxyAgent({ host: proxyHost, port: proxyPort }); + + // Add the agent to the request's options + return { + agent: proxyAgent, + timeout: serverProxyOptions.timeout + }; } catch (error) { - throw new ExportError('[cache] Could not create a Proxy Agent.').setError( - error - ); + throw new ExportError( + '[cache] Could not create a Proxy Agent.', + 500 + ).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'); -}; + // Return an empty object when no proxy agent is created + return {}; +} /** - * 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} A Promise resolving to an object representing - * the fetched modules. + * Extracts Highcharts version from the cache's sources string. * - * @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. + * @function _extractHcVersion * - * @param {string} newVersion - The new Highcharts version to be applied. + * @param {Object} cacheSources - The cache sources object. * - * @returns {Promise<(object|boolean)>} A Promise resolving to the updated - * configuration with the new version, or false if no applied configuration - * exists. + * @returns {string} The extracted Highcharts version. */ -export const updateVersion = async (newVersion) => { - const options = getOptions(); - if (options?.highcharts) { - options.highcharts.version = newVersion; - } - await checkAndUpdateCache(options); -}; +function _extractHcVersion(cacheSources) { + return cacheSources + .substring(0, cacheSources.indexOf('*/')) + .replace('/*', '') + .replace('*/', '') + .replace(/\n/g, '') + .trim(); +} /** - * Checks the cache for Highcharts dependencies, updates the cache if needed, - * and loads the sources. + * Extracts the Highcharts module name based on the `scriptPath` property. * - * @param {Object} options - Object containing all options. + * @function _extractModuleName * - * @returns {Promise} A Promise that resolves once the cache is checked - * and updated. + * @param {string} scriptPath - The path of the script from which the module + * name will be extracted. * - * @throws {ExportError} Throws an ExportError if there is an issue updating - * or reading the cache. + * @returns {string} The extracted module name. */ -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; +function _extractModuleName(scriptPath) { + return scriptPath.replace( + /(.*)\/|(.*)modules\/|stock\/(.*)indicators\/|maps\/(.*)modules\//gi, + '' + ); +} export default { - checkAndUpdateCache, - getCachePath, - updateVersion, + checkCache, + getHcVersion, + updateHcVersion, getCache, - highcharts, - version + getCachePath }; diff --git a/lib/chart.js b/lib/chart.js index 47b28f8d..e3665637 100644 --- a/lib/chart.js +++ b/lib/chart.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,555 +12,1014 @@ See LICENSE file in root for details. *******************************************************************************/ +/** + * @overview This module provides functions that prepare for the exporting + * charts into various image output formats such as JPEG, PNG, PDF, and SVGs. + * It supports single and batch export operations and allows customization + * through options passed from configurations or APIs. + */ + import { readFileSync, writeFileSync } from 'fs'; -import { getOptions, initExportSettings } from './config.js'; +import { isAllowedConfig, updateOptions } from './config.js'; import { log, logWithStack } from './logger.js'; -import { killPool, postWork, stats } from './pool.js'; -import { - fixType, - handleResources, - isCorrectJSON, - optionsStringify, - roundNumber, - toBoolean, - wrapAround -} from './utils.js'; +import { getPoolStats, killPool, postWork } from './pool.js'; import { sanitize } from './sanitize.js'; +import { getAbsolutePath, getBase64, isObject, roundNumber } from './utils.js'; + import ExportError from './errors/ExportError.js'; +// The global flag for the code execution permission let allowCodeExecution = false; /** - * Starts an export process. The `settings` contains final options gathered - * from all possible sources (config, env, cli, json). The `endCallback` is - * called when the export is completed, with an error object as the first - * argument and the second containing the base64 respresentation of a chart. - * - * @param {Object} settings - The settings object containing export - * configuration. - * @param {function} endCallback - The callback function to be invoked upon - * finalizing work or upon error occurance of the exporting process. - * - * @returns {void} This function does not return a value directly; instead, - * it communicates results via the endCallback. + * Starts a single export process based on the specified options and saves + * the resulting image to the provided output file. + * + * @async + * @function singleExport + * + * @param {Object} options - The `options` object, which should include settings + * from the `export` and `customLogic` sections. It can be a partial or complete + * set of options from these sections. The object must contain at least one + * of the following `export` properties: `infile`, `instr`, `options`, or `svg` + * to generate a valid image. + * + * @returns {Promise} A Promise that resolves once the single export + * process is completed. + * + * @throws {ExportError} Throws an `ExportError` if an error occurs during + * the single export process. */ -export const startExport = async (settings, endCallback) => { - // Starting exporting process message - log(4, '[chart] Starting the exporting process.'); +export async function singleExport(options) { + // Check if the export makes sense + if (options && options.export) { + // Perform an export + await startExport( + { export: options.export, customLogic: options.customLogic }, + async (error, data) => { + // Exit process when error exists + if (error) { + throw error; + } - // Initialize options - const options = initExportSettings(settings, getOptions()); + // Get the `b64`, `outfile`, and `type` for a chart + const { b64, outfile, type } = data.options.export; - // Get the export options - const exportOptions = options.export; + // Save the result + try { + if (b64) { + // As a Base64 string to a txt file + writeFileSync( + `${outfile.split('.').shift() || 'chart'}.txt`, + getBase64(data.result, type) + ); + } else { + // As a correct image format + writeFileSync( + outfile || `chart.${type}`, + type !== 'svg' ? Buffer.from(data.result, 'base64') : data.result + ); + } + } catch (error) { + throw new ExportError( + '[chart] Error while saving a chart.', + 500 + ).setError(error); + } - // If SVG is an input (argument can be sent only by the request) - if (options.payload?.svg && options.payload.svg !== '') { - try { - log(4, '[chart] Attempting to export from a SVG input.'); + // Kill pool and close browser after finishing single export + await killPool(); + } + ); + } else { + throw new ExportError( + '[chart] No expected `export` options were found. Please provide one of the following options: `infile`, `instr`, `options`, or `svg` to generate a valid image.', + 400 + ); + } +} - const result = exportAsString( - sanitize(options.payload.svg), // #209 - options, - endCallback - ); +/** + * Starts a batch export process for multiple charts based on information + * provided in the `batch` option. The `batch` is a string in the following + * format: "infile1.json=outfile1.png;infile2.json=outfile2.png;...". Results + * are saved to the specified output files. + * + * @async + * @function batchExport + * + * @param {Object} options - The `options` object, which should include settings + * from the `export` and `customLogic` sections. It can be a partial or complete + * set of options from these sections. It must contain the `batch` option from + * the `export` section to generate valid images. + * + * @returns {Promise} A Promise that resolves once the batch export + * processes are completed. + * + * @throws {ExportError} Throws an `ExportError` if an error occurs during + * any of the batch export process. + */ +export async function batchExport(options) { + // Check if the export makes sense + if (options && options.export && options.export.batch) { + // An array for collecting batch exports + const batchFunctions = []; - ++stats.exportFromSvgAttempts; - return result; - } catch (error) { - return endCallback( - new ExportError('[chart] Error loading SVG input.').setError(error) - ); + // Split and pair the `batch` arguments + for (let pair of options.export.batch.split(';') || []) { + pair = pair.split('='); + if (pair.length === 2) { + batchFunctions.push( + startExport( + { + export: { + ...options.export, + infile: pair[0], + outfile: pair[1] + }, + customLogic: options.customLogic + }, + (error, data) => { + // Exit process when error exists + if (error) { + throw error; + } + + // Get the `b64`, `outfile`, and `type` for a chart + const { b64, outfile, type } = data.options.export; + + // Save the result + try { + if (b64) { + // As a Base64 string to a txt file + writeFileSync( + `${outfile.split('.').shift() || 'chart'}.txt`, + getBase64(data.result, type) + ); + } else { + // As a correct image format + writeFileSync( + outfile, + type !== 'svg' + ? Buffer.from(data.result, 'base64') + : data.result + ); + } + } catch (error) { + throw new ExportError( + '[chart] Error while saving a chart.', + 500 + ).setError(error); + } + } + ) + ); + } else { + log(2, '[chart] No correct pair found for the batch export.'); + } } + + // Await all exports are done + const batchResults = await Promise.allSettled(batchFunctions); + + // Kill pool and close browser after finishing batch export + await killPool(); + + // Log errors if found + batchResults.forEach((result, index) => { + // Log the error with stack about the specific batch export + if (result.reason) { + logWithStack( + 1, + result.reason, + `[chart] Batch export number ${index + 1} could not be correctly completed.` + ); + } + }); + } else { + throw new ExportError( + '[chart] No expected `export` options were found. Please provide the `batch` option to generate valid images.', + 400 + ); } +} - // Export using options from the file - if (exportOptions.infile && exportOptions.infile.length) { - // Try to read the file to get the string representation - try { - log(4, '[chart] Attempting to export from an input file.'); - options.export.instr = readFileSync(exportOptions.infile, 'utf8'); - return exportAsString(options.export.instr.trim(), options, endCallback); - } catch (error) { - return endCallback( - new ExportError('[chart] Error loading input file.').setError(error) +/** + * Starts an export process. The `imageOptions` parameter is an object that + * should include settings from the `export` and `customLogic` sections. It can + * be a partial or complete set of options from these sections. If partial + * options are provided, missing values will be merged with the current global + * options. + * + * The `endCallback` function is invoked upon the completion of the export, + * either successfully or with an error. The `error` object is provided + * as the first argument, and the `data` object is the second, containing + * the Base64 representation of the chart in the `result` property + * and the complete set of options in the `options` property. + * + * @async + * @function startExport + * + * @param {Object} imageOptions - The `imageOptions` object, which should + * include settings from the `export` and `customLogic` sections. It can + * be a partial or complete set of options from these sections. If the provided + * options are partial, missing values will be merged with the current global + * options. + * @param {Function} endCallback - The callback function to be invoked upon + * finalizing the export process or upon encountering an error. The first + * argument is the `error` object, and the second argument is the `data` object, + * which includes the Base64 representation of the chart in the `result` + * property and the full set of options in the `options` property. + * + * @returns {Promise} This function does not return a value directly. + * Instead, it communicates results via the `endCallback`. + * + * @throws {ExportError} Throws an `ExportError` if there is a problem with + * processing input of any type. The error is passed into the `endCallback` + * function and processed there. + */ +export async function startExport(imageOptions, endCallback) { + try { + // Check if provided options are in an object + if (!isObject(imageOptions)) { + throw new ExportError( + '[chart] Incorrect value of the provided `imageOptions`. Needs to be an object.', + 400 ); } - } - // Export with options from the raw representation - if ( - (exportOptions.instr && exportOptions.instr !== '') || - (exportOptions.options && exportOptions.options !== '') - ) { - try { - log(4, '[chart] Attempting to export from a raw input.'); + // Merge additional options to the copy of the instance options + const options = updateOptions( + { + export: imageOptions.export, + customLogic: imageOptions.customLogic + }, + true + ); + + // Get the `export` options + const exportOptions = options.export; - // Perform a direct inject when forced - if (toBoolean(options.customLogic?.allowCodeExecution)) { - return doStraightInject(options, endCallback); + // Starting exporting process message + log(4, '[chart] Starting the exporting process.'); + + // Export using options from the file as an input + if (exportOptions.infile !== null) { + log(4, '[chart] Attempting to export from a file input.'); + + let fileContent; + try { + // Try to read the file to get the string representation + fileContent = readFileSync( + getAbsolutePath(exportOptions.infile), + 'utf8' + ); + } catch (error) { + throw new ExportError( + '[chart] Error loading content from a file input.', + 400 + ).setError(error); } - // Either try to parse to JSON first or do the direct export - return typeof exportOptions.instr === 'string' - ? exportAsString(exportOptions.instr.trim(), options, endCallback) - : doExport( - options, - exportOptions.instr || exportOptions.options, - endCallback - ); - } catch (error) { - return endCallback( - new ExportError('[chart] Error loading raw input.').setError(error) + // Check the file's extension + if (exportOptions.infile.endsWith('.svg')) { + // Set to the `svg` option + exportOptions.svg = fileContent; + } else if (exportOptions.infile.endsWith('.json')) { + // Set to the `instr` option + exportOptions.instr = fileContent; + } else { + throw new ExportError( + '[chart] Incorrect value of the `infile` option.', + 400 + ); + } + } + + // Export using SVG as an input + if (exportOptions.svg !== null) { + log(4, '[chart] Attempting to export from an SVG input.'); + + // SVG exports attempts counter + ++getPoolStats().exportsFromSvgAttempts; + + // Export from an SVG string + const result = await _exportFromSvg( + sanitize(exportOptions.svg), // #209 + options + ); + + // SVG exports counter + ++getPoolStats().exportsFromSvg; + + // Pass SVG export result to the end callback + return endCallback(null, result); + } + + // Export using options as an input + if (exportOptions.instr !== null || exportOptions.options !== null) { + log(4, '[chart] Attempting to export from options input.'); + + // Options exports attempts counter + ++getPoolStats().exportsFromOptionsAttempts; + + // Export from options + const result = await _exportFromOptions( + exportOptions.instr || exportOptions.options, + options ); + + // Options exports counter + ++getPoolStats().exportsFromOptions; + + // Pass options export result to the end callback + return endCallback(null, result); } + + // No input specified, pass an error message to the callback + return endCallback( + new ExportError( + `[chart] No valid input specified. Check if at least one of the following parameters is correctly set: 'infile', 'instr', 'options', or 'svg'.`, + 400 + ) + ); + } catch (error) { + return endCallback(error); } +} - // No input specified, pass an error message to the callback - return endCallback( - new ExportError( - `[chart] No valid input specified. Check if at least one of the following parameters is correctly set: 'infile', 'instr', 'options', or 'svg'.` - ) - ); -}; +/** + * Retrieves and returns the current status of the code execution permission. + * + * @function getAllowCodeExecution + * + * @returns {boolean} The value of the global `allowCodeExecution` option. + */ +export function getAllowCodeExecution() { + return allowCodeExecution; +} /** - * Starts a batch export process for multiple charts based on the information - * in the batch option. The batch is a string in the following format: - * "infile1.json=outfile1.png;infile2.json=outfile2.png;..." + * Sets the code execution permission based on the provided boolean value. * - * @param {Object} options - The options object containing configuration for - * a batch export. + * @function setAllowCodeExecution * - * @returns {Promise} A Promise that resolves once the batch export - * process is completed. + * @param {boolean} value - The boolean value to be assigned to the global + * `allowCodeExecution` option. + */ +export function setAllowCodeExecution(value) { + allowCodeExecution = value; +} + +/** + * Exports from an SVG based input with the provided options. * - * @throws {ExportError} Throws an ExportError if an error occurs during - * any of the batch export process. + * @async + * @function _exportFromSvg + * + * @param {string} inputToExport - The SVG based input to be exported. + * @param {Object} options - The configuration object containing complete set + * of options. + * + * @returns {Promise} A Promise that resolves to a result of the export + * process. + * + * @throws {ExportError} Throws an `ExportError` if there is not a correct SVG + * input. */ -export const batchExport = async (options) => { - const batchFunctions = []; - - // Split and pair the --batch arguments - for (let pair of options.export.batch.split(';')) { - pair = pair.split('='); - if (pair.length === 2) { - batchFunctions.push( - startExport( - { - ...options, - export: { - ...options.export, - infile: pair[0], - outfile: pair[1] - } - }, - (error, info) => { - // Throw an error - if (error) { - throw error; - } +async function _exportFromSvg(inputToExport, options) { + // Check if it is SVG + if ( + typeof inputToExport === 'string' && + (inputToExport.indexOf('= 0 || inputToExport.indexOf('= 0) + ) { + log(4, '[chart] Parsing input as SVG.'); - // Save the base64 from a buffer to a correct image file - writeFileSync( - info.options.export.outfile, - info.options.export.type !== 'svg' - ? Buffer.from(info.result, 'base64') - : info.result - ); - } - ) - ); - } + // Set the export input as SVG + options.export.svg = inputToExport; + + // Reset the rest of the export input options + options.export.options = null; + options.export.instr = null; + + // Call the function with an SVG string as an export input + return _prepareExport(options); + } else { + throw new ExportError('[chart] Not a correct SVG input.', 400); } +} - try { - // Await all exports are done - await Promise.all(batchFunctions); +/** + * Exports from an options based input with the provided options. + * + * @async + * @function _exportFromOptions + * + * @param {string} inputToExport - The options based input to be exported. + * @param {Object} options - The configuration object containing complete set + * of options. + * + * @returns {Promise} A Promise that resolves to a result of the export + * process. + * + * @throws {ExportError} Throws an `ExportError` if there is not a correct + * chart options input. + */ +async function _exportFromOptions(inputToExport, options) { + log(4, '[chart] Parsing input from options.'); - // Kill pool and close browser after finishing batch export - await killPool(); - } catch (error) { + // Try to check, validate and parse to stringified options + const stringifiedOptions = isAllowedConfig( + inputToExport, + true, + options.customLogic.allowCodeExecution + ); + + // Check if a correct stringified options + if ( + stringifiedOptions === null || + typeof stringifiedOptions !== 'string' || + !stringifiedOptions.startsWith('{') || + !stringifiedOptions.endsWith('}') + ) { throw new ExportError( - '[chart] Error encountered during batch export.' - ).setError(error); + '[chart] Invalid configuration provided - Only options configurations and SVG are allowed for this server. If this is your server, JavaScript custom code can be enabled by starting the server with the `allowCodeExecution` options set to true.', + 403 + ); } -}; + + // Set the export input as a stringified chart options + options.export.instr = stringifiedOptions; + + // Reset the rest of the export input options + options.export.options = null; + options.export.svg = null; + + // Call the function with a stringified chart options + return _prepareExport(options); +} /** - * Starts a single export process based on the specified options. + * Function for finalizing options and configurations before export. * - * @param {Object} options - The options object containing configuration for - * a single export. + * @async + * @function _prepareExport * - * @returns {Promise} A Promise that resolves once the single export - * process is completed. + * @param {Object} options - The configuration object containing complete set + * of options. * - * @throws {ExportError} Throws an ExportError if an error occurs during - * the single export process. + * @returns {Promise} A Promise that resolves to a result of the export + * process. */ -export const singleExport = async (options) => { - // Use instr or its alias, options - options.export.instr = options.export.instr || options.export.options; - - // Perform an export - await startExport(options, async (error, info) => { - // Exit process when error - if (error) { - throw error; - } +async function _prepareExport(options) { + // Get the `export` and `customLogic` options + const { export: exportOptions, customLogic: customLogicOptions } = options; - const { outfile, type } = info.options.export; + // Prepare the `constr` option + exportOptions.constr = _fixConstr(exportOptions.constr); - // Save the base64 from a buffer to a correct image file - writeFileSync( - outfile || `chart.${type}`, - type !== 'svg' ? Buffer.from(info.result, 'base64') : info.result - ); + // Prepare the `type` option + exportOptions.type = _fixType(exportOptions.type, exportOptions.outfile); - // Kill pool and close browser after finishing single export - await killPool(); - }); -}; + // Prepare the `outfile` option + exportOptions.outfile = _fixOutfile( + exportOptions.type, + exportOptions.outfile + ); + + // Notify about the custom logic usage status + log( + 3, + `[chart] The custom logic is ${customLogicOptions.allowCodeExecution ? 'allowed' : 'disallowed'}.` + ); + + // Prepare the `customCode`, `callback`, and `resources` options + _handleCustomLogic(customLogicOptions); + + // Prepare the `globalOptions` and `themeOptions` options + _handleGlobalAndTheme(exportOptions, customLogicOptions); + + // Prepare the `height`, `width`, and `scale` options + _handleSize(exportOptions); + + // Check if the image options object does not exceed the size limit + _checkDataSize({ export: exportOptions, customLogic: customLogicOptions }); + + // Post the work to the pool + return postWork(options); +} /** - * Determines the size and scale for chart export based on the provided options. + * Handles adjusting the constructor name by transforming and normalizing + * it based on common chart types. * - * @param {Object} options - The options object containing configuration for - * chart export. + * @function _fixConstr * - * @returns {Object} An object containing the calculated height, width, - * and scale for the chart export. + * @param {string} constr - The original constructor name to be adjusted. + * + * @returns {string} The corrected constructor name, or 'chart' if the input + * is not recognized. */ -export const findChartSize = (options) => { - const { chart, exporting } = - options.export?.options || isCorrectJSON(options.export?.instr); - - // See if globalOptions holds chart or exporting size - const globalOptions = isCorrectJSON(options.export?.globalOptions); - - // Secure scale value - let scale = - options.export?.scale || - exporting?.scale || - globalOptions?.exporting?.scale || - options.export?.defaultScale || - 1; - - // the scale cannot be lower than 0.1 and cannot be higher than 5.0 - scale = Math.max(0.1, Math.min(scale, 5.0)); - - // we want to round the numbers like 0.23234 -> 0.23 - scale = roundNumber(scale, 2); - - // Find chart size and scale - const size = { - height: - options.export?.height || - exporting?.sourceHeight || - chart?.height || - globalOptions?.exporting?.sourceHeight || - globalOptions?.chart?.height || - options.export?.defaultHeight || - 400, - width: - options.export?.width || - exporting?.sourceWidth || - chart?.width || - globalOptions?.exporting?.sourceWidth || - globalOptions?.chart?.width || - options.export?.defaultWidth || - 600, - scale - }; +function _fixConstr(constr) { + try { + // Fix the constructor by lowering casing + const fixedConstr = `${constr.toLowerCase().replace('chart', '')}Chart`; + + // Handle the case where the result is just 'Chart' + if (fixedConstr === 'Chart') { + fixedConstr.toLowerCase(); + } - // Get rid of potential px and % - for (let [param, value] of Object.entries(size)) { - size[param] = - typeof value === 'string' ? +value.replace(/px|%/gi, '') : value; + // Return the corrected constructor, otherwise default to 'chart' + return ['chart', 'stockChart', 'mapChart', 'ganttChart'].includes( + fixedConstr + ) + ? fixedConstr + : 'chart'; + } catch { + // Default to 'chart' in case of any error + return 'chart'; } - return size; -}; +} /** - * Function for finalizing options before export. + * Handles fixing the outfile based on provided type. * - * @param {Object} options - The options object containing configuration for - * the export process. - * @param {Object} chartJson - The JSON representation of the chart. - * @param {Function} endCallback - The callback function to be called upon - * completion or error. - * @param {string} svg - The SVG representation of the chart. + * @function _fixOutfile * - * @returns {Promise} A Promise that resolves once the export process - * is completed. + * @param {string} type - The original export type. + * @param {string} outfile - The file path or name. + * + * @returns {string} The corrected outfile, or 'chart.png' if the input + * is not recognized. */ -const doExport = async (options, chartJson, endCallback, svg) => { - let { export: exportOptions, customLogic: customLogicOptions } = options; - - const allowCodeExecutionScoped = - typeof customLogicOptions.allowCodeExecution === 'boolean' - ? customLogicOptions.allowCodeExecution - : allowCodeExecution; - - if (!customLogicOptions) { - customLogicOptions = options.customLogic = {}; - } else if (allowCodeExecutionScoped) { - if (typeof options.customLogic.resources === 'string') { - // Process resources - options.customLogic.resources = handleResources( - options.customLogic.resources, - toBoolean(options.customLogic.allowFileResources) - ); - } else if (!options.customLogic.resources) { - try { - const resources = readFileSync('resources.json', 'utf8'); - options.customLogic.resources = handleResources( - resources, - toBoolean(options.customLogic.allowFileResources) - ); - } catch (error) { - logWithStack( - 2, - error, - `[chart] Unable to load the default resources.json file.` - ); - } +function _fixOutfile(type, outfile) { + // Get the file name from the `outfile` option + const fileName = getAbsolutePath(outfile || 'chart') + .split('.') + .shift(); + + // Return a correct outfile + return `${fileName}.${type || 'png'}`; +} + +/** + * Handles fixing the export type based on MIME types and file extensions. + * + * @function _fixType + * + * @param {string} type - The original export type. + * @param {string} [outfile=null] - The file path or name. The default value + * is `null`. + * + * @returns {string} The corrected export type, or 'png' if the input + * is not recognized. + */ +function _fixType(type, outfile = null) { + // MIME types + const mimeTypes = { + 'image/png': 'png', + 'image/jpeg': 'jpeg', + 'application/pdf': 'pdf', + 'image/svg+xml': 'svg' + }; + + // Get formats + const formats = Object.values(mimeTypes); + + // Check if type and outfile's extensions are the same + if (outfile) { + const outType = outfile.split('.').pop(); + + // Support the JPG type + if (outType === 'jpg') { + type = 'jpeg'; + } else if (formats.includes(outType) && type !== outType) { + type = outType; } } - // If the allowCodeExecution flag isn't set, we should refuse the usage - // of callback, resources, and custom code. Additionally, the worker will - // refuse to run arbitrary JavaScript. Prioritized should be the scoped - // option, then we should take a look at the overall pool option. - if (!allowCodeExecutionScoped && customLogicOptions) { - if ( - customLogicOptions.callback || - customLogicOptions.resources || - customLogicOptions.customCode - ) { - // Send back a friendly message saying that the exporter does not support - // these settings. - return endCallback( - new ExportError( - `[chart] The 'callback', 'resources' and 'customCode' options have been disabled for this server.` - ) - ); - } + // Return a correct type + return mimeTypes[type] || formats.find((t) => t === type) || 'png'; +} - // Reset all additional custom code - customLogicOptions.callback = false; - customLogicOptions.resources = false; - customLogicOptions.customCode = false; - } +/** + * Handle calculating the `height`, `width` and `scale` for chart exports based + * on the provided export options. + * + * The function prioritizes values in the following order: + * + * 1. The `height`, `width`, `scale` from the `exportOptions`. + * 2. Options from the chart configuration (from `exporting` and `chart`). + * 3. Options from the global options (from `exporting` and `chart`). + * 4. Options from the theme options (from `exporting` and `chart` sections). + * 5. Fallback default values (`height = 400`, `width = 600`, `scale = 1`). + * + * @function _handleSize + * + * @param {Object} exportOptions - The configuration object containing `export` + * options. + */ +function _handleSize(exportOptions) { + // Check the `options` and `instr` for chart and exporting sections + const { chart: optionsChart, exporting: optionsExporting } = + isAllowedConfig(exportOptions.instr) || false; - // Clean properties to keep it lean and mean - if (chartJson) { - chartJson.chart = chartJson.chart || {}; - chartJson.exporting = chartJson.exporting || {}; - chartJson.exporting.enabled = false; - } + // Check the `globalOptions` for chart and exporting sections + const { chart: globalOptionsChart, exporting: globalOptionsExporting } = + isAllowedConfig(exportOptions.globalOptions) || false; + + // Check the `themeOptions` for chart and exporting sections + const { chart: themeOptionsChart, exporting: themeOptionsExporting } = + isAllowedConfig(exportOptions.themeOptions) || false; + + // Find the `height` value + const height = + exportOptions.height || + optionsExporting?.sourceHeight || + optionsChart?.height || + globalOptionsExporting?.sourceHeight || + globalOptionsChart?.height || + themeOptionsExporting?.sourceHeight || + themeOptionsChart?.height || + exportOptions.defaultHeight || + 400; + + // Find the `width` value + const width = + exportOptions.width || + optionsExporting?.sourceWidth || + optionsChart?.width || + globalOptionsExporting?.sourceWidth || + globalOptionsChart?.width || + themeOptionsExporting?.sourceWidth || + themeOptionsChart?.width || + exportOptions.defaultWidth || + 600; + + // Find the `scale` value: + // - Cannot be lower than 0.1 + // - Cannot be higher than 5.0 + // - Must be rounded to 2 decimal places (e.g. 0.23234 -> 0.23) + const scale = roundNumber( + Math.max( + 0.1, + Math.min( + exportOptions.scale || + optionsExporting?.scale || + globalOptionsExporting?.scale || + themeOptionsExporting?.scale || + exportOptions.defaultScale || + 1, + 5.0 + ) + ), + 2 + ); + + // Update `height`, `width`, and `scale` information in the `export` options + exportOptions.height = height; + exportOptions.width = width; + exportOptions.scale = scale; - exportOptions.constr = exportOptions.constr || 'chart'; - exportOptions.type = fixType(exportOptions.type, exportOptions.outfile); - if (exportOptions.type === 'svg') { - exportOptions.width = false; + // Get rid of potential `px` and `%` + for (let param of ['height', 'width', 'scale']) { + if (typeof exportOptions[param] === 'string') { + exportOptions[param] = +exportOptions[param].replace(/px|%/gi, ''); + } } +} - // Prepare global and theme options - ['globalOptions', 'themeOptions'].forEach((optionsName) => { +/** + * Handles the execution of custom logic options, including loading `resources`, + * `customCode`, and `callback`. If code execution is allowed, it processes + * the custom logic options accordingly. If code execution is not allowed, + * it disables the usage of resources, custom code and callback. + * + * @function _handleCustomLogic + * + * @param {Object} customLogicOptions - The configuration object containing + * `customLogic` options. + * + * @throws {ExportError} Throws an `ExportError` if code execution + * is not allowed but custom logic options are still provided. + */ +function _handleCustomLogic(customLogicOptions) { + // In case of allowing code execution + if (customLogicOptions.allowCodeExecution) { + // Process the `resources` option try { - if (exportOptions && exportOptions[optionsName]) { - if ( - typeof exportOptions[optionsName] === 'string' && - exportOptions[optionsName].endsWith('.json') - ) { - exportOptions[optionsName] = isCorrectJSON( - readFileSync(exportOptions[optionsName], 'utf8'), - true - ); - } else { - exportOptions[optionsName] = isCorrectJSON( - exportOptions[optionsName], - true - ); - } - } + // Try to handle resources + customLogicOptions.resources = _handleResources( + customLogicOptions.resources, + customLogicOptions.allowFileResources, + true + ); } catch (error) { - exportOptions[optionsName] = {}; - logWithStack(2, error, `[chart] The '${optionsName}' cannot be loaded.`); + log(2, '[chart] The `resources` cannot be loaded.'); + + // In case of an error, set the option with null + customLogicOptions.resources = null; } - }); - // Prepare the customCode - if (customLogicOptions.allowCodeExecution) { + // Process the `customCode` option try { - customLogicOptions.customCode = wrapAround( + // Try to load custom code and wrap around it in a self invoking function + customLogicOptions.customCode = _handleCustomCode( customLogicOptions.customCode, customLogicOptions.allowFileResources ); } catch (error) { - logWithStack(2, error, `[chart] The 'customCode' cannot be loaded.`); - } - } + logWithStack(2, error, '[chart] The `customCode` cannot be loaded.'); - // Get the callback - if ( - customLogicOptions && - customLogicOptions.callback && - customLogicOptions.callback?.indexOf('{') < 0 - ) { - // The allowFileResources is always set to false for HTTP requests to avoid - // injecting arbitrary files from the fs - if (customLogicOptions.allowFileResources) { - try { - customLogicOptions.callback = readFileSync( - customLogicOptions.callback, - 'utf8' - ); - } catch (error) { - customLogicOptions.callback = false; - logWithStack(2, error, `[chart] The 'callback' cannot be loaded.`); - } - } else { - customLogicOptions.callback = false; + // In case of an error, set the option with null + customLogicOptions.customCode = null; } - } - // Size search - options.export = { - ...options.export, - ...findChartSize(options) - }; + // Process the `callback` option + try { + // Try to load callback function + customLogicOptions.callback = _handleCustomCode( + customLogicOptions.callback, + customLogicOptions.allowFileResources, + true + ); + } catch (error) { + logWithStack(2, error, '[chart] The `callback` cannot be loaded.'); - // Post the work to the pool - try { - const result = await postWork( - exportOptions.strInj || chartJson || svg, - options - ); - return endCallback(false, result); - } catch (error) { - return endCallback(error); - } -}; + // In case of an error, set the option with null + customLogicOptions.callback = null; + } -/** - * Performs a direct inject of options before export. The function attempts - * to stringify the provided options and removes unnecessary characters, - * ensuring a clean and formatted input. The resulting string is saved as - * a "stright inject" string in the export options. It then invokes the - * doExport function with the updated options. - * - * IMPORTANT: Dangerous and must be used deliberately by someone who sets up - * a server (see the --allowCodeExecution option). - * - * @param {Object} options - The export options containing the input - * to be injected. - * @param {function} endCallback - The callback function to be invoked - * at the end of the process. - * - * @returns {Promise} A Promise that resolves with the result of the export - * operation or rejects with an error if any issues occur during the process. - */ -const doStraightInject = (options, endCallback) => { - try { - let strInj; - let instr = options.export.instr || options.export.options; - - if (typeof instr !== 'string') { - // Try to stringify options - strInj = instr = optionsStringify( - instr, - options.customLogic?.allowCodeExecution - ); + // Check if there is the `customCode` present + if ([null, undefined].includes(customLogicOptions.customCode)) { + log(3, '[chart] No value for the `customCode` option found.'); } - strInj = instr.replaceAll(/\t|\n|\r/g, '').trim(); - // Get rid of the ; - if (strInj[strInj.length - 1] === ';') { - strInj = strInj.substring(0, strInj.length - 1); + // Check if there is the `callback` present + if ([null, undefined].includes(customLogicOptions.callback)) { + log(3, '[chart] No value for the `callback` option found.'); } - // Save as stright inject string - options.export.strInj = strInj; - return doExport(options, false, endCallback); - } catch (error) { - return endCallback( - new ExportError( - `[chart] Malformed input detected for ${options.export?.requestId || '?'}. Please make sure that your JSON/JavaScript options are sent using the "options" attribute, and that if you're using SVG, it is unescaped.` - ).setError(error) - ); + // Check if there is the `resources` present + if ([null, undefined].includes(customLogicOptions.resources)) { + log(3, '[chart] No value for the `resources` option found.'); + } + } else { + // If the `allowCodeExecution` flag is set to false, we should refuse + // the usage of the `callback`, `resources`, and `customCode` options. + // Additionally, the worker will refuse to run arbitrary JavaScript. + if ( + customLogicOptions.callback || + customLogicOptions.resources || + customLogicOptions.customCode + ) { + // Reset all custom code options + customLogicOptions.callback = null; + customLogicOptions.resources = null; + customLogicOptions.customCode = null; + + // Send a message saying that the exporter does not support these settings + throw new ExportError( + `[chart] The 'callback', 'resources', and 'customCode' options have been disabled for this server.`, + 403 + ); + } } -}; +} /** - * Exports a string based on the provided options and invokes an end callback. + * Handles and validates resources from the `resources` option for export. * - * @param {string} stringToExport - The string content to be exported. - * @param {Object} options - Export options, including customLogic with - * allowCodeExecution flag. - * @param {Function} endCallback - Callback function to be invoked at the end - * of the export process. + * @function _handleResources * - * @returns {any} Result of the export process or an error if encountered. + * @param {(Object|string|null)} [resources=null] - The resources to be handled. + * Can be either a JSON object, stringified JSON, a path to a JSON file, + * or null. The default value is `null`. + * @param {boolean} allowFileResources - A flag indicating whether loading + * resources from files is allowed. + * @param {boolean} allowCodeExecution - A flag indicating whether code + * execution is allowed. + * + * @returns {(Object|null)} The handled resources or null if no valid resources + * are found. */ -const exportAsString = (stringToExport, options, endCallback) => { - const { allowCodeExecution } = options.customLogic; +function _handleResources( + resources = null, + allowFileResources, + allowCodeExecution +) { + let handledResources = resources; - // Check if it is SVG + // If no resources found, try to load the default resources + if (!handledResources) { + resources = 'resources.json'; + } + + // List of allowed sections in the resources JSON + const allowedProps = ['js', 'css', 'files']; + + // A flag that decides based to return resources or `null` + let correctResources = false; + + // Try to load resources from a file if ( - stringToExport.indexOf('= 0 || - stringToExport.indexOf('= 0 + allowFileResources && + typeof resources === 'string' && + resources.endsWith('.json') ) { - log(4, '[chart] Parsing input as SVG.'); - return doExport(options, false, endCallback, stringToExport); + handledResources = isAllowedConfig( + readFileSync(getAbsolutePath(resources), 'utf8'), + false, + allowCodeExecution + ); + } else { + // Try to get JSON + handledResources = isAllowedConfig(resources, false, allowCodeExecution); + + // Get rid of the files section + if (handledResources && !allowFileResources) { + delete handledResources.files; + } } - try { - // Try to parse to JSON and call the doExport function - const chartJSON = JSON.parse(stringToExport.replaceAll(/\t|\n|\r/g, ' ')); + // Filter from unnecessary properties + for (const propName in handledResources) { + if (!allowedProps.includes(propName)) { + delete handledResources[propName]; + } else if (!correctResources) { + correctResources = true; + } + } - // If a correct JSON, do the export - return doExport(options, chartJSON, endCallback); - } catch (error) { - // Not a valid JSON - if (toBoolean(allowCodeExecution)) { - return doStraightInject(options, endCallback); - } else { - // Do not allow straight injection without the allowCodeExecution flag - return endCallback( - new ExportError( - '[chart] Only JSON configurations and SVG are allowed for this server. If this is your server, JavaScript custom code can be enabled by starting the server with the --allowCodeExecution flag.' - ).setError(error) - ); + // Check if at least one of allowed properties is present + if (!correctResources) { + return null; + } + + // Handle files section + if (handledResources.files) { + handledResources.files = handledResources.files.map((item) => item.trim()); + if (!handledResources.files || handledResources.files.length <= 0) { + delete handledResources.files; } } -}; + + // Return resources + return handledResources; +} /** - * Retrieves and returns the current status of code execution permission. + * Handles custom code to execute it safely. + * + * @function _handleCustomCode + * + * @param {string} customCode - The custom code to be wrapped. + * @param {boolean} allowFileResources - Flag to allow loading code from a file. + * @param {boolean} [isCallback=false] - Flag that indicates the returned code + * must be in a callback format. * - * @returns {any} The value of allowCodeExecution. + * @returns {(string|null)} The wrapped custom code or null if wrapping fails. */ -export const getAllowCodeExecution = () => allowCodeExecution; +function _handleCustomCode(customCode, allowFileResources, isCallback = false) { + if (customCode && typeof customCode === 'string') { + customCode = customCode.trim(); + + if (customCode.endsWith('.js')) { + // Load a file if the file resources are allowed + return allowFileResources + ? _handleCustomCode( + readFileSync(getAbsolutePath(customCode), 'utf8'), + allowFileResources, + isCallback + ) + : null; + } else if ( + !isCallback && + (customCode.startsWith('function()') || + customCode.startsWith('function ()') || + customCode.startsWith('()=>') || + customCode.startsWith('() =>')) + ) { + // Treat a function as a self-invoking expression + return `(${customCode})()`; + } + + // Or return as a stringified code + return customCode.replace(/;$/, ''); + } +} /** - * Sets the code execution permission based on the provided boolean value. + * Handles the loading and validation of the `globalOptions` and `themeOptions` + * in the export options. If the option is a string and references a JSON file + * (when the `allowFileResources` is `true`), it reads and parses the file. + * Otherwise, it attempts to parse the string or object as JSON. If any errors + * occur during this process, the option is set to `null`. If there is an error + * loading or parsing the `globalOptions` or `themeOptions`, the error is logged + * and the option is set to `null`. + * + * @function _handleGlobalAndTheme * - * @param {any} value - The value to be converted and assigned - * to allowCodeExecution. + * @param {Object} exportOptions - The configuration object containing `export` + * options. + * @param {Object} customLogicOptions - The configuration object containing + * `customLogic` options. */ -export const setAllowCodeExecution = (value) => { - allowCodeExecution = toBoolean(value); -}; +function _handleGlobalAndTheme(exportOptions, customLogicOptions) { + // Get the `allowFileResources` and `allowCodeExecution` flags + const { allowFileResources, allowCodeExecution } = customLogicOptions; + + // Check the `globalOptions` and `themeOptions` options + ['globalOptions', 'themeOptions'].forEach((optionsName) => { + try { + // Check if the option exists + if (exportOptions[optionsName]) { + // Check if it is a string and a file name with the `.json` extension + if ( + allowFileResources && + typeof exportOptions[optionsName] === 'string' && + exportOptions[optionsName].endsWith('.json') + ) { + // Check if the file content can be a config, and save it as a string + exportOptions[optionsName] = isAllowedConfig( + readFileSync(getAbsolutePath(exportOptions[optionsName]), 'utf8'), + true, + allowCodeExecution + ); + } else { + // Check if the value can be a config, and save it as a string + exportOptions[optionsName] = isAllowedConfig( + exportOptions[optionsName], + true, + allowCodeExecution + ); + } + } + } catch (error) { + logWithStack( + 2, + error, + `[chart] The \`${optionsName}\` cannot be loaded.` + ); + + // In case of an error, set the option with null + exportOptions[optionsName] = null; + } + }); + + // Check if there is the `globalOptions` present + if ([null, undefined].includes(exportOptions.globalOptions)) { + log(3, '[chart] No value for the `globalOptions` option found.'); + } + + // Check if there is the `themeOptions` present + if ([null, undefined].includes(exportOptions.themeOptions)) { + log(3, '[chart] No value for the `themeOptions` option found.'); + } +} + +/** + * Validates the size of the data for the export process against a fixed limit + * of 100MB. + * + * @function _checkDataSize + * + * @param {Object} imageOptions - The data object, which includes options from + * the `export` and `customLogic` sections and will be sent to a Puppeteer page. + * + * @throws {ExportError} Throws an `ExportError` if the size of the data for + * the export process object exceeds the 100MB limit. + */ +function _checkDataSize(imageOptions) { + // Set the fixed data limit (100MB) for the dev-tools protocol + const dataLimit = 100 * 1024 * 1024; + + // Get the size of the data + const totalSize = Buffer.byteLength(JSON.stringify(imageOptions), 'utf-8'); + + // Log the size in MB + log( + 3, + `[chart] The current total size of the data for the export process is around ${( + totalSize / + (1024 * 1024) + ).toFixed(2)}MB.` + ); + + // Check the size of data before passing to a page + if (totalSize >= dataLimit) { + throw new ExportError( + `[chart] The data for the export process exceeds 100MB limit.` + ); + } +} export default { - batchExport, singleExport, - getAllowCodeExecution, - setAllowCodeExecution, + batchExport, startExport, - findChartSize + getAllowCodeExecution, + setAllowCodeExecution }; diff --git a/lib/config.js b/lib/config.js index 36b9243e..a7c1149a 100644 --- a/lib/config.js +++ b/lib/config.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,285 +12,396 @@ See LICENSE file in root for details. *******************************************************************************/ -import { existsSync, readFileSync, promises as fsPromises } from 'fs'; +/** + * @overview This module manages configuration for the Highcharts Export Server + * by loading and merging options from multiple sources, such as the default + * settings, environment variables, user-provided options, and command-line + * arguments. Ensures the global options are up-to-date with the highest + * priority values. Provides functions for accessing and updating configuration. + */ -import prompts from 'prompts'; +import { readFileSync } from 'fs'; -import { - absoluteProps, - defaultConfig, - nestedArgs, - promptsConfig -} from './schemas/config.js'; import { envs } from './envs.js'; import { log, logWithStack } from './logger.js'; -import { deepCopy, isObject, printUsage, toBoolean } from './utils.js'; +import { deepCopy, getAbsolutePath, isObject } from './utils.js'; + +import defaultConfig from './schemas/config.js'; + +// Sets the global options with initial values from the default config +const globalOptions = _initOptions(defaultConfig); + +// Properties nesting level of all options +const nestedProps = _createNestedProps(defaultConfig); -let generalOptions = {}; +// Properties names that should not be recursively merged +const absoluteProps = _createAbsoluteProps(defaultConfig); /** - * Retrieves and returns the general options for the export process. + * Retrieves a copy of the global options object or an original global options + * object, based on the `getCopy` flag. * - * @returns {Object} The general options object. + * @function getOptions + * + * @param {boolean} [getCopy=true] - Specifies whether to return a copied + * object of the global options (`true`) or a reference to the global options + * object (`false`). The default value is `true`. + * + * @returns {Object} A copy of the global options object, or a reference + * to the global options object. */ -export const getOptions = () => generalOptions; +export function getOptions(getCopy = true) { + // Return a copy or an original global options object + return getCopy ? deepCopy(globalOptions) : globalOptions; +} /** - * Initializes and sets the general options for the server instace, keeping - * the principle of the options load priority. It accepts optional userOptions - * and args from the CLI. + * Updates and returns the global options object or a copy of the global options + * object, based on the `getCopy` flag. + * + * @function updateOptions * - * @param {Object} userOptions - User-provided options for customization. - * @param {Array} args - Command-line arguments for additional configuration - * (CLI usage). + * @param {Object} newOptions - An object containing the new options to be + * merged into the global options. + * @param {boolean} [getCopy=false] - Determines whether to merge the new + * options into a copy of the global options object (`true`) or directly into + * the global options object (`false`). The default value is `false`. * - * @returns {Object} The updated general options object. + * @returns {Object} The updated options object, either the modified global + * options or a modified copy, based on the value of `getCopy`. */ -export const setOptions = (userOptions, args) => { - // Only for the CLI usage - if (args?.length) { - // Get the additional options from the custom JSON file - generalOptions = loadConfigFile(args); - } +export function updateOptions(newOptions, getCopy = false) { + // Merge new options to the global options or its copy and return the result + return _mergeOptions(getOptions(getCopy), newOptions); +} - // Update the default config with a correct option values - updateDefaultConfig(defaultConfig, generalOptions); +/** + * Updates and returns the global options object with values provided through + * the CLI, keeping the principle of options load priority. The function accepts + * a `cliArgs` array containing arguments from the CLI, which will be validated + * and applied if provided. + * + * The function prioritizes values in the following order: + * + * 1. Values from the command line interface (CLI). + * 2. Values from a custom JSON file (loaded by the `--loadConfig` option). + * + * @function setCliOptions + * + * @param {Array} cliArgs - An array of command line arguments used + * for additional configuration. + * + * @returns {Object} The updated global options object, reflecting the merged + * configuration from sources provided through the CLI. + */ +export function setCliOptions(cliArgs) { + // Only for the CLI usage + if (cliArgs && Array.isArray(cliArgs) && cliArgs.length) { + // Get options from the custom JSON loaded via the `--loadConfig` + const configOptions = _loadConfigFile(cliArgs); - // Set values for server's options and returns them - generalOptions = initOptions(defaultConfig); + // Update global options with the values from the `configOptions` object + updateOptions(configOptions); - // Apply user options if there are any - if (userOptions) { - // Merge user options - generalOptions = mergeConfigOptions( - generalOptions, - userOptions, - absoluteProps - ); - } + // Get options from the CLI + const cliOptions = _pairArgumentValue(cliArgs); - // Only for the CLI usage - if (args?.length) { - // Pair provided arguments - generalOptions = pairArgumentValue(generalOptions, args, defaultConfig); + // Update global options with the values from the `cliOptions` object + updateOptions(cliOptions); } - // Return final general options - return generalOptions; -}; + // Return reference to the global options + return getOptions(false); +} /** - * Allows manual configuration based on specified prompts and saves - * the configuration to a file. + * Maps old-structured configuration options (PhantomJS-based) to a new format + * (Puppeteer-based). This function converts flat, old-structured options into + * a new, nested configuration format based on a predefined mapping provided + * in the `nestedProps` object. The new format is used for Puppeteer, while + * the old format was used for PhantomJS. + * + * @function mapToNewOptions * - * @param {string} configFileName - The name of the configuration file. + * @param {Object} oldOptions - The old, flat configuration options + * to be converted. * - * @returns {Promise} A Promise that resolves to true once the manual - * configuration is completed and saved. + * @returns {Object} A new object containing options structured according + * to the mapping defined in the `nestedProps` object or an empty object + * if the provided `oldOptions` is not a correct object. */ -export const manualConfig = async (configFileName) => { - // Prepare a config object - let configFile = {}; - - // Check if provided config file exists - if (existsSync(configFileName)) { - configFile = JSON.parse(readFileSync(configFileName, 'utf8')); - } +export function mapToNewOptions(oldOptions) { + // An object for the new structured options + const newOptions = {}; - // Question about a configuration category - const onSubmit = async (p, categories) => { - let questionsCounter = 0; - let allQuestions = []; - - // Create a corresponding property in the manualConfig object - for (const section of categories) { - // Mark each option with a section - promptsConfig[section] = promptsConfig[section].map((option) => ({ - ...option, - section - })); - - // Collect the questions - allQuestions = [...allQuestions, ...promptsConfig[section]]; + // Check if provided value is a correct object + if (isObject(oldOptions)) { + // Iterate over each key-value pair in the old-structured options + for (const [key, value] of Object.entries(oldOptions)) { + // If there is a nested mapping, split it into a properties chain + const propertiesChain = nestedProps[key] + ? nestedProps[key].split('.') + : []; + + // If it is the last property in the chain, assign the value, otherwise, + // create or reuse the nested object + propertiesChain.reduce( + (obj, prop, index) => + (obj[prop] = + propertiesChain.length - 1 === index ? value : obj[prop] || {}), + newOptions + ); } + } else { + log( + 2, + '[config] No correct object with options was provided. Returning an empty object.' + ); + } - await prompts(allQuestions, { - onSubmit: async (prompt, answer) => { - // Get the default module scripts - if (prompt.name === 'moduleScripts') { - answer = answer.length - ? answer.map((module) => prompt.choices[module]) - : prompt.choices; - - configFile[prompt.section][prompt.name] = answer; - } else { - configFile[prompt.section] = recursiveProps( - Object.assign({}, configFile[prompt.section] || {}), - prompt.name.split('.'), - prompt.choices ? prompt.choices[answer] : answer - ); - } + // Return the new, structured options object + return newOptions; +} - if (++questionsCounter === allQuestions.length) { - try { - await fsPromises.writeFile( - configFileName, - JSON.stringify(configFile, null, 2), - 'utf8' - ); - } catch (error) { - logWithStack( - 1, - error, - `[config] An error occurred while creating the ${configFileName} file.` - ); - } - return true; - } - } - }); +/** + * Validates, parses, and checks if the provided config is allowed set + * of options. + * + * @function isAllowedConfig + * + * @param {unknown} config - The config to be validated and parsed as a set + * of options. Must be either an object or a string. + * @param {boolean} [toString=false] - Whether to return a stringified version + * of the parsed config. The default value is `false`. + * @param {boolean} [allowFunctions=false] - Whether to allow functions + * in the parsed config. If `true`, functions are preserved. Otherwise, when + * a function is found, `null` is returned. The default value is `false`. + * + * @returns {(Object|string|null)} Returns a parsed set of options object, + * a stringified set of options object if the `toString` is `true`, and `null` + * if the config is not a valid set of options or parsing fails. + */ +export function isAllowedConfig( + config, + toString = false, + allowFunctions = false +) { + try { + // Accept only objects and strings + if (!isObject(config) && typeof config !== 'string') { + // Return `null` if any other type + return null; + } - return true; - }; + // Get the object representation of the original config + const objectConfig = + typeof config === 'string' + ? allowFunctions + ? eval(`(${config})`) + : JSON.parse(config) + : config; + + // Preserve or remove potential functions based on the `allowFunctions` flag + const stringifiedOptions = _optionsStringify( + objectConfig, + allowFunctions, + false + ); - // Find the categories - const choices = Object.keys(promptsConfig).map((choice) => ({ - title: `${choice} options`, - value: choice - })); - - // Category prompt - return prompts( - { - type: 'multiselect', - name: 'category', - message: 'Which category do you want to configure?', - hint: 'Space: Select specific, A: Select all, Enter: Confirm.', - instructions: '', - choices - }, - { onSubmit } - ); -}; + // Parse the config to check if it is valid set of options + const parsedOptions = allowFunctions + ? JSON.parse( + _optionsStringify(objectConfig, allowFunctions, true), + (_, value) => + typeof value === 'string' && value.startsWith('function') + ? eval(`(${value})`) + : value + ) + : JSON.parse(stringifiedOptions); + + // Return stringified or object options based on the `toString` flag + return toString ? stringifiedOptions : parsedOptions; + } catch (error) { + // Return `null` if parsing fails + return null; + } +} /** - * Maps old-structured (PhantomJS) options to a new configuration format - * (Puppeteer). + * Initializes and returns the global options object based on the provided + * configuration, setting values from nested properties recursively. * - * @param {Object} oldOptions - Old-structured options to be mapped. + * The function prioritizes values in the following order: * - * @returns {Object} New options structured based on the defined nestedArgs - * mapping. + * 1. Values from environment variables (specified in the `.env` file). + * 2. Values from the `./lib/schemas/config.js` file (defaults). + * + * @function _initOptions + * + * @param {Object} config - The configuration object used for initializing + * the global options. It should include nested properties with a `value` + * and an `envLink` for linking to environment variables. + * + * @returns {Object} The initialized global options object, populated with + * values based on the provided configuration and the established priority + * order. */ -export const mapToNewConfig = (oldOptions) => { - const newOptions = {}; - // Cycle through old-structured options - for (const [key, value] of Object.entries(oldOptions)) { - const propertiesChain = nestedArgs[key] ? nestedArgs[key].split('.') : []; - - // Populate object in correct properties levels - propertiesChain.reduce( - (obj, prop, index) => - (obj[prop] = - propertiesChain.length - 1 === index ? value : obj[prop] || {}), - newOptions - ); +function _initOptions(config) { + // Init the object for options + const options = {}; + + // Start initializing the `options` object recursively + for (const [name, item] of Object.entries(config)) { + if (Object.prototype.hasOwnProperty.call(item, 'value')) { + // Set the correct value based on the established priority order + if (envs[item.envLink] !== undefined && envs[item.envLink] !== null) { + // The environment variables value + options[name] = envs[item.envLink]; + } else { + // The value from the config file + options[name] = item.value; + } + } else { + // Create a category of options in the `options` object + options[name] = _initOptions(item); + } } - return newOptions; -}; + + // Return the created `options` object + return options; +} /** - * Merges two sets of configuration options, considering absolute properties. + * Recursively merges two sets of configuration options, taking into account + * properties specified in the `absoluteProps` array that require absolute + * merging. The `originalOptions` object will be extended with options from + * the `newOptions` object. + * + * @function _mergeOptions * - * @param {Object} options - Original configuration options. - * @param {Object} newOptions - New configuration options to be merged. - * @param {Array} absoluteProps - List of properties that should - * not be recursively merged. + * @param {Object} originalOptions - The original configuration options object + * to be extended. + * @param {Object} newOptions - The new configuration options object to merge. * - * @returns {Object} Merged configuration options. + * @returns {Object} The extended `originalOptions` object. */ -export const mergeConfigOptions = (options, newOptions, absoluteProps = []) => { - const mergedOptions = deepCopy(options); - - for (const [key, value] of Object.entries(newOptions)) { - mergedOptions[key] = - isObject(value) && - !absoluteProps.includes(key) && - mergedOptions[key] !== undefined - ? mergeConfigOptions(mergedOptions[key], value, absoluteProps) - : value !== undefined - ? value - : mergedOptions[key]; +function _mergeOptions(originalOptions, newOptions) { + // Check if the `originalOptions` and `newOptions` are correct objects + if (isObject(originalOptions) && isObject(newOptions)) { + for (const [key, value] of Object.entries(newOptions)) { + originalOptions[key] = + isObject(value) && + !absoluteProps.includes(key) && + originalOptions[key] !== undefined + ? _mergeOptions(originalOptions[key], value) + : value !== undefined + ? value + : originalOptions[key] || null; + } } - return mergedOptions; -}; + // Return the original (modified or not) options + return originalOptions; +} /** - * Initializes export settings based on provided exportOptions - * and generalOptions. + * Converts the provided options object to a JSON string with the option + * to preserve functions. In order for a function to be preserved, it needs + * to follow the format `function (...) {...}`. Such a function can also + * be stringified. + * + * @function _optionsStringify + * + * @param {Object} options - The options object to be converted to a string. + * @param {boolean} allowFunctions - If set to `true`, functions are preserved + * in the output. Otherwise an error is thrown. + * @param {boolean} stringifyFunctions - If set to `true`, functions are saved + * as strings. The `allowFunctions` must be set to `true` as well for this + * to take an effect. * - * @param {Object} exportOptions - Options specific to the export process. - * @param {Object} generalOptions - General configuration options. + * @returns {string} The JSON-formatted string representing the options. * - * @returns {Object} Initialized export settings. + * @throws {Error} Throws an `Error` when functions are not allowed but are + * found in provided options object. */ -export const initExportSettings = (exportOptions, generalOptions = {}) => { - let options = {}; - - if (exportOptions.svg) { - options = deepCopy(generalOptions); - options.export.type = exportOptions.type || exportOptions.export.type; - options.export.scale = exportOptions.scale || exportOptions.export.scale; - options.export.outfile = - exportOptions.outfile || exportOptions.export.outfile; - options.payload = { - svg: exportOptions.svg - }; - } else { - options = mergeConfigOptions( - generalOptions, - exportOptions, - // Omit going down recursively with the belows - absoluteProps - ); - } +function _optionsStringify(options, allowFunctions, stringifyFunctions) { + const replacerCallback = (_, value) => { + // Trim string values + if (typeof value === 'string') { + value = value.trim(); + } - options.export.outfile = - options.export?.outfile || `chart.${options.export?.type || 'png'}`; - return options; -}; + // If `value` is a function or stringified function + if ( + typeof value === 'function' || + (typeof value === 'string' && + value.startsWith('function') && + value.endsWith('}')) + ) { + // If the `allowFunctions` is set to `true`, preserve functions + if (allowFunctions) { + // Based on the `stringifyFunctions` options, set function values + return stringifyFunctions + ? // As stringified functions + `"EXP_FUN${(value + '').replaceAll(/\s+/g, ' ')}EXP_FUN"` + : // As functions + `EXP_FUN${(value + '').replaceAll(/\s+/g, ' ')}EXP_FUN`; + } else { + // Throw an error otherwise + throw new Error(); + } + } + + // In all other cases, simply return the value + return value; + }; + + // Stringify options and if required, replace special functions marks + return JSON.stringify(options, replacerCallback).replaceAll( + stringifyFunctions ? /\\"EXP_FUN|EXP_FUN\\"/g : /"EXP_FUN|EXP_FUN"/g, + '' + ); +} /** - * Loads additional configuration from a specified file using - * the --loadConfig option. + * Loads additional configuration from a specified file provided via + * the `--loadConfig` option in the command-line arguments. + * + * @function _loadConfigFile * - * @param {Array} args - Command-line arguments to check for - * the --loadConfig option. + * @param {Array} cliArgs - Command-line arguments to search + * for the `--loadConfig` option and the corresponding file path. * - * @returns {Object} Additional configuration loaded from the specified file, - * or an empty object if not found or invalid. + * @returns {Object} The additional configuration loaded from the specified + * file, or an empty object if the file is not found, invalid, or an error + * occurs. */ -function loadConfigFile(args) { - // Check if the --loadConfig option was used - const configIndex = args.findIndex( +function _loadConfigFile(cliArgs) { + // Get the allow flags for the custom logic check + const { allowCodeExecution, allowFileResources } = getOptions().customLogic; + + // Check if the `--loadConfig` option was used + const configIndex = cliArgs.findIndex( (arg) => arg.replace(/-/g, '') === 'loadConfig' ); - // Check if the --loadConfig has a value - if (configIndex > -1 && args[configIndex + 1]) { - const fileName = args[configIndex + 1]; + // Get the `--loadConfig` option value + const configFileName = configIndex > -1 && cliArgs[configIndex + 1]; + + // Check if the `--loadConfig` is present and has a correct value + if (configFileName && allowFileResources) { try { - // Check if an additional config file is a correct JSON file - if (fileName && fileName.endsWith('.json')) { - // Load an optional custom JSON config file - return JSON.parse(readFileSync(fileName)); - } + // Load an optional custom JSON config file + return isAllowedConfig( + readFileSync(getAbsolutePath(configFileName), 'utf8'), + false, + allowCodeExecution + ); } catch (error) { logWithStack( 2, error, - `[config] Unable to load the configuration from the ${fileName} file.` + `[config] Unable to load the configuration from the ${configFileName} file.` ); } } @@ -300,158 +411,136 @@ function loadConfigFile(args) { } /** - * Updates the default configuration object with values from a custom object - * and environment variables. + * Parses command-line arguments and pairs each argument with its corresponding + * option in the configuration. The values are structured into a nested options + * object, based on predefined mappings in the `nestedProps` object. * - * @param {Object} configObj - The default configuration object. - * @param {Object} customObj - Custom configuration object to override defaults. - * @param {string} propChain - Property chain for tracking nested properties - * during recursion. - */ -function updateDefaultConfig(configObj, customObj = {}, propChain = '') { - Object.keys(configObj).forEach((key) => { - const entry = configObj[key]; - const customValue = customObj && customObj[key]; - - if (typeof entry.value === 'undefined') { - updateDefaultConfig(entry, customValue, `${propChain}.${key}`); - } else { - // If a value from a custom JSON exists, it take precedence - if (customValue !== undefined) { - entry.value = customValue; - } - - // If a value from an env variable exists, it take precedence - if (entry.envLink in envs && envs[entry.envLink] !== undefined) { - entry.value = envs[entry.envLink]; - } - } - }); -} - -/** - * Initializes options object based on provided items, setting values from - * nested properties recursively. + * @function _pairArgumentValue * - * @param {Object} items - Configuration items to be used for initializing - * options. + * @param {Array} cliArgs - An array of command-line arguments + * containing options and their associated values. * - * @returns {Object} Initialized options object. + * @returns {Object} An updated options object where each option from + * the command-line is paired with its value, structured into nested objects + * as defined. */ -function initOptions(items) { - let options = {}; - for (const [name, item] of Object.entries(items)) { - options[name] = Object.prototype.hasOwnProperty.call(item, 'value') - ? item.value - : initOptions(item); - } - return options; -} +function _pairArgumentValue(cliArgs) { + // An empty object to collect and structurize data from the args + const cliOptions = {}; -/** - * Pairs argument values with corresponding options in the configuration, - * updating the options object. - * - * @param {Object} options - Configuration options object to be updated. - * @param {Array} args - Command-line arguments containing values for specific - * options. - * @param {Object} defaultConfig - Default configuration object for reference. - * - * @returns {Object} Updated options object. - */ -function pairArgumentValue(options, args, defaultConfig) { - let showUsage = false; - for (let i = 0; i < args.length; i++) { - const option = args[i].replace(/-/g, ''); + // Cycle through all CLI args and filter them + for (let i = 0; i < cliArgs.length; i++) { + const option = cliArgs[i].replace(/-/g, ''); // Find the right place for property's value - const propertiesChain = nestedArgs[option] - ? nestedArgs[option].split('.') + const propertiesChain = nestedProps[option] + ? nestedProps[option].split('.') : []; - // Get the correct type for CLI args which are passed as strings - let argumentType; + // Create options object with values from CLI for later parsing and merging propertiesChain.reduce((obj, prop, index) => { if (propertiesChain.length - 1 === index) { - argumentType = obj[prop].type; - } - return obj[prop]; - }, defaultConfig); - - propertiesChain.reduce((obj, prop, index) => { - if (propertiesChain.length - 1 === index) { - // Finds an option and set a corresponding value - if (typeof obj[prop] !== 'undefined') { - if (args[++i]) { - if (argumentType === 'boolean') { - obj[prop] = toBoolean(args[i]); - } else if (argumentType === 'number') { - obj[prop] = +args[i]; - } else if (argumentType.indexOf(']') >= 0) { - obj[prop] = args[i].split(','); - } else { - obj[prop] = args[i]; - } - } else { - log( - 2, - `[config] Missing value for the '${option}' argument. Using the default value.` - ); - showUsage = true; - } + const value = cliArgs[++i]; + if (!value) { + log( + 2, + `[config] Missing value for the CLI '--${option}' argument. Using the default value.` + ); } + obj[prop] = value || null; + } else if (obj[prop] === undefined) { + obj[prop] = {}; } return obj[prop]; - }, options); - } - - // Display the usage for the reference if needed - if (showUsage) { - printUsage(defaultConfig); + }, cliOptions); } - return options; + // Return parsed CLI options + return cliOptions; } /** - * Recursively updates properties in an object based on nested names and assigns - * the final value. + * Recursively generates a mapping of nested argument chains from a nested + * config object. This function traverses a nested object and creates a mapping + * where each key is an argument name (either from `cliName`, `legacyName`, + * or the original key) and each value is a string representing the chain + * of nested properties leading to that argument. * - * @param {Object} objectToUpdate - The object to be updated. - * @param {Array} nestedNames - Array of nested property names. - * @param {any} value - The final value to be assigned. + * @function _createNestedProps * - * @returns {Object} Updated object with assigned values. + * @param {Object} config - The configuration object. + * @param {Object} [nestedProps={}] - The accumulator object for storing + * the resulting arguments chains. The default value is an empty object. + * @param {string} [propChain=''] - The current chain of nested properties, + * used internally during recursion. The default value is an empty string. + * + * @returns {Object} An object mapping argument names to their corresponding + * nested property chains. */ -function recursiveProps(objectToUpdate, nestedNames, value) { - while (nestedNames.length > 1) { - const propName = nestedNames.shift(); +function _createNestedProps(config, nestedProps = {}, propChain = '') { + Object.keys(config).forEach((key) => { + // Get the specific section + const entry = config[key]; - // Create a property in object if it doesn't exist - if (!Object.prototype.hasOwnProperty.call(objectToUpdate, propName)) { - objectToUpdate[propName] = {}; + // Check if there is still more depth to traverse + if (typeof entry.value === 'undefined') { + // Recurse into deeper levels of nested arguments + _createNestedProps(entry, nestedProps, `${propChain}.${key}`); + } else { + // Create the chain of nested arguments + nestedProps[entry.cliName || key] = `${propChain}.${key}`.substring(1); + + // Support for the legacy, PhantomJS properties names + if (entry.legacyName !== undefined) { + nestedProps[entry.legacyName] = `${propChain}.${key}`.substring(1); + } } + }); - // Call function again if there still names to go - objectToUpdate[propName] = recursiveProps( - Object.assign({}, objectToUpdate[propName]), - nestedNames, - value - ); + // Return the object with nested argument chains + return nestedProps; +} - return objectToUpdate; - } +/** + * Recursively gathers the names of properties from a configuration object that + * should be treated as absolute properties. These properties have values that + * are objects and do not contain further nested depth when merging an object + * containing these options. + * + * @function _createAbsoluteProps + * + * @param {Object} config - The configuration object. + * @param {Array} [absoluteProps=[]] - An array to collect the names + * of absolute properties. The default value is an empty array. + * + * @returns {Array} An array containing the names of absolute + * properties. + */ +function _createAbsoluteProps(config, absoluteProps = []) { + Object.keys(config).forEach((key) => { + // Get the specific section + const entry = config[key]; + + // Check if there is still more depth to traverse + if (typeof entry.types === 'undefined') { + // Recurse into deeper levels + _createAbsoluteProps(entry, absoluteProps); + } else { + // If the option can be an object, save its type in the array + if (entry.types.includes('Object')) { + absoluteProps.push(key); + } + } + }); - // Assign the final value - objectToUpdate[nestedNames[0]] = value; - return objectToUpdate; + // Return the array with the names of absolute properties + return absoluteProps; } export default { getOptions, - setOptions, - manualConfig, - mapToNewConfig, - mergeConfigOptions, - initExportSettings + updateOptions, + setCliOptions, + mapToNewOptions, + isAllowedConfig }; diff --git a/lib/envs.js b/lib/envs.js index 885d7f07..3393aa2a 100644 --- a/lib/envs.js +++ b/lib/envs.js @@ -1,9 +1,22 @@ +/******************************************************************************* + +Highcharts Export Server + +Copyright (c) 2016-2025, Highsoft + +Licenced under the MIT licence. + +Additionally a valid Highcharts license is required for use. + +See LICENSE file in root for details. + +*******************************************************************************/ + /** - * @fileoverview - * This file is responsible for parsing the environment variables with the 'zod' - * library. The parsed environment variables are then exported to be used - * in the application as "envs". We should not use process.env directly - * in the application as these would not be parsed properly. + * @overview This file is responsible for parsing the environment variables + * with the 'zod' library. The parsed environment variables are then exported + * to be used in the application as `envs`. We should not use the `process.env` + * directly in the application as these would not be parsed properly. * * The environment variables are parsed and validated only once when * the application starts. We should write a custom validator or a transformer @@ -13,7 +26,7 @@ import dotenv from 'dotenv'; import { z } from 'zod'; -import { scriptsNames } from './schemas/config.js'; +import defaultConfig from './schemas/config.js'; // Load .env into environment variables dotenv.config(); @@ -96,6 +109,9 @@ const v = { }; export const Config = z.object({ + // puppeteer + PUPPETEER_ARGS: v.string(), + // highcharts HIGHCHARTS_VERSION: z .string() @@ -120,29 +136,55 @@ export const Config = z.object({ }) ) .transform((value) => (value !== '' ? value : undefined)), - HIGHCHARTS_CORE_SCRIPTS: v.array(scriptsNames.core), - HIGHCHARTS_MODULE_SCRIPTS: v.array(scriptsNames.modules), - HIGHCHARTS_INDICATOR_SCRIPTS: v.array(scriptsNames.indicators), HIGHCHARTS_FORCE_FETCH: v.boolean(), HIGHCHARTS_CACHE_PATH: v.string(), HIGHCHARTS_ADMIN_TOKEN: v.string(), + HIGHCHARTS_CORE_SCRIPTS: v.array(defaultConfig.highcharts.coreScripts.value), + HIGHCHARTS_MODULE_SCRIPTS: v.array( + defaultConfig.highcharts.moduleScripts.value + ), + HIGHCHARTS_INDICATOR_SCRIPTS: v.array( + defaultConfig.highcharts.indicatorScripts.value + ), + HIGHCHARTS_CUSTOM_SCRIPTS: v.array( + defaultConfig.highcharts.customScripts.value + ), // export + EXPORT_INFILE: v.string(), + EXPORT_INSTR: v.string(), + EXPORT_OPTIONS: v.string(), + EXPORT_SVG: v.string(), + EXPORT_BATCH: v.string(), + EXPORT_OUTFILE: v.string(), EXPORT_TYPE: v.enum(['jpeg', 'png', 'pdf', 'svg']), EXPORT_CONSTR: v.enum(['chart', 'stockChart', 'mapChart', 'ganttChart']), + EXPORT_B64: v.boolean(), + EXPORT_NO_DOWNLOAD: v.boolean(), + EXPORT_HEIGHT: v.positiveNum(), + EXPORT_WIDTH: v.positiveNum(), + EXPORT_SCALE: v.positiveNum(), EXPORT_DEFAULT_HEIGHT: v.positiveNum(), EXPORT_DEFAULT_WIDTH: v.positiveNum(), EXPORT_DEFAULT_SCALE: v.positiveNum(), + EXPORT_GLOBAL_OPTIONS: v.string(), + EXPORT_THEME_OPTIONS: v.string(), EXPORT_RASTERIZATION_TIMEOUT: v.nonNegativeNum(), // custom CUSTOM_LOGIC_ALLOW_CODE_EXECUTION: v.boolean(), CUSTOM_LOGIC_ALLOW_FILE_RESOURCES: v.boolean(), + CUSTOM_LOGIC_CUSTOM_CODE: v.string(), + CUSTOM_LOGIC_CALLBACK: v.string(), + CUSTOM_LOGIC_RESOURCES: v.string(), + CUSTOM_LOGIC_LOAD_CONFIG: v.string(), + CUSTOM_LOGIC_CREATE_CONFIG: v.string(), // server SERVER_ENABLE: v.boolean(), SERVER_HOST: v.string(), SERVER_PORT: v.positiveNum(), + SERVER_UPLOAD_LIMIT: v.positiveNum(), SERVER_BENCHMARKING: v.boolean(), // server proxy diff --git a/lib/errors/ExportError.js b/lib/errors/ExportError.js index be659551..c024e42e 100644 --- a/lib/errors/ExportError.js +++ b/lib/errors/ExportError.js @@ -1,22 +1,72 @@ +/******************************************************************************* + +Highcharts Export Server + +Copyright (c) 2016-2025, Highsoft + +Licenced under the MIT licence. + +Additionally a valid Highcharts license is required for use. + +See LICENSE file in root for details. + +*******************************************************************************/ + +/** + * A custom error class for handling export-related errors. Extends the native + * `Error` class to include additional properties like status code and stack + * trace details. + */ class ExportError extends Error { - constructor(message) { + /** + * Creates an instance of the `ExportError`. + * + * @param {string} message - The error message to be displayed. + * @param {number} statusCode - Optional HTTP status code associated + * with the error (e.g., 400, 500). + */ + constructor(message, statusCode) { super(); + + // Set the `message` and `stackMessage` with provided message this.message = message; this.stackMessage = message; + + // Set the `statusCode` if provided + if (statusCode) { + this.statusCode = statusCode; + } } + /** + * Sets additional error details based on an existing error object. + * + * @param {Error} error - An error object containing details to populate + * the `ExportError` instance. + * + * @returns {ExportError} The updated instance of the `ExportError` class. + */ setError(error) { + // Save the provided error this.error = error; + + // Set the error's name if present if (error.name) { this.name = error.name; } + + // Set the error's status code if present if (error.statusCode) { this.statusCode = error.statusCode; } + + // Set the error's stack and stack's message if present if (error.stack) { this.stackMessage = error.message; this.stack = error.stack; } + + // Return updated `ExportError` instance return this; } } diff --git a/lib/errors/HttpError.js b/lib/errors/HttpError.js deleted file mode 100644 index b995a4d2..00000000 --- a/lib/errors/HttpError.js +++ /dev/null @@ -1,15 +0,0 @@ -import ExportError from './ExportError.js'; - -class HttpError extends ExportError { - constructor(message, status) { - super(message); - this.status = this.statusCode = status; - } - - setStatus(status) { - this.status = status; - return this; - } -} - -export default HttpError; diff --git a/lib/export.js b/lib/export.js index a3c69b31..e07be658 100644 --- a/lib/export.js +++ b/lib/export.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,26 +12,158 @@ See LICENSE file in root for details. *******************************************************************************/ +/** + * @overview This module handles chart export functionality using Puppeteer. + * It supports exporting charts as SVG, PNG, JPEG, and PDF formats. The module + * manages page resources, sets up the export environment, and processes chart + * configurations or SVG inputs for rendering. Exports to a chart from a page + * using Puppeteer. + */ + import { addPageResources, clearPageResources } from './browser.js'; -import { getCache } from './cache.js'; -import { triggerExport } from './highcharts.js'; +import { createChart } from './highcharts.js'; import { log } from './logger.js'; -import svgTemplate from './../templates/svgExport/svgExport.js'; +import svgTemplate from '../templates/svgExport/svgExport.js'; import ExportError from './errors/ExportError.js'; /** - * Retrieves the clipping region coordinates of the specified page element with - * the id 'chart-container'. + * Exports to a chart from a page using Puppeteer. + * + * @async + * @function puppeteerExport * * @param {Object} page - Puppeteer page object. + * @param {Object} exportOptions - The configuration object containing `export` + * options. + * @param {Object} customLogicOptions - The configuration object containing + * `customLogic` options. * - * @returns {Promise} Promise resolving to an object containing - * x, y, width, and height properties. + * @returns {Promise<(string|Buffer|ExportError)>} A Promise that resolves + * to the exported data or rejecting with an `ExportError`. + * + * @throws {ExportError} Throws an `ExportError` if export to an unsupported + * output format occurs. */ -const getClipRegion = (page) => - page.$eval('#chart-container', (element) => { +export async function puppeteerExport(page, exportOptions, customLogicOptions) { + // Injected resources array (additional JS and CSS) + const injectedResources = []; + + try { + let isSVG = false; + + // Decide on the export method + if (exportOptions.svg) { + log(4, '[export] Treating as SVG input.'); + + // If the `type` is also SVG, return the input + if (exportOptions.type === 'svg') { + return exportOptions.svg; + } + + // Mark as SVG export for the later size corrections + isSVG = true; + + // SVG export + await page.setContent(svgTemplate(exportOptions.svg), { + waitUntil: 'domcontentloaded' + }); + } else { + log(4, '[export] Treating as JSON config.'); + + // Options export + await page.evaluate(createChart, exportOptions, customLogicOptions); + } + + // Keeps track of all resources added on the page with addXXXTag. etc + // It's VITAL that all added resources ends up here so we can clear things + // out when doing a new export in the same page! + injectedResources.push( + ...(await addPageResources(page, customLogicOptions)) + ); + + // Get the real chart size and set the zoom accordingly + const size = await _getChartSize(page, isSVG, exportOptions.scale); + + // Get the clip region for the page + const { x, y } = await _getClipRegion(page); + + // Set final `height` for viewport + const viewportHeight = Math.abs( + Math.ceil(size.chartHeight || exportOptions.height) + ); + + // Set final `width` for viewport + const viewportWidth = Math.abs( + Math.ceil(size.chartWidth || exportOptions.width) + ); + + // Set the final viewport now that we have the real height + await page.setViewport({ + height: viewportHeight, + width: viewportWidth, + deviceScaleFactor: isSVG ? 1 : parseFloat(exportOptions.scale) + }); + + let result; + // Rasterization process + switch (exportOptions.type) { + case 'svg': + result = await _createSVG(page); + break; + case 'png': + case 'jpeg': + result = await _createImage( + page, + exportOptions.type, + { + width: viewportWidth, + height: viewportHeight, + x, + y + }, + exportOptions.rasterizationTimeout + ); + break; + case 'pdf': + result = await _createPDF( + page, + viewportHeight, + viewportWidth, + exportOptions.rasterizationTimeout + ); + break; + default: + throw new ExportError( + `[export] Unsupported output format: ${exportOptions.type}.`, + 400 + ); + } + + // Clear previously injected JS and CSS resources + await clearPageResources(page, injectedResources); + return result; + } catch (error) { + await clearPageResources(page, injectedResources); + return error; + } +} + +/** + * Retrieves the clipping region coordinates of the specified page element + * with the 'chart-container' id. + * + * @async + * @function _getClipRegion + * + * @param {Object} page - Puppeteer page object. + * + * @returns {Promise} A Promise that resolves to an object containing + * `x`, `y`, `width`, and `height` properties. + */ +async function _getClipRegion(page) { + return page.$eval('#chart-container', (element) => { const { x, y, width, height } = element.getBoundingClientRect(); return { x, @@ -40,267 +172,148 @@ const getClipRegion = (page) => height: Math.trunc(height > 1 ? height : 500) }; }); +} + +/** + * Retrieves the real chart dimensions from a Puppeteer page. The function + * behaves differently based on whether the export type is SVG or another + * format. + * + * @async + * @function _getChartSize + * + * @param {Object} page - Puppeteer page object. + * @param {boolean} isSVG - Determines whether the chart being processed + * is an SVG or another format. + * @param {number} scale - The scale factor to be applied to the chart + * dimensions. + * + * @returns {Promise} A Promise that resolves to an object containing + * the actual height and width of the chart after scaling. + */ +async function _getChartSize(page, isSVG, scale) { + // Trigger appropriate function based on the `isSvg` flag to get chart size + return isSVG + ? await page.evaluate((scale) => { + const svgElement = document.querySelector( + '#chart-container svg:first-of-type' + ); + + // Get the values correctly scaled + const chartHeight = svgElement.height.baseVal.value * scale; + const chartWidth = svgElement.width.baseVal.value * scale; + + // In case of SVG the zoom must be set directly for body as scale + // eslint-disable-next-line no-undef + document.body.style.zoom = scale; + + // Set the margin to 0px + // eslint-disable-next-line no-undef + document.body.style.margin = '0px'; + + return { + chartHeight, + chartWidth + }; + }, parseFloat(scale)) + : await page.evaluate(() => { + // eslint-disable-next-line no-undef + const { chartHeight, chartWidth } = window.Highcharts.charts[0]; + + // No need for such scale manipulation in case of other types + // of exports. Reset the zoom for other exports than to SVGs + // eslint-disable-next-line no-undef + document.body.style.zoom = 1; + + return { + chartHeight, + chartWidth + }; + }); +} + +/** + * Creates an SVG by evaluating the `outerHTML` of the first 'svg' element + * inside an element with the id 'container'. + * + * @async + * @function _createSVG + * + * @param {Object} page - Puppeteer page object. + * + * @returns {Promise} A Promise that resolves to the SVG string. + */ +async function _createSVG(page) { + return page.$eval( + '#container svg:first-of-type', + (element) => element.outerHTML + ); +} /** - * Creates an image using Puppeteer's page screenshot functionality with + * Creates an image using Puppeteer's page `screenshot` functionality with * specified options. * + * @async + * @function _createImage + * * @param {Object} page - Puppeteer page object. * @param {string} type - Image type. - * @param {string} encoding - Image encoding. * @param {Object} clip - Clipping region coordinates. * @param {number} rasterizationTimeout - Timeout for rasterization * in milliseconds. * - * @returns {Promise} Promise resolving to the image buffer or rejecting - * with an ExportError for timeout. + * @returns {Promise} A Promise that resolves to the image buffer + * or rejecting with an `ExportError` for timeout. */ -const createImage = (page, type, encoding, clip, rasterizationTimeout) => - Promise.race([ +async function _createImage(page, type, clip, rasterizationTimeout) { + return Promise.race([ page.screenshot({ type, - encoding, clip, - captureBeyondViewport: true, + encoding: 'base64', fullPage: false, optimizeForSpeed: true, + captureBeyondViewport: true, ...(type !== 'png' ? { quality: 80 } : {}), - - // #447, #463 - always render on a transparent page if the expected type - // format is PNG - omitBackground: type == 'png' + // Always render on a transparent page if the expected type format is PNG + omitBackground: type == 'png' // #447, #463 }), new Promise((_resolve, reject) => setTimeout( - () => reject(new ExportError('Rasterization timeout')), + () => reject(new ExportError('Rasterization timeout', 408)), rasterizationTimeout || 1500 ) ) ]); +} /** - * Creates a PDF using Puppeteer's page pdf functionality with specified + * Creates a PDF using Puppeteer's page `pdf` functionality with specified * options. * + * @async + * @function _createPDF + * * @param {Object} page - Puppeteer page object. * @param {number} height - PDF height. * @param {number} width - PDF width. - * @param {string} encoding - PDF encoding. + * @param {number} rasterizationTimeout - Timeout for rasterization + * in milliseconds. * - * @returns {Promise} Promise resolving to the PDF buffer. + * @returns {Promise} A Promise that resolves to the PDF buffer. */ -const createPDF = async ( - page, - height, - width, - encoding, - rasterizationTimeout -) => { +async function _createPDF(page, height, width, rasterizationTimeout) { await page.emulateMediaType('screen'); - return page.pdf({ // This will remove an extra empty page in PDF exports height: height + 1, width, - encoding, + encoding: 'base64', timeout: rasterizationTimeout || 1500 }); -}; +} -/** - * Creates an SVG string by evaluating the outerHTML of the first 'svg' element - * inside an element with the id 'container'. - * - * @param {Object} page - Puppeteer page object. - * - * @returns {Promise} Promise resolving to the SVG string. - */ -const createSVG = (page) => - page.$eval('#container svg:first-of-type', (element) => element.outerHTML); - -/** - * Sets the specified chart and options as configuration into the triggerExport - * function within the window context using page.evaluate. - * - * @param {Object} page - Puppeteer page object. - * @param {any} chart - The chart object to be configured. - * @param {Object} options - Configuration options for the chart. - * - * @returns {Promise} Promise resolving after the configuration is set. - */ -const setAsConfig = async (page, chart, options, displayErrors) => - page.evaluate(triggerExport, chart, options, displayErrors); - -/** - * Exports to a chart from a page using Puppeteer. - * - * @param {Object} page - Puppeteer page object. - * @param {any} chart - The chart object or SVG configuration to be exported. - * @param {Object} options - Export options and configuration. - * - * @returns {Promise} Promise resolving to - * the exported data or rejecting with an ExportError. - */ -export default async (page, chart, options) => { - // Injected resources array (additional JS and CSS) - let injectedResources = []; - - try { - log(4, '[export] Determining export path.'); - - const exportOptions = options.export; - - // Decide whether display error or debbuger wrapper around it - const displayErrors = - exportOptions?.options?.chart?.displayErrors && - getCache().activeManifest.modules.debugger; - - let isSVG; - if ( - chart.indexOf && - (chart.indexOf('= 0 || chart.indexOf('= 0) - ) { - // SVG input handling - log(4, '[export] Treating as SVG.'); - - // If input is also SVG, just return it - if (exportOptions.type === 'svg') { - return chart; - } - - isSVG = true; - await page.setContent(svgTemplate(chart), { - waitUntil: 'domcontentloaded' - }); - } else { - // JSON config handling - log(4, '[export] Treating as config.'); - - // Need to perform straight inject - if (exportOptions.strInj) { - // Injection based configuration export - await setAsConfig( - page, - { - chart: { - height: exportOptions.height, - width: exportOptions.width - } - }, - options, - displayErrors - ); - } else { - // Basic configuration export - chart.chart.height = exportOptions.height; - chart.chart.width = exportOptions.width; - - await setAsConfig(page, chart, options, displayErrors); - } - } - - // Keeps track of all resources added on the page with addXXXTag. etc - // It's VITAL that all added resources ends up here so we can clear things - // out when doing a new export in the same page! - injectedResources = await addPageResources(page, options); - - // Get the real chart size and set the zoom accordingly - const size = isSVG - ? await page.evaluate((scale) => { - const svgElement = document.querySelector( - '#chart-container svg:first-of-type' - ); - - // Get the values correctly scaled - const chartHeight = svgElement.height.baseVal.value * scale; - const chartWidth = svgElement.width.baseVal.value * scale; - - // In case of SVG the zoom must be set directly for body - // Set the zoom as scale - // eslint-disable-next-line no-undef - document.body.style.zoom = scale; - - // Set the margin to 0px - // eslint-disable-next-line no-undef - document.body.style.margin = '0px'; - - return { - chartHeight, - chartWidth - }; - }, parseFloat(exportOptions.scale)) - : await page.evaluate(() => { - // eslint-disable-next-line no-undef - const { chartHeight, chartWidth } = window.Highcharts.charts[0]; - - // No need for such scale manipulation in case of other types of exports - // Reset the zoom for other exports than to SVGs - // eslint-disable-next-line no-undef - document.body.style.zoom = 1; - - return { - chartHeight, - chartWidth - }; - }); - - // Set final height and width for viewport - const viewportHeight = Math.abs( - Math.ceil(size.chartHeight || exportOptions.height) - ); - const viewportWidth = Math.abs( - Math.ceil(size.chartWidth || exportOptions.width) - ); - - // Get the clip region for the page - const { x, y } = await getClipRegion(page); - - // Set the final viewport now that we have the real height - await page.setViewport({ - height: viewportHeight, - width: viewportWidth, - deviceScaleFactor: isSVG ? 1 : parseFloat(exportOptions.scale) - }); - - let data; - // Rasterization process - if (exportOptions.type === 'svg') { - // SVG - data = await createSVG(page); - } else if (['png', 'jpeg'].includes(exportOptions.type)) { - // PNG or JPEG - data = await createImage( - page, - exportOptions.type, - 'base64', - { - width: viewportWidth, - height: viewportHeight, - x, - y - }, - exportOptions.rasterizationTimeout - ); - } else if (exportOptions.type === 'pdf') { - // PDF - data = await createPDF( - page, - viewportHeight, - viewportWidth, - 'base64', - exportOptions.rasterizationTimeout - ); - } else { - throw new ExportError( - `[export] Unsupported output format ${exportOptions.type}.` - ); - } - - // Clear previously injected JS and CSS resources - await clearPageResources(page, injectedResources); - return data; - } catch (error) { - await clearPageResources(page, injectedResources); - return error; - } +export default { + puppeteerExport }; diff --git a/lib/fetch.js b/lib/fetch.js index 25a6b90b..db87f8c1 100644 --- a/lib/fetch.js +++ b/lib/fetch.js @@ -1,49 +1,61 @@ +/******************************************************************************* + +Highcharts Export Server + +Copyright (c) 2016-2025, Highsoft + +Licenced under the MIT licence. + +Additionally a valid Highcharts license is required for use. + +See LICENSE file in root for details. + +*******************************************************************************/ + /** - * This module exports two functions: fetch (for GET requests) and post (for POST requests). + * @overview HTTP utility module for fetching and posting data. Supports both + * HTTP and HTTPS protocols, providing methods to make GET and POST requests + * with customizable options. Includes protocol determination based on URL + * and augments response objects with a 'text' property for easier data access. */ import http from 'http'; import https from 'https'; /** - * Returns the HTTP or HTTPS protocol module based on the provided URL. - * - * @param {string} url - The URL to determine the protocol. + * Sends a GET request to the specified URL using either HTTP or HTTPS protocol. * - * @returns {Object} The HTTP or HTTPS protocol module (http or https). - */ -const getProtocol = (url) => (url.startsWith('https') ? https : http); - -/** - * Fetches data from the specified URL using either HTTP or HTTPS protocol. + * @async + * @function get * - * @param {string} url - The URL to fetch data from. - * @param {Object} requestOptions - Options for the HTTP request (optional). + * @param {string} url - The URL to get data from. + * @param {Object} [requestOptions={}] - Options for the HTTP/HTTPS request. + * The default value is an empty object. * - * @returns {Promise} Promise resolving to the HTTP response object - * with added 'text' property or rejecting with an error. + * @returns {Promise} A Promise that resolves to the HTTP/HTTPS response + * object with added 'text' property or rejecting with an error. */ -async function fetch(url, requestOptions = {}) { +export async function get(url, requestOptions = {}) { return new Promise((resolve, reject) => { - const protocol = getProtocol(url); - - protocol - .get(url, requestOptions, (res) => { - let data = ''; + // Decide on the protocol + _getProtocolModule(url) + .get(url, requestOptions, (response) => { + let responseData = ''; - // A chunk of data has been received. - res.on('data', (chunk) => { - data += chunk; + // A chunk of data has been received + response.on('data', (chunk) => { + responseData += chunk; }); - // The whole response has been received. - res.on('end', () => { - if (!data) { + // The whole response has been received + response.on('end', () => { + if (!responseData) { reject('Nothing was fetched from the URL.'); } - res.text = data; - resolve(res); + // Get the full result and resolve the request + response.text = responseData; + resolve(response); }); }) .on('error', (error) => { @@ -56,20 +68,23 @@ async function fetch(url, requestOptions = {}) { * Sends a POST request to the specified URL with the provided JSON body using * either HTTP or HTTPS protocol. * + * @async + * @function post + * * @param {string} url - The URL to send the POST request to. - * @param {Object} body - The JSON body to include in the POST request - * (optional, default is an empty object). - * @param {Object} requestOptions - Options for the HTTP request (optional). + * @param {Object} [body={}] - The JSON body to include in the POST request. + * The default value is an empty object. + * @param {Object} [requestOptions={}] - Options for the HTTP/HTTPS request. + * The default value is an empty object. * - * @returns {Promise} Promise resolving to the HTTP response object with - * added 'text' property or rejecting with an error. + * @returns {Promise} A Promise that resolves to the HTTP/HTTPS response + * object with added 'text' property or rejecting with an error. */ -async function post(url, body = {}, requestOptions = {}) { +export async function post(url, body = {}, requestOptions = {}) { return new Promise((resolve, reject) => { - const protocol = getProtocol(url); const data = JSON.stringify(body); - // Set default headers and merge with requestOptions + // Set default headers and merge with `requestOptions` const options = Object.assign( { method: 'POST', @@ -81,20 +96,22 @@ async function post(url, body = {}, requestOptions = {}) { requestOptions ); - const req = protocol - .request(url, options, (res) => { + // Decide on the protocol + const request = _getProtocolModule(url) + .request(url, options, (response) => { let responseData = ''; - // A chunk of data has been received. - res.on('data', (chunk) => { + // A chunk of data has been received + response.on('data', (chunk) => { responseData += chunk; }); - // The whole response has been received. - res.on('end', () => { + // The whole response has been received + response.on('end', () => { try { - res.text = responseData; - resolve(res); + // Get the full result and resolve the request + response.text = responseData; + resolve(response); } catch (error) { reject(error); } @@ -104,11 +121,26 @@ async function post(url, body = {}, requestOptions = {}) { reject(error); }); - // Write the request body and end the request. - req.write(data); - req.end(); + // Write the request body and end the request + request.write(data); + request.end(); }); } -export default fetch; -export { fetch, post }; +/** + * Returns the HTTP or HTTPS protocol module based on the provided URL. + * + * @function _getProtocolModule + * + * @param {string} url - The URL to determine the protocol. + * + * @returns {Object} The HTTP or HTTPS protocol module (`http` or `https`). + */ +function _getProtocolModule(url) { + return url.startsWith('https') ? https : http; +} + +export default { + get, + post +}; diff --git a/lib/highcharts.js b/lib/highcharts.js index 57fb58c0..812617f0 100644 --- a/lib/highcharts.js +++ b/lib/highcharts.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,10 +12,20 @@ See LICENSE file in root for details. *******************************************************************************/ +/** + * @overview Provides methods for initializing Highcharts with customized + * animation settings and triggering the creation of Highcharts charts with + * export-specific configurations in the page context. Supports dynamic option + * merging, custom logic injection, and control over rendering behaviors. Used + * by the Puppeteer page. + */ + /* eslint-disable no-undef */ /** - * Setting the animObject. Called when initing the page. + * Setting the `Highcharts.animObject` function. Called when initing the page. + * + * @function setupHighcharts */ export function setupHighcharts() { Highcharts.animObject = function () { @@ -24,38 +34,29 @@ export function setupHighcharts() { } /** - * Creates the actual chart. + * Creates the actual Highcharts chart on a page. + * + * @async + * @function createChart + * + * @param {Object} exportOptions - The configuration object containing `export` + * options. + * @param {Object} customLogicOptions - The configuration object containing + * `customLogic` options. * - * @param {object} chartOptions - The options for the Highcharts chart. - * @param {object} options - The export options. - * @param {boolean} displayErrors - A flag indicating whether to display errors. */ -export async function triggerExport(chartOptions, options, displayErrors) { - // Display errors flag taken from chart options nad debugger module - window._displayErrors = displayErrors; - +export async function createChart(exportOptions, customLogicOptions) { // Get required functions - const { getOptions, merge, setOptions, wrap } = Highcharts; + const { getOptions, setOptions, merge, wrap } = Highcharts; - // Create a separate object for a potential setOptions usages in order to - // prevent from polluting other exports that can happen on the same page + // Create a separate object for a potential `setOptions` usages in order + // to prevent from polluting other exports that can happen on the same page Highcharts.setOptionsObj = merge(false, {}, getOptions()); - // By default animation is disabled - const chart = { - animation: false - }; - - // When straight inject, the size is set through CSS only - if (options.export.strInj) { - chart.height = chartOptions.chart.height; - chart.width = chartOptions.chart.width; - } - // NOTE: Is this used for anything useful? window.isRenderComplete = false; wrap(Highcharts.Chart.prototype, 'init', function (proceed, userOptions, cb) { - // Override userOptions with image friendly options + // Override the `userOptions` with image friendly options userOptions = merge(userOptions, { exporting: { enabled: false @@ -67,7 +68,7 @@ export async function triggerExport(chartOptions, options, displayErrors) { } } }, - /* Expects tooltip in userOptions when forExport is true. + /* Expects tooltip in the `userOptions` when `forExport` is true. https://github.com/highcharts/highcharts/blob/3ad430a353b8056b9e764aa4e5cd6828aa479db2/js/parts/Chart.js#L241 */ tooltip: {} @@ -91,45 +92,81 @@ export async function triggerExport(chartOptions, options, displayErrors) { proceed.apply(this, [chart, options]); }); - // Get the user options - const userOptions = options.export.strInj - ? new Function(`return ${options.export.strInj}`)() - : chartOptions; + // Some mandatory additional `chart` and `exporting` options + const additionalOptions = { + chart: { + // By default animation is disabled + animation: false, + // Get the right size values + height: exportOptions.height, + width: exportOptions.width + }, + exporting: { + // No need for the exporting button + enabled: false + } + }; - // Trigger custom code - if (options.customLogic.customCode) { - new Function('options', options.customLogic.customCode)(userOptions); - } + // Get the input to export from the `instr` option + const userOptions = new Function(`return ${exportOptions.instr}`)(); - // Merge the globalOptions, themeOptions, options from the wrapped - // setOptions function and user options to create the final options object + // Get the `themeOptions` option + const themeOptions = new Function(`return ${exportOptions.themeOptions}`)(); + + // Merge the following options objects to create final options const finalOptions = merge( false, - JSON.parse(options.export.themeOptions), + themeOptions, userOptions, // Placed it here instead in the init because of the size issues - { chart } + additionalOptions ); - const finalCallback = options.customLogic.callback - ? new Function(`return ${options.customLogic.callback}`)() - : undefined; + // Prepare the `callback` option + const finalCallback = customLogicOptions.callback + ? new Function(`return ${customLogicOptions.callback}`)() + : null; + + // Trigger the `customCode` option + if (customLogicOptions.customCode) { + new Function('options', customLogicOptions.customCode)(userOptions); + } + + // Get the `globalOptions` option + const globalOptions = new Function(`return ${exportOptions.globalOptions}`)(); // Set the global options if exist - const globalOptions = JSON.parse(options.export.globalOptions); if (globalOptions) { setOptions(globalOptions); } - let constr = options.export.constr || 'chart'; - constr = typeof Highcharts[constr] !== 'undefined' ? constr : 'chart'; + // Call the chart creation + Highcharts[exportOptions.constr]('container', finalOptions, finalCallback); - Highcharts[constr]('container', finalOptions, finalCallback); + // Get all images from within the chart + const images = Array.from( + document.querySelectorAll('.highcharts-container image') + ); + + // Wait for all images for 2 seconds + await Promise.race([ + Promise.all( + images.map((image) => + image.complete && image.naturalHeight !== 0 + ? Promise.resolve() + : new Promise((resolve) => + image.addEventListener('load', resolve, { once: true }) + ) + ) + ), + // Proceed further even if images did not load + new Promise((resolve) => setTimeout(resolve, 2000)) + ]); // Get the current global options const defaultOptions = getOptions(); - // Clear it just in case (e.g. the setOptions was used in the customCode) + // Clear it just in case (e.g. the `setOptions` was used in the `customCode`) for (const prop in defaultOptions) { if (typeof defaultOptions[prop] !== 'function') { delete defaultOptions[prop]; @@ -142,3 +179,8 @@ export async function triggerExport(chartOptions, options, displayErrors) { // Empty the custom global options object Highcharts.setOptionsObj = {}; } + +export default { + setupHighcharts, + createChart +}; diff --git a/lib/index.js b/lib/index.js index a825e688..07f7efa2 100644 --- a/lib/index.js +++ b/lib/index.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,109 +12,123 @@ See LICENSE file in root for details. *******************************************************************************/ +/** + * @overview This core module initializes and manages the Highcharts Export + * Server. The main `initExport` function handles logging, script caching, + * resource pooling, browser startup, and ensures graceful process cleanup + * on exit. Additionally, it provides API functions for using it as a Node.js + * module, offering functionalities for processing options, configuring + * and performing exports, and setting up server. + */ + import 'colors'; -import { checkAndUpdateCache } from './cache.js'; +import { checkCache } from './cache.js'; import { batchExport, - setAllowCodeExecution, singleExport, - startExport + startExport, + setAllowCodeExecution } from './chart.js'; -import { mapToNewConfig, manualConfig, setOptions } from './config.js'; +import { getOptions, updateOptions, mapToNewOptions } from './config.js'; import { - initLogging, log, logWithStack, - setLogLevel, - enableFileLogging + initLogging, + enableConsoleLogging, + enableFileLogging, + setLogLevel } from './logger.js'; import { initPool, killPool } from './pool.js'; import { shutdownCleanUp } from './resourceRelease.js'; -import server, { startServer } from './server/server.js'; -import { printLogo, printUsage } from './utils.js'; + +import server from './server/server.js'; + +/** + * Initializes the export process. Tasks such as configuring logging, checking + * the cache and sources, and initializing the resource pool occur during this + * stage. + * + * This function must be called before attempting to export charts or set + * up a server. + * + * @async + * @function initExport + * + * @param {Object} initOptions - The `initOptions` object, which may + * be a partial or complete set of options. If the options are partial, missing + * values will default to the current global configuration. + */ +export async function initExport(initOptions) { + // Init and update the instance options object + const options = updateOptions(initOptions); + + // Set the `allowCodeExecution` per export module scope + setAllowCodeExecution(options.customLogic.allowCodeExecution); + + // Init the logging + initLogging(options.logging); + + // Attach process' exit listeners + if (options.other.listenToProcessExits) { + _attachProcessExitListeners(); + } + + // Check the current status of cache + await checkCache(options.highcharts, options.server.proxy); + + // Init the pool + await initPool(options.pool, options.puppeteer.args); +} /** * Attaches exit listeners to the process, ensuring proper cleanup of resources - * and termination on exit signals. Handles 'exit', 'SIGINT', 'SIGTERM', and - * 'uncaughtException' events. + * and termination on exit signals. Handles 'exit', 'SIGINT', 'SIGTERM', + * and 'uncaughtException' events. + * + * @function _attachProcessExitListeners */ -const attachProcessExitListeners = () => { +function _attachProcessExitListeners() { log(3, '[process] Attaching exit listeners to the process.'); // Handler for the 'exit' process.on('exit', (code) => { - log(4, `Process exited with code ${code}.`); + log(4, `[process] Process exited with code: ${code}.`); }); // Handler for the 'SIGINT' process.on('SIGINT', async (name, code) => { - log(4, `The ${name} event with code: ${code}.`); - await shutdownCleanUp(0); + log(4, `[process] The ${name} event with code: ${code}.`); + await shutdownCleanUp(); }); // Handler for the 'SIGTERM' process.on('SIGTERM', async (name, code) => { - log(4, `The ${name} event with code: ${code}.`); - await shutdownCleanUp(0); + log(4, `[process] The ${name} event with code: ${code}.`); + await shutdownCleanUp(); }); // Handler for the 'SIGHUP' process.on('SIGHUP', async (name, code) => { - log(4, `The ${name} event with code: ${code}.`); - await shutdownCleanUp(0); + log(4, `[process] The ${name} event with code: ${code}.`); + await shutdownCleanUp(); }); // Handler for the 'uncaughtException' process.on('uncaughtException', async (error, name) => { - logWithStack(1, error, `The ${name} error.`); + logWithStack(1, error, `[process] The ${name} error.`); await shutdownCleanUp(1); }); -}; - -/** - * Initializes the export process. Tasks such as configuring logging, checking - * cache and sources, and initializing the pool of resources happen during - * this stage. Function that is required to be called before trying to export charts or setting a server. The `options` is an object that contains all options. - * - * @param {Object} options - All export options. - * - * @returns {Promise} Promise resolving to the updated export options. - */ -const initExport = async (options) => { - // Set the allowCodeExecution per export module scope - setAllowCodeExecution( - options.customLogic && options.customLogic.allowCodeExecution - ); - - // Init the logging - initLogging(options.logging); - - // Attach process' exit listeners - if (options.other.listenToProcessExits) { - attachProcessExitListeners(); - } - - // Check if cache needs to be updated - await checkAndUpdateCache(options); - - // Init the pool - await initPool({ - pool: options.pool || { - minWorkers: 1, - maxWorkers: 1 - }, - puppeteerArgs: options.puppeteer.args || [] - }); - - // Return updated options - return options; -}; +} export default { // Server - server, - startServer, + ...server, + + // Options + getOptions, + updateOptions, + mapToNewOptions, // Exporting initExport, @@ -122,23 +136,50 @@ export default { batchExport, startExport, - // Pool - initPool, + // Release killPool, - - // Other - setOptions, shutdownCleanUp, // Logs log, logWithStack, - setLogLevel, - enableFileLogging, - - // Utils - mapToNewConfig, - manualConfig, - printLogo, - printUsage + setLogLevel: function (level) { + // Update the instance options object + const options = updateOptions({ + logging: { + level + } + }); + + // Call the function + setLogLevel(options.logging.level); + }, + enableConsoleLogging: function (toConsole) { + // Update the instance options object + const options = updateOptions({ + logging: { + toConsole + } + }); + + // Call the function + enableConsoleLogging(options.logging.toConsole); + }, + enableFileLogging: function (dest, file, toFile) { + // Update the instance options object + const options = updateOptions({ + logging: { + dest, + file, + toFile + } + }); + + // Call the function + enableFileLogging( + options.logging.dest, + options.logging.file, + options.logging.toFile + ); + } }; diff --git a/lib/info.js b/lib/info.js new file mode 100644 index 00000000..992897c4 --- /dev/null +++ b/lib/info.js @@ -0,0 +1,141 @@ +/******************************************************************************* + +Highcharts Export Server + +Copyright (c) 2016-2025, Highsoft + +Licenced under the MIT licence. + +Additionally a valid Highcharts license is required for use. + +See LICENSE file in root for details. + +*******************************************************************************/ + +/** + * @overview This module provides simple functions for displaying information + * about the Highcharts Export Server, including version details, license + * information, and CLI usage instructions. + */ + +import { readFileSync } from 'fs'; +import { join } from 'path'; + +import { __dirname } from './utils.js'; + +import defaultConfig from './schemas/config.js'; + +/** + * Prints the Highcharts Export Server logo or text with the version and license + * information. + * + * @function printInfo + * + * @param {boolean} [noLogo=false] - If `true`, the text with the version number + * is only printed, without the logo. The default value is `false`. + * @param {boolean} [noLicense=false] - If `true`, the license information will + * not be displayed. The default value is `false`. + */ +export function printInfo(noLogo = false, noLicense = false) { + // Get package version either from `.env` or from `package.json` + const packageVersion = JSON.parse( + readFileSync(join(__dirname, 'package.json'), 'utf8') + ).version; + + // Print text only + if (noLogo) { + console.log(`Highcharts Export Server v${packageVersion}`); + } else { + // Print the logo + console.log( + readFileSync(join(__dirname, 'msg', 'startup.msg'), 'utf8').toString() + .bold.yellow, + `v${packageVersion}\n`.bold + ); + } + + // Print the license information, if needed + if (!noLicense) { + console.log( + 'This software requires a valid Highcharts license for commercial use.\n' + .yellow, + '\nFor a full list of CLI options, type:', + '\nhighcharts-export-server --help\n'.green, + '\nIf you do not have a license, one can be obtained here:', + '\nhttps://shop.highsoft.com/\n'.green, + '\nTo customize your installation, please refer to the README file at:', + '\nhttps://github.com/highcharts/node-export-server#readme\n'.green + ); + } +} + +/** + * Prints usage information for CLI arguments, displaying available options + * and their descriptions. It can list properties recursively if categories + * contain nested options. + * + * @function printUsage + */ +export function printUsage() { + // Display README and general usage information + console.log( + '\nUsage of CLI arguments:'.bold, + '\n-----------------------', + `\nFor more detailed information, visit the README file at: ${'https://github.com/highcharts/node-export-server#readme'.green}.\n` + ); + + // Iterate through each category in the `defaultConfig` and display usage info + Object.keys(defaultConfig).forEach((category) => { + console.log(`${category.toUpperCase()}`.bold.red); + _cycleCategories(defaultConfig[category]); + console.log(''); + }); +} + +/** + * Recursively traverses the options object to print the usage information + * for each option category and individual option. + * + * @function _cycleCategories + * + * @param {Object} options - The options object containing CLI options. It may + * include nested categories and individual options. + */ +function _cycleCategories(options) { + for (const [name, option] of Object.entries(options)) { + // If the current entry is a category and not a leaf option, recurse into it + if (!Object.prototype.hasOwnProperty.call(option, 'value')) { + _cycleCategories(option); + } else { + // Prepare description + const descName = ` --${option.cliName || name}`; + + // Get the value + let optionValue = option.value; + + // Prepare value for option that is not null and is array of strings + if (optionValue !== null && option.types.includes('string[]')) { + optionValue = + '[' + optionValue.map((item) => `'${item}'`).join(', ') + ']'; + } + + // Prepare value for option that is not null and is a string + if (optionValue !== null && option.types.includes('string')) { + optionValue = `'${optionValue}'`; + } + + // Display correctly aligned messages + console.log( + descName.green, + `${('<' + option.types.join('|') + '>').yellow}`, + `${String(optionValue).bold}`.blue, + `- ${option.description}.` + ); + } + } +} + +export default { + printInfo, + printUsage +}; diff --git a/lib/intervals.js b/lib/intervals.js deleted file mode 100644 index f2563c6c..00000000 --- a/lib/intervals.js +++ /dev/null @@ -1,42 +0,0 @@ -/******************************************************************************* - -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 { log } from './logger.js'; - -// Array that contains ids of all ongoing intervals -const intervalIds = []; - -/** - * Adds id of a setInterval to the intervalIds array. - * - * @param {NodeJS.Timeout} id - Id of an interval. - */ -export const addInterval = (id) => { - intervalIds.push(id); -}; - -/** - * Clears all of ongoing intervals by ids gathered in the intervalIds array. - */ -export const clearAllIntervals = () => { - log(4, `[server] Clearing all registered intervals.`); - for (const id of intervalIds) { - clearInterval(id); - } -}; - -export default { - addInterval, - clearAllIntervals -}; diff --git a/lib/logger.js b/lib/logger.js index 8d7374fd..5972777c 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,17 +12,29 @@ See LICENSE file in root for details. *******************************************************************************/ +/** + * @overview A module for managing logging functionality with customizable + * log levels, console and file logging options, and error handling support. + * The module also ensures that file-based logs are stored in a structured + * directory, creating the necessary paths automatically if they do not exist. + */ + import { appendFile, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; + +import { getAbsolutePath, getNewDate } from './utils.js'; // The available colors const colors = ['red', 'yellow', 'blue', 'gray', 'green']; // The default logging config -let logging = { +const logging = { // Flags for logging status toConsole: true, toFile: false, pathCreated: false, + // Full path to the log file + pathToLog: '', // Log levels levelsDesc: [ { @@ -45,57 +57,29 @@ let logging = { title: 'benchmark', color: colors[4] } - ], - // Log listeners - listeners: [] + ] }; /** - * Logs the provided texts to a file, if file logging is enabled. It creates - * the necessary directory structure if not already created and appends the - * content, including an optional prefix, to the specified log file. + * Logs a message with a specified log level. Accepts a variable number + * of arguments. The arguments after the `level` are passed to `console.log` + * and/or used to construct and append messages to a log file. * - * @param {string[]} texts - An array of texts to be logged. - * @param {string} prefix - An optional prefix to be added to each log entry. - */ -const logToFile = (texts, prefix) => { - if (!logging.pathCreated) { - // Create if does not exist - !existsSync(logging.dest) && mkdirSync(logging.dest); - - // We now assume the path is available, e.g. it's the responsibility - // of the user to create the path with the correct access rights. - logging.pathCreated = true; - } - - // Add the content to a file - appendFile( - `${logging.dest}${logging.file}`, - [prefix].concat(texts).join(' ') + '\n', - (error) => { - if (error) { - console.log(`[logger] Unable to write to log file: ${error}`); - logging.toFile = false; - } - } - ); -}; - -/** - * Logs a message. Accepts a variable amount of arguments. Arguments after - * `level` will be passed directly to console.log, and/or will be joined - * and appended to the log file. + * @function log + * + * @param {...unknown} args - An array of arguments where the first is the log + * level and the remaining are strings used to build the log message. * - * @param {any} args - An array of arguments where the first is the log level - * and the rest are strings to build a message with. + * @returns {void} Exits the function execution if attempting to log at a level + * higher than allowed. */ -export const log = (...args) => { +export function log(...args) { const [newLevel, ...texts] = args; // Current logging options const { levelsDesc, level } = logging; - // Check if log level is within a correct range or is a benchmark log + // Check if the log level is within a correct range or is it a benchmark log if ( newLevel !== 5 && (newLevel === 0 || newLevel > level || level > levelsDesc.length) @@ -103,16 +87,13 @@ export const log = (...args) => { return; } - // Get rid of the GMT text information - const newDate = new Date().toString().split('(')[0].trim(); - // Create a message's prefix - const prefix = `${newDate} [${levelsDesc[newLevel - 1].title}] -`; + const prefix = `${getNewDate()} [${levelsDesc[newLevel - 1].title}] -`; - // Call available log listeners - logging.listeners.forEach((fn) => { - fn(prefix, texts.join(' ')); - }); + // Log to file + if (logging.toFile) { + _logToFile(texts, prefix); + } // Log to console if (logging.toConsole) { @@ -121,145 +102,185 @@ export const log = (...args) => { [prefix.toString()[logging.levelsDesc[newLevel - 1].color]].concat(texts) ); } - - // Log to file - if (logging.toFile) { - logToFile(texts, prefix); - } -}; +} /** - * Logs an error message with its stack trace. Optionally, a custom message - * can be provided. + * Logs an error message along with its stack trace. Optionally, a custom + * message can be provided. * - * @param {number} level - The log level. - * @param {Error} error - The error object. - * @param {string} customMessage - An optional custom message to be logged along - * with the error. + * @function logWithStack + * + * @param {number} newLevel - The log level. + * @param {Error} error - The error object containing the stack trace. + * @param {string} customMessage - An optional custom message to be included + * in the log alongside the error. + * + * @returns {void} Exits the function execution if attempting to log at a level + * higher than allowed. */ -export const logWithStack = (newLevel, error, customMessage) => { +export function logWithStack(newLevel, error, customMessage) { // Get the main message - const mainMessage = customMessage || error.message; + const mainMessage = customMessage || (error && error.message) || ''; // Current logging options const { level, levelsDesc } = logging; - // Check if log level is within a correct range + // Check if the log level is within a correct range if (newLevel === 0 || newLevel > level || level > levelsDesc.length) { return; } - // Get rid of the GMT text information - const newDate = new Date().toString().split('(')[0].trim(); - // Create a message's prefix - const prefix = `${newDate} [${levelsDesc[newLevel - 1].title}] -`; + const prefix = `${getNewDate()} [${levelsDesc[newLevel - 1].title}] -`; - // If the customMessage exists, we want to display the whole stack message - const stackMessage = - error.message !== error.stackMessage || error.stackMessage === undefined - ? error.stack - : error.stack.split('\n').slice(1).join('\n'); + // Add the whole stack message + const stackMessage = error && error.stack; - // Combine custom message or error message with error stack message - const texts = [mainMessage, '\n', stackMessage]; + // Combine custom message or error message with error stack message, if exists + const texts = [mainMessage]; + if (stackMessage) { + texts.push('\n', stackMessage); + } + + // Log to file + if (logging.toFile) { + _logToFile(texts, prefix); + } // Log to console if (logging.toConsole) { console.log.apply( undefined, [prefix.toString()[logging.levelsDesc[newLevel - 1].color]].concat([ - mainMessage[colors[newLevel - 1]], - '\n', - stackMessage + texts.shift()[colors[newLevel - 1]], + ...texts ]) ); } +} - // Call available log listeners - logging.listeners.forEach((fn) => { - fn(prefix, texts.join(' ')); - }); +/** + * Initializes logging with the specified logging configuration. + * + * @function initLogging + * + * @param {Object} loggingOptions - The configuration object containing + * `logging` options. + */ +export function initLogging(loggingOptions) { + // Get options from the `loggingOptions` object + const { level, dest, file, toConsole, toFile } = loggingOptions; - // Log to file - if (logging.toFile) { - logToFile(texts, prefix); - } -}; + // Reset flags to the default values + logging.pathCreated = false; + logging.pathToLog = ''; + + // Set the logging level + setLogLevel(level); + + // Set the console logging + enableConsoleLogging(toConsole); + + // Set the file logging + enableFileLogging(dest, file, toFile); +} /** - * Sets the log level to the specified value. Log levels are (0 = no logging, - * 1 = error, 2 = warning, 3 = notice, 4 = verbose or 5 = benchmark) + * Sets the log level to the specified value. Log levels are (`0` = no logging, + * `1` = error, `2` = warning, `3` = notice, `4` = verbose, or `5` = benchmark). + * + * @function setLogLevel * - * @param {number} newLevel - The new log level to be set. + * @param {number} level - The log level to be set. */ -export const setLogLevel = (newLevel) => { - if (newLevel >= 0 && newLevel <= logging.levelsDesc.length) { - logging.level = newLevel; +export function setLogLevel(level) { + if ( + Number.isInteger(level) && + level >= 0 && + level <= logging.levelsDesc.length + ) { + // Update the module logging's `level` option + logging.level = level; } -}; +} /** - * Enables file logging with the specified destination and log file. + * Enables console logging. * - * @param {string} logDest - The destination path for log files. - * @param {string} logFile - The log file name. + * @function enableConsoleLogging + * + * @param {boolean} toConsole - The flag for setting the logging to the console. */ -export const enableFileLogging = (logDest, logFile) => { - // Update logging options - logging = { - ...logging, - dest: logDest || logging.dest, - file: logFile || logging.file, - toFile: true - }; - - if (logging.dest.length === 0) { - return log(1, '[logger] File logging initialization: no path supplied.'); - } - - if (!logging.dest.endsWith('/')) { - logging.dest += '/'; - } -}; +export function enableConsoleLogging(toConsole) { + // Update the module logging's `toConsole` option + logging.toConsole = !!toConsole; +} /** - * Initializes logging with the specified logging configuration. + * Enables file logging with the specified destination and log file name. * - * @param {Object} loggingOptions - The logging configuration object. + * @function enableFileLogging + * + * @param {string} dest - The destination path where the log file should + * be saved. + * @param {string} file - The name of the log file. + * @param {boolean} toFile - A flag indicating whether logging should + * be directed to a file. */ -export const initLogging = (loggingOptions) => { - // Set all the logging options on our logging module object - for (const [key, value] of Object.entries(loggingOptions)) { - logging[key] = value; - } - - // Set the log level - setLogLevel(loggingOptions && parseInt(loggingOptions.level)); +export function enableFileLogging(dest, file, toFile) { + // Update the module logging's `toFile` option + logging.toFile = !!toFile; - // Set the log file path and name - if (loggingOptions && loggingOptions.dest && loggingOptions.toFile) { - enableFileLogging( - loggingOptions.dest, - loggingOptions.file || 'highcharts-export-server.log' - ); + // Set the `dest` and `file` options only if the file logging is enabled + if (logging.toFile) { + logging.dest = dest || 'log'; + logging.file = file || 'highcharts-export-server.log'; } -}; +} /** - * Adds a listener function to the logging system. + * Logs the provided texts to a file, if file logging is enabled. It creates + * the necessary directory structure if not already created and appends + * the content, including an optional prefix, to the specified log file. + * + * @function _logToFile * - * @param {function} fn - The listener function to be added. + * @param {Array} texts - An array of texts to be logged. + * @param {string} prefix - An optional prefix to be added to each log entry. */ -export const listen = (fn) => { - logging.listeners.push(fn); -}; +function _logToFile(texts, prefix) { + if (!logging.pathCreated) { + // Create if does not exist + !existsSync(getAbsolutePath(logging.dest)) && + mkdirSync(getAbsolutePath(logging.dest)); + + // Create the full path + logging.pathToLog = getAbsolutePath(join(logging.dest, logging.file)); + + // We now assume the path is available, e.g. it's the responsibility + // of the user to create the path with the correct access rights. + logging.pathCreated = true; + } + + // Add the content to a file + appendFile( + logging.pathToLog, + [prefix].concat(texts).join(' ') + '\n', + (error) => { + if (error && logging.toFile && logging.pathCreated) { + logging.toFile = false; + logging.pathCreated = false; + logWithStack(2, error, `[logger] Unable to write to log file.`); + } + } + ); +} export default { log, logWithStack, - setLogLevel, - enableFileLogging, initLogging, - listen + setLogLevel, + enableConsoleLogging, + enableFileLogging }; diff --git a/lib/pool.js b/lib/pool.js index 5b5f09e5..d3847748 100644 --- a/lib/pool.js +++ b/lib/pool.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,200 +12,117 @@ See LICENSE file in root for details. *******************************************************************************/ +/** + * @overview This module provides a worker pool implementation for managing + * the browser instance and pages, specifically designed for use with + * the Highcharts Export Server. It optimizes resources usage and performance + * by maintaining a pool of workers that can handle concurrent export tasks + * using Puppeteer. + */ + import { Pool } from 'tarn'; import { v4 as uuid } from 'uuid'; -import { - create as createBrowser, - close as closeBrowser, - newPage, - clearPage -} from './browser.js'; -import puppeteerExport from './export.js'; +import { createBrowser, closeBrowser, newPage, clearPage } from './browser.js'; +import { puppeteerExport } from './export.js'; import { log, logWithStack } from './logger.js'; -import { measureTime } from './utils.js'; +import { getNewDateTime, measureTime } from './utils.js'; import ExportError from './errors/ExportError.js'; // The pool instance -let pool = false; +let pool = null; // Pool statistics -export const stats = { - performedExports: 0, - exportAttempts: 0, - exportFromSvgAttempts: 0, +const poolStats = { + exportsAttempted: 0, + exportsPerformed: 0, + exportsDropped: 0, + exportsFromSvg: 0, + exportsFromOptions: 0, + exportsFromSvgAttempts: 0, + exportsFromOptionsAttempts: 0, timeSpent: 0, - droppedExports: 0, - spentAverage: 0 -}; - -let poolConfig = {}; - -const factory = { - /** - * Creates a new worker page for the export pool. - * - * @returns {Object} - An object containing the worker ID, a reference to the - * browser page, and initial work count. - * - * @throws {ExportError} - If there's an error during the creation of the new - * page. - */ - create: async () => { - let page = false; - - const id = uuid(); - const startDate = new Date().getTime(); - - try { - page = await newPage(); - - if (!page || page.isClosed()) { - throw new ExportError('The page is invalid or closed.'); - } - - log( - 3, - `[pool] Successfully created a worker ${id} - took ${ - new Date().getTime() - startDate - } ms.` - ); - } catch (error) { - throw new ExportError( - 'Error encountered when creating a new page.' - ).setError(error); - } - - return { - id, - page, - // Try to distribute the initial work count - workCount: Math.round(Math.random() * (poolConfig.workLimit / 2)) - }; - }, - - /** - * Validates a worker page in the export pool, checking if it has exceeded - * the work limit. - * - * @param {Object} workerHandle - The handle to the worker, containing the - * worker's ID, a reference to the browser page, and work count. - * - * @returns {boolean} - Returns true if the worker is valid and within - * the work limit; otherwise, returns false. - */ - validate: async (workerHandle) => { - // NOTE: In certain cases acquiring throws a TargetCloseError, which may - // be caused by two things: - // - The page is closed and attempted to be reused. - // - Lost contact with the browser - // What we're seeing in logs is that successive exports typically - // succeeds, and the server recovers, indicating that it's likely - // the first case. This is an attempt at allievating the issue by - // simply not validating the worker if the page is null or closed. - // - // The actual result from when this happened, was that a worker would - // be completely locked, stopping it from being acquired until - // its work count reached the limit. - if (!workerHandle.page || workerHandle.page?.isClosed()) { - return false; - } - - if ( - poolConfig.workLimit && - ++workerHandle.workCount > poolConfig.workLimit - ) { - log( - 3, - `[pool] Worker failed validation: exceeded work limit (limit is ${poolConfig.workLimit}).` - ); - return false; - } - return true; - }, - - /** - * Destroys a worker entry in the export pool, closing its associated page. - * - * @param {Object} workerHandle - The handle to the worker, containing - * the worker's ID and a reference to the browser page. - */ - destroy: async (workerHandle) => { - log(3, `[pool] Destroying pool entry ${workerHandle.id}.`); - - if (workerHandle.page && !workerHandle.page.isClosed()) { - await workerHandle.page.close(); - } - } - - // log: (message, level) => log(1, '[tarn] ' + message) + timeSpentAverage: 0 }; /** * Initializes the export pool with the provided configuration, creating * a browser instance and setting up worker resources. * - * @param {Object} config - Configuration options for the export pool along - * with custom puppeteer arguments for the puppeteer.launch function. + * @async + * @function initPool + * + * @param {Object} poolOptions - The configuration object containing `pool` + * options. + * @param {Array} puppeteerArgs - Additional arguments for Puppeteer + * launch. + * + * @returns {Promise} A Promise that resolves to ending the function + * execution when an already initialized pool of resources is found. + * + * @throws {ExportError} Throws an `ExportError` if could not create the pool + * of workers. */ -export const initPool = async (config) => { - // For the module scope usage - poolConfig = config && config.pool ? { ...config.pool } : {}; - +export async function initPool(poolOptions, puppeteerArgs) { // Create a browser instance with the puppeteer arguments - await createBrowser(config.puppeteerArgs); + await createBrowser(puppeteerArgs); - log( - 3, - `[pool] Initializing pool with workers: min ${poolConfig.minWorkers}, max ${poolConfig.maxWorkers}.` - ); - - if (pool) { - return log( - 4, - '[pool] Already initialized, please kill it before creating a new one.' + try { + log( + 3, + `[pool] Initializing pool with workers: min ${poolOptions.minWorkers}, max ${poolOptions.maxWorkers}.` ); - } - if (parseInt(poolConfig.minWorkers) > parseInt(poolConfig.maxWorkers)) { - poolConfig.minWorkers = poolConfig.maxWorkers; - } + if (pool) { + log( + 4, + '[pool] Already initialized, please kill it before creating a new one.' + ); + return; + } + + // Keep an eye on a correct min and max workers number + if (poolOptions.minWorkers > poolOptions.maxWorkers) { + poolOptions.minWorkers = poolOptions.maxWorkers; + } - try { // Create a pool along with a minimal number of resources pool = new Pool({ - // Get the create/validate/destroy/log functions - ...factory, - min: parseInt(poolConfig.minWorkers), - max: parseInt(poolConfig.maxWorkers), - acquireTimeoutMillis: poolConfig.acquireTimeout, - createTimeoutMillis: poolConfig.createTimeout, - destroyTimeoutMillis: poolConfig.destroyTimeout, - idleTimeoutMillis: poolConfig.idleTimeout, - createRetryIntervalMillis: poolConfig.createRetryInterval, - reapIntervalMillis: poolConfig.reaperInterval, + // Get the `create`, `validate`, and `destroy` functions + ..._factory(poolOptions), + min: poolOptions.minWorkers, + max: poolOptions.maxWorkers, + acquireTimeoutMillis: poolOptions.acquireTimeout, + createTimeoutMillis: poolOptions.createTimeout, + destroyTimeoutMillis: poolOptions.destroyTimeout, + idleTimeoutMillis: poolOptions.idleTimeout, + createRetryIntervalMillis: poolOptions.createRetryInterval, + reapIntervalMillis: poolOptions.reaperInterval, propagateCreateError: false }); // Set events pool.on('release', async (resource) => { // Clear page - const r = await clearPage(resource.page, false); + const clearStatus = await clearPage(resource, false); log( 4, - `[pool] Releasing a worker with ID ${resource.id}. Clear page status: ${r}.` + `[pool] Pool resource [${resource.id}] - Releasing a worker. Clear page status: ${clearStatus}.` ); }); - pool.on('destroySuccess', (eventId, resource) => { - log(4, `[pool] Destroyed a worker with ID ${resource.id}.`); + pool.on('destroySuccess', (_eventId, resource) => { + log( + 4, + `[pool] Pool resource [${resource.id}] - Destroyed a worker successfully.` + ); resource.page = null; }); const initialResources = []; // Create an initial number of resources - for (let i = 0; i < poolConfig.minWorkers; i++) { + for (let i = 0; i < poolOptions.minWorkers; i++) { try { const resource = await pool.acquire().promise; initialResources.push(resource); @@ -225,17 +142,21 @@ export const initPool = async (config) => { ); } catch (error) { throw new ExportError( - '[pool] Could not create the pool of workers.' + '[pool] Could not configure and create the pool of workers.', + 500 ).setError(error); } -}; +} /** - * Kills all workers in the pool, destroys the pool, and closes the browser + * Terminates all workers in the pool, destroys the pool, and closes the browser * instance. * - * @returns {Promise} A promise that resolves after the workers are - * killed, the pool is destroyed, and the browser is closed. + * @async + * @function killPool + * + * @returns {Promise} A Promise that resolves once all workers are + * terminated, the pool is destroyed, and the browser is successfully closed. */ export async function killPool() { log(3, '[pool] Killing pool with all workers and closing browser.'); @@ -250,8 +171,9 @@ export async function killPool() { // Destroy the pool if it is still available if (!pool.destroyed) { await pool.destroy(); - log(4, '[browser] Destroyed the pool of resources.'); + log(4, '[pool] Destroyed the pool of resources.'); } + pool = null; } // Close the browser instance @@ -263,100 +185,126 @@ export async function killPool() { * handle from the pool, performs the export using puppeteer, and releases * the worker handle back to the pool. * - * @param {string} chart - The chart data or configuration to be exported. - * @param {Object} options - Export options and configuration. + * @async + * @function postWork * - * @returns {Promise} A promise that resolves with the export resultand - * options. + * @param {Object} options - The configuration object containing complete set + * of options. + * + * @returns {Promise} A Promise that resolves to the export result + * and options. * - * @throws {ExportError} If an error occurs during the export process. + * @throws {ExportError} Throws an `ExportError` if an error occurs during + * the export process. */ -export const postWork = async (chart, options) => { +export async function postWork(options) { let workerHandle; try { log(4, '[pool] Work received, starting to process.'); - ++stats.exportAttempts; - if (poolConfig.benchmarking) { - getPoolInfo(); + // An export attempt counted + ++poolStats.exportsAttempted; + + // Display the pool information if needed + if (options.pool.benchmarking) { + _getPoolInfo(); } + // Throw an error in case of lacking the pool instance if (!pool) { - throw new ExportError('Work received, but pool has not been started.'); + throw new ExportError( + '[pool] Work received, but pool has not been started.', + 500 + ); } - // Acquire the worker along with the id of resource and work count + // The acquire counter const acquireCounter = measureTime(); + + // Try to acquire the worker along with the id, works count and page try { log(4, '[pool] Acquiring a worker handle.'); + + // Acquire a pool resource workerHandle = await pool.acquire().promise; // Check the page acquire time if (options.server.benchmarking) { log( 5, - options.payload?.requestId - ? `[benchmark] Request with ID ${options.payload?.requestId} -` - : '[benchmark]', - `Acquired a worker handle: ${acquireCounter()}ms.` + `[benchmark] ${options.requestId ? `Request [${options.requestId}] - ` : ''}`, + `Acquiring a worker handle took ${acquireCounter()}ms.` ); } } catch (error) { throw new ExportError( - (options.payload?.requestId - ? `For request with ID ${options.payload?.requestId} - ` - : '') + - `Error encountered when acquiring an available entry: ${acquireCounter()}ms.` + `[pool] ${ + options.requestId ? `Request [${options.requestId}] - ` : '' + }Error encountered when acquiring an available entry: ${acquireCounter()}ms.`, + 400 ).setError(error); } log(4, '[pool] Acquired a worker handle.'); if (!workerHandle.page) { + // Set the `workLimit` to exceeded in order to recreate the resource + workerHandle.workCount = options.pool.workLimit + 1; throw new ExportError( - 'Resolved worker page is invalid: the pool setup is wonky.' + '[pool] Resolved worker page is invalid: the pool setup is wonky.', + 400 ); } - // Save the start time - let workStart = new Date().getTime(); + log( + 4, + `[pool] Pool resource [${workerHandle.id}] - Starting work on this pool entry.` + ); - log(4, `[pool] Starting work on pool entry with ID ${workerHandle.id}.`); + // Start measuring export time + const exportCounter = measureTime(); // Perform an export on a puppeteer level - const exportCounter = measureTime(); - const result = await puppeteerExport(workerHandle.page, chart, options); + const exportResult = await puppeteerExport( + workerHandle.page, + options.export, + options.customLogic + ); // Check if it's an error - if (result instanceof Error) { - // NOTE: If there's a rasterization timeout, we want need to flush the page. - // This is because the page may be in a state where it's waiting for - // the screenshot to finish even though the timeout has occured. - // Which of course causes a lot of issues with the event system, - // and page consistency. + if (exportResult instanceof Error) { + // NOTE: + // If there's a rasterization timeout, we want need to flush the page. + // This is because the page may be in a state where it's waiting for + // the screenshot to finish even though the timeout has occured. + // Which of course causes a lot of issues with the event system, + // and page consistency. // - // NOTE: Only page.screenshot will throw this, timeouts for PDF's are - // handled by the page.pdf function itself. + // Only `page.screenshot` will throw this, timeouts for PDF's are + // handled by the `page.pdf` function itself. // - // ...yes, this is ugly. - if (result.message === 'Rasterization timeout') { - workerHandle.workCount = poolConfig.workLimit + 1; + // ...yes, this is ugly. + if (exportResult.message === 'Rasterization timeout') { + // Set the `workLimit` to exceeded in order to recreate the resource + workerHandle.workCount = options.pool.workLimit + 1; workerHandle.page = null; } if ( - result.name === 'TimeoutError' || - result.message === 'Rasterization timeout' + exportResult.name === 'TimeoutError' || + exportResult.message === 'Rasterization timeout' ) { throw new ExportError( - 'Rasterization timeout: your chart may be too complex or large, and failed to render within the allotted time.' - ).setError(result); + `[pool] ${ + options.requestId ? `Request [${options.requestId}] - ` : '' + }Rasterization timeout: your chart may be too complex or large, and failed to render within the allotted time.` + ).setError(exportResult); } else { throw new ExportError( - (options.payload?.requestId - ? `For request with ID ${options.payload?.requestId} - ` - : '') + `Error encountered during export: ${exportCounter()}ms.` - ).setError(result); + `[pool] ${ + options.requestId ? `Request [${options.requestId}] - ` : '' + }Error encountered during export: ${exportCounter()}ms.` + ).setError(exportResult); } } @@ -364,80 +312,302 @@ export const postWork = async (chart, options) => { if (options.server.benchmarking) { log( 5, - options.payload?.requestId - ? `[benchmark] Request with ID ${options.payload?.requestId} -` - : '[benchmark]', - `Exported a chart sucessfully: ${exportCounter()}ms.` + `[benchmark] ${options.requestId ? `Request [${options.requestId}] - ` : ''}`, + `Exporting a chart sucessfully took ${exportCounter()}ms.` ); } // Release the resource back to the pool pool.release(workerHandle); - // Used for statistics in averageTime and processedWorkCount, which - // in turn is used by the /health route. - const workEnd = new Date().getTime(); - const exportTime = workEnd - workStart; - stats.timeSpent += exportTime; - stats.spentAverage = stats.timeSpent / ++stats.performedExports; + // Update statistics + poolStats.timeSpent += exportCounter(); + poolStats.timeSpentAverage = + poolStats.timeSpent / ++poolStats.exportsPerformed; - log(4, `[pool] Work completed in ${exportTime} ms.`); + log(4, `[pool] Work completed in ${exportCounter()}ms.`); - // Otherwise return the result + // Otherwise return an object with the result and options return { - result, + result: exportResult, options }; } catch (error) { - ++stats.droppedExports; + ++poolStats.exportsDropped; + // Try to release the worker, if it exists if (workerHandle) { pool.release(workerHandle); } - throw new ExportError(`[pool] In pool.postWork: ${error.message}`).setError( - error - ); + throw error; } -}; +} /** * Retrieves the current pool instance. * - * @returns {Object|null} The current pool instance if initialized, or null + * @function getPool + * + * @returns {(Object|null)} The current pool instance if initialized, or `null` * if the pool has not been created. */ -export const getPool = () => pool; +export function getPool() { + return pool; +} + +/** + * Gets the statistic of a pool instace about exports. + * + * @function getPoolStats + * + * @returns {Object} The current pool statistics. + */ +export function getPoolStats() { + return poolStats; +} /** * Retrieves pool information in JSON format, including minimum and maximum * workers, available workers, workers in use, and pending acquire requests. * + * @function getPoolInfoJSON + * * @returns {Object} Pool information in JSON format. */ -export const getPoolInfoJSON = () => ({ - min: pool.min, - max: pool.max, - all: pool.numFree() + pool.numUsed(), - available: pool.numFree(), - used: pool.numUsed(), - pending: pool.numPendingAcquires() -}); +export function getPoolInfoJSON() { + return { + min: pool.min, + max: pool.max, + used: pool.numUsed(), + available: pool.numFree(), + allCreated: pool.numUsed() + pool.numFree(), + pendingAcquires: pool.numPendingAcquires(), + pendingCreates: pool.numPendingCreates(), + pendingValidations: pool.numPendingValidations(), + pendingDestroys: pool.pendingDestroys.length, + absoluteAll: + pool.numUsed() + + pool.numFree() + + pool.numPendingAcquires() + + pool.numPendingCreates() + + pool.numPendingValidations() + + pool.pendingDestroys.length + }; +} /** * Logs information about the current state of the pool, including the minimum * and maximum workers, available workers, workers in use, and pending acquire * requests. + * + * @function _getPoolInfo */ -export function getPoolInfo() { - const { min, max, all, available, used, pending } = getPoolInfoJSON(); +function _getPoolInfo() { + const { + min, + max, + used, + available, + allCreated, + pendingAcquires, + pendingCreates, + pendingValidations, + pendingDestroys, + absoluteAll + } = getPoolInfoJSON(); log(5, `[pool] The minimum number of resources allowed by pool: ${min}.`); log(5, `[pool] The maximum number of resources allowed by pool: ${max}.`); - log(5, `[pool] The number of all created resources: ${all}.`); - log(5, `[pool] The number of available resources: ${available}.`); - log(5, `[pool] The number of acquired resources: ${used}.`); - log(5, `[pool] The number of resources waiting to be acquired: ${pending}.`); + log(5, `[pool] The number of used resources: ${used}.`); + log(5, `[pool] The number of free resources: ${available}.`); + log( + 5, + `[pool] The number of all created (used and free) resources: ${allCreated}.` + ); + log( + 5, + `[pool] The number of resources waiting to be acquired: ${pendingAcquires}.` + ); + log( + 5, + `[pool] The number of resources waiting to be created: ${pendingCreates}.` + ); + log( + 5, + `[pool] The number of resources waiting to be validated: ${pendingValidations}.` + ); + log( + 5, + `[pool] The number of resources waiting to be destroyed: ${pendingDestroys}.` + ); + log(5, `[pool] The number of all resources: ${absoluteAll}.`); +} + +/** + * Factory function that returns an object with `create`, `validate`, `destroy` + * functions for the pool instance. + * + * @function _factory + * + * @param {Object} poolOptions - The configuration object containing `pool` + * options. + */ +function _factory(poolOptions) { + return { + /** + * Creates a new worker page for the export pool. + * + * @async + * @function create + * + * @returns {Promise} A Promise that resolves to an object + * containing the worker ID, a reference to the browser page, and initial + * work count. + * + * @throws {ExportError} Throws an `ExportError` if there is an error during + * the creation of the new page. + */ + create: async () => { + // Init the resource with unique id and work count + const poolResource = { + id: uuid(), + // Try to distribute the initial work count + workCount: Math.round(Math.random() * (poolOptions.workLimit / 2)) + }; + + try { + // Start measuring a page creation time + const startDate = getNewDateTime(); + + // Create a new page + await newPage(poolResource); + + // Measure the time of full creation and configuration of a page + log( + 3, + `[pool] Pool resource [${poolResource.id}] - Successfully created a worker, took ${ + getNewDateTime() - startDate + }ms.` + ); + + // Return ready pool resource + return poolResource; + } catch (error) { + log( + 3, + `[pool] Pool resource [${poolResource.id}] - Error encountered when creating a new page.` + ); + throw error; + } + }, + + /** + * Validates a worker page in the export pool, checking if it has exceeded + * the work limit. + * + * @async + * @function validate + * + * @param {Object} poolResource - The handle to the worker, containing + * the worker's ID, a reference to the browser page, and work count. + * + * @returns {Promise} A Promise that resolves to true if the worker + * is valid and within the work limit; otherwise, to false. + */ + validate: async (poolResource) => { + // NOTE: + // In certain cases acquiring throws a `TargetCloseError`, which may + // be caused by two things: + // - The page is closed and attempted to be reused. + // - Lost contact with the browser. + // + // What we're seeing in logs is that successive exports typically + // succeeds, and the server recovers, indicating that it's likely + // the first case. This is an attempt at allievating the issue by + // simply not validating the worker if the page is null or closed. + // + // The actual result from when this happened, was that a worker would + // be completely locked, stopping it from being acquired until + // its work count reached the limit. + + // Check if the `page` is valid + if (!poolResource.page) { + log( + 3, + `[pool] Pool resource [${poolResource.id}] - Validation failed (no valid page is found).` + ); + return false; + } + + // Check if the `page` is closed + if (poolResource.page.isClosed()) { + log( + 3, + `[pool] Pool resource [${poolResource.id}] - Validation failed (page is closed or invalid).` + ); + return false; + } + + // Check if the `mainFrame` is detached + if (poolResource.page.mainFrame().detached) { + log( + 3, + `[pool] Pool resource [${poolResource.id}] - Validation failed (page's frame is detached).` + ); + return false; + } + + // Check if the `workLimit` is exceeded + if ( + poolOptions.workLimit && + ++poolResource.workCount > poolOptions.workLimit + ) { + log( + 3, + `[pool] Pool resource [${poolResource.id}] - Validation failed (exceeded the ${poolOptions.workLimit} works per resource limit).` + ); + return false; + } + + // The `poolResource` is validated + return true; + }, + + /** + * Destroys a worker entry in the export pool, closing its associated page. + * + * @async + * @function destroy + * + * @param {Object} poolResource - The handle to the worker, containing + * the worker's ID, a reference to the browser page, and work count. + */ + destroy: async (poolResource) => { + log( + 3, + `[pool] Pool resource [${poolResource.id}] - Destroying a worker.` + ); + + if (poolResource.page && !poolResource.page.isClosed()) { + try { + // Remove all attached event listeners from the resource + poolResource.page.removeAllListeners('pageerror'); + poolResource.page.removeAllListeners('console'); + poolResource.page.removeAllListeners('framedetached'); + + // We need to wait around for this + await poolResource.page.close(); + } catch (error) { + log( + 3, + `[pool] Pool resource [${poolResource.id}] - Page could not be closed upon destroying.` + ); + throw error; + } + } + } + }; } export default { @@ -445,7 +615,6 @@ export default { killPool, postWork, getPool, - getPoolInfo, - getPoolInfoJSON, - getStats: () => stats + getPoolStats, + getPoolInfoJSON }; diff --git a/lib/prompt.js b/lib/prompt.js new file mode 100644 index 00000000..3d018b8b --- /dev/null +++ b/lib/prompt.js @@ -0,0 +1,314 @@ +/******************************************************************************* + +Highcharts Export Server + +Copyright (c) 2016-2025, Highsoft + +Licenced under the MIT licence. + +Additionally a valid Highcharts license is required for use. + +See LICENSE file in root for details. + +*******************************************************************************/ + +/** + * @overview This module provides a manual configuration feature that prompts + * users to customize and save their desired settings into a configuration file. + */ + +import { existsSync, readFileSync, writeFileSync } from 'fs'; + +import prompts from 'prompts'; + +import { isAllowedConfig } from './config.js'; +import { log, logWithStack } from './logger.js'; +import { getAbsolutePath } from './utils.js'; + +import defaultConfig from './schemas/config.js'; + +/** + * Initiates a manual configuration process, prompting the user to configure + * settings based on the specified configuration file and saving the results + * to the file. + * + * @async + * @function manualConfig + * + * @param {string} fileName - The name of the configuration file to save to. + * @param {boolean} allowCodeExecution - A flag indicating whether code + * execution is allowed. + * + * @returns {Promise} A Promise that resolves to true once the manual + * configuration process is completed and the updated configuration is saved. + */ +export async function manualConfig(fileName, allowCodeExecution) { + // Initialize an empty object to hold the config data + let configFile = {}; + + // Check if the specified configuration file already exists + if (existsSync(getAbsolutePath(fileName))) { + try { + // If the file exists, read and parse its contents + configFile = isAllowedConfig( + readFileSync(getAbsolutePath(fileName), 'utf8'), + false, + allowCodeExecution + ); + } catch (error) { + log( + 2, + '[prompt] The existing file for the `createConfig` option is not valid JSON. Using an empty object for the config instead.' + ); + } + } + + /** + * Handles submitting of answers during the prompt process for each + * configuration category. + * + * @async + * @function onSubmit + * + * @param {unknown} _ - Unused, automatically passed argument. + * @param {Array} categories - The selected categories to configure. + * + * @returns {Promise} A Promise that resolves to `true` once + * the configuration process is completed and saved. + */ + const onSubmit = async (_, categories) => { + // Track how many questions have been answered + let questionsCounter = 0; + + // Collect all prompt questions for selected categories + const allQuestions = []; + + // Object to store the prompt responses for configuration + const promptsConfig = {}; + + // Iterate through each selected category + for (const section of categories) { + Object.entries(defaultConfig[section]).forEach(([category, options]) => { + // Init an array to store prompts for this section if not already done + if (!promptsConfig[section]) { + promptsConfig[section] = []; + } + + // Add prompts for any option with prompt configuration + if (options.promptOptions) { + promptsConfig[section].push( + _preparePrompt([category, options], section) + ); + } + + // Handle subsections such as `proxy`, `rateLimiting` and `ssl` + if (['proxy', 'rateLimiting', 'ssl'].includes(category)) { + Object.entries(defaultConfig[section][category]).forEach( + (element) => { + // Add prompts for any option with prompt configuration + if (element[1].promptOptions) { + promptsConfig[section].push( + _preparePrompt( + [`${category}.${element[0]}`, element[1]], + section + ) + ); + } + } + ); + } + }); + + // Append all prompts for the section to the full list of questions + allQuestions.push(...promptsConfig[section]); + } + + // Prompt the user with the collected questions + await prompts(allQuestions, { + /** + * Handles submission of individual prompt answers. + * + * @async + * @function onSubmit + * + * @param {Object} prompt - The current prompt being answered. + * @param {unknown} answer - The user's response to the prompt. + * + * @returns {Promise} A Promise that resolves once the answer + * is processed. + */ + onSubmit: async (prompt, answer) => { + // Handle specific script configurations + if ( + ['coreScripts', 'moduleScripts', 'indicatorScripts'].includes( + prompt.name + ) + ) { + // If the answer is provided, use the selected values or the defaults + answer = answer.length + ? answer.map((module) => prompt.choices[module]) + : prompt.choices; + + // Store the answer in the config file under the appropriate section + configFile[prompt.section][prompt.name] = answer; + } else { + // Update the config file with an answer, handling nested properties + configFile[prompt.section] = _recursiveProps( + Object.assign({}, configFile[prompt.section] || {}), + prompt.name.split('.'), + prompt.choices ? prompt.choices[answer] : answer + ); + } + + // If all questions have been answered, save the updated config + if (++questionsCounter === allQuestions.length) { + try { + // Save the prompt result + writeFileSync( + getAbsolutePath(fileName), + isAllowedConfig(configFile, true, allowCodeExecution), + 'utf8' + ); + } catch (error) { + logWithStack( + 1, + error, + `[prompt] An error occurred while creating the ${fileName} file.` + ); + } + return true; + } + } + }); + return true; + }; + + // Generate a list of categories available for configuration + const choices = Object.keys(defaultConfig).map((choice) => ({ + title: `${choice} options`, + value: choice + })); + + // Prompt the user to select one or more categories to configure + return prompts( + { + type: 'multiselect', + name: 'category', + message: 'Which category do you want to configure?', + hint: 'Space: Select specific, A: Select all, Enter: Confirm.', + instructions: '', + choices + }, + { onSubmit } + ); +} + +/** + * Prepares a prompt configuration object based on the provided entry + * and section. The prompt configuration includes the prompt type, initial + * value, format, and additional options for rendering and validation + * in interactive prompts. + * + * @function _preparePrompt + * + * @param {Array[string, Object]} entry - An array where the first element + * is the name of the option, and the second element is an object containing + * the option details. + * @param {string} section - The section name for the prompt, used to categorize + * the prompt message. + * + * @returns {Object} The prepared prompt object containing configuration + * for the interactive prompt, including the type, initial value, + * and any formatting or choices required. + */ +function _preparePrompt(entry, section) { + // Retrieve the option name + const name = entry[0]; + + // Retrieve the option configuration + const option = entry[1]; + + // Collect common data for the prompt + const prompt = { + name, + message: `${section}.${name}`.blue + ` - ${option.description}`, + section, + ...option.promptOptions + }; + + // Update the prompt configuration with type-specific data + switch (prompt.type) { + case 'text': + prompt.initial = option.value; + prompt.format = (value) => (typeof value === 'string' ? value : null); + break; + case 'number': + prompt.initial = option.value; + prompt.format = (value) => (typeof value === 'number' ? value : null); + break; + case 'toggle': + prompt.initial = option.value; + prompt.format = (value) => (typeof value === 'boolean' ? value : null); + break; + case 'list': + prompt.initial = option.value.join(';'); + break; + case 'select': + prompt.initial = 0; + break; + case 'multiselect': + prompt.choices = option.value; + break; + } + + // Return the prompt configuration + return prompt; +} + +/** + * Recursively updates or creates nested properties within an object and assigns + * the final value to the deepest property. + * + * @function _recursiveProps + * + * @param {Object} objectToUpdate - The object in which nested properties + * will be updated or created. + * @param {Array} nestedNames - An array of property names representing + * the nesting hierarchy. + * @param {unknown} value - The final value to be assigned to the deepest nested + * property. + * + * @returns {Object} The updated object with the specified value assigned + * to the nested property. + */ +function _recursiveProps(objectToUpdate, nestedNames, value) { + while (nestedNames.length > 1) { + // Retrieve and remove the next property name from the nested hierarchy + const propName = nestedNames.shift(); + + // Create an empty object if the property doesn't exist + if (!Object.prototype.hasOwnProperty.call(objectToUpdate, propName)) { + objectToUpdate[propName] = {}; + } + + // Recur to the next level, cloning the current property object + objectToUpdate[propName] = _recursiveProps( + Object.assign({}, objectToUpdate[propName]), + nestedNames, + value + ); + + // Return after each recursive call + return objectToUpdate; + } + + // Assign the final value to the last property in the chain + objectToUpdate[nestedNames[0]] = value; + + // Return the fully updated object + return objectToUpdate; +} + +export default { + manualConfig +}; diff --git a/lib/resourceRelease.js b/lib/resourceRelease.js index 1d8e85ce..150b217a 100644 --- a/lib/resourceRelease.js +++ b/lib/resourceRelease.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,31 +12,43 @@ See LICENSE file in root for details. *******************************************************************************/ -import { clearAllIntervals } from './intervals.js'; +/** + * @overview Handles graceful shutdown of the Highcharts Export Server, ensuring + * proper cleanup of resources such as browser, pages, servers, and timers. + */ + import { killPool } from './pool.js'; +import { clearAllTimers } from './timer.js'; + import { closeServers } from './server/server.js'; /** - * Clean up function to trigger before ending process for the graceful shutdown. + * Performs cleanup operations to ensure a graceful shutdown of the process. + * This includes clearing all registered timeouts/intervals, closing active + * servers, terminating resources (pages) of the pool, pool itself, and closing + * the browser. + * + * @function shutdownCleanUp * - * @param {number} exitCode - An exit code for the process.exit() function. + * @param {number} [exitCode=0] - The exit code to use with `process.exit()`. + * The default value is `0`. */ -export const shutdownCleanUp = async (exitCode) => { +export async function shutdownCleanUp(exitCode = 0) { // Await freeing all resources await Promise.allSettled([ // Clear all ongoing intervals - clearAllIntervals(), + clearAllTimers(), // Get available server instances (HTTP/HTTPS) and close them closeServers(), - // Close pool along with its workers and the browser instance, if exists + // Close an active pool along with its workers and the browser instance killPool() ]); // Exit process with a correct code process.exit(exitCode); -}; +} export default { shutdownCleanUp diff --git a/lib/sanitize.js b/lib/sanitize.js index 42ffa5bc..64710c33 100644 --- a/lib/sanitize.js +++ b/lib/sanitize.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -15,23 +15,33 @@ See LICENSE file in root for details. /** * @overview Used to sanitize the strings coming from the exporting module * to prevent XSS attacks (with the DOMPurify library). - **/ + */ -import { JSDOM } from 'jsdom'; import DOMPurify from 'dompurify'; +import { JSDOM } from 'jsdom'; /** - * Sanitizes a given HTML string by removing tags and any content within them. + * Sanitizes a given HTML string by removing + * tags and any content within them. + * + * @function sanitize + * + * @param {string} input - The HTML string to be sanitized. * - * @param {string} input The HTML string to be sanitized. * @returns {string} The sanitized HTML string. */ export function sanitize(input) { + // Get the virtual DOM const window = new JSDOM('').window; + + // Create a purifying instance const purify = DOMPurify(window); + + // Return sanitized input, allowing for the `foreignObject` elements return purify.sanitize(input, { ADD_TAGS: ['foreignObject'] }); } -export default sanitize; +export default { + sanitize +}; diff --git a/lib/schemas/config.js b/lib/schemas/config.js index 50600dfd..c8d1e677 100644 --- a/lib/schemas/config.js +++ b/lib/schemas/config.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,87 +12,26 @@ See LICENSE file in root for details. *******************************************************************************/ -// Possible names for Highcharts scripts -export const scriptsNames = { - core: ['highcharts', 'highcharts-more', 'highcharts-3d'], - modules: [ - 'stock', - 'map', - 'gantt', - 'exporting', - 'parallel-coordinates', - 'accessibility', - // 'annotations-advanced', - 'boost-canvas', - 'boost', - 'data', - 'data-tools', - 'draggable-points', - 'static-scale', - 'broken-axis', - 'heatmap', - 'tilemap', - 'tiledwebmap', - 'timeline', - 'treemap', - 'treegraph', - 'item-series', - 'drilldown', - 'histogram-bellcurve', - 'bullet', - 'funnel', - 'funnel3d', - 'geoheatmap', - 'pyramid3d', - 'networkgraph', - 'overlapping-datalabels', - 'pareto', - 'pattern-fill', - 'pictorial', - 'price-indicator', - 'sankey', - 'arc-diagram', - 'dependency-wheel', - 'series-label', - 'series-on-point', - 'solid-gauge', - 'sonification', - // 'stock-tools', - 'streamgraph', - 'sunburst', - 'variable-pie', - 'variwide', - 'vector', - 'venn', - 'windbarb', - 'wordcloud', - 'xrange', - 'no-data-to-display', - 'drag-panes', - 'debugger', - 'dumbbell', - 'lollipop', - 'cylinder', - 'organization', - 'dotplot', - 'marker-clusters', - 'hollowcandlestick', - 'heikinashi', - 'flowmap', - 'export-data', - 'navigator', - 'textpath' - ], - indicators: ['indicators-all'], - custom: [ - 'https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.30.1/moment.min.js', - 'https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.45/moment-timezone-with-data.min.js' - ] -}; +/** + * @overview Configuration management module for the Highcharts Export Server. + * It provides a default configuration object with predefined default values, + * descriptions, and characteristics for each option used in the Export Server. + */ -// This is the configuration object with all options and their default values, -// also from the .env file if one exists -export const defaultConfig = { +/** + * The default configuration object containing all available options, organized + * by sections. + * + * This object includes: + * - Default values for each option. + * - Data types for validation. + * - Names of corresponding environment variables. + * - Descriptions of each property. + * - Information used for prompts in interactive configuration. + * - [Optional] Corresponding CLI argument names for CLI usage. + * - [Optional] Legacy names from the previous PhantomJS-based server. + */ +const defaultConfig = { puppeteer: { args: { value: [ @@ -144,1031 +83,911 @@ export const defaultConfig = { '--process-per-tab', '--use-mock-keychain' ], - type: 'string[]', - description: 'Arguments array to send to Puppeteer.' + types: ['string[]'], + envLink: 'PUPPETEER_ARGS', + cliName: 'puppeteerArgs', + description: 'Array of Puppeteer arguments', + promptOptions: { + type: 'list', + separator: ';' + } } }, highcharts: { version: { value: 'latest', - type: 'string', + types: ['string'], envLink: 'HIGHCHARTS_VERSION', - description: 'The Highcharts version to be used.' + description: 'Highcharts version', + promptOptions: { + type: 'text' + } }, - cdnURL: { - value: 'https://code.highcharts.com/', - type: 'string', + cdnUrl: { + value: 'https://code.highcharts.com', + types: ['string'], envLink: 'HIGHCHARTS_CDN_URL', - description: 'The CDN URL for Highcharts scripts to be used.' + description: 'CDN URL for Highcharts scripts', + promptOptions: { + type: 'text' + } + }, + forceFetch: { + value: false, + types: ['boolean'], + envLink: 'HIGHCHARTS_FORCE_FETCH', + description: 'Flag to refetch scripts after each server rerun', + promptOptions: { + type: 'toggle' + } + }, + cachePath: { + value: '.cache', + types: ['string'], + envLink: 'HIGHCHARTS_CACHE_PATH', + description: 'Directory path for cached Highcharts scripts', + promptOptions: { + type: 'text' + } }, coreScripts: { - value: scriptsNames.core, - type: 'string[]', + value: ['highcharts', 'highcharts-more', 'highcharts-3d'], + types: ['string[]'], envLink: 'HIGHCHARTS_CORE_SCRIPTS', - description: 'The core Highcharts scripts to fetch.' + description: 'Highcharts core scripts to fetch', + promptOptions: { + type: 'multiselect', + instructions: 'Space: Select specific, A: Select all, Enter: Confirm' + } }, moduleScripts: { - value: scriptsNames.modules, - type: 'string[]', + value: [ + 'stock', + 'map', + 'gantt', + 'exporting', + 'parallel-coordinates', + 'accessibility', + // 'annotations-advanced', + 'boost-canvas', + 'boost', + 'data', + 'data-tools', + 'draggable-points', + 'static-scale', + 'broken-axis', + 'heatmap', + 'tilemap', + 'tiledwebmap', + 'timeline', + 'treemap', + 'treegraph', + 'item-series', + 'drilldown', + 'histogram-bellcurve', + 'bullet', + 'funnel', + 'funnel3d', + 'geoheatmap', + 'pyramid3d', + 'networkgraph', + 'overlapping-datalabels', + 'pareto', + 'pattern-fill', + 'pictorial', + 'price-indicator', + 'sankey', + 'arc-diagram', + 'dependency-wheel', + 'series-label', + 'series-on-point', + 'solid-gauge', + 'sonification', + // 'stock-tools', + 'streamgraph', + 'sunburst', + 'variable-pie', + 'variwide', + 'vector', + 'venn', + 'windbarb', + 'wordcloud', + 'xrange', + 'no-data-to-display', + 'drag-panes', + 'debugger', + 'dumbbell', + 'lollipop', + 'cylinder', + 'organization', + 'dotplot', + 'marker-clusters', + 'hollowcandlestick', + 'heikinashi', + 'flowmap', + 'export-data', + 'navigator', + 'textpath' + ], + types: ['string[]'], envLink: 'HIGHCHARTS_MODULE_SCRIPTS', - description: 'The modules of Highcharts to fetch.' + description: 'Highcharts module scripts to fetch', + promptOptions: { + type: 'multiselect', + instructions: 'Space: Select specific, A: Select all, Enter: Confirm' + } }, indicatorScripts: { - value: scriptsNames.indicators, - type: 'string[]', + value: ['indicators-all'], + types: ['string[]'], envLink: 'HIGHCHARTS_INDICATOR_SCRIPTS', - description: 'The indicators of Highcharts to fetch.' + description: 'Highcharts indicator scripts to fetch', + promptOptions: { + type: 'multiselect', + instructions: 'Space: Select specific, A: Select all, Enter: Confirm' + } }, customScripts: { - value: scriptsNames.custom, - type: 'string[]', - description: 'Additional custom scripts or dependencies to fetch.' - }, - forceFetch: { - value: false, - type: 'boolean', - envLink: 'HIGHCHARTS_FORCE_FETCH', - description: - 'The flag to determine whether to refetch all scripts after each server rerun.' - }, - cachePath: { - value: '.cache', - type: 'string', - envLink: 'HIGHCHARTS_CACHE_PATH', - description: - 'The path to the cache directory. It is used to store the Highcharts scripts and custom scripts.' + value: [ + 'https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.30.1/moment.min.js', + 'https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.45/moment-timezone-with-data.min.js' + ], + types: ['string[]'], + envLink: 'HIGHCHARTS_CUSTOM_SCRIPTS', + description: 'Additional custom scripts or dependencies to fetch', + promptOptions: { + type: 'list', + separator: ';' + } } }, export: { infile: { - value: false, - type: 'string', + value: null, + types: ['string', 'null'], + envLink: 'EXPORT_INFILE', description: - 'The input file should include a name and a type (json or svg). It must be correctly formatted as a JSON or SVG file.' + 'Input filename with type, formatted correctly as JSON or SVG', + promptOptions: { + type: 'text' + } }, instr: { - value: false, - type: 'string', + value: null, + types: ['Object', 'string', 'null'], + envLink: 'EXPORT_INSTR', description: - 'Input, provided in the form of a stringified JSON or SVG file, will override the --infile option.' + 'Overrides the `infile` with JSON, stringified JSON, or SVG input', + promptOptions: { + type: 'text' + } }, options: { - value: false, - type: 'string', - description: 'An alias for the --instr option.' + value: null, + types: ['Object', 'string', 'null'], + envLink: 'EXPORT_OPTIONS', + description: 'Alias for the `instr` option', + promptOptions: { + type: 'text' + } + }, + svg: { + value: null, + types: ['string', 'null'], + envLink: 'EXPORT_SVG', + description: 'SVG string representation of the chart to render', + promptOptions: { + type: 'text' + } + }, + batch: { + value: null, + types: ['string', 'null'], + envLink: 'EXPORT_BATCH', + description: + 'Batch job string with input/output pairs: "in=out;in=out;..."', + promptOptions: { + type: 'text' + } }, outfile: { - value: false, - type: 'string', + value: null, + types: ['string', 'null'], + envLink: 'EXPORT_OUTFILE', description: - 'The output filename along with a type (jpeg, png, pdf, or svg). This will ignore the --type flag.' + 'Output filename with type. Can be jpeg, png, pdf, or svg and ignores `type` option', + promptOptions: { + type: 'text' + } }, type: { value: 'png', - type: 'string', + types: ['string'], envLink: 'EXPORT_TYPE', - description: 'The file export format. It can be jpeg, png, pdf, or svg.' + description: 'File export format. Can be jpeg, png, pdf, or svg', + promptOptions: { + type: 'select', + hint: 'Default: png', + choices: ['png', 'jpeg', 'pdf', 'svg'] + } }, constr: { value: 'chart', - type: 'string', + types: ['string'], envLink: 'EXPORT_CONSTR', description: - 'The constructor to use. Can be chart, stockChart, mapChart, or ganttChart.' + 'Chart constructor. Can be chart, stockChart, mapChart, or ganttChart', + promptOptions: { + type: 'select', + hint: 'Default: chart', + choices: ['chart', 'stockChart', 'mapChart', 'ganttChart'] + } + }, + b64: { + value: false, + types: ['boolean'], + envLink: 'EXPORT_B64', + description: + 'Whether or not to the chart should be received in Base64 format instead of binary', + promptOptions: { + type: 'toggle' + } + }, + noDownload: { + value: false, + types: ['boolean'], + envLink: 'EXPORT_NO_DOWNLOAD', + description: + 'Whether or not to include or exclude attachment headers in the response', + promptOptions: { + type: 'toggle' + } + }, + height: { + value: null, + types: ['number', 'null'], + envLink: 'EXPORT_HEIGHT', + description: 'Height of the exported chart, overrides chart settings', + promptOptions: { + type: 'number' + } + }, + width: { + value: null, + types: ['number', 'null'], + envLink: 'EXPORT_WIDTH', + description: 'Width of the exported chart, overrides chart settings', + promptOptions: { + type: 'number' + } + }, + scale: { + value: null, + types: ['number', 'null'], + envLink: 'EXPORT_SCALE', + description: + 'Scale of the exported chart, overrides chart settings. Ranges from 0.1 to 5.0', + promptOptions: { + type: 'number' + } }, defaultHeight: { value: 400, - type: 'number', + types: ['number'], envLink: 'EXPORT_DEFAULT_HEIGHT', - description: - 'the default height of the exported chart. Used when no value is set.' + description: 'Default height of the exported chart if not set', + promptOptions: { + type: 'number' + } }, defaultWidth: { value: 600, - type: 'number', + types: ['number'], envLink: 'EXPORT_DEFAULT_WIDTH', - description: - 'The default width of the exported chart. Used when no value is set.' + description: 'Default width of the exported chart if not set', + promptOptions: { + type: 'number' + } }, defaultScale: { value: 1, - type: 'number', + types: ['number'], envLink: 'EXPORT_DEFAULT_SCALE', description: - 'The default scale of the exported chart. Used when no value is set.' - }, - height: { - value: false, - type: 'number', - description: - 'The height of the exported chart, overriding the option in the chart settings.' - }, - width: { - value: false, - type: 'number', - description: - 'The width of the exported chart, overriding the option in the chart settings.' - }, - scale: { - value: false, - type: 'number', - description: - 'The scale of the exported chart, overriding the option in the chart settings. Ranges between 0.1 and 5.0.' + 'Default scale of the exported chart if not set. Ranges from 0.1 to 5.0', + promptOptions: { + type: 'number', + min: 0.1, + max: 5 + } }, globalOptions: { - value: false, - type: 'string', + value: null, + types: ['Object', 'string', 'null'], + envLink: 'EXPORT_GLOBAL_OPTIONS', description: - 'Either a stringified JSON or a filename containing options to be passed into the Highcharts.setOptions.' + 'JSON, stringified JSON or filename with global options for Highcharts.setOptions', + promptOptions: { + type: 'text' + } }, themeOptions: { - value: false, - type: 'string', + value: null, + types: ['Object', 'string', 'null'], + envLink: 'EXPORT_THEME_OPTIONS', description: - 'Either a stringified JSON or a filename containing theme options to be passed into the Highcharts.setOptions.' - }, - batch: { - value: false, - type: 'string', - description: - 'Initiates a batch job with a string containing input/output pairs: "in=out;in=out;...".' + 'JSON, stringified JSON or filename with theme options for Highcharts.setOptions', + promptOptions: { + type: 'text' + } }, rasterizationTimeout: { value: 1500, - type: 'number', + types: ['number'], envLink: 'EXPORT_RASTERIZATION_TIMEOUT', - description: - 'The duration in milliseconds to wait for rendering a webpage.' + description: 'Milliseconds to wait for webpage rendering', + promptOptions: { + type: 'number' + } } }, customLogic: { allowCodeExecution: { value: false, - type: 'boolean', + types: ['boolean'], envLink: 'CUSTOM_LOGIC_ALLOW_CODE_EXECUTION', description: - 'Controls whether the execution of arbitrary code is allowed during the exporting process.' + 'Allows or disallows execution of arbitrary code during exporting', + promptOptions: { + type: 'toggle' + } }, allowFileResources: { value: false, - type: 'boolean', + types: ['boolean'], envLink: 'CUSTOM_LOGIC_ALLOW_FILE_RESOURCES', description: - 'Controls the ability to inject resources from the filesystem. This setting has no effect when running as a server.' + 'Allows or disallows injection of filesystem resources (disabled in server mode)', + promptOptions: { + type: 'toggle' + } }, customCode: { - value: false, - type: 'string', + value: null, + types: ['string', 'null'], + envLink: 'CUSTOM_LOGIC_CUSTOM_CODE', description: - 'Custom code to execute before chart initialization. It can be a function, code wrapped within a function, or a filename with the .js extension.' + 'Custom code to execute before chart initialization. Can be a function, code wrapped in a function, or a .js filename', + promptOptions: { + type: 'text' + } }, callback: { - value: false, - type: 'string', + value: null, + types: ['string', 'null'], + envLink: 'CUSTOM_LOGIC_CALLBACK', description: - 'JavaScript code to run during construction. It can be a function or a filename with the .js extension.' + 'JavaScript code to run during construction. Can be a function or a .js filename', + promptOptions: { + type: 'text' + } }, resources: { - value: false, - type: 'string', + value: null, + types: ['Object', 'string', 'null'], + envLink: 'CUSTOM_LOGIC_RESOURCES', description: - 'Additional resource in the form of a stringified JSON, which may contain files, js, and css sections.' + 'Additional resources as JSON, stringified JSON, or filename, containing files, js, and css sections', + promptOptions: { + type: 'text' + } }, loadConfig: { - value: false, - type: 'string', + value: null, + types: ['string', 'null'], + envLink: 'CUSTOM_LOGIC_LOAD_CONFIG', legacyName: 'fromFile', - description: 'A file containing a pre-defined configuration to use.' + description: 'File with a pre-defined configuration to use', + promptOptions: { + type: 'text' + } }, createConfig: { - value: false, - type: 'string', + value: null, + types: ['string', 'null'], + envLink: 'CUSTOM_LOGIC_CREATE_CONFIG', description: - 'Enables setting options through a prompt and saving them in a provided config file.' + 'Prompt-based option setting, saved to a provided config file', + promptOptions: { + type: 'text' + } } }, server: { enable: { value: false, - type: 'boolean', + types: ['boolean'], envLink: 'SERVER_ENABLE', cliName: 'enableServer', - description: - 'When set to true, the server starts on the local IP address 0.0.0.0.' + description: 'Starts the server when true', + promptOptions: { + type: 'toggle' + } }, host: { value: '0.0.0.0', - type: 'string', + types: ['string'], envLink: 'SERVER_HOST', - description: - 'The hostname of the server. Additionally, it starts a server on the provided hostname.' + description: 'Hostname of the server', + promptOptions: { + type: 'text' + } }, port: { value: 7801, - type: 'number', + types: ['number'], envLink: 'SERVER_PORT', - description: 'The server port when enabled.' + description: 'Port number for the server', + promptOptions: { + type: 'number' + } + }, + uploadLimit: { + value: 3, + types: ['number'], + envLink: 'SERVER_UPLOAD_LIMIT', + description: 'Maximum request body size in MB', + promptOptions: { + type: 'number' + } }, benchmarking: { value: false, - type: 'boolean', + types: ['boolean'], envLink: 'SERVER_BENCHMARKING', cliName: 'serverBenchmarking', description: - 'Indicates whether to display the duration, in milliseconds, of specific actions that occur on the server while serving a request.' + 'Displays or not action durations in milliseconds during server requests', + promptOptions: { + type: 'toggle' + } }, proxy: { host: { - value: false, - type: 'string', + value: null, + types: ['string', 'null'], envLink: 'SERVER_PROXY_HOST', cliName: 'proxyHost', - description: 'The host of the proxy server to use, if it exists.' + description: 'Host of the proxy server, if applicable', + promptOptions: { + type: 'text' + } }, port: { - value: 8080, - type: 'number', + value: null, + types: ['number', 'null'], envLink: 'SERVER_PROXY_PORT', cliName: 'proxyPort', - description: 'The port of the proxy server to use, if it exists.' + description: 'Port of the proxy server, if applicable', + promptOptions: { + type: 'number' + } }, timeout: { value: 5000, - type: 'number', + types: ['number'], envLink: 'SERVER_PROXY_TIMEOUT', cliName: 'proxyTimeout', - description: 'The timeout for the proxy server to use, if it exists.' + description: + 'Timeout in milliseconds for the proxy server, if applicable', + promptOptions: { + type: 'number' + } } }, rateLimiting: { enable: { value: false, - type: 'boolean', + types: ['boolean'], envLink: 'SERVER_RATE_LIMITING_ENABLE', cliName: 'enableRateLimiting', - description: 'Enables rate limiting for the server.' + description: 'Enables or disables rate limiting on the server', + promptOptions: { + type: 'toggle' + } }, maxRequests: { value: 10, - type: 'number', + types: ['number'], envLink: 'SERVER_RATE_LIMITING_MAX_REQUESTS', legacyName: 'rateLimit', - description: 'The maximum number of requests allowed in one minute.' + description: 'Maximum number of requests allowed per minute', + promptOptions: { + type: 'number' + } }, window: { value: 1, - type: 'number', + types: ['number'], envLink: 'SERVER_RATE_LIMITING_WINDOW', - description: 'The time window, in minutes, for the rate limiting.' + description: 'Time window in minutes for rate limiting', + promptOptions: { + type: 'number' + } }, delay: { value: 0, - type: 'number', + types: ['number'], envLink: 'SERVER_RATE_LIMITING_DELAY', description: - 'The delay duration for each successive request before reaching the maximum limit.' + 'Delay duration between successive requests before reaching the limit', + promptOptions: { + type: 'number' + } }, trustProxy: { value: false, - type: 'boolean', + types: ['boolean'], envLink: 'SERVER_RATE_LIMITING_TRUST_PROXY', - description: 'Set this to true if the server is behind a load balancer.' + description: 'Set to true if the server is behind a load balancer', + promptOptions: { + type: 'toggle' + } }, skipKey: { - value: false, - type: 'string', + value: null, + types: ['string', 'null'], envLink: 'SERVER_RATE_LIMITING_SKIP_KEY', - description: - 'Allows bypassing the rate limiter and should be provided with the skipToken argument.' + description: 'Key to bypass the rate limiter, used with `skipToken`', + promptOptions: { + type: 'text' + } }, skipToken: { - value: false, - type: 'string', + value: null, + types: ['string', 'null'], envLink: 'SERVER_RATE_LIMITING_SKIP_TOKEN', - description: - 'Allows bypassing the rate limiter and should be provided with the skipKey argument.' + description: 'Token to bypass the rate limiter, used with `skipKey`', + promptOptions: { + type: 'text' + } } }, ssl: { enable: { value: false, - type: 'boolean', + types: ['boolean'], envLink: 'SERVER_SSL_ENABLE', cliName: 'enableSsl', - description: 'Enables or disables the SSL protocol.' + description: 'Enables or disables SSL protocol', + promptOptions: { + type: 'toggle' + } }, force: { value: false, - type: 'boolean', + types: ['boolean'], envLink: 'SERVER_SSL_FORCE', cliName: 'sslForce', legacyName: 'sslOnly', - description: - 'When set to true, the server is forced to serve only over HTTPS.' + description: 'Forces the server to use HTTPS only when true', + promptOptions: { + type: 'toggle' + } }, port: { value: 443, - type: 'number', + types: ['number'], envLink: 'SERVER_SSL_PORT', cliName: 'sslPort', - description: 'The port on which to run the SSL server.' + description: 'Port for the SSL server', + promptOptions: { + type: 'number' + } }, certPath: { - value: false, - type: 'string', + value: null, + types: ['string', 'null'], envLink: 'SERVER_SSL_CERT_PATH', + cliName: 'sslCertPath', legacyName: 'sslPath', - description: 'The path to the SSL certificate/key file.' + description: 'Path to the SSL certificate/key file', + promptOptions: { + type: 'text' + } } } }, pool: { minWorkers: { value: 4, - type: 'number', + types: ['number'], envLink: 'POOL_MIN_WORKERS', - description: 'The number of minimum and initial pool workers to spawn.' + description: 'Minimum and initial number of pool workers to spawn', + promptOptions: { + type: 'number' + } }, maxWorkers: { value: 8, - type: 'number', + types: ['number'], envLink: 'POOL_MAX_WORKERS', legacyName: 'workers', - description: 'The number of maximum pool workers to spawn.' + description: 'Maximum number of pool workers to spawn', + promptOptions: { + type: 'number' + } }, workLimit: { value: 40, - type: 'number', + types: ['number'], envLink: 'POOL_WORK_LIMIT', - description: - 'The number of work pieces that can be performed before restarting the worker process.' + description: 'Number of tasks a worker can handle before restarting', + promptOptions: { + type: 'number' + } }, acquireTimeout: { value: 5000, - type: 'number', + types: ['number'], envLink: 'POOL_ACQUIRE_TIMEOUT', - description: - 'The duration, in milliseconds, to wait for acquiring a resource.' + description: 'Timeout in milliseconds for acquiring a resource', + promptOptions: { + type: 'number' + } }, createTimeout: { value: 5000, - type: 'number', + types: ['number'], envLink: 'POOL_CREATE_TIMEOUT', - description: - 'The duration, in milliseconds, to wait for creating a resource.' + description: 'Timeout in milliseconds for creating a resource', + promptOptions: { + type: 'number' + } }, destroyTimeout: { value: 5000, - type: 'number', + types: ['number'], envLink: 'POOL_DESTROY_TIMEOUT', - description: - 'The duration, in milliseconds, to wait for destroying a resource.' + description: 'Timeout in milliseconds for destroying a resource', + promptOptions: { + type: 'number' + } }, idleTimeout: { value: 30000, - type: 'number', + types: ['number'], envLink: 'POOL_IDLE_TIMEOUT', - description: - 'The duration, in milliseconds, after which an idle resource is destroyed.' + description: 'Timeout in milliseconds for destroying idle resources', + promptOptions: { + type: 'number' + } }, createRetryInterval: { value: 200, - type: 'number', + types: ['number'], envLink: 'POOL_CREATE_RETRY_INTERVAL', description: - 'The duration, in milliseconds, to wait before retrying the create process in case of a failure.' + 'Interval in milliseconds before retrying resource creation on failure', + promptOptions: { + type: 'number' + } }, reaperInterval: { value: 1000, - type: 'number', + types: ['number'], envLink: 'POOL_REAPER_INTERVAL', description: - 'The duration, in milliseconds, after which the check for idle resources to destroy is triggered.' + 'Interval in milliseconds to check and destroy idle resources', + promptOptions: { + type: 'number' + } }, benchmarking: { value: false, - type: 'boolean', + types: ['boolean'], envLink: 'POOL_BENCHMARKING', cliName: 'poolBenchmarking', - description: - 'Indicate whether to show statistics for the pool of resources or not.' + description: 'Shows statistics for the pool of resources', + promptOptions: { + type: 'toggle' + } } }, logging: { level: { value: 4, - type: 'number', + types: ['number'], envLink: 'LOGGING_LEVEL', cliName: 'logLevel', - description: 'The logging level to be used.' + description: 'Logging verbosity level', + promptOptions: { + type: 'number', + round: 0, + min: 0, + max: 5 + } }, file: { value: 'highcharts-export-server.log', - type: 'string', + types: ['string'], envLink: 'LOGGING_FILE', cliName: 'logFile', description: - 'The name of a log file. The `logToFile` and `logDest` options also need to be set to enable file logging.' + 'Log file name. Requires `logToFile` and `logDest` to be set', + promptOptions: { + type: 'text' + } }, dest: { - value: 'log/', - type: 'string', + value: 'log', + types: ['string'], envLink: 'LOGGING_DEST', cliName: 'logDest', - description: - 'The path to store log files. The `logToFile` option also needs to be set to enable file logging.' + description: 'Path to store log files. Requires `logToFile` to be set', + promptOptions: { + type: 'text' + } }, toConsole: { value: true, - type: 'boolean', + types: ['boolean'], envLink: 'LOGGING_TO_CONSOLE', cliName: 'logToConsole', - description: 'Enables or disables showing logs in the console.' + description: 'Enables or disables console logging', + promptOptions: { + type: 'toggle' + } }, toFile: { value: true, - type: 'boolean', + types: ['boolean'], envLink: 'LOGGING_TO_FILE', cliName: 'logToFile', - description: - 'Enables or disables creation of the log directory and saving the log into a .log file.' + description: 'Enables or disables logging to a file', + promptOptions: { + type: 'toggle' + } } }, ui: { enable: { value: false, - type: 'boolean', + types: ['boolean'], envLink: 'UI_ENABLE', cliName: 'enableUi', - description: - 'Enables or disables the user interface (UI) for the export server.' + description: 'Enables or disables the UI for the Export Server', + promptOptions: { + type: 'toggle' + } }, route: { value: '/', - type: 'string', + types: ['string'], envLink: 'UI_ROUTE', cliName: 'uiRoute', - description: - 'The endpoint route to which the user interface (UI) should be attached.' + description: 'The endpoint route for the UI', + promptOptions: { + type: 'text' + } } }, other: { nodeEnv: { value: 'production', - type: 'string', + types: ['string'], envLink: 'OTHER_NODE_ENV', - description: 'The type of Node.js environment.' + description: 'The Node.js environment type', + promptOptions: { + type: 'text' + } }, listenToProcessExits: { value: true, - type: 'boolean', + types: ['boolean'], envLink: 'OTHER_LISTEN_TO_PROCESS_EXITS', - description: 'Decides whether or not to attach process.exit handlers.' + description: 'Whether or not to attach process.exit handlers', + promptOptions: { + type: 'toggle' + } }, noLogo: { value: false, - type: 'boolean', + types: ['boolean'], envLink: 'OTHER_NO_LOGO', - description: - 'Skip printing the logo on a startup. Will be replaced by a simple text.' + description: 'Display or skip printing the logo on startup', + promptOptions: { + type: 'toggle' + } }, hardResetPage: { value: false, - type: 'boolean', + types: ['boolean'], envLink: 'OTHER_HARD_RESET_PAGE', - description: 'Decides if the page content should be reset entirely.' + description: 'Whether or not to reset the page content entirely', + promptOptions: { + type: 'toggle' + } }, browserShellMode: { value: true, - type: 'boolean', + types: ['boolean'], envLink: 'OTHER_BROWSER_SHELL_MODE', - description: 'Decides if the browser runs in the shell mode.' + description: 'Whether or not to set the browser to run in shell mode', + promptOptions: { + type: 'toggle' + } } }, debug: { enable: { value: false, - type: 'boolean', + types: ['boolean'], envLink: 'DEBUG_ENABLE', cliName: 'enableDebug', - description: 'Enables or disables debug mode for the underlying browser.' + description: 'Enables or disables debug mode for the underlying browser', + promptOptions: { + type: 'toggle' + } }, headless: { - value: true, - type: 'boolean', + value: false, + types: ['boolean'], envLink: 'DEBUG_HEADLESS', description: - 'Controls the mode in which the browser is launched when in the debug mode.' + 'Whether or not to set the browser to run in headless mode during debugging', + promptOptions: { + type: 'toggle' + } }, devtools: { value: false, - type: 'boolean', + types: ['boolean'], envLink: 'DEBUG_DEVTOOLS', - description: - 'Decides whether to enable DevTools when the browser is in a headful state.' + description: 'Enables or disables DevTools in headful mode', + promptOptions: { + type: 'toggle' + } }, listenToConsole: { value: false, - type: 'boolean', + types: ['boolean'], envLink: 'DEBUG_LISTEN_TO_CONSOLE', description: - 'Decides whether to enable a listener for console messages sent from the browser.' + 'Enables or disables listening to console messages from the browser', + promptOptions: { + type: 'toggle' + } }, dumpio: { value: false, - type: 'boolean', + types: ['boolean'], envLink: 'DEBUG_DUMPIO', description: - 'Redirects browser process stdout and stderr to process.stdout and process.stderr.' + 'Redirects or not browser stdout and stderr to process.stdout and process.stderr', + promptOptions: { + type: 'toggle' + } }, slowMo: { value: 0, - type: 'number', + types: ['number'], envLink: 'DEBUG_SLOW_MO', - description: - 'Slows down Puppeteer operations by the specified number of milliseconds.' + description: 'Delays Puppeteer operations by the specified milliseconds', + promptOptions: { + type: 'number' + } }, debuggingPort: { value: 9222, - type: 'number', + types: ['number'], envLink: 'DEBUG_DEBUGGING_PORT', - description: 'Specifies the debugging port.' - } - } -}; - -// The config descriptions object for the prompts functionality. It contains -// information like: -// * Type of a prompt -// * Name of an option -// * Short description of a chosen option -// * Initial value -export const promptsConfig = { - puppeteer: [ - { - type: 'list', - name: 'args', - message: 'Puppeteer arguments', - initial: defaultConfig.puppeteer.args.value.join(','), - separator: ',' - } - ], - highcharts: [ - { - type: 'text', - name: 'version', - message: 'Highcharts version', - initial: defaultConfig.highcharts.version.value - }, - { - type: 'text', - name: 'cdnURL', - message: 'The URL of CDN', - initial: defaultConfig.highcharts.cdnURL.value - }, - { - type: 'multiselect', - name: 'coreScripts', - message: 'Available core scripts', - instructions: 'Space: Select specific, A: Select all, Enter: Confirm.', - choices: defaultConfig.highcharts.coreScripts.value - }, - { - type: 'multiselect', - name: 'moduleScripts', - message: 'Available module scripts', - instructions: 'Space: Select specific, A: Select all, Enter: Confirm.', - choices: defaultConfig.highcharts.moduleScripts.value - }, - { - type: 'multiselect', - name: 'indicatorScripts', - message: 'Available indicator scripts', - instructions: 'Space: Select specific, A: Select all, Enter: Confirm.', - choices: defaultConfig.highcharts.indicatorScripts.value - }, - { - type: 'list', - name: 'customScripts', - message: 'Custom scripts', - initial: defaultConfig.highcharts.customScripts.value.join(','), - separator: ',' - }, - { - type: 'toggle', - name: 'forceFetch', - message: 'Force re-fetch the scripts', - initial: defaultConfig.highcharts.forceFetch.value - }, - { - type: 'text', - name: 'cachePath', - message: 'The path to the cache directory', - initial: defaultConfig.highcharts.cachePath.value - } - ], - export: [ - { - type: 'select', - name: 'type', - message: 'The default export file type', - hint: `Default: ${defaultConfig.export.type.value}`, - initial: 0, - choices: ['png', 'jpeg', 'pdf', 'svg'] - }, - { - type: 'select', - name: 'constr', - message: 'The default constructor for Highcharts', - hint: `Default: ${defaultConfig.export.constr.value}`, - initial: 0, - choices: ['chart', 'stockChart', 'mapChart', 'ganttChart'] - }, - { - type: 'number', - name: 'defaultHeight', - message: 'The default fallback height of the exported chart', - initial: defaultConfig.export.defaultHeight.value - }, - { - type: 'number', - name: 'defaultWidth', - message: 'The default fallback width of the exported chart', - initial: defaultConfig.export.defaultWidth.value - }, - { - type: 'number', - name: 'defaultScale', - message: 'The default fallback scale of the exported chart', - initial: defaultConfig.export.defaultScale.value, - min: 0.1, - max: 5 - }, - { - type: 'number', - name: 'rasterizationTimeout', - message: 'The rendering webpage timeout in milliseconds', - initial: defaultConfig.export.rasterizationTimeout.value - } - ], - customLogic: [ - { - type: 'toggle', - name: 'allowCodeExecution', - message: 'Enable execution of custom code', - initial: defaultConfig.customLogic.allowCodeExecution.value - }, - { - type: 'toggle', - name: 'allowFileResources', - message: 'Enable file resources', - initial: defaultConfig.customLogic.allowFileResources.value - } - ], - server: [ - { - type: 'toggle', - name: 'enable', - message: 'Starts the server on 0.0.0.0', - initial: defaultConfig.server.enable.value - }, - { - type: 'text', - name: 'host', - message: 'Server hostname', - initial: defaultConfig.server.host.value - }, - { - type: 'number', - name: 'port', - message: 'Server port', - initial: defaultConfig.server.port.value - }, - { - type: 'toggle', - name: 'benchmarking', - message: 'Enable server benchmarking', - initial: defaultConfig.server.benchmarking.value - }, - { - type: 'text', - name: 'proxy.host', - message: 'The host of the proxy server to use', - initial: defaultConfig.server.proxy.host.value - }, - { - type: 'number', - name: 'proxy.port', - message: 'The port of the proxy server to use', - initial: defaultConfig.server.proxy.port.value - }, - { - type: 'number', - name: 'proxy.timeout', - message: 'The timeout for the proxy server to use', - initial: defaultConfig.server.proxy.timeout.value - }, - { - type: 'toggle', - name: 'rateLimiting.enable', - message: 'Enable rate limiting', - initial: defaultConfig.server.rateLimiting.enable.value - }, - { - type: 'number', - name: 'rateLimiting.maxRequests', - message: 'The maximum requests allowed per minute', - initial: defaultConfig.server.rateLimiting.maxRequests.value - }, - { - type: 'number', - name: 'rateLimiting.window', - message: 'The rate-limiting time window in minutes', - initial: defaultConfig.server.rateLimiting.window.value - }, - { - type: 'number', - name: 'rateLimiting.delay', - message: - 'The delay for each successive request before reaching the maximum', - initial: defaultConfig.server.rateLimiting.delay.value - }, - { - type: 'toggle', - name: 'rateLimiting.trustProxy', - message: 'Set to true if behind a load balancer', - initial: defaultConfig.server.rateLimiting.trustProxy.value - }, - { - type: 'text', - name: 'rateLimiting.skipKey', - message: - 'Allows bypassing the rate limiter when provided with the skipToken argument', - initial: defaultConfig.server.rateLimiting.skipKey.value - }, - { - type: 'text', - name: 'rateLimiting.skipToken', - message: - 'Allows bypassing the rate limiter when provided with the skipKey argument', - initial: defaultConfig.server.rateLimiting.skipToken.value - }, - { - type: 'toggle', - name: 'ssl.enable', - message: 'Enable SSL protocol', - initial: defaultConfig.server.ssl.enable.value - }, - { - type: 'toggle', - name: 'ssl.force', - message: 'Force serving only over HTTPS', - initial: defaultConfig.server.ssl.force.value - }, - { - type: 'number', - name: 'ssl.port', - message: 'SSL server port', - initial: defaultConfig.server.ssl.port.value - }, - { - type: 'text', - name: 'ssl.certPath', - message: 'The path to find the SSL certificate/key', - initial: defaultConfig.server.ssl.certPath.value - } - ], - pool: [ - { - type: 'number', - name: 'minWorkers', - message: 'The initial number of workers to spawn', - initial: defaultConfig.pool.minWorkers.value - }, - { - type: 'number', - name: 'maxWorkers', - message: 'The maximum number of workers to spawn', - initial: defaultConfig.pool.maxWorkers.value - }, - { - type: 'number', - name: 'workLimit', - message: - 'The pieces of work that can be performed before restarting a Puppeteer process', - initial: defaultConfig.pool.workLimit.value - }, - { - type: 'number', - name: 'acquireTimeout', - message: 'The number of milliseconds to wait for acquiring a resource', - initial: defaultConfig.pool.acquireTimeout.value - }, - { - type: 'number', - name: 'createTimeout', - message: 'The number of milliseconds to wait for creating a resource', - initial: defaultConfig.pool.createTimeout.value - }, - { - type: 'number', - name: 'destroyTimeout', - message: 'The number of milliseconds to wait for destroying a resource', - initial: defaultConfig.pool.destroyTimeout.value - }, - { - type: 'number', - name: 'idleTimeout', - message: 'The number of milliseconds after an idle resource is destroyed', - initial: defaultConfig.pool.idleTimeout.value - }, - { - type: 'number', - name: 'createRetryInterval', - message: - 'The retry interval in milliseconds after a create process fails', - initial: defaultConfig.pool.createRetryInterval.value - }, - { - type: 'number', - name: 'reaperInterval', - message: - 'The reaper interval in milliseconds after triggering the check for idle resources to destroy', - initial: defaultConfig.pool.reaperInterval.value - }, - { - type: 'toggle', - name: 'benchmarking', - message: 'Enable benchmarking for a resource pool', - initial: defaultConfig.pool.benchmarking.value - } - ], - logging: [ - { - type: 'number', - name: 'level', - message: - 'The log level (0: silent, 1: error, 2: warning, 3: notice, 4: verbose, 5: benchmark)', - initial: defaultConfig.logging.level.value, - round: 0, - min: 0, - max: 5 - }, - { - type: 'text', - name: 'file', - message: - 'A log file name. Set with --toFile and --logDest to enable file logging', - initial: defaultConfig.logging.file.value - }, - { - type: 'text', - name: 'dest', - message: 'The path to a log file when the file logging is enabled', - initial: defaultConfig.logging.dest.value - }, - { - type: 'toggle', - name: 'toConsole', - message: 'Enable logging to the console', - initial: defaultConfig.logging.toConsole.value - }, - { - type: 'toggle', - name: 'toFile', - message: 'Enables logging to a file', - initial: defaultConfig.logging.toFile.value - } - ], - ui: [ - { - type: 'toggle', - name: 'enable', - message: 'Enable UI for the export server', - initial: defaultConfig.ui.enable.value - }, - { - type: 'text', - name: 'route', - message: 'A route to attach the UI', - initial: defaultConfig.ui.route.value - } - ], - other: [ - { - type: 'text', - name: 'nodeEnv', - message: 'The type of Node.js environment', - initial: defaultConfig.other.nodeEnv.value - }, - { - type: 'toggle', - name: 'listenToProcessExits', - message: 'Set to false to skip attaching process.exit handlers', - initial: defaultConfig.other.listenToProcessExits.value - }, - { - type: 'toggle', - name: 'noLogo', - message: 'Skip printing the logo on startup. Replaced by simple text', - initial: defaultConfig.other.noLogo.value - }, - { - type: 'toggle', - name: 'hardResetPage', - message: 'Decides if the page content should be reset entirely', - initial: defaultConfig.other.hardResetPage.value - }, - { - type: 'toggle', - name: 'browserShellMode', - message: 'Decides if the browser runs in the shell mode', - initial: defaultConfig.other.browserShellMode.value - } - ], - debug: [ - { - type: 'toggle', - name: 'enable', - message: 'Enables debug mode for the browser instance', - initial: defaultConfig.debug.enable.value - }, - { - type: 'toggle', - name: 'headless', - message: 'The mode setting for the browser', - initial: defaultConfig.debug.headless.value - }, - { - type: 'toggle', - name: 'devtools', - message: 'The DevTools for the headful browser', - initial: defaultConfig.debug.devtools.value - }, - { - type: 'toggle', - name: 'listenToConsole', - message: 'The event listener for console messages from the browser', - initial: defaultConfig.debug.listenToConsole.value - }, - { - type: 'toggle', - name: 'dumpio', - message: 'Redirects the browser stdout and stderr to NodeJS process', - initial: defaultConfig.debug.dumpio.value - }, - { - type: 'number', - name: 'slowMo', - message: 'Puppeteer operations slow down in milliseconds', - initial: defaultConfig.debug.slowMo.value - }, - { - type: 'number', - name: 'debuggingPort', - message: 'The port number for debugging', - initial: defaultConfig.debug.debuggingPort.value - } - ] -}; - -// Absolute props that, in case of merging recursively, need to be force merged -export const absoluteProps = [ - 'options', - 'globalOptions', - 'themeOptions', - 'resources', - 'payload' -]; - -// Argument nesting level of all export server options -export const nestedArgs = {}; - -/** - * Recursively creates a chain of nested arguments from an object. - * - * @param {Object} obj - The object containing nested arguments. - * @param {string} propChain - The current chain of nested properties - * (used internally during recursion). - */ -const createNestedArgs = (obj, propChain = '') => { - Object.keys(obj).forEach((k) => { - if (!['puppeteer', 'highcharts'].includes(k)) { - const entry = obj[k]; - if (typeof entry.value === 'undefined') { - // Go deeper in the nested arguments - createNestedArgs(entry, `${propChain}.${k}`); - } else { - // Create the chain of nested arguments - nestedArgs[entry.cliName || k] = `${propChain}.${k}`.substring(1); - - // Support for the legacy, PhantomJS properties names - if (entry.legacyName !== undefined) { - nestedArgs[entry.legacyName] = `${propChain}.${k}`.substring(1); - } + description: 'Port used for debugging', + promptOptions: { + type: 'number' } } - }); + } }; -createNestedArgs(defaultConfig); +export default defaultConfig; diff --git a/lib/server/error.js b/lib/server/error.js deleted file mode 100644 index eaeef9f9..00000000 --- a/lib/server/error.js +++ /dev/null @@ -1,48 +0,0 @@ -import { envs } from '../envs.js'; -import { logWithStack } from '../logger.js'; - -/** - * Middleware for logging errors with stack trace and handling error response. - * - * @param {Error} error - The error object. - * @param {Express.Request} req - The Express request object. - * @param {Express.Response} res - The Express response object. - * @param {Function} next - The next middleware function. - */ -const logErrorMiddleware = (error, req, res, next) => { - // Display the error with stack in a correct format - logWithStack(1, error); - - // Delete the stack for the environment other than the development - if (envs.OTHER_NODE_ENV !== 'development') { - delete error.stack; - } - - // Call the returnErrorMiddleware - next(error); -}; - -/** - * Middleware for returning error response. - * - * @param {Error} error - The error object. - * @param {Express.Request} req - The Express request object. - * @param {Express.Response} res - The Express response object. - * @param {Function} next - The next middleware function. - */ -const returnErrorMiddleware = (error, req, res, next) => { - // Gather all requied information for the response - const { statusCode: stCode, status, message, stack } = error; - const statusCode = stCode || status || 400; - - // Set and return response - res.status(statusCode).json({ statusCode, message, stack }); -}; - -export default (app) => { - // Add log error middleware - app.use(logErrorMiddleware); - - // Add set status and return error middleware - app.use(returnErrorMiddleware); -}; diff --git a/lib/server/middlewares/error.js b/lib/server/middlewares/error.js new file mode 100644 index 00000000..f454c89a --- /dev/null +++ b/lib/server/middlewares/error.js @@ -0,0 +1,81 @@ +/******************************************************************************* + +Highcharts Export Server + +Copyright (c) 2016-2025, Highsoft + +Licenced under the MIT licence. + +Additionally a valid Highcharts license is required for use. + +See LICENSE file in root for details. + +*******************************************************************************/ + +/** + * @overview Provides middleware functions for logging errors with stack traces + * and handling error responses in an Express application. + */ + +import { getOptions } from '../../config.js'; +import { logWithStack } from '../../logger.js'; + +/** + * Middleware for logging errors with stack trace and handling error response. + * + * @function logErrorMiddleware + * + * @param {Error} error - The error object. + * @param {Express.Request} request - The Express request object. + * @param {Express.Response} response - The Express response object. + * @param {Function} next - The next middleware function. + * + * @returns {undefined} The call to the next middleware function with + * the passed error. + */ +function logErrorMiddleware(error, request, response, next) { + // Display the error with stack in a correct format + logWithStack(1, error); + + // Delete the stack for the environment other than the development + if (getOptions().other.nodeEnv !== 'development') { + delete error.stack; + } + + // Call the `returnErrorMiddleware` middleware + return next(error); +} + +/** + * Middleware for returning error response. + * + * @function returnErrorMiddleware + * + * @param {Error} error - The error object. + * @param {Express.Request} request - The Express request object. + * @param {Express.Response} response - The Express response object. + * @param {Function} next - The next middleware function. + */ +function returnErrorMiddleware(error, request, response, next) { + // Gather all requied information for the response + const { message, stack } = error; + + // Use the error's status code or the default 400 + const statusCode = error.statusCode || 400; + + // Set and return response + response.status(statusCode).json({ statusCode, message, stack }); +} + +/** + * Adds the error middlewares to the passed express app instance. + * + * @param {Express} app - The Express app instance. + */ +export default function errorMiddleware(app) { + // Add log error middleware + app.use(logErrorMiddleware); + + // Add set status and return error middleware + app.use(returnErrorMiddleware); +} diff --git a/lib/server/middlewares/rateLimiting.js b/lib/server/middlewares/rateLimiting.js new file mode 100644 index 00000000..b7e23694 --- /dev/null +++ b/lib/server/middlewares/rateLimiting.js @@ -0,0 +1,105 @@ +/******************************************************************************* + +Highcharts Export Server + +Copyright (c) 2016-2025, Highsoft + +Licenced under the MIT licence. + +Additionally a valid Highcharts license is required for use. + +See LICENSE file in root for details. + +*******************************************************************************/ + +/** + * @overview Provides middleware functions for configuring and enabling rate + * limiting in an Express application. + */ + +import rateLimit from 'express-rate-limit'; + +import { log } from '../../logger.js'; + +import ExportError from '../../errors/ExportError.js'; + +/** + * Middleware for enabling rate limiting on the specified Express app. + * + * @param {Express} app - The Express app instance. + * @param {Object} rateLimitingOptions - The configuration object containing + * `rateLimiting` options. + * + * @throws {ExportError} Throws an `ExportError` if could not configure and set + * the rate limiting options. + */ +export default function rateLimitingMiddleware(app, rateLimitingOptions) { + try { + // Check if the rate limiting is enabled and the app exists + if (app && rateLimitingOptions.enable) { + const message = + 'Too many requests, you have been rate limited. Please try again later.'; + + // Options for the rate limiter + const rateOptions = { + window: rateLimitingOptions.window || 1, + maxRequests: rateLimitingOptions.maxRequests || 30, + delay: rateLimitingOptions.delay || 0, + trustProxy: rateLimitingOptions.trustProxy || false, + skipKey: rateLimitingOptions.skipKey || null, + skipToken: rateLimitingOptions.skipToken || null + }; + + // Set if behind a proxy + if (rateOptions.trustProxy) { + app.enable('trust proxy'); + } + + // Create a limiter + const limiter = rateLimit({ + // Time frame for which requests are checked and remembered + windowMs: rateOptions.window * 60 * 1000, + // Limit each IP to 100 requests per `windowMs` + limit: rateOptions.maxRequests, + // Disable delaying, full speed until the max limit is reached + delayMs: rateOptions.delay, + handler: (request, response) => { + response.format({ + json: () => { + response.status(429).send({ message }); + }, + default: () => { + response.status(429).send(message); + } + }); + }, + skip: (request) => { + // Allow bypassing the limiter if a valid key/token has been sent + if ( + rateOptions.skipKey !== null && + rateOptions.skipToken !== null && + request.query.key === rateOptions.skipKey && + request.query.access_token === rateOptions.skipToken + ) { + log(4, '[rate limiting] Skipping rate limiter.'); + return true; + } + return false; + } + }); + + // Use a limiter as a middleware + app.use(limiter); + + log( + 3, + `[rate limiting] Enabled rate limiting with ${rateOptions.maxRequests} requests per ${rateOptions.window} minute for each IP, trusting proxy: ${rateOptions.trustProxy}.` + ); + } + } catch (error) { + throw new ExportError( + '[rate limiting] Could not configure and set the rate limiting options.', + 500 + ).setError(error); + } +} diff --git a/lib/server/middlewares/validation.js b/lib/server/middlewares/validation.js new file mode 100644 index 00000000..96d6b51d --- /dev/null +++ b/lib/server/middlewares/validation.js @@ -0,0 +1,204 @@ +/******************************************************************************* + +Highcharts Export Server + +Copyright (c) 2016-2025, Highsoft + +Licenced under the MIT licence. + +Additionally a valid Highcharts license is required for use. + +See LICENSE file in root for details. + +*******************************************************************************/ + +/** + * @overview Provides middleware functions for validating incoming HTTP requests + * in an Express application. This module ensures that requests contain + * appropriate content types and valid request bodies, including proper JSON + * structures and chart data for exports. It checks for potential issues such + * as missing or malformed data, private range URLs in SVG payloads, and allows + * for flexible options validation. The middleware logs detailed information + * and handles errors related to incorrect payloads, chart data, and private URL + * usage. + */ + +import { v4 as uuid } from 'uuid'; + +import { getAllowCodeExecution } from '../../chart.js'; +import { isAllowedConfig } from '../../config.js'; +import { log } from '../../logger.js'; +import { isObjectEmpty, isPrivateRangeUrlFound } from '../../utils.js'; + +import ExportError from '../../errors/ExportError.js'; + +/** + * Middleware for validating the content-type header. + * + * @function contentTypeMiddleware + * + * @param {Express.Request} request - The Express request object. + * @param {Express.Response} response - The Express response object. + * @param {Function} next - The next middleware function. + * + * @returns {undefined} The call to the next middleware function. + * + * @throws {ExportError} Throws an `ExportError` if the content-type + * is not correct. + */ +function contentTypeMiddleware(request, response, next) { + try { + // Get the content type header + const contentType = request.headers['content-type'] || ''; + + // Allow only JSON, URL-encoded and form data without files types of data + if ( + !contentType.includes('application/json') && + !contentType.includes('application/x-www-form-urlencoded') && + !contentType.includes('multipart/form-data') + ) { + throw new ExportError( + '[validation] Content-Type must be application/json, application/x-www-form-urlencoded, or multipart/form-data.', + 415 + ); + } + + // Call the `requestBodyMiddleware` middleware + return next(); + } catch (error) { + return next(error); + } +} + +/** + * Middleware for validating the request's body. + * + * @function requestBodyMiddleware + * + * @param {Express.Request} request - The Express request object. + * @param {Express.Response} response - The Express response object. + * @param {Function} next - The next middleware function. + * + * @returns {undefined} The call to the next middleware function. + * + * @throws {ExportError} Throws an `ExportError` if the body is not correct. + * @throws {ExportError} Throws an `ExportError` if the chart data from the body + * is not correct. + * @throws {ExportError} Throws an `ExportError` in case of the private range + * url error. + */ +function requestBodyMiddleware(request, response, next) { + try { + // Get the request body + const body = request.body; + + // Create a unique ID for a request + const requestId = uuid(); + + // Throw an error if there is no correct body + if (!body || isObjectEmpty(body)) { + log( + 2, + `[validation] Request [${requestId}] - The request from ${ + request.headers['x-forwarded-for'] || request.connection.remoteAddress + } was incorrect. Received payload is empty.` + ); + + throw new ExportError( + `[validation] Request [${requestId}] - The request body is required. Please ensure that your Content-Type header is correct. Accepted types are 'application/json' and 'multipart/form-data'.`, + 400 + ); + } + + // Get the allowCodeExecution option for the server + const allowCodeExecution = getAllowCodeExecution(); + + // Find a correct chart options + const instr = isAllowedConfig( + // Use one of the below + body.instr || body.options || body.infile || body.data, + // Stringify options + true, + // Allow or disallow functions + allowCodeExecution + ); + + // Throw an error if there is no correct chart data + if (instr === null && !body.svg) { + log( + 2, + `[validation] Request [${requestId}] - The request from ${ + request.headers['x-forwarded-for'] || request.connection.remoteAddress + } was incorrect. Received payload is missing correct chart data for export: ${JSON.stringify(body)}.` + ); + + throw new ExportError( + `[validation] Request [${requestId}] - No correct chart data found. Ensure that you are using either application/json or multipart/form-data headers. If sending JSON, make sure the chart data is in the 'infile', 'options', or 'data' attribute. If sending SVG, ensure it is in the 'svg' attribute.`, + 400 + ); + } + + // Throw an error if test of xlink:href elements from payload's SVG fails + if (body.svg && isPrivateRangeUrlFound(body.svg)) { + throw new ExportError( + `[validation] Request [${requestId}] - SVG potentially contain at least one forbidden URL in 'xlink:href' element. Please review the SVG content and ensure that all referenced URLs comply with security policies.`, + 400 + ); + } + + // Get the request options and store parsed structure in the request + request.validatedOptions = { + // Set the created ID as a `requestId` property in the options + requestId, + export: { + instr, + svg: body.svg, + outfile: + body.outfile || + `${request.params.filename || 'chart'}.${body.type || 'png'}`, + type: body.type, + constr: body.constr, + b64: body.b64, + noDownload: body.noDownload, + height: body.height, + width: body.width, + scale: body.scale, + globalOptions: isAllowedConfig( + body.globalOptions, + true, + allowCodeExecution + ), + themeOptions: isAllowedConfig( + body.themeOptions, + true, + allowCodeExecution + ) + }, + customLogic: { + allowCodeExecution, + allowFileResources: false, + customCode: body.customCode, + callback: body.callback, + resources: isAllowedConfig(body.resources, true, allowCodeExecution) + } + }; + + // Call the next middleware + return next(); + } catch (error) { + return next(error); + } +} + +/** + * Adds the validation middlewares to the passed express app instance. + * + * @param {Express} app - The Express app instance. + */ +export default function validationMiddleware(app) { + // Add content type validation middleware + app.post(['/', '/:filename'], contentTypeMiddleware); + + // Add request body request validation middleware + app.post(['/', '/:filename'], requestBodyMiddleware); +} diff --git a/lib/server/rateLimiting.js b/lib/server/rateLimiting.js deleted file mode 100644 index d4686203..00000000 --- a/lib/server/rateLimiting.js +++ /dev/null @@ -1,83 +0,0 @@ -/******************************************************************************* - -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 rateLimit from 'express-rate-limit'; - -import { log } from '../logger.js'; - -/** - * Middleware for enabling rate limiting on the specified Express app. - * - * @param {Express} app - The Express app instance. - * @param {Object} limitConfig - Configuration options for rate limiting. - */ -export default (app, limitConfig) => { - const msg = - 'Too many requests, you have been rate limited. Please try again later.'; - - // Options for the rate limiter - const rateOptions = { - max: limitConfig.maxRequests || 30, - window: limitConfig.window || 1, - delay: limitConfig.delay || 0, - trustProxy: limitConfig.trustProxy || false, - skipKey: limitConfig.skipKey || false, - skipToken: limitConfig.skipToken || false - }; - - // Set if behind a proxy - if (rateOptions.trustProxy) { - app.enable('trust proxy'); - } - - // Create a limiter - const limiter = rateLimit({ - windowMs: rateOptions.window * 60 * 1000, - // Limit each IP to 100 requests per windowMs - max: rateOptions.max, - // Disable delaying, full speed until the max limit is reached - delayMs: rateOptions.delay, - handler: (request, response) => { - response.format({ - json: () => { - response.status(429).send({ message: msg }); - }, - default: () => { - response.status(429).send(msg); - } - }); - }, - skip: (request) => { - // Allow bypassing the limiter if a valid key/token has been sent - if ( - rateOptions.skipKey !== false && - rateOptions.skipToken !== false && - request.query.key === rateOptions.skipKey && - request.query.access_token === rateOptions.skipToken - ) { - log(4, '[rate limiting] Skipping rate limiter.'); - return true; - } - return false; - } - }); - - // Use a limiter as a middleware - app.use(limiter); - - log( - 3, - `[rate limiting] Enabled rate limiting with ${rateOptions.max} requests per ${rateOptions.window} minute for each IP, trusting proxy: ${rateOptions.trustProxy}.` - ); -}; diff --git a/lib/server/routes/export.js b/lib/server/routes/export.js index 4a3c656c..c664a573 100644 --- a/lib/server/routes/export.js +++ b/lib/server/routes/export.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,21 +12,20 @@ See LICENSE file in root for details. *******************************************************************************/ -import { v4 as uuid } from 'uuid'; +/** + * @overview Defines the export routes and logic for handling chart export + * requests in an Express server. This module processes incoming requests + * to export charts in various formats (e.g. JPEG, PNG, PDF, SVG). It integrates + * with Highcharts' core functionalities and supports both immediate download + * responses and Base64-encoded content returns. The code also features + * benchmarking for performance monitoring. + */ -import { getAllowCodeExecution, startExport } from '../../chart.js'; -import { getOptions, mergeConfigOptions } from '../../config.js'; +import { startExport } from '../../chart.js'; import { log } from '../../logger.js'; -import { - fixType, - isCorrectJSON, - isObjectEmpty, - isPrivateRangeUrlFound, - optionsStringify, - measureTime -} from '../../utils.js'; +import { getBase64, measureTime } from '../../utils.js'; -import HttpError from '../../errors/HttpError.js'; +import ExportError from '../../errors/ExportError.js'; // Reversed MIME types const reversedMime = { @@ -37,209 +36,53 @@ const reversedMime = { svg: 'image/svg+xml' }; -// The requests counter -let requestsCounter = 0; - -// The array of callbacks to call before a request -const beforeRequest = []; - -// The array of callbacks to call after a request -const afterRequest = []; - -/** - * Invokes an array of callback functions with specified parameters, allowing - * customization of request handling. - * - * @param {Function[]} callbacks - An array of callback functions - * to be executed. - * @param {Express.Request} request - The Express request object. - * @param {Express.Response} response - The Express response object. - * @param {Object} data - An object containing parameters like id, uniqueId, - * type, and body. - * - * @returns {boolean} - Returns a boolean indicating the overall result - * of the callback invocations. - */ -const doCallbacks = (callbacks, request, response, data) => { - let result = true; - const { id, uniqueId, type, body } = data; - - callbacks.some((callback) => { - if (callback) { - let callResponse = callback(request, response, id, uniqueId, type, body); - - if (callResponse !== undefined && callResponse !== true) { - result = callResponse; - } - - return true; - } - }); - - return result; -}; - /** * Handles the export requests from the client. * + * @async + * @function requestExport + * * @param {Express.Request} request - The Express request object. * @param {Express.Response} response - The Express response object. * @param {Function} next - The next middleware function. * - * @returns {Promise} - A promise that resolves once the export process + * @returns {Promise} A Promise that resolves once the export process * is complete. */ -const exportHandler = async (request, response, next) => { +async function requestExport(request, response, next) { try { - // Start counting time - const stopCounter = measureTime(); - - // Create a unique ID for a request - const uniqueId = uuid().replace(/-/g, ''); - - // Get the current server's general options - const defaultOptions = getOptions(); - - const body = request.body; - const id = ++requestsCounter; - - let type = fixType(body.type); - - // Throw 'Bad Request' if there's no body - if (!body || isObjectEmpty(body)) { - throw new HttpError( - 'The request body is required. Please ensure that your Content-Type header is correct (accepted types are application/json and multipart/form-data).', - 400 - ); - } - - // All of the below can be used - let instr = isCorrectJSON(body.infile || body.options || body.data); - - // Throw 'Bad Request' if there's no JSON or SVG to export - if (!instr && !body.svg) { - log( - 2, - `The request with ID ${uniqueId} from ${ - request.headers['x-forwarded-for'] || request.connection.remoteAddress - } was incorrect: - Content-Type: ${request.headers['content-type']}. - Chart constructor: ${body.constr}. - Dimensions: ${body.width}x${body.height} @ ${body.scale} scale. - Type: ${type}. - Is SVG set? ${typeof body.svg !== 'undefined'}. - B64? ${typeof body.b64 !== 'undefined'}. - No download? ${typeof body.noDownload !== 'undefined'}. - - Payload received: ${JSON.stringify(body.infile || body.options || body.data || body.svg)} - - ` - ); - - throw new HttpError( - "No correct chart data found. Ensure that you are using either application/json or multipart/form-data headers. If sending JSON, make sure the chart data is in the 'infile', 'options', or 'data' attribute. If sending SVG, ensure it is in the 'svg' attribute.", - 400 - ); - } - - let callResponse = false; - - // Call the before request functions - callResponse = doCallbacks(beforeRequest, request, response, { - id, - uniqueId, - type, - body - }); - - // Block the request if one of a callbacks failed - if (callResponse !== true) { - return response.send(callResponse); - } - - let connectionAborted = false; + // Start counting time for a request + const requestCounter = measureTime(); // In case the connection is closed, force to abort further actions + let connectionAborted = false; request.socket.on('close', (hadErrors) => { if (hadErrors) { connectionAborted = true; } }); - log(4, `[export] Got an incoming HTTP request with ID ${uniqueId}.`); + // Get the options previously validated in the validation middleware + const options = request.validatedOptions; - body.constr = (typeof body.constr === 'string' && body.constr) || 'chart'; - - // Gather and organize options from the payload - const requestOptions = { - export: { - instr, - type, - constr: body.constr[0].toLowerCase() + body.constr.substr(1), - height: body.height, - width: body.width, - scale: body.scale || defaultOptions.export.scale, - globalOptions: isCorrectJSON(body.globalOptions, true), - themeOptions: isCorrectJSON(body.themeOptions, true) - }, - customLogic: { - allowCodeExecution: getAllowCodeExecution(), - allowFileResources: false, - resources: isCorrectJSON(body.resources, true), - callback: body.callback, - customCode: body.customCode - } - }; + // Get the request id + const requestId = options.requestId; - if (instr) { - // Stringify JSON with options - requestOptions.export.instr = optionsStringify( - instr, - requestOptions.customLogic.allowCodeExecution - ); - } - - // Merge the request options into default ones - const options = mergeConfigOptions(defaultOptions, requestOptions); - - // Save the JSON if exists - options.export.options = instr; - - // Lastly, add the server specific arguments into options as payload - options.payload = { - svg: body.svg || false, - b64: body.b64 || false, - noDownload: body.noDownload || false, - requestId: uniqueId - }; - - // Test xlink:href elements from payload's SVG - if (body.svg && isPrivateRangeUrlFound(options.payload.svg)) { - throw new HttpError( - 'SVG potentially contain at least one forbidden URL in xlink:href element. Please review the SVG content and ensure that all referenced URLs comply with security policies.', - 400 - ); - } + // Info about an incoming request with correct data + log(4, `[export] Request [${requestId}] - Got an incoming HTTP request.`); // Start the export process - await startExport(options, (error, info) => { + await startExport(options, (error, data) => { // Remove the close event from the socket request.socket.removeAllListeners('close'); - // After the whole exporting process - if (defaultOptions.server.benchmarking) { - log( - 5, - `[benchmark] Request with ID ${uniqueId} - After the whole exporting process: ${stopCounter()}ms.` - ); - } - // If the connection was closed, do nothing if (connectionAborted) { - return log( + log( 3, - `[export] The client closed the connection before the chart finished processing.` + `[export] Request [${requestId}] - The client closed the connection before the chart finished processing.` ); + return; } // If error, log it and send it to the error middleware @@ -248,64 +91,72 @@ const exportHandler = async (request, response, next) => { } // If data is missing, log the message and send it to the error middleware - if (!info || !info.result) { - throw new HttpError( - `Unexpected return from chart generation. Please check your request data. For the request with ID ${uniqueId}, the result is ${info.result}.`, + if (!data || !data.result) { + log( + 2, + `[export] Request [${requestId}] - Request from ${ + request.headers['x-forwarded-for'] || + request.connection.remoteAddress + } was incorrect. Received result is ${data.result}.` + ); + + throw new ExportError( + `[export] Request [${requestId}] - Unexpected return of the export result from the chart generation. Please check your request data.`, 400 ); } - // Get the type from options - type = info.options.export.type; - - // The after request callbacks - doCallbacks(afterRequest, request, response, { id, body: info.result }); + // Return the result in an appropriate format + if (data.result) { + log( + 3, + `[export] Request [${requestId}] - The whole exporting process took ${requestCounter()}ms.` + ); - if (info.result) { - // If only base64 is required, return it - if (body.b64) { - // SVG Exception for the Highcharts 11.3.0 version - if (type === 'pdf' || type == 'svg') { - return response.send( - Buffer.from(info.result, 'utf8').toString('base64') - ); - } + // Get the `type`, `b64`, `noDownload`, and `outfile` from options + const { type, b64, noDownload, outfile } = data.options.export; - return response.send(info.result); + // If only Base64 is required, return it + if (b64) { + return response.send(getBase64(data.result, type)); } // Set correct content type response.header('Content-Type', reversedMime[type] || 'image/png'); // Decide whether to download or not chart file - if (!body.noDownload) { - response.attachment( - `${request.params.filename || request.body.filename || 'chart'}.${ - type || 'png' - }` - ); + if (!noDownload) { + response.attachment(outfile); } - // If SVG, return plain content + // If SVG, return plain content, otherwise a b64 string from a buffer return type === 'svg' - ? response.send(info.result) - : response.send(Buffer.from(info.result, 'base64')); + ? response.send(data.result) + : response.send(Buffer.from(data.result, 'base64')); } }); } catch (error) { - next(error); + return next(error); } -}; +} -export default (app) => { +/** + * Adds the `export` routes. + * + * @function exportRoutes + * + * @param {Express} app - The Express app instance. + */ +export default function exportRoutes(app) { /** - * Adds the POST / a route for handling POST requests at the root endpoint. + * Adds the POST '/' - A route for handling POST requests at the root + * endpoint. */ - app.post('/', exportHandler); + app.post('/', requestExport); /** - * Adds the POST /:filename a route for handling POST requests with + * Adds the POST '/:filename' - A route for handling POST requests with * a specified filename parameter. */ - app.post('/:filename', exportHandler); -}; + app.post('/:filename', requestExport); +} diff --git a/lib/server/routes/health.js b/lib/server/routes/health.js index 5925cc68..dd9345fa 100644 --- a/lib/server/routes/health.js +++ b/lib/server/routes/health.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,102 +12,132 @@ See LICENSE file in root for details. *******************************************************************************/ -import { readFileSync } from 'fs'; -import { join as pather } from 'path'; -import { log } from '../../logger.js'; +/** + * @overview Defines an Express route for server health monitoring, including + * uptime, success rates, and other server statistics. + */ -import { version } from '../../cache.js'; -import { addInterval } from '../../intervals.js'; -import pool from '../../pool.js'; -import { __dirname } from '../../utils.js'; +import { readFileSync } from 'fs'; +import { join } from 'path'; -const pkgFile = JSON.parse(readFileSync(pather(__dirname, 'package.json'))); +import { getHcVersion } from '../../cache.js'; +import { log } from '../../logger.js'; +import { getPoolStats, getPoolInfoJSON } from '../../pool.js'; +import { addTimer } from '../../timer.js'; +import { __dirname, getNewDateTime } from '../../utils.js'; +// Set the start date of the server const serverStartTime = new Date(); +// Get the `package.json` content +const packageFile = JSON.parse( + readFileSync(join(__dirname, 'package.json'), 'utf8') +); + +// An array for success rate ratios const successRates = []; -const recordInterval = 60 * 1000; // record every minute -const windowSize = 30; // 30 minutes + +// Record every minute +const recordInterval = 60 * 1000; + +// 30 minutes +const windowSize = 30; /** - * Calculates moving average indicator based on the data from the successRates + * Calculates moving average indicator based on the data from the `successRates` * array. * - * @returns {number} - A moving average for success ratio of the server exports. + * @function _calculateMovingAverage + * + * @returns {number} A moving average for success ratio of the server exports. */ -function calculateMovingAverage() { - const sum = successRates.reduce((a, b) => a + b, 0); - return sum / successRates.length; +function _calculateMovingAverage() { + return successRates.reduce((a, b) => a + b, 0) / successRates.length; } /** * Starts the interval responsible for calculating current success rate ratio - * and gathers + * and collects records to the `successRates` array. * - * @returns {NodeJS.Timeout} id - Id of an interval. + * @function _startSuccessRate + * + * @returns {NodeJS.Timeout} Id of an interval. */ -export const startSuccessRate = () => - setInterval(() => { - const stats = pool.getStats(); +function _startSuccessRate() { + return setInterval(() => { + const stats = getPoolStats(); const successRatio = - stats.exportAttempts === 0 + stats.exportsAttempted === 0 ? 1 - : (stats.performedExports / stats.exportAttempts) * 100; + : (stats.exportsPerformed / stats.exportsAttempted) * 100; successRates.push(successRatio); if (successRates.length > windowSize) { successRates.shift(); } }, recordInterval); +} /** - * Adds the /health and /success-moving-average routes - * which output basic stats for the server. + * Adds the `health` routes. + * + * @function healthRoutes + * + * @param {Express} app - The Express app instance. */ -export default function addHealthRoutes(app) { - if (!app) { - return false; - } - +export default function healthRoutes(app) { // Start processing success rate ratio interval and save its id to the array - // for the graceful clearing on shutdown with injected addInterval funtion - addInterval(startSuccessRate()); - - app.get('/health', (_, res) => { - const stats = pool.getStats(); - const period = successRates.length; - const movingAverage = calculateMovingAverage(); - - log(4, '[health.js] GET /health [200] - returning server health.'); - - res.send({ - status: 'OK', - bootTime: serverStartTime, - uptime: - Math.floor( - (new Date().getTime() - serverStartTime.getTime()) / 1000 / 60 - ) + ' minutes', - version: pkgFile.version, - highchartsVersion: version(), - averageProcessingTime: stats.spentAverage, - performedExports: stats.performedExports, - failedExports: stats.droppedExports, - exportAttempts: stats.exportAttempts, - sucessRatio: (stats.performedExports / stats.exportAttempts) * 100, - // eslint-disable-next-line import/no-named-as-default-member - pool: pool.getPoolInfoJSON(), - - // Moving average - period, - movingAverage, - message: - isNaN(movingAverage) || !successRates.length - ? 'Too early to report. No exports made yet. Please check back soon.' - : `Last ${period} minutes had a success rate of ${movingAverage.toFixed(2)}%.`, - - // SVG/JSON attempts - svgExportAttempts: stats.exportFromSvgAttempts, - jsonExportAttempts: stats.performedExports - stats.exportFromSvgAttempts - }); + // for the graceful clearing on shutdown with injected `addTimer` funtion + addTimer(_startSuccessRate()); + + /** + * Adds the GET '/health' - A route for getting the basic stats of the server. + */ + app.get('/health', (request, response, next) => { + try { + log(4, '[health] Returning server health.'); + + const stats = getPoolStats(); + const period = successRates.length; + const movingAverage = _calculateMovingAverage(); + + // Send the server's statistics + response.send({ + // Status and times + status: 'OK', + bootTime: serverStartTime, + uptime: `${Math.floor((getNewDateTime() - serverStartTime.getTime()) / 1000 / 60)} minutes`, + + // Versions + serverVersion: packageFile.version, + highchartsVersion: getHcVersion(), + + // Exports + averageExportTime: stats.timeSpentAverage, + attemptedExports: stats.exportsAttempted, + performedExports: stats.exportsPerformed, + failedExports: stats.exportsDropped, + sucessRatio: (stats.exportsPerformed / stats.exportsAttempted) * 100, + + // Pool + pool: getPoolInfoJSON(), + + // Moving average + period, + movingAverage, + message: + isNaN(movingAverage) || !successRates.length + ? 'Too early to report. No exports made yet. Please check back soon.' + : `Last ${period} minutes had a success rate of ${movingAverage.toFixed(2)}%.`, + + // SVG and JSON exports + svgExports: stats.exportsFromSvg, + jsonExports: stats.exportsFromOptions, + svgExportsAttempts: stats.exportsFromSvgAttempts, + jsonExportsAttempts: stats.exportsFromOptionsAttempts + }); + } catch (error) { + return next(error); + } }); } diff --git a/lib/server/routes/ui.js b/lib/server/routes/ui.js index 5255a9b9..3356f773 100644 --- a/lib/server/routes/ui.js +++ b/lib/server/routes/ui.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,18 +12,40 @@ See LICENSE file in root for details. *******************************************************************************/ +/** + * @overview Defines an Express route for serving the UI for the Export Server + * when enabled. + */ + import { join } from 'path'; +import { getOptions } from '../../config.js'; +import { log } from '../../logger.js'; import { __dirname } from '../../utils.js'; /** - * Adds the GET / route for a UI when enabled on the export server. + * Adds the `ui` routes. + * + * @function uiRoutes + * + * @param {Express} app - The Express app instance. */ -export default (app) => - !app - ? false - : app.get('/', (_request, response) => { +export default function uiRoutes(app) { + // Add the UI endpoint only if required + if (getOptions().ui.enable) { + /** + * Adds the GET '/' - A route for a UI when enabled on the Export Server. + */ + app.get(getOptions().ui.route || '/', (request, response, next) => { + try { + log(4, '[ui] Returning UI for the export.'); + response.sendFile(join(__dirname, 'public', 'index.html'), { acceptRanges: false }); - }); + } catch (error) { + return next(error); + } + }); + } +} diff --git a/lib/server/routes/versionChange.js b/lib/server/routes/versionChange.js index df72049e..976a1ccb 100644 --- a/lib/server/routes/versionChange.js +++ b/lib/server/routes/versionChange.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,68 +12,81 @@ See LICENSE file in root for details. *******************************************************************************/ -import { updateVersion, version } from '../../cache.js'; +/** + * @overview Defines an Express route for updating the Highcharts version + * on the server, with authentication and validation. + */ + +import { getHcVersion, updateHcVersion } from '../../cache.js'; import { envs } from '../../envs.js'; +import { log } from '../../logger.js'; -import HttpError from '../../errors/HttpError.js'; +import ExportError from '../../errors/ExportError.js'; /** - * Adds the POST /change_hc_version/:newVersion route that can be utilized to modify - * the Highcharts version on the server. + * Adds the `version_change` routes. * - * TODO: Add auth token and connect to API + * @function versionChangeRoutes + * + * @param {Express} app - The Express app instance. */ -export default (app) => - !app - ? false - : app.post( - '/version/change/:newVersion', - async (request, response, next) => { - try { - const adminToken = envs.HIGHCHARTS_ADMIN_TOKEN; - - // Check the existence of the token - if (!adminToken || !adminToken.length) { - throw new HttpError( - 'The server is not configured to perform run-time version changes: HIGHCHARTS_ADMIN_TOKEN is not set.', - 401 - ); - } - - // Check if the hc-auth header contain a correct token - const token = request.get('hc-auth'); - if (!token || token !== adminToken) { - throw new HttpError( - 'Invalid or missing token: Set the token in the hc-auth header.', - 401 - ); - } - - // Compare versions - const newVersion = request.params.newVersion; - if (newVersion) { - try { - // eslint-disable-next-line import/no-named-as-default-member - await updateVersion(newVersion); - } catch (error) { - throw new HttpError( - `Version change: ${error.message}`, - error.statusCode - ).setError(error); - } - - // Success - response.status(200).send({ - statusCode: 200, - version: version(), - message: `Successfully updated Highcharts to version: ${newVersion}.` - }); - } else { - // No version specified - throw new HttpError('No new version supplied.', 400); - } - } catch (error) { - next(error); - } +export default function versionChangeRoutes(app) { + /** + * Adds the POST '/version_change/:newVersion' - A route for changing + * the Highcharts version on the server. + */ + app.post('/version_change/:newVersion', async (request, response, next) => { + try { + log(4, '[version] Changing Highcharts version.'); + + // Get the token directly from envs + const adminToken = envs.HIGHCHARTS_ADMIN_TOKEN; + + // Check the existence of the token + if (!adminToken || !adminToken.length) { + throw new ExportError( + '[version] The server is not configured to perform run-time version changes: `HIGHCHARTS_ADMIN_TOKEN` is not set.', + 401 + ); + } + + // Get the token from the hc-auth header + const token = request.get('hc-auth'); + + // Check if the hc-auth header contain a correct token + if (!token || token !== adminToken) { + throw new ExportError( + '[version] Invalid or missing token: Set the token in the hc-auth header.', + 401 + ); + } + + // Get the new version from the params + const newVersion = request.params.newVersion; + + // Update version + if (newVersion) { + try { + await updateHcVersion(newVersion); + } catch (error) { + throw new ExportError( + `[version] Version change: ${error.message}`, + 400 + ).setError(error); } - ); + + // Success + response.status(200).send({ + statusCode: 200, + highchartsVersion: getHcVersion(), + message: `Successfully updated Highcharts to version: ${newVersion}.` + }); + } else { + // No version specified + throw new ExportError('[version] No new version supplied.', 400); + } + } catch (error) { + return next(error); + } + }); +} diff --git a/lib/server/server.js b/lib/server/server.js index c4176fec..d1120384 100644 --- a/lib/server/server.js +++ b/lib/server/server.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,8 +12,17 @@ See LICENSE file in root for details. *******************************************************************************/ -import { promises as fsPromises } from 'fs'; -import { posix } from 'path'; +/** + * @overview A module that sets up and manages HTTP and HTTPS servers + * for the Highcharts Export Server. It handles server initialization, + * configuration, error handling, middlewares setup, route definition, and rate + * limiting. The module exports functions to start, stop, and manage server + * instances, as well as utility functions for defining routes and attaching + * middlewares. + */ + +import { readFileSync } from 'fs'; +import { join } from 'path'; import cors from 'cors'; import express from 'express'; @@ -21,15 +30,18 @@ import http from 'http'; import https from 'https'; import multer from 'multer'; -import errorHandler from './error.js'; -import rateLimit from './rateLimiting.js'; +import { updateOptions } from '../config.js'; import { log, logWithStack } from '../logger.js'; -import { __dirname } from '../utils.js'; +import { __dirname, getAbsolutePath } from '../utils.js'; + +import errorMiddleware from './middlewares/error.js'; +import rateLimitingMiddleware from './middlewares/rateLimiting.js'; +import validationMiddleware from './middlewares/validation.js'; -import vSwitchRoute from './routes/versionChange.js'; import exportRoutes from './routes/export.js'; -import healthRoute from './routes/health.js'; -import uiRoute from './routes/ui.js'; +import healthRoutes from './routes/health.js'; +import uiRoutes from './routes/ui.js'; +import versionChangeRoutes from './routes/versionChange.js'; import ExportError from '../errors/ExportError.js'; @@ -39,126 +51,138 @@ const activeServers = new Map(); // Create express app const app = express(); -// Disable the X-Powered-By header -app.disable('x-powered-by'); - -// Enable CORS support -app.use(cors()); - -// Getting a lot of RangeNotSatisfiableError exception. -// Even though this is a deprecated options, let's try to set it to false. -app.use((_req, res, next) => { - res.set('Accept-Ranges', 'none'); - next(); -}); - -// TODO: Read from config/env -// NOTE: -// Too big limits lead to timeouts in the export process when the -// rasterization timeout is set too low. -const uploadLimitMiB = 3; -const uploadLimitBytes = uploadLimitMiB * 1024 * 1024; - -// Enable parsing of form data (files) with Multer package -const storage = multer.memoryStorage(); -const upload = multer({ - storage, - limits: { - fieldSize: uploadLimitBytes - } -}); - -// Enable body parser -app.use(express.json({ limit: uploadLimitBytes })); -app.use(express.urlencoded({ extended: true, limit: uploadLimitBytes })); - -// Use only non-file multipart form fields -app.use(upload.none()); - /** - * Attach error handlers to the server. + * Starts an HTTP and/or HTTPS server based on the provided configuration. + * The `serverOptions` object contains server-related properties (refer + * to the `server` section in the `./lib/schemas/config.js` file for details). * - * @param {http.Server} server - The HTTP/HTTPS server instance. - */ -const attachServerErrorHandlers = (server) => { - server.on('clientError', (error, socket) => { - logWithStack( - 1, - error, - `[server] Client error: ${error.message}, destroying socket.` - ); - socket.destroy(); - }); - - server.on('error', (error) => { - logWithStack(1, error, `[server] Server error: ${error.message}`); - }); - - server.on('connection', (socket) => { - socket.on('error', (error) => { - logWithStack(1, error, `[server] Socket error: ${error.message}`); - }); - }); -}; - -/** - * Starts an HTTP server based on the provided configuration. The `serverConfig` - * object contains all server related properties (see the `server` section - * in the `lib/schemas/config.js` file for a reference). + * @async + * @function startServer + * + * @param {Object} serverOptions - The configuration object containing `server` + * options. This object may include a partial or complete set of the `server` + * options. If the options are partial, missing values will default + * to the current global configuration. * - * @param {Object} serverConfig - The server configuration object. + * @returns {Promise} A Promise that resolves when the server is either + * not enabled or no valid Express app is found, signaling the end of the + * function's execution. * - * @throws {ExportError} - Throws an error if the server cannot be configured - * and started. + * @throws {ExportError} Throws an `ExportError` if the server cannot + * be configured and started. */ -export const startServer = async (serverConfig) => { +export async function startServer(serverOptions) { try { + // Update the instance options object + const options = updateOptions({ + server: serverOptions + }); + + // Use validated options + serverOptions = options.server; + // Stop if not enabled - if (!serverConfig.enable) { - return false; + if (!serverOptions.enable || !app) { + throw new ExportError( + '[server] Server cannot be started (not enabled or no correct Express app found).', + 500 + ); } + // Too big limits lead to timeouts in the export process when + // the rasterization timeout is set too low + const uploadLimitBytes = serverOptions.uploadLimit * 1024 * 1024; + + // Memory storage for multer package + const storage = multer.memoryStorage(); + + // Enable parsing of form data (files) with multer package + const upload = multer({ + storage, + limits: { + fieldSize: uploadLimitBytes + } + }); + + // Disable the X-Powered-By header + app.disable('x-powered-by'); + + // Enable CORS support + app.use( + cors({ + methods: ['POST', 'GET', 'OPTIONS'] + }) + ); + + // Getting a lot of `RangeNotSatisfiableError` exceptions (even though this + // is a deprecated options, let's try to set it to false) + app.use((request, response, next) => { + response.set('Accept-Ranges', 'none'); + next(); + }); + + // Enable body parser for JSON data + app.use( + express.json({ + limit: uploadLimitBytes + }) + ); + + // Enable body parser for URL-encoded form data + app.use( + express.urlencoded({ + extended: true, + limit: uploadLimitBytes + }) + ); + + // Use only non-file multipart form fields + app.use(upload.none()); + + // Set up static folder's route + app.use(express.static(join(__dirname, 'public'))); + // Listen HTTP server - if (!serverConfig.ssl.force) { + if (!serverOptions.ssl.force) { // Main server instance (HTTP) const httpServer = http.createServer(app); // Attach error handlers and listen to the server - attachServerErrorHandlers(httpServer); + _attachServerErrorHandlers(httpServer); // Listen - httpServer.listen(serverConfig.port, serverConfig.host); - - // Save the reference to HTTP server - activeServers.set(serverConfig.port, httpServer); + httpServer.listen(serverOptions.port, serverOptions.host, () => { + // Save the reference to HTTP server + activeServers.set(serverOptions.port, httpServer); - log( - 3, - `[server] Started HTTP server on ${serverConfig.host}:${serverConfig.port}.` - ); + log( + 3, + `[server] Started HTTP server on ${serverOptions.host}:${serverOptions.port}.` + ); + }); } // Listen HTTPS server - if (serverConfig.ssl.enable) { + if (serverOptions.ssl.enable) { // Set up an SSL server also let key, cert; try { // Get the SSL key - key = await fsPromises.readFile( - posix.join(serverConfig.ssl.certPath, 'server.key'), + key = readFileSync( + join(getAbsolutePath(serverOptions.ssl.certPath), 'server.key'), 'utf8' ); // Get the SSL certificate - cert = await fsPromises.readFile( - posix.join(serverConfig.ssl.certPath, 'server.crt'), + cert = readFileSync( + join(getAbsolutePath(serverOptions.ssl.certPath), 'server.crt'), 'utf8' ); } catch (error) { log( 2, - `[server] Unable to load key/certificate from the '${serverConfig.ssl.certPath}' path. Could not run secured layer server.` + `[server] Unable to load key/certificate from the '${serverOptions.ssl.certPath}' path. Could not run secured layer server.` ); } @@ -167,126 +191,189 @@ export const startServer = async (serverConfig) => { const httpsServer = https.createServer({ key, cert }, app); // Attach error handlers and listen to the server - attachServerErrorHandlers(httpsServer); + _attachServerErrorHandlers(httpsServer); // Listen - httpsServer.listen(serverConfig.ssl.port, serverConfig.host); - - // Save the reference to HTTPS server - activeServers.set(serverConfig.ssl.port, httpsServer); - - log( - 3, - `[server] Started HTTPS server on ${serverConfig.host}:${serverConfig.ssl.port}.` - ); + httpsServer.listen(serverOptions.ssl.port, serverOptions.host, () => { + // Save the reference to HTTPS server + activeServers.set(serverOptions.ssl.port, httpsServer); + + log( + 3, + `[server] Started HTTPS server on ${serverOptions.host}:${serverOptions.ssl.port}.` + ); + }); } } - // Enable the rate limiter if config says so - if ( - serverConfig.rateLimiting && - serverConfig.rateLimiting.enable && - ![0, NaN].includes(serverConfig.rateLimiting.maxRequests) - ) { - rateLimit(app, serverConfig.rateLimiting); - } + // Set up the rate limiter + rateLimitingMiddleware(app, serverOptions.rateLimiting); - // Set up static folder's route - app.use(express.static(posix.join(__dirname, 'public'))); + // Set up the validation handler + validationMiddleware(app); // Set up routes - healthRoute(app); exportRoutes(app); - uiRoute(app); - vSwitchRoute(app); + healthRoutes(app); + uiRoutes(app); + versionChangeRoutes(app); - // Set up centralized error handler - errorHandler(app); + // Set up the centralized error handler + errorMiddleware(app); } catch (error) { throw new ExportError( - '[server] Could not configure and start the server.' + '[server] Could not configure and start the server.', + 500 ).setError(error); } -}; +} /** * Closes all servers associated with Express app instance. + * + * @function closeServers */ -export const closeServers = () => { - log(4, `[server] Closing all servers.`); - for (const [port, server] of activeServers) { - server.close(() => { - activeServers.delete(port); - log(4, `[server] Closed server on port: ${port}.`); - }); +export function closeServers() { + // Check if there are servers working + if (activeServers.size > 0) { + log(4, `[server] Closing all servers.`); + + // Close each one of servers + for (const [port, server] of activeServers) { + server.close(() => { + activeServers.delete(port); + log(4, `[server] Closed server on port: ${port}.`); + }); + } } -}; +} /** * Get all servers associated with Express app instance. * - * @returns {Array} - Servers associated with Express app instance. + * @function getServers + * + * @returns {Array} Servers associated with Express app instance. */ -export const getServers = () => activeServers; +export function getServers() { + return activeServers; +} /** - * Enable rate limiting for the server. + * Get the Express instance. + * + * @function getExpress * - * @param {Object} limitConfig - Configuration object for rate limiting. + * @returns {Express} The Express instance. */ -export const enableRateLimiting = (limitConfig) => rateLimit(app, limitConfig); +export function getExpress() { + return express; +} /** - * Get the Express instance. + * Get the Express app instance. * - * @returns {Object} - The Express instance. + * @function getApp + * + * @returns {Express} The Express app instance. */ -export const getExpress = () => express; +export function getApp() { + return app; +} /** - * Get the Express app instance. + * Enable rate limiting for the server. + * + * @function enableRateLimiting * - * @returns {Object} - The Express app instance. + * @param {Object} rateLimitingOptions - The configuration object containing + * `rateLimiting` options. This object may include a partial or complete set + * of the `rateLimiting` options. If the options are partial, missing values + * will default to the current global configuration. */ -export const getApp = () => app; +export function enableRateLimiting(rateLimitingOptions) { + // Update the instance options object + const options = updateOptions({ + server: { + rateLimiting: rateLimitingOptions + } + }); + + // Set the rate limiting options + rateLimitingMiddleware(app, options.server.rateLimitingOptions); +} /** * Apply middleware(s) to a specific path. * + * @function use + * * @param {string} path - The path to which the middleware(s) should be applied. - * @param {...Function} middlewares - The middleware functions to be applied. + * @param {...Function} middlewares - The middleware function(s) to be applied. */ -export const use = (path, ...middlewares) => { +export function use(path, ...middlewares) { app.use(path, ...middlewares); -}; +} /** * Set up a route with GET method and apply middleware(s). * - * @param {string} path - The route path. - * @param {...Function} middlewares - The middleware functions to be applied. + * @function get + * + * @param {string} path - The path to which the middleware(s) should be applied. + * @param {...Function} middlewares - The middleware function(s) to be applied. */ -export const get = (path, ...middlewares) => { +export function get(path, ...middlewares) { app.get(path, ...middlewares); -}; +} /** * Set up a route with POST method and apply middleware(s). * - * @param {string} path - The route path. - * @param {...Function} middlewares - The middleware functions to be applied. + * @function post + * + * @param {string} path - The path to which the middleware(s) should be applied. + * @param {...Function} middlewares - The middleware function(s) to be applied. */ -export const post = (path, ...middlewares) => { +export function post(path, ...middlewares) { app.post(path, ...middlewares); -}; +} + +/** + * Attach error handlers to the server. + * + * @function _attachServerErrorHandlers + * + * @param {(http.Server|https.Server)} server - The HTTP/HTTPS server instance. + */ +function _attachServerErrorHandlers(server) { + server.on('clientError', (error, socket) => { + logWithStack( + 1, + error, + `[server] Client error: ${error.message}, destroying socket.` + ); + socket.destroy(); + }); + + server.on('error', (error) => { + logWithStack(1, error, `[server] Server error: ${error.message}`); + }); + + server.on('connection', (socket) => { + socket.on('error', (error) => { + logWithStack(1, error, `[server] Socket error: ${error.message}`); + }); + }); +} export default { startServer, closeServers, getServers, - enableRateLimiting, getExpress, getApp, + enableRateLimiting, use, get, post diff --git a/lib/timer.js b/lib/timer.js new file mode 100644 index 00000000..2b777996 --- /dev/null +++ b/lib/timer.js @@ -0,0 +1,55 @@ +/******************************************************************************* + +Highcharts Export Server + +Copyright (c) 2016-2025, Highsoft + +Licenced under the MIT licence. + +Additionally a valid Highcharts license is required for use. + +See LICENSE file in root for details. + +*******************************************************************************/ + +/** + * @overview This module provides utility functions for managing intervals + * and timeouts in a centralized manner. It maintains a registry of all active + * timers and allows for their efficient cleanup when needed to avoid potential + * memory leaks, unintended behavior or a process from being stopped. + */ + +import { log } from './logger.js'; + +// Array that contains ids of all ongoing intervals and timeouts +const timerIds = []; + +/** + * Adds id of the `setInterval` or `setTimeout` and to the `timerIds` array. + * + * @function addTimer + * + * @param {NodeJS.Timeout} id - Id of an interval or a timeout. + */ +export function addTimer(id) { + timerIds.push(id); +} + +/** + * Clears all of ongoing intervals and timeouts by ids gathered + * in the `timerIds` array. + * + * @function clearAllTimers + */ +export function clearAllTimers() { + log(4, `[timer] Clearing all registered intervals and timeouts.`); + for (const id of timerIds) { + clearInterval(id); + clearTimeout(id); + } +} + +export default { + addTimer, + clearAllTimers +}; diff --git a/lib/utils.js b/lib/utils.js index 6823c003..9c0bbf6d 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,15 +12,18 @@ See LICENSE file in root for details. *******************************************************************************/ -import { readFileSync } from 'fs'; -import { join } from 'path'; -import { fileURLToPath } from 'url'; +/** + * @overview The Highcharts Export Server utility module provides + * a comprehensive set of helper functions and constants designed to streamline + * and enhance various operations required for Highcharts export tasks. + */ -import { defaultConfig } from '../lib/schemas/config.js'; -import { log, logWithStack } from './logger.js'; +import { isAbsolute, normalize, resolve } from 'path'; +import { fileURLToPath } from 'url'; const MAX_BACKOFF_ATTEMPTS = 6; +// The directory path export const __dirname = fileURLToPath(new URL('../.', import.meta.url)); /** @@ -28,32 +31,69 @@ export const __dirname = fileURLToPath(new URL('../.', import.meta.url)); * characters with a single space and trimming any leading or trailing * whitespace. * + * @function clearText + * * @param {string} text - The input text to be cleared. * @param {RegExp} [rule=/\s\s+/g] - The regular expression rule to match - * multiple consecutive whitespace characters. + * multiple consecutive whitespace characters. The default value + * is the '/\s\s+/g' RegExp. * @param {string} [replacer=' '] - The string used to replace multiple - * consecutive whitespace characters. + * consecutive whitespace characters. The default value is the ' ' string. * - * @returns {string} - The cleared and standardized text. + * @returns {string} The cleared and standardized text. */ -export const clearText = (text, rule = /\s\s+/g, replacer = ' ') => - text.replaceAll(rule, replacer).trim(); +export function clearText(text, rule = /\s\s+/g, replacer = ' ') { + return text.replaceAll(rule, replacer).trim(); +} + +/** + * Creates a deep copy of the given object or array. + * + * @function deepCopy + * + * @param {(Object|Array)} objArr - The object or array to be deeply copied. + * + * @returns {(Object|Array)} The deep copy of the provided object or array. + */ +export function deepCopy(objArr) { + // If the `objArr` is null or not of the `object` type, return it + if (objArr === null || typeof objArr !== 'object') { + return objArr; + } + + // Prepare either a new array or a new object + const objArrCopy = Array.isArray(objArr) ? [] : {}; + + // Recursively copy each property + for (const key in objArr) { + if (Object.prototype.hasOwnProperty.call(objArr, key)) { + objArrCopy[key] = deepCopy(objArr[key]); + } + } + + // Return the copied object + return objArrCopy; +} /** * Implements an exponential backoff strategy for retrying a function until * a certain number of attempts are reached. * + * @async + * @function expBackoff + * * @param {Function} fn - The function to be retried. - * @param {number} [attempt=0] - The current attempt number. - * @param {...any} args - Arguments to be passed to the function. + * @param {number} [attempt=0] - The current attempt number. The default value + * is `0`. + * @param {...unknown} args - Arguments to be passed to the function. * - * @returns {Promise} - A promise that resolves to the result of the function - * if successful. + * @returns {Promise} A Promise that resolves to the result + * of the function if successful. * - * @throws {Error} - Throws an error if the maximum number of attempts + * @throws {Error} Throws an `Error` if the maximum number of attempts * is reached. */ -export const expBackoff = async (fn, attempt = 0, ...args) => { +export async function expBackoff(fn, attempt = 0, ...args) { try { // Try to call the function return await fn(...args); @@ -61,180 +101,123 @@ export const expBackoff = async (fn, attempt = 0, ...args) => { // Calculate delay in ms const delayInMs = 2 ** attempt * 1000; - // If the attempt exceeds the maximum attempts of reapeat, throw an error + // If the attempt exceeds the maximum attempts of repeat, throw an error if (++attempt >= MAX_BACKOFF_ATTEMPTS) { throw error; } // Wait given amount of time await new Promise((response) => setTimeout(response, delayInMs)); - log( - 3, - `[pool] Waited ${delayInMs}ms until next call for the resource id: ${args[0]}.` - ); + + /// TO DO: Correct + // // Information about the resource timeout + // log( + // 3, + // `[utils] Waited ${delayInMs}ms until next call for the resource of ID: ${args[0]}.` + // ); // Try again return expBackoff(fn, attempt, ...args); } -}; +} /** - * Fixes the export type based on MIME types and file extensions. + * Checks if the given path is relative or absolute and returns the corrected, + * absolute path. * - * @param {string} type - The original export type. - * @param {string} outfile - The file path or name. + * @function getAbsolutePath * - * @returns {string} - The corrected export type. + * @param {string} path - The path to be checked on. + * + * @returns {string} The absolute path. */ -export const fixType = (type, outfile) => { - // MIME types - const mimeTypes = { - 'image/png': 'png', - 'image/jpeg': 'jpeg', - 'application/pdf': 'pdf', - 'image/svg+xml': 'svg' - }; - - // Formats - const formats = ['png', 'jpeg', 'pdf', 'svg']; - - // Check if type and outfile's extensions are the same - if (outfile) { - const outType = outfile.split('.').pop(); - - if (outType === 'jpg') { - type = 'jpeg'; - } else if (formats.includes(outType) && type !== outType) { - type = outType; - } - } - - // Return a correct type - return mimeTypes[type] || formats.find((t) => t === type) || 'png'; -}; +export function getAbsolutePath(path) { + return isAbsolute(path) ? normalize(path) : resolve(path); +} /** - * Handles and validates resources for export. + * Converts input data to a Base64 string based on the export type. + * + * @function getBase64 * - * @param {Object|string} resources - The resources to be handled. Can be either - * a JSON object, stringified JSON or a path to a JSON file. - * @param {boolean} allowFileResources - Whether to allow loading resources from - * files. + * @param {string} input - The input to be transformed to Base64 format. + * @param {string} type - The original export type. * - * @returns {Object|undefined} - The handled resources or undefined if no valid - * resources are found. + * @returns {string} The Base64 string representation of the input. */ -export const handleResources = (resources = false, allowFileResources) => { - const allowedProps = ['js', 'css', 'files']; - - let handledResources = resources; - let correctResources = false; - - // Try to load resources from a file - if (allowFileResources && resources.endsWith('.json')) { - try { - handledResources = isCorrectJSON(readFileSync(resources, 'utf8')); - } catch (error) { - return logWithStack(2, error, `[cli] No resources found.`); - } - } else { - // Try to get JSON - handledResources = isCorrectJSON(resources); - - // Get rid of the files section - if (handledResources && !allowFileResources) { - delete handledResources.files; - } +export function getBase64(input, type) { + // For pdf and svg types the input must be transformed to Base64 from a buffer + if (type === 'pdf' || type == 'svg') { + return Buffer.from(input, 'utf8').toString('base64'); } - // Filter from unnecessary properties - for (const propName in handledResources) { - if (!allowedProps.includes(propName)) { - delete handledResources[propName]; - } else if (!correctResources) { - correctResources = true; - } - } - - // Check if at least one of allowed properties is present - if (!correctResources) { - return log(3, `[cli] No resources found.`); - } - - // Handle files section - if (handledResources.files) { - handledResources.files = handledResources.files.map((item) => item.trim()); - if (!handledResources.files || handledResources.files.length <= 0) { - delete handledResources.files; - } - } - - // Return resources - return handledResources; -}; + // For png and jpeg input is already a Base64 string + return input; +} /** - * Validates and parses JSON data. Checks if provided data is or can - * be a correct JSON. If a primitive is provided, it is stringified and returned. - * - * @param {Object|string} data - The JSON data to be validated and parsed. - * @param {boolean} toString - Whether to return a stringified representation - * of the parsed JSON. + * Returns stringified date without the GMT text information. * - * @returns {Object|string|boolean} - The parsed JSON object, stringified JSON, - * or false if validation fails. + * @function getNewDate */ -export function isCorrectJSON(data, toString) { - try { - // Get the string representation if not already before parsing - const parsedData = JSON.parse( - typeof data !== 'string' ? JSON.stringify(data) : data - ); - - // Return a stringified representation of a JSON if required - if (typeof parsedData !== 'string' && toString) { - return JSON.stringify(parsedData); - } +export function getNewDate() { + // Get rid of the GMT text information + return new Date().toString().split('(')[0].trim(); +} - // Return a JSON - return parsedData; - } catch { - return false; - } +/** + * Returns the stored time value in milliseconds. + * + * @function getNewDateTime + */ +export function getNewDateTime() { + return new Date().getTime(); } /** * Checks if the given item is an object. * - * @param {any} item - The item to be checked. + * @function isObject + * + * @param {unknown} item - The item to be checked. * - * @returns {boolean} - True if the item is an object, false otherwise. + * @returns {boolean} Returns `true` if the item is an object, `false` + * otherwise. */ -export const isObject = (item) => - typeof item === 'object' && !Array.isArray(item) && item !== null; +export function isObject(item) { + return Object.prototype.toString.call(item) === '[object Object]'; +} /** * Checks if the given object is empty. * + * @function isObjectEmpty + * * @param {Object} item - The object to be checked. * - * @returns {boolean} - True if the object is empty, false otherwise. + * @returns {boolean} Returns `true` if the item is an empty object, `false` + * otherwise. */ -export const isObjectEmpty = (item) => - typeof item === 'object' && - !Array.isArray(item) && - item !== null && - Object.keys(item).length === 0; +export function isObjectEmpty(item) { + return ( + typeof item === 'object' && + !Array.isArray(item) && + item !== null && + Object.keys(item).length === 0 + ); +} /** * Checks if a private IP range URL is found in the given string. * + * @function isPrivateRangeUrlFound + * * @param {string} item - The string to be checked for a private IP range URL. * - * @returns {boolean} - True if a private IP range URL is found, false + * @returns {boolean} Returns `true` if a private IP range URL is found, `false` * otherwise. */ -export const isPrivateRangeUrlFound = (item) => { +export function isPrivateRangeUrlFound(item) { const regexPatterns = [ /xlink:href="(?:http:\/\/|https:\/\/)?localhost\b/, /xlink:href="(?:http:\/\/|https:\/\/)?10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/, @@ -244,225 +227,63 @@ export const isPrivateRangeUrlFound = (item) => { ]; return regexPatterns.some((pattern) => pattern.test(item)); -}; - -/** - * Creates a deep copy of the given object or array. - * - * @param {Object|Array} obj - The object or array to be deeply copied. - * - * @returns {Object|Array} - The deep copy of the provided object or array. - */ -export const deepCopy = (obj) => { - if (obj === null || typeof obj !== 'object') { - return obj; - } - - const copy = Array.isArray(obj) ? [] : {}; - - for (const key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key)) { - copy[key] = deepCopy(obj[key]); - } - } - - return copy; -}; +} /** - * Converts the provided options object to a JSON-formatted string with the - * option to preserve functions. + * Utility to measure elapsed time using the Node.js `process.hrtime()` method. * - * @param {Object} options - The options object to be converted to a string. - * @param {boolean} allowFunctions - If set to true, functions are preserved - * in the output. + * @function measureTime * - * @returns {string} - The JSON-formatted string representing the options. + * @returns {Function} A function to calculate the elapsed time in milliseconds. */ -export const optionsStringify = (options, allowFunctions) => { - const replacerCallback = (name, value) => { - if (typeof value === 'string') { - value = value.trim(); - - // If allowFunctions is set to true, preserve functions - if ( - (value.startsWith('function(') || value.startsWith('function (')) && - value.endsWith('}') - ) { - value = allowFunctions - ? `EXP_FUN${(value + '').replaceAll(/\n|\t|\r/g, ' ')}EXP_FUN` - : undefined; - } - } - - return typeof value === 'function' - ? `EXP_FUN${(value + '').replaceAll(/\n|\t|\r/g, ' ')}EXP_FUN` - : value; - }; - - // Stringify options and if required, replace special functions marks - return JSON.stringify(options, replacerCallback).replaceAll( - /"EXP_FUN|EXP_FUN"/g, - '' - ); -}; - -/** - * Prints the Highcharts Export Server logo and version information. - * - * @param {boolean} noLogo - If true, only prints version information without - * the logo. - */ -export const printLogo = (noLogo) => { - // Get package version either from env or from package.json - const packageVersion = JSON.parse( - readFileSync(join(__dirname, 'package.json')) - ).version; - - // Print text only - if (noLogo) { - console.log(`Starting Highcharts Export Server v${packageVersion}...`); - return; - } - - // Print the logo - console.log( - readFileSync(__dirname + '/msg/startup.msg').toString().bold.yellow, - `v${packageVersion}\n`.bold - ); -}; - -/** - * Prints the usage information for CLI arguments. If required, it can list - * properties recursively - */ -export function printUsage() { - const pad = 48; - const readme = 'https://github.com/highcharts/node-export-server#readme'; - - // Display readme information - console.log( - '\nUsage of CLI arguments:'.bold, - '\n------', - `\nFor more detailed information, visit the readme at: ${readme.bold.yellow}.` - ); - - const cycleCategories = (options) => { - for (const [name, option] of Object.entries(options)) { - // If category has more levels, go further - if (!Object.prototype.hasOwnProperty.call(option, 'value')) { - cycleCategories(option); - } else { - let descName = ` --${option.cliName || name} ${ - ('<' + option.type + '>').green - } `; - if (descName.length < pad) { - for (let i = descName.length; i < pad; i++) { - descName += '.'; - } - } - - // Display correctly aligned messages - console.log( - descName, - option.description, - `[Default: ${option.value.toString().bold}]`.blue - ); - } - } - }; - - // Cycle through options of each categories and display the usage info - Object.keys(defaultConfig).forEach((category) => { - // Only puppeteer and highcharts categories cannot be configured through CLI - if (!['puppeteer', 'highcharts'].includes(category)) { - console.log(`\n${category.toUpperCase()}`.red); - cycleCategories(defaultConfig[category]); - } - }); - console.log('\n'); +export function measureTime() { + const start = process.hrtime.bigint(); + return () => Number(process.hrtime.bigint() - start) / 1000000; } /** * Rounds a number to the specified precision. * + * @function roundNumber + * * @param {number} value - The number to be rounded. * @param {number} precision - The number of decimal places to round to. * - * @returns {number} - The rounded number. + * @returns {number} The rounded number. */ -export const roundNumber = (value, precision = 1) => { +export function roundNumber(value, precision = 1) { const multiplier = Math.pow(10, precision || 0); return Math.round(+value * multiplier) / multiplier; -}; +} /** * Converts a value to a boolean. * - * @param {any} item - The value to be converted to a boolean. + * @function toBoolean * - * @returns {boolean} - The boolean representation of the input value. + * @param {unknown} item - The value to be converted to a boolean. + * + * @returns {boolean} The boolean representation of the input value. */ -export const toBoolean = (item) => - ['false', 'undefined', 'null', 'NaN', '0', ''].includes(item) +export function toBoolean(item) { + return ['false', 'undefined', 'null', 'NaN', '0', ''].includes(item) ? false : !!item; - -/** - * Wraps custom code to execute it safely. - * - * @param {string} customCode - The custom code to be wrapped. - * @param {boolean} allowFileResources - Flag to allow loading code from a file. - * - * @returns {string|boolean} - The wrapped custom code or false if wrapping - * fails. - */ -export const wrapAround = (customCode, allowFileResources) => { - if (customCode && typeof customCode === 'string') { - customCode = customCode.trim(); - - if (customCode.endsWith('.js')) { - return allowFileResources - ? wrapAround(readFileSync(customCode, 'utf8')) - : false; - } else if ( - customCode.startsWith('function()') || - customCode.startsWith('function ()') || - customCode.startsWith('()=>') || - customCode.startsWith('() =>') - ) { - return `(${customCode})()`; - } - return customCode.replace(/;$/, ''); - } -}; - -/** - * Utility to measure elapsed time using the Node.js process.hrtime() method. - * - * @returns {function(): number} - A function to calculate the elapsed time - * in milliseconds. - */ -export const measureTime = () => { - const start = process.hrtime.bigint(); - return () => Number(process.hrtime.bigint() - start) / 1000000; -}; +} export default { __dirname, clearText, + deepCopy, expBackoff, - fixType, - handleResources, - isCorrectJSON, + getAbsolutePath, + getBase64, + getNewDate, + getNewDateTime, isObject, isObjectEmpty, isPrivateRangeUrlFound, - optionsStringify, - printLogo, - printUsage, + measureTime, roundNumber, - toBoolean, - wrapAround, - measureTime + toBoolean }; diff --git a/msg/licenseagree.msg b/msg/licenseagree.msg index f1d81d6e..9a960a98 100644 --- a/msg/licenseagree.msg +++ b/msg/licenseagree.msg @@ -3,8 +3,8 @@ Highcharts Export Server https://github.com/highcharts/node-export-server -In order to use this application, Highcharts needs to be downloaded and -embedded. A license is required to use Highcharts if you're a +In order to use this application, Highcharts needs to be downloaded and +embedded. A license is required to use Highcharts if you're a for-profit, commercial, outfit. The license can be viewed here: https://highcharts.com/license diff --git a/package-lock.json b/package-lock.json index 47d5def7..7714c43e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,44 +1,45 @@ { "name": "highcharts-export-server", - "version": "4.1.0", + "version": "5.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "highcharts-export-server", - "version": "4.1.0", + "version": "5.0.0", "license": "MIT", "dependencies": { "colors": "1.4.0", "cors": "^2.8.5", - "dompurify": "^3.1.6", - "dotenv": "^16.4.5", - "express": "^4.19.2", - "express-rate-limit": "^7.3.1", - "https-proxy-agent": "^7.0.5", - "jsdom": "^24.1.0", + "dompurify": "3.1.6", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "express-rate-limit": "^7.5.0", + "https-proxy-agent": "^7.0.6", + "jsdom": "^26.0.0", "multer": "^1.4.5-lts.1", "prompts": "^2.4.2", - "puppeteer": "^22.12.1", + "puppeteer": "^24.2.0", "tarn": "^3.0.2", - "uuid": "^10.0.0", - "zod": "^3.23.8" + "uuid": "^11.0.5", + "zod": "^3.24.2" }, "bin": { "highcharts-export-server": "bin/cli.js" }, "devDependencies": { + "@jest/globals": "^29.7.0", "@rollup/plugin-terser": "^0.4.4", "eslint": "^8.57.0", - "eslint-config-prettier": "^9.1.0", + "eslint-config-prettier": "^10.0.1", "eslint-plugin-import": "^2.31.0", - "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-prettier": "^5.2.3", "husky": "^9.1.7", "jest": "^29.7.0", - "lint-staged": "^15.2.10", - "nodemon": "^3.1.7", - "prettier": "^3.4.1", - "rollup": "^4.27.4" + "lint-staged": "^15.4.3", + "nodemon": "^3.1.9", + "prettier": "^3.5.1", + "rollup": "^4.34.6" }, "engines": { "node": ">=18.12.0" @@ -58,6 +59,25 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-2.8.3.tgz", + "integrity": "sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.1", + "@csstools/css-color-parser": "^3.0.7", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -73,9 +93,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.2.tgz", - "integrity": "sha512-Z0WgzSEa+aUcdiJuCIqgujCshpMWgUpgOxXotrYPSA53hA3qopNaqcJpyr0hVb1FeWdnqFA35/fUtXgBK8srQg==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", + "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", "dev": true, "license": "MIT", "engines": { @@ -83,22 +103,23 @@ } }, "node_modules/@babel/core": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", - "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.8.tgz", + "integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.0", - "@babel/generator": "^7.26.0", - "@babel/helper-compilation-targets": "^7.25.9", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.8", + "@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.0", - "@babel/parser": "^7.26.0", - "@babel/template": "^7.25.9", - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.26.0", + "@babel/helpers": "^7.26.7", + "@babel/parser": "^7.26.8", + "@babel/template": "^7.26.8", + "@babel/traverse": "^7.26.8", + "@babel/types": "^7.26.8", + "@types/gensync": "^1.0.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -114,14 +135,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.2.tgz", - "integrity": "sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.8.tgz", + "integrity": "sha512-ef383X5++iZHWAXX0SXQR6ZyQhw/0KtTkrTz61WXRhFM6dhpHulO/RJz79L8S6ugZHJkOOkUrUdxgdF2YiPFnA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.2", - "@babel/types": "^7.26.0", + "@babel/parser": "^7.26.8", + "@babel/types": "^7.26.8", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -131,13 +152,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", - "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", + "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.25.9", + "@babel/compat-data": "^7.26.5", "@babel/helper-validator-option": "^7.25.9", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -180,9 +201,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", - "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", + "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", "dev": true, "license": "MIT", "engines": { @@ -219,27 +240,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", - "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.7.tgz", + "integrity": "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.25.9", - "@babel/types": "^7.26.0" + "@babel/types": "^7.26.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.2.tgz", - "integrity": "sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.8.tgz", + "integrity": "sha512-TZIQ25pkSoaKEYYaHbbxkfL36GNsQ6iFiBbeuzAkLnXayKR1yP1zFe+NxuZWWsUyvt8icPU9CCq0sgWGXR1GEw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.26.0" + "@babel/types": "^7.26.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -488,32 +509,32 @@ } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.8.tgz", + "integrity": "sha512-iNKaX3ZebKIsCvJ+0jd6embf+Aulaa3vNBqZ41kM7iTWjx5qzWKXGHiJUW3+nTpQ18SG11hdF8OAzKrpXkb96Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.8", + "@babel/types": "^7.26.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz", - "integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.8.tgz", + "integrity": "sha512-nic9tRkjYH0oB2dzr/JoGIm+4Q6SuYeLEiIiZDwBscRMYFJ+tMAz98fuel9ZnbXViA2I0HVSSRRK8DW5fjXStA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/generator": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/template": "^7.25.9", - "@babel/types": "^7.25.9", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.8", + "@babel/parser": "^7.26.8", + "@babel/template": "^7.26.8", + "@babel/types": "^7.26.8", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -532,9 +553,9 @@ } }, "node_modules/@babel/types": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz", - "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.8.tgz", + "integrity": "sha512-eUuWapzEGWFEpHFxgEaBG8e3n6S8L3MSu0oda755rOfabWPnh0Our1AozNFVUxGFIhbKgd1ksprsoDGMinTOTA==", "dev": true, "license": "MIT", "dependencies": { @@ -552,6 +573,116 @@ "dev": true, "license": "MIT" }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.1.tgz", + "integrity": "sha512-MKtmkA0BX87PKaO1NFRTFH+UnkgnmySQOvNxJubsadusqPEC2aJ9MOQiMceZJJ6oitUl/i0L6u0M1IrmAOmgBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.1.tgz", + "integrity": "sha512-rL7kaUnTkL9K+Cvo2pnCieqNpTKgQzy5f+N+5Iuko9HAoasP+xgprVh7KN/MaJVvVL1l0EzQq2MoqBHKSrDrag==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.7.tgz", + "integrity": "sha512-nkMp2mTICw32uE5NN+EsJ4f5N+IGFeCFu4bGpiKgb2Pq/7J/MpyLBeQ5ry4KKtRFZaYs6sTmcMYrSRIyj5DFKA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.0.1", + "@csstools/css-calc": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz", + "integrity": "sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz", + "integrity": "sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", @@ -1070,9 +1201,9 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", "dev": true, "license": "MIT", "dependencies": { @@ -1185,18 +1316,17 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz", - "integrity": "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.7.1.tgz", + "integrity": "sha512-MK7rtm8JjaxPN7Mf1JdZIZKPD2Z+W7osvrC1vjpvfOX1K0awDIHYbNi89f7eotp7eMUn2shWnt03HwVbriXtKQ==", "license": "Apache-2.0", "dependencies": { - "debug": "^4.3.5", + "debug": "^4.4.0", "extract-zip": "^2.0.1", "progress": "^2.0.3", - "proxy-agent": "^6.4.0", - "semver": "^7.6.3", - "tar-fs": "^3.0.6", - "unbzip2-stream": "^1.4.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.0", + "tar-fs": "^3.0.8", "yargs": "^17.7.2" }, "bin": { @@ -1207,9 +1337,9 @@ } }, "node_modules/@puppeteer/browsers/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1241,10 +1371,262 @@ } } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.6.tgz", + "integrity": "sha512-+GcCXtOQoWuC7hhX1P00LqjjIiS/iOouHXhMdiDSnq/1DGTox4SpUvO52Xm+div6+106r+TcvOeo/cxvyEyTgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.6.tgz", + "integrity": "sha512-E8+2qCIjciYUnCa1AiVF1BkRgqIGW9KzJeesQqVfyRITGQN+dFuoivO0hnro1DjT74wXLRZ7QF8MIbz+luGaJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.6.tgz", + "integrity": "sha512-z9Ib+OzqN3DZEjX7PDQMHEhtF+t6Mi2z/ueChQPLS/qUMKY7Ybn5A2ggFoKRNRh1q1T03YTQfBTQCJZiepESAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.6.tgz", + "integrity": "sha512-PShKVY4u0FDAR7jskyFIYVyHEPCPnIQY8s5OcXkdU8mz3Y7eXDJPdyM/ZWjkYdR2m0izD9HHWA8sGcXn+Qrsyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.6.tgz", + "integrity": "sha512-YSwyOqlDAdKqs0iKuqvRHLN4SrD2TiswfoLfvYXseKbL47ht1grQpq46MSiQAx6rQEN8o8URtpXARCpqabqxGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.6.tgz", + "integrity": "sha512-HEP4CgPAY1RxXwwL5sPFv6BBM3tVeLnshF03HMhJYCNc6kvSqBgTMmsEjb72RkZBAWIqiPUyF1JpEBv5XT9wKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.6.tgz", + "integrity": "sha512-88fSzjC5xeH9S2Vg3rPgXJULkHcLYMkh8faix8DX4h4TIAL65ekwuQMA/g2CXq8W+NJC43V6fUpYZNjaX3+IIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.6.tgz", + "integrity": "sha512-wM4ztnutBqYFyvNeR7Av+reWI/enK9tDOTKNF+6Kk2Q96k9bwhDDOlnCUNRPvromlVXo04riSliMBs/Z7RteEg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.6.tgz", + "integrity": "sha512-9RyprECbRa9zEjXLtvvshhw4CMrRa3K+0wcp3KME0zmBe1ILmvcVHnypZ/aIDXpRyfhSYSuN4EPdCCj5Du8FIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.6.tgz", + "integrity": "sha512-qTmklhCTyaJSB05S+iSovfo++EwnIEZxHkzv5dep4qoszUMX5Ca4WM4zAVUMbfdviLgCSQOu5oU8YoGk1s6M9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.6.tgz", + "integrity": "sha512-4Qmkaps9yqmpjY5pvpkfOerYgKNUGzQpFxV6rnS7c/JfYbDSU0y6WpbbredB5cCpLFGJEqYX40WUmxMkwhWCjw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.6.tgz", + "integrity": "sha512-Zsrtux3PuaxuBTX/zHdLaFmcofWGzaWW1scwLU3ZbW/X+hSsFbz9wDIp6XvnT7pzYRl9MezWqEqKy7ssmDEnuQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.6.tgz", + "integrity": "sha512-aK+Zp+CRM55iPrlyKiU3/zyhgzWBxLVrw2mwiQSYJRobCURb781+XstzvA8Gkjg/hbdQFuDw44aUOxVQFycrAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.6.tgz", + "integrity": "sha512-WoKLVrY9ogmaYPXwTH326+ErlCIgMmsoRSx6bO+l68YgJnlOXhygDYSZe/qbUJCSiCiZAQ+tKm88NcWuUXqOzw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.6.tgz", + "integrity": "sha512-Sht4aFvmA4ToHd2vFzwMFaQCiYm2lDFho5rPcvPBT5pCdC+GwHG6CMch4GQfmWTQ1SwRKS0dhDYb54khSrjDWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.6.tgz", + "integrity": "sha512-zmmpOQh8vXc2QITsnCiODCDGXFC8LMi64+/oPpPx5qz3pqv0s6x46ps4xoycfUiVZps5PFn1gksZzo4RGTKT+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.6.tgz", + "integrity": "sha512-3/q1qUsO/tLqGBaD4uXsB6coVGB3usxw3qyeVb59aArCgedSF66MPdgRStUd7vbZOsko/CgVaY5fo2vkvPLWiA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.6.tgz", + "integrity": "sha512-oLHxuyywc6efdKVTxvc0135zPrRdtYVjtVD5GUm55I3ODxhU/PwkQFD97z16Xzxa1Fz0AEe4W/2hzRtd+IfpOA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.27.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.27.4.tgz", - "integrity": "sha512-3j4jx1TppORdTAoBJRd+/wJRGCPC0ETWkXOecJ6PPZLj6SptXkrXcNqdj0oclbKML6FkQltdz7bBA3rUSirZug==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.6.tgz", + "integrity": "sha512-0PVwmgzZ8+TZ9oGBmdZoQVXflbvuwzN/HRclujpl4N/q3i+y0lqLw8n1bXA8ru3sApDjlmONaNAuYr38y1Kr9w==", "cpu": [ "x64" ], @@ -1347,6 +1729,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/gensync": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.4.tgz", + "integrity": "sha512-C3YYeRQWp2fmq9OryX+FoDy8nXS6scQ7dPptD8LnFDAUNcKWJjXQKDNJD3HVm+kOUsXhTOkpi69vI4EuAr95bA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -1392,9 +1781,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz", - "integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==", + "version": "22.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz", + "integrity": "sha512-ywP2X0DYtX3y08eFVx5fNIw7/uIv8hYUKgXoK8oayJlLnKcRfEYCxWMVE1XagUdVtCJlZT1AU4LXEABW+L1Peg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1436,9 +1825,9 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, "license": "ISC" }, @@ -1479,13 +1868,10 @@ } }, "node_modules/agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, "engines": { "node": ">= 14" } @@ -1587,14 +1973,14 @@ "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", - "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", - "is-array-buffer": "^3.0.4" + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" }, "engines": { "node": ">= 0.4" @@ -1652,16 +2038,16 @@ } }, "node_modules/array.prototype.flat": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", - "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -1671,16 +2057,16 @@ } }, "node_modules/array.prototype.flatmap": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", - "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -1690,20 +2076,19 @@ } }, "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", - "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.5", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", - "es-abstract": "^1.22.3", - "es-errors": "^1.2.1", - "get-intrinsic": "^1.2.3", - "is-array-buffer": "^3.0.4", - "is-shared-array-buffer": "^1.0.2" + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" }, "engines": { "node": ">= 0.4" @@ -1724,6 +2109,16 @@ "node": ">=4" } }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1876,70 +2271,68 @@ "license": "MIT" }, "node_modules/bare-events": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.0.tgz", - "integrity": "sha512-/E8dDe9dsbLyh2qrZ64PEPadOQ0F4gbl1sUJOrmph7xOiIxfY8vwab/4bFLh4Y88/Hk/ujKcrQKc+ps0mv873A==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", + "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", "license": "Apache-2.0", "optional": true }, "node_modules/bare-fs": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.5.tgz", - "integrity": "sha512-SlE9eTxifPDJrT6YgemQ1WGFleevzwY+XAP1Xqgl56HtcrisC2CHCZ2tq6dBpcH2TnNxwUEUGhweo+lrQtYuiw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.1.tgz", + "integrity": "sha512-ilQs4fm/l9eMfWY2dY0WCIUplSUp7U0CT1vrqMg1MUdeZl4fypu5UP0XcDBK5WBQPJAKP1b7XEodISmekH/CEg==", "license": "Apache-2.0", "optional": true, "dependencies": { "bare-events": "^2.0.0", - "bare-path": "^2.0.0", + "bare-path": "^3.0.0", "bare-stream": "^2.0.0" + }, + "engines": { + "bare": ">=1.7.0" } }, "node_modules/bare-os": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.4.tgz", - "integrity": "sha512-z3UiI2yi1mK0sXeRdc4O1Kk8aOa/e+FNWZcTiPB/dfTWyLypuE99LibgRaQki914Jq//yAWylcAt+mknKdixRQ==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.4.0.tgz", + "integrity": "sha512-9Ous7UlnKbe3fMi7Y+qh0DwAup6A1JkYgPnjvMDNOlmnxNRQvQ/7Nst+OnUQKzk0iAT0m9BisbDVp9gCv8+ETA==", "license": "Apache-2.0", - "optional": true + "optional": true, + "engines": { + "bare": ">=1.6.0" + } }, "node_modules/bare-path": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.3.tgz", - "integrity": "sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", "license": "Apache-2.0", "optional": true, "dependencies": { - "bare-os": "^2.1.0" + "bare-os": "^3.0.1" } }, "node_modules/bare-stream": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.4.2.tgz", - "integrity": "sha512-XZ4ln/KV4KT+PXdIWTKjsLY+quqCaEtqqtgGJVPw9AoM73By03ij64YjepK0aQvHSWDb6AfAZwqKaFu68qkrdA==", + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", + "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "streamx": "^2.20.0" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true }, - { - "type": "consulting", - "url": "https://feross.org/support" + "bare-events": { + "optional": true } - ], - "license": "MIT" + } }, "node_modules/basic-ftp": { "version": "5.0.5", @@ -2027,9 +2420,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", - "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", + "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", "dev": true, "funding": [ { @@ -2047,9 +2440,9 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001669", - "electron-to-chromium": "^1.5.41", - "node-releases": "^2.0.18", + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.1" }, "bin": { @@ -2069,30 +2462,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -2129,16 +2498,45 @@ } }, "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -2167,9 +2565,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001684", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001684.tgz", - "integrity": "sha512-G1LRwLIQjBQoyq0ZJGqGIJUXzJ8irpbjHLpVRXDvBEScFJ9b17sgK6vlx0GAJFE21okD7zXl08rRRUfq6HdoEQ==", + "version": "1.0.30001699", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001699.tgz", + "integrity": "sha512-b+uH5BakXZ9Do9iK+CkDmctUSEqZl+SP056vc5usa0PL+ev5OHw003rZXcnjNDv3L8P5j6rwT6C0BPKSikW08w==", "dev": true, "funding": [ { @@ -2253,14 +2651,13 @@ } }, "node_modules/chromium-bidi": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.3.tgz", - "integrity": "sha512-qXlsCmpCZJAnoTYI83Iu6EdYQpMYdVkCfq08KDh2pmlVqK5t5IA9mGs4/LwCwp4fqisSOMXZxP3HIh8w8aRn0A==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-1.2.0.tgz", + "integrity": "sha512-XtdJ1GSN6S3l7tO7F77GhNsw0K367p0IsLYf2yZawCVAKKC3lUvDhPdMVrB2FNhmhfW43QGYbEX3Wg6q0maGwQ==", "license": "Apache-2.0", "dependencies": { - "mitt": "3.0.1", - "urlpattern-polyfill": "10.0.0", - "zod": "3.23.8" + "mitt": "^3.0.1", + "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" @@ -2283,9 +2680,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", - "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "dev": true, "license": "MIT" }, @@ -2447,9 +2844,9 @@ } }, "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", "dev": true, "license": "MIT", "engines": { @@ -2604,12 +3001,13 @@ } }, "node_modules/cssstyle": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.1.0.tgz", - "integrity": "sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.2.1.tgz", + "integrity": "sha512-9+vem03dMXG7gDmZ62uqmRiMRNtinIZ9ZyuF6BdxzfOD+FdN5hretzynkn0ReS2DO2GSw76RWHs0UmJPI2zUjw==", "license": "MIT", "dependencies": { - "rrweb-cssom": "^0.7.1" + "@asamuzakjp/css-color": "^2.8.2", + "rrweb-cssom": "^0.8.0" }, "engines": { "node": ">=18" @@ -2638,15 +3036,15 @@ } }, "node_modules/data-view-buffer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", - "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "is-data-view": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -2656,31 +3054,31 @@ } }, "node_modules/data-view-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", - "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "is-data-view": "^1.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/inspect-js" } }, "node_modules/data-view-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", - "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", + "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" }, @@ -2692,9 +3090,9 @@ } }, "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2709,9 +3107,9 @@ } }, "node_modules/decimal.js": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", - "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", + "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", "license": "MIT" }, "node_modules/dedent": { @@ -2750,6 +3148,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -2834,9 +3233,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1312386", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", - "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==", + "version": "0.0.1402036", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1402036.tgz", + "integrity": "sha512-JwAYQgEvm3yD45CHB+RmF5kMbWtXBaOGwuxa87sZogHcLCv8c/IqnThaoQ1y60d7pXWjSKWQphPEc+1rAScVdg==", "license": "BSD-3-Clause" }, "node_modules/diff-sequences": { @@ -2869,9 +3268,9 @@ "license": "(MPL-2.0 OR Apache-2.0)" }, "node_modules/dotenv": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", - "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -2880,6 +3279,20 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2887,9 +3300,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.66", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.66.tgz", - "integrity": "sha512-pI2QF6+i+zjPbqRzJwkMvtvkdI7MjVbSh2g8dlMguDJIXEPw+kwasS1Jl+YGPEBfGVxsVgGUratAKymPdPo2vQ==", + "version": "1.5.99", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.99.tgz", + "integrity": "sha512-77c/+fCyL2U+aOyqfIFi89wYLBeSTCs55xCZL0oFH0KjqsvSvyh6AdQ+UIl1vgpnQQE6g+/KK8hOIupH6VwPtg==", "dev": true, "license": "ISC" }, @@ -2975,58 +3388,63 @@ } }, "node_modules/es-abstract": { - "version": "1.23.5", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.5.tgz", - "integrity": "sha512-vlmniQ0WNPwXqA0BnmwV3Ng7HxiGlh6r5U6JcTMNx8OilcAGqVJBHJcPjqOMaczU9fRuRK5Px2BdVyPRnKMMVQ==", + "version": "1.23.9", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", + "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", "dev": true, "license": "MIT", "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "arraybuffer.prototype.slice": "^1.0.3", + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "data-view-buffer": "^1.0.1", - "data-view-byte-length": "^1.0.1", - "data-view-byte-offset": "^1.0.0", - "es-define-property": "^1.0.0", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", - "es-set-tostringtag": "^2.0.3", - "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.4", - "get-symbol-description": "^1.0.2", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.0", + "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", - "gopd": "^1.0.1", + "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", - "has-proto": "^1.0.3", - "has-symbols": "^1.0.3", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", "hasown": "^2.0.2", - "internal-slot": "^1.0.7", - "is-array-buffer": "^3.0.4", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", - "is-data-view": "^1.0.1", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.3", - "is-string": "^1.0.7", - "is-typed-array": "^1.1.13", - "is-weakref": "^1.0.2", + "is-data-view": "^1.0.2", + "is-regex": "^1.2.1", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.0", + "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.3", "object-keys": "^1.1.1", - "object.assign": "^4.1.5", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.3", - "safe-array-concat": "^1.1.2", - "safe-regex-test": "^1.0.3", - "string.prototype.trim": "^1.2.9", - "string.prototype.trimend": "^1.0.8", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.2", - "typed-array-byte-length": "^1.0.1", - "typed-array-byte-offset": "^1.0.2", - "typed-array-length": "^1.0.6", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.15" + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.18" }, "engines": { "node": ">= 0.4" @@ -3036,13 +3454,10 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, "engines": { "node": ">= 0.4" } @@ -3057,10 +3472,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", - "dev": true, + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -3070,28 +3484,32 @@ } }, "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.4", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" } }, "node_modules/es-shim-unscopables": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", - "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.0" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/es-to-primitive": { @@ -3219,13 +3637,13 @@ } }, "node_modules/eslint-config-prettier": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", - "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.1.tgz", + "integrity": "sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==", "dev": true, "license": "MIT", "bin": { - "eslint-config-prettier": "bin/cli.js" + "eslint-config-prettier": "build/bin/cli.js" }, "peerDependencies": { "eslint": ">=7.0.0" @@ -3339,9 +3757,9 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", - "integrity": "sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", + "integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", "dev": true, "license": "MIT", "dependencies": { @@ -3587,9 +4005,9 @@ } }, "node_modules/express-rate-limit": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.4.1.tgz", - "integrity": "sha512-KS3efpnpIDVIXopMc65EMbWbUht7qvTCdtCR2dD/IZmi9MIkopYESwyRqLgv8Pfu589+KqDqOdzJWW7AHoACeg==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", "license": "MIT", "engines": { "node": ">= 16" @@ -3598,7 +4016,7 @@ "url": "https://github.com/sponsors/express-rate-limit" }, "peerDependencies": { - "express": "4 || 5 || ^5.0.0-beta.1" + "express": "^4.11 || 5 || ^5.0.0-beta.1" } }, "node_modules/express/node_modules/debug": { @@ -3686,9 +4104,9 @@ "license": "MIT" }, "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz", + "integrity": "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==", "dev": true, "license": "ISC", "dependencies": { @@ -3813,13 +4231,19 @@ "license": "ISC" }, "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, "license": "MIT", "dependencies": { - "is-callable": "^1.1.3" + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/form-data": { @@ -3854,20 +4278,6 @@ "node": ">= 0.6" } }, - "node_modules/fs-extra": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", - "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3875,6 +4285,21 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3885,16 +4310,18 @@ } }, "node_modules/function.prototype.name": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", - "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "functions-have-names": "^1.2.3" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" }, "engines": { "node": ">= 0.4" @@ -3946,16 +4373,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", + "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -3974,6 +4406,19 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -3988,15 +4433,15 @@ } }, "node_modules/get-symbol-description": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", - "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4" + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -4006,15 +4451,14 @@ } }, "node_modules/get-uri": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.3.tgz", - "integrity": "sha512-BzUrJBS9EcUb4cFol8r4W3v1cPsSyajLSthNkz5BxbpDcHN5tIrM10E2eNvfnvBn3DaT3DUgx0OpsBKkaOpanw==", + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.4.tgz", + "integrity": "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==", "license": "MIT", "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4", - "fs-extra": "^11.2.0" + "debug": "^4.3.4" }, "engines": { "node": ">= 14" @@ -4089,12 +4533,12 @@ } }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.3" + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4104,6 +4548,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -4114,11 +4559,14 @@ "license": "MIT" }, "node_modules/has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true, "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -4137,6 +4585,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -4146,10 +4595,14 @@ } }, "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -4158,9 +4611,9 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -4246,12 +4699,12 @@ } }, "node_modules/https-proxy-agent": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", - "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { @@ -4296,26 +4749,6 @@ "node": ">=0.10.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4334,9 +4767,9 @@ "license": "ISC" }, "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -4398,15 +4831,15 @@ "license": "ISC" }, "node_modules/internal-slot": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", - "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "hasown": "^2.0.0", - "side-channel": "^1.0.4" + "hasown": "^2.0.2", + "side-channel": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -4435,14 +4868,15 @@ } }, "node_modules/is-array-buffer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", - "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -4458,13 +4892,17 @@ "license": "MIT" }, "node_modules/is-async-function": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", - "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "dev": true, "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -4474,13 +4912,16 @@ } }, "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, "license": "MIT", "dependencies": { - "has-bigints": "^1.0.1" + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4500,14 +4941,14 @@ } }, "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -4530,9 +4971,9 @@ } }, "node_modules/is-core-module": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", - "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "license": "MIT", "dependencies": { @@ -4546,12 +4987,14 @@ } }, "node_modules/is-data-view": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", - "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "dev": true, "license": "MIT", "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" }, "engines": { @@ -4562,13 +5005,14 @@ } }, "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -4588,13 +5032,13 @@ } }, "node_modules/is-finalizationregistry": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.0.tgz", - "integrity": "sha512-qfMdqbAQEwBw78ZyReKnlA8ezmPdb9BemzIIip/JkjaZUhitfXDkkr+3QTboW0JrSXT1QWyYShpvnNHGZ4c4yA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7" + "call-bound": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -4627,13 +5071,16 @@ } }, "node_modules/is-generator-function": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", - "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", "dev": true, "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -4668,19 +5115,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4692,13 +5126,14 @@ } }, "node_modules/is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -4724,14 +5159,16 @@ "license": "MIT" }, "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -4754,13 +5191,13 @@ } }, "node_modules/is-shared-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", - "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7" + "call-bound": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -4783,13 +5220,14 @@ } }, "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -4799,13 +5237,15 @@ } }, "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, "license": "MIT", "dependencies": { - "has-symbols": "^1.0.2" + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -4815,13 +5255,13 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", - "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.14" + "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" @@ -4844,27 +5284,30 @@ } }, "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2" + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-weakset": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.3.tgz", - "integrity": "sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "get-intrinsic": "^1.2.4" + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -4914,9 +5357,9 @@ } }, "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "dev": true, "license": "ISC", "bin": { @@ -5453,9 +5896,9 @@ } }, "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "dev": true, "license": "ISC", "bin": { @@ -5591,30 +6034,30 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "24.1.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", - "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.0.0.tgz", + "integrity": "sha512-BZYDGVAIriBWTpIxYzrXjv3E/4u8+/pSG5bQdIYCbNCGOvsPkDQfTVLAIXAf9ETdCpduCVTkDe2NNZ8NIwUVzw==", "license": "MIT", "dependencies": { - "cssstyle": "^4.0.1", + "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.4.3", - "form-data": "^4.0.0", + "form-data": "^4.0.1", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.5", + "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.12", - "parse5": "^7.1.2", - "rrweb-cssom": "^0.7.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.4", + "tough-cookie": "^5.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0", + "whatwg-url": "^14.1.0", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, @@ -5622,7 +6065,7 @@ "node": ">=18" }, "peerDependencies": { - "canvas": "^2.11.2" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { "canvas": { @@ -5631,9 +6074,9 @@ } }, "node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "license": "MIT", "bin": { @@ -5683,18 +6126,6 @@ "node": ">=6" } }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5739,9 +6170,9 @@ } }, "node_modules/lilconfig": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", - "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, "license": "MIT", "engines": { @@ -5758,22 +6189,22 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "15.2.10", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.10.tgz", - "integrity": "sha512-5dY5t743e1byO19P9I4b3x8HJwalIznL5E1FWYnU6OWw33KxNBSLAc6Cy7F2PsFEO8FKnLwjwm5hx7aMF0jzZg==", + "version": "15.4.3", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.4.3.tgz", + "integrity": "sha512-FoH1vOeouNh1pw+90S+cnuoFwRfUD9ijY2GKy5h7HS3OR7JVir2N2xrsa0+Twc1B7cW72L+88geG5cW4wIhn7g==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "~5.3.0", - "commander": "~12.1.0", - "debug": "~4.3.6", - "execa": "~8.0.1", - "lilconfig": "~3.1.2", - "listr2": "~8.2.4", - "micromatch": "~4.0.8", - "pidtree": "~0.6.0", - "string-argv": "~0.3.2", - "yaml": "~2.5.0" + "chalk": "^5.4.1", + "commander": "^13.1.0", + "debug": "^4.4.0", + "execa": "^8.0.1", + "lilconfig": "^3.1.3", + "listr2": "^8.2.5", + "micromatch": "^4.0.8", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.7.0" }, "bin": { "lint-staged": "bin/lint-staged.js" @@ -5786,9 +6217,9 @@ } }, "node_modules/lint-staged/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", "dev": true, "license": "MIT", "engines": { @@ -6121,9 +6552,9 @@ } }, "node_modules/make-dir/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "dev": true, "license": "ISC", "bin": { @@ -6143,6 +6574,15 @@ "tmpl": "1.0.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -6344,16 +6784,16 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "dev": true, "license": "MIT" }, "node_modules/nodemon": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.7.tgz", - "integrity": "sha512-hLj7fuMow6f0lbB0cD14Lz2xNjwsyruH251Pk4t/yIitCFJbmY1myuLlHm/q06aST4jg6EgAh74PIBBrRqpVAQ==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", + "integrity": "sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==", "dev": true, "license": "MIT", "dependencies": { @@ -6390,9 +6830,9 @@ } }, "node_modules/nodemon/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "dev": true, "license": "ISC", "bin": { @@ -6439,9 +6879,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.13", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.13.tgz", - "integrity": "sha512-cTGB9ptp9dY9A5VbMSe7fQBcl/tt22Vcqdq8+eN93rblOuE0aCFu4aZ2vMwct/2t+lFnosm8RkQW1I0Omb1UtQ==", + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.16.tgz", + "integrity": "sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==", "license": "MIT" }, "node_modules/object-assign": { @@ -6454,9 +6894,9 @@ } }, "node_modules/object-inspect": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", - "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6476,15 +6916,17 @@ } }, "node_modules/object.assign": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", - "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", "define-properties": "^1.2.1", - "has-symbols": "^1.0.3", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", "object-keys": "^1.1.1" }, "engines": { @@ -6529,13 +6971,14 @@ } }, "node_modules/object.values": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.0.tgz", - "integrity": "sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" }, @@ -6601,6 +7044,24 @@ "node": ">= 0.8.0" } }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6644,19 +7105,19 @@ } }, "node_modules/pac-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.0.2.tgz", - "integrity": "sha512-BFi3vZnO9X5Qt6NRz7ZOaPja3ic0PhlsmCRYLOpN11+mWBCR6XJDqW5RF3j8jm4WGGQZtBA+bTfxYzeKW73eHg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.1.0.tgz", + "integrity": "sha512-Z5FnLVVZSnX7WjBg0mhDtydeRZ1xMcATZThjySQUHqr+0ksP8kqaw23fNKkaaN/Z8gwLUs/W7xdl0I75eP2Xyw==", "license": "MIT", "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.5", + "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.4" + "socks-proxy-agent": "^8.0.5" }, "engines": { "node": ">= 14" @@ -6887,9 +7348,9 @@ } }, "node_modules/possible-typed-array-names": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", - "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", "dev": true, "license": "MIT", "engines": { @@ -6907,9 +7368,9 @@ } }, "node_modules/prettier": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.1.tgz", - "integrity": "sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz", + "integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==", "dev": true, "license": "MIT", "bin": { @@ -7005,19 +7466,19 @@ } }, "node_modules/proxy-agent": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz", - "integrity": "sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", "license": "MIT", "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.3", + "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.0.1", + "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.2" + "socks-proxy-agent": "^8.0.5" }, "engines": { "node": ">= 14" @@ -7038,15 +7499,6 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, - "node_modules/psl": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.13.0.tgz", - "integrity": "sha512-BFwmFXiJoFqlUpZ5Qssolv15DMyc84gTBds1BjsV1BfXEo1UyyD7GsmN67n7J77uRhoSNW1AXtXKPLcBFQn9Aw==", - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - } - }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -7074,34 +7526,37 @@ } }, "node_modules/puppeteer": { - "version": "22.15.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-22.15.0.tgz", - "integrity": "sha512-XjCY1SiSEi1T7iSYuxS82ft85kwDJUS7wj1Z0eGVXKdtr5g4xnVcbjwxhq5xBnpK/E7x1VZZoJDxpjAOasHT4Q==", + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.2.0.tgz", + "integrity": "sha512-z8vv7zPEgrilIbOo3WNvM+2mXMnyM9f4z6zdrB88Fzeuo43Oupmjrzk3EpuvuCtyK0A7Lsllfx7Z+4BvEEGJcQ==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.3.0", + "@puppeteer/browsers": "2.7.1", + "chromium-bidi": "1.2.0", "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1312386", - "puppeteer-core": "22.15.0" + "devtools-protocol": "0.0.1402036", + "puppeteer-core": "24.2.0", + "typed-query-selector": "^2.12.0" }, "bin": { - "puppeteer": "lib/esm/puppeteer/node/cli.js" + "puppeteer": "lib/cjs/puppeteer/node/cli.js" }, "engines": { "node": ">=18" } }, "node_modules/puppeteer-core": { - "version": "22.15.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-22.15.0.tgz", - "integrity": "sha512-cHArnywCiAAVXa3t4GGL2vttNxh7GqXtIYGym99egkNJ3oG//wL9LkvO4WE8W1TJe95t1F1ocu9X4xWaGsOKOA==", + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.2.0.tgz", + "integrity": "sha512-e4A4/xqWdd4kcE6QVHYhJ+Qlx/+XpgjP4d8OwBx0DJoY/nkIRhSgYmKQnv7+XSs1ofBstalt+XPGrkaz4FoXOQ==", "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.3.0", - "chromium-bidi": "0.6.3", - "debug": "^4.3.6", - "devtools-protocol": "0.0.1312386", + "@puppeteer/browsers": "2.7.1", + "chromium-bidi": "1.2.0", + "debug": "^4.4.0", + "devtools-protocol": "0.0.1402036", + "typed-query-selector": "^2.12.0", "ws": "^8.18.0" }, "engines": { @@ -7140,12 +7595,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "license": "MIT" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7167,12 +7616,6 @@ ], "license": "MIT" }, - "node_modules/queue-tick": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", - "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", - "license": "MIT" - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -7249,19 +7692,20 @@ } }, "node_modules/reflect.getprototypeof": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.7.tgz", - "integrity": "sha512-bMvFGIUKlc/eSfXNX+aZ+EL95/EgZzuwA0OBPTbZZDEJw/0AkentjMuM1oiRfwHrshqk4RzdgiTg5CcDalXN5g==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", + "es-abstract": "^1.23.9", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "which-builtin-type": "^1.1.4" + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -7271,15 +7715,17 @@ } }, "node_modules/regexp.prototype.flags": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", - "integrity": "sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", "set-function-name": "^2.0.2" }, "engines": { @@ -7298,26 +7744,23 @@ "node": ">=0.10.0" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "license": "MIT" - }, "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.13.0", + "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -7355,9 +7798,9 @@ } }, "node_modules/resolve.exports": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", - "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", "dev": true, "license": "MIT", "engines": { @@ -7446,9 +7889,9 @@ } }, "node_modules/rollup": { - "version": "4.27.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.27.4.tgz", - "integrity": "sha512-RLKxqHEMjh/RGLsDxAEsaLO3mWgyoU6x9w6n1ikAzet4B3gI2/3yP6PWY2p9QzRTh6MfEIXB3MwsOY0Iv3vNrw==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.6.tgz", + "integrity": "sha512-wc2cBWqJgkU3Iz5oztRkQbfVkbxoz5EhnCGOrnJvnLnQ7O0WhQUYyv18qQI79O8L7DdHrrlJNeCHd4VGpnaXKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7462,31 +7905,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.27.4", - "@rollup/rollup-android-arm64": "4.27.4", - "@rollup/rollup-darwin-arm64": "4.27.4", - "@rollup/rollup-darwin-x64": "4.27.4", - "@rollup/rollup-freebsd-arm64": "4.27.4", - "@rollup/rollup-freebsd-x64": "4.27.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.27.4", - "@rollup/rollup-linux-arm-musleabihf": "4.27.4", - "@rollup/rollup-linux-arm64-gnu": "4.27.4", - "@rollup/rollup-linux-arm64-musl": "4.27.4", - "@rollup/rollup-linux-powerpc64le-gnu": "4.27.4", - "@rollup/rollup-linux-riscv64-gnu": "4.27.4", - "@rollup/rollup-linux-s390x-gnu": "4.27.4", - "@rollup/rollup-linux-x64-gnu": "4.27.4", - "@rollup/rollup-linux-x64-musl": "4.27.4", - "@rollup/rollup-win32-arm64-msvc": "4.27.4", - "@rollup/rollup-win32-ia32-msvc": "4.27.4", - "@rollup/rollup-win32-x64-msvc": "4.27.4", + "@rollup/rollup-android-arm-eabi": "4.34.6", + "@rollup/rollup-android-arm64": "4.34.6", + "@rollup/rollup-darwin-arm64": "4.34.6", + "@rollup/rollup-darwin-x64": "4.34.6", + "@rollup/rollup-freebsd-arm64": "4.34.6", + "@rollup/rollup-freebsd-x64": "4.34.6", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.6", + "@rollup/rollup-linux-arm-musleabihf": "4.34.6", + "@rollup/rollup-linux-arm64-gnu": "4.34.6", + "@rollup/rollup-linux-arm64-musl": "4.34.6", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.6", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.6", + "@rollup/rollup-linux-riscv64-gnu": "4.34.6", + "@rollup/rollup-linux-s390x-gnu": "4.34.6", + "@rollup/rollup-linux-x64-gnu": "4.34.6", + "@rollup/rollup-linux-x64-musl": "4.34.6", + "@rollup/rollup-win32-arm64-msvc": "4.34.6", + "@rollup/rollup-win32-ia32-msvc": "4.34.6", + "@rollup/rollup-win32-x64-msvc": "4.34.6", "fsevents": "~2.3.2" } }, "node_modules/rrweb-cssom": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", - "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "license": "MIT" }, "node_modules/run-parallel": { @@ -7514,15 +7958,16 @@ } }, "node_modules/safe-array-concat": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", - "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "get-intrinsic": "^1.2.4", - "has-symbols": "^1.0.3", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, "engines": { @@ -7559,16 +8004,40 @@ ], "license": "MIT" }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-regex-test": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", - "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", + "call-bound": "^1.0.2", "es-errors": "^1.3.0", - "is-regex": "^1.1.4" + "is-regex": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -7682,6 +8151,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -7711,6 +8181,21 @@ "node": ">= 0.4" } }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -7741,15 +8226,69 @@ } }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -7779,9 +8318,9 @@ } }, "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "dev": true, "license": "ISC", "bin": { @@ -7855,9 +8394,9 @@ "license": "MIT" }, "node_modules/socks": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", - "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", + "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", "license": "MIT", "dependencies": { "ip-address": "^9.0.5", @@ -7869,12 +8408,12 @@ } }, "node_modules/socks-proxy-agent": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.4.tgz", - "integrity": "sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "license": "MIT", "dependencies": { - "agent-base": "^7.1.1", + "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" }, @@ -7950,13 +8489,12 @@ } }, "node_modules/streamx": { - "version": "2.20.2", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.20.2.tgz", - "integrity": "sha512-aDGDLU+j9tJcUdPGOaHmVF1u/hhI+CsGkT02V3OKlHDV7IukOI+nTWAGkiZEKCO35rWN1wIr4tS7YFr1f4qSvA==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", + "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", "license": "MIT", "dependencies": { "fast-fifo": "^1.3.2", - "queue-tick": "^1.0.1", "text-decoder": "^1.1.0" }, "optionalDependencies": { @@ -8050,16 +8588,19 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", - "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", - "es-object-atoms": "^1.0.0" + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -8069,16 +8610,20 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", - "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8196,17 +8741,17 @@ } }, "node_modules/tar-fs": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.6.tgz", - "integrity": "sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz", + "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==", "license": "MIT", "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { - "bare-fs": "^2.1.1", - "bare-path": "^2.1.0" + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" } }, "node_modules/tar-stream": { @@ -8230,9 +8775,9 @@ } }, "node_modules/terser": { - "version": "5.36.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz", - "integrity": "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==", + "version": "5.39.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", + "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -8282,10 +8827,13 @@ } }, "node_modules/text-decoder": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.1.tgz", - "integrity": "sha512-x9v3H/lTKIJKQQe7RPQkLfKAnc9lUTkWDypIQgTzPJAq+5/GCDHonmshfvlsNSj58yyshbIJJDLmU15qNERrXQ==", - "license": "Apache-2.0" + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } }, "node_modules/text-table": { "version": "0.2.0", @@ -8294,10 +8842,22 @@ "dev": true, "license": "MIT" }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "node_modules/tldts": { + "version": "6.1.77", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.77.tgz", + "integrity": "sha512-lBpoWgy+kYmuXWQ83+R7LlJCnsd9YW8DGpZSHhrMl4b8Ly/1vzOie3OdtmUJDkKxcgRGOehDu5btKkty+JEe+g==", + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.77" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.77", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.77.tgz", + "integrity": "sha512-bCaqm24FPk8OgBkM0u/SrEWJgHnhBWYqeBo6yUmcZJDCHt/IfyWBb+14CXdGi4RInMv4v7eUAin15W0DoA+Ytg==", "license": "MIT" }, "node_modules/tmpl": { @@ -8340,27 +8900,15 @@ } }, "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.1.tgz", + "integrity": "sha512-Ek7HndSVkp10hmHP9V4qZO1u+pn1RU5sI0Fw+jCU3lyvuMZcgqsNgc6CmJJZyByK4Vm/qotGRJlfgAX8q+4JiA==", "license": "BSD-3-Clause", "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "tldts": "^6.1.32" }, "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "license": "MIT", - "engines": { - "node": ">= 4.0.0" + "node": ">=16" } }, "node_modules/tr46": { @@ -8467,32 +9015,32 @@ } }, "node_modules/typed-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", - "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-typed-array": "^1.1.13" + "is-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" } }, "node_modules/typed-array-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", - "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -8502,19 +9050,19 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.3.tgz", - "integrity": "sha512-GsvTyUHTriq6o/bHcTd0vM7OQ9JEdlvluu9YISaA7+KzDzPaIzEeDFNkTfhdE3MYcNhNi0vq/LlegYgIs5yPAw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13", - "reflect.getprototypeof": "^1.0.6" + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" }, "engines": { "node": ">= 0.4" @@ -8544,6 +9092,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "license": "MIT" + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -8551,31 +9105,24 @@ "license": "MIT" }, "node_modules/unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", + "call-bound": "^1.0.3", "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/unbzip2-stream": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", - "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", - "license": "MIT", - "dependencies": { - "buffer": "^5.2.1", - "through": "^2.3.8" - } - }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -8590,15 +9137,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -8609,9 +9147,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", - "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", + "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==", "dev": true, "funding": [ { @@ -8630,7 +9168,7 @@ "license": "MIT", "dependencies": { "escalade": "^3.2.0", - "picocolors": "^1.1.0" + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -8649,22 +9187,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "license": "MIT", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, - "node_modules/urlpattern-polyfill": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", - "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==", - "license": "MIT" - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -8681,16 +9203,16 @@ } }, "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/v8-to-istanbul": { @@ -8782,9 +9304,9 @@ } }, "node_modules/whatwg-url": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", - "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.1.tgz", + "integrity": "sha512-mDGf9diDad/giZ/Sm9Xi2YcyzaFpbdLpJPr+E9fSkyQ7KpQD4SdFcugkRQYzhmfI4KeV4Qpnn2sKPdo+kmsgRQ==", "license": "MIT", "dependencies": { "tr46": "^5.0.0", @@ -8811,42 +9333,45 @@ } }, "node_modules/which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", "dev": true, "license": "MIT", "dependencies": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/which-builtin-type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.0.tgz", - "integrity": "sha512-I+qLGQ/vucCby4tf5HsLmGueEla4ZhwTBSqaooS+Y0BuxN4Cp+okmGuV+8mXZ84KDI9BA+oklo+RzKg0ONdSUA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", - "is-date-object": "^1.0.5", + "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", - "is-regex": "^1.1.4", + "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", - "which-boxed-primitive": "^1.0.2", + "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", - "which-typed-array": "^1.1.15" + "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" @@ -8882,16 +9407,17 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.16.tgz", - "integrity": "sha512-g+N+GAWiRj66DngFwHvISJd+ITsyphZvD1vChfVg6cEdnzy53GzB3oy0fUNlvhz7H7+MiqhYr26qxQShCpKTTQ==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz", + "integrity": "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", "for-each": "^0.3.3", - "gopd": "^1.0.1", + "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" }, "engines": { @@ -9053,9 +9579,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", - "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", + "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", "dev": true, "license": "ISC", "bin": { @@ -9145,9 +9671,9 @@ } }, "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 6c7a3b6c..d24cadc2 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "author": "Highsoft AS (http://www.highcharts.com/about)", "license": "MIT", "type": "module", - "version": "4.1.0", + "version": "5.0.0", "main": "./dist/index.esm.js", "repository": { "url": "https://github.com/highcharts/node-export-server", @@ -37,44 +37,39 @@ "complete": "npm run lint && npm run format && npm run build", "lint": "eslint ./ --ext .js,.ts --fix", "format": "prettier ./ --config .prettierrc.cjs --write", - "build": "rollup -c", + "build": "rollup --config --silent", "prepack": "npm run build", - "cli-tests": "node ./tests/cli/cliTestRunner.js", - "cli-tests-single": "node ./tests/cli/cliTestRunnerSingle.js", - "http-tests": "node ./tests/http/httpTestRunner.js", - "http-tests-single": "node ./tests/http/httpTestRunnerSingle.js", - "node-tests": "node ./tests/node/nodeTestRunner.js", - "node-tests-single": "node ./tests/node/nodeTestRunnerSingle.js", "unit:test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "prepare": "husky || true" }, "dependencies": { "colors": "1.4.0", "cors": "^2.8.5", - "dompurify": "^3.1.6", - "dotenv": "^16.4.5", - "express": "^4.19.2", - "express-rate-limit": "^7.3.1", - "https-proxy-agent": "^7.0.5", - "jsdom": "^24.1.0", + "dompurify": "3.1.6", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "express-rate-limit": "^7.5.0", + "https-proxy-agent": "^7.0.6", + "jsdom": "^26.0.0", "multer": "^1.4.5-lts.1", "prompts": "^2.4.2", - "puppeteer": "^22.12.1", + "puppeteer": "^24.2.0", "tarn": "^3.0.2", - "uuid": "^10.0.0", - "zod": "^3.23.8" + "uuid": "^11.0.5", + "zod": "^3.24.2" }, "devDependencies": { + "@jest/globals": "^29.7.0", "@rollup/plugin-terser": "^0.4.4", "eslint": "^8.57.0", - "eslint-config-prettier": "^9.1.0", + "eslint-config-prettier": "^10.0.1", "eslint-plugin-import": "^2.31.0", - "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-prettier": "^5.2.3", "husky": "^9.1.7", "jest": "^29.7.0", - "lint-staged": "^15.2.10", - "nodemon": "^3.1.7", - "prettier": "^3.4.1", - "rollup": "^4.27.4" + "lint-staged": "^15.4.3", + "nodemon": "^3.1.9", + "prettier": "^3.5.1", + "rollup": "^4.34.6" } } diff --git a/public/index.html b/public/index.html index 75866695..8113211d 100644 --- a/public/index.html +++ b/public/index.html @@ -49,7 +49,7 @@

Highcharts Export Server

This page allows you to experiment with different options for the - export server. If you use the public Export Server at + Export Server. If you use the public Export Server at https://export.highcharts.com diff --git a/public/js/main.js b/public/js/main.js index 374b9875..bb3fd4af 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -1,4 +1,19 @@ +/******************************************************************************* + +Highcharts Export Server + +Copyright (c) 2016-2025, Highsoft + +Licenced under the MIT licence. + +Additionally a valid Highcharts license is required for use. + +See LICENSE file in root for details. + +*******************************************************************************/ + /* eslint-disable no-undef */ + const highexp = {}; (function () { @@ -44,7 +59,7 @@ const highexp = {}; function toStructure(b64) { return { - infile: optionsCM.getValue(), + options: optionsCM.getValue(), width: width.value.length ? width.value : false, scale: scale.value.length ? scale.value : false, constr: constr.value, diff --git a/samples/http/requestInfile.json b/samples/http/requestInfile.json index 7459eb73..286722c4 100644 --- a/samples/http/requestInfile.json +++ b/samples/http/requestInfile.json @@ -1,5 +1,5 @@ { - "infile": { + "options": { "chart": { "type": "column" }, @@ -32,17 +32,12 @@ ] }, "type": "png", - "scale": 2, - "width": 800, - "height": 800, - "callback": "function callback(chart) {chart.renderer.label('This label is added in the stringified callback.
Highcharts version ' + Highcharts.version,75,75).attr({fill: '#90ed7d', padding: 10, r: 10, zIndex: 10}).css({color: 'black', width: '100px'}).add();}", - "resources": { - "js": "Highcharts.charts[0].update({title: {text: 'Resources title'}});", - "css": ".highcharts-color-0 {fill: #7cb5ec; stroke: #7cb5ec;} .highcharts-axis.highcharts-color-0 .highcharts-axis-line {stroke: #7cb5ec;} .highcharts-axis.highcharts-color-0 text {fill: #7cb5ec;}.highcharts-color-1 {fill: #90ed7d; stroke: #90ed7d;} .highcharts-axis.highcharts-color-1 .highcharts-axis-line {stroke: #90ed7d;} .highcharts-axis.highcharts-color-1 text {fill: #90ed7d;}.highcharts-yaxis .highcharts-axis-line {stroke-width: 2px;}" - }, "constr": "chart", "b64": false, "noDownload": false, + "height": 800, + "width": 800, + "scale": 2, "globalOptions": { "chart": { "borderWidth": 2, @@ -95,5 +90,10 @@ } } }, - "customCode": "function () {Highcharts.setOptions({chart: {borderWidth: 2, plotBackgroundColor: 'rgba(255, 255, 255, .9)', plotShadow: true, plotBorderWidth: 1, events: {render: function() {this.renderer.image('https://www.highcharts.com/samples/graphics/sun.png', 250, 120, 20, 20).add();}}}});}" + "customCode": "function () {Highcharts.setOptions({chart: {borderWidth: 2, plotBackgroundColor: 'rgba(255, 255, 255, .9)', plotShadow: true, plotBorderWidth: 1, events: {render: function() {this.renderer.image('https://www.highcharts.com/samples/graphics/sun.png', 250, 120, 20, 20).add();}}}});}", + "callback": "function callback(chart) {chart.renderer.label('This label is added in the stringified callback.
Highcharts version ' + Highcharts.version,75,75).attr({fill: '#90ed7d', padding: 10, r: 10, zIndex: 10}).css({color: 'black', width: '100px'}).add();}", + "resources": { + "js": "Highcharts.charts[0].update({title: {text: 'Resources title'}});", + "css": ".highcharts-color-0 {fill: #7cb5ec; stroke: #7cb5ec;} .highcharts-axis.highcharts-color-0 .highcharts-axis-line {stroke: #7cb5ec;} .highcharts-axis.highcharts-color-0 text {fill: #7cb5ec;}.highcharts-color-1 {fill: #90ed7d; stroke: #90ed7d;} .highcharts-axis.highcharts-color-1 .highcharts-axis-line {stroke: #90ed7d;} .highcharts-axis.highcharts-color-1 text {fill: #90ed7d;}.highcharts-yaxis .highcharts-axis-line {stroke-width: 2px;}" + } } diff --git a/samples/module/optionsPhantom.js b/samples/module/optionsPhantom.js index a76a71a2..ea4e6413 100644 --- a/samples/module/optionsPhantom.js +++ b/samples/module/optionsPhantom.js @@ -1,16 +1,21 @@ -import { writeFileSync } from 'fs'; +/******************************************************************************* -import exporter from '../../lib/index.js'; +Highcharts Export Server -// Export settings with the old options structure (PhantomJS) -// Will be mapped appropriately to the new structure with the mapToNewConfig utility -const exportSettings = { - type: 'png', - constr: 'chart', - outfile: './samples/module/optionsPhantom.jpeg', - logLevel: 4, - scale: 1, - workers: 1, +Copyright (c) 2016-2025, Highsoft + +Licenced under the MIT licence. + +Additionally a valid Highcharts license is required for use. + +See LICENSE file in root for details. + +*******************************************************************************/ + +import exporter, { initExport } from '../../lib/index.js'; + +// Old options structure (PhantomJS) +const oldOptions = { options: { chart: { type: 'column' @@ -44,37 +49,35 @@ const exportSettings = { data: [5, 3, 4, 2] } ] - } + }, + outfile: './samples/module/optionsPhantom.png', + type: 'png', + constr: 'chart', + width: 1000, + scale: 1, + globalOptions: './samples/resources/optionsGlobal.json', + allowFileResources: true, + callback: './samples/resources/callback.js', + resources: './samples/resources/resources.json', + fromFile: './samples/resources/customOptions.json', + workers: 1, + workLimit: 5, + logLevel: 4, + logFile: 'phantom.log', + logDest: './samples/module/log', + logToFile: false }; -const start = async () => { +(async () => { try { - // Map to fit the new options structure - const mappedOptions = exporter.mapToNewConfig(exportSettings); - - // Set the new options - const options = exporter.setOptions(mappedOptions); + // Map to fit the new options structure (Puppeteer) + const newOptions = exporter.mapToNewOptions(oldOptions); // Init a pool for one export - await exporter.initExport(options); + await initExport(newOptions); // Perform an export - await exporter.startExport(options, async (error, info) => { - // Exit process and display error - if (error) { - throw error; - } - const { outfile, type } = info.options.export; - - // Save the base64 from a buffer to a correct image file - writeFileSync( - outfile, - type !== 'svg' ? Buffer.from(info.result, 'base64') : info.result - ); - - // Kill the pool - await exporter.killPool(); - }); + await exporter.singleExport(newOptions); } catch (error) { // Log the error with stack exporter.logWithStack(1, error); @@ -82,6 +85,4 @@ const start = async () => { // Gracefully shut down the process await exporter.shutdownCleanUp(1); } -}; - -start(); +})(); diff --git a/samples/module/optionsPuppeteer.js b/samples/module/optionsPuppeteer.js index a1783a0f..f3c37b87 100644 --- a/samples/module/optionsPuppeteer.js +++ b/samples/module/optionsPuppeteer.js @@ -1,20 +1,22 @@ -import { writeFileSync } from 'fs'; +/******************************************************************************* -import exporter from '../../lib/index.js'; +Highcharts Export Server -// Export settings with new options structure (Puppeteer) -const exportSettings = { - pool: { - minWorkers: 1, - maxWorkers: 1 - }, +Copyright (c) 2016-2025, Highsoft + +Licenced under the MIT licence. + +Additionally a valid Highcharts license is required for use. + +See LICENSE file in root for details. + +*******************************************************************************/ + +import exporter, { initExport } from '../../lib/index.js'; + +// New options structure (Puppeteer) +const newOptions = { export: { - type: 'jpeg', - constr: 'chart', - outfile: './samples/module/optionsPuppeteer.jpeg', - height: 800, - width: 1200, - scale: 1, options: { chart: { type: 'column' @@ -62,6 +64,12 @@ const exportSettings = { } ] }, + outfile: './samples/module/optionsPuppeteer.jpeg', + type: 'jpeg', + constr: 'chart', + height: 800, + width: 1200, + scale: 1, globalOptions: { chart: { borderWidth: 2, @@ -112,40 +120,28 @@ const exportSettings = { customLogic: { allowCodeExecution: true, allowFileResources: true, - callback: './samples/resources/callback.js', customCode: './samples/resources/customCode.js', + callback: './samples/resources/callback.js', resources: { js: "Highcharts.charts[0].update({xAxis: {title: {text: 'Resources axis title'}}});", css: '.highcharts-yaxis .highcharts-axis-line { stroke-width: 2px; } .highcharts-color-0 { fill: #f7a35c; stroke: #f7a35c; }' } + }, + pool: { + maxWorkers: 1 + }, + logging: { + toFile: false } }; -const start = async () => { +(async () => { try { - // Set the new options - const options = exporter.setOptions(exportSettings); - // Init a pool for one export - await exporter.initExport(options); + await initExport(newOptions); // Perform an export - await exporter.startExport(options, async (error, info) => { - // Exit process and display error - if (error) { - throw error; - } - const { outfile, type } = info.options.export; - - // Save the base64 from a buffer to a correct image file - writeFileSync( - outfile, - type !== 'svg' ? Buffer.from(info.result, 'base64') : info.result - ); - - // Kill the pool - await exporter.killPool(); - }); + await exporter.singleExport(newOptions); } catch (error) { // Log the error with stack exporter.logWithStack(1, error); @@ -153,6 +149,4 @@ const start = async () => { // Gracefully shut down the process await exporter.shutdownCleanUp(1); } -}; - -start(); +})(); diff --git a/samples/module/promises.js b/samples/module/promises.js index c5cb7ab4..01bdbcff 100644 --- a/samples/module/promises.js +++ b/samples/module/promises.js @@ -1,33 +1,48 @@ -import { writeFileSync } from 'fs'; +/******************************************************************************* + +Highcharts Export Server + +Copyright (c) 2016-2025, Highsoft + +Licenced under the MIT licence. + +Additionally a valid Highcharts license is required for use. -import exporter from '../../lib/index.js'; +See LICENSE file in root for details. -const exportCharts = async (charts, exportOptions = {}) => { - // Set the new options - const options = exporter.setOptions(exportOptions); +*******************************************************************************/ +import { writeFileSync } from 'fs'; + +import exporter, { initExport } from '../../lib/index.js'; + +const exportCharts = async (charts, initOptions) => { // Init the pool - await exporter.initExport(options); + await initExport(initOptions); const promises = []; const chartResults = []; // Start exporting charts - charts.forEach((chart) => { + charts.forEach((options) => { promises.push( new Promise((resolve, reject) => { - const settings = { ...options }; - settings.export.options = chart; + exporter.startExport( + { + export: { + options + } + }, + (error, data) => { + if (error) { + return reject(error); + } - exporter.startExport(settings, (error, info) => { - if (error) { - return reject(error); + // Add the data to the chartResults + chartResults.push(data.result); + resolve(); } - - // Add the data to the chartResults - chartResults.push(info.result); - resolve(); - }); + ); }) ); }); @@ -73,7 +88,8 @@ exportCharts( maxWorkers: 2 }, logging: { - level: 4 + level: 4, + toFile: false } } ) @@ -86,8 +102,8 @@ exportCharts( Buffer.from(chart, 'base64') ); }); - exporter.log(4, 'All done!'); + exporter.log(4, '[promises] All done!'); }) .catch((error) => { - exporter.logWithStack(1, error, 'Something went wrong!'); + exporter.logWithStack(1, error, '[promises] Something went wrong!'); }); diff --git a/samples/module/svg.js b/samples/module/svg.js index 81a68263..f5e3caf8 100644 --- a/samples/module/svg.js +++ b/samples/module/svg.js @@ -1,48 +1,42 @@ -import { writeFileSync } from 'fs'; +/******************************************************************************* -import exporter from '../../lib/index.js'; +Highcharts Export Server -// Export settings with new options structure (Puppeteer) -const exportSettings = { +Copyright (c) 2016-2025, Highsoft + +Licenced under the MIT licence. + +Additionally a valid Highcharts license is required for use. + +See LICENSE file in root for details. + +*******************************************************************************/ + +import exporter, { initExport } from '../../lib/index.js'; + +// SVG options +const svgOptions = { export: { - type: 'png', + svg: 'Highcharts.com', outfile: './samples/module/svg.png', + type: 'png', scale: 2 }, pool: { - minWorkers: 1, maxWorkers: 1 }, - payload: { - svg: 'Highcharts.com' + logging: { + toFile: false } }; -const start = async () => { +(async () => { try { - // Set the new options - const options = exporter.setOptions(exportSettings); - // Init a pool for one export - await exporter.initExport(options); + await initExport(svgOptions); // Perform an export - await exporter.startExport(options, async (error, info) => { - // Exit process and display error - if (error) { - throw error; - } - const { outfile, type } = info.options.export; - - // Save the base64 from a buffer to a correct image file - writeFileSync( - outfile, - type !== 'svg' ? Buffer.from(info.result, 'base64') : info.result - ); - - // Kill the pool - await exporter.killPool(); - }); + await exporter.singleExport(svgOptions); } catch (error) { // Log the error with stack exporter.logWithStack(1, error); @@ -50,6 +44,4 @@ const start = async () => { // Gracefully shut down the process await exporter.shutdownCleanUp(1); } -}; - -start(); +})(); diff --git a/samples/cli/customOptions.json b/samples/resources/customOptions.json similarity index 100% rename from samples/cli/customOptions.json rename to samples/resources/customOptions.json diff --git a/templates/svgExport/css.js b/templates/svgExport/css.js index 76d08a5a..cddd360a 100644 --- a/templates/svgExport/css.js +++ b/templates/svgExport/css.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,6 +12,11 @@ See LICENSE file in root for details. *******************************************************************************/ +/** + * The CSS to be used on the exported page. + * + * @returns {string} The CSS configuration. + */ export default () => ` html, body { diff --git a/templates/svgExport/svgExport.js b/templates/svgExport/svgExport.js index f3ae939c..98d6236b 100644 --- a/templates/svgExport/svgExport.js +++ b/templates/svgExport/svgExport.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -14,7 +14,14 @@ See LICENSE file in root for details. import cssTemplate from './css.js'; -export default (chart) => ` +/** + * The SVG template to use when loading SVG content to be exported. + * + * @param {string} svg - The SVG input content to be exported. + * + * @returns {string} The SVG template. + */ +export default (svg) => ` @@ -26,7 +33,7 @@ export default (chart) => `

- ${chart} + ${svg}
diff --git a/tests/cli/cliTestRunner.js b/tests/cli/cliTestRunner.js index 5bd02a4c..7e5a2c45 100644 --- a/tests/cli/cliTestRunner.js +++ b/tests/cli/cliTestRunner.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -19,7 +19,7 @@ import { promisify } from 'util'; import 'colors'; -import { __dirname } from '../../lib/utils.js'; +import { __dirname, getNewDateTime } from '../../lib/utils.js'; // Convert from callback to promise const spawn = promisify(exec); @@ -81,7 +81,7 @@ for (const file of files.filter((file) => file.endsWith('.json'))) { cliCommand = cliCommand.join(' '); // The start date of a CLI command - const startDate = new Date().getTime(); + const startDate = getNewDateTime(); let didFail = false; try { @@ -94,7 +94,7 @@ for (const file of files.filter((file) => file.endsWith('.json'))) { testCounter++; const endMessage = `CLI command from file: ${file}, took ${ - new Date().getTime() - startDate + getNewDateTime() - startDate }ms.`; console.log( diff --git a/tests/cli/cliTestRunnerSingle.js b/tests/cli/cliTestRunnerSingle.js index 985f6ea8..cf3941f8 100644 --- a/tests/cli/cliTestRunnerSingle.js +++ b/tests/cli/cliTestRunnerSingle.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -18,7 +18,7 @@ import { basename, join } from 'path'; import 'colors'; -import { __dirname } from '../../lib/utils.js'; +import { __dirname, getNewDateTime } from '../../lib/utils.js'; // Test runner message console.log( @@ -70,7 +70,7 @@ if (existsSync(file) && file.endsWith('.json')) { cliCommand = cliCommand.join(' '); // The start date of a CLI command - const startDate = new Date().getTime(); + const startDate = getNewDateTime(); // Launch command in a new process spawn(cliCommand); @@ -78,10 +78,10 @@ if (existsSync(file) && file.endsWith('.json')) { // Close event for a process process.on('exit', (code) => { const endMessage = `CLI command from file: ${file}, took ${ - new Date().getTime() - startDate + getNewDateTime() - startDate }ms.`; - // If code is 1, it means that export server thrown an error + // If code is 1, it means that Export Server thrown an error if (code === 1) { return console.error(`[Fail] ${endMessage}`.red); } diff --git a/tests/cli/errorScenarios/doNotAllowCodeExecutionAndFileResources.json b/tests/cli/errorScenarios/doNotAllowCodeExecutionAndFileResources.json deleted file mode 100644 index 3133799e..00000000 --- a/tests/cli/errorScenarios/doNotAllowCodeExecutionAndFileResources.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "instr": "{\"title\":{\"text\":\"Do not allow code execution from strings\"},\"xAxis\":{\"categories\":[\"Jan\",\"Feb\",\"Mar\",\"Apr\"]},\"series\":[{\"type\":\"column\",\"data\":[5,6,7,8]},{\"type\":\"line\",\"data\":[1,2,3,4]}]}", - "outfile": "allowCodeExecutionStringified.png", - "allowCodeExecution": false, - "allowFileResources": false, - "callback": "function callback(chart){chart.renderer.label('This label is added in the callback.
Highcharts version '+Highcharts.version,75,75).attr({id:'renderer-callback-label',fill:'#90ed7d',padding:10,r:10,zIndex:10}).css({color:'black',width:'100px'}).add();}", - "customCode": "./samples/resources/customCode.js", - "resources": "./samples/resources/resources.js" -} diff --git a/tests/cli/scenarios/allowCodeExecutionAndFileResources.json b/tests/cli/scenarios/allowCodeExecutionAndFileResources.json index 0c10c420..fb369141 100644 --- a/tests/cli/scenarios/allowCodeExecutionAndFileResources.json +++ b/tests/cli/scenarios/allowCodeExecutionAndFileResources.json @@ -2,7 +2,7 @@ "instr": "{\"title\":{\"text\":\"Allow code execution and file resources\"},\"xAxis\":{\"categories\":[\"Jan\",\"Feb\",\"Mar\",\"Apr\"]},\"series\":[{\"type\":\"column\",\"data\":[5,6,7,8]},{\"type\":\"line\",\"data\":[1,2,3,4]}]}", "allowCodeExecution": true, "allowFileResources": true, - "callback": "./samples/resources/callback.js", "customCode": "Highcharts.setOptions({chart:{events:{render:function (){this.renderer.image('https://www.highcharts.com/samples/graphics/sun.png',75,50,20,20).add();}}}});", + "callback": "./samples/resources/callback.js", "resources": "{\"js\":\"Highcharts.charts[0].update({xAxis:{title:{text:'Title from the resources object, js section'}}});\",\"css\":\".highcharts-yaxis .highcharts-axis-line{stroke-width:2px;stroke:#FF0000;}\",\"files\":[\"./samples/resources/resourcesFile1.js\",\"./samples/resources/resourcesFile2.js\"]}" } diff --git a/tests/cli/scenarios/loadConfig.json b/tests/cli/scenarios/loadConfig.json index bfd48789..13928970 100644 --- a/tests/cli/scenarios/loadConfig.json +++ b/tests/cli/scenarios/loadConfig.json @@ -1,3 +1,3 @@ { - "loadConfig": "./samples/cli/customOptions.json" + "loadConfig": "./samples/resources/customOptions.json" } diff --git a/tests/http/httpTestRunner.js b/tests/http/httpTestRunner.js index fbb0cec0..abb4ca8a 100644 --- a/tests/http/httpTestRunner.js +++ b/tests/http/httpTestRunner.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -24,8 +24,8 @@ import { join } from 'path'; import 'colors'; -import { fetch } from '../../lib/fetch.js'; -import { __dirname, clearText } from '../../lib/utils.js'; +import { get } from '../../lib/fetch.js'; +import { __dirname, clearText, getNewDateTime } from '../../lib/utils.js'; // Test runner message console.log( @@ -36,11 +36,11 @@ console.log( '(results are stored in the ./tests/http/_results).\n'.green ); -// Url of Puppeteer export server +// Url of Puppeteer Export Server const url = 'http://127.0.0.1:7801'; // Perform a health check before continuing -fetch(`${url}/health`) +get(`${url}/health`) .then(() => { // Results and scenarios paths const resultsPath = join(__dirname, 'tests', 'http', '_results'); @@ -87,7 +87,7 @@ fetch(`${url}/health`) return new Promise((resolve) => { // The start date of a POST request - const startDate = new Date().getTime(); + const startDate = getNewDateTime(); const request = http.request( url, { @@ -110,7 +110,7 @@ fetch(`${url}/health`) fileStream.end(); const endMessage = `HTTP request with a payload from file: ${file}, took ${ - new Date().getTime() - startDate + getNewDateTime() - startDate }ms.`; // Based on received status code check if requests failed diff --git a/tests/http/httpTestRunnerSingle.js b/tests/http/httpTestRunnerSingle.js index 12241029..9700b23d 100644 --- a/tests/http/httpTestRunnerSingle.js +++ b/tests/http/httpTestRunnerSingle.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -18,8 +18,8 @@ import { basename, join } from 'path'; import 'colors'; -import { fetch } from '../../lib/fetch.js'; -import { __dirname, clearText } from '../../lib/utils.js'; +import { get } from '../../lib/fetch.js'; +import { __dirname, clearText, getNewDateTime } from '../../lib/utils.js'; // Test runner message console.log( @@ -30,11 +30,11 @@ console.log( '(results are stored in the ./tests/http/_results).\n'.green ); -// Url of Puppeteer export server +// Url of Puppeteer Export Server const url = 'http://127.0.0.1:7801'; // Perform a health check before continuing -fetch(`${url}/health`) +get(`${url}/health`) .then(() => { // Results path const resultsPath = join(__dirname, 'tests', 'http', '_results'); @@ -64,7 +64,7 @@ fetch(`${url}/health`) ); // The start date of a POST request - const startDate = new Date().getTime(); + const startDate = getNewDateTime(); const request = http.request( url, { @@ -87,7 +87,7 @@ fetch(`${url}/health`) fileStream.end(); const endMessage = `HTTP request with a payload from file: ${file}, took ${ - new Date().getTime() - startDate + getNewDateTime() - startDate }ms.`; // Based on received status code check if requests failed diff --git a/tests/http/scenarios/allowCodeExecution.json b/tests/http/scenarios/allowCodeExecution.json index 95bbcc0b..4519b245 100644 --- a/tests/http/scenarios/allowCodeExecution.json +++ b/tests/http/scenarios/allowCodeExecution.json @@ -17,7 +17,7 @@ } ] }, - "callback": "function callback(chart){chart.renderer.label('This label is added in the stringified callback.
Highcharts version '+Highcharts.version,75,75).attr({id:'renderer-callback-label',fill:'#90ed7d',padding:10,r:10,zIndex:10}).css({color:'black',width:'100px'}).add();}", "customCode": "Highcharts.setOptions({chart:{events:{render:function (){this.renderer.image('https://www.highcharts.com/samples/graphics/sun.png',75,50,20,20).add();}}}});", + "callback": "function callback(chart){chart.renderer.label('This label is added in the stringified callback.
Highcharts version '+Highcharts.version,75,75).attr({id:'renderer-callback-label',fill:'#90ed7d',padding:10,r:10,zIndex:10}).css({color:'black',width:'100px'}).add();}", "resources": "{\"js\":\"Highcharts.charts[0].update({xAxis:{title:{text:'Title from the resources stringified object, js section'}}});\",\"css\":\".highcharts-yaxis .highcharts-axis-line{stroke-width:2px;stroke:#FF0000;}\"}" } diff --git a/tests/http/scenarios/doNotAllowFileResources.json b/tests/http/scenarios/doNotAllowFileResources.json index 4adc4624..5cfd69fe 100644 --- a/tests/http/scenarios/doNotAllowFileResources.json +++ b/tests/http/scenarios/doNotAllowFileResources.json @@ -17,7 +17,7 @@ } ] }, - "callback": "./samples/resources/callback.js", "customCode": "./samples/resources/customCode.js", + "callback": "./samples/resources/callback.js", "resources": "./samples/resources/resources.js" } diff --git a/tests/http/scenarios/globalAndThemeFromFiles.json b/tests/http/scenarios/doNotAllowGlobalAndThemeFromFiles.json similarity index 100% rename from tests/http/scenarios/globalAndThemeFromFiles.json rename to tests/http/scenarios/doNotAllowGlobalAndThemeFromFiles.json diff --git a/tests/http/scenarios/infileJson.json b/tests/http/scenarios/infile.json similarity index 100% rename from tests/http/scenarios/infileJson.json rename to tests/http/scenarios/infile.json diff --git a/tests/http/scenarios/infileStringified.json b/tests/http/scenarios/infileStringified.json deleted file mode 100644 index bc1be290..00000000 --- a/tests/http/scenarios/infileStringified.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "infile": "{\"title\":{\"text\":\"Chart created from the stringified 'infile'\"},\"xAxis\":{\"categories\":[\"Jan\",\"Feb\",\"Mar\",\"Apr\"]},\"series\":[{\"type\":\"column\",\"data\":[5,6,7,8]},{\"type\":\"line\",\"data\":[1,2,3,4]}]}" -} diff --git a/tests/http/scenarios/instr.json b/tests/http/scenarios/instr.json new file mode 100644 index 00000000..8110796a --- /dev/null +++ b/tests/http/scenarios/instr.json @@ -0,0 +1,3 @@ +{ + "instr": "{\"title\":{\"text\":\"Chart created from the stringified 'instr'\"},\"xAxis\":{\"categories\":[\"Jan\",\"Feb\",\"Mar\",\"Apr\"]},\"series\":[{\"type\":\"column\",\"data\":[5,6,7,8]},{\"type\":\"line\",\"data\":[1,2,3,4]}]}" +} diff --git a/tests/node/errorScenarios/doNotAllowCodeExecutionAndFileResources.json b/tests/node/errorScenarios/doNotAllowCodeExecutionAndFileResources.json deleted file mode 100644 index eff5177e..00000000 --- a/tests/node/errorScenarios/doNotAllowCodeExecutionAndFileResources.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "export": { - "options": { - "chart": { - "type": "column" - }, - "title": { - "text": "Do not allow code execution" - }, - "yAxis": [ - { - "title": { - "text": "Primary axis" - } - }, - { - "opposite": true, - "title": { - "text": "Secondary axis" - } - } - ], - "plotOptions": { - "column": { - "borderRadius": 5 - } - }, - "series": [ - { - "data": [1, 3, 2, 4] - }, - { - "data": [324, 124, 547, 221], - "yAxis": 1 - } - ] - } - }, - "customLogic": { - "allowCodeExecution": false, - "allowFileResources": false, - "callback": "function callback(chart) {chart.renderer.label('This label is added in the callback.
Highcharts version '+Highcharts.version,75,75).attr({id:'renderer-callback-label',fill:'#90ed7d',padding:10,r:10,zIndex:10}).css({color:'black',width:'100px'}).add();}", - "customCode": "Highcharts.setOptions({chart:{events:{render:function (){this.renderer.image('https://www.highcharts.com/samples/graphics/sun.png',100,50,20,20).add();}}}});", - "resources": { - "js": "Highcharts.charts[0].update({xAxis:{title:{text:'Title from the resources file, js section'}}});", - "css": ".highcharts-yaxis .highcharts-axis-line {stroke-width:2px;stroke:#FF0000;}", - "files": "./samples/resources/resourcesFile1.js,./samples/resources/resourcesFile2.js" - } - } -} diff --git a/tests/node/errorScenarios/optionsStringifiedWrong.json b/tests/node/errorScenarios/optionsStringifiedWrong.json deleted file mode 100644 index 12e4bbb5..00000000 --- a/tests/node/errorScenarios/optionsStringifiedWrong.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "export": { - "constr": "chart", - "options": "xAxis: {categories: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']}, series: [{type: 'line', data: [1, 3, 2, 4]}, {type: 'line', data: [5, 3, 4, 2]}]}" - } -} diff --git a/tests/node/nodeTestRunner.js b/tests/node/nodeTestRunner.js index 9119aa70..f6403b88 100644 --- a/tests/node/nodeTestRunner.js +++ b/tests/node/nodeTestRunner.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -23,8 +23,8 @@ import { basename, join } from 'path'; import 'colors'; -import exporter from '../../lib/index.js'; -import { __dirname } from '../../lib/utils.js'; +import exporter, { initExport } from '../../lib/index.js'; +import { __dirname, getNewDateTime } from '../../lib/utils.js'; console.log( 'Highcharts Export Server Node Test Runner'.yellow.bold.underline, @@ -46,12 +46,9 @@ console.log( // Get files names const files = readdirSync(scenariosPath); - // Set options - const options = exporter.setOptions(); - try { - // Initialize pool with disabled logging - await exporter.initExport(options); + // Initialize pool + await initExport(); } catch (error) { await exporter.killPool(); throw error; @@ -88,11 +85,11 @@ console.log( ); // The start date of a startExport function run - const startTime = new Date().getTime(); + const startTime = getNewDateTime(); // Start the export process exporter - .startExport(fileOptions, (error, info) => { + .startExport(fileOptions, (error, data) => { // Throw an error if (error) { throw error; @@ -100,16 +97,16 @@ console.log( // Save returned data to a correct image file if no error occured writeFileSync( - info.options.export.outfile, - info.options?.export?.type !== 'svg' - ? Buffer.from(info.result, 'base64') - : info.result + data.options.export.outfile, + data.options?.export?.type !== 'svg' + ? Buffer.from(data.result, 'base64') + : data.result ); // Information about the results and the time it took console.log( `[Success] Node module from file: ${file}, took: ${ - new Date().getTime() - startTime + getNewDateTime() - startTime }ms.`.green ); }) @@ -117,7 +114,7 @@ console.log( // Information about the error and the time it took console.log( `[Fail] Node module from file: ${file}, took: ${ - new Date().getTime() - startTime + getNewDateTime() - startTime }ms.`.red ); exporter.setLogLevel(1); diff --git a/tests/node/nodeTestRunnerSingle.js b/tests/node/nodeTestRunnerSingle.js index af7b1ae2..248bb35d 100644 --- a/tests/node/nodeTestRunnerSingle.js +++ b/tests/node/nodeTestRunnerSingle.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -17,8 +17,8 @@ import { basename, join } from 'path'; import 'colors'; -import exporter from '../../lib/index.js'; -import { __dirname } from '../../lib/utils.js'; +import exporter, { initExport } from '../../lib/index.js'; +import { __dirname, getNewDateTime } from '../../lib/utils.js'; console.log( 'Highcharts Export Server Node Test Runner'.yellow.bold.underline, @@ -42,23 +42,6 @@ console.log( // Check if file even exists and if it is a JSON if (existsSync(file) && file.endsWith('.json')) { - // Set options - const options = exporter.setOptions({ - pool: { - minWorkers: 1, - maxWorkers: 1 - }, - logging: { - level: 0 - } - }); - - // Initialize pool with disabled logging - await exporter.initExport(options); - - // Start the export - console.log('[Test runner]'.blue, `Processing test ${file}.`); - // Options from a file const fileOptions = JSON.parse(readFileSync(file)); @@ -72,12 +55,26 @@ console.log( ) ); + // Initialize pool with disabled logging + await initExport({ + pool: { + minWorkers: 1, + maxWorkers: 1 + }, + logging: { + level: 0 + } + }); + + // Start the export + console.log('[Test runner]'.blue, `Processing test ${file}.`); + // The start date of a startExport function run - const startTime = new Date().getTime(); + const startTime = getNewDateTime(); try { // Start the export process - await exporter.startExport(fileOptions, async (error, info) => { + await exporter.startExport(fileOptions, async (error, data) => { // Throw an error if (error) { throw error; @@ -85,16 +82,16 @@ console.log( // Save returned data to a correct image file if no error occured writeFileSync( - info.options.export.outfile, - info.options?.export?.type !== 'svg' - ? Buffer.from(info.result, 'base64') - : info.result + data.options.export.outfile, + data.options?.export?.type !== 'svg' + ? Buffer.from(data.result, 'base64') + : data.result ); // Information about the results and the time it took console.log( `[Success] Node module from file: ${file}, took: ${ - new Date().getTime() - startTime + getNewDateTime() - startTime }ms.`.green ); }); @@ -102,7 +99,7 @@ console.log( // Information about the error and the time it took console.log( `[Fail] Node module from file: ${file}, took: ${ - new Date().getTime() - startTime + getNewDateTime() - startTime }ms.`.red ); } diff --git a/tests/node/scenarios/allowCodeExecution.json b/tests/node/scenarios/allowCodeExecution.json index 9357eff4..5f2b289d 100644 --- a/tests/node/scenarios/allowCodeExecution.json +++ b/tests/node/scenarios/allowCodeExecution.json @@ -38,8 +38,8 @@ }, "customLogic": { "allowCodeExecution": true, - "callback": "function callback(chart){chart.renderer.label('This label is added in the stringified callback.
Highcharts version '+Highcharts.version,75,75).attr({id:'renderer-callback-label',fill:'#90ed7d',padding:10,r:10,zIndex:10}).css({color:'black',width:'100px'}).add();}", "customCode": "Highcharts.setOptions({chart:{events:{render:function (){this.renderer.image('https://www.highcharts.com/samples/graphics/sun.png',75,50,20,20).add();}}}});", + "callback": "function callback(chart){chart.renderer.label('This label is added in the stringified callback.
Highcharts version '+Highcharts.version,75,75).attr({id:'renderer-callback-label',fill:'#90ed7d',padding:10,r:10,zIndex:10}).css({color:'black',width:'100px'}).add();}", "resources": { "js": "Highcharts.charts[0].update({xAxis:{title:{text:'Title from the resources object, js section'}}});", "css": ".highcharts-yaxis .highcharts-axis-line{stroke-width:2px;stroke:#FF0000}" diff --git a/tests/node/scenarios/allowFileResources.json b/tests/node/scenarios/allowFileResources.json index 21ab61e7..e42210e8 100644 --- a/tests/node/scenarios/allowFileResources.json +++ b/tests/node/scenarios/allowFileResources.json @@ -39,8 +39,8 @@ "customLogic": { "allowCodeExecution": true, "allowFileResources": true, - "callback": "./samples/resources/callback.js", "customCode": "./samples/resources/customCode.js", + "callback": "./samples/resources/callback.js", "resources": "./samples/resources/resources.json" } } diff --git a/tests/node/scenarios/allowFileResourcesFalse.json b/tests/node/scenarios/allowFileResourcesFalse.json index 377b5405..2563ed0e 100644 --- a/tests/node/scenarios/allowFileResourcesFalse.json +++ b/tests/node/scenarios/allowFileResourcesFalse.json @@ -39,8 +39,8 @@ "customLogic": { "allowCodeExecution": true, "allowFileResources": false, - "callback": "./samples/resources/callback.js", "customCode": "./samples/resources/customCode.js", + "callback": "./samples/resources/callback.js", "resources": "./samples/resources/resources.json" } } diff --git a/tests/node/scenarios/constrChart.json b/tests/node/scenarios/constrChart.json index 2a02bf27..451dd139 100644 --- a/tests/node/scenarios/constrChart.json +++ b/tests/node/scenarios/constrChart.json @@ -1,6 +1,5 @@ { "export": { - "constr": "chart", "options": { "title": { "text": "Chart" @@ -48,6 +47,7 @@ ] } ] - } + }, + "constr": "chart" } } diff --git a/tests/node/scenarios/constrGanttChart.json b/tests/node/scenarios/constrGanttChart.json index de93f1c8..c4345eea 100644 --- a/tests/node/scenarios/constrGanttChart.json +++ b/tests/node/scenarios/constrGanttChart.json @@ -1,6 +1,5 @@ { "export": { - "constr": "ganttChart", "options": { "title": { "text": "Gantt Chart" @@ -41,6 +40,7 @@ ] } ] - } + }, + "constr": "ganttChart" } } diff --git a/tests/node/scenarios/constrMapChart.json b/tests/node/scenarios/constrMapChart.json index de72b6f1..99001edc 100644 --- a/tests/node/scenarios/constrMapChart.json +++ b/tests/node/scenarios/constrMapChart.json @@ -1,6 +1,5 @@ { "export": { - "constr": "mapChart", "options": { "chart": { "map": { @@ -8249,6 +8248,7 @@ ] } ] - } + }, + "constr": "mapChart" } } diff --git a/tests/node/scenarios/constrStockChart.json b/tests/node/scenarios/constrStockChart.json index 86663a58..98426e84 100644 --- a/tests/node/scenarios/constrStockChart.json +++ b/tests/node/scenarios/constrStockChart.json @@ -1,6 +1,5 @@ { "export": { - "constr": "stockChart", "options": { "rangeSelector": { "selected": 1 @@ -520,6 +519,7 @@ ] } ] - } + }, + "constr": "stockChart" } } diff --git a/tests/node/scenarios/optionsJson.json b/tests/node/scenarios/optionsJson.json index c9d52404..78c0a55e 100644 --- a/tests/node/scenarios/optionsJson.json +++ b/tests/node/scenarios/optionsJson.json @@ -1,6 +1,5 @@ { "export": { - "constr": "chart", "options": { "title": { "text": "Basic options" diff --git a/tests/node/scenarios/optionsStringified.json b/tests/node/scenarios/optionsStringified.json index d04738aa..1c5f9567 100644 --- a/tests/node/scenarios/optionsStringified.json +++ b/tests/node/scenarios/optionsStringified.json @@ -1,6 +1,5 @@ { "export": { - "constr": "chart", "options": "{\"title\":{\"text\":\"Stringified options\"},\"xAxis\":{\"categories\":[\"Jan\",\"Feb\",\"Mar\",\"Apr\",\"May\",\"Jun\",\"Jul\",\"Aug\",\"Sep\",\"Oct\",\"Nov\",\"Dec\"]},\"series\":[{\"type\":\"column\",\"data\":[1,3,2,4]},{\"type\":\"column\",\"data\":[5,3,4,2]}]}" } } diff --git a/tests/node/scenarios/sizesAndScaleFromCliPost.json b/tests/node/scenarios/sizesAndScaleFromCliPost.json index 36724f31..a307b83e 100644 --- a/tests/node/scenarios/sizesAndScaleFromCliPost.json +++ b/tests/node/scenarios/sizesAndScaleFromCliPost.json @@ -1,8 +1,5 @@ { "export": { - "height": 800, - "width": 1200, - "scale": 2, "options": { "chart": { "type": "column" @@ -37,6 +34,9 @@ "yAxis": 1 } ] - } + }, + "height": 800, + "width": 1200, + "scale": 2 } } diff --git a/tests/node/scenarios/svgBasic.json b/tests/node/scenarios/svgBasic.json index 44d36aaf..c190c479 100644 --- a/tests/node/scenarios/svgBasic.json +++ b/tests/node/scenarios/svgBasic.json @@ -1,7 +1,7 @@ { - "svg": "Created with Highcharts 10.2.1ValuesBasic SVGSeries 1Series 2JanFebMarApr0246810Highcharts.com", - "scale": 2, "export": { - "outfile": "svgBasic.png" + "svg": "Created with Highcharts 10.2.1ValuesBasic SVGSeries 1Series 2JanFebMarApr0246810Highcharts.com", + "outfile": "svgBasic.png", + "scale": 2 } } diff --git a/tests/node/scenarios/svgBasicWithScale.json b/tests/node/scenarios/svgBasicWithScale.json index 1132fd6a..a8cb83c9 100644 --- a/tests/node/scenarios/svgBasicWithScale.json +++ b/tests/node/scenarios/svgBasicWithScale.json @@ -1,7 +1,7 @@ { - "svg": "Created with Highcharts 10.2.1ValuesBasic SVGSeries 1Series 2JanFebMarApr0246810Highcharts.com", - "scale": 3, "export": { - "outfile": "svgBasicWithScale.png" + "svg": "Created with Highcharts 10.2.1ValuesBasic SVGSeries 1Series 2JanFebMarApr0246810Highcharts.com", + "outfile": "svgBasicWithScale.png", + "scale": 3 } } diff --git a/tests/node/scenarios/svgBasicWithScaleToPdf.json b/tests/node/scenarios/svgBasicWithScaleToPdf.json index 8e2fb279..c88f7676 100644 --- a/tests/node/scenarios/svgBasicWithScaleToPdf.json +++ b/tests/node/scenarios/svgBasicWithScaleToPdf.json @@ -1,8 +1,8 @@ { - "svg": "Created with Highcharts 10.2.1ValuesBasic SVGSeries 1Series 2JanFebMarApr0246810Highcharts.com", - "type": "pdf", - "scale": 2, "export": { - "outfile": "svgBasicWithScaleToPdf.pdf" + "svg": "Created with Highcharts 10.2.1ValuesBasic SVGSeries 1Series 2JanFebMarApr0246810Highcharts.com", + "outfile": "svgBasicWithScaleToPdf.pdf", + "type": "pdf", + "scale": 2 } } diff --git a/tests/node/scenarios/svgForeignObject.json b/tests/node/scenarios/svgForeignObject.json index 74f9112f..9d5e3fa0 100644 --- a/tests/node/scenarios/svgForeignObject.json +++ b/tests/node/scenarios/svgForeignObject.json @@ -1,7 +1,7 @@ { - "svg": "Created with Highcharts 10.2.1ValuesSVG with a foreign objectSeries 1Series 2JanFebMarAprMayJunJulAugSepOctNovDec-1001020304050Highcharts.comThis subtitle is HTML", - "scale": 2, "export": { - "outfile": "svgForeignObject.png" + "svg": "Created with Highcharts 10.2.1ValuesSVG with a foreign objectSeries 1Series 2JanFebMarAprMayJunJulAugSepOctNovDec-1001020304050Highcharts.comThis subtitle is HTML", + "outfile": "svgForeignObject.png", + "scale": 2 } } diff --git a/tests/node/scenarios/typeJpeg.json b/tests/node/scenarios/typeJpeg.json index 4a2c2bee..fac68bdd 100644 --- a/tests/node/scenarios/typeJpeg.json +++ b/tests/node/scenarios/typeJpeg.json @@ -1,6 +1,5 @@ { "export": { - "type": "jpeg", "options": { "title": { "text": "Chart type set to JPEG" @@ -18,6 +17,7 @@ "data": [1, 2, 3, 4] } ] - } + }, + "type": "jpeg" } } diff --git a/tests/node/scenarios/typePdf.json b/tests/node/scenarios/typePdf.json index f34fd2e6..61bc2f77 100644 --- a/tests/node/scenarios/typePdf.json +++ b/tests/node/scenarios/typePdf.json @@ -1,6 +1,5 @@ { "export": { - "type": "pdf", "options": { "title": { "text": "Chart type set to PDF" @@ -18,6 +17,7 @@ "data": [1, 2, 3, 4] } ] - } + }, + "type": "pdf" } } diff --git a/tests/node/scenarios/typePng.json b/tests/node/scenarios/typePng.json index c7ab4ec4..0196640a 100644 --- a/tests/node/scenarios/typePng.json +++ b/tests/node/scenarios/typePng.json @@ -1,6 +1,5 @@ { "export": { - "type": "png", "options": { "title": { "text": "Chart type set to PNG" @@ -18,6 +17,7 @@ "data": [1, 2, 3, 4] } ] - } + }, + "type": "png" } } diff --git a/tests/node/scenarios/typeSvg.json b/tests/node/scenarios/typeSvg.json index 1dd39d3b..6a902f2b 100644 --- a/tests/node/scenarios/typeSvg.json +++ b/tests/node/scenarios/typeSvg.json @@ -1,6 +1,5 @@ { "export": { - "type": "svg", "options": { "title": { "text": "Chart type set to SVG" @@ -18,6 +17,7 @@ "data": [1, 2, 3, 4] } ] - } + }, + "type": "svg" } } diff --git a/tests/other/privateRangeUrl.js b/tests/other/privateRangeUrl.js deleted file mode 100644 index eff3e1d5..00000000 --- a/tests/other/privateRangeUrl.js +++ /dev/null @@ -1,59 +0,0 @@ -/******************************************************************************* - -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 'colors'; - -import { isPrivateRangeUrlFound } from '../../lib/utils.js'; - -// Test message -console.log( - 'The isPrivateRangeUrlFound utility test'.yellow, - `\nIt checks multiple IPs and finds which are public and private.\n`.green -); - -// IP adresses to test -const ipAddresses = [ - // The localhost - 'localhost', - '127.0.0.1', - // Private range (10.0.0.0/8) - '10.151.223.167', - '10.190.93.233', - // Private range (172.0.0.0/12) - '172.22.34.250', - '172.27.95.8', - // Private range (192.168.0.0/16) - '192.168.218.176', - '192.168.231.157', - // Public range - '53.96.110.150', - '155.212.200.223' -]; - -// Test ips in different configurations, with or without a protocol prefix -['', 'http://', 'https://'].forEach((protocol) => { - if (protocol) { - console.log(`\n${protocol}`.blue.underline); - } - - ipAddresses.forEach((ip) => { - const url = `${protocol}${ip}`; - console.log( - `${url} - ` + - (isPrivateRangeUrlFound(`xlink:href="${url}"`) - ? 'private IP'.red - : 'public IP'.green) - ); - }); -}); diff --git a/tests/other/sideBySide.js b/tests/other/sideBySide.js index 2c797134..d0bda3ab 100644 --- a/tests/other/sideBySide.js +++ b/tests/other/sideBySide.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,14 +12,14 @@ See LICENSE file in root for details. *******************************************************************************/ -import { fetch } from '../../lib/fetch.js'; +import { get } from '../../lib/fetch.js'; import { exec as spawn } from 'child_process'; import { existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import 'colors'; -import { __dirname } from '../../lib/utils.js'; +import { __dirname, getNewDateTime } from '../../lib/utils.js'; // Results paths const resultsPath = join(__dirname, 'tests', 'other', '_results'); @@ -27,7 +27,7 @@ const resultsPath = join(__dirname, 'tests', 'other', '_results'); // Create results folder for CLI exports if doesn't exist !existsSync(resultsPath) && mkdirSync(resultsPath); -// Urls of Puppeteer and PhantomJS export servers +// Urls of Puppeteer and PhantomJS Export Servers const urls = ['http://127.0.0.1:7801', 'http://127.0.0.1:7802']; // Test message @@ -41,7 +41,7 @@ try { // Run for both servers for (const [index, url] of urls.entries()) { // Perform a health check before continuing - fetch(`${url}/health`) + get(`${url}/health`) .then(() => { // And all types for (const type of ['png', 'jpeg', 'svg', 'pdf']) { @@ -53,7 +53,7 @@ try { // Payload body const payload = JSON.stringify({ - infile: { + options: { title: { text: index ? 'Phantom Export Server' @@ -93,7 +93,7 @@ try { ].join(' '); // The start date of a POST request - const startDate = new Date().getTime(); + const startDate = getNewDateTime(); // Launch command in a new process // eslint-disable-next-line no-global-assign @@ -103,7 +103,7 @@ try { process.on('close', () => { const message = `Done with ${ index ? '[PhantomJS]' : '[Puppeteer]' - } ${type} export, took ${new Date().getTime() - startDate}ms.`; + } ${type} export, took ${getNewDateTime() - startDate}ms.`; console.log(index ? message.blue : message.green); }); diff --git a/tests/other/stressTest.js b/tests/other/stressTest.js index 26bd1841..07542be4 100644 --- a/tests/other/stressTest.js +++ b/tests/other/stressTest.js @@ -2,7 +2,7 @@ Highcharts Export Server -Copyright (c) 2016-2024, Highsoft +Copyright (c) 2016-2025, Highsoft Licenced under the MIT licence. @@ -12,9 +12,11 @@ See LICENSE file in root for details. *******************************************************************************/ -import { fetch, post } from '../../lib/fetch.js'; import 'colors'; +import { get, post } from '../../lib/fetch.js'; +import { getNewDateTime } from '../../lib/utils.js'; + // Test message console.log( 'Highcharts Export Server stress test'.yellow, @@ -24,7 +26,7 @@ console.log( // The request options const requestBody = { type: 'svg', - infile: { + options: { title: { text: 'Chart' }, @@ -45,14 +47,14 @@ const interval = 150; const stressTest = () => { for (let i = 1; i <= requestsNumber; i++) { - const startTime = new Date().getTime(); + const startTime = getNewDateTime(); // Perform a request post(url, requestBody) - .then(async (res) => { - const postTime = new Date().getTime() - startTime; + .then(async (response) => { + const postTime = getNewDateTime() - startTime; console.log(`${i} request is done, took ${postTime}ms`); - console.log(`---\n${res.text}\n---`); + console.log(`---\n${response.text}\n---`); }) .catch((error) => { return console.log(`[${i}] request returned error: ${error}`); @@ -61,7 +63,7 @@ const stressTest = () => { }; // Perform a health check before continuing -fetch(`${url}/health`) +get(`${url}/health`) .then(() => { stressTest(); setInterval(stressTest, interval); diff --git a/tests/unit/cache.test.js b/tests/unit/cache.test.js deleted file mode 100644 index 8086706c..00000000 --- a/tests/unit/cache.test.js +++ /dev/null @@ -1,24 +0,0 @@ -// cacheManager.test.js -import { extractVersion, extractModuleName } from '../../lib/cache'; - -describe('extractVersion', () => { - it('should extract the Highcharts version correctly', () => { - const cache = { sources: '/* Highcharts 9.3.2 */' }; - - const version = extractVersion(cache); - expect(version).toBe('Highcharts 9.3.2'); - }); -}); - -describe('extractModuleName', () => { - it('should extract the module name from a given script path', () => { - const paths = [ - { input: 'modules/exporting', expected: 'exporting' }, - { input: 'maps/modules/map', expected: 'map' } - ]; - - paths.forEach(({ input, expected }) => { - expect(extractModuleName(input)).toBe(expected); - }); - }); -}); diff --git a/tests/unit/config.test.js b/tests/unit/config.test.js new file mode 100644 index 00000000..b39a2379 --- /dev/null +++ b/tests/unit/config.test.js @@ -0,0 +1,157 @@ +/******************************************************************************* + +Highcharts Export Server + +Copyright (c) 2016-2025, Highsoft + +Licenced under the MIT licence. + +Additionally a valid Highcharts license is required for use. + +See LICENSE file in root for details. + +*******************************************************************************/ + +import { describe, expect, it } from '@jest/globals'; + +import { isAllowedConfig, mapToNewOptions } from '../../lib/config'; + +describe('isAllowedJSON', () => { + it('parses valid JSON strings', () => { + const json = '{"key":"value"}'; + + expect(isAllowedConfig(json)).toEqual({ key: 'value' }); + }); + + it('returns null for invalid JSON strings', () => { + const json = '{"key":value}'; + + expect(isAllowedConfig(json)).toBe(null); + }); + + it('parses JavaScript objects', () => { + const obj = { key: 'value' }; + + expect(isAllowedConfig(obj)).toEqual({ key: 'value' }); + }); + + it('parses JavaScript objects with functions when the `allowFunctions` is true', () => { + const obj = { key1: 'value', key2: function () {} }; + + expect(isAllowedConfig(obj, false, true)).toEqual({ + key1: 'value', + key2: expect.any(Function) + }); + }); + + it('returns a stringified version of a valid JSON/object when the `toString` is true', () => { + const obj = { key: 'value' }; + const json = '{"key":"value"}'; + + expect(isAllowedConfig(obj, true)).toBe(json); + expect(isAllowedConfig(json, true)).toBe(json); + }); + + it('returns a stringified version of a valid JSON/object with functions when the `toString` and `allowFunctions` are true', () => { + const obj = { key1: 'value', key2: function () {} }; + + expect(isAllowedConfig(obj, true, true)).toBe( + '{"key1":"value","key2":function () {}}' + ); + }); + + it('handles non-JSON strings', () => { + const str = 'Just a string'; + + expect(isAllowedConfig(str)).toBe(null); + }); + + it('handles non-object types (e.g., numbers, booleans)', () => { + expect(isAllowedConfig(123)).toBe(null); + expect(isAllowedConfig(true)).toBe(null); + }); + + it('correctly parses and stringifies an array when `toString` is true', () => { + const arr = [1, 2, 3]; + + expect(isAllowedConfig(arr, true)).toBe(null); + }); +}); + +describe('mapToNewOptions', () => { + it('should map the old (PhantomJS) options structure to the new (Puppetter) correctly', () => { + const oldOptions = { + options: null, + outfile: null, + type: 'png', + constr: 'chart', + width: null, + scale: null, + globalOptions: null, + allowFileResources: false, + callback: null, + resources: null, + fromFile: null, + enableServer: false, + host: '0.0.0.0', + port: 7801, + rateLimit: 10, + skipKey: null, + skipToken: null, + sslOnly: false, + sslPort: 443, + sslPath: null, + workers: 8, + workLimit: 40, + logLevel: 4, + logFile: 'highcharts-export-server.log', + logDest: 'log', + listenToProcessExits: true + }; + + expect(mapToNewOptions(oldOptions)).toEqual({ + export: { + options: null, + outfile: null, + type: 'png', + constr: 'chart', + width: null, + scale: null, + globalOptions: null + }, + customLogic: { + allowFileResources: false, + callback: null, + resources: null, + loadConfig: null + }, + server: { + enable: false, + host: '0.0.0.0', + port: 7801, + rateLimiting: { + maxRequests: 10, + skipKey: null, + skipToken: null + }, + ssl: { + force: false, + port: 443, + certPath: null + } + }, + pool: { + maxWorkers: 8, + workLimit: 40 + }, + logging: { + level: 4, + file: 'highcharts-export-server.log', + dest: 'log' + }, + other: { + listenToProcessExits: true + } + }); + }); +}); diff --git a/tests/unit/envs.test.js b/tests/unit/envs.test.js index fe31e8b9..0cd71622 100644 --- a/tests/unit/envs.test.js +++ b/tests/unit/envs.test.js @@ -1,4 +1,20 @@ -import { Config } from '../../lib/envs'; +/******************************************************************************* + +Highcharts Export Server + +Copyright (c) 2016-2025, Highsoft + +Licenced under the MIT licence. + +Additionally a valid Highcharts license is required for use. + +See LICENSE file in root for details. + +*******************************************************************************/ + +import { describe, expect } from '@jest/globals'; + +import { Config } from '../../lib/envs.js'; describe('Environment variables should be correctly parsed', () => { test('HIGHCHARTS_VERSION accepts latests and not unrelated strings', () => { diff --git a/tests/unit/index.test.js b/tests/unit/index.test.js deleted file mode 100644 index 85e102db..00000000 --- a/tests/unit/index.test.js +++ /dev/null @@ -1,8 +0,0 @@ -describe('Simple Variable Comparison', () => { - it('should compare two variables for equality', () => { - const variable1 = 42; - const variable2 = 42; - - expect(variable1).toBe(variable2); - }); -}); diff --git a/tests/unit/sanitize.test.js b/tests/unit/sanitize.test.js index 060ff643..5cbf791a 100644 --- a/tests/unit/sanitize.test.js +++ b/tests/unit/sanitize.test.js @@ -1,3 +1,19 @@ +/******************************************************************************* + +Highcharts Export Server + +Copyright (c) 2016-2025, Highsoft + +Licenced under the MIT licence. + +Additionally a valid Highcharts license is required for use. + +See LICENSE file in root for details. + +*******************************************************************************/ + +import { describe, expect, it } from '@jest/globals'; + import { sanitize } from '../../lib/sanitize.js'; describe('sanitize', () => { diff --git a/tests/unit/utils.test.js b/tests/unit/utils.test.js index 7c89f52d..64c4ca00 100644 --- a/tests/unit/utils.test.js +++ b/tests/unit/utils.test.js @@ -1,13 +1,27 @@ +/******************************************************************************* + +Highcharts Export Server + +Copyright (c) 2016-2025, Highsoft + +Licenced under the MIT licence. + +Additionally a valid Highcharts license is required for use. + +See LICENSE file in root for details. + +*******************************************************************************/ + +import { describe, expect, it } from '@jest/globals'; + import { clearText, - fixType, roundNumber, toBoolean, - isCorrectJSON, isObject, isObjectEmpty, isPrivateRangeUrlFound -} from '../../lib/utils'; +} from '../../lib/utils.js'; describe('clearText', () => { it('replaces multiple spaces with a single space and trims the text', () => { @@ -17,16 +31,6 @@ describe('clearText', () => { }); }); -describe('fixType', () => { - it('corrects the export type based on file extension', () => { - expect(fixType('image/jpeg', 'output.png')).toBe('png'); - }); - - it('returns the original type if no outfile is provided', () => { - expect(fixType('pdf')).toBe('pdf'); - }); -}); - describe('roundNumber', () => { it('rounds a number to the specified precision', () => { expect(roundNumber(3.14159, 2)).toBe(3.14); @@ -44,45 +48,6 @@ describe('toBoolean', () => { }); }); -describe('isCorrectJSON', () => { - it('parses valid JSON strings', () => { - const json = '{"key":"value"}'; - expect(isCorrectJSON(json)).toEqual({ key: 'value' }); - }); - - it('returns false for invalid JSON strings', () => { - const json = '{"key":value}'; - expect(isCorrectJSON(json)).toBe(false); - }); - - it('parses JavaScript objects', () => { - const obj = { key: 'value' }; - expect(isCorrectJSON(obj)).toEqual({ key: 'value' }); - }); - - it('returns a stringified version of a valid JSON/object when toString is true', () => { - const obj = { key: 'value' }; - const json = '{"key":"value"}'; - expect(isCorrectJSON(obj, true)).toBe(json); - expect(isCorrectJSON(json, true)).toBe(json); - }); - - it('handles non-JSON strings', () => { - const str = 'Just a string'; - expect(isCorrectJSON(str)).toBe(false); - }); - - it('handles non-object types (e.g., numbers, booleans)', () => { - expect(isCorrectJSON(123)).toBe(123); - expect(isCorrectJSON(true)).toBe(true); - }); - - it('correctly parses and stringifies an array when toString is true', () => { - const arr = [1, 2, 3]; - expect(isCorrectJSON(arr, true)).toBe('[1,2,3]'); - }); -}); - describe('isObject', () => { it('returns true for plain objects', () => { expect(isObject({})).toBe(true);