├── .env.sample ├── .eslintignore ├── .eslintrc.cjs ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ ├── build-and-push.yml │ ├── eslint-check.yml │ └── unit-tests.yml ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc ├── .prettierignore ├── .prettierrc ├── .puppeteerrc.cjs ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin └── cli.js ├── dist ├── index.cjs ├── index.esm.js └── index.esm.js.map ├── install.js ├── jest.config.js ├── lib ├── browser.js ├── cache.js ├── chart.js ├── config.js ├── envs.js ├── errors │ ├── ExportError.js │ └── HttpError.js ├── export.js ├── fetch.js ├── highcharts.js ├── index.js ├── intervals.js ├── logger.js ├── pool.js ├── resource_release.js ├── sanitize.js ├── schemas │ └── config.js ├── server │ ├── error.js │ ├── rate_limit.js │ ├── routes │ │ ├── change_hc_version.js │ │ ├── export.js │ │ ├── health.js │ │ └── ui.js │ └── server.js └── utils.js ├── msg ├── licenseagree.msg └── startup.msg ├── nodemon.json ├── package-lock.json ├── package.json ├── public ├── css │ └── style.css ├── favicon.ico ├── img │ └── logo.svg ├── index.html ├── js │ └── main.js └── robots.txt ├── rollup.config.js ├── samples ├── batch │ ├── batch_1.json │ ├── batch_2.json │ └── batch_3.json ├── cli │ ├── custom_options.json │ ├── infile_json.json │ └── infile_not_json.json ├── http │ ├── request_infile.json │ └── request_svg.json ├── module │ ├── options_phantomjs.js │ ├── options_puppeteer.js │ ├── promises.js │ └── svg.js └── resources │ ├── callback.js │ ├── custom_code.js │ ├── options_global.json │ ├── options_theme.json │ ├── resources.json │ ├── resources_file_1.js │ └── resources_file_2.js ├── templates ├── svg_export │ ├── css.js │ └── svg_export.js └── template.html └── tests ├── cli ├── cli_test_runner.js ├── cli_test_runner_single.js ├── error_scenarios │ └── do_not_allow_code_execution_and_file_resources.json └── scenarios │ ├── allow_code_execution_and_file_resources.json │ ├── batch.json │ ├── constr.json │ ├── global_and_theme_from_files.json │ ├── global_and_theme_stringified.json │ ├── height_width_scale.json │ ├── infile_json.json │ ├── infile_svg.json │ ├── infile_svg_with_scale.json │ ├── infile_svg_with_scale_to_pdf.json │ ├── instr.json │ ├── load_config.json │ ├── options.json │ ├── outfile.json │ └── type.json ├── http ├── http_test_runner.js ├── http_test_runner_single.js └── scenarios │ ├── allow_code_execution.json │ ├── b64.json │ ├── constr.json │ ├── data.json │ ├── do_not_allow_file_resources.json │ ├── global_and_theme_from_files.json │ ├── global_and_theme_stringified.json │ ├── height_width_scale.json │ ├── infile_json.json │ ├── infile_stringified.json │ ├── options.json │ ├── svg.json │ ├── svg_with_scale.json │ ├── svg_with_scale_to_pdf.json │ └── type.json ├── node ├── error_scenarios │ ├── do_not_allow_code_execution_and_file_resources.json │ └── options_stringified_wrong.json ├── node_test_runner.js ├── node_test_runner_single.js └── scenarios │ ├── allow_code_execution.json │ ├── allow_file_resources.json │ ├── allow_file_resources_false.json │ ├── constr_chart.json │ ├── constr_gantt_chart.json │ ├── constr_map_chart.json │ ├── constr_stock_chart.json │ ├── css_import_theme.json │ ├── css_raw.json │ ├── custom_code_from_file.json │ ├── custom_code_from_string.json │ ├── global_and_theme_options_from_files.json │ ├── global_and_theme_options_from_objects.json │ ├── global_and_theme_options_from_stringified_objects.json │ ├── infile.json │ ├── instr.json │ ├── options_json.json │ ├── options_stringified.json │ ├── outfile.json │ ├── sizes_and_scale_from_cli_post.json │ ├── sizes_and_scale_from_default_config.json │ ├── sizes_and_scale_from_exporting_options.json │ ├── sizes_from_chart_options.json │ ├── svg_basic.json │ ├── svg_basic_with_scale.json │ ├── svg_basic_with_scale_to_pdf.json │ ├── svg_foreign_object.json │ ├── symbols.json │ ├── type_jpeg.json │ ├── type_pdf.json │ ├── type_png.json │ └── type_svg.json ├── other ├── private_range_url.js ├── side_by_side.js └── stress_test.js └── unit ├── cache.test.js ├── envs.test.js ├── index.test.js ├── sanitize.test.js └── utils.test.js /.env.sample: -------------------------------------------------------------------------------- 1 | # PUPPETEER CONFIG 2 | PUPPETEER_TEMP_DIR = ./tmp/ 3 | 4 | # HIGHCHARTS CONFIG 5 | HIGHCHARTS_VERSION = latest 6 | HIGHCHARTS_CDN_URL = https://code.highcharts.com/ 7 | HIGHCHARTS_CORE_SCRIPTS = 8 | HIGHCHARTS_MODULE_SCRIPTS = 9 | HIGHCHARTS_INDICATOR_SCRIPTS = 10 | HIGHCHARTS_FORCE_FETCH = false 11 | HIGHCHARTS_CACHE_PATH = 12 | HIGHCHARTS_ADMIN_TOKEN = 13 | 14 | # EXPORT CONFIG 15 | EXPORT_TYPE = png 16 | EXPORT_CONSTR = chart 17 | EXPORT_DEFAULT_HEIGHT = 400 18 | EXPORT_DEFAULT_WIDTH = 600 19 | EXPORT_DEFAULT_SCALE = 1 20 | EXPORT_RASTERIZATION_TIMEOUT = 1500 21 | 22 | # CUSTOM LOGIC CONFIG 23 | CUSTOM_LOGIC_ALLOW_CODE_EXECUTION = false 24 | CUSTOM_LOGIC_ALLOW_FILE_RESOURCES = false 25 | 26 | # SERVER CONFIG 27 | SERVER_ENABLE = false 28 | SERVER_HOST = 0.0.0.0 29 | SERVER_PORT = 7801 30 | SERVER_MAX_UPLOAD_SIZE = 3 31 | SERVER_BENCHMARKING = false 32 | 33 | # SERVER PROXY CONFIG 34 | SERVER_PROXY_HOST = 35 | SERVER_PROXY_PORT = 36 | SERVER_PROXY_USERNAME = 37 | SERVER_PROXY_PASSWORD = 38 | SERVER_PROXY_TIMEOUT = 5000 39 | 40 | # SERVER RATE LIMITING CONFIG 41 | SERVER_RATE_LIMITING_ENABLE = false 42 | SERVER_RATE_LIMITING_MAX_REQUESTS = 10 43 | SERVER_RATE_LIMITING_WINDOW = 1 44 | SERVER_RATE_LIMITING_DELAY = 0 45 | SERVER_RATE_LIMITING_TRUST_PROXY = false 46 | SERVER_RATE_LIMITING_SKIP_KEY = 47 | SERVER_RATE_LIMITING_SKIP_TOKEN = 48 | 49 | # SERVER SSL CONFIG 50 | SERVER_SSL_ENABLE = false 51 | SERVER_SSL_FORCE = false 52 | SERVER_SSL_PORT = 443 53 | SERVER_SSL_CERT_PATH = 54 | 55 | # POOL CONFIG 56 | POOL_MIN_WORKERS = 4 57 | POOL_MAX_WORKERS = 8 58 | POOL_WORK_LIMIT = 40 59 | POOL_ACQUIRE_TIMEOUT = 5000 60 | POOL_CREATE_TIMEOUT = 5000 61 | POOL_DESTROY_TIMEOUT = 5000 62 | POOL_IDLE_TIMEOUT = 30000 63 | POOL_CREATE_RETRY_INTERVAL = 200 64 | POOL_REAPER_INTERVAL = 1000 65 | POOL_BENCHMARKING = false 66 | 67 | # LOGGING CONFIG 68 | LOGGING_LEVEL = 4 69 | LOGGING_FILE = highcharts-export-server.log 70 | LOGGING_DEST = log/ 71 | LOGGING_TO_CONSOLE = true 72 | LOGGING_TO_FILE = true 73 | 74 | # UI CONFIG 75 | UI_ENABLE = true 76 | UI_ROUTE = / 77 | 78 | # OTHER CONFIG 79 | OTHER_NODE_ENV = production 80 | OTHER_LISTEN_TO_PROCESS_EXITS = true 81 | OTHER_NO_LOGO = false 82 | OTHER_HARD_RESET_PAGE = false 83 | OTHER_BROWSER_SHELL_MODE = true 84 | 85 | # DEBUG CONFIG 86 | DEBUG_ENABLE = false 87 | DEBUG_HEADLESS = true 88 | DEBUG_DEVTOOLS = false 89 | DEBUG_LISTEN_TO_CONSOLE = false 90 | DEBUG_DUMPIO = false 91 | DEBUG_SLOW_MO = 0 92 | DEBUG_DEBUGGING_PORT = 9222 93 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/* 3 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true 6 | }, 7 | root: true, 8 | parserOptions: { 9 | ecmaVersion: 'latest', 10 | sourceType: 'module' 11 | }, 12 | plugins: ['import', 'prettier'], 13 | extends: [ 14 | 'eslint:recommended', 15 | 'plugin:import/recommended', 16 | 'plugin:prettier/recommended' 17 | ], 18 | overrides: [ 19 | { 20 | files: ['*.test.js', '*.spec.js'], 21 | env: { 22 | jest: true 23 | } 24 | } 25 | ], 26 | rules: { 27 | 'no-unused-vars': 0, 28 | 'import/no-cycle': 2, 29 | 'prettier/prettier': [ 30 | 'error', 31 | { 32 | endOfLine: require('os').EOL === '\r\n' ? 'crlf' : 'lf' 33 | } 34 | ] 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | #### Expected behaviour 10 | 11 | 12 | #### Actual behaviour 13 | 14 | 15 | #### Reproduction steps 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/build-and-push.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push 2 | 3 | on: 4 | push: 5 | branches: 6 | - stable 7 | 8 | jobs: 9 | build-and-push: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout Repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '18.x.x' 20 | 21 | - name: Install Dependencies 22 | run: npm ci 23 | 24 | - name: Build Project 25 | run: npm run build 26 | 27 | - name: Commit and Push /dist Directory 28 | run: | 29 | git config --local user.email "action@github.com" 30 | git config --local user.name "GitHub Action" 31 | git add -f dist/ 32 | git commit -m "Build the dist files after merge." 33 | git push -f 34 | -------------------------------------------------------------------------------- /.github/workflows/eslint-check.yml: -------------------------------------------------------------------------------- 1 | name: ESLint check 2 | 3 | on: [ pull_request ] 4 | 5 | jobs: 6 | eslint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout Repository 10 | uses: actions/checkout@v4 11 | 12 | - name: Set up Node.js 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: '18.x.x' 16 | 17 | - name: Install Dependencies 18 | run: npm install 19 | 20 | - name: Run ESLint 21 | id: eslint 22 | continue-on-error: true 23 | run: | 24 | ESLINT_OUTPUT=$(npx eslint . --ext .js,.jsx,.ts,.tsx) 25 | echo "::set-output name=result::$ESLINT_OUTPUT" 26 | if [ -z "$ESLINT_OUTPUT" ]; then 27 | echo "ESLint found no issues :white_check_mark:" 28 | echo "::set-output name=status::success" 29 | else 30 | echo "$ESLINT_OUTPUT" 31 | echo "::set-output name=status::failure" 32 | exit 1 33 | fi 34 | 35 | - name: Success Message 36 | if: steps.eslint.outputs.status == 'success' 37 | run: echo "✅ ESLint check passed successfully!" 38 | 39 | - name: Failure Message 40 | if: failure() 41 | run: echo "❌ ESLint check failed. Please fix the issues." 42 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | 7 | jobs: 8 | testing: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout Repository 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: '18.x.x' 19 | 20 | - name: Install dependencies 21 | run: npm ci 22 | 23 | - name: Run unit tests 24 | run: npm run unit:test 25 | env: 26 | CI: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | log/ 3 | tests/_temp 4 | tmp/ 5 | dist/ 6 | .cert/ 7 | 8 | .DS_Store 9 | .cache 10 | .vscode 11 | .env 12 | .eslintcache 13 | 14 | tests/**/_results/ 15 | 16 | resources.json 17 | 18 | **/*.png 19 | **/*.pdf 20 | **/*.svg 21 | **/*.jpeg 22 | **/*.log 23 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint 2 | npm run unit:test 3 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "lib/**/\*.{js,json}": ["eslint", "npx prettier --write"] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/* 3 | README.md 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "printWidth": 80, 6 | "trailingComma": "none" 7 | } 8 | -------------------------------------------------------------------------------- /.puppeteerrc.cjs: -------------------------------------------------------------------------------- 1 | const { join } = require('path'); 2 | 3 | /** 4 | * @type {import("puppeteer").Configuration} 5 | */ 6 | module.exports = { 7 | cacheDirectory: join(__dirname, 'node_modules', '.puppeteer-cache') 8 | }; 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Highcharts Export Server 2 | 3 | Copyright (c) 2016-2024, Highsoft 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /******************************************************************************* 3 | 4 | Highcharts Export Server 5 | 6 | Copyright (c) 2016-2024, Highsoft 7 | 8 | Licenced under the MIT licence. 9 | 10 | Additionally a valid Highcharts license is required for use. 11 | 12 | See LICENSE file in root for details. 13 | 14 | *******************************************************************************/ 15 | 16 | import main from '../lib/index.js'; 17 | 18 | import ExportError from '../lib/errors/ExportError.js'; 19 | 20 | /** 21 | * The primary function to initiate the server or perform the direct export. 22 | * 23 | * @throws {ExportError} Throws an ExportError if no valid options are provided. 24 | * @throws {Error} Throws an Error if an unexpected error occurs during 25 | * execution. 26 | */ 27 | const start = async () => { 28 | try { 29 | // Get the CLI arguments 30 | const args = process.argv; 31 | 32 | // Print the usage information if no arguments supplied 33 | if (args.length <= 2) { 34 | main.log( 35 | 2, 36 | '[cli] The number of provided arguments is too small. Please refer to the section below.' 37 | ); 38 | return main.printUsage(); 39 | } 40 | 41 | // Set the options, keeping the priority order of setting values: 42 | // 1. Options from the lib/schemas/config.js file 43 | // 2. Options from a custom JSON file (loaded by the --loadConfig argument) 44 | // 3. Options from the environment variables (the .env file) 45 | // 4. Options from the CLI 46 | const options = main.setOptions(null, args); 47 | 48 | // If all options correctly parsed 49 | if (options) { 50 | // Print initial logo or text 51 | main.printLogo(options.other.noLogo); 52 | 53 | // In this case we want to prepare config manually 54 | if (options.customLogic.createConfig) { 55 | return main.manualConfig(options.customLogic.createConfig); 56 | } 57 | 58 | // Start server 59 | if (options.server.enable) { 60 | // Init the export mechanism for the server configuration 61 | await main.initExport(options); 62 | 63 | // Run the server 64 | await main.startServer(options.server); 65 | } else { 66 | // Perform batch exports 67 | if (options.export.batch) { 68 | // If not set explicitly, use default option for batch exports 69 | if (!args.includes('--minWorkers', '--maxWorkers')) { 70 | options.pool = { 71 | ...options.pool, 72 | minWorkers: 2, 73 | maxWorkers: 25 74 | }; 75 | } 76 | 77 | // Init a pool for the batch exports 78 | await main.initExport(options); 79 | 80 | // Start batch exports 81 | await main.batchExport(options); 82 | } else { 83 | // No need for multiple workers in case of a single CLI export 84 | options.pool = { 85 | ...options.pool, 86 | minWorkers: 1, 87 | maxWorkers: 1 88 | }; 89 | 90 | // Init a pool for one export 91 | await main.initExport(options); 92 | 93 | // Start a single export 94 | await main.singleExport(options); 95 | } 96 | } 97 | } else { 98 | throw new ExportError( 99 | '[cli] No valid options provided. Please check your input and try again.' 100 | ); 101 | } 102 | } catch (error) { 103 | // Log the error with stack 104 | main.logWithStack(1, error); 105 | 106 | // Gracefully shut down the process 107 | await main.shutdownCleanUp(1); 108 | } 109 | }; 110 | 111 | start(); 112 | -------------------------------------------------------------------------------- /install.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import { readFileSync } from 'fs'; 16 | import { join } from 'path'; 17 | 18 | import { __dirname } from './lib/utils.js'; 19 | 20 | import 'colors'; 21 | 22 | const pkgFile = JSON.parse(readFileSync(join(__dirname, 'package.json'))); 23 | 24 | console.log( 25 | ` 26 | Highcharts Export Server V${pkgFile.version} 27 | 28 | ${'This software requires a valid Highcharts license for commercial use.'.yellow} 29 | 30 | If you do not have a license, one can be obtained here: 31 | ${'https://shop.highsoft.com/'.green} 32 | 33 | To customize your installation (include additional/fewer modules and so on), 34 | please refer to the readme file. 35 | `.green 36 | ); 37 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | testEnvironment: 'jest-environment-node', 3 | testMatch: ['**/tests/unit/**/*.test.js'], 4 | transform: { 5 | // '^.+\\.test.js?$': 'babel-jest' 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /lib/envs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview 3 | * This file is responsible for parsing the environment variables with the 'zod' 4 | * library. The parsed environment variables are then exported to be used 5 | * in the application as "envs". We should not use process.env directly 6 | * in the application as these would not be parsed properly. 7 | * 8 | * The environment variables are parsed and validated only once when 9 | * the application starts. We should write a custom validator or a transformer 10 | * for each of the options. 11 | */ 12 | 13 | import dotenv from 'dotenv'; 14 | import { z } from 'zod'; 15 | 16 | import { scriptsNames } from './schemas/config.js'; 17 | 18 | // Load .env into environment variables 19 | dotenv.config(); 20 | 21 | // Object with custom validators and transformers, to avoid repetition 22 | // in the Config object 23 | const v = { 24 | // Splits string value into elements in an array, trims every element, checks 25 | // if an array is correct, if it is empty, and if it is, returns undefined 26 | array: (filterArray) => 27 | z 28 | .string() 29 | .transform((value) => 30 | value 31 | .split(',') 32 | .map((value) => value.trim()) 33 | .filter((value) => filterArray.includes(value)) 34 | ) 35 | .transform((value) => (value.length ? value : undefined)), 36 | 37 | // Allows only true, false and correctly parse the value to boolean 38 | // or no value in which case the returned value will be undefined 39 | boolean: () => 40 | z 41 | .enum(['true', 'false', '']) 42 | .transform((value) => (value !== '' ? value === 'true' : undefined)), 43 | 44 | // Allows passed values or no value in which case the returned value will 45 | // be undefined 46 | enum: (values) => 47 | z 48 | .enum([...values, '']) 49 | .transform((value) => (value !== '' ? value : undefined)), 50 | 51 | // Trims the string value and checks if it is empty or contains stringified 52 | // values such as false, undefined, null, NaN, if it does, returns undefined 53 | string: () => 54 | z 55 | .string() 56 | .trim() 57 | .refine( 58 | (value) => 59 | !['false', 'undefined', 'null', 'NaN'].includes(value) || 60 | value === '', 61 | (value) => ({ 62 | message: `The string contains forbidden values, received '${value}'` 63 | }) 64 | ) 65 | .transform((value) => (value !== '' ? value : undefined)), 66 | 67 | // Checks if the string is a valid path directory (path format) 68 | path: () => 69 | z 70 | .string() 71 | .trim() 72 | .refine( 73 | (value) => { 74 | // Simplified regex to match both absolute and relative paths 75 | return /^(\.\/|\.\.\/|\/|[a-zA-Z]:\\|[a-zA-Z]:\/)?((?:[\w-]+)[\\/]?)+$/.test( 76 | value 77 | ); 78 | }, 79 | {}, 80 | { 81 | message: 'The string is an invalid path directory string.' 82 | } 83 | ), 84 | 85 | // Allows positive numbers or no value in which case the returned value will 86 | // be undefined 87 | positiveNum: () => 88 | z 89 | .string() 90 | .trim() 91 | .refine( 92 | (value) => 93 | value === '' || (!isNaN(parseFloat(value)) && parseFloat(value) > 0), 94 | (value) => ({ 95 | message: `The value must be numeric and positive, received '${value}'` 96 | }) 97 | ) 98 | .transform((value) => (value !== '' ? parseFloat(value) : undefined)), 99 | 100 | // Allows non-negative numbers or no value in which case the returned value 101 | // will be undefined 102 | nonNegativeNum: () => 103 | z 104 | .string() 105 | .trim() 106 | .refine( 107 | (value) => 108 | value === '' || (!isNaN(parseFloat(value)) && parseFloat(value) >= 0), 109 | (value) => ({ 110 | message: `The value must be numeric and non-negative, received '${value}'` 111 | }) 112 | ) 113 | .transform((value) => (value !== '' ? parseFloat(value) : undefined)) 114 | }; 115 | 116 | export const Config = z.object({ 117 | // puppeteer 118 | PUPPETEER_TEMP_DIR: v.path(), 119 | 120 | // highcharts 121 | HIGHCHARTS_VERSION: z 122 | .string() 123 | .trim() 124 | .refine( 125 | (value) => /^(latest|\d+(\.\d+){0,2})$/.test(value) || value === '', 126 | (value) => ({ 127 | message: `HIGHCHARTS_VERSION must be 'latest', a major version, or in the form XX.YY.ZZ, received '${value}'` 128 | }) 129 | ) 130 | .transform((value) => (value !== '' ? value : undefined)), 131 | HIGHCHARTS_CDN_URL: z 132 | .string() 133 | .trim() 134 | .refine( 135 | (value) => 136 | value.startsWith('https://') || 137 | value.startsWith('http://') || 138 | value === '', 139 | (value) => ({ 140 | message: `Invalid value for HIGHCHARTS_CDN_URL. It should start with http:// or https://, received '${value}'` 141 | }) 142 | ) 143 | .transform((value) => (value !== '' ? value : undefined)), 144 | HIGHCHARTS_CORE_SCRIPTS: v.array(scriptsNames.core), 145 | HIGHCHARTS_MODULE_SCRIPTS: v.array(scriptsNames.modules), 146 | HIGHCHARTS_INDICATOR_SCRIPTS: v.array(scriptsNames.indicators), 147 | HIGHCHARTS_FORCE_FETCH: v.boolean(), 148 | HIGHCHARTS_CACHE_PATH: v.string(), 149 | HIGHCHARTS_ADMIN_TOKEN: v.string(), 150 | 151 | // export 152 | EXPORT_TYPE: v.enum(['jpeg', 'png', 'pdf', 'svg']), 153 | EXPORT_CONSTR: v.enum(['chart', 'stockChart', 'mapChart', 'ganttChart']), 154 | EXPORT_DEFAULT_HEIGHT: v.positiveNum(), 155 | EXPORT_DEFAULT_WIDTH: v.positiveNum(), 156 | EXPORT_DEFAULT_SCALE: v.positiveNum(), 157 | EXPORT_RASTERIZATION_TIMEOUT: v.nonNegativeNum(), 158 | 159 | // custom 160 | CUSTOM_LOGIC_ALLOW_CODE_EXECUTION: v.boolean(), 161 | CUSTOM_LOGIC_ALLOW_FILE_RESOURCES: v.boolean(), 162 | 163 | // server 164 | SERVER_ENABLE: v.boolean(), 165 | SERVER_HOST: v.string(), 166 | SERVER_PORT: v.positiveNum(), 167 | SERVER_MAX_UPLOAD_SIZE: v.positiveNum(), 168 | SERVER_BENCHMARKING: v.boolean(), 169 | 170 | // server proxy 171 | SERVER_PROXY_HOST: v.string(), 172 | SERVER_PROXY_PORT: v.positiveNum(), 173 | SERVER_PROXY_USERNAME: v.string(), 174 | SERVER_PROXY_PASSWORD: v.string(), 175 | SERVER_PROXY_TIMEOUT: v.nonNegativeNum(), 176 | 177 | // server rate limiting 178 | SERVER_RATE_LIMITING_ENABLE: v.boolean(), 179 | SERVER_RATE_LIMITING_MAX_REQUESTS: v.nonNegativeNum(), 180 | SERVER_RATE_LIMITING_WINDOW: v.nonNegativeNum(), 181 | SERVER_RATE_LIMITING_DELAY: v.nonNegativeNum(), 182 | SERVER_RATE_LIMITING_TRUST_PROXY: v.boolean(), 183 | SERVER_RATE_LIMITING_SKIP_KEY: v.string(), 184 | SERVER_RATE_LIMITING_SKIP_TOKEN: v.string(), 185 | 186 | // server ssl 187 | SERVER_SSL_ENABLE: v.boolean(), 188 | SERVER_SSL_FORCE: v.boolean(), 189 | SERVER_SSL_PORT: v.positiveNum(), 190 | SERVER_SSL_CERT_PATH: v.string(), 191 | 192 | // pool 193 | POOL_MIN_WORKERS: v.nonNegativeNum(), 194 | POOL_MAX_WORKERS: v.nonNegativeNum(), 195 | POOL_WORK_LIMIT: v.positiveNum(), 196 | POOL_ACQUIRE_TIMEOUT: v.nonNegativeNum(), 197 | POOL_CREATE_TIMEOUT: v.nonNegativeNum(), 198 | POOL_DESTROY_TIMEOUT: v.nonNegativeNum(), 199 | POOL_IDLE_TIMEOUT: v.nonNegativeNum(), 200 | POOL_CREATE_RETRY_INTERVAL: v.nonNegativeNum(), 201 | POOL_REAPER_INTERVAL: v.nonNegativeNum(), 202 | POOL_BENCHMARKING: v.boolean(), 203 | 204 | // logger 205 | LOGGING_LEVEL: z 206 | .string() 207 | .trim() 208 | .refine( 209 | (value) => 210 | value === '' || 211 | (!isNaN(parseFloat(value)) && 212 | parseFloat(value) >= 0 && 213 | parseFloat(value) <= 5), 214 | (value) => ({ 215 | message: `Invalid value for LOGGING_LEVEL. We only accept values from 0 to 5 as logging levels, received '${value}'` 216 | }) 217 | ) 218 | .transform((value) => (value !== '' ? parseFloat(value) : undefined)), 219 | LOGGING_FILE: v.string(), 220 | LOGGING_DEST: v.string(), 221 | LOGGING_TO_CONSOLE: v.boolean(), 222 | LOGGING_TO_FILE: v.boolean(), 223 | 224 | // ui 225 | UI_ENABLE: v.boolean(), 226 | UI_ROUTE: v.string(), 227 | 228 | // other 229 | OTHER_NODE_ENV: v.enum(['development', 'production', 'test']), 230 | OTHER_LISTEN_TO_PROCESS_EXITS: v.boolean(), 231 | OTHER_NO_LOGO: v.boolean(), 232 | OTHER_HARD_RESET_PAGE: v.boolean(), 233 | OTHER_BROWSER_SHELL_MODE: v.boolean(), 234 | OTHER_ALLOW_XLINK: v.boolean(), 235 | 236 | // debugger 237 | DEBUG_ENABLE: v.boolean(), 238 | DEBUG_HEADLESS: v.boolean(), 239 | DEBUG_DEVTOOLS: v.boolean(), 240 | DEBUG_LISTEN_TO_CONSOLE: v.boolean(), 241 | DEBUG_DUMPIO: v.boolean(), 242 | DEBUG_SLOW_MO: v.nonNegativeNum(), 243 | DEBUG_DEBUGGING_PORT: v.positiveNum() 244 | }); 245 | 246 | export const envs = Config.partial().parse(process.env); 247 | -------------------------------------------------------------------------------- /lib/errors/ExportError.js: -------------------------------------------------------------------------------- 1 | class ExportError extends Error { 2 | constructor(message) { 3 | super(); 4 | this.message = message; 5 | this.stackMessage = message; 6 | } 7 | 8 | setError(error) { 9 | this.error = error; 10 | if (error.name) { 11 | this.name = error.name; 12 | } 13 | if (error.statusCode) { 14 | this.statusCode = error.statusCode; 15 | } 16 | if (error.stack) { 17 | this.stackMessage = error.message; 18 | this.stack = error.stack; 19 | } 20 | return this; 21 | } 22 | } 23 | 24 | export default ExportError; 25 | -------------------------------------------------------------------------------- /lib/errors/HttpError.js: -------------------------------------------------------------------------------- 1 | import ExportError from './ExportError.js'; 2 | 3 | class HttpError extends ExportError { 4 | constructor(message, status) { 5 | super(message); 6 | this.status = this.statusCode = status; 7 | } 8 | 9 | setStatus(status) { 10 | this.status = status; 11 | return this; 12 | } 13 | } 14 | 15 | export default HttpError; 16 | -------------------------------------------------------------------------------- /lib/fetch.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This module exports two functions: fetch (for GET requests) and post (for POST requests). 3 | */ 4 | 5 | import http from 'http'; 6 | import https from 'https'; 7 | 8 | /** 9 | * Returns the HTTP or HTTPS protocol module based on the provided URL. 10 | * 11 | * @param {string} url - The URL to determine the protocol. 12 | * 13 | * @returns {Object} The HTTP or HTTPS protocol module (http or https). 14 | */ 15 | const getProtocol = (url) => (url.startsWith('https') ? https : http); 16 | 17 | /** 18 | * Fetches data from the specified URL using either HTTP or HTTPS protocol. 19 | * 20 | * @param {string} url - The URL to fetch data from. 21 | * @param {Object} requestOptions - Options for the HTTP request (optional). 22 | * 23 | * @returns {Promise} Promise resolving to the HTTP response object 24 | * with added 'text' property or rejecting with an error. 25 | */ 26 | async function fetch(url, requestOptions = {}) { 27 | return new Promise((resolve, reject) => { 28 | const protocol = getProtocol(url); 29 | 30 | protocol 31 | .get( 32 | url, 33 | Object.assign( 34 | { 35 | headers: { 36 | 'User-Agent': 'highcharts/export', 37 | Referer: 'highcharts.export' 38 | } 39 | }, 40 | requestOptions || {} 41 | ), 42 | (res) => { 43 | let data = ''; 44 | 45 | // A chunk of data has been received. 46 | res.on('data', (chunk) => { 47 | data += chunk; 48 | }); 49 | 50 | // The whole response has been received. 51 | res.on('end', () => { 52 | if (!data) { 53 | reject('Nothing was fetched from the URL.'); 54 | } 55 | 56 | res.text = data; 57 | resolve(res); 58 | }); 59 | } 60 | ) 61 | .on('error', (error) => { 62 | reject(error); 63 | }); 64 | }); 65 | } 66 | 67 | /** 68 | * Sends a POST request to the specified URL with the provided JSON body using 69 | * either HTTP or HTTPS protocol. 70 | * 71 | * @param {string} url - The URL to send the POST request to. 72 | * @param {Object} body - The JSON body to include in the POST request 73 | * (optional, default is an empty object). 74 | * @param {Object} requestOptions - Options for the HTTP request (optional). 75 | * 76 | * @returns {Promise} Promise resolving to the HTTP response object with 77 | * added 'text' property or rejecting with an error. 78 | */ 79 | async function post(url, body = {}, requestOptions = {}) { 80 | return new Promise((resolve, reject) => { 81 | const protocol = getProtocol(url); 82 | const data = JSON.stringify(body); 83 | 84 | // Set default headers and merge with requestOptions 85 | const options = Object.assign( 86 | { 87 | method: 'POST', 88 | headers: { 89 | 'Content-Type': 'application/json', 90 | 'Content-Length': data.length 91 | } 92 | }, 93 | requestOptions 94 | ); 95 | 96 | const req = protocol 97 | .request(url, options, (res) => { 98 | let responseData = ''; 99 | 100 | // A chunk of data has been received. 101 | res.on('data', (chunk) => { 102 | responseData += chunk; 103 | }); 104 | 105 | // The whole response has been received. 106 | res.on('end', () => { 107 | try { 108 | res.text = responseData; 109 | resolve(res); 110 | } catch (error) { 111 | reject(error); 112 | } 113 | }); 114 | }) 115 | .on('error', (error) => { 116 | reject(error); 117 | }); 118 | 119 | // Write the request body and end the request. 120 | req.write(data); 121 | req.end(); 122 | }); 123 | } 124 | 125 | export default fetch; 126 | export { fetch, post }; 127 | -------------------------------------------------------------------------------- /lib/highcharts.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | /* eslint-disable no-undef */ 16 | 17 | /** 18 | * Setting the animObject. Called when initing the page. 19 | */ 20 | export function setupHighcharts() { 21 | Highcharts.animObject = function () { 22 | return { duration: 0 }; 23 | }; 24 | } 25 | 26 | /** 27 | * Creates the actual chart. 28 | * 29 | * @param {object} chartOptions - The options for the Highcharts chart. 30 | * @param {object} options - The export options. 31 | * @param {boolean} displayErrors - A flag indicating whether to display errors. 32 | */ 33 | export async function triggerExport(chartOptions, options, displayErrors) { 34 | // Display errors flag taken from chart options nad debugger module 35 | window._displayErrors = displayErrors; 36 | 37 | // Get required functions 38 | const { getOptions, merge, setOptions, wrap } = Highcharts; 39 | 40 | // Create a separate object for a potential setOptions usages in order to 41 | // prevent from polluting other exports that can happen on the same page 42 | Highcharts.setOptionsObj = merge(false, {}, getOptions()); 43 | 44 | // By default animation is disabled 45 | const chart = { 46 | animation: false 47 | }; 48 | 49 | // When straight inject, the size is set through CSS only 50 | if (options.export.strInj) { 51 | chart.height = chartOptions.chart.height; 52 | chart.width = chartOptions.chart.width; 53 | } 54 | 55 | // NOTE: Is this used for anything useful? 56 | window.isRenderComplete = false; 57 | wrap(Highcharts.Chart.prototype, 'init', function (proceed, userOptions, cb) { 58 | // Override userOptions with image friendly options 59 | userOptions = merge(userOptions, { 60 | exporting: { 61 | enabled: false 62 | }, 63 | plotOptions: { 64 | series: { 65 | label: { 66 | enabled: false 67 | } 68 | } 69 | }, 70 | /* Expects tooltip in userOptions when forExport is true. 71 | https://github.com/highcharts/highcharts/blob/3ad430a353b8056b9e764aa4e5cd6828aa479db2/js/parts/Chart.js#L241 72 | */ 73 | tooltip: {} 74 | }); 75 | 76 | (userOptions.series || []).forEach(function (series) { 77 | series.animation = false; 78 | }); 79 | 80 | // Add flag to know if chart render has been called. 81 | if (!window.onHighchartsRender) { 82 | window.onHighchartsRender = Highcharts.addEvent(this, 'render', () => { 83 | window.isRenderComplete = true; 84 | }); 85 | } 86 | 87 | proceed.apply(this, [userOptions, cb]); 88 | }); 89 | 90 | wrap(Highcharts.Series.prototype, 'init', function (proceed, chart, options) { 91 | proceed.apply(this, [chart, options]); 92 | }); 93 | 94 | // Get the user options 95 | const userOptions = options.export.strInj 96 | ? new Function(`return ${options.export.strInj}`)() 97 | : chartOptions; 98 | 99 | // Trigger custom code 100 | if (options.customLogic.customCode) { 101 | new Function('options', options.customLogic.customCode)(userOptions); 102 | } 103 | 104 | // Merge the globalOptions, themeOptions, options from the wrapped 105 | // setOptions function and user options to create the final options object 106 | const finalOptions = merge( 107 | false, 108 | JSON.parse(options.export.themeOptions), 109 | userOptions, 110 | // Placed it here instead in the init because of the size issues 111 | { chart } 112 | ); 113 | 114 | const finalCallback = options.customLogic.callback 115 | ? new Function(`return ${options.customLogic.callback}`)() 116 | : undefined; 117 | 118 | // Set the global options if exist 119 | const globalOptions = JSON.parse(options.export.globalOptions); 120 | if (globalOptions) { 121 | setOptions(globalOptions); 122 | } 123 | 124 | let constr = options.export.constr || 'chart'; 125 | constr = typeof Highcharts[constr] !== 'undefined' ? constr : 'chart'; 126 | 127 | Highcharts[constr]('container', finalOptions, finalCallback); 128 | 129 | // Get the current global options 130 | const defaultOptions = getOptions(); 131 | 132 | // Clear it just in case (e.g. the setOptions was used in the customCode) 133 | for (const prop in defaultOptions) { 134 | if (typeof defaultOptions[prop] !== 'function') { 135 | delete defaultOptions[prop]; 136 | } 137 | } 138 | 139 | // Set the default options back 140 | setOptions(Highcharts.setOptionsObj); 141 | 142 | // Empty the custom global options object 143 | Highcharts.setOptionsObj = {}; 144 | } 145 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import 'colors'; 16 | 17 | import { checkAndUpdateCache } from './cache.js'; 18 | import { 19 | batchExport, 20 | setAllowCodeExecution, 21 | singleExport, 22 | startExport 23 | } from './chart.js'; 24 | import { mapToNewConfig, manualConfig, setOptions } from './config.js'; 25 | import { 26 | initLogging, 27 | log, 28 | logWithStack, 29 | setLogLevel, 30 | enableFileLogging 31 | } from './logger.js'; 32 | import { initPool, killPool } from './pool.js'; 33 | import { shutdownCleanUp } from './resource_release.js'; 34 | import server, { startServer } from './server/server.js'; 35 | import { printLogo, printUsage } from './utils.js'; 36 | 37 | /** 38 | * Attaches exit listeners to the process, ensuring proper cleanup of resources 39 | * and termination on exit signals. Handles 'exit', 'SIGINT', 'SIGTERM', and 40 | * 'uncaughtException' events. 41 | */ 42 | const attachProcessExitListeners = () => { 43 | log(3, '[process] Attaching exit listeners to the process.'); 44 | 45 | // Handler for the 'exit' 46 | process.on('exit', (code) => { 47 | log(4, `Process exited with code ${code}.`); 48 | }); 49 | 50 | // Handler for the 'SIGINT' 51 | process.on('SIGINT', async (name, code) => { 52 | log(4, `The ${name} event with code: ${code}.`); 53 | await shutdownCleanUp(0); 54 | }); 55 | 56 | // Handler for the 'SIGTERM' 57 | process.on('SIGTERM', async (name, code) => { 58 | log(4, `The ${name} event with code: ${code}.`); 59 | await shutdownCleanUp(0); 60 | }); 61 | 62 | // Handler for the 'SIGHUP' 63 | process.on('SIGHUP', async (name, code) => { 64 | log(4, `The ${name} event with code: ${code}.`); 65 | await shutdownCleanUp(0); 66 | }); 67 | 68 | // Handler for the 'uncaughtException' 69 | process.on('uncaughtException', async (error, name) => { 70 | logWithStack(1, error, `The ${name} error.`); 71 | await shutdownCleanUp(1); 72 | }); 73 | }; 74 | 75 | /** 76 | * Initializes the export process. Tasks such as configuring logging, checking 77 | * cache and sources, and initializing the pool of resources happen during 78 | * 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. 79 | * 80 | * @param {Object} options - All export options. 81 | * 82 | * @returns {Promise} Promise resolving to the updated export options. 83 | */ 84 | const initExport = async (options) => { 85 | // Set the allowCodeExecution per export module scope 86 | setAllowCodeExecution( 87 | options.customLogic && options.customLogic.allowCodeExecution 88 | ); 89 | 90 | // Init the logging 91 | initLogging(options.logging); 92 | 93 | // Attach process' exit listeners 94 | if (options.other.listenToProcessExits) { 95 | attachProcessExitListeners(); 96 | } 97 | 98 | // Check if cache needs to be updated 99 | await checkAndUpdateCache(options); 100 | 101 | // Init the pool 102 | await initPool({ 103 | pool: options.pool || { 104 | minWorkers: 1, 105 | maxWorkers: 1 106 | }, 107 | puppeteerArgs: options.puppeteer.args || [] 108 | }); 109 | 110 | // Return updated options 111 | return options; 112 | }; 113 | 114 | export default { 115 | // Server 116 | server, 117 | startServer, 118 | 119 | // Exporting 120 | initExport, 121 | singleExport, 122 | batchExport, 123 | startExport, 124 | 125 | // Pool 126 | initPool, 127 | killPool, 128 | 129 | // Other 130 | setOptions, 131 | shutdownCleanUp, 132 | 133 | // Logs 134 | log, 135 | logWithStack, 136 | setLogLevel, 137 | enableFileLogging, 138 | 139 | // Utils 140 | mapToNewConfig, 141 | manualConfig, 142 | printLogo, 143 | printUsage 144 | }; 145 | -------------------------------------------------------------------------------- /lib/intervals.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import { log } from './logger.js'; 16 | 17 | // Array that contains ids of all ongoing intervals 18 | const intervalIds = []; 19 | 20 | /** 21 | * Adds id of a setInterval to the intervalIds array. 22 | * 23 | * @param {NodeJS.Timeout} id - Id of an interval. 24 | */ 25 | export const addInterval = (id) => { 26 | intervalIds.push(id); 27 | }; 28 | 29 | /** 30 | * Clears all of ongoing intervals by ids gathered in the intervalIds array. 31 | */ 32 | export const clearAllIntervals = () => { 33 | log(4, `[server] Clearing all registered intervals.`); 34 | for (const id of intervalIds) { 35 | clearInterval(id); 36 | } 37 | }; 38 | 39 | export default { 40 | addInterval, 41 | clearAllIntervals 42 | }; 43 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import { appendFile, existsSync, mkdirSync } from 'fs'; 16 | 17 | // The available colors 18 | const colors = ['red', 'yellow', 'blue', 'gray', 'green']; 19 | 20 | // The default logging config 21 | let logging = { 22 | // Flags for logging status 23 | toConsole: true, 24 | toFile: false, 25 | pathCreated: false, 26 | // Log levels 27 | levelsDesc: [ 28 | { 29 | title: 'error', 30 | color: colors[0] 31 | }, 32 | { 33 | title: 'warning', 34 | color: colors[1] 35 | }, 36 | { 37 | title: 'notice', 38 | color: colors[2] 39 | }, 40 | { 41 | title: 'verbose', 42 | color: colors[3] 43 | }, 44 | { 45 | title: 'benchmark', 46 | color: colors[4] 47 | } 48 | ], 49 | // Log listeners 50 | listeners: [] 51 | }; 52 | 53 | /** 54 | * Logs the provided texts to a file, if file logging is enabled. It creates 55 | * the necessary directory structure if not already created and appends the 56 | * content, including an optional prefix, to the specified log file. 57 | * 58 | * @param {string[]} texts - An array of texts to be logged. 59 | * @param {string} prefix - An optional prefix to be added to each log entry. 60 | */ 61 | const logToFile = (texts, prefix) => { 62 | if (!logging.pathCreated) { 63 | // Create if does not exist 64 | !existsSync(logging.dest) && mkdirSync(logging.dest); 65 | 66 | // We now assume the path is available, e.g. it's the responsibility 67 | // of the user to create the path with the correct access rights. 68 | logging.pathCreated = true; 69 | } 70 | 71 | // Add the content to a file 72 | appendFile( 73 | `${logging.dest}${logging.file}`, 74 | [prefix].concat(texts).join(' ') + '\n', 75 | (error) => { 76 | if (error) { 77 | console.log(`[logger] Unable to write to log file: ${error}`); 78 | logging.toFile = false; 79 | } 80 | } 81 | ); 82 | }; 83 | 84 | /** 85 | * Logs a message. Accepts a variable amount of arguments. Arguments after 86 | * `level` will be passed directly to console.log, and/or will be joined 87 | * and appended to the log file. 88 | * 89 | * @param {any} args - An array of arguments where the first is the log level 90 | * and the rest are strings to build a message with. 91 | */ 92 | export const log = (...args) => { 93 | const [newLevel, ...texts] = args; 94 | 95 | // Current logging options 96 | const { levelsDesc, level } = logging; 97 | 98 | // Check if log level is within a correct range or is a benchmark log 99 | if ( 100 | newLevel !== 5 && 101 | (newLevel === 0 || newLevel > level || level > levelsDesc.length) 102 | ) { 103 | return; 104 | } 105 | 106 | // Get rid of the GMT text information 107 | const newDate = new Date().toString().split('(')[0].trim(); 108 | 109 | // Create a message's prefix 110 | const prefix = `${newDate} [${levelsDesc[newLevel - 1].title}] -`; 111 | 112 | // Call available log listeners 113 | logging.listeners.forEach((fn) => { 114 | fn(prefix, texts.join(' ')); 115 | }); 116 | 117 | // Log to console 118 | if (logging.toConsole) { 119 | console.log.apply( 120 | undefined, 121 | [prefix.toString()[logging.levelsDesc[newLevel - 1].color]].concat(texts) 122 | ); 123 | } 124 | 125 | // Log to file 126 | if (logging.toFile) { 127 | logToFile(texts, prefix); 128 | } 129 | }; 130 | 131 | /** 132 | * Logs an error message with its stack trace. Optionally, a custom message 133 | * can be provided. 134 | * 135 | * @param {number} level - The log level. 136 | * @param {Error} error - The error object. 137 | * @param {string} customMessage - An optional custom message to be logged along 138 | * with the error. 139 | */ 140 | export const logWithStack = (newLevel, error, customMessage) => { 141 | // Get the main message 142 | const mainMessage = customMessage || error.message; 143 | 144 | // Current logging options 145 | const { level, levelsDesc } = logging; 146 | 147 | // Check if log level is within a correct range 148 | if (newLevel === 0 || newLevel > level || level > levelsDesc.length) { 149 | return; 150 | } 151 | 152 | // Get rid of the GMT text information 153 | const newDate = new Date().toString().split('(')[0].trim(); 154 | 155 | // Create a message's prefix 156 | const prefix = `${newDate} [${levelsDesc[newLevel - 1].title}] -`; 157 | 158 | // If the customMessage exists, we want to display the whole stack message 159 | const stackMessage = 160 | error.message !== error.stackMessage || error.stackMessage === undefined 161 | ? error.stack 162 | : error.stack.split('\n').slice(1).join('\n'); 163 | 164 | // Combine custom message or error message with error stack message 165 | const texts = [mainMessage, '\n', stackMessage]; 166 | 167 | // Log to console 168 | if (logging.toConsole) { 169 | console.log.apply( 170 | undefined, 171 | [prefix.toString()[logging.levelsDesc[newLevel - 1].color]].concat([ 172 | mainMessage[colors[newLevel - 1]], 173 | '\n', 174 | stackMessage 175 | ]) 176 | ); 177 | } 178 | 179 | // Call available log listeners 180 | logging.listeners.forEach((fn) => { 181 | fn(prefix, texts.join(' ')); 182 | }); 183 | 184 | // Log to file 185 | if (logging.toFile) { 186 | logToFile(texts, prefix); 187 | } 188 | }; 189 | 190 | /** 191 | * Sets the log level to the specified value. Log levels are (0 = no logging, 192 | * 1 = error, 2 = warning, 3 = notice, 4 = verbose or 5 = benchmark) 193 | * 194 | * @param {number} newLevel - The new log level to be set. 195 | */ 196 | export const setLogLevel = (newLevel) => { 197 | if (newLevel >= 0 && newLevel <= logging.levelsDesc.length) { 198 | logging.level = newLevel; 199 | } 200 | }; 201 | 202 | /** 203 | * Enables file logging with the specified destination and log file. 204 | * 205 | * @param {string} logDest - The destination path for log files. 206 | * @param {string} logFile - The log file name. 207 | */ 208 | export const enableFileLogging = (logDest, logFile) => { 209 | // Update logging options 210 | logging = { 211 | ...logging, 212 | dest: logDest || logging.dest, 213 | file: logFile || logging.file, 214 | toFile: true 215 | }; 216 | 217 | if (logging.dest.length === 0) { 218 | return log(1, '[logger] File logging initialization: no path supplied.'); 219 | } 220 | 221 | if (!logging.dest.endsWith('/')) { 222 | logging.dest += '/'; 223 | } 224 | }; 225 | 226 | /** 227 | * Initializes logging with the specified logging configuration. 228 | * 229 | * @param {Object} loggingOptions - The logging configuration object. 230 | */ 231 | export const initLogging = (loggingOptions) => { 232 | // Set all the logging options on our logging module object 233 | for (const [key, value] of Object.entries(loggingOptions)) { 234 | logging[key] = value; 235 | } 236 | 237 | // Set the log level 238 | setLogLevel(loggingOptions && parseInt(loggingOptions.level)); 239 | 240 | // Set the log file path and name 241 | if (loggingOptions && loggingOptions.dest && loggingOptions.toFile) { 242 | enableFileLogging( 243 | loggingOptions.dest, 244 | loggingOptions.file || 'highcharts-export-server.log' 245 | ); 246 | } 247 | }; 248 | 249 | /** 250 | * Adds a listener function to the logging system. 251 | * 252 | * @param {function} fn - The listener function to be added. 253 | */ 254 | export const listen = (fn) => { 255 | logging.listeners.push(fn); 256 | }; 257 | 258 | export default { 259 | log, 260 | logWithStack, 261 | setLogLevel, 262 | enableFileLogging, 263 | initLogging, 264 | listen 265 | }; 266 | -------------------------------------------------------------------------------- /lib/resource_release.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import { clearAllIntervals } from './intervals.js'; 16 | import { killPool } from './pool.js'; 17 | import { closeServers } from './server/server.js'; 18 | 19 | /** 20 | * Clean up function to trigger before ending process for the graceful shutdown. 21 | * 22 | * @param {number} exitCode - An exit code for the process.exit() function. 23 | */ 24 | export const shutdownCleanUp = async (exitCode) => { 25 | // Await freeing all resources 26 | await Promise.allSettled([ 27 | // Clear all ongoing intervals 28 | clearAllIntervals(), 29 | 30 | // Get available server instances (HTTP/HTTPS) and close them 31 | closeServers(), 32 | 33 | // Close pool along with its workers and the browser instance, if exists 34 | killPool() 35 | ]); 36 | 37 | // Exit process with a correct code 38 | process.exit(exitCode); 39 | }; 40 | 41 | export default { 42 | shutdownCleanUp 43 | }; 44 | -------------------------------------------------------------------------------- /lib/sanitize.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | /** 16 | * @overview Used to sanitize the strings coming from the exporting module 17 | * to prevent XSS attacks (with the DOMPurify library). 18 | **/ 19 | 20 | import { JSDOM } from 'jsdom'; 21 | import DOMPurify from 'dompurify'; 22 | 23 | import { envs } from './envs.js'; 24 | /** 25 | * Sanitizes a given HTML string by removing tags and any content within them. 28 | * 29 | * @param {string} input The HTML string to be sanitized. 30 | * @returns {string} The sanitized HTML string. 31 | */ 32 | export function sanitize(input) { 33 | const forbidden = []; 34 | 35 | if (!envs.OTHER_ALLOW_XLINK) { 36 | forbidden.push('xlink:href'); 37 | } 38 | 39 | const window = new JSDOM('').window; 40 | const purify = DOMPurify(window); 41 | return purify.sanitize(input, { 42 | ADD_TAGS: ['foreignObject'], 43 | FORBID_ATTR: forbidden 44 | }); 45 | } 46 | 47 | export default sanitize; 48 | -------------------------------------------------------------------------------- /lib/server/error.js: -------------------------------------------------------------------------------- 1 | import { envs } from '../envs.js'; 2 | import { logWithStack } from '../logger.js'; 3 | 4 | /** 5 | * Middleware for logging errors with stack trace and handling error response. 6 | * 7 | * @param {Error} error - The error object. 8 | * @param {Express.Request} req - The Express request object. 9 | * @param {Express.Response} res - The Express response object. 10 | * @param {Function} next - The next middleware function. 11 | */ 12 | const logErrorMiddleware = (error, req, res, next) => { 13 | // Display the error with stack in a correct format 14 | logWithStack(1, error); 15 | 16 | // Delete the stack for the environment other than the development 17 | if (envs.OTHER_NODE_ENV !== 'development') { 18 | delete error.stack; 19 | } 20 | 21 | // Call the returnErrorMiddleware 22 | next(error); 23 | }; 24 | 25 | /** 26 | * Middleware for returning error response. 27 | * 28 | * @param {Error} error - The error object. 29 | * @param {Express.Request} req - The Express request object. 30 | * @param {Express.Response} res - The Express response object. 31 | * @param {Function} next - The next middleware function. 32 | */ 33 | const returnErrorMiddleware = (error, req, res, next) => { 34 | // Gather all requied information for the response 35 | const { statusCode: stCode, status, message, stack } = error; 36 | const statusCode = stCode || status || 400; 37 | 38 | // Set and return response 39 | res.status(statusCode).json({ statusCode, message, stack }); 40 | }; 41 | 42 | export default (app) => { 43 | // Add log error middleware 44 | app.use(logErrorMiddleware); 45 | 46 | // Add set status and return error middleware 47 | app.use(returnErrorMiddleware); 48 | }; 49 | -------------------------------------------------------------------------------- /lib/server/rate_limit.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import rateLimit from 'express-rate-limit'; 16 | 17 | import { log } from '../logger.js'; 18 | 19 | /** 20 | * Middleware for enabling rate limiting on the specified Express app. 21 | * 22 | * @param {Express} app - The Express app instance. 23 | * @param {Object} limitConfig - Configuration options for rate limiting. 24 | */ 25 | export default (app, limitConfig) => { 26 | const msg = 27 | 'Too many requests, you have been rate limited. Please try again later.'; 28 | 29 | // Options for the rate limiter 30 | const rateOptions = { 31 | max: limitConfig.maxRequests || 30, 32 | window: limitConfig.window || 1, 33 | delay: limitConfig.delay || 0, 34 | trustProxy: limitConfig.trustProxy || false, 35 | skipKey: limitConfig.skipKey || false, 36 | skipToken: limitConfig.skipToken || false 37 | }; 38 | 39 | // Set if behind a proxy 40 | if (rateOptions.trustProxy) { 41 | app.enable('trust proxy'); 42 | } 43 | 44 | // Create a limiter 45 | const limiter = rateLimit({ 46 | windowMs: rateOptions.window * 60 * 1000, 47 | // Limit each IP to 100 requests per windowMs 48 | max: rateOptions.max, 49 | // Disable delaying, full speed until the max limit is reached 50 | delayMs: rateOptions.delay, 51 | handler: (request, response) => { 52 | response.format({ 53 | json: () => { 54 | response.status(429).send({ message: msg }); 55 | }, 56 | default: () => { 57 | response.status(429).send(msg); 58 | } 59 | }); 60 | }, 61 | skip: (request) => { 62 | // Allow bypassing the limiter if a valid key/token has been sent 63 | if ( 64 | rateOptions.skipKey !== false && 65 | rateOptions.skipToken !== false && 66 | request.query.key === rateOptions.skipKey && 67 | request.query.access_token === rateOptions.skipToken 68 | ) { 69 | log(4, '[rate limiting] Skipping rate limiter.'); 70 | return true; 71 | } 72 | return false; 73 | } 74 | }); 75 | 76 | // Use a limiter as a middleware 77 | app.use(limiter); 78 | 79 | log( 80 | 3, 81 | `[rate limiting] Enabled rate limiting with ${rateOptions.max} requests per ${rateOptions.window} minute for each IP, trusting proxy: ${rateOptions.trustProxy}.` 82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /lib/server/routes/change_hc_version.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import { updateVersion, version } from '../../cache.js'; 16 | import { envs } from '../../envs.js'; 17 | 18 | import HttpError from '../../errors/HttpError.js'; 19 | 20 | /** 21 | * Adds the POST /change_hc_version/:newVersion route that can be utilized to modify 22 | * the Highcharts version on the server. 23 | * 24 | * TODO: Add auth token and connect to API 25 | */ 26 | export default (app) => 27 | !app 28 | ? false 29 | : app.post( 30 | '/version/change/:newVersion', 31 | async (request, response, next) => { 32 | try { 33 | const adminToken = envs.HIGHCHARTS_ADMIN_TOKEN; 34 | 35 | // Check the existence of the token 36 | if (!adminToken || !adminToken.length) { 37 | throw new HttpError( 38 | 'The server is not configured to perform run-time version changes: HIGHCHARTS_ADMIN_TOKEN is not set.', 39 | 401 40 | ); 41 | } 42 | 43 | // Check if the hc-auth header contain a correct token 44 | const token = request.get('hc-auth'); 45 | if (!token || token !== adminToken) { 46 | throw new HttpError( 47 | 'Invalid or missing token: Set the token in the hc-auth header.', 48 | 401 49 | ); 50 | } 51 | 52 | // Compare versions 53 | const newVersion = request.params.newVersion; 54 | if (newVersion) { 55 | try { 56 | // eslint-disable-next-line import/no-named-as-default-member 57 | await updateVersion(newVersion); 58 | } catch (error) { 59 | throw new HttpError( 60 | `Version change: ${error.message}`, 61 | error.statusCode 62 | ).setError(error); 63 | } 64 | 65 | // Success 66 | response.status(200).send({ 67 | statusCode: 200, 68 | version: version(), 69 | message: `Successfully updated Highcharts to version: ${newVersion}.` 70 | }); 71 | } else { 72 | // No version specified 73 | throw new HttpError('No new version supplied.', 400); 74 | } 75 | } catch (error) { 76 | next(error); 77 | } 78 | } 79 | ); 80 | -------------------------------------------------------------------------------- /lib/server/routes/export.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import { v4 as uuid } from 'uuid'; 16 | 17 | import { getAllowCodeExecution, startExport } from '../../chart.js'; 18 | import { getOptions, mergeConfigOptions } from '../../config.js'; 19 | import { log } from '../../logger.js'; 20 | import { 21 | fixType, 22 | isCorrectJSON, 23 | isObjectEmpty, 24 | isPrivateRangeUrlFound, 25 | optionsStringify, 26 | measureTime 27 | } from '../../utils.js'; 28 | 29 | import HttpError from '../../errors/HttpError.js'; 30 | 31 | // Reversed MIME types 32 | const reversedMime = { 33 | png: 'image/png', 34 | jpeg: 'image/jpeg', 35 | gif: 'image/gif', 36 | pdf: 'application/pdf', 37 | svg: 'image/svg+xml' 38 | }; 39 | 40 | // The requests counter 41 | let requestsCounter = 0; 42 | 43 | // The array of callbacks to call before a request 44 | const beforeRequest = []; 45 | 46 | // The array of callbacks to call after a request 47 | const afterRequest = []; 48 | 49 | /** 50 | * Invokes an array of callback functions with specified parameters, allowing 51 | * customization of request handling. 52 | * 53 | * @param {Function[]} callbacks - An array of callback functions 54 | * to be executed. 55 | * @param {Express.Request} request - The Express request object. 56 | * @param {Express.Response} response - The Express response object. 57 | * @param {Object} data - An object containing parameters like id, uniqueId, 58 | * type, and body. 59 | * 60 | * @returns {boolean} - Returns a boolean indicating the overall result 61 | * of the callback invocations. 62 | */ 63 | const doCallbacks = (callbacks, request, response, data) => { 64 | let result = true; 65 | const { id, uniqueId, type, body } = data; 66 | 67 | callbacks.some((callback) => { 68 | if (callback) { 69 | let callResponse = callback(request, response, id, uniqueId, type, body); 70 | 71 | if (callResponse !== undefined && callResponse !== true) { 72 | result = callResponse; 73 | } 74 | 75 | return true; 76 | } 77 | }); 78 | 79 | return result; 80 | }; 81 | 82 | /** 83 | * Handles the export requests from the client. 84 | * 85 | * @param {Express.Request} request - The Express request object. 86 | * @param {Express.Response} response - The Express response object. 87 | * @param {Function} next - The next middleware function. 88 | * 89 | * @returns {Promise} - A promise that resolves once the export process 90 | * is complete. 91 | */ 92 | const exportHandler = async (request, response, next) => { 93 | try { 94 | // Start counting time 95 | const stopCounter = measureTime(); 96 | 97 | // Create a unique ID for a request 98 | const uniqueId = uuid().replace(/-/g, ''); 99 | 100 | // Get the current server's general options 101 | const defaultOptions = getOptions(); 102 | 103 | const body = request.body; 104 | const id = ++requestsCounter; 105 | 106 | let type = fixType(body.type); 107 | 108 | // Throw 'Bad Request' if there's no body 109 | if (!body || isObjectEmpty(body)) { 110 | throw new HttpError( 111 | 'The request body is required. Please ensure that your Content-Type header is correct (accepted types are application/json and multipart/form-data).', 112 | 400 113 | ); 114 | } 115 | 116 | // All of the below can be used 117 | let instr = isCorrectJSON(body.infile || body.options || body.data); 118 | 119 | // Throw 'Bad Request' if there's no JSON or SVG to export 120 | if (!instr && !body.svg) { 121 | log( 122 | 2, 123 | `The request with ID ${uniqueId} from ${ 124 | request.headers['x-forwarded-for'] || request.connection.remoteAddress 125 | } was incorrect: 126 | Content-Type: ${request.headers['content-type']}. 127 | Chart constructor: ${body.constr}. 128 | Dimensions: ${body.width}x${body.height} @ ${body.scale} scale. 129 | Type: ${type}. 130 | Is SVG set? ${typeof body.svg !== 'undefined'}. 131 | B64? ${typeof body.b64 !== 'undefined'}. 132 | No download? ${typeof body.noDownload !== 'undefined'}. 133 | 134 | Payload received: ${JSON.stringify(body.infile || body.options || body.data || body.svg)} 135 | 136 | ` 137 | ); 138 | 139 | throw new HttpError( 140 | "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.", 141 | 400 142 | ); 143 | } 144 | 145 | let callResponse = false; 146 | 147 | // Call the before request functions 148 | callResponse = doCallbacks(beforeRequest, request, response, { 149 | id, 150 | uniqueId, 151 | type, 152 | body 153 | }); 154 | 155 | // Block the request if one of a callbacks failed 156 | if (callResponse !== true) { 157 | return response.send(callResponse); 158 | } 159 | 160 | let connectionAborted = false; 161 | 162 | // In case the connection is closed, force to abort further actions 163 | request.socket.on('close', (hadErrors) => { 164 | if (hadErrors) { 165 | connectionAborted = true; 166 | } 167 | }); 168 | 169 | log(4, `[export] Got an incoming HTTP request with ID ${uniqueId}.`); 170 | 171 | body.constr = (typeof body.constr === 'string' && body.constr) || 'chart'; 172 | 173 | // Gather and organize options from the payload 174 | const requestOptions = { 175 | export: { 176 | instr, 177 | type, 178 | constr: body.constr[0].toLowerCase() + body.constr.substr(1), 179 | height: body.height, 180 | width: body.width, 181 | scale: body.scale || defaultOptions.export.scale, 182 | globalOptions: isCorrectJSON(body.globalOptions, true), 183 | themeOptions: isCorrectJSON(body.themeOptions, true) 184 | }, 185 | customLogic: { 186 | allowCodeExecution: getAllowCodeExecution(), 187 | allowFileResources: false, 188 | resources: isCorrectJSON(body.resources, true), 189 | callback: body.callback, 190 | customCode: body.customCode 191 | } 192 | }; 193 | 194 | if (instr) { 195 | // Stringify JSON with options 196 | requestOptions.export.instr = optionsStringify( 197 | instr, 198 | requestOptions.customLogic.allowCodeExecution 199 | ); 200 | } 201 | 202 | // Merge the request options into default ones 203 | const options = mergeConfigOptions(defaultOptions, requestOptions); 204 | 205 | // Save the JSON if exists 206 | options.export.options = instr; 207 | 208 | // Lastly, add the server specific arguments into options as payload 209 | options.payload = { 210 | svg: body.svg || false, 211 | b64: body.b64 || false, 212 | noDownload: body.noDownload || false, 213 | requestId: uniqueId 214 | }; 215 | 216 | // Test xlink:href elements from payload's SVG 217 | if (body.svg && isPrivateRangeUrlFound(options.payload.svg)) { 218 | throw new HttpError( 219 | '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.', 220 | 400 221 | ); 222 | } 223 | 224 | // Start the export process 225 | await startExport(options, (error, info) => { 226 | // Remove the close event from the socket 227 | request.socket.removeAllListeners('close'); 228 | 229 | // After the whole exporting process 230 | if (defaultOptions.server.benchmarking) { 231 | log( 232 | 5, 233 | `[benchmark] Request with ID ${uniqueId} - After the whole exporting process: ${stopCounter()}ms.` 234 | ); 235 | } 236 | 237 | // If the connection was closed, do nothing 238 | if (connectionAborted) { 239 | return log( 240 | 3, 241 | `[export] The client closed the connection before the chart finished processing.` 242 | ); 243 | } 244 | 245 | // If error, log it and send it to the error middleware 246 | if (error) { 247 | throw error; 248 | } 249 | 250 | // If data is missing, log the message and send it to the error middleware 251 | if (!info || !info.result) { 252 | throw new HttpError( 253 | `Unexpected return from chart generation. Please check your request data. For the request with ID ${uniqueId}, the result is ${info.result}.`, 254 | 400 255 | ); 256 | } 257 | 258 | // Get the type from options 259 | type = info.options.export.type; 260 | 261 | // The after request callbacks 262 | doCallbacks(afterRequest, request, response, { id, body: info.result }); 263 | 264 | if (info.result) { 265 | // If only base64 is required, return it 266 | if (body.b64) { 267 | // SVG Exception for the Highcharts 11.3.0 version 268 | if (type === 'pdf' || type == 'svg') { 269 | return response.send( 270 | Buffer.from(info.result, 'utf8').toString('base64') 271 | ); 272 | } 273 | 274 | return response.send(info.result); 275 | } 276 | 277 | // Set correct content type 278 | response.header('Content-Type', reversedMime[type] || 'image/png'); 279 | 280 | // Decide whether to download or not chart file 281 | if (!body.noDownload) { 282 | response.attachment( 283 | `${request.params.filename || request.body.filename || 'chart'}.${ 284 | type || 'png' 285 | }` 286 | ); 287 | } 288 | 289 | // If SVG, return plain content 290 | return type === 'svg' 291 | ? response.send(info.result) 292 | : response.send(Buffer.from(info.result, 'base64')); 293 | } 294 | }); 295 | } catch (error) { 296 | next(error); 297 | } 298 | }; 299 | 300 | export default (app) => { 301 | /** 302 | * Adds the POST / a route for handling POST requests at the root endpoint. 303 | */ 304 | app.post('/', exportHandler); 305 | 306 | /** 307 | * Adds the POST /:filename a route for handling POST requests with 308 | * a specified filename parameter. 309 | */ 310 | app.post('/:filename', exportHandler); 311 | }; 312 | -------------------------------------------------------------------------------- /lib/server/routes/health.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import { readFileSync } from 'fs'; 16 | import { join as pather } from 'path'; 17 | import { log } from '../../logger.js'; 18 | 19 | import { version } from '../../cache.js'; 20 | import { addInterval } from '../../intervals.js'; 21 | import pool from '../../pool.js'; 22 | import { __dirname } from '../../utils.js'; 23 | 24 | const pkgFile = JSON.parse(readFileSync(pather(__dirname, 'package.json'))); 25 | 26 | const serverStartTime = new Date(); 27 | 28 | const successRates = []; 29 | const recordInterval = 60 * 1000; // record every minute 30 | const windowSize = 30; // 30 minutes 31 | 32 | /** 33 | * Calculates moving average indicator based on the data from the successRates 34 | * array. 35 | * 36 | * @returns {number} - A moving average for success ratio of the server exports. 37 | */ 38 | function calculateMovingAverage() { 39 | const sum = successRates.reduce((a, b) => a + b, 0); 40 | return sum / successRates.length; 41 | } 42 | 43 | /** 44 | * Starts the interval responsible for calculating current success rate ratio 45 | * and gathers 46 | * 47 | * @returns {NodeJS.Timeout} id - Id of an interval. 48 | */ 49 | export const startSuccessRate = () => 50 | setInterval(() => { 51 | const stats = pool.getStats(); 52 | const successRatio = 53 | stats.exportAttempts === 0 54 | ? 1 55 | : (stats.performedExports / stats.exportAttempts) * 100; 56 | 57 | successRates.push(successRatio); 58 | if (successRates.length > windowSize) { 59 | successRates.shift(); 60 | } 61 | }, recordInterval); 62 | 63 | /** 64 | * Adds the /health and /success-moving-average routes 65 | * which output basic stats for the server. 66 | */ 67 | export default function addHealthRoutes(app) { 68 | if (!app) { 69 | return false; 70 | } 71 | 72 | // Start processing success rate ratio interval and save its id to the array 73 | // for the graceful clearing on shutdown with injected addInterval funtion 74 | addInterval(startSuccessRate()); 75 | 76 | app.get('/health', (_, res) => { 77 | const stats = pool.getStats(); 78 | const period = successRates.length; 79 | const movingAverage = calculateMovingAverage(); 80 | 81 | log(4, '[health.js] GET /health [200] - returning server health.'); 82 | 83 | res.send({ 84 | status: 'OK', 85 | bootTime: serverStartTime, 86 | uptime: 87 | Math.floor( 88 | (new Date().getTime() - serverStartTime.getTime()) / 1000 / 60 89 | ) + ' minutes', 90 | version: pkgFile.version, 91 | highchartsVersion: version(), 92 | averageProcessingTime: stats.spentAverage, 93 | performedExports: stats.performedExports, 94 | failedExports: stats.droppedExports, 95 | exportAttempts: stats.exportAttempts, 96 | sucessRatio: (stats.performedExports / stats.exportAttempts) * 100, 97 | // eslint-disable-next-line import/no-named-as-default-member 98 | pool: pool.getPoolInfoJSON(), 99 | 100 | // Moving average 101 | period, 102 | movingAverage, 103 | message: 104 | isNaN(movingAverage) || !successRates.length 105 | ? 'Too early to report. No exports made yet. Please check back soon.' 106 | : `Last ${period} minutes had a success rate of ${movingAverage.toFixed(2)}%.`, 107 | 108 | // SVG/JSON attempts 109 | svgExportAttempts: stats.exportFromSvgAttempts, 110 | jsonExportAttempts: stats.performedExports - stats.exportFromSvgAttempts 111 | }); 112 | }); 113 | } 114 | -------------------------------------------------------------------------------- /lib/server/routes/ui.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import { join } from 'path'; 16 | 17 | import { __dirname } from '../../utils.js'; 18 | 19 | /** 20 | * Adds the GET / route for a UI when enabled on the export server. 21 | */ 22 | export default (app) => 23 | !app 24 | ? false 25 | : app.get('/', (_request, response) => { 26 | response.sendFile(join(__dirname, 'public', 'index.html'), { 27 | acceptRanges: false 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /lib/server/server.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import { promises as fsPromises } from 'fs'; 16 | import { posix } from 'path'; 17 | 18 | import cors from 'cors'; 19 | import express from 'express'; 20 | import http from 'http'; 21 | import https from 'https'; 22 | import multer from 'multer'; 23 | 24 | import errorHandler from './error.js'; 25 | import rateLimit from './rate_limit.js'; 26 | import { log, logWithStack } from '../logger.js'; 27 | import { __dirname } from '../utils.js'; 28 | 29 | import vSwitchRoute from './routes/change_hc_version.js'; 30 | import exportRoutes from './routes/export.js'; 31 | import healthRoute from './routes/health.js'; 32 | import uiRoute from './routes/ui.js'; 33 | 34 | import ExportError from '../errors/ExportError.js'; 35 | 36 | // Array of an active servers 37 | const activeServers = new Map(); 38 | 39 | // Create express app 40 | const app = express(); 41 | 42 | // Disable the X-Powered-By header 43 | app.disable('x-powered-by'); 44 | 45 | // Enable CORS support 46 | app.use(cors()); 47 | 48 | // Getting a lot of RangeNotSatisfiableError exception. 49 | // Even though this is a deprecated options, let's try to set it to false. 50 | app.use((_req, res, next) => { 51 | res.set('Accept-Ranges', 'none'); 52 | next(); 53 | }); 54 | 55 | /** 56 | * Attach error handlers to the server. 57 | * 58 | * @param {http.Server} server - The HTTP/HTTPS server instance. 59 | */ 60 | const attachServerErrorHandlers = (server) => { 61 | server.on('clientError', (error, socket) => { 62 | logWithStack( 63 | 1, 64 | error, 65 | `[server] Client error: ${error.message}, destroying socket.` 66 | ); 67 | socket.destroy(); 68 | }); 69 | 70 | server.on('error', (error) => { 71 | logWithStack(1, error, `[server] Server error: ${error.message}`); 72 | }); 73 | 74 | server.on('connection', (socket) => { 75 | socket.on('error', (error) => { 76 | logWithStack(1, error, `[server] Socket error: ${error.message}`); 77 | }); 78 | }); 79 | }; 80 | 81 | /** 82 | * Starts an HTTP server based on the provided configuration. The `serverConfig` 83 | * object contains all server related properties (see the `server` section 84 | * in the `lib/schemas/config.js` file for a reference). 85 | * 86 | * @param {Object} serverConfig - The server configuration object. 87 | * 88 | * @throws {ExportError} - Throws an error if the server cannot be configured 89 | * and started. 90 | */ 91 | export const startServer = async (serverConfig) => { 92 | try { 93 | // TODO: Read from config/env 94 | // NOTE: 95 | // Too big limits lead to timeouts in the export process when the 96 | // rasterization timeout is set too low. 97 | const uploadLimitMiB = serverConfig.maxUploadSize || 3; 98 | const uploadLimitBytes = uploadLimitMiB * 1024 * 1024; 99 | 100 | // Enable parsing of form data (files) with Multer package 101 | const storage = multer.memoryStorage(); 102 | const upload = multer({ 103 | storage, 104 | limits: { 105 | fieldSize: uploadLimitBytes 106 | } 107 | }); 108 | 109 | // Enable body parser 110 | app.use(express.json({ limit: uploadLimitBytes })); 111 | app.use(express.urlencoded({ extended: true, limit: uploadLimitBytes })); 112 | 113 | // Use only non-file multipart form fields 114 | app.use(upload.none()); 115 | 116 | // Stop if not enabled 117 | if (!serverConfig.enable) { 118 | return false; 119 | } 120 | 121 | // Listen HTTP server 122 | if (!serverConfig.ssl.force) { 123 | // Main server instance (HTTP) 124 | const httpServer = http.createServer(app); 125 | 126 | // Attach error handlers and listen to the server 127 | attachServerErrorHandlers(httpServer); 128 | 129 | // Listen 130 | httpServer.listen(serverConfig.port, serverConfig.host); 131 | 132 | // Save the reference to HTTP server 133 | activeServers.set(serverConfig.port, httpServer); 134 | 135 | log( 136 | 3, 137 | `[server] Started HTTP server on ${serverConfig.host}:${serverConfig.port}.` 138 | ); 139 | } 140 | 141 | // Listen HTTPS server 142 | if (serverConfig.ssl.enable) { 143 | // Set up an SSL server also 144 | let key, cert; 145 | 146 | try { 147 | // Get the SSL key 148 | key = await fsPromises.readFile( 149 | posix.join(serverConfig.ssl.certPath, 'server.key'), 150 | 'utf8' 151 | ); 152 | 153 | // Get the SSL certificate 154 | cert = await fsPromises.readFile( 155 | posix.join(serverConfig.ssl.certPath, 'server.crt'), 156 | 'utf8' 157 | ); 158 | } catch (error) { 159 | log( 160 | 2, 161 | `[server] Unable to load key/certificate from the '${serverConfig.ssl.certPath}' path. Could not run secured layer server.` 162 | ); 163 | } 164 | 165 | if (key && cert) { 166 | // Main server instance (HTTPS) 167 | const httpsServer = https.createServer({ key, cert }, app); 168 | 169 | // Attach error handlers and listen to the server 170 | attachServerErrorHandlers(httpsServer); 171 | 172 | // Listen 173 | httpsServer.listen(serverConfig.ssl.port, serverConfig.host); 174 | 175 | // Save the reference to HTTPS server 176 | activeServers.set(serverConfig.ssl.port, httpsServer); 177 | 178 | log( 179 | 3, 180 | `[server] Started HTTPS server on ${serverConfig.host}:${serverConfig.ssl.port}.` 181 | ); 182 | } 183 | } 184 | 185 | // Enable the rate limiter if config says so 186 | if ( 187 | serverConfig.rateLimiting && 188 | serverConfig.rateLimiting.enable && 189 | ![0, NaN].includes(serverConfig.rateLimiting.maxRequests) 190 | ) { 191 | rateLimit(app, serverConfig.rateLimiting); 192 | } 193 | 194 | // Set up static folder's route 195 | app.use(express.static(posix.join(__dirname, 'public'))); 196 | 197 | // Set up routes 198 | healthRoute(app); 199 | exportRoutes(app); 200 | uiRoute(app); 201 | vSwitchRoute(app); 202 | 203 | // Set up centralized error handler 204 | errorHandler(app); 205 | } catch (error) { 206 | throw new ExportError( 207 | '[server] Could not configure and start the server.' 208 | ).setError(error); 209 | } 210 | }; 211 | 212 | /** 213 | * Closes all servers associated with Express app instance. 214 | */ 215 | export const closeServers = () => { 216 | log(4, `[server] Closing all servers.`); 217 | for (const [port, server] of activeServers) { 218 | server.close(() => { 219 | activeServers.delete(port); 220 | log(4, `[server] Closed server on port: ${port}.`); 221 | }); 222 | } 223 | }; 224 | 225 | /** 226 | * Get all servers associated with Express app instance. 227 | * 228 | * @returns {Array} - Servers associated with Express app instance. 229 | */ 230 | export const getServers = () => activeServers; 231 | 232 | /** 233 | * Enable rate limiting for the server. 234 | * 235 | * @param {Object} limitConfig - Configuration object for rate limiting. 236 | */ 237 | export const enableRateLimiting = (limitConfig) => rateLimit(app, limitConfig); 238 | 239 | /** 240 | * Get the Express instance. 241 | * 242 | * @returns {Object} - The Express instance. 243 | */ 244 | export const getExpress = () => express; 245 | 246 | /** 247 | * Get the Express app instance. 248 | * 249 | * @returns {Object} - The Express app instance. 250 | */ 251 | export const getApp = () => app; 252 | 253 | /** 254 | * Apply middleware(s) to a specific path. 255 | * 256 | * @param {string} path - The path to which the middleware(s) should be applied. 257 | * @param {...Function} middlewares - The middleware functions to be applied. 258 | */ 259 | export const use = (path, ...middlewares) => { 260 | app.use(path, ...middlewares); 261 | }; 262 | 263 | /** 264 | * Set up a route with GET method and apply middleware(s). 265 | * 266 | * @param {string} path - The route path. 267 | * @param {...Function} middlewares - The middleware functions to be applied. 268 | */ 269 | export const get = (path, ...middlewares) => { 270 | app.get(path, ...middlewares); 271 | }; 272 | 273 | /** 274 | * Set up a route with POST method and apply middleware(s). 275 | * 276 | * @param {string} path - The route path. 277 | * @param {...Function} middlewares - The middleware functions to be applied. 278 | */ 279 | export const post = (path, ...middlewares) => { 280 | app.post(path, ...middlewares); 281 | }; 282 | 283 | export default { 284 | startServer, 285 | closeServers, 286 | getServers, 287 | enableRateLimiting, 288 | getExpress, 289 | getApp, 290 | use, 291 | get, 292 | post 293 | }; 294 | -------------------------------------------------------------------------------- /msg/licenseagree.msg: -------------------------------------------------------------------------------- 1 | 2 | Highcharts Export Server 3 | 4 | https://github.com/highcharts/node-export-server 5 | 6 | In order to use this application, Highcharts needs to be downloaded and 7 | embedded. A license is required to use Highcharts if you're a 8 | for-profit, commercial, outfit. 9 | 10 | The license can be viewed here: https://highcharts.com/license 11 | 12 | If you need a licence, one can be gotten here: 13 | https://shop.highsoft.com 14 | -------------------------------------------------------------------------------- /msg/startup.msg: -------------------------------------------------------------------------------- 1 | __ __ __ __ __ 2 | / / / /_ ___ / /_ _____/ /_ ____ ____/ /_ ____ 3 | / /_/ / / __ \/ __ \/ ___/ __ \/ __ \/ __/ __/ ___/ 4 | / __ / / /_/ / / / / /__/ / / / /_/ / / / /_(__ ) 5 | /_/ /_/_/\__ /_/ /_/\___/_/ /_/\__,_/_/ \__/____/ 6 | ___/ / __ _____ 7 | / ___/_ _ ____ ____ ____/ /_ / ___/___ _____ __ __ ____ 8 | / __/ | |/ / __ \/ __ \/ __/ __/ \__ \/ _ \/ __/ | / / _ \/ __/ 9 | / /___ > (http://www.highcharts.com/about)", 4 | "license": "MIT", 5 | "type": "module", 6 | "version": "5.0.0", 7 | "main": "./dist/index.esm.js", 8 | "engines": { 9 | "node": ">=18.12.0" 10 | }, 11 | "exports": { 12 | ".": { 13 | "import": "./dist/index.esm.js", 14 | "require": "./dist/index.cjs" 15 | } 16 | }, 17 | "files": [ 18 | "dist", 19 | "bin", 20 | "templates", 21 | "install.js", 22 | "lib", 23 | "msg", 24 | "public" 25 | ], 26 | "repository": { 27 | "url": "https://github.com/highcharts/node-export-server", 28 | "type": "git" 29 | }, 30 | "bin": { 31 | "highcharts-export-server": "./bin/cli.js" 32 | }, 33 | "scripts": { 34 | "install": "node ./install.js", 35 | "prestart": "rm -rf tmp || del -rf tmp /Q && node ./node_modules/puppeteer/install.mjs", 36 | "start": "node ./bin/cli.js --enableServer 1 --logLevel 2", 37 | "start:dev": "nodemon ./bin/cli.js --enableServer 1 --logLevel 4", 38 | "start:debug": "node --inspect-brk=9229 ./bin/cli.js --enableDebug 1 --enableServer 1 --logLevel 4", 39 | "lint": "eslint ./ --fix", 40 | "cli-tests": "node ./tests/cli/cli_test_runner.js", 41 | "cli-tests-single": "node ./tests/cli/cli_test_runner_single.js", 42 | "http-tests": "node ./tests/http/http_test_runner.js", 43 | "http-tests-single": "node ./tests/http/http_test_runner_single.js", 44 | "node-tests": "node ./tests/node/node_test_runner.js", 45 | "node-tests-single": "node ./tests/node/node_test_runner_single.js", 46 | "prepare": "husky || true", 47 | "prepack": "npm run build", 48 | "build": "rollup -c", 49 | "unit:test": "node --experimental-vm-modules node_modules/jest/bin/jest.js" 50 | }, 51 | "devDependencies": { 52 | "@rollup/plugin-terser": "^0.4.4", 53 | "eslint": "^8.57.0", 54 | "eslint-config-prettier": "^9.1.0", 55 | "eslint-plugin-import": "^2.29.1", 56 | "eslint-plugin-prettier": "^5.1.3", 57 | "husky": "^9.0.11", 58 | "jest": "^29.7.0", 59 | "lint-staged": "^15.2.7", 60 | "nodemon": "^3.1.4", 61 | "prettier": "^3.3.2", 62 | "rollup": "^4.18.0" 63 | }, 64 | "dependencies": { 65 | "colors": "1.4.0", 66 | "cors": "^2.8.5", 67 | "dompurify": "^3.1.5", 68 | "dotenv": "^16.4.5", 69 | "express": "^4.19.2", 70 | "express-rate-limit": "^7.3.1", 71 | "https-proxy-agent": "^7.0.5", 72 | "jsdom": "^24.1.0", 73 | "multer": "^1.4.5-lts.1", 74 | "prompts": "^2.4.2", 75 | "puppeteer": "^22.12.1", 76 | "tarn": "^3.0.2", 77 | "uuid": "^10.0.0", 78 | "zod": "^3.23.8" 79 | }, 80 | "lint-staged": { 81 | "*.js": "npx eslint --cache --fix", 82 | "*.{js,css,md}": "npx prettier --write" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | width: 100%; 4 | height: 100%; 5 | margin: 0; 6 | padding: 0; 7 | font-family: 'Source Sans Pro', sans-serif; 8 | background: #f5f5f5; 9 | } 10 | 11 | label { 12 | display: block; 13 | padding-bottom: 5px; 14 | padding-top: 2em; 15 | clear: both; 16 | } 17 | 18 | input { 19 | width: 100%; 20 | font-size: 20px; 21 | outline: none; 22 | background: #fff; 23 | border: 1px solid #eee; 24 | } 25 | 26 | .button { 27 | cursor: pointer; 28 | margin-top: 10px; 29 | margin-left: 10px; 30 | float: right; 31 | padding-left: 10px; 32 | padding-right: 10px; 33 | padding-top: 5px; 34 | padding-bottom: 5px; 35 | text-align: center; 36 | font-size: 20px; 37 | font-family: 'Source Sans Pro', sans-serif; 38 | background: #4caf50; 39 | color: #242424; 40 | border-radius: 4px; 41 | outline: none; 42 | border: 1px solid #eee; 43 | transition: 0.2s ease all; 44 | -moz-transition: 0.2s ease all; 45 | -webkit-transition: 0.2s ease all; 46 | } 47 | 48 | button:hover { 49 | background: #43a047; 50 | color: #fafafa; 51 | } 52 | 53 | button:active { 54 | background: #e8f5e9; 55 | color: #242424; 56 | } 57 | 58 | .info { 59 | padding-top: 2px; 60 | padding-bottom: 10px; 61 | font-size: 0.8em; 62 | color: gray; 63 | } 64 | 65 | .preview-container { 66 | pointer-events: auto !important; 67 | min-height: 400px; 68 | } 69 | 70 | .preview-container img { 71 | width: 100%; 72 | } 73 | 74 | .codeinput { 75 | width: 100%; 76 | height: 100px; 77 | outline: none; 78 | background: #fff; 79 | border: 1px solid #eee; 80 | } 81 | 82 | .box-size { 83 | box-sizing: border-box !important; 84 | -moz-box-sizing: border-box !important; 85 | -webkit-box-sizing: border-box !important; 86 | } 87 | 88 | .page { 89 | padding-top: 60px; 90 | height: 100%; 91 | width: 100%; 92 | } 93 | 94 | .header { 95 | background: #252530; 96 | height: 60px; 97 | position: fixed; 98 | z-index: 2000; 99 | width: 100%; 100 | top: 0px; 101 | left: 0px; 102 | background-image: url('../img/logo.svg'); 103 | background-position: left center; 104 | background-size: auto 100%; 105 | background-repeat: no-repeat; 106 | } 107 | 108 | .buttons { 109 | position: fixed; 110 | width: 60%; 111 | left: 0px; 112 | bottom: 10px; 113 | } 114 | 115 | .panel-container { 116 | float: left; 117 | padding: 50px; 118 | padding-bottom: 100px; 119 | } 120 | 121 | .panel { 122 | overflow-y: auto; 123 | width: 100%; 124 | height: 100%; 125 | padding: 20px; 126 | background: #fff; 127 | border: 1px solid #eee; 128 | } 129 | 130 | .main-panel { 131 | width: 60%; 132 | height: 100%; 133 | padding-right: 25px; 134 | padding-left: 25px; 135 | } 136 | 137 | .chart-panel { 138 | position: absolute; 139 | top: 60px; 140 | left: 60%; 141 | width: 40%; 142 | height: 50%; 143 | pointer-events: none; 144 | padding-left: 25px; 145 | } 146 | 147 | .chart-frame { 148 | position: absolute; 149 | right: 0px; 150 | top: 60px; 151 | height: 100%; 152 | width: 25%; 153 | } 154 | 155 | h1 { 156 | margin-top: 0px; 157 | display: block; 158 | font-size: 2em; 159 | -webkit-margin-after: 0.67em; 160 | -webkit-margin-start: 0px; 161 | -webkit-margin-end: 0px; 162 | font-weight: bold; 163 | } 164 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/highcharts/node-export-server/15421d34fd4577da19b7505756f76630dd05561e/public/favicon.ico -------------------------------------------------------------------------------- /public/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Highcharts Export Server 6 | 7 | 8 | 10 | 11 | 13 | 14 | 15 | 17 | 19 | 20 | 21 | 22 | 23 |
24 |
25 |
26 |

Highcharts Export Server

27 | 28 |

29 | This page allows you to experiment with different options for the 30 | export server. If you use the public Export Server at 31 | https://export.highcharts.com 32 | you should read our Terms of use and Fair Usage Policy. 34 |

35 | 36 | 37 |
Your Highcharts configuration object.
38 |
39 | 69 | 70 | 71 | 77 | 78 | 79 |
80 | The exact pixel width of the exported image. Defaults to chart.width 81 | or 600px. Maximum width is 2000px. 82 |
83 | 84 | 85 | 86 |
87 | A scaling factor for a higher image resolution. Maximum scaling is 88 | set to 4x. Remember that the width parameter has a higher precedence 89 | over scaling. 90 |
91 | 92 | 93 | 94 |
95 | Either a chart, stockChart, mapChart, or a ganttChart (depending on what product you use). 96 |
97 | 103 |
104 |
105 | 106 |
107 |
108 |

Result Preview

109 |
110 |
Click the Preview button to see a preview.
111 |
112 |
113 |
114 | 115 |
116 | 117 | 118 |
119 |
120 | 121 |
122 | 123 | 124 | 127 | 128 | -------------------------------------------------------------------------------- /public/js/main.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const highexp = {}; 3 | 4 | (function () { 5 | highexp.init = function () { 6 | const options = document.getElementById('options'); 7 | const format = document.getElementById('format'); 8 | const width = document.getElementById('width'); 9 | const scale = document.getElementById('scale'); 10 | const constr = document.getElementById('constr'); 11 | const btnPreview = document.getElementById('preview'); 12 | const btnDownload = document.getElementById('download'); 13 | const preview = document.getElementById('preview-container'); 14 | 15 | const mime = { 16 | 'image/png': 'png', 17 | 'image/jpeg': 'jpg', 18 | 'image/svg+xml': 'xml', 19 | 'application/pdf': 'pdf' 20 | }; 21 | 22 | const optionsCM = CodeMirror.fromTextArea(options, { 23 | lineNumbers: true, 24 | mode: 'javascript' 25 | }); 26 | 27 | function ajax(url, data, yes, no) { 28 | const r = new XMLHttpRequest(); 29 | r.open('post', url, true); 30 | r.setRequestHeader('Content-Type', 'application/json'); 31 | r.onreadystatechange = function () { 32 | if (r.readyState === 4 && r.status === 200) { 33 | if (yes) { 34 | yes(r.responseText); 35 | } 36 | } else if (r.readyState === 4) { 37 | if (no) { 38 | no(r.status, r.responseText); 39 | } 40 | } 41 | }; 42 | r.send(JSON.stringify(data)); 43 | } 44 | 45 | function toStructure(b64) { 46 | return { 47 | infile: optionsCM.getValue(), 48 | width: width.value.length ? width.value : false, 49 | scale: scale.value.length ? scale.value : false, 50 | constr: constr.value, 51 | type: format.value, 52 | b64 53 | }; 54 | } 55 | 56 | btnPreview.onclick = function () { 57 | preview.innerHTML = 58 | '
Processing chart, please wait...
'; 59 | 60 | ajax( 61 | '/', 62 | toStructure(true), 63 | function (data) { 64 | const embed = document.createElement('embed'); 65 | embed.className = 'box-size'; 66 | 67 | if (format.value === 'image/png' || format.value === 'image/jpeg') { 68 | preview.innerHTML = 69 | ''; 70 | } else if (format.value === 'image/svg+xml') { 71 | preview.innerHTML = 72 | ''; 73 | } else if (format.value === 'application/pdf') { 74 | preview.innerHTML = ''; 75 | try { 76 | embed.src = 'data:application/pdf;base64,' + data; 77 | embed.style.width = '100%'; 78 | embed.style.height = document.body.clientHeight - 280 + 'px'; 79 | preview.appendChild(embed); 80 | } catch (e) { 81 | preview.innerHTML = e; 82 | } 83 | } 84 | }, 85 | function (r, txt) { 86 | if (r == 429) { 87 | preview.innerHTML = 88 | '
Too many requests - please try again later
'; 89 | } else { 90 | preview.innerHTML = 91 | '
Error when processing chart: ' + 92 | txt + 93 | '
'; 94 | } 95 | } 96 | ); 97 | }; 98 | 99 | btnDownload.onclick = function () { 100 | ajax( 101 | '/', 102 | toStructure(true), 103 | function (data) { 104 | const l = document.createElement('a'); 105 | l.download = 'chart.' + mime[format.value]; 106 | l.href = 'data:' + format.value + ';base64,' + data; 107 | document.body.appendChild(l); 108 | l.click(); 109 | document.body.removeChild(l); 110 | }, 111 | function () {} 112 | ); 113 | }; 114 | }; 115 | })(); 116 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | // rollup plugin for minifying the code 2 | import terser from '@rollup/plugin-terser'; 3 | 4 | // exporting the rollup config 5 | export default { 6 | input: 'lib/index.js', 7 | output: [ 8 | { 9 | file: 'dist/index.esm.js', 10 | format: 'es', 11 | sourcemap: true 12 | }, 13 | { 14 | file: 'dist/index.cjs', 15 | format: 'cjs', 16 | sourcemap: 'inline' 17 | } 18 | ], 19 | plugins: [terser()] 20 | }; 21 | -------------------------------------------------------------------------------- /samples/batch/batch_1.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": { 3 | "text": "Batch no. 1" 4 | }, 5 | "xAxis": { 6 | "categories": ["Jan", "Feb", "Mar", "Apr"] 7 | }, 8 | "series": [ 9 | { 10 | "type": "line", 11 | "data": [1, 2, 3, 4] 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /samples/batch/batch_2.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": { 3 | "text": "Batch no. 2" 4 | }, 5 | "xAxis": { 6 | "categories": ["Jan", "Feb", "Mar", "Apr"] 7 | }, 8 | "series": [ 9 | { 10 | "type": "column", 11 | "data": [1, 2, 3, 4] 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /samples/batch/batch_3.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": { 3 | "text": "Batch no. 3" 4 | }, 5 | "xAxis": { 6 | "categories": ["Jan", "Feb", "Mar", "Apr"] 7 | }, 8 | "series": [ 9 | { 10 | "type": "scatter", 11 | "data": [1, 2, 3, 4] 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /samples/cli/custom_options.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "height": 600, 4 | "width": 600, 5 | "scale": 2, 6 | "options": { 7 | "title": { 8 | "text": "From custom JSON infile (--loadConfig option)" 9 | }, 10 | "xAxis": { 11 | "categories": ["Jan", "Feb", "Mar", "Apr"] 12 | }, 13 | "series": [ 14 | { 15 | "type": "column", 16 | "data": [5, 6, 7, 8] 17 | }, 18 | { 19 | "type": "line", 20 | "data": [1, 2, 3, 4] 21 | } 22 | ] 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /samples/cli/infile_json.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": { 3 | "text": "From JSON infile" 4 | }, 5 | "xAxis": { 6 | "categories": ["Jan", "Feb", "Mar", "Apr"] 7 | }, 8 | "series": [ 9 | { 10 | "type": "column", 11 | "data": [5, 6, 7, 8] 12 | }, 13 | { 14 | "type": "line", 15 | "data": [1, 2, 3, 4] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /samples/cli/infile_not_json.json: -------------------------------------------------------------------------------- 1 | { 2 | "xAxis": { 3 | "categories": ["Jan", "Feb", "Mar", "Apr"], 4 | "labels": { 5 | "formatter": function () { 6 | return `Label - ${this.value}`; 7 | } 8 | } 9 | }, 10 | "series": [ 11 | { 12 | "type": "line", 13 | "data": [1, 2, 3, 4] 14 | }, 15 | { 16 | "type": "line", 17 | "data": [5, 6, 7, 8] 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /samples/http/request_infile.json: -------------------------------------------------------------------------------- 1 | { 2 | "infile": { 3 | "chart": { 4 | "type": "column" 5 | }, 6 | "title": { 7 | "text": "Styling axes" 8 | }, 9 | "yAxis": [ 10 | { 11 | "className": "highcharts-color-0", 12 | "title": { 13 | "text": "Primary axis" 14 | } 15 | }, 16 | { 17 | "className": "highcharts-color-1", 18 | "opposite": true, 19 | "title": { 20 | "text": "Secondary axis" 21 | } 22 | } 23 | ], 24 | "series": [ 25 | { 26 | "data": [1, 3, 2, 4] 27 | }, 28 | { 29 | "data": [324, 124, 547, 221], 30 | "yAxis": 1 31 | } 32 | ] 33 | }, 34 | "type": "png", 35 | "scale": 2, 36 | "width": 800, 37 | "height": 800, 38 | "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();}", 39 | "resources": { 40 | "js": "Highcharts.charts[0].update({title: {text: 'Resources title'}});", 41 | "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;}" 42 | }, 43 | "constr": "chart", 44 | "b64": false, 45 | "noDownload": false, 46 | "globalOptions": { 47 | "chart": { 48 | "borderWidth": 2, 49 | "plotBackgroundColor": "rgba(255, 255, 255, .9)", 50 | "plotShadow": true, 51 | "plotBorderWidth": 1 52 | }, 53 | "subtitle": { 54 | "text": "Global options subtitle" 55 | } 56 | }, 57 | "themeOptions": { 58 | "colors": [ 59 | "#058DC7", 60 | "#50B432", 61 | "#ED561B", 62 | "#DDDF00", 63 | "#24CBE5", 64 | "#64E572", 65 | "#FF9655", 66 | "#FFF263", 67 | "#6AF9C4" 68 | ], 69 | "chart": { 70 | "backgroundColor": { 71 | "linearGradient": [0, 0, 500, 500], 72 | "stops": [ 73 | [0, "rgb(255, 255, 255)"], 74 | [1, "rgb(240, 240, 255)"] 75 | ] 76 | } 77 | }, 78 | "title": { 79 | "style": { 80 | "color": "#000", 81 | "font": "bold 16px Trebuchet MS, Verdana, sans-serif" 82 | } 83 | }, 84 | "subtitle": { 85 | "text": "Theme options subtitle", 86 | "style": { 87 | "color": "#666666", 88 | "font": "bold 12px Trebuchet MS, Verdana, sans-serif" 89 | } 90 | }, 91 | "legend": { 92 | "itemStyle": { 93 | "font": "9pt Trebuchet MS, Verdana, sans-serif", 94 | "color": "black" 95 | } 96 | } 97 | }, 98 | "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();}}}});}" 99 | } 100 | -------------------------------------------------------------------------------- /samples/module/options_phantomjs.js: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'fs'; 2 | 3 | import exporter from '../../lib/index.js'; 4 | 5 | // Export settings with the old options structure (PhantomJS) 6 | // Will be mapped appropriately to the new structure with the mapToNewConfig utility 7 | const exportSettings = { 8 | type: 'png', 9 | constr: 'chart', 10 | outfile: './samples/module/options_phantom.jpeg', 11 | logLevel: 4, 12 | scale: 1, 13 | workers: 1, 14 | options: { 15 | chart: { 16 | type: 'column' 17 | }, 18 | title: { 19 | text: 'PhantomJS options structure' 20 | }, 21 | xAxis: { 22 | categories: ['Jan', 'Feb', 'Mar', 'Apr'] 23 | }, 24 | yAxis: [ 25 | { 26 | title: { 27 | text: 'Primary axis' 28 | } 29 | }, 30 | { 31 | opposite: true, 32 | title: { 33 | text: 'Secondary axis' 34 | } 35 | } 36 | ], 37 | series: [ 38 | { 39 | yAxis: 0, 40 | data: [1, 3, 2, 4] 41 | }, 42 | { 43 | yAxis: 1, 44 | data: [5, 3, 4, 2] 45 | } 46 | ] 47 | } 48 | }; 49 | 50 | const start = async () => { 51 | try { 52 | // Map to fit the new options structure 53 | const mappedOptions = exporter.mapToNewConfig(exportSettings); 54 | 55 | // Set the new options 56 | const options = exporter.setOptions(mappedOptions); 57 | 58 | // Init a pool for one export 59 | await exporter.initExport(options); 60 | 61 | // Perform an export 62 | await exporter.startExport(options, async (error, info) => { 63 | // Exit process and display error 64 | if (error) { 65 | throw error; 66 | } 67 | const { outfile, type } = info.options.export; 68 | 69 | // Save the base64 from a buffer to a correct image file 70 | writeFileSync( 71 | outfile, 72 | type !== 'svg' ? Buffer.from(info.result, 'base64') : info.result 73 | ); 74 | 75 | // Kill the pool 76 | await exporter.killPool(); 77 | }); 78 | } catch (error) { 79 | // Log the error with stack 80 | exporter.logWithStack(1, error); 81 | 82 | // Gracefully shut down the process 83 | await exporter.shutdownCleanUp(1); 84 | } 85 | }; 86 | 87 | start(); 88 | -------------------------------------------------------------------------------- /samples/module/options_puppeteer.js: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'fs'; 2 | 3 | import exporter from '../../lib/index.js'; 4 | 5 | // Export settings with new options structure (Puppeteer) 6 | const exportSettings = { 7 | pool: { 8 | minWorkers: 1, 9 | maxWorkers: 1 10 | }, 11 | export: { 12 | type: 'jpeg', 13 | constr: 'chart', 14 | outfile: './samples/module/options_puppeteer.jpeg', 15 | height: 800, 16 | width: 1200, 17 | scale: 1, 18 | options: { 19 | chart: { 20 | type: 'column' 21 | }, 22 | title: { 23 | text: 'Puppeteer options structure' 24 | }, 25 | xAxis: { 26 | categories: ['Jan', 'Feb', 'Mar', 'Apr'] 27 | }, 28 | yAxis: [ 29 | { 30 | className: 'highcharts-color-0', 31 | title: { 32 | text: 'Primary axis' 33 | } 34 | }, 35 | { 36 | className: 'highcharts-color-1', 37 | opposite: true, 38 | title: { 39 | text: 'Secondary axis' 40 | } 41 | } 42 | ], 43 | plotOptions: { 44 | series: { 45 | dataLabels: { 46 | enabled: true, 47 | allowOverlap: true, 48 | formatter: function () { 49 | return `${this.series.name}: ${this.y}`; 50 | } 51 | } 52 | } 53 | }, 54 | series: [ 55 | { 56 | yAxis: 0, 57 | data: [1, 3, 2, 4] 58 | }, 59 | { 60 | yAxis: 1, 61 | data: [5, 3, 4, 2] 62 | } 63 | ] 64 | }, 65 | globalOptions: { 66 | chart: { 67 | borderWidth: 2, 68 | plotBackgroundColor: 'rgba(255, 255, 255, .9)', 69 | plotBorderWidth: 1, 70 | plotShadow: true 71 | }, 72 | subtitle: { 73 | text: 'Global options subtitle' 74 | } 75 | }, 76 | themeOptions: { 77 | colors: [ 78 | '#058DC7', 79 | '#50B432', 80 | '#ED561B', 81 | '#DDDF00', 82 | '#24CBE5', 83 | '#64E572', 84 | '#FF9655', 85 | '#FFF263', 86 | '#6AF9C4' 87 | ], 88 | chart: { 89 | backgroundColor: { 90 | linearGradient: [0, 0, 500, 500], 91 | stops: [ 92 | [0, 'rgb(123, 142, 200)'], 93 | [1, 'rgb(156, 122, 213)'] 94 | ] 95 | } 96 | }, 97 | subtitle: { 98 | text: 'Theme options subtitle', 99 | style: { 100 | color: '#666666', 101 | font: 'bold 12px Trebuchet MS, Verdana, sans-serif' 102 | } 103 | }, 104 | legend: { 105 | itemStyle: { 106 | font: '9pt Trebuchet MS, Verdana, sans-serif', 107 | color: 'black' 108 | } 109 | } 110 | } 111 | }, 112 | customLogic: { 113 | allowCodeExecution: true, 114 | allowFileResources: true, 115 | callback: './samples/resources/callback.js', 116 | customCode: './samples/resources/custom_code.js', 117 | resources: { 118 | js: "Highcharts.charts[0].update({xAxis: {title: {text: 'Resources axis title'}}});", 119 | css: '.highcharts-yaxis .highcharts-axis-line { stroke-width: 2px; } .highcharts-color-0 { fill: #f7a35c; stroke: #f7a35c; }' 120 | } 121 | } 122 | }; 123 | 124 | const start = async () => { 125 | try { 126 | // Set the new options 127 | const options = exporter.setOptions(exportSettings); 128 | 129 | // Init a pool for one export 130 | await exporter.initExport(options); 131 | 132 | // Perform an export 133 | await exporter.startExport(options, async (error, info) => { 134 | // Exit process and display error 135 | if (error) { 136 | throw error; 137 | } 138 | const { outfile, type } = info.options.export; 139 | 140 | // Save the base64 from a buffer to a correct image file 141 | writeFileSync( 142 | outfile, 143 | type !== 'svg' ? Buffer.from(info.result, 'base64') : info.result 144 | ); 145 | 146 | // Kill the pool 147 | await exporter.killPool(); 148 | }); 149 | } catch (error) { 150 | // Log the error with stack 151 | exporter.logWithStack(1, error); 152 | 153 | // Gracefully shut down the process 154 | await exporter.shutdownCleanUp(1); 155 | } 156 | }; 157 | 158 | start(); 159 | -------------------------------------------------------------------------------- /samples/module/promises.js: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'fs'; 2 | 3 | import exporter from '../../lib/index.js'; 4 | 5 | const exportCharts = async (charts, exportOptions = {}) => { 6 | // Set the new options 7 | const options = exporter.setOptions(exportOptions); 8 | 9 | // Init the pool 10 | await exporter.initExport(options); 11 | 12 | const promises = []; 13 | const chartResults = []; 14 | 15 | // Start exporting charts 16 | charts.forEach((chart) => { 17 | promises.push( 18 | new Promise((resolve, reject) => { 19 | const settings = { ...options }; 20 | settings.export.options = chart; 21 | 22 | exporter.startExport(settings, (error, info) => { 23 | if (error) { 24 | return reject(error); 25 | } 26 | 27 | // Add the data to the chartResults 28 | chartResults.push(info.result); 29 | resolve(); 30 | }); 31 | }) 32 | ); 33 | }); 34 | 35 | return Promise.all(promises) 36 | .then(async () => { 37 | await exporter.killPool(); 38 | return Promise.resolve(chartResults); 39 | }) 40 | .catch(async (error) => { 41 | await exporter.killPool(); 42 | return Promise.reject(error); 43 | }); 44 | }; 45 | 46 | // Export a couple of charts 47 | exportCharts( 48 | [ 49 | { 50 | title: { 51 | text: 'Chart 1' 52 | }, 53 | series: [ 54 | { 55 | data: [1, 2, 3] 56 | } 57 | ] 58 | }, 59 | { 60 | title: { 61 | text: 'Chart 2' 62 | }, 63 | series: [ 64 | { 65 | data: [3, 2, 1] 66 | } 67 | ] 68 | } 69 | ], 70 | { 71 | pool: { 72 | minWorkers: 2, 73 | maxWorkers: 2 74 | }, 75 | logging: { 76 | level: 4 77 | } 78 | } 79 | ) 80 | .then((charts) => { 81 | // Result of export is in charts, which is an array of base64 encoded files 82 | charts.forEach((chart, index) => { 83 | // Save the base64 from a buffer to a correct image file 84 | writeFileSync( 85 | `./samples/module/promise_${index + 1}.jpeg`, 86 | Buffer.from(chart, 'base64') 87 | ); 88 | }); 89 | exporter.log(4, 'All done!'); 90 | }) 91 | .catch((error) => { 92 | exporter.logWithStack(1, error, 'Something went wrong!'); 93 | }); 94 | -------------------------------------------------------------------------------- /samples/resources/callback.js: -------------------------------------------------------------------------------- 1 | function callback(chart) { 2 | chart.renderer 3 | .label( 4 | 'This label is added in the callback.
Highcharts version ' + 5 | // eslint-disable-next-line no-undef 6 | Highcharts.version, 7 | 75, 8 | 75 9 | ) 10 | .attr({ 11 | id: 'renderer-callback-label', 12 | fill: '#90ed7d', 13 | padding: 10, 14 | r: 10, 15 | zIndex: 10 16 | }) 17 | .css({ 18 | color: 'black', 19 | width: '100px' 20 | }) 21 | .add(); 22 | } 23 | -------------------------------------------------------------------------------- /samples/resources/custom_code.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | Highcharts.setOptions({ 3 | chart: { 4 | events: { 5 | render: function () { 6 | this.renderer 7 | .image( 8 | 'https://www.highcharts.com/samples/graphics/sun.png', 9 | 75, 10 | 50, 11 | 20, 12 | 20 13 | ) 14 | .add(); 15 | } 16 | } 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /samples/resources/options_global.json: -------------------------------------------------------------------------------- 1 | { 2 | "chart": { 3 | "borderWidth": 2, 4 | "plotBackgroundColor": "rgba(255, 61, 61, .9)", 5 | "plotShadow": true, 6 | "plotBorderWidth": 1 7 | }, 8 | "subtitle": { 9 | "text": "Global options subtitle" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /samples/resources/options_theme.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors": [ 3 | "#058DC7", 4 | "#50B432", 5 | "#ED561B", 6 | "#DDDF00", 7 | "#24CBE5", 8 | "#64E572", 9 | "#FF9655", 10 | "#FFF263", 11 | "#6AF9C4" 12 | ], 13 | "chart": { 14 | "backgroundColor": { 15 | "linearGradient": [0, 0, 500, 500], 16 | "stops": [ 17 | [0, "rgb(255, 255, 255)"], 18 | [1, "rgb(240, 240, 255)"] 19 | ] 20 | } 21 | }, 22 | "title": { 23 | "style": { 24 | "color": "#000", 25 | "font": "bold 16px Trebuchet MS, Verdana, sans-serif" 26 | } 27 | }, 28 | "subtitle": { 29 | "text": "Theme options subtitle", 30 | "style": { 31 | "color": "#666666", 32 | "font": "bold 12px Trebuchet MS, Verdana, sans-serif" 33 | } 34 | }, 35 | "legend": { 36 | "itemStyle": { 37 | "font": "9pt Trebuchet MS, Verdana, sans-serif", 38 | "color": "black" 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /samples/resources/resources.json: -------------------------------------------------------------------------------- 1 | { 2 | "js": "Highcharts.charts[0].update({xAxis:{title:{text:'Title from the resources file, js section'}}});", 3 | "css": ".highcharts-yaxis .highcharts-axis-line{stroke-width:2px;stroke:#00FF00}", 4 | "files": [ 5 | "./samples/resources/resources_file_1.js", 6 | "./samples/resources/resources_file_2.js" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /samples/resources/resources_file_1.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | Highcharts.charts[0].update({ 3 | xAxis: [ 4 | { 5 | tickPositioner: function () { 6 | return [0, 3]; 7 | } 8 | } 9 | ] 10 | }); 11 | -------------------------------------------------------------------------------- /samples/resources/resources_file_2.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | Highcharts.charts[0].update({ 3 | chart: { 4 | plotBackgroundColor: { 5 | linearGradient: [0, 0, 500, 500], 6 | stops: [ 7 | [0, 'rgb(0, 122, 255)'], 8 | [1, 'rgb(255, 122, 0)'] 9 | ] 10 | } 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /templates/svg_export/css.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | export default () => ` 16 | 17 | html, body { 18 | margin: 0; 19 | padding: 0; 20 | box-sizing: border-box; 21 | } 22 | 23 | #table-div, #sliders, #datatable, #controls, .ld-row { 24 | display: none; 25 | height: 0; 26 | } 27 | 28 | #chart-container { 29 | box-sizing: border-box; 30 | margin: 0; 31 | overflow: auto; 32 | font-size: 0; 33 | } 34 | 35 | #chart-container > figure, div { 36 | margin-top: 0 !important; 37 | margin-bottom: 0 !important; 38 | } 39 | 40 | `; 41 | -------------------------------------------------------------------------------- /templates/svg_export/svg_export.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import cssTemplate from './css.js'; 16 | 17 | export default (chart) => ` 18 | 19 | 20 | 21 | 22 | Highcharts Export 23 | 24 | 27 | 28 |
29 | ${chart} 30 |
31 | 32 | 33 | 34 | `; 35 | -------------------------------------------------------------------------------- /templates/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Highcharts Export 6 | 35 | 36 | 37 | 38 |
39 |
40 |
41 | 42 | 43 | -------------------------------------------------------------------------------- /tests/cli/cli_test_runner.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import { exec } from 'child_process'; 16 | import { existsSync, mkdirSync, readdirSync, readFileSync } from 'fs'; 17 | import { join } from 'path'; 18 | import { promisify } from 'util'; 19 | 20 | import 'colors'; 21 | 22 | import { __dirname } from '../../lib/utils.js'; 23 | 24 | // Convert from callback to promise 25 | const spawn = promisify(exec); 26 | 27 | // Test runner message 28 | console.log( 29 | 'Highcharts Export Server CLI Test Runner'.yellow.bold.underline, 30 | '\nThis tool simulates the CLI commands sent to Highcharts Export Server.' 31 | .green, 32 | '\nLoads all JSON files from the ./tests/cli folder and runs them sequentially.' 33 | .green, 34 | '\nThe results are stored in the ./tests/cli/_results.\n'.green 35 | ); 36 | 37 | // Results and scenarios paths 38 | const resultsPath = join(__dirname, 'tests', 'cli', '_results'); 39 | const scenariosPath = join(__dirname, 'tests', 'cli', 'scenarios'); 40 | 41 | // Create results folder for CLI exports if doesn't exist 42 | !existsSync(resultsPath) && mkdirSync(resultsPath); 43 | 44 | // Get files names 45 | const files = readdirSync(scenariosPath); 46 | 47 | // Tests counters 48 | let testCounter = 0; 49 | let failsCounter = 0; 50 | 51 | for (const file of files.filter((file) => file.endsWith('.json'))) { 52 | // For a separate CLI command trigger 53 | await (async (file) => { 54 | try { 55 | console.log('[Test runner]'.blue, `Processing test ${file}.`); 56 | 57 | // Read a CLI file 58 | const cliJson = JSON.parse(readFileSync(join(scenariosPath, file))); 59 | 60 | // No need for that when doing export through the --batch option 61 | if (!cliJson.batch) { 62 | // If needed, prepare default outfile 63 | cliJson.outfile = join( 64 | resultsPath, 65 | cliJson.outfile || file.replace('.json', `.${cliJson.type || 'png'}`) 66 | ); 67 | } 68 | 69 | // Complete the CLI command 70 | let cliCommand = []; 71 | 72 | // Check if run in debug mode 73 | cliCommand.push('node', './bin/cli.js'); 74 | 75 | // Cycle through commands with value 76 | for (const [argument, value] of Object.entries(cliJson)) { 77 | cliCommand.push(`--${argument}`, JSON.stringify(value)); 78 | } 79 | 80 | // Complete the CLI command 81 | cliCommand = cliCommand.join(' '); 82 | 83 | // The start date of a CLI command 84 | const startDate = new Date().getTime(); 85 | 86 | let didFail = false; 87 | try { 88 | // Launch command as a new child process 89 | await spawn(cliCommand); 90 | } catch (error) { 91 | failsCounter++; 92 | didFail = true; 93 | } 94 | testCounter++; 95 | 96 | const endMessage = `CLI command from file: ${file}, took ${ 97 | new Date().getTime() - startDate 98 | }ms.`; 99 | 100 | console.log( 101 | didFail ? `[Fail] ${endMessage}`.red : `[Success] ${endMessage}`.green, 102 | '\n' 103 | ); 104 | } catch (error) { 105 | console.error(error); 106 | } 107 | })(file); 108 | } 109 | 110 | // Display the results in numbers 111 | console.log( 112 | '--------------------------------', 113 | failsCounter 114 | ? `\n${testCounter} tests done, ${failsCounter} error(s) found!`.red 115 | : `\n${testCounter} tests done, errors not found!`.green, 116 | '\n--------------------------------' 117 | ); 118 | -------------------------------------------------------------------------------- /tests/cli/cli_test_runner_single.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import { exec as spawn } from 'child_process'; 16 | import { existsSync, mkdirSync, readFileSync } from 'fs'; 17 | import { basename, join } from 'path'; 18 | 19 | import 'colors'; 20 | 21 | import { __dirname } from '../../lib/utils.js'; 22 | 23 | // Test runner message 24 | console.log( 25 | 'Highcharts Export Server CLI Test Runner'.yellow.bold.underline, 26 | '\nThis tool simulates the CLI commands to Highcharts Export Server.'.green, 27 | '\nLoads a specified JSON file and runs it'.green, 28 | '(results are stored in the ./tests/cli/_results).\n'.green 29 | ); 30 | 31 | // Results and scenarios paths 32 | const resultsPath = join(__dirname, 'tests', 'cli', '_results'); 33 | 34 | // Create results folder for CLI exports if doesn't exist 35 | !existsSync(resultsPath) && mkdirSync(resultsPath); 36 | 37 | // Get the file's name 38 | const file = process.argv[2]; 39 | 40 | // Check if file even exists and if it is a JSON 41 | if (existsSync(file) && file.endsWith('.json')) { 42 | try { 43 | console.log('[Test runner]'.blue, `Processing test ${file}.`); 44 | 45 | // Read a CLI file 46 | const cliJson = JSON.parse(readFileSync(file)); 47 | 48 | // No need for that when doing export through the --batch option 49 | if (!cliJson.batch) { 50 | // If needed, prepare default outfile 51 | cliJson.outfile = join( 52 | resultsPath, 53 | cliJson.outfile || 54 | basename(file).replace('.json', `.${cliJson.type || 'png'}`) 55 | ); 56 | } 57 | 58 | // Complete the CLI command 59 | let cliCommand = []; 60 | 61 | // Check if run in debug mode 62 | cliCommand.push('node', './bin/cli.js'); 63 | 64 | // Cycle through commands with value 65 | for (const [argument, value] of Object.entries(cliJson)) { 66 | cliCommand.push(`--${argument}`, JSON.stringify(value)); 67 | } 68 | 69 | // Complete the CLI command 70 | cliCommand = cliCommand.join(' '); 71 | 72 | // The start date of a CLI command 73 | const startDate = new Date().getTime(); 74 | 75 | // Launch command in a new process 76 | spawn(cliCommand); 77 | 78 | // Close event for a process 79 | process.on('exit', (code) => { 80 | const endMessage = `CLI command from file: ${file}, took ${ 81 | new Date().getTime() - startDate 82 | }ms.`; 83 | 84 | // If code is 1, it means that export server thrown an error 85 | if (code === 1) { 86 | return console.error(`[Fail] ${endMessage}`.red); 87 | } 88 | 89 | console.log(`[Success] ${endMessage}`.green); 90 | }); 91 | } catch (error) { 92 | console.error(error); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/cli/error_scenarios/do_not_allow_code_execution_and_file_resources.json: -------------------------------------------------------------------------------- 1 | { 2 | "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]}]}", 3 | "outfile": "allow_code_execution_stringified.png", 4 | "allowCodeExecution": false, 5 | "allowFileResources": false, 6 | "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();}", 7 | "customCode": "./samples/resources/custom_code.js", 8 | "resources": "./samples/resources/resources.js" 9 | } 10 | -------------------------------------------------------------------------------- /tests/cli/scenarios/allow_code_execution_and_file_resources.json: -------------------------------------------------------------------------------- 1 | { 2 | "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]}]}", 3 | "allowCodeExecution": true, 4 | "allowFileResources": true, 5 | "callback": "./samples/resources/callback.js", 6 | "customCode": "Highcharts.setOptions({chart:{events:{render:function (){this.renderer.image('https://www.highcharts.com/samples/graphics/sun.png',75,50,20,20).add();}}}});", 7 | "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/resources_file_1.js\",\"./samples/resources/resources_file_2.js\"]}" 8 | } 9 | -------------------------------------------------------------------------------- /tests/cli/scenarios/batch.json: -------------------------------------------------------------------------------- 1 | { 2 | "batch": "./samples/batch/batch_1.json=./tests/cli/_results/batch_1.png;./samples/batch/batch_2.json=./tests/cli/_results/batch_2.png;./samples/batch/batch_3.json=./tests/cli/_results/batch_3.png;" 3 | } 4 | -------------------------------------------------------------------------------- /tests/cli/scenarios/constr.json: -------------------------------------------------------------------------------- 1 | { 2 | "instr": "{\"title\":{\"text\":\"Stock chart constr\"},\"series\":[{\"type\":\"column\",\"data\":[5,6,7,8]},{\"type\":\"line\",\"data\":[1,2,3,4]}]}", 3 | "constr": "stockChart" 4 | } 5 | -------------------------------------------------------------------------------- /tests/cli/scenarios/global_and_theme_from_files.json: -------------------------------------------------------------------------------- 1 | { 2 | "instr": "{\"title\":{\"text\":\"Global and theme options from files\"},\"xAxis\":{\"categories\":[\"Jan\",\"Feb\",\"Mar\",\"Apr\"]},\"series\":[{\"type\":\"column\",\"data\":[5,6,7,8]},{\"type\":\"line\",\"data\":[1,2,3,4]}]}", 3 | "globalOptions": "./samples/resources/options_global.json", 4 | "themeOptions": "./samples/resources/options_theme.json" 5 | } 6 | -------------------------------------------------------------------------------- /tests/cli/scenarios/global_and_theme_stringified.json: -------------------------------------------------------------------------------- 1 | { 2 | "instr": "{\"title\":{\"text\":\"Stringified global and theme options\"},\"xAxis\":{\"categories\":[\"Jan\",\"Feb\",\"Mar\",\"Apr\"]},\"series\":[{\"type\":\"column\",\"data\":[5,6,7,8]},{\"type\":\"line\",\"data\":[1,2,3,4]}]}", 3 | "globalOptions": "{\"chart\":{\"borderWidth\":2,\"plotBackgroundColor\":\"rgba(61,61,255,.9)\",\"plotShadow\":true,\"plotBorderWidth\":1},\"subtitle\":{\"text\":\"Global options subtitle\"}}", 4 | "themeOptions": "{\"colors\":[\"#058DC7\",\"#50B432\",\"#ED561B\",\"#DDDF00\",\"#24CBE5\",\"#64E572\",\"#FF9655\",\"#FFF263\",\"#6AF9C4\"],\"chart\":{\"backgroundColor\":{\"linearGradient\":[0,0,500,500],\"stops\":[[0,\"rgb(255,255,255)\"],[1,\"rgb(240,240,255)\"]]}},\"title\":{\"style\":{\"color\":\"#000\",\"font\":\"bold 16px Trebuchet MS, Verdana, sans-serif\"}},\"subtitle\":{\"text\":\"Theme options subtitle\",\"style\":{\"color\":\"#666666\",\"font\":\"bold 12px Trebuchet MS, Verdana, sans-serif\"}},\"legend\":{\"itemStyle\":{\"font\":\"9pt Trebuchet MS, Verdana, sans-serif\",\"color\":\"black\"}}}" 5 | } 6 | -------------------------------------------------------------------------------- /tests/cli/scenarios/height_width_scale.json: -------------------------------------------------------------------------------- 1 | { 2 | "instr": "{\"title\":{\"text\":\"Height, width, scale\"},\"xAxis\":{\"categories\":[\"Jan\",\"Feb\",\"Mar\",\"Apr\"]},\"series\":[{\"type\":\"column\",\"data\":[5,6,7,8]},{\"type\":\"line\",\"data\":[1,2,3,4]}]}", 3 | "height": 800, 4 | "width": 1000, 5 | "scale": 2 6 | } 7 | -------------------------------------------------------------------------------- /tests/cli/scenarios/infile_json.json: -------------------------------------------------------------------------------- 1 | { 2 | "infile": "./samples/cli/infile_json.json" 3 | } 4 | -------------------------------------------------------------------------------- /tests/cli/scenarios/infile_svg.json: -------------------------------------------------------------------------------- 1 | { 2 | "infile": "./samples/cli/svg_basic.svg" 3 | } 4 | -------------------------------------------------------------------------------- /tests/cli/scenarios/infile_svg_with_scale.json: -------------------------------------------------------------------------------- 1 | { 2 | "infile": "./samples/cli/svg_basic.svg", 3 | "scale": 3 4 | } 5 | -------------------------------------------------------------------------------- /tests/cli/scenarios/infile_svg_with_scale_to_pdf.json: -------------------------------------------------------------------------------- 1 | { 2 | "infile": "./samples/cli/svg_basic.svg", 3 | "type": "pdf", 4 | "scale": 3 5 | } 6 | -------------------------------------------------------------------------------- /tests/cli/scenarios/instr.json: -------------------------------------------------------------------------------- 1 | { 2 | "instr": "{\"title\":{\"text\":\"From instr\"},\"xAxis\":{\"categories\":[\"Jan\",\"Feb\",\"Mar\",\"Apr\"]},\"series\":[{\"type\":\"column\",\"data\":[5,6,7,8]},{\"type\":\"line\",\"data\":[1,2,3,4]}]}" 3 | } 4 | -------------------------------------------------------------------------------- /tests/cli/scenarios/load_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "loadConfig": "./samples/cli/custom_options.json" 3 | } 4 | -------------------------------------------------------------------------------- /tests/cli/scenarios/options.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": "{\"title\":{\"text\":\"From options\"},\"xAxis\":{\"categories\":[\"Jan\",\"Feb\",\"Mar\",\"Apr\"]},\"series\":[{\"type\":\"column\",\"data\":[5,6,7,8]},{\"type\":\"line\",\"data\":[1,2,3,4]}]}" 3 | } 4 | -------------------------------------------------------------------------------- /tests/cli/scenarios/outfile.json: -------------------------------------------------------------------------------- 1 | { 2 | "instr": "{\"title\":{\"text\":\"Outfile JPEG\"},\"xAxis\":{\"categories\":[\"Jan\",\"Feb\",\"Mar\",\"Apr\"]},\"series\":[{\"type\":\"column\",\"data\":[5,6,7,8]},{\"type\":\"line\",\"data\":[1,2,3,4]}]}", 3 | "outfile": "outfile_custom.jpeg" 4 | } 5 | -------------------------------------------------------------------------------- /tests/cli/scenarios/type.json: -------------------------------------------------------------------------------- 1 | { 2 | "instr": "{\"title\":{\"text\":\"PDF type\"},\"xAxis\":{\"categories\":[\"Jan\",\"Feb\",\"Mar\",\"Apr\"]},\"series\":[{\"type\":\"column\",\"data\":[5,6,7,8]},{\"type\":\"line\",\"data\":[1,2,3,4]}]}", 3 | "type": "pdf" 4 | } 5 | -------------------------------------------------------------------------------- /tests/http/http_test_runner.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import { 16 | createWriteStream, 17 | existsSync, 18 | mkdirSync, 19 | readdirSync, 20 | readFileSync 21 | } from 'fs'; 22 | import http from 'http'; 23 | import { join } from 'path'; 24 | 25 | import 'colors'; 26 | 27 | import { fetch } from '../../lib/fetch.js'; 28 | import { __dirname, clearText } from '../../lib/utils.js'; 29 | 30 | // Test runner message 31 | console.log( 32 | 'Highcharts Export Server HTTP Requests Test Runner'.yellow.bold.underline, 33 | '\nThis tool simulates POST requests to Highcharts Export Server.'.green, 34 | '\nThe server needs to be started before running this test.'.green, 35 | '\nLoads all JSON files from the ./tests/http folder and runs them'.green, 36 | '(results are stored in the ./tests/http/_results).\n'.green 37 | ); 38 | 39 | // Url of Puppeteer export server 40 | const url = 'http://127.0.0.1:7801'; 41 | 42 | // Perform a health check before continuing 43 | fetch(`${url}/health`) 44 | .then(() => { 45 | // Results and scenarios paths 46 | const resultsPath = join(__dirname, 'tests', 'http', '_results'); 47 | const scenariosPath = join(__dirname, 'tests', 'http', 'scenarios'); 48 | 49 | // Create results folder for HTTP exports if it doesn't exist 50 | !existsSync(resultsPath) && mkdirSync(resultsPath); 51 | 52 | // Get files' names 53 | const files = readdirSync(scenariosPath); 54 | 55 | // Tests counters 56 | let testCounter = 0; 57 | let failsCounter = 0; 58 | 59 | // Disable event listeners limiter 60 | process.setMaxListeners(0); 61 | Promise.all( 62 | files 63 | .filter((file) => file.endsWith('.json')) 64 | .map(async (file) => { 65 | try { 66 | console.log('[Test runner]'.blue, `Processing test ${file}.`); 67 | 68 | // A file path 69 | const filePath = join(scenariosPath, file); 70 | 71 | // Read a payload file 72 | const payload = clearText( 73 | readFileSync(filePath).toString(), 74 | /\s\s+/g, 75 | '' 76 | ); 77 | const parsedPayload = JSON.parse(payload); 78 | 79 | // Results folder path 80 | const resultsFile = join( 81 | resultsPath, 82 | file.replace( 83 | '.json', 84 | `.${parsedPayload.b64 ? 'txt' : parsedPayload.type || 'png'}` 85 | ) 86 | ); 87 | 88 | return new Promise((resolve) => { 89 | // The start date of a POST request 90 | const startDate = new Date().getTime(); 91 | const request = http.request( 92 | url, 93 | { 94 | path: '/', 95 | method: 'POST', 96 | headers: { 97 | 'Content-Type': 'application/json' 98 | } 99 | }, 100 | (response) => { 101 | const fileStream = createWriteStream(resultsFile); 102 | 103 | // A chunk of data has been received 104 | response.on('data', (chunk) => { 105 | fileStream.write(chunk); 106 | }); 107 | 108 | // The whole response has been received 109 | response.on('end', () => { 110 | fileStream.end(); 111 | 112 | const endMessage = `HTTP request with a payload from file: ${file}, took ${ 113 | new Date().getTime() - startDate 114 | }ms.`; 115 | 116 | // Based on received status code check if requests failed 117 | if (response.statusCode >= 400) { 118 | failsCounter++; 119 | console.log(`[Fail] ${endMessage}`.red); 120 | } else { 121 | testCounter++; 122 | console.log(`[Success] ${endMessage}`.green); 123 | } 124 | resolve(); 125 | }); 126 | } 127 | ); 128 | request.write(payload); 129 | request.end(); 130 | }); 131 | } catch (error) { 132 | console.error(error); 133 | } 134 | }) 135 | ).then(() => { 136 | console.log( 137 | '\n--------------------------------', 138 | failsCounter 139 | ? `\n${testCounter} tests done, ${failsCounter} error(s) found!`.red 140 | : `\n${testCounter} tests done, errors not found!`.green, 141 | '\n--------------------------------' 142 | ); 143 | }); 144 | }) 145 | .catch((error) => { 146 | if (error.code === 'ECONNREFUSED') { 147 | return console.log( 148 | `[ERROR] Couldn't connect to ${url}.`.red, 149 | `Set your server before running tests.`.red 150 | ); 151 | } 152 | }); 153 | -------------------------------------------------------------------------------- /tests/http/http_test_runner_single.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import { createWriteStream, existsSync, mkdirSync, readFileSync } from 'fs'; 16 | import http from 'http'; 17 | import { basename, join } from 'path'; 18 | 19 | import 'colors'; 20 | 21 | import { fetch } from '../../lib/fetch.js'; 22 | import { __dirname, clearText } from '../../lib/utils.js'; 23 | 24 | // Test runner message 25 | console.log( 26 | 'Highcharts Export Server HTTP Requests Test Runner'.yellow.bold.underline, 27 | '\nThis tool simulates POST requests to Highcharts Export Server.'.green, 28 | '\nThe server needs to be started before running this test.'.green, 29 | '\nLoads a specified JSON file and runs it'.green, 30 | '(results are stored in the ./tests/http/_results).\n'.green 31 | ); 32 | 33 | // Url of Puppeteer export server 34 | const url = 'http://127.0.0.1:7801'; 35 | 36 | // Perform a health check before continuing 37 | fetch(`${url}/health`) 38 | .then(() => { 39 | // Results path 40 | const resultsPath = join(__dirname, 'tests', 'http', '_results'); 41 | 42 | // Create results folder for HTTP exports if it doesn't exist 43 | !existsSync(resultsPath) && mkdirSync(resultsPath); 44 | 45 | // Get the file's name 46 | const file = process.argv[2]; 47 | 48 | // Check if file even exists and if it is a JSON 49 | if (existsSync(file) && file.endsWith('.json')) { 50 | try { 51 | console.log('[Test runner]'.blue, `Processing test ${file}.`); 52 | 53 | // Read a payload file 54 | const payload = clearText(readFileSync(file).toString(), /\s\s+/g, ''); 55 | const parsedJPayload = JSON.parse(payload); 56 | 57 | // Results folder path 58 | const resultsFile = join( 59 | resultsPath, 60 | basename(file).replace( 61 | '.json', 62 | `.${parsedJPayload.b64 ? 'txt' : parsedJPayload.type || 'png'}` 63 | ) 64 | ); 65 | 66 | // The start date of a POST request 67 | const startDate = new Date().getTime(); 68 | const request = http.request( 69 | url, 70 | { 71 | path: '/', 72 | method: 'POST', 73 | headers: { 74 | 'Content-Type': 'application/json' 75 | } 76 | }, 77 | (response) => { 78 | const fileStream = createWriteStream(resultsFile); 79 | 80 | // A chunk of data has been received 81 | response.on('data', (chunk) => { 82 | fileStream.write(chunk); 83 | }); 84 | 85 | // The whole response has been received 86 | response.on('end', () => { 87 | fileStream.end(); 88 | 89 | const endMessage = `HTTP request with a payload from file: ${file}, took ${ 90 | new Date().getTime() - startDate 91 | }ms.`; 92 | 93 | // Based on received status code check if requests failed 94 | if (response.statusCode >= 400) { 95 | console.log(`[Fail] ${endMessage}`.red); 96 | } else { 97 | console.log(`[Success] ${endMessage}`.green); 98 | } 99 | }); 100 | } 101 | ); 102 | request.write(payload); 103 | request.end(); 104 | } catch (error) { 105 | console.error(error); 106 | } 107 | } 108 | }) 109 | .catch((error) => { 110 | if (error.code === 'ECONNREFUSED') { 111 | return console.log( 112 | `[ERROR] Couldn't connect to ${url}.`.red, 113 | `Set your server before running tests.`.red 114 | ); 115 | } 116 | }); 117 | -------------------------------------------------------------------------------- /tests/http/scenarios/allow_code_execution.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "title": { 4 | "text": "The default value of the 'allowCodeExecution' allows stringified resources" 5 | }, 6 | "xAxis": { 7 | "categories": ["Jan", "Feb", "Mar", "Apr"] 8 | }, 9 | "series": [ 10 | { 11 | "type": "column", 12 | "data": [5, 6, 7, 8] 13 | }, 14 | { 15 | "type": "line", 16 | "data": [1, 2, 3, 4] 17 | } 18 | ] 19 | }, 20 | "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();}", 21 | "customCode": "Highcharts.setOptions({chart:{events:{render:function (){this.renderer.image('https://www.highcharts.com/samples/graphics/sun.png',75,50,20,20).add();}}}});", 22 | "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;}\"}" 23 | } 24 | -------------------------------------------------------------------------------- /tests/http/scenarios/b64.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "title": { 4 | "text": "The 'b64' option returns base64 string" 5 | }, 6 | "xAxis": { 7 | "categories": ["Jan", "Feb", "Mar", "Apr"] 8 | }, 9 | "series": [ 10 | { 11 | "type": "column", 12 | "data": [5, 6, 7, 8] 13 | }, 14 | { 15 | "type": "line", 16 | "data": [1, 2, 3, 4] 17 | } 18 | ] 19 | }, 20 | "b64": true 21 | } 22 | -------------------------------------------------------------------------------- /tests/http/scenarios/constr.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "title": { 4 | "text": "The 'constr' option is set to use stockChart" 5 | }, 6 | "series": [ 7 | { 8 | "type": "column", 9 | "data": [5, 6, 7, 8] 10 | }, 11 | { 12 | "type": "line", 13 | "data": [1, 2, 3, 4] 14 | } 15 | ] 16 | }, 17 | "constr": "stockChart" 18 | } 19 | -------------------------------------------------------------------------------- /tests/http/scenarios/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "title": { 4 | "text": "Chart created from the 'data' JSON" 5 | }, 6 | "xAxis": { 7 | "categories": ["Jan", "Feb", "Mar", "Apr"] 8 | }, 9 | "series": [ 10 | { 11 | "type": "column", 12 | "data": [5, 6, 7, 8] 13 | }, 14 | { 15 | "type": "line", 16 | "data": [1, 2, 3, 4] 17 | } 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/http/scenarios/do_not_allow_file_resources.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "title": { 4 | "text": "The default value of the 'allowFileResources' prevents resources from files" 5 | }, 6 | "xAxis": { 7 | "categories": ["Jan", "Feb", "Mar", "Apr"] 8 | }, 9 | "series": [ 10 | { 11 | "type": "column", 12 | "data": [5, 6, 7, 8] 13 | }, 14 | { 15 | "type": "line", 16 | "data": [1, 2, 3, 4] 17 | } 18 | ] 19 | }, 20 | "callback": "./samples/resources/callback.js", 21 | "customCode": "./samples/resources/custom_code.js", 22 | "resources": "./samples/resources/resources.js" 23 | } 24 | -------------------------------------------------------------------------------- /tests/http/scenarios/global_and_theme_from_files.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "title": { 4 | "text": "The default value of the 'allowFileResources' prevents 'globalOptions' and 'themeOptions' from files" 5 | }, 6 | "xAxis": { 7 | "categories": ["Jan", "Feb", "Mar", "Apr"] 8 | }, 9 | "series": [ 10 | { 11 | "type": "column", 12 | "data": [5, 6, 7, 8] 13 | }, 14 | { 15 | "type": "line", 16 | "data": [1, 2, 3, 4] 17 | } 18 | ] 19 | }, 20 | "globalOptions": "./samples/resources/options_global.json", 21 | "themeOptions": "./samples/resources/options_theme.json" 22 | } 23 | -------------------------------------------------------------------------------- /tests/http/scenarios/global_and_theme_stringified.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "title": { 4 | "text": "The default value of the 'allowFileResources' allows stringified 'globalOptions' and 'themeOptions'" 5 | }, 6 | "xAxis": { 7 | "categories": ["Jan", "Feb", "Mar", "Apr"] 8 | }, 9 | "series": [ 10 | { 11 | "type": "column", 12 | "data": [5, 6, 7, 8] 13 | }, 14 | { 15 | "type": "line", 16 | "data": [1, 2, 3, 4] 17 | } 18 | ] 19 | }, 20 | "globalOptions": "{\"chart\":{\"borderWidth\":2,\"plotBackgroundColor\":\"rgba(61,61,255,.9)\",\"plotShadow\":true,\"plotBorderWidth\":1},\"subtitle\":{\"text\":\"Global options subtitle\"}}", 21 | "themeOptions": "{\"colors\":[\"#058DC7\",\"#50B432\",\"#ED561B\",\"#DDDF00\",\"#24CBE5\",\"#64E572\",\"#FF9655\",\"#FFF263\",\"#6AF9C4\"],\"chart\":{\"backgroundColor\":{\"linearGradient\":[0,0,500,500],\"stops\":[[0,\"rgb(255,255,255)\"],[1,\"rgb(240,240,255)\"]]}},\"title\":{\"style\":{\"color\":\"#000\",\"font\":\"bold 16px Trebuchet MS, Verdana, sans-serif\"}},\"subtitle\":{\"text\":\"Theme options subtitle\",\"style\":{\"color\":\"#666666\",\"font\":\"bold 12px Trebuchet MS, Verdana, sans-serif\"}},\"legend\":{\"itemStyle\":{\"font\":\"9pt Trebuchet MS, Verdana, sans-serif\",\"color\":\"black\"}}}" 22 | } 23 | -------------------------------------------------------------------------------- /tests/http/scenarios/height_width_scale.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "title": { 4 | "text": "The usage of the 'height', 'width' and 'scale' options" 5 | }, 6 | "xAxis": { 7 | "categories": ["Jan", "Feb", "Mar", "Apr"] 8 | }, 9 | "series": [ 10 | { 11 | "type": "column", 12 | "data": [5, 6, 7, 8] 13 | }, 14 | { 15 | "type": "line", 16 | "data": [1, 2, 3, 4] 17 | } 18 | ] 19 | }, 20 | "height": 800, 21 | "width": 800, 22 | "scale": 2 23 | } 24 | -------------------------------------------------------------------------------- /tests/http/scenarios/infile_json.json: -------------------------------------------------------------------------------- 1 | { 2 | "infile": { 3 | "title": { 4 | "text": "Chart created from the 'infile' JSON" 5 | }, 6 | "xAxis": { 7 | "categories": ["Jan", "Feb", "Mar", "Apr"] 8 | }, 9 | "series": [ 10 | { 11 | "type": "column", 12 | "data": [5, 6, 7, 8] 13 | }, 14 | { 15 | "type": "line", 16 | "data": [1, 2, 3, 4] 17 | } 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/http/scenarios/infile_stringified.json: -------------------------------------------------------------------------------- 1 | { 2 | "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]}]}" 3 | } 4 | -------------------------------------------------------------------------------- /tests/http/scenarios/options.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "title": { 4 | "text": "Chart created from the 'options' JSON" 5 | }, 6 | "xAxis": { 7 | "categories": ["Jan", "Feb", "Mar", "Apr"] 8 | }, 9 | "series": [ 10 | { 11 | "type": "column", 12 | "data": [5, 6, 7, 8] 13 | }, 14 | { 15 | "type": "line", 16 | "data": [1, 2, 3, 4] 17 | } 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/http/scenarios/type.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "title": { 4 | "text": "The 'type' option is set to SVG" 5 | }, 6 | "xAxis": { 7 | "categories": ["Jan", "Feb", "Mar", "Apr"] 8 | }, 9 | "series": [ 10 | { 11 | "type": "column", 12 | "data": [5, 6, 7, 8] 13 | }, 14 | { 15 | "type": "line", 16 | "data": [1, 2, 3, 4] 17 | } 18 | ] 19 | }, 20 | "type": "svg" 21 | } 22 | -------------------------------------------------------------------------------- /tests/node/error_scenarios/do_not_allow_code_execution_and_file_resources.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "options": { 4 | "chart": { 5 | "type": "column" 6 | }, 7 | "title": { 8 | "text": "Do not allow code execution" 9 | }, 10 | "yAxis": [ 11 | { 12 | "title": { 13 | "text": "Primary axis" 14 | } 15 | }, 16 | { 17 | "opposite": true, 18 | "title": { 19 | "text": "Secondary axis" 20 | } 21 | } 22 | ], 23 | "plotOptions": { 24 | "column": { 25 | "borderRadius": 5 26 | } 27 | }, 28 | "series": [ 29 | { 30 | "data": [1, 3, 2, 4] 31 | }, 32 | { 33 | "data": [324, 124, 547, 221], 34 | "yAxis": 1 35 | } 36 | ] 37 | } 38 | }, 39 | "customLogic": { 40 | "allowCodeExecution": false, 41 | "allowFileResources": false, 42 | "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();}", 43 | "customCode": "Highcharts.setOptions({chart:{events:{render:function (){this.renderer.image('https://www.highcharts.com/samples/graphics/sun.png',100,50,20,20).add();}}}});", 44 | "resources": { 45 | "js": "Highcharts.charts[0].update({xAxis:{title:{text:'Title from the resources file, js section'}}});", 46 | "css": ".highcharts-yaxis .highcharts-axis-line {stroke-width:2px;stroke:#FF0000;}", 47 | "files": "./samples/resources/resources_file_1.js,./samples/resources/resources_file_2.js" 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/node/error_scenarios/options_stringified_wrong.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "constr": "chart", 4 | "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]}]}" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/node/node_test_runner.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import { 16 | existsSync, 17 | mkdirSync, 18 | readFileSync, 19 | readdirSync, 20 | writeFileSync 21 | } from 'fs'; 22 | import { basename, join } from 'path'; 23 | 24 | import 'colors'; 25 | 26 | import exporter from '../../lib/index.js'; 27 | import { __dirname } from '../../lib/utils.js'; 28 | 29 | console.log( 30 | 'Highcharts Export Server Node Test Runner'.yellow.bold.underline, 31 | '\nThis tool simulates NodeJS module execution by using selected'.green, 32 | 'functions (initExport and startExport) of Highcharts Export Server.'.green, 33 | '\nLoads all JSON files from the ./tests/node folder and runs them'.green, 34 | '(results are stored in the ./test/node/_results).\n'.green 35 | ); 36 | 37 | (async () => { 38 | try { 39 | // Results and scenarios paths 40 | const resultsPath = join(__dirname, 'tests', 'node', '_results'); 41 | const scenariosPath = join(__dirname, 'tests', 'node', 'scenarios'); 42 | 43 | // Create results folder for HTTP exports if doesn't exist 44 | !existsSync(resultsPath) && mkdirSync(resultsPath); 45 | 46 | // Get files names 47 | const files = readdirSync(scenariosPath); 48 | 49 | // Set options 50 | const options = exporter.setOptions(); 51 | 52 | try { 53 | // Initialize pool with disabled logging 54 | await exporter.initExport(options); 55 | } catch (error) { 56 | await exporter.killPool(); 57 | throw error; 58 | } 59 | 60 | // Disable logs for the rest of the code 61 | exporter.setLogLevel(0); 62 | 63 | let testCounter = 0; 64 | let failsCounter = 0; 65 | 66 | // Await all exports 67 | Promise.all( 68 | files 69 | .filter((file) => file.endsWith('.json')) 70 | .map( 71 | (file) => 72 | new Promise((resolve) => { 73 | console.log('[Test runner]'.blue, `Processing test ${file}.`); 74 | 75 | // Options from a file 76 | const fileOptions = JSON.parse( 77 | readFileSync(join(scenariosPath, file)) 78 | ); 79 | 80 | // Prepare an outfile path 81 | fileOptions.export.outfile = join( 82 | resultsPath, 83 | fileOptions.export?.outfile || 84 | basename(file).replace( 85 | '.json', 86 | `.${fileOptions.export?.type || 'png'}` 87 | ) 88 | ); 89 | 90 | // The start date of a startExport function run 91 | const startTime = new Date().getTime(); 92 | 93 | // Start the export process 94 | exporter 95 | .startExport(fileOptions, (error, info) => { 96 | // Throw an error 97 | if (error) { 98 | throw error; 99 | } 100 | 101 | // Save returned data to a correct image file if no error occured 102 | writeFileSync( 103 | info.options.export.outfile, 104 | info.options?.export?.type !== 'svg' 105 | ? Buffer.from(info.result, 'base64') 106 | : info.result 107 | ); 108 | 109 | // Information about the results and the time it took 110 | console.log( 111 | `[Success] Node module from file: ${file}, took: ${ 112 | new Date().getTime() - startTime 113 | }ms.`.green 114 | ); 115 | }) 116 | .catch((error) => { 117 | // Information about the error and the time it took 118 | console.log( 119 | `[Fail] Node module from file: ${file}, took: ${ 120 | new Date().getTime() - startTime 121 | }ms.`.red 122 | ); 123 | exporter.setLogLevel(1); 124 | exporter.logWithStack(1, error); 125 | exporter.setLogLevel(0); 126 | failsCounter++; 127 | }) 128 | .finally(() => { 129 | testCounter++; 130 | resolve(); 131 | }); 132 | }) 133 | ) 134 | ).then(async () => { 135 | // Summarize the run and kill the pool 136 | console.log( 137 | '\n--------------------------------', 138 | failsCounter 139 | ? `\n${testCounter} tests done, ${failsCounter} error(s) found!`.red 140 | : `\n${testCounter} tests done, errors not found!`.green, 141 | '\n--------------------------------' 142 | ); 143 | await exporter.killPool(); 144 | }); 145 | } catch (error) { 146 | console.error(error); 147 | } 148 | })(); 149 | -------------------------------------------------------------------------------- /tests/node/node_test_runner_single.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; 16 | import { basename, join } from 'path'; 17 | 18 | import 'colors'; 19 | 20 | import exporter from '../../lib/index.js'; 21 | import { __dirname } from '../../lib/utils.js'; 22 | 23 | console.log( 24 | 'Highcharts Export Server Node Test Runner'.yellow.bold.underline, 25 | '\nThis tool simulates NodeJS module execution by using selected'.green, 26 | 'functions (initExport and startExport) of Highcharts Export Server.'.green, 27 | '\nLoads a specified JSON file and runs it'.green, 28 | '(results are stored in the ./test/node/_results).\n'.green 29 | ); 30 | 31 | // Create a promise for the export 32 | (async () => { 33 | try { 34 | // Results and scenarios paths 35 | const resultsPath = join(__dirname, 'tests', 'node', '_results'); 36 | 37 | // Create results folder for HTTP exports if doesn't exist 38 | !existsSync(resultsPath) && mkdirSync(resultsPath); 39 | 40 | // Get the file's name 41 | const file = process.argv[2]; 42 | 43 | // Check if file even exists and if it is a JSON 44 | if (existsSync(file) && file.endsWith('.json')) { 45 | // Set options 46 | const options = exporter.setOptions({ 47 | pool: { 48 | minWorkers: 1, 49 | maxWorkers: 1 50 | }, 51 | logging: { 52 | level: 0 53 | } 54 | }); 55 | 56 | // Initialize pool with disabled logging 57 | await exporter.initExport(options); 58 | 59 | // Start the export 60 | console.log('[Test runner]'.blue, `Processing test ${file}.`); 61 | 62 | // Options from a file 63 | const fileOptions = JSON.parse(readFileSync(file)); 64 | 65 | // Prepare an outfile path 66 | fileOptions.export.outfile = join( 67 | resultsPath, 68 | fileOptions.export?.outfile || 69 | basename(file).replace( 70 | '.json', 71 | `.${fileOptions.export?.type || 'png'}` 72 | ) 73 | ); 74 | 75 | // The start date of a startExport function run 76 | const startTime = new Date().getTime(); 77 | 78 | try { 79 | // Start the export process 80 | await exporter.startExport(fileOptions, async (error, info) => { 81 | // Throw an error 82 | if (error) { 83 | throw error; 84 | } 85 | 86 | // Save returned data to a correct image file if no error occured 87 | writeFileSync( 88 | info.options.export.outfile, 89 | info.options?.export?.type !== 'svg' 90 | ? Buffer.from(info.result, 'base64') 91 | : info.result 92 | ); 93 | 94 | // Information about the results and the time it took 95 | console.log( 96 | `[Success] Node module from file: ${file}, took: ${ 97 | new Date().getTime() - startTime 98 | }ms.`.green 99 | ); 100 | }); 101 | } catch (error) { 102 | // Information about the error and the time it took 103 | console.log( 104 | `[Fail] Node module from file: ${file}, took: ${ 105 | new Date().getTime() - startTime 106 | }ms.`.red 107 | ); 108 | } 109 | 110 | // Kill the pool 111 | await exporter.killPool(); 112 | } else { 113 | console.log( 114 | 'The test does not exist. Please give a full path starting from ./tests.' 115 | ); 116 | } 117 | } catch (error) { 118 | console.error(error); 119 | } 120 | })(); 121 | -------------------------------------------------------------------------------- /tests/node/scenarios/allow_code_execution.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "options": { 4 | "chart": { 5 | "type": "column" 6 | }, 7 | "title": { 8 | "text": "Allow code execution" 9 | }, 10 | "yAxis": [ 11 | { 12 | "title": { 13 | "text": "Primary axis" 14 | } 15 | }, 16 | { 17 | "opposite": true, 18 | "title": { 19 | "text": "Secondary axis" 20 | } 21 | } 22 | ], 23 | "plotOptions": { 24 | "column": { 25 | "borderRadius": 5 26 | } 27 | }, 28 | "series": [ 29 | { 30 | "data": [1, 3, 2, 4] 31 | }, 32 | { 33 | "data": [324, 124, 547, 221], 34 | "yAxis": 1 35 | } 36 | ] 37 | } 38 | }, 39 | "customLogic": { 40 | "allowCodeExecution": true, 41 | "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();}", 42 | "customCode": "Highcharts.setOptions({chart:{events:{render:function (){this.renderer.image('https://www.highcharts.com/samples/graphics/sun.png',75,50,20,20).add();}}}});", 43 | "resources": { 44 | "js": "Highcharts.charts[0].update({xAxis:{title:{text:'Title from the resources object, js section'}}});", 45 | "css": ".highcharts-yaxis .highcharts-axis-line{stroke-width:2px;stroke:#FF0000}" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/node/scenarios/allow_file_resources.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "options": { 4 | "chart": { 5 | "type": "column" 6 | }, 7 | "title": { 8 | "text": "Allow file resources" 9 | }, 10 | "yAxis": [ 11 | { 12 | "title": { 13 | "text": "Primary axis" 14 | } 15 | }, 16 | { 17 | "opposite": true, 18 | "title": { 19 | "text": "Secondary axis" 20 | } 21 | } 22 | ], 23 | "plotOptions": { 24 | "column": { 25 | "borderRadius": 5 26 | } 27 | }, 28 | "series": [ 29 | { 30 | "data": [1, 3, 2, 4] 31 | }, 32 | { 33 | "data": [324, 124, 547, 221], 34 | "yAxis": 1 35 | } 36 | ] 37 | } 38 | }, 39 | "customLogic": { 40 | "allowCodeExecution": true, 41 | "allowFileResources": true, 42 | "callback": "./samples/resources/callback.js", 43 | "customCode": "./samples/resources/custom_code.js", 44 | "resources": "./samples/resources/resources.json" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/node/scenarios/allow_file_resources_false.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "options": { 4 | "chart": { 5 | "type": "column" 6 | }, 7 | "title": { 8 | "text": "Do not allow file resources" 9 | }, 10 | "yAxis": [ 11 | { 12 | "title": { 13 | "text": "Primary axis" 14 | } 15 | }, 16 | { 17 | "opposite": true, 18 | "title": { 19 | "text": "Secondary axis" 20 | } 21 | } 22 | ], 23 | "plotOptions": { 24 | "column": { 25 | "borderRadius": 5 26 | } 27 | }, 28 | "series": [ 29 | { 30 | "data": [1, 3, 2, 4] 31 | }, 32 | { 33 | "data": [324, 124, 547, 221], 34 | "yAxis": 1 35 | } 36 | ] 37 | } 38 | }, 39 | "customLogic": { 40 | "allowCodeExecution": true, 41 | "allowFileResources": false, 42 | "callback": "./samples/resources/callback.js", 43 | "customCode": "./samples/resources/custom_code.js", 44 | "resources": "./samples/resources/resources.json" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/node/scenarios/constr_chart.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "constr": "chart", 4 | "options": { 5 | "title": { 6 | "text": "Chart" 7 | }, 8 | "xAxis": { 9 | "categories": [ 10 | "Jan", 11 | "Feb", 12 | "Mar", 13 | "Apr", 14 | "May", 15 | "Jun", 16 | "Jul", 17 | "Aug", 18 | "Sep", 19 | "Oct", 20 | "Nov", 21 | "Dec" 22 | ] 23 | }, 24 | "yAxis": { 25 | "title": { 26 | "text": "Temperature (°C)" 27 | } 28 | }, 29 | "plotOptions": { 30 | "line": { 31 | "dataLabels": { 32 | "enabled": true 33 | } 34 | } 35 | }, 36 | "series": [ 37 | { 38 | "name": "Reggane", 39 | "data": [ 40 | 16.0, 18.2, 23.1, 27.9, 32.2, 36.4, 39.8, 38.4, 35.5, 29.2, 22.0, 41 | 17.8 42 | ] 43 | }, 44 | { 45 | "name": "Tallinn", 46 | "data": [ 47 | -2.9, -3.6, -0.6, 4.8, 10.2, 14.5, 17.6, 16.5, 12.0, 6.5, 2.0, -0.9 48 | ] 49 | } 50 | ] 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/node/scenarios/constr_gantt_chart.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "constr": "ganttChart", 4 | "options": { 5 | "title": { 6 | "text": "Gantt Chart" 7 | }, 8 | "xAxis": { 9 | "min": 0, 10 | "max": 12 11 | }, 12 | "series": [ 13 | { 14 | "name": "Project 1", 15 | "data": [ 16 | { 17 | "name": "Start prototype", 18 | "start": 1, 19 | "end": 7, 20 | "completed": 0.25 21 | }, 22 | { 23 | "name": "Test prototype", 24 | "start": 9, 25 | "end": 11 26 | }, 27 | { 28 | "name": "Develop", 29 | "start": 3, 30 | "end": 8, 31 | "completed": { 32 | "amount": 0.12, 33 | "fill": "#fa0" 34 | } 35 | }, 36 | { 37 | "name": "Run acceptance tests", 38 | "start": 6, 39 | "end": 9 40 | } 41 | ] 42 | } 43 | ] 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/node/scenarios/css_import_theme.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "options": { 4 | "chart": { 5 | "styledMode": true, 6 | "type": "column" 7 | }, 8 | "title": { 9 | "text": "Styling axes (Import Dark Unica theme)" 10 | }, 11 | "yAxis": [ 12 | { 13 | "title": { 14 | "text": "Primary axis" 15 | } 16 | }, 17 | { 18 | "opposite": true, 19 | "title": { 20 | "text": "Secondary axis" 21 | } 22 | } 23 | ], 24 | "plotOptions": { 25 | "column": { 26 | "borderRadius": 5 27 | } 28 | }, 29 | "series": [ 30 | { 31 | "data": [1, 3, 2, 4] 32 | }, 33 | { 34 | "data": [324, 124, 547, 221], 35 | "yAxis": 1 36 | } 37 | ] 38 | } 39 | }, 40 | "customLogic": { 41 | "allowCodeExecution": true, 42 | "allowFileResources": true, 43 | "resources": { 44 | "css": "@import 'https://code.highcharts.com/css/themes/dark-unica.css';" 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/node/scenarios/css_raw.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "options": { 4 | "chart": { 5 | "styledMode": true, 6 | "type": "column" 7 | }, 8 | "title": { 9 | "text": "Styling axes (Raw CSS with the default CSS file)" 10 | }, 11 | "yAxis": [ 12 | { 13 | "className": "highcharts-color-0", 14 | "title": { 15 | "text": "Primary axis" 16 | } 17 | }, 18 | { 19 | "className": "highcharts-color-1", 20 | "opposite": true, 21 | "title": { 22 | "text": "Secondary axis" 23 | } 24 | } 25 | ], 26 | "plotOptions": { 27 | "column": { 28 | "borderRadius": 5 29 | } 30 | }, 31 | "series": [ 32 | { 33 | "data": [1, 3, 2, 4] 34 | }, 35 | { 36 | "data": [324, 124, 547, 221], 37 | "yAxis": 1 38 | } 39 | ] 40 | } 41 | }, 42 | "customLogic": { 43 | "allowCodeExecution": true, 44 | "allowFileResources": true, 45 | "resources": { 46 | "css": "@import url(https://code.highcharts.com/css/highcharts.css);.highcharts-yaxis .highcharts-axis-line{stroke-width:2px}.highcharts-color-0{fill:#f7a35c;stroke:#f7a35c}.highcharts-axis.highcharts-color-0 .highcharts-axis-line{stroke:#f7a35c}.highcharts-axis.highcharts-color-0 text{fill:#f7a35c}.highcharts-color-1{fill:#90ed7d;stroke:#90ed7d}.highcharts-axis.highcharts-color-1 .highcharts-axis-line{stroke:#90ed7d}#renderer-callback-label .highcharts-label-box,.highcharts-axis.highcharts-color-1 text{fill:#90ed7d}" 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/node/scenarios/custom_code_from_file.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "options": { 4 | "chart": { 5 | "type": "column" 6 | }, 7 | "title": { 8 | "text": "Custom code (from file)" 9 | }, 10 | "yAxis": [ 11 | { 12 | "title": { 13 | "text": "Primary axis" 14 | } 15 | }, 16 | { 17 | "opposite": true, 18 | "title": { 19 | "text": "Secondary axis" 20 | } 21 | } 22 | ], 23 | "plotOptions": { 24 | "column": { 25 | "borderRadius": 5 26 | } 27 | }, 28 | "series": [ 29 | { 30 | "data": [1, 3, 2, 4] 31 | }, 32 | { 33 | "data": [324, 124, 547, 221], 34 | "yAxis": 1 35 | } 36 | ] 37 | } 38 | }, 39 | "customLogic": { 40 | "allowCodeExecution": true, 41 | "allowFileResources": true, 42 | "customCode": "./samples/resources/custom_code.js" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/node/scenarios/custom_code_from_string.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "options": { 4 | "chart": { 5 | "type": "column" 6 | }, 7 | "title": { 8 | "text": "Custom code (from string)" 9 | }, 10 | "yAxis": [ 11 | { 12 | "title": { 13 | "text": "Primary axis" 14 | } 15 | }, 16 | { 17 | "opposite": true, 18 | "title": { 19 | "text": "Secondary axis" 20 | } 21 | } 22 | ], 23 | "plotOptions": { 24 | "column": { 25 | "borderRadius": 5 26 | } 27 | }, 28 | "series": [ 29 | { 30 | "data": [1, 3, 2, 4] 31 | }, 32 | { 33 | "data": [324, 124, 547, 221], 34 | "yAxis": 1 35 | } 36 | ] 37 | } 38 | }, 39 | "customLogic": { 40 | "allowCodeExecution": true, 41 | "allowFileResources": true, 42 | "customCode": "Highcharts.setOptions({chart:{events:{render:function (){this.renderer.image('https://www.highcharts.com/samples/graphics/sun.png',75,50,20,20).add();}}}});" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/node/scenarios/global_and_theme_options_from_files.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "options": { 4 | "chart": { 5 | "type": "column" 6 | }, 7 | "title": { 8 | "text": "Theme and global options from files" 9 | }, 10 | "yAxis": [ 11 | { 12 | "className": "highcharts-color-0", 13 | "title": { 14 | "text": "Primary axis" 15 | } 16 | }, 17 | { 18 | "className": "highcharts-color-1", 19 | "opposite": true, 20 | "title": { 21 | "text": "Secondary axis" 22 | } 23 | } 24 | ], 25 | "plotOptions": { 26 | "column": { 27 | "borderRadius": 5 28 | } 29 | }, 30 | "series": [ 31 | { 32 | "data": [1, 3, 2, 4] 33 | }, 34 | { 35 | "data": [324, 124, 547, 221], 36 | "yAxis": 1 37 | } 38 | ] 39 | }, 40 | "globalOptions": "./samples/resources/options_global.json", 41 | "themeOptions": "./samples/resources/options_theme.json" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/node/scenarios/global_and_theme_options_from_objects.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "options": { 4 | "chart": { 5 | "type": "column" 6 | }, 7 | "title": { 8 | "text": "Theme and global options from objects" 9 | }, 10 | "yAxis": [ 11 | { 12 | "className": "highcharts-color-0", 13 | "title": { 14 | "text": "Primary axis" 15 | } 16 | }, 17 | { 18 | "className": "highcharts-color-1", 19 | "opposite": true, 20 | "title": { 21 | "text": "Secondary axis" 22 | } 23 | } 24 | ], 25 | "plotOptions": { 26 | "column": { 27 | "borderRadius": 5 28 | } 29 | }, 30 | "series": [ 31 | { 32 | "data": [1, 3, 2, 4] 33 | }, 34 | { 35 | "data": [324, 124, 547, 221], 36 | "yAxis": 1 37 | } 38 | ] 39 | }, 40 | "globalOptions": { 41 | "chart": { 42 | "borderWidth": 2, 43 | "plotBackgroundColor": "rgba(61, 255, 61, .9)", 44 | "plotShadow": true, 45 | "plotBorderWidth": 1 46 | }, 47 | "subtitle": { 48 | "text": "Global options subtitle" 49 | } 50 | }, 51 | "themeOptions": { 52 | "colors": [ 53 | "#058DC7", 54 | "#50B432", 55 | "#ED561B", 56 | "#DDDF00", 57 | "#24CBE5", 58 | "#64E572", 59 | "#FF9655", 60 | "#FFF263", 61 | "#6AF9C4" 62 | ], 63 | "chart": { 64 | "backgroundColor": { 65 | "linearGradient": [0, 0, 500, 500], 66 | "stops": [ 67 | [0, "rgb(255, 255, 255)"], 68 | [1, "rgb(240, 240, 255)"] 69 | ] 70 | } 71 | }, 72 | "title": { 73 | "style": { 74 | "color": "#000", 75 | "font": "bold 16px Trebuchet MS, Verdana, sans-serif" 76 | } 77 | }, 78 | "subtitle": { 79 | "text": "Theme options subtitle", 80 | "style": { 81 | "color": "#666666", 82 | "font": "bold 12px Trebuchet MS, Verdana, sans-serif" 83 | } 84 | }, 85 | "legend": { 86 | "itemStyle": { 87 | "font": "9pt Trebuchet MS, Verdana, sans-serif", 88 | "color": "black" 89 | } 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/node/scenarios/global_and_theme_options_from_stringified_objects.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "options": { 4 | "chart": { 5 | "type": "column" 6 | }, 7 | "title": { 8 | "text": "Theme and global options stringified objects" 9 | }, 10 | "yAxis": [ 11 | { 12 | "className": "highcharts-color-0", 13 | "title": { 14 | "text": "Primary axis" 15 | } 16 | }, 17 | { 18 | "className": "highcharts-color-1", 19 | "opposite": true, 20 | "title": { 21 | "text": "Secondary axis" 22 | } 23 | } 24 | ], 25 | "plotOptions": { 26 | "column": { 27 | "borderRadius": 5 28 | } 29 | }, 30 | "series": [ 31 | { 32 | "data": [1, 3, 2, 4] 33 | }, 34 | { 35 | "data": [324, 124, 547, 221], 36 | "yAxis": 1 37 | } 38 | ] 39 | }, 40 | "globalOptions": "{\"chart\":{\"borderWidth\":2,\"plotBackgroundColor\":\"rgba(61,61,255,.9)\",\"plotShadow\":true,\"plotBorderWidth\":1},\"subtitle\":{\"text\":\"Global options subtitle\"}}", 41 | "themeOptions": "{\"colors\":[\"#058DC7\",\"#50B432\",\"#ED561B\",\"#DDDF00\",\"#24CBE5\",\"#64E572\",\"#FF9655\",\"#FFF263\",\"#6AF9C4\"],\"chart\":{\"backgroundColor\":{\"linearGradient\":[0,0,500,500],\"stops\":[[0,\"rgb(255,255,255)\"],[1,\"rgb(240,240,255)\"]]}},\"title\":{\"style\":{\"color\":\"#000\",\"font\":\"bold 16px Trebuchet MS, Verdana, sans-serif\"}},\"subtitle\":{\"text\":\"Theme options subtitle\",\"style\":{\"color\":\"#666666\",\"font\":\"bold 12px Trebuchet MS, Verdana, sans-serif\"}},\"legend\":{\"itemStyle\":{\"font\":\"9pt Trebuchet MS, Verdana, sans-serif\",\"color\":\"black\"}}}" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/node/scenarios/infile.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "infile": "./samples/cli/infile_json.json" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/node/scenarios/instr.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "instr": "{\"title\":{\"text\":\"From instr\"},\"xAxis\":{\"categories\":[\"Jan\",\"Feb\",\"Mar\",\"Apr\"]},\"series\":[{\"type\":\"column\",\"data\":[5,6,7,8]},{\"type\":\"line\",\"data\":[1,2,3,4]}]}" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/node/scenarios/options_json.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "constr": "chart", 4 | "options": { 5 | "title": { 6 | "text": "Basic options" 7 | }, 8 | "xAxis": { 9 | "categories": ["Jan", "Feb", "Mar", "Apr"] 10 | }, 11 | "series": [ 12 | { 13 | "type": "line", 14 | "data": [1, 3, 2, 4] 15 | }, 16 | { 17 | "type": "line", 18 | "data": [5, 3, 4, 2] 19 | } 20 | ] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/node/scenarios/options_stringified.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "constr": "chart", 4 | "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]}]}" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/node/scenarios/outfile.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "infile": "./samples/cli/infile_json.json", 4 | "outfile": "outfile_custom.png" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/node/scenarios/sizes_and_scale_from_cli_post.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "height": 800, 4 | "width": 1200, 5 | "scale": 2, 6 | "options": { 7 | "chart": { 8 | "type": "column" 9 | }, 10 | "title": { 11 | "text": "Size and scale (From CLI/POST)" 12 | }, 13 | "yAxis": [ 14 | { 15 | "title": { 16 | "text": "Primary axis" 17 | } 18 | }, 19 | { 20 | "opposite": true, 21 | "title": { 22 | "text": "Secondary axis" 23 | } 24 | } 25 | ], 26 | "plotOptions": { 27 | "column": { 28 | "borderRadius": 5 29 | } 30 | }, 31 | "series": [ 32 | { 33 | "data": [1, 3, 2, 4] 34 | }, 35 | { 36 | "data": [324, 124, 547, 221], 37 | "yAxis": 1 38 | } 39 | ] 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/node/scenarios/sizes_and_scale_from_default_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "options": { 4 | "chart": { 5 | "type": "column" 6 | }, 7 | "title": { 8 | "text": "Size and scale (Defaults from envs, config or fixed values)" 9 | }, 10 | "yAxis": [ 11 | { 12 | "title": { 13 | "text": "Primary axis" 14 | } 15 | }, 16 | { 17 | "opposite": true, 18 | "title": { 19 | "text": "Secondary axis" 20 | } 21 | } 22 | ], 23 | "plotOptions": { 24 | "column": { 25 | "borderRadius": 5 26 | } 27 | }, 28 | "series": [ 29 | { 30 | "data": [1, 3, 2, 4] 31 | }, 32 | { 33 | "data": [324, 124, 547, 221], 34 | "yAxis": 1 35 | } 36 | ] 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/node/scenarios/sizes_and_scale_from_exporting_options.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "options": { 4 | "chart": { 5 | "type": "column" 6 | }, 7 | "exporting": { 8 | "sourceHeight": 550, 9 | "sourceWidth": 950, 10 | "scale": 2 11 | }, 12 | "title": { 13 | "text": "Size and scale (From exporting options)" 14 | }, 15 | "yAxis": [ 16 | { 17 | "title": { 18 | "text": "Primary axis" 19 | } 20 | }, 21 | { 22 | "opposite": true, 23 | "title": { 24 | "text": "Secondary axis" 25 | } 26 | } 27 | ], 28 | "plotOptions": { 29 | "column": { 30 | "borderRadius": 5 31 | } 32 | }, 33 | "series": [ 34 | { 35 | "data": [1, 3, 2, 4] 36 | }, 37 | { 38 | "data": [324, 124, 547, 221], 39 | "yAxis": 1 40 | } 41 | ] 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/node/scenarios/sizes_from_chart_options.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "options": { 4 | "chart": { 5 | "type": "column", 6 | "height": 600, 7 | "width": 1000 8 | }, 9 | "exporting": { 10 | "scale": 2 11 | }, 12 | "title": { 13 | "text": "Size and scale (From chart options)" 14 | }, 15 | "yAxis": [ 16 | { 17 | "title": { 18 | "text": "Primary axis" 19 | } 20 | }, 21 | { 22 | "opposite": true, 23 | "title": { 24 | "text": "Secondary axis" 25 | } 26 | } 27 | ], 28 | "plotOptions": { 29 | "column": { 30 | "borderRadius": 5 31 | } 32 | }, 33 | "series": [ 34 | { 35 | "data": [1, 3, 2, 4] 36 | }, 37 | { 38 | "data": [324, 124, 547, 221], 39 | "yAxis": 1 40 | } 41 | ] 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/node/scenarios/symbols.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "options": { 4 | "title": { 5 | "text": "Demo of predefined, image and custom marker symbols" 6 | }, 7 | "credits": { 8 | "enabled": false 9 | }, 10 | "subtitle": { 11 | "text": "*) Base64 not supported in IE6 and IE7", 12 | "verticalAlign": "bottom", 13 | "align": "right", 14 | "style": { 15 | "fontSize": "10px" 16 | } 17 | }, 18 | "xAxis": { 19 | "categories": [ 20 | "Jan", 21 | "Feb", 22 | "Mar", 23 | "Apr", 24 | "May", 25 | "Jun", 26 | "Jul", 27 | "Aug", 28 | "Sep", 29 | "Oct", 30 | "Nov", 31 | "Dec" 32 | ] 33 | }, 34 | "series": [ 35 | { 36 | "name": "Predefined symbol", 37 | "marker": { 38 | "symbol": "triangle" 39 | }, 40 | "data": [ 41 | 29.9, 71.5, 106.4, 129.2, 144.0, 176.0, 135.6, 148.5, 316.4, 294.1, 42 | 195.6, 154.4 43 | ] 44 | }, 45 | { 46 | "name": "Image symbol", 47 | "marker": { 48 | "symbol": "url(https://www.highcharts.com/samples/graphics/sun.png)" 49 | }, 50 | "data": [ 51 | 216.4, 194.1, 95.6, 54.4, 29.9, 71.5, 106.4, 129.2, 144.0, 176.0, 52 | 135.6, 148.5 53 | ] 54 | }, 55 | { 56 | "name": "Base64 symbol (*)", 57 | "marker": { 58 | "symbol": "url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5Si +ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVi +pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+ 1dT1gvWd+ 1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx+ 1/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb+ 16EHTh0kX/i +c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADqYAAAOpgAABdvkl/FRgAAAVVJREFUeNpi/P37NwOxYM2pHtm7lw8uYmBgYGAiVtPC3RWh+88vuneT474Dv4DkcUZibJy8PG72le/nkn+zMzAaMhnNyY1clMpCjKbz/86lMLAzMMA0MTAwMOC1Ea6JgYFB9pPwncbMg6owOaY1p3pk15zqkcWnie8j63ddY18nZHmWI2eW3vzN/Jf168c3UfGuHathAXHl+7lkBnYGBtafDP8NVd3jQ8xKHiNrZMyeqPPtE/9vTgYGBgb1H4oHlHXt43ZfWfDwNzsDIwMDA4POX831RXGrg9BdxLhob63VgTurjsAUsv5k+A9jC3/g/NCdfVoQm/+ZIu3qjhnyW3XABJANMNL19cYVcPBQrZpq9eyFwCdJmIT6D8UD5cmbHXFphKccI9Mgc84vTH9goYhPE4rGELOSx0bSjsUMDAwMunJ2FQST0+/fv1Hw5BWJbehi2DBgAHTKsWmiz+rJAAAAAElFTkSuQmCC)" 59 | }, 60 | "data": [ 61 | 106.4, 129.2, 144.0, 176.0, 135.6, 148.5, 216.4, 194.1, 95.6, 54.4, 62 | 29.9, 71.5 63 | ] 64 | } 65 | ] 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/node/scenarios/type_jpeg.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "type": "jpeg", 4 | "options": { 5 | "title": { 6 | "text": "Chart type set to JPEG" 7 | }, 8 | "xAxis": { 9 | "categories": ["Jan", "Feb", "Mar", "Apr"] 10 | }, 11 | "series": [ 12 | { 13 | "type": "column", 14 | "data": [5, 6, 7, 8] 15 | }, 16 | { 17 | "type": "line", 18 | "data": [1, 2, 3, 4] 19 | } 20 | ] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/node/scenarios/type_pdf.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "type": "pdf", 4 | "options": { 5 | "title": { 6 | "text": "Chart type set to PDF" 7 | }, 8 | "xAxis": { 9 | "categories": ["Jan", "Feb", "Mar", "Apr"] 10 | }, 11 | "series": [ 12 | { 13 | "type": "column", 14 | "data": [5, 6, 7, 8] 15 | }, 16 | { 17 | "type": "line", 18 | "data": [1, 2, 3, 4] 19 | } 20 | ] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/node/scenarios/type_png.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "type": "png", 4 | "options": { 5 | "title": { 6 | "text": "Chart type set to PNG" 7 | }, 8 | "xAxis": { 9 | "categories": ["Jan", "Feb", "Mar", "Apr"] 10 | }, 11 | "series": [ 12 | { 13 | "type": "column", 14 | "data": [5, 6, 7, 8] 15 | }, 16 | { 17 | "type": "line", 18 | "data": [1, 2, 3, 4] 19 | } 20 | ] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/node/scenarios/type_svg.json: -------------------------------------------------------------------------------- 1 | { 2 | "export": { 3 | "type": "svg", 4 | "options": { 5 | "title": { 6 | "text": "Chart type set to SVG" 7 | }, 8 | "xAxis": { 9 | "categories": ["Jan", "Feb", "Mar", "Apr"] 10 | }, 11 | "series": [ 12 | { 13 | "type": "column", 14 | "data": [5, 6, 7, 8] 15 | }, 16 | { 17 | "type": "line", 18 | "data": [1, 2, 3, 4] 19 | } 20 | ] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/other/private_range_url.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import 'colors'; 16 | 17 | import { isPrivateRangeUrlFound } from '../../lib/utils.js'; 18 | 19 | // Test message 20 | console.log( 21 | 'The isPrivateRangeUrlFound utility test'.yellow, 22 | `\nIt checks multiple IPs and finds which are public and private.\n`.green 23 | ); 24 | 25 | // IP adresses to test 26 | const ipAddresses = [ 27 | // The localhost 28 | 'localhost', 29 | '127.0.0.1', 30 | // Private range (10.0.0.0/8) 31 | '10.151.223.167', 32 | '10.190.93.233', 33 | // Private range (172.0.0.0/12) 34 | '172.22.34.250', 35 | '172.27.95.8', 36 | // Private range (192.168.0.0/16) 37 | '192.168.218.176', 38 | '192.168.231.157', 39 | // Public range 40 | '53.96.110.150', 41 | '155.212.200.223' 42 | ]; 43 | 44 | // Test ips in different configurations, with or without a protocol prefix 45 | ['', 'http://', 'https://'].forEach((protocol) => { 46 | if (protocol) { 47 | console.log(`\n${protocol}`.blue.underline); 48 | } 49 | 50 | ipAddresses.forEach((ip) => { 51 | const url = `${protocol}${ip}`; 52 | console.log( 53 | `${url} - ` + 54 | (isPrivateRangeUrlFound(`xlink:href="${url}"`) 55 | ? 'private IP'.red 56 | : 'public IP'.green) 57 | ); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /tests/other/side_by_side.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import { fetch } from '../../lib/fetch.js'; 16 | import { exec as spawn } from 'child_process'; 17 | import { existsSync, mkdirSync } from 'fs'; 18 | import { join } from 'path'; 19 | 20 | import 'colors'; 21 | 22 | import { __dirname } from '../../lib/utils.js'; 23 | 24 | // Results paths 25 | const resultsPath = join(__dirname, 'tests', 'other', '_results'); 26 | 27 | // Create results folder for CLI exports if doesn't exist 28 | !existsSync(resultsPath) && mkdirSync(resultsPath); 29 | 30 | // Urls of Puppeteer and PhantomJS export servers 31 | const urls = ['http://127.0.0.1:7801', 'http://127.0.0.1:7802']; 32 | 33 | // Test message 34 | console.log( 35 | 'Highcharts Export Server side by side comparator'.yellow, 36 | `\nPuppeteer: ${urls[0]}`.green, 37 | `\nPhantomJS: ${urls[1]}\n`.blue 38 | ); 39 | 40 | try { 41 | // Run for both servers 42 | for (const [index, url] of urls.entries()) { 43 | // Perform a health check before continuing 44 | fetch(`${url}/health`) 45 | .then(() => { 46 | // And all types 47 | for (const type of ['png', 'jpeg', 'svg', 'pdf']) { 48 | // Results folder path 49 | const resultsFile = join( 50 | resultsPath, 51 | (index ? 'phantom_' : 'puppeteer_') + `chart.${type}` 52 | ); 53 | 54 | // Payload body 55 | const payload = JSON.stringify({ 56 | infile: { 57 | title: { 58 | text: index 59 | ? 'Phantom Export Server' 60 | : 'Puppeteer Export Server' 61 | }, 62 | xAxis: { 63 | categories: ['Jan', 'Feb', 'Mar', 'Apr'] 64 | }, 65 | series: [ 66 | { 67 | type: 'line', 68 | data: [1, 3, 2, 4] 69 | }, 70 | { 71 | type: 'line', 72 | data: [5, 3, 4, 2] 73 | } 74 | ] 75 | }, 76 | type, 77 | scale: 2, 78 | callback: 79 | "function callback(chart){chart.renderer.label('This label is added in the callback',75,75).attr({id:'renderer-callback-label',fill:'#90ed7d',padding:10,r:10,zIndex:10}).css({color:'black',width:'100px'}).add();}" 80 | }); 81 | 82 | // Complete the curl command 83 | const command = [ 84 | 'curl', 85 | '-H "Content-Type: application/json"', 86 | '-X POST', 87 | '-d', 88 | // Stringify again for a correct format for both Unix and Windows 89 | JSON.stringify(payload), 90 | url, 91 | '-o', 92 | resultsFile 93 | ].join(' '); 94 | 95 | // The start date of a POST request 96 | const startDate = new Date().getTime(); 97 | 98 | // Launch command in a new process 99 | // eslint-disable-next-line no-global-assign 100 | process = spawn(command); 101 | 102 | // Close event for a process 103 | process.on('close', () => { 104 | const message = `Done with ${ 105 | index ? '[PhantomJS]' : '[Puppeteer]' 106 | } ${type} export, took ${new Date().getTime() - startDate}ms.`; 107 | 108 | console.log(index ? message.blue : message.green); 109 | }); 110 | } 111 | }) 112 | .catch((error) => { 113 | if (error.code === 'ECONNREFUSED') { 114 | return console.log( 115 | `[ERROR] Couldn't connect to ${url}.`.red, 116 | `Set your server before running tests.`.red 117 | ); 118 | } 119 | }); 120 | } 121 | } catch (error) { 122 | console.error(error); 123 | } 124 | -------------------------------------------------------------------------------- /tests/other/stress_test.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | Highcharts Export Server 4 | 5 | Copyright (c) 2016-2024, Highsoft 6 | 7 | Licenced under the MIT licence. 8 | 9 | Additionally a valid Highcharts license is required for use. 10 | 11 | See LICENSE file in root for details. 12 | 13 | *******************************************************************************/ 14 | 15 | import { fetch, post } from '../../lib/fetch.js'; 16 | import 'colors'; 17 | 18 | // Test message 19 | console.log( 20 | 'Highcharts Export Server stress test'.yellow, 21 | `\nIt sends a certain number of requests in a certain interval`.green 22 | ); 23 | 24 | // The request options 25 | const requestBody = { 26 | type: 'svg', 27 | infile: { 28 | title: { 29 | text: 'Chart' 30 | }, 31 | xAxis: { 32 | categories: ['Jan', 'Feb', 'Mar'] 33 | }, 34 | series: [ 35 | { 36 | data: [29.9, 71.5, 106.4] 37 | } 38 | ] 39 | } 40 | }; 41 | 42 | const url = 'http://127.0.0.1:7801/'; 43 | const requestsNumber = 1; 44 | const interval = 150; 45 | 46 | const stressTest = () => { 47 | for (let i = 1; i <= requestsNumber; i++) { 48 | const startTime = new Date().getTime(); 49 | 50 | // Perform a request 51 | post(url, requestBody) 52 | .then(async (res) => { 53 | const postTime = new Date().getTime() - startTime; 54 | console.log(`${i} request is done, took ${postTime}ms`); 55 | console.log(`---\n${res.text}\n---`); 56 | }) 57 | .catch((error) => { 58 | return console.log(`[${i}] request returned error: ${error}`); 59 | }); 60 | } 61 | }; 62 | 63 | // Perform a health check before continuing 64 | fetch(`${url}/health`) 65 | .then(() => { 66 | stressTest(); 67 | setInterval(stressTest, interval); 68 | }) 69 | .catch((error) => { 70 | if (error.code === 'ECONNREFUSED') { 71 | return console.log( 72 | `[ERROR] Couldn't connect to ${url}.`.red, 73 | `Set your server before running tests.`.red 74 | ); 75 | } 76 | }); 77 | -------------------------------------------------------------------------------- /tests/unit/cache.test.js: -------------------------------------------------------------------------------- 1 | // cacheManager.test.js 2 | import { extractVersion, extractModuleName } from '../../lib/cache'; 3 | 4 | describe('extractVersion', () => { 5 | it('should extract the Highcharts version correctly', () => { 6 | const cache = { sources: '/* Highcharts 9.3.2 */' }; 7 | 8 | const version = extractVersion(cache); 9 | expect(version).toBe('Highcharts 9.3.2'); 10 | }); 11 | }); 12 | 13 | describe('extractModuleName', () => { 14 | it('should extract the module name from a given script path', () => { 15 | const paths = [ 16 | { input: 'modules/exporting', expected: 'exporting' }, 17 | { input: 'maps/modules/map', expected: 'map' } 18 | ]; 19 | 20 | paths.forEach(({ input, expected }) => { 21 | expect(extractModuleName(input)).toBe(expected); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/unit/envs.test.js: -------------------------------------------------------------------------------- 1 | import { Config } from '../../lib/envs'; 2 | 3 | describe('Environment variables should be correctly parsed', () => { 4 | test('PUPPETEER_TEMP_DIR should be a valid path', () => { 5 | const env = { PUPPETEER_TEMP_DIR: '/path/to/dir' }; 6 | expect(Config.partial().parse(env).PUPPETEER_TEMP_DIR).toEqual( 7 | '/path/to/dir' 8 | ); 9 | 10 | env.PUPPETEER_TEMP_DIR = '/another/path/to/dir'; 11 | expect(Config.partial().parse(env).PUPPETEER_TEMP_DIR).toEqual( 12 | '/another/path/to/dir' 13 | ); 14 | 15 | env.PUPPETEER_TEMP_DIR = ''; 16 | expect(() => Config.partial().parse(env)).toThrow(); 17 | }); 18 | 19 | test('PUPPETEER_TEMP_DIR can be a relative path', () => { 20 | const env = { PUPPETEER_TEMP_DIR: './tmp/' }; 21 | expect(Config.partial().parse(env).PUPPETEER_TEMP_DIR).toEqual('./tmp/'); 22 | 23 | env.PUPPETEER_TEMP_DIR = '../custom-tmp/'; 24 | expect(Config.partial().parse(env).PUPPETEER_TEMP_DIR).toEqual( 25 | '../custom-tmp/' 26 | ); 27 | }); 28 | 29 | test('HIGHCHARTS_VERSION accepts latests and not unrelated strings', () => { 30 | const env = { HIGHCHARTS_VERSION: 'string-other-than-latest' }; 31 | expect(() => Config.partial().parse(env)).toThrow(); 32 | 33 | env.HIGHCHARTS_VERSION = 'latest'; 34 | expect(Config.partial().parse(env).HIGHCHARTS_VERSION).toEqual('latest'); 35 | }); 36 | 37 | test('HIGHCHARTS_VERSION accepts proper version strings like XX.YY.ZZ', () => { 38 | const env = { HIGHCHARTS_VERSION: '11' }; 39 | expect(Config.partial().parse(env).HIGHCHARTS_VERSION).toEqual('11'); 40 | 41 | env.HIGHCHARTS_VERSION = '11.0.0'; 42 | expect(Config.partial().parse(env).HIGHCHARTS_VERSION).toEqual('11.0.0'); 43 | 44 | env.HIGHCHARTS_VERSION = '9.1'; 45 | expect(Config.partial().parse(env).HIGHCHARTS_VERSION).toEqual('9.1'); 46 | 47 | env.HIGHCHARTS_VERSION = '11a.2.0'; 48 | expect(() => Config.partial().parse(env)).toThrow(); 49 | }); 50 | 51 | test('HIGHCHARTS_CDN_URL should start with http:// or https://', () => { 52 | const env = { HIGHCHARTS_CDN_URL: 'http://example.com' }; 53 | expect(Config.partial().parse(env).HIGHCHARTS_CDN_URL).toEqual( 54 | 'http://example.com' 55 | ); 56 | 57 | env.HIGHCHARTS_CDN_URL = 'https://example.com'; 58 | expect(Config.partial().parse(env).HIGHCHARTS_CDN_URL).toEqual( 59 | 'https://example.com' 60 | ); 61 | 62 | env.HIGHCHARTS_CDN_URL = 'example.com'; 63 | expect(() => Config.partial().parse(env)).toThrow(); 64 | }); 65 | 66 | test('CORE, MODULE, INDICATOR scripts should be arrays', () => { 67 | const env = { 68 | HIGHCHARTS_CORE_SCRIPTS: 'core1, core2, highcharts', 69 | HIGHCHARTS_MODULE_SCRIPTS: 'module1, map, module2', 70 | HIGHCHARTS_INDICATOR_SCRIPTS: 'indicators-all, indicator1, indicator2' 71 | }; 72 | 73 | const parsed = Config.partial().parse(env); 74 | 75 | expect(parsed.HIGHCHARTS_CORE_SCRIPTS).toEqual(['highcharts']); 76 | expect(parsed.HIGHCHARTS_MODULE_SCRIPTS).toEqual(['map']); 77 | expect(parsed.HIGHCHARTS_INDICATOR_SCRIPTS).toEqual(['indicators-all']); 78 | }); 79 | 80 | test('HIGHCHARTS_FORCE_FETCH should be a boolean', () => { 81 | const env = { HIGHCHARTS_FORCE_FETCH: 'true' }; 82 | expect(Config.partial().parse(env).HIGHCHARTS_FORCE_FETCH).toEqual(true); 83 | 84 | env.HIGHCHARTS_FORCE_FETCH = 'false'; 85 | expect(Config.partial().parse(env).HIGHCHARTS_FORCE_FETCH).toEqual(false); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /tests/unit/index.test.js: -------------------------------------------------------------------------------- 1 | describe('Simple Variable Comparison', () => { 2 | it('should compare two variables for equality', () => { 3 | const variable1 = 42; 4 | const variable2 = 42; 5 | 6 | expect(variable1).toBe(variable2); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /tests/unit/sanitize.test.js: -------------------------------------------------------------------------------- 1 | import { sanitize } from '../../lib/sanitize.js'; 2 | 3 | describe('sanitize', () => { 4 | it('removes simple script tags', () => { 5 | const input = ' Hello World!'; 6 | const output = sanitize(input); 7 | expect(output).toBe('Hello World!'); 8 | }); 9 | 10 | it('removes nested script tags', () => { 11 | const input = '
'; 12 | const output = sanitize(input); 13 | expect(output).toBe('
'); 14 | }); 15 | 16 | it('removes script tags with attributes', () => { 17 | const input = 18 | ' Hello World!'; 19 | const output = sanitize(input); 20 | expect(output).toBe('Hello World!'); 21 | }); 22 | 23 | it('removes script tags regardless of case', () => { 24 | const input = ' Hello World!'; 25 | const output = sanitize(input); 26 | expect(output).toBe('Hello World!'); 27 | }); 28 | 29 | it('removes multiple script tags', () => { 30 | const input = 31 | 'Hello World!'; 32 | const output = sanitize(input); 33 | expect(output).toBe('Hello World!'); 34 | }); 35 | 36 | it('does not remove non-script tags', () => { 37 | const input = '
Hello World!
'; 38 | const output = sanitize(input); 39 | expect(output).toBe('
Hello World!
'); 40 | }); 41 | 42 | it('handles malformed script tags', () => { 43 | const input = '