├── .dockerignore ├── .gitignore ├── .npmignore ├── README.md ├── bin └── cli.js ├── package-lock.json ├── package.json ├── src ├── index.ts ├── logger.ts ├── plugins.ts ├── proxy-cache.ts ├── ssr-build.ts ├── ssr-proxy.ts ├── ssr-render.ts ├── types.ts └── utils.ts ├── test ├── build.ts ├── docker │ ├── docker-compose.yml │ ├── nginx.Dockerfile │ ├── nginx.conf │ ├── ssr-proxy-js.config.json │ └── ssr.Dockerfile ├── package-lock.json ├── package.json ├── proxy.js ├── proxy.ts ├── public │ ├── iframe.html │ ├── index.css │ ├── index.html │ ├── index.js │ ├── nested.dot │ │ ├── index.dot.css │ │ └── index.html │ ├── nested │ │ ├── index.css │ │ └── index.html │ └── page.html ├── ssr-build-js.config.json ├── ssr-proxy-js.config.json ├── test-request.js ├── test-stream.js └── tsconfig.json ├── tsconfig.json └── webpack.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | # Node.js .dockerignore 2 | 3 | **/dist 4 | **/logs 5 | **/*.log 6 | **/node_modules/ 7 | **/npm-debug.log 8 | **/.git 9 | **/.vscode 10 | **/.gitignore 11 | **/.dockerignore 12 | **/README.md 13 | **/LICENSE 14 | **/.editorconfig 15 | **/Dockerfile 16 | **/*.Dockerfile 17 | **/docs 18 | **/.github -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node.js .gitignore 2 | 3 | **/dist 4 | **/node_modules 5 | output.html 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | pnpm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | lerna-debug.log* 15 | 16 | # OS 17 | .DS_Store 18 | 19 | # Tests 20 | /coverage 21 | /.nyc_output 22 | 23 | # IDEs and editors 24 | /.idea 25 | .project 26 | .classpath 27 | .c9/ 28 | *.launch 29 | .settings/ 30 | *.sublime-workspace 31 | 32 | # IDE - VSCode 33 | .vscode/* 34 | !.vscode/settings.json 35 | !.vscode/tasks.json 36 | !.vscode/launch.json 37 | !.vscode/extensions.json -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![NuGet Status](https://img.shields.io/npm/v/ssr-proxy-js)](https://www.npmjs.com/package/ssr-proxy-js) 2 | [![NuGet Status](https://img.shields.io/npm/dt/ssr-proxy-js)](https://www.npmjs.com/package/ssr-proxy-js) 3 | 4 | # SSRProxy.js 5 | 6 | Modes:\ 7 | [SSR Build - Static Site Generator](#ssr-build-static-site-generator-mode)\ 8 | [SSR Proxy - Server Rendering](#ssr-proxy-server-rendering-mode) 9 | 10 | A Node.js tool for Server-Side Rendering (SSR) and Static Site Generation (SSG) using headless Chrome via Puppeteer. 11 | 12 | Server-Side Rendering, or SSR for short, is a technique used to serve Single-Page Applications (SPAs, e.g. React.js, Vue.js and Angular based websites) with Web Crawlers in mind, such as Googlebot. Crawlers are used everywhere in the internet to a variety of objectives, with the most known being for indexing the web for search engines, which is done by companies such as Google (Googlebot), Bing (Bingbot) and DuckDuckGo (DuckDuckBot). 13 | 14 | The main problem of serving SPAs "normally" (i.e. Client-Side Rendering) is that when your website is accessed by a Web Crawler, it's usually only able to read the source HTML code, which most probably does not represent your actual website. In case of a React App, for example, a Crawler might be only able to interpret your website like so: 15 | 16 | ```html 17 | 18 | 19 | 20 | 21 | React App 22 | 23 | 24 |
25 | 26 | 27 | 28 | ``` 29 | 30 | For the contents of a SPA to be correct, the JavaScript files should be loaded and executed by the browser, and that's where Server-Side Rendering plays a big role. SSR will receive the HTTP request from the client, create a browser instance, load the page just like we do while surfing the web, and just then return the actual rendered HTML to the request, after the SPA is fully loaded. 31 | 32 | The implemantation of this package is hugelly inspired by an article from Google, using Pupperteer as it's engine: 33 | https://developers.google.com/web/tools/puppeteer/articles/ssr 34 | 35 | The main problem regarding the workflow described above is that the process of rendering the web page through a browser takes some time, so if done incorrectly, it might have a big impact on the users experience. That's why this package also comes with two essencial feature: **Caching**, **Fallbacks** and **Static Site Generation**. 36 | 37 | --- 38 | 39 | ## SSR Build (Static Site Generator mode) 40 | 41 | Build pre-rendered pages to serve to your users, without any added server complexity or extra response delay, using your server tool of choice (e g. nginx). 42 | 43 | If all your content is static, meaning it won't change dependending on who or how your pages are accessed, you can pre-build all your routes using the `--mode=build` option, instead of building in real time with the default SSR Proxy mode. This will access all your pre-defined routes in build time, render the HTML, and save the resulting content back to a dist folder. You can then serve your dist folder instead of serving your original non pre-rendered bundle. 44 | 45 | ### npx Example 46 | 47 | **Commands** 48 | ```bash 49 | # With Args 50 | npx ssr-proxy-js --mode=build --src=./public --dist=./dist --job.routes='[{"url":"/"},{"url":"/nested"}]' 51 | 52 | # With Config File 53 | npx ssr-proxy-js --mode=build -c ./ssr-build-js.config.json 54 | ``` 55 | 56 | **Config File** 57 | ```javascript 58 | // ./ssr-build-js.config.json 59 | { 60 | "src": "./public", 61 | "dist": "./src", 62 | "job": { 63 | "routes": [ 64 | { "method": "GET", "url": "/" }, 65 | { "method": "GET", "url": "/nested" } 66 | ] 67 | } 68 | } 69 | ``` 70 | 71 | ### Simple Example 72 | 73 | ```javascript 74 | const { SsrBuild } = require('ssr-proxy-js'); 75 | 76 | const ssrBuild = new SsrBuild({ 77 | "src": "./public", 78 | "dist": "./src", 79 | "job": { 80 | "routes": [ 81 | { "method": "GET", "url": "/" }, 82 | { "method": "GET", "url": "/nested" } 83 | ] 84 | } 85 | }); 86 | 87 | ssrBuild.start(); 88 | ``` 89 | 90 | ### Full Example 91 | 92 | ```typescript 93 | import * as os from 'os'; 94 | import * as path from 'path'; 95 | import { LogLevel, SsrBuild, SsrBuildConfig } from 'ssr-proxy-js'; 96 | 97 | const config: SsrBuildConfig = { 98 | httpPort: 8080, 99 | hostname: 'localhost', 100 | src: 'public', 101 | dist: 'dist', 102 | stopOnError: false, 103 | serverMiddleware: async (req, res, next) => { 104 | res.sendFile(path.join(__dirname, 'public/index.html')); 105 | }, 106 | reqMiddleware: async (params) => { 107 | params.headers['Referer'] = 'http://google.com'; 108 | return params; 109 | }, 110 | resMiddleware: async (params, result) => { 111 | if (result.text == null) return result; 112 | result.text = result.text.replace('', '\n\t
MIDDLEWARE
\n'); 113 | result.text = result.text.replace(/]*>[\s\S]*?<\/style>/gi, ''); 114 | return result; 115 | }, 116 | ssr: { 117 | browserConfig: { headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], timeout: 60000 }, 118 | sharedBrowser: true, 119 | queryParams: [{ key: 'headless', value: 'true' }], 120 | allowedResources: ['document', 'script', 'xhr', 'fetch'], 121 | waitUntil: 'networkidle0', 122 | timeout: 60000, 123 | }, 124 | log: { 125 | level: LogLevel.Info, 126 | console: { 127 | enabled: true, 128 | }, 129 | file: { 130 | enabled: true, 131 | dirPath: path.join(os.tmpdir(), 'ssr-proxy-js/logs'), 132 | }, 133 | }, 134 | job: { 135 | retries: 3, 136 | parallelism: 5, 137 | routes: [{ method: 'GET', url: '/' },{ method: 'GET', url: '/nested' },{ method: 'GET', url: '/page.html' },{ method: 'GET', url: '/iframe.html' }], 138 | }, 139 | }; 140 | 141 | const ssrBuild = new SsrBuild(config); 142 | 143 | ssrBuild.start(); 144 | ``` 145 | 146 | --- 147 | 148 | ## SSR Proxy (Server Rendering mode) 149 | 150 | Proxy your requests via the SSR server to serve pre-rendered pages to your users. 151 | 152 | > Note: to ensure the best security and performance, it's adivisable to use this proxy behind a reverse proxy, such as [Nginx](https://www.nginx.com/). 153 | 154 | ### npx Example 155 | 156 | **Commands** 157 | ```bash 158 | # With Args 159 | npx ssr-proxy-js --httpPort=8080 --targetRoute=http://localhost:3000 --static.dirPath=./public --proxyOrder=SsrProxy --proxyOrder=StaticProxy 160 | 161 | # With Config File 162 | npx ssr-proxy-js -c ./ssr-proxy-js.config.json 163 | ``` 164 | 165 | **Config File** 166 | ```javascript 167 | // ./ssr-proxy-js.config.json 168 | { 169 | "httpPort": 8080, 170 | "targetRoute": "http://localhost:3000" 171 | } 172 | ``` 173 | 174 | ### Simple Example 175 | 176 | ```javascript 177 | const { SsrProxy } = require('ssr-proxy-js'); 178 | 179 | const ssrProxy = new SsrProxy({ 180 | httpPort: 8080, 181 | targetRoute: 'http://localhost:3000' 182 | }); 183 | 184 | ssrProxy.start(); 185 | ``` 186 | 187 | ### Full Example 188 | 189 | ```javascript 190 | const os = require('os'); 191 | const path = require('path'); 192 | const { SsrProxy } = require('ssr-proxy-js-local'); 193 | 194 | const BASE_PROXY_PORT = '8080'; 195 | const BASE_PROXY_ROUTE = `http://localhost:${BASE_PROXY_PORT}`; 196 | const STATIC_FILES_PATH = path.join(process.cwd(), 'public'); 197 | const LOGGING_PATH = path.join(os.tmpdir(), 'ssr-proxy/logs'); 198 | 199 | console.log(`\nLogging at: ${LOGGING_PATH}`); 200 | 201 | const ssrProxy = new SsrProxy({ 202 | httpPort: 8081, 203 | hostname: '0.0.0.0', 204 | targetRoute: BASE_PROXY_ROUTE, 205 | proxyOrder: ['SsrProxy', 'HttpProxy', 'StaticProxy'], 206 | isBot: (method, url, headers) => true, 207 | failStatus: params => 404, 208 | customError: err => err.toString(), 209 | ssr: { 210 | shouldUse: params => params.isBot && (/\.html$/.test(params.targetUrl.pathname) || !/\./.test(params.targetUrl.pathname)), 211 | browserConfig: { headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], timeout: 60000 }, 212 | queryParams: [{ key: 'headless', value: 'true' }], 213 | allowedResources: ['document', 'script', 'xhr', 'fetch'], 214 | waitUntil: 'networkidle0', 215 | timeout: 60000, 216 | }, 217 | httpProxy: { 218 | shouldUse: params => true, 219 | timeout: 60000, 220 | }, 221 | static: { 222 | shouldUse: params => true, 223 | dirPath: STATIC_FILES_PATH, 224 | useIndexFile: path => path.endsWith('/'), 225 | indexFile: 'index.html', 226 | }, 227 | log: { 228 | level: 3, 229 | console: { 230 | enabled: true, 231 | }, 232 | file: { 233 | enabled: true, 234 | dirPath: LOGGING_PATH, 235 | }, 236 | }, 237 | cache: { 238 | shouldUse: params => params.proxyType === 'SsrProxy', 239 | maxEntries: 50, 240 | maxByteSize: 50 * 1024 * 1024, // 50MB 241 | expirationMs: 25 * 60 * 60 * 1000, // 25h 242 | autoRefresh: { 243 | enabled: true, 244 | shouldUse: () => true, 245 | proxyOrder: ['SsrProxy', 'HttpProxy'], 246 | initTimeoutMs: 5 * 1000, // 5s 247 | intervalCron: '0 0 3 * * *', // every day at 3am 248 | intervalTz: 'Etc/UTC', 249 | retries: 3, 250 | parallelism: 5, 251 | closeBrowser: true, 252 | isBot: true, 253 | routes: [ 254 | { method: 'GET', url: '/' }, 255 | { method: 'GET', url: '/login' }, 256 | ], 257 | }, 258 | }, 259 | }); 260 | 261 | ssrProxy.start(); 262 | ``` 263 | 264 | ### Caching 265 | 266 | Caching allows us to increase the performance of the web serving by preventing excessive new renders for web pages that have been accessed recently. Caching is highly configurable to allow total control of the workflow, for example, it's possible to decide if cache should or shouldn't be used each time the website is accessed, with the "shouldUse" option. Also, it's possible to configure a automatic cache refresh, using the "cache.autoRefresh" configuration. 267 | 268 | ### Fallbacks 269 | 270 | In case of a human user access, we can serve the web site the "normal" way, without asking the SSR to pre-render the page. For that it's possible to use 3 types of proxies: SSR Proxy, HTTP Proxy or Static File Serving, in any order that you see fit. Firstly, the order of priority should be configured with the "proxyOrder" option, so for example, if configured as ['SsrProxy', 'HttpProxy', 'StaticProxy'], "ssr.shouldUse" will ask if SSR should be used, if it returns false, then "httpProxy.shouldUse" will ask if HTTP Proxy should be used, and finally, "static.shouldUse" will ask if Static File Serving should be used. If the return of all proxy options is false, or if one of then returns a exception (e.g. page not found), the web server will return a empty HTTP response with status equals to the return of the "failStatus" callback. 271 | 272 | ## More options 273 | 274 | For further options, check: 275 | 276 | Example: https://github.com/Tpessia/ssr-proxy-js/tree/main/test 277 | 278 | Types: https://github.com/Tpessia/ssr-proxy-js/blob/main/src/types.ts 279 | 280 | 284 | 285 | 307 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // cd test && npx ssr-proxy-js-local 4 | 5 | // npx ssr-proxy-js 6 | // npx ssr-proxy-js -c ./ssr-proxy-js.config.json 7 | // npx ssr-proxy-js --httpPort=8080 --targetRoute=http://localhost:3000 --static.dirPath=./public --proxyOrder=SsrProxy --proxyOrder=StaticProxy --log.level=3 8 | 9 | const fs = require('fs'); 10 | const path = require('path'); 11 | const minimist = require('minimist'); 12 | const deepmerge = require('deepmerge'); 13 | const { SsrProxy, SsrBuild } = require('../dist/index'); 14 | 15 | const argv = minimist(process.argv.slice(2)); 16 | 17 | const { _: argv_, mode: argv_mode, c: argv_c, config: argv_config, ...argv_rest } = argv; 18 | const explicitConfig = !!(argv_c || argv_config); 19 | 20 | if (!!argv_mode && argv_mode !== 'proxy' && argv_mode !== 'build') { 21 | logWarn('Invalid mode, must be either "proxy" or "build"'); 22 | process.exit(1); 23 | } 24 | 25 | const mode = argv_mode || 'proxy'; 26 | 27 | const options = { }; 28 | options.configPath = argv_c || argv_config || (mode === 'proxy' ? './ssr-proxy-js.config.json' : './ssr-build-js.config.json'); 29 | options.configPath = path.resolve(process.cwd(), options.configPath); 30 | 31 | try { 32 | if (options.configPath) 33 | options.configJson = fs.readFileSync(options.configPath, { encoding: 'utf8' }); 34 | } catch (err) { 35 | if (explicitConfig) 36 | logWarn(`Unable to find the config, looking for: ${options.configPath}`, err); 37 | } 38 | 39 | try { 40 | if (options.configJson) options.config = JSON.parse(options.configJson); 41 | else options.config = {}; 42 | } catch (err) { 43 | logWarn('Unable to parse the config', err); 44 | } 45 | 46 | if (typeof argv_rest?.cache?.autoRefresh?.routes === 'string') argv_rest.cache.autoRefresh.routes = JSON.parse(argv_rest.cache.autoRefresh.routes); 47 | if (typeof argv_rest?.job?.routes === 'string') argv_rest.job.routes = JSON.parse(argv_rest.job.routes); 48 | 49 | options.config = deepmerge(options.config, argv_rest, { 50 | arrayMerge: (destArray, srcArray, opts) => srcArray, 51 | }); 52 | 53 | if (isEmpty(options.config)) { 54 | logWarn('No config file or cli arguments found!'); 55 | } 56 | 57 | if (mode === 'proxy') { 58 | const ssrProxy = new SsrProxy(options.config); 59 | ssrProxy.start(); 60 | } else if (mode === 'build') { 61 | const ssrBuild = new SsrBuild(options.config); 62 | ssrBuild.start(); 63 | } 64 | 65 | // Utils 66 | 67 | function logWarn(...msg) { 68 | if (!msg || !msg.length) return; 69 | msg[0] = '\x1b[33m' + msg[0]; 70 | msg[msg.length - 1] += '\x1b[0m'; 71 | console.log(...msg); 72 | } 73 | 74 | function isEmpty(obj) { 75 | return obj && Object.keys(obj).length === 0 && Object.getPrototypeOf(obj) === Object.prototype; 76 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ssr-proxy-js", 3 | "version": "2.1.6", 4 | "description": "Server-Side Rendering Proxy", 5 | "keywords": [ 6 | "ssr", 7 | "proxy", 8 | "spa" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/Tpessia/ssr-proxy-js" 13 | }, 14 | "author": "Thiago Pessia", 15 | "license": "MIT", 16 | "main": "./dist/index.js", 17 | "types": "./dist/index.d.ts", 18 | "bin": "./bin/cli.js", 19 | "files": [ 20 | "bin/**/*", 21 | "dist/**/*", 22 | "src/**/*" 23 | ], 24 | "scripts": { 25 | "init": "npm i -g np && npm i", 26 | "build:dev": "rimraf dist && cross-env NODE_ENV=development webpack --config webpack.config.js --mode development --watch", 27 | "build": "rimraf dist && cross-env NODE_ENV=production webpack --config webpack.config.js --mode production", 28 | "publish:pack": "npm pack", 29 | "publish:dry": "npm publish --dry-run", 30 | "publish:np": "npm run build && np --no-yarn --no-tests --branch=main --no-2fa" 31 | }, 32 | "dependencies": { 33 | "axios": "^1.7.2", 34 | "clone-deep": "^4.0.1", 35 | "deepmerge": "^4.3.1", 36 | "express": "^4.19.2", 37 | "isbot": "^5.1.13", 38 | "mime-types": "^2.1.35", 39 | "minimist": "^1.2.8", 40 | "node-schedule": "^2.1.1", 41 | "puppeteer": "^22.13.1", 42 | "winston": "^3.13.1", 43 | "winston-daily-rotate-file": "^4.7.1" 44 | }, 45 | "devDependencies": { 46 | "@types/clone-deep": "^4.0.4", 47 | "@types/express": "^4.17.21", 48 | "@types/mime-types": "^2.1.4", 49 | "@types/node-schedule": "^2.1.7", 50 | "cross-env": "^7.0.3", 51 | "rimraf": "^6.0.1", 52 | "ts-loader": "^9.5.1", 53 | "typescript": "^4.9.5", 54 | "webpack": "^5.93.0", 55 | "webpack-cli": "^4.10.0", 56 | "webpack-node-externals": "^3.0.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './logger'; 2 | export * from './plugins'; 3 | export * from './proxy-cache'; 4 | export * from './ssr-build'; 5 | export * from './ssr-proxy'; 6 | export * from './ssr-render'; 7 | export * from './types'; 8 | export * from './utils'; 9 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import winston from 'winston'; 3 | import 'winston-daily-rotate-file'; 4 | import { LogLevel } from './types'; 5 | 6 | export class Logger { 7 | private static logLevel: LogLevel = LogLevel.None; 8 | private static enableConsole?: boolean; 9 | private static fileLogger?: winston.Logger; 10 | 11 | loggerId?: number; 12 | loggerIdStr: string = ''; 13 | 14 | constructor(useId = true) { 15 | if (useId) { 16 | this.loggerId = Math.round(Math.random() * 99999); 17 | this.loggerIdStr = `[${this.loggerId}] `; 18 | } 19 | } 20 | 21 | error(errName: string, err: any, withStack: boolean = false) { 22 | Logger.error(errName, err, withStack, this.loggerIdStr); 23 | } 24 | 25 | warn(errName: string, err: any, withStack: boolean = false) { 26 | Logger.warn(errName, err, withStack, this.loggerIdStr); 27 | } 28 | 29 | info(msg: string) { 30 | Logger.info(msg, this.loggerIdStr); 31 | } 32 | 33 | debug(msg: string) { 34 | Logger.debug(msg, this.loggerIdStr); 35 | } 36 | 37 | static error(errName: string, err: any, withStack: boolean = false, prefix: string = '') { 38 | const logMsg = `${this.logPrefix()}${prefix}${errName}: ${this.errorStr(err)}${(withStack && ('\n' + err.stack)) || ''}`; 39 | if (this.logLevel >= 1) { 40 | if (this.enableConsole) console.log(`\x1b[31m${logMsg}\x1b[0m`); 41 | if (this.fileLogger) this.fileLogger.error(logMsg); 42 | } 43 | } 44 | 45 | static warn(errName: string, err: any, withStack: boolean = false, prefix: string = '') { 46 | const logMsg = `${this.logPrefix()}${prefix}${errName}: ${this.errorStr(err)}${(withStack && ('\n' + err.stack)) || ''}`; 47 | if (this.logLevel >= 2) { 48 | if (this.enableConsole) console.log(`\x1b[33m${logMsg}\x1b[0m`); 49 | if (this.fileLogger) this.fileLogger.warn(logMsg); 50 | } 51 | } 52 | 53 | static info(msg: string, prefix: string = '') { 54 | const logMsg = `${this.logPrefix()}${prefix}${msg}`; 55 | if (this.logLevel >= 2) { 56 | if (this.enableConsole) console.log(`\x1b[37m${logMsg}\x1b[0m`); 57 | if (this.fileLogger) this.fileLogger.info(logMsg); 58 | } 59 | } 60 | 61 | static debug(msg: string, prefix: string = '') { 62 | const logMsg = `${this.logPrefix()}${prefix}${msg}`; 63 | if (this.logLevel >= 3) { 64 | if (this.enableConsole) console.log(`\x1b[34m${logMsg}\x1b[0m`); 65 | if (this.fileLogger) this.fileLogger.debug(logMsg); 66 | } 67 | } 68 | 69 | static errorStr(err: any) { 70 | return err && (err.message || err.toString()); 71 | } 72 | 73 | static setLevel(level: LogLevel) { 74 | this.logLevel = level; 75 | } 76 | 77 | static configConsole(enable: boolean) { 78 | this.enableConsole = enable; 79 | } 80 | 81 | static configFile(enable: boolean, dirPath: string) { 82 | if (!enable || !dirPath) { 83 | this.fileLogger = undefined; 84 | } 85 | 86 | const transport = new winston.transports.DailyRotateFile({ 87 | filename: path.join(dirPath, 'log-%DATE%.log'), 88 | datePattern: 'YYYY-MM-DD', 89 | maxSize: '20m', 90 | maxFiles: '5d' 91 | }); 92 | 93 | const logger = winston.createLogger({ 94 | exitOnError: false, 95 | level: 'info', 96 | format: winston.format.combine( 97 | winston.format.timestamp(), 98 | winston.format.printf(info => `${info.timestamp} ${info.level}: ${info.message}`+(info.splat!==undefined?`${info.splat}`:" ")) 99 | ), 100 | transports: [transport] 101 | }); 102 | 103 | this.fileLogger = logger; 104 | } 105 | 106 | private static logPrefix() { 107 | return `[${new Date().toISOString()}] `; 108 | } 109 | } -------------------------------------------------------------------------------- /src/plugins.ts: -------------------------------------------------------------------------------- 1 | // https://rollupjs.org/plugin-development/#writebundle 2 | // https://vite.dev/guide/api-plugin 3 | 4 | import { SsrBuild } from './ssr-build'; 5 | import { SsrBuildConfig } from './types'; 6 | 7 | type Apply = 'serve' | 'build'; 8 | type Enforce = 'pre' | 'post' | undefined; 9 | type Order = 'pre' | 'post' | undefined; 10 | type Event = 'writeBundle' | 'buildEnd' | 'closeBundle' | (string & {}); 11 | 12 | export const ssrBuildVitePlugin = (config: SsrBuildConfig, pluginOverride?: { apply?: Apply, enforce?: Enforce, [key: string]: any; }) => { 13 | return { 14 | name: 'ssr-build-js', 15 | apply: 'build' as Apply, 16 | // enforce: 'pre' as Enforce, 17 | writeBundle: { 18 | sequential: true, 19 | // order: 'pre' as Order, 20 | async handler(outputOptions: any, bundle: any) { 21 | const ssrBuild = new SsrBuild(config); 22 | const result = await ssrBuild.start(); 23 | result.forEach(e => { 24 | const fileName = e.urlPath.replace(/^\/+/, ''); 25 | const duplicate = Object.keys(bundle).find(e => e === fileName); 26 | if (duplicate) delete bundle[duplicate]; 27 | (this as any).emitFile({ type: 'asset', fileName, source: e.text }); 28 | }); 29 | }, 30 | }, 31 | ...(pluginOverride || {}), 32 | }; 33 | }; -------------------------------------------------------------------------------- /src/proxy-cache.ts: -------------------------------------------------------------------------------- 1 | import { Stream } from 'stream'; 2 | import { CacheDeletion, CacheItem, InternalCacheItem } from './types'; 3 | import { streamToString } from './utils'; 4 | 5 | export class ProxyCache { 6 | private cache: Map = new Map(); 7 | 8 | maxEntries: number; 9 | maxSize: number; 10 | expirationMs: number; 11 | 12 | constructor(maxEntries: number = 10, maxSize: number = 10 * 1024 * 1024, expirationMs: number = 5 * 60 * 1000) { 13 | this.maxEntries = maxEntries; 14 | this.maxSize = maxSize; 15 | this.expirationMs = expirationMs; 16 | } 17 | 18 | has(urlStr: string) { 19 | return this.cache.has(urlStr); 20 | } 21 | 22 | keys() { 23 | return this.cache.keys(); 24 | } 25 | 26 | get(urlStr: string): CacheItem | null { 27 | const entry = this.cache.get(urlStr); 28 | if (!entry) return null; 29 | entry.hits++; 30 | return { text: entry.text, contentType: entry.contentType }; 31 | } 32 | 33 | set(urlStr: string, text: string, status: number, contentType: string) { 34 | return this.cache.set(urlStr, { text, status, contentType, hits: 0, date: new Date() }); 35 | } 36 | 37 | delete(urlStr: string) { 38 | return this.cache.delete(urlStr); 39 | } 40 | 41 | async pipe(urlStr: string, stream: Stream, status: number, contentType: string) { 42 | return await streamToString(stream).then(str => this.cache.set(urlStr, { text: str, status, contentType, hits: 0, date: new Date() })); 43 | } 44 | 45 | tryClear() { 46 | const $this = this; 47 | let cacheSize = 0; 48 | const entries = [...this.cache.entries()].sort((a, b) => b[1].hits - a[1].hits); 49 | 50 | const deleted: CacheDeletion[] = []; 51 | 52 | for (const i in entries) { 53 | const key = entries[i][0]; 54 | const entry = entries[i][1]; 55 | 56 | if (this.cache.has(key)) { 57 | cacheSize += cacheSize > this.maxSize ? cacheSize : Buffer.from(entry.text).length; 58 | 59 | const deleteBySize = cacheSize > this.maxSize; 60 | if (deleteBySize) deleteEntry(key, 'size'); 61 | 62 | // delete by length 63 | const deleteByLength = this.cache.size > this.maxEntries; 64 | if (deleteByLength) deleteEntry(entries[this.maxEntries - +i][0], 'length'); 65 | 66 | // delete by date 67 | const deleteByDate = new Date().getTime() - entry.date.getTime() > this.expirationMs; 68 | if (deleteByDate) deleteEntry(key, 'expired'); 69 | } 70 | } 71 | 72 | return deleted; 73 | 74 | function deleteEntry(key: string, reason: string) { 75 | if ($this.delete(key)) deleted.push({ key, reason }); 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /src/ssr-build.ts: -------------------------------------------------------------------------------- 1 | import deepmerge from 'deepmerge'; 2 | import express from 'express'; 3 | import fs from 'fs'; 4 | import os from 'os'; 5 | import path from 'path'; 6 | import { Logger } from './logger'; 7 | import { SsrRender } from './ssr-render'; 8 | import { BuildParams, BuildResult, LogLevel, SsrBuildConfig } from './types'; 9 | import { promiseParallel, promiseRetry } from './utils'; 10 | 11 | export class SsrBuild extends SsrRender { 12 | private config: SsrBuildConfig; 13 | 14 | constructor(customConfig: SsrBuildConfig) { 15 | const defaultConfig: SsrBuildConfig = { 16 | httpPort: 8080, 17 | hostname: 'localhost', 18 | src: 'src', 19 | dist: 'dist', 20 | stopOnError: false, 21 | forceExit: false, 22 | serverMiddleware: undefined, 23 | reqMiddleware: undefined, 24 | resMiddleware: undefined, 25 | ssr: { 26 | browserConfig: { headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], timeout: 60000 }, 27 | sharedBrowser: true, 28 | queryParams: [{ key: 'headless', value: 'true' }], 29 | allowedResources: ['document', 'script', 'xhr', 'fetch'], 30 | waitUntil: 'networkidle0', 31 | timeout: 60000, 32 | sleep: undefined, 33 | }, 34 | log: { 35 | level: LogLevel.Info, 36 | console: { 37 | enabled: true, 38 | }, 39 | file: { 40 | enabled: true, 41 | dirPath: path.join(os.tmpdir(), 'ssr-proxy-js/logs'), 42 | }, 43 | }, 44 | job: { 45 | retries: 3, 46 | parallelism: 5, 47 | routes: [{ method: 'GET', url: '/' }], 48 | }, 49 | }; 50 | 51 | let config: SsrBuildConfig; 52 | 53 | if (customConfig) { 54 | config = deepmerge(defaultConfig, customConfig, { 55 | arrayMerge: (destArray, srcArray, opts) => srcArray, 56 | }); 57 | } else { 58 | console.warn('No configuration found for ssr-proxy-js, using default config!'); 59 | config = defaultConfig; 60 | } 61 | 62 | config.src = path.isAbsolute(config.src!) ? config.src! : path.join(process.cwd(), config.src!); 63 | config.dist = path.isAbsolute(config.dist!) ? config.dist! : path.join(process.cwd(), config.dist!); 64 | if (config.job!.parallelism! < 1) throw new Error(`Parallelism should be greater than 0 (${config.job!.parallelism})`); 65 | 66 | super(config.ssr!); 67 | this.config = config; 68 | 69 | const cLog = this.config.log; 70 | Logger.setLevel(cLog!.level!); 71 | Logger.configConsole(cLog!.console!.enabled!); 72 | Logger.configFile(cLog!.file!.enabled!, cLog!.file!.dirPath!); 73 | } 74 | 75 | async start(): Promise { 76 | Logger.info(`SrcPath: ${this.config.src!}`); 77 | Logger.info(`DistPath: ${this.config.dist!}`); 78 | 79 | const { server } = await this.serve(); 80 | 81 | const shutDown = async () => { 82 | Logger.debug('Shutting down...'); 83 | 84 | await this.browserShutDown(); 85 | 86 | Logger.debug('Closing the server...'); 87 | server.close(() => { 88 | Logger.debug('Shut down completed!'); 89 | if (this.config.forceExit) process.exit(0); 90 | }); 91 | 92 | if (this.config.forceExit) { 93 | setTimeout(() => { 94 | Logger.error(`Shutdown`, 'Could not shut down in time, forcefully shutting down!'); 95 | process.exit(1); 96 | }, 10000); 97 | } 98 | }; 99 | process.on('SIGTERM', shutDown); 100 | process.on('SIGINT', shutDown); 101 | 102 | try { 103 | return await this.render(); 104 | } catch (err) { 105 | throw err; 106 | } finally { 107 | await shutDown(); 108 | } 109 | } 110 | 111 | async serve() { 112 | const cfg = this.config; 113 | 114 | const app = express(); 115 | 116 | // Serve Static Files 117 | app.use(express.static(cfg.src!)); 118 | 119 | // Catch-all: Serve index.html for any non-file request 120 | app.use((req, res, next) => { 121 | if (cfg.serverMiddleware) cfg.serverMiddleware(req, res, next); 122 | else res.sendFile(path.join(cfg.src!, 'index.html')); // serve root index.html 123 | }); 124 | 125 | // Error Handler 126 | app.use((err: any, req: any, res: any, next: any) => { 127 | Logger.error('Error', err, true); 128 | res.contentType('text/plain'); 129 | res.status(err.status || 500); 130 | res.send(Logger.errorStr(err)); 131 | next(); 132 | }); 133 | 134 | // HTTP Listen 135 | const server = app.listen(this.config.httpPort!, this.config.hostname!, () => { 136 | Logger.debug('----- Starting HTTP Server -----'); 137 | Logger.debug(`Listening on http://${this.config.hostname!}:${this.config.httpPort!}`); 138 | }); 139 | 140 | return { app, server }; 141 | } 142 | 143 | async render(): Promise { 144 | const $this = this; 145 | const cJob = this.config.job!; 146 | 147 | const routesStr = '> ' + cJob.routes!.map(e => `[${e.method ?? 'GET'}] ${e.url}`).join('\n> '); 148 | Logger.info(`SSR Building (p:${cJob.parallelism},r:${cJob.retries}):\n${routesStr}`); 149 | 150 | const results = await promiseParallel(cJob.routes!.map((route) => () => new Promise(async (res, rej) => { 151 | const logger = new Logger(true); 152 | 153 | try { 154 | const result = await promiseRetry(runRender, cJob.retries!, e => logger.warn('SSR Build Retry', e, false)); 155 | res(result); 156 | } catch (err) { 157 | logger.error('SSR Build', err); 158 | rej(err); 159 | } 160 | 161 | async function runRender(): Promise { 162 | const targetUrl = new URL(route.url, `http://${$this.config.hostname!}:${$this.config.httpPort!}`); 163 | 164 | const params: BuildParams = { method: route.method, targetUrl, headers: route.headers || {} }; 165 | if ($this.config.reqMiddleware) await $this.config.reqMiddleware(params); 166 | 167 | const { text, status, headers, ttRenderMs } = await $this.tryRender(params.targetUrl.toString(), params.headers || {}, logger, params.method); 168 | 169 | const urlPath = path.join(params.targetUrl.pathname, params.targetUrl.pathname.endsWith('.html') ? '' : 'index.html'); 170 | const filePath = path.join($this.config.dist!, urlPath); 171 | const result: BuildResult = { text, status, headers, urlPath, filePath, encoding: 'utf-8' }; 172 | if ($this.config.resMiddleware) await $this.config.resMiddleware(params, result); 173 | 174 | if (status !== 200) { 175 | const msg = `Render failed: ${params.targetUrl} - Status ${status} - ${ttRenderMs}ms\n${text}`; 176 | if ($this.config.stopOnError) throw new Error(msg); 177 | logger.warn('SSR Build', msg); 178 | return result; 179 | } 180 | 181 | if (result.text == null) { 182 | logger.warn('SSR Build', `Empty content: ${params.targetUrl} - ${ttRenderMs}ms`); 183 | return result; 184 | } 185 | 186 | logger.info(`Saving render: ${params.targetUrl} -> ${result.filePath}`); 187 | 188 | const dirPath = path.dirname(result.filePath); 189 | if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true }); 190 | fs.writeFileSync(result.filePath, result.text, { encoding: result.encoding }); 191 | 192 | logger.debug(`SSR Built: ${params.targetUrl} - ${ttRenderMs}ms`); 193 | 194 | return result; 195 | } 196 | })), cJob.parallelism!, false); 197 | 198 | Logger.info(`SSR build finished!`); 199 | 200 | return results; 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/ssr-proxy.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import cloneDeep from 'clone-deep'; 3 | import deepmerge from 'deepmerge'; 4 | import express from 'express'; 5 | import fs from 'fs'; 6 | import http from 'http'; 7 | import https from 'https'; 8 | import { isbot } from 'isbot'; 9 | import mime from 'mime-types'; 10 | import { scheduleJob } from 'node-schedule'; 11 | import os from 'os'; 12 | import path from 'path'; 13 | import { Stream } from 'stream'; 14 | import { Logger } from './logger'; 15 | import { ProxyCache } from './proxy-cache'; 16 | import { SsrRender } from './ssr-render'; 17 | import { CacheItem, LogLevel, HttpHeaders, ProxyParams, ProxyResult, ProxyType, ProxyTypeParams, SsrProxyConfig } from './types'; 18 | import { getOrCall, promiseParallel, promiseRetry, streamToString } from './utils'; 19 | 20 | export class SsrProxy extends SsrRender { 21 | private config: SsrProxyConfig; 22 | private proxyCache?: ProxyCache; // In-memory cache of rendered pages 23 | 24 | constructor(customConfig: SsrProxyConfig) { 25 | const defaultConfig: SsrProxyConfig = { 26 | // TODO: AllowRedirect: boolean, return without redirecting 27 | httpPort: 8080, 28 | httpsPort: 8443, 29 | httpsKey: undefined, 30 | httpsCert: undefined, 31 | hostname: '0.0.0.0', 32 | targetRoute: 'http://localhost:80', 33 | proxyOrder: [ProxyType.SsrProxy, ProxyType.HttpProxy, ProxyType.StaticProxy], 34 | isBot: (method, url, headers) => headers?.['user-agent'] ? isbot(headers['user-agent']) : false, 35 | failStatus: 404, 36 | customError: undefined, 37 | skipOnError: true, 38 | forceExit: true, 39 | reqMiddleware: undefined, 40 | resMiddleware: undefined, 41 | ssr: { 42 | shouldUse: params => params.isBot && (/\.html$/.test(params.targetUrl.pathname) || !/\./.test(params.targetUrl.pathname)), 43 | browserConfig: { headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], timeout: 60000 }, 44 | sharedBrowser: true, 45 | queryParams: [{ key: 'headless', value: 'true' }], 46 | allowedResources: ['document', 'script', 'xhr', 'fetch'], 47 | waitUntil: 'networkidle0', 48 | timeout: 60000, 49 | sleep: undefined, 50 | cleanUpCron: undefined, 51 | cleanUpTz: 'Etc/UTC', 52 | }, 53 | httpProxy: { 54 | shouldUse: true, 55 | queryParams: [], 56 | unsafeHttps: false, 57 | timeout: 60000, 58 | }, 59 | static: { 60 | shouldUse: false, 61 | dirPath: 'public', 62 | useIndexFile: path => path.endsWith('/'), 63 | indexFile: 'index.html', 64 | }, 65 | log: { 66 | level: LogLevel.Info, 67 | console: { 68 | enabled: true, 69 | }, 70 | file: { 71 | enabled: true, 72 | dirPath: path.join(os.tmpdir(), 'ssr-proxy-js/logs'), 73 | }, 74 | }, 75 | cache: { 76 | shouldUse: params => params.proxyType === ProxyType.SsrProxy, 77 | maxEntries: 50, 78 | maxByteSize: 50 * 1000 * 1000, // 50MB 79 | expirationMs: 25 * 60 * 60 * 1000, // 25h 80 | autoRefresh: { 81 | enabled: false, 82 | shouldUse: true, 83 | proxyOrder: [ProxyType.SsrProxy], 84 | initTimeoutMs: 5 * 1000, // 5s 85 | intervalCron: '0 0 3 * * *', // every day at 3am 86 | intervalTz: 'Etc/UTC', 87 | retries: 3, 88 | parallelism: 5, 89 | closeBrowser: true, 90 | isBot: true, 91 | routes: [{ method: 'GET', url: '/' }], 92 | }, 93 | }, 94 | }; 95 | 96 | let config: SsrProxyConfig; 97 | 98 | if (customConfig) { 99 | config = deepmerge(defaultConfig, customConfig, { 100 | arrayMerge: (destArray, srcArray, opts) => srcArray, 101 | }); 102 | } else { 103 | console.warn('No configuration found for ssr-proxy-js, using default config!'); 104 | config = defaultConfig; 105 | } 106 | 107 | if (config.static) config.static.dirPath = path.isAbsolute(config.static.dirPath!) ? config.static.dirPath! : path.join(process.cwd(), config.static.dirPath!); 108 | if (config.cache!.autoRefresh!.parallelism! < 1) throw new Error(`Parallelism should be greater than 0 (${config.cache!.autoRefresh!.parallelism})`); 109 | 110 | super(config.ssr!); 111 | this.config = config; 112 | 113 | const cLog = this.config.log; 114 | Logger.setLevel(cLog!.level!); 115 | Logger.configConsole(cLog!.console!.enabled!); 116 | Logger.configFile(cLog!.file!.enabled!, cLog!.file!.dirPath!); 117 | 118 | const cCache = this.config.cache; 119 | this.proxyCache = new ProxyCache(cCache!.maxEntries!, cCache!.maxByteSize!, cCache!.expirationMs!); 120 | } 121 | 122 | start() { 123 | this.startCleanUpJob(); 124 | this.startCacheJob(); 125 | 126 | const { server } = this.listen(); 127 | 128 | const shutDown = async () => { 129 | Logger.info('Shutting down...'); 130 | 131 | await this.browserShutDown(); 132 | 133 | Logger.info('Closing the server...'); 134 | server.close(() => { 135 | Logger.info('Shut down completed!'); 136 | if (this.config.forceExit) process.exit(0); 137 | }); 138 | 139 | if (this.config.forceExit) { 140 | setTimeout(() => { 141 | Logger.error(`Shutdown`, 'Could not shut down in time, forcefully shutting down!'); 142 | process.exit(1); 143 | }, 10000); 144 | } 145 | }; 146 | process.on('SIGTERM', shutDown); 147 | process.on('SIGINT', shutDown); 148 | } 149 | 150 | private async startCleanUpJob() { 151 | const cleanUpCron = this.config.ssr!.cleanUpCron; 152 | const cleanUpTz = this.config.ssr!.cleanUpTz; 153 | if (!cleanUpCron) return; 154 | scheduleJob({ rule: cleanUpCron, tz: cleanUpTz }, async () => { 155 | this.sharedBrowser?.close(); 156 | }); 157 | } 158 | 159 | private startCacheJob() { 160 | const $this = this; 161 | const cCache = this.config.cache!; 162 | const cAutoCache = cCache.autoRefresh!; 163 | 164 | const enabled = cAutoCache.enabled! && cAutoCache.routes! && cAutoCache.routes!.length!; 165 | if (!enabled) return; 166 | 167 | if (cAutoCache.initTimeoutMs) 168 | setTimeout(runRefresh, cAutoCache.initTimeoutMs); 169 | 170 | if (cAutoCache.intervalCron) 171 | scheduleJob({ rule: cAutoCache.intervalCron, tz: cAutoCache.intervalTz }, runRefresh); 172 | 173 | async function runRefresh() { 174 | const logger = new Logger(true); 175 | 176 | try { 177 | if (!cAutoCache.shouldUse || !getOrCall(cAutoCache.shouldUse) || !cAutoCache.routes?.length) return; 178 | 179 | const routesStr = '> ' + cAutoCache.routes!.map(e => e.url).join('\n> '); 180 | logger.info(`Refreshing Cache:\n${routesStr}`); 181 | 182 | await promiseParallel(cAutoCache.routes!.map((route) => () => new Promise(async (res, rej) => { 183 | try { 184 | await promiseRetry(runProxy, cAutoCache.retries!, e => logger.warn('CacheRefresh Retry', e)); 185 | res('ok'); 186 | } catch (err) { 187 | logger.error('CacheRefresh', err); 188 | rej(err); 189 | } 190 | 191 | async function runProxy() { 192 | const targetUrl = new URL(route.url, $this.config.targetRoute!); 193 | const params: ProxyParams = { isBot: cAutoCache.isBot!, cacheBypass: true, sourceUrl: route.url, targetUrl, method: route.method, headers: route.headers || {} }; 194 | const { proxyType, result } = await $this.runProxy(params, cAutoCache.proxyOrder!, logger); 195 | } 196 | })), cAutoCache.parallelism!, true); 197 | 198 | logger.info(`Cache Refreshed!`); 199 | } catch (err) { 200 | logger.error('CacheRefresh', err); 201 | } finally { 202 | if (cAutoCache.closeBrowser) $this.sharedBrowser?.close(); 203 | } 204 | } 205 | } 206 | 207 | private listen() { 208 | const $this = this; 209 | 210 | const app = express(); 211 | 212 | // Proxy requests 213 | app.use('*', async (req, res, next) => { 214 | const logger = new Logger(); 215 | 216 | try { 217 | const sourceUrl = req.originalUrl; 218 | const targetUrl = new URL(req.originalUrl, this.config.targetRoute); 219 | const method = req.method; 220 | const headers = this.fixHeaders(req.headers); 221 | 222 | const isBot = getOrCall(this.config.isBot, method, sourceUrl, headers)!; 223 | 224 | logger.info(`[${method}] ${sourceUrl} | IsBot: ${isBot} | User Agent: ${headers['user-agent']}`); 225 | 226 | const params: ProxyParams = { isBot, cacheBypass: false, method, sourceUrl, headers, targetUrl }; 227 | 228 | const { proxyType, result } = await this.runProxy(params, this.config.proxyOrder!, logger); 229 | 230 | const proxyTypeParams: ProxyTypeParams = { ...params, proxyType }; 231 | 232 | // if (proxyType === ProxyType.Redirect) return sendRedirect(result, proxyTypeParams); 233 | if (result?.error != null) return sendFail(result, proxyTypeParams); 234 | else if (result?.text != null) return sendText(result, proxyTypeParams); 235 | else if (result?.stream != null) return sendStream(result, proxyTypeParams); 236 | else return sendFail({ ...result, error: 'No Proxy Result' }, proxyTypeParams); 237 | } catch (err) { 238 | return next(err); 239 | } 240 | 241 | async function sendText(result: ProxyResult, params: ProxyTypeParams) { 242 | res.status(result.status || 200); 243 | res.contentType(result.contentType!); 244 | setHeaders(result.headers!) 245 | return res.send(result.text!); 246 | } 247 | 248 | async function sendStream(result: ProxyResult, params: ProxyTypeParams) { 249 | res.status(result.status || 200); 250 | res.contentType(result.contentType!); 251 | setHeaders(result.headers!) 252 | return result.stream!.on('error', err => { 253 | res.status(getOrCall($this.config.failStatus, params)!); 254 | // res.contentType('text/plain'); 255 | // const error = Logger.errorStr(result.error!); 256 | return res.send(); 257 | }).pipe(res); 258 | } 259 | 260 | // async function sendRedirect(result: ProxyResult, params: ProxyTypeParams) { 261 | // res.status(result.status || 302); 262 | // setHeaders(result.headers!) 263 | // return res.redirect(result.text!); 264 | // } 265 | 266 | async function sendFail(result: ProxyResult, params: ProxyTypeParams) { 267 | res.status(getOrCall($this.config.failStatus, params)!); 268 | res.contentType('text/plain'); 269 | setHeaders(result.headers!) 270 | const errMsg = getOrCall($this.config.customError, result.error!) ?? Logger.errorStr(result.error!); 271 | return res.send(errMsg); 272 | } 273 | 274 | function setHeaders(headers: HttpHeaders) { 275 | for (let key in headers) { 276 | try { 277 | res.set(key, headers[key]); 278 | } catch (err) { 279 | Logger.errorStr(`Invalid headers:\nKey: ${key}\nValue: ${headers[key]})`); 280 | } 281 | } 282 | } 283 | }); 284 | 285 | // Error Handler 286 | app.use((err: any, req: any, res: any, next: any) => { 287 | Logger.error('Error', err, true); 288 | res.contentType('text/plain'); 289 | res.status(err.status || 500); 290 | const errMsg = getOrCall(this.config.customError, err) ?? Logger.errorStr(err); 291 | res.send(errMsg); 292 | next(); 293 | }); 294 | 295 | let server: http.Server; 296 | 297 | if (this.config.httpPort) { 298 | // HTTP Listen 299 | server = app.listen(this.config.httpPort, this.config.hostname!, () => { 300 | Logger.info('----- Starting HTTP SSR Proxy -----'); 301 | Logger.info(`Listening on http://${this.config.hostname!}:${this.config.httpPort!}`); 302 | Logger.info(`Proxy: ${this.config.targetRoute!}`); 303 | Logger.info(`DirPath: ${this.config.static!.dirPath!}`); 304 | Logger.info(`ProxyOrder: ${this.config.proxyOrder!}\n`); 305 | }); 306 | } else if (this.config.httpsPort && this.config.httpsKey && this.config.httpsCert) { 307 | // HTTPS Listen 308 | server = https.createServer({ 309 | key: fs.readFileSync(this.config.httpsKey), 310 | cert: fs.readFileSync(this.config.httpsCert), 311 | }, app); 312 | server.listen(this.config.httpsPort, this.config.hostname!, () => { 313 | Logger.info('\n----- Starting HTTPS SSR Proxy -----'); 314 | Logger.info(`Listening on https://${this.config.hostname!}:${this.config.httpsPort!}`); 315 | Logger.info(`Proxy: ${this.config.targetRoute!}`); 316 | Logger.info(`DirPath: ${this.config.static!.dirPath!}`); 317 | Logger.info(`ProxyOrder: ${this.config.proxyOrder!}\n`); 318 | }); 319 | } else { 320 | throw new Error('Invalid Ports or Certificates'); 321 | } 322 | 323 | return { app, server }; 324 | } 325 | 326 | private async runProxy(params: ProxyParams, proxyOrder: ProxyType[], logger: Logger) { 327 | if (!proxyOrder.length) throw new Error('Invalid Proxy Order'); 328 | 329 | params.headers ||= {}; 330 | params.method ||= 'GET'; 331 | 332 | let result: ProxyResult = {}; 333 | let proxyType: ProxyType = proxyOrder[0]; 334 | 335 | for (let i in proxyOrder) { 336 | proxyType = proxyOrder[i]; 337 | 338 | const proxyParams = cloneDeep(this.config.reqMiddleware != null ? await this.config.reqMiddleware(params) : params); 339 | 340 | try { 341 | // const redirect = await this.checkForRedirect(proxyParams, logger); 342 | // if (redirect.status) return { result: redirect, proxyType: ProxyType.Redirect }; 343 | 344 | if (proxyType === ProxyType.SsrProxy) { 345 | result = await this.runSsrProxy(proxyParams, logger); 346 | } else if (proxyType === ProxyType.HttpProxy) { 347 | result = await this.runHttpProxy(proxyParams, logger); 348 | } else if (proxyType === ProxyType.StaticProxy) { 349 | result = await this.runStaticProxy(proxyParams, logger); 350 | } else { 351 | throw new Error('Invalid Proxy Type'); 352 | } 353 | } catch (err) { 354 | result = { error: err }; 355 | params.lastError = err; 356 | } 357 | 358 | // Success 359 | if (!result.skipped && result.error == null) break; 360 | 361 | // Bubble up errors 362 | if (!this.config.skipOnError && result.error != null) 363 | throw (typeof result.error === 'string' ? new Error(result.error) : result.error); 364 | } 365 | 366 | if (this.config.resMiddleware != null) result = await this.config.resMiddleware(params, result); 367 | 368 | return { proxyType, result }; 369 | } 370 | 371 | private async runSsrProxy(params: ProxyParams, logger: Logger): Promise { 372 | const cSsr = this.config.ssr!; 373 | const cacheKey = `${ProxyType.SsrProxy}:${params.targetUrl}`; 374 | const typeParams = { ...params, proxyType: ProxyType.SsrProxy }; 375 | 376 | const shouldUse = getOrCall(cSsr.shouldUse, params)!; 377 | if (!shouldUse) { 378 | logger.debug(`Skipped SsrProxy: ${params.targetUrl}`); 379 | return { skipped: true }; 380 | } 381 | 382 | try { 383 | logger.info(`Using SsrProxy: ${params.targetUrl}`); 384 | 385 | // Try use Cache 386 | 387 | const cache = !params.cacheBypass && this.tryGetCache(cacheKey, typeParams, logger); 388 | if (cache) { 389 | logger.info(`SSR Cache Hit`); 390 | return { text: cache.text, contentType: cache.contentType }; 391 | } 392 | 393 | // Try use SsrProxy 394 | 395 | let { status, text, error, headers: ssrHeaders, ttRenderMs } = await this.tryRender(params.targetUrl.toString(), params.headers, logger, params.method); 396 | 397 | status ||= 200; 398 | const isSuccess = error == null; 399 | 400 | logger.info(`SSR Result | Render Time: ${ttRenderMs}ms | Success: ${isSuccess}${isSuccess ? '' : ` | Message: ${error}`}`); 401 | 402 | if (!isSuccess) return { error }; 403 | if (text == null) text = ''; 404 | 405 | const resHeaders = { 406 | ...(ssrHeaders || {}), 407 | 'Server-Timing': `Prerender;dur=${ttRenderMs};desc="Headless render time (ms)"`, 408 | }; 409 | 410 | const contentType = this.getContentType(params.targetUrl.pathname); 411 | 412 | this.trySaveCache(text, status, contentType, cacheKey, typeParams, logger); 413 | 414 | return { text, status, contentType, headers: resHeaders }; 415 | } catch (err: any) { 416 | logger.error('SsrError', err); 417 | return { error: err }; 418 | } 419 | } 420 | 421 | private async runHttpProxy(params: ProxyParams, logger: Logger): Promise { 422 | const cHttpProxy = this.config.httpProxy!; 423 | const cacheKey = `${ProxyType.HttpProxy}:${params.method}:${params.targetUrl}`; 424 | const typeParams = { ...params, proxyType: ProxyType.HttpProxy }; 425 | 426 | const shouldUse = getOrCall(cHttpProxy.shouldUse, params)!; 427 | if (!shouldUse) { 428 | logger.debug(`Skipped HttpProxy: ${params.targetUrl}`); 429 | return { skipped: true }; 430 | } 431 | 432 | try { 433 | logger.debug(`Using HttpProxy: ${params.targetUrl}`); 434 | 435 | // Try use Cache 436 | 437 | const cache = !params.cacheBypass && this.tryGetCache(cacheKey, typeParams, logger); 438 | if (cache) { 439 | logger.info(`HTTP Cache Hit`); 440 | return { text: cache.text, contentType: cache.contentType }; 441 | } 442 | 443 | // Try use HttpProxy 444 | 445 | // Indicate http proxy to client 446 | for (let param of cHttpProxy.queryParams!) 447 | params.targetUrl.searchParams.set(param.key, param.value); 448 | 449 | const reqHeaders = this.fixReqHeaders(params.headers); 450 | 451 | logger.debug(`HttpProxy: Connecting - ${JSON.stringify(reqHeaders)}`); 452 | 453 | const response = await axios.request({ 454 | url: params.targetUrl.toString(), 455 | method: params.method as any, 456 | responseType: 'stream', 457 | headers: reqHeaders, 458 | httpsAgent: new https.Agent({ rejectUnauthorized: getOrCall(cHttpProxy.unsafeHttps, params) }), 459 | timeout: cHttpProxy.timeout, 460 | }); 461 | 462 | const status = response.status; 463 | 464 | const resHeaders = this.fixResHeaders(response.headers); 465 | 466 | logger.debug(`HttpProxy: Connected - ${JSON.stringify(resHeaders)}`); 467 | 468 | const contentType = this.getContentType(params.targetUrl.pathname); 469 | 470 | this.trySaveCacheStream(response.data, status, contentType, cacheKey, typeParams, logger); 471 | 472 | return { status: response.status, stream: response.data, headers: resHeaders, contentType }; 473 | } catch (err: any) { 474 | const error = err?.response?.data ? await streamToString(err.response.data).catch(err => err) : err; 475 | logger.error('HttpProxyError', error); 476 | return { error }; 477 | } 478 | } 479 | 480 | private async runStaticProxy(params: ProxyParams, logger: Logger): Promise { 481 | const cStatic = this.config.static!; 482 | const cacheKey = `${ProxyType.StaticProxy}:${params.targetUrl}`; 483 | const typeParams = { ...params, proxyType: ProxyType.StaticProxy }; 484 | 485 | const shouldUse = getOrCall(cStatic.shouldUse, params)!; 486 | if (!shouldUse) { 487 | logger.debug(`Skipped StaticProxy: ${params.targetUrl}`); 488 | return { skipped: true }; 489 | } 490 | 491 | try { 492 | logger.debug(`Using StaticProxy: ${params.targetUrl}`); 493 | 494 | // Try use Cache 495 | 496 | const cache = !params.cacheBypass && this.tryGetCache(cacheKey, typeParams, logger); 497 | if (cache) { 498 | logger.info(`Static Cache Hit`); 499 | return { text: cache.text, contentType: cache.contentType }; 500 | } 501 | 502 | // Try use StaticProxy 503 | 504 | if (cStatic.useIndexFile!(params.sourceUrl)) 505 | params.sourceUrl = `${params.sourceUrl}/${cStatic.indexFile!}`.replace(/\/\//g, '/'); 506 | 507 | const filePath = path.join(cStatic.dirPath!, params.sourceUrl); 508 | 509 | logger.debug(`Static Path: ${filePath}`); 510 | 511 | if (!fs.existsSync(filePath)) 512 | throw new Error(`File Not Found: ${filePath}`); 513 | 514 | const fileStream = fs.createReadStream(filePath); 515 | 516 | const contentType = this.getContentType(filePath); 517 | 518 | this.trySaveCacheStream(fileStream, 200, contentType, cacheKey, typeParams, logger); 519 | 520 | return { stream: fileStream, status: 200, contentType }; 521 | } catch (err: any) { 522 | logger.error('StaticError', err); 523 | return { error: err }; 524 | } 525 | } 526 | 527 | // private async checkForRedirect(proxyParams: ProxyParams, logger: Logger): Promise { 528 | // // TODO: 529 | // // cache the redirect 530 | // // fix targetUrl: Target (http://web-server:8080/) 531 | 532 | // try { 533 | // const targetUrl = proxyParams.targetUrl.toString(); 534 | 535 | // logger.debug(`Redirect: Checking (${targetUrl})`); 536 | 537 | // // Use axios with a short timeout and redirect: false 538 | // const response = await axios.request({ 539 | // url: targetUrl, 540 | // method: 'HEAD', // HEAD request is faster than GET 541 | // headers: this.fixReqHeaders(proxyParams.headers), 542 | // maxRedirects: 0, // Don't follow redirects 543 | // validateStatus: (status) => status < 400 || status === 404, // Accept any status that isn't an error 544 | // timeout: 5000 // 5 second timeout 545 | // }); 546 | 547 | // // Check if this is a redirect status 548 | // if (response.status === 301 || response.status === 302 || response.status === 307 || response.status === 308) { 549 | // logger.info(`Redirect: Detected (${targetUrl} - ${response.status})`); 550 | // const location = response.headers['location']; 551 | 552 | // if (location) { 553 | // const redirectUrl = new URL(location, targetUrl).toString(); 554 | // logger.info(`Redirect: Target (${redirectUrl})`); 555 | 556 | // return { 557 | // text: redirectUrl, 558 | // status: response.status, 559 | // headers: this.fixResHeaders(response.headers), 560 | // }; 561 | // } 562 | // } 563 | 564 | // return {}; 565 | // } catch (err: any) { 566 | // return { error: err }; 567 | // } 568 | // } 569 | 570 | private getContentType(path: string) { 571 | const isHtml = () => /\.html$/.test(path) || !/\./.test(path) 572 | const type = mime.lookup(path) || (isHtml() ? 'text/html' : 'text/plain'); 573 | return type; 574 | } 575 | 576 | // Cache 577 | 578 | private tryGetCache(cacheKey: string, params: ProxyTypeParams, logger: Logger): CacheItem | null { 579 | const cCache = this.config.cache!; 580 | 581 | const shouldUse = getOrCall(cCache.shouldUse, params)! && this.proxyCache?.has(cacheKey); 582 | if (shouldUse) { 583 | logger.debug(`Cache Hit: ${cacheKey}`); 584 | const cache = this.proxyCache!.get(cacheKey)!; 585 | 586 | if (!cache) return null; 587 | 588 | return cache; 589 | } 590 | 591 | return null; 592 | } 593 | 594 | private trySaveCache(text: string, status: number, contentType: string, cacheKey: string, params: ProxyTypeParams, logger: Logger) { 595 | const cCache = this.config.cache!; 596 | 597 | const shouldUse = getOrCall(cCache.shouldUse, params)! && this.proxyCache!; 598 | if (shouldUse) { 599 | logger.debug(`Caching: ${cacheKey}`); 600 | this.proxyCache!.set(cacheKey, text, status, contentType); 601 | this.tryClearCache(logger); 602 | } 603 | } 604 | 605 | private trySaveCacheStream(stream: Stream, status: number, contentType: string, cacheKey: string, params: ProxyTypeParams, logger: Logger) { 606 | const cCache = this.config.cache!; 607 | 608 | const shouldUse = getOrCall(cCache.shouldUse, params)! && this.proxyCache!; 609 | if (shouldUse) { 610 | logger.debug(`Caching: ${cacheKey}`); 611 | this.proxyCache!.pipe(cacheKey, stream, status, contentType) 612 | .then(() => this.tryClearCache(logger)) 613 | .catch(err => logger.error('SaveCacheStream', err)); 614 | } 615 | } 616 | 617 | private tryClearCache(logger: Logger) { 618 | if (this.proxyCache!) { 619 | const deleted = this.proxyCache.tryClear(); 620 | if (deleted.length) logger.debug(`Cache Cleared: ${JSON.stringify(deleted)}`); 621 | } 622 | } 623 | } 624 | -------------------------------------------------------------------------------- /src/ssr-render.ts: -------------------------------------------------------------------------------- 1 | import puppeteer, { Browser, ContinueRequestOverrides, Page } from 'puppeteer'; 2 | import { Logger } from './logger'; 3 | import { HttpHeaders, SsrConfig, SsrRenderResult } from './types'; 4 | import { createLock, sleep } from './utils'; 5 | 6 | export abstract class SsrRender { 7 | constructor(protected configSsr: SsrConfig) { } 8 | 9 | // Reusable browser connection 10 | protected sharedBrowser?: { 11 | browser: Promise; 12 | wsEndpoint: Promise; 13 | close: () => Promise; 14 | }; 15 | 16 | protected tempBrowsers: Browser[] = []; 17 | 18 | private lock = createLock(); 19 | 20 | protected async getBrowser(logger: Logger): Promise { 21 | const cSsr = this.configSsr!; 22 | 23 | try { 24 | await this.lock(async () => { 25 | if (cSsr.sharedBrowser && !this.sharedBrowser) { 26 | logger.debug('SSR: Creating browser instance'); 27 | const browserMain = puppeteer.launch(cSsr.browserConfig!); 28 | const wsEndpoint = browserMain.then(e => e.wsEndpoint()); 29 | this.sharedBrowser = { 30 | browser: browserMain, 31 | wsEndpoint: wsEndpoint, 32 | close: async () => { 33 | try { 34 | logger.debug('SSR: Closing browser instance'); 35 | this.sharedBrowser = undefined; 36 | await (await browserMain).close(); 37 | } catch (err) { 38 | logger.error('BrowserCloseError', err, false); 39 | } 40 | }, 41 | }; 42 | } 43 | }); 44 | } catch (err: any) { 45 | logger.error('BrowserError', err, false); 46 | } 47 | 48 | logger.debug('SSR: Connecting'); 49 | const wsEndpoint = this.sharedBrowser?.wsEndpoint && await this.sharedBrowser.wsEndpoint; 50 | 51 | logger.debug(`SSR: WSEndpoint=${wsEndpoint}`); 52 | const browser = wsEndpoint ? await puppeteer.connect({ browserWSEndpoint: wsEndpoint }) : await puppeteer.launch(cSsr.browserConfig!); 53 | 54 | return browser; 55 | } 56 | 57 | protected async tryRender(urlStr: string, headers: HttpHeaders, logger: Logger, method?: string): Promise { 58 | const cSsr = this.configSsr!; 59 | const start = Date.now(); 60 | 61 | let browser: Browser | undefined; 62 | let page: Page | undefined; 63 | 64 | try { 65 | browser = await this.getBrowser(logger); 66 | if (!cSsr.sharedBrowser) this.tempBrowsers.push(browser); 67 | 68 | // await sleep(10_000); // test sigterm shutdown 69 | 70 | const url = new URL(urlStr); 71 | 72 | // Indicate headless render to client 73 | // e.g. use to disable some features if ssr 74 | for (let param of cSsr.queryParams!) 75 | url.searchParams.set(param.key, param.value); 76 | 77 | logger.debug('SSR: New Page'); 78 | page = await browser.newPage(); 79 | 80 | // Intercept network requests 81 | let interceptCount = 0; 82 | await page.setRequestInterception(true); 83 | page.on('request', req => { 84 | // console.log('Request:', req.url()); 85 | 86 | interceptCount++; 87 | 88 | // Ignore requests for resources that don't produce DOM (e.g. images, stylesheets, media) 89 | const reqType = req.resourceType(); 90 | if (!cSsr.allowedResources!.includes(reqType)) return req.abort(); 91 | 92 | // Custom headers and method 93 | let override: ContinueRequestOverrides = { method: 'GET', headers: req.headers() }; 94 | if (interceptCount === 1) { 95 | if (method) override.method = method; 96 | override.headers = this.fixReqHeaders({ ...(headers || {}), ...(override.headers || {}) }); 97 | logger.debug(`SSR: Intercepted - ${JSON.stringify(override.headers)}`); 98 | } 99 | 100 | // Pass through all other requests 101 | req.continue(override); 102 | }); 103 | 104 | // Render 105 | 106 | logger.debug('SSR: Accessing'); 107 | const response = await page.goto(url.toString(), { waitUntil: cSsr.waitUntil, timeout: cSsr.timeout }); 108 | // await page.waitForNetworkIdle({ idleTime: 1000, timeout: cSsr.timeout }); 109 | 110 | const ssrStatus = response?.status(); 111 | const ssrHeaders = response?.headers(); 112 | const resHeaders = this.fixResHeaders(ssrHeaders); 113 | 114 | logger.debug(`SSR: Rendered - ${JSON.stringify(resHeaders)}`); 115 | 116 | if (cSsr.sleep) await sleep(cSsr.sleep); 117 | 118 | // Serialize text from DOM 119 | 120 | const text = await page.content(); 121 | logger.debug(`SSR: DOM Serialized - ${text.length} size`); 122 | 123 | const ttRenderMs = Date.now() - start; 124 | 125 | return { status: ssrStatus, text, headers: resHeaders, ttRenderMs }; 126 | } catch (err: any) { 127 | let error = ((err && (err.message || err.toString())) || 'Proxy Error'); 128 | const ttRenderMs = Date.now() - start; 129 | return { ttRenderMs, error }; 130 | } finally { 131 | logger.debug('SSR: Closing'); 132 | if (page && !page.isClosed()) await page.close(); 133 | if (browser) { 134 | if (cSsr.sharedBrowser) { 135 | await browser.disconnect(); 136 | } else { 137 | await browser.close(); 138 | this.tempBrowsers = this.tempBrowsers.filter(e => e !== browser); 139 | } 140 | } 141 | logger.debug('SSR: Closed'); 142 | } 143 | } 144 | 145 | protected async browserShutDown() { 146 | if (this.configSsr!.sharedBrowser) { 147 | Logger.debug('Closing the shared browser...'); 148 | await this.sharedBrowser?.close(); 149 | } else { 150 | this.tempBrowsers.forEach(async (browser,i,arr) => { 151 | Logger.debug(`Closing temp browser ${browser?.process()?.pid} (${i+1}/${arr.length})...`); 152 | if (browser) await browser.close(); 153 | }); 154 | } 155 | } 156 | 157 | protected fixReqHeaders(headers: any) { 158 | const proxyHeaders = this.fixHeaders(headers); 159 | delete proxyHeaders['host']; 160 | delete proxyHeaders['referer']; 161 | delete proxyHeaders['user-agent']; 162 | return proxyHeaders; 163 | } 164 | 165 | protected fixResHeaders(headers: any) { 166 | const proxyHeaders = this.fixHeaders({}); 167 | // TODO: fix response headers 168 | // delete proxyHeaders['content-encoding']; 169 | // delete proxyHeaders['transfer-encoding']; 170 | return proxyHeaders; 171 | } 172 | 173 | protected fixHeaders(headers: object) { 174 | return Object.entries(headers).reduce((acc, [key, value]) => (value != null ? { ...acc, [key.toLowerCase()]: value?.toString() } : acc), {} as HttpHeaders); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import { BrowserConnectOptions, BrowserLaunchArgumentOptions, LaunchOptions, Product, PuppeteerLifeCycleEvent, ResourceType } from 'puppeteer'; 3 | import { Stream } from 'stream'; 4 | 5 | // SSR 6 | 7 | export type HttpHeaders = Record; 8 | 9 | export interface SsrRenderResult { 10 | status?: number; 11 | text?: string; 12 | error?: string; 13 | headers?: HttpHeaders; 14 | ttRenderMs: number; 15 | } 16 | 17 | /** 18 | * SSR config 19 | * @public 20 | */ 21 | export interface SsrConfig { 22 | /** 23 | * Browser configuration used by Puppeteer 24 | * @default 25 | * { headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], timeout: 60000 } 26 | */ 27 | browserConfig?: SsrBrowerConfig; 28 | /** 29 | * Use shared browser instance 30 | * @default true 31 | */ 32 | sharedBrowser?: boolean; 33 | /** 34 | * Which query string params to include in the url before proxying 35 | * @default 36 | * [{ key: 'headless', value: 'true' }] 37 | */ 38 | queryParams?: { 39 | key: string; 40 | value: string; 41 | }[]; 42 | /** 43 | * Which resource types to load 44 | * @default 45 | * ['document', 'script', 'xhr', 'fetch'] 46 | */ 47 | allowedResources?: ResourceType[]; 48 | /** 49 | * Which events to wait before returning the rendered HTML 50 | * @default 'networkidle0' 51 | */ 52 | waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; 53 | /** 54 | * Timeout 55 | * @default 60000 56 | */ 57 | timeout?: number; 58 | /** 59 | * Sleep time for debugging 60 | * @default undefined 61 | */ 62 | sleep?: number; 63 | } 64 | 65 | /** 66 | * SSR job config 67 | * @public 68 | */ 69 | export interface SsrJob { 70 | /** 71 | * Number of retries if fails 72 | * @default 3 73 | */ 74 | retries?: number; 75 | /** 76 | * Maximum number of parallel refreshes 77 | * @default 5 * 60 * 1000 // 5 minutes 78 | */ 79 | parallelism?: number; 80 | /** 81 | * Routes to auto refresh 82 | * @default 83 | * [{ method: 'GET', url: '/' }] 84 | */ 85 | routes?: { 86 | /** 87 | * Route URL 88 | * @example '/example/' 89 | */ 90 | url: string; 91 | /** 92 | * Route HTTP Method 93 | * @example 'GET' 94 | */ 95 | method?: string; 96 | /** 97 | * Route Headers 98 | * @example { 'X-Example': 'Test' } 99 | */ 100 | headers?: HttpHeaders; 101 | }[]; 102 | } 103 | 104 | /** 105 | * Logging configuration 106 | * @public 107 | */ 108 | export interface LogConfig { 109 | /** 110 | * Logging level 111 | * @example 112 | * ```text 113 | * None = 0, Error = 1, Info = 2, Debug = 3 114 | * ``` 115 | * @default 2 116 | */ 117 | level?: LogLevel; 118 | /** 119 | * Console logging configuration 120 | */ 121 | console?: { 122 | /** 123 | * Indicates whether to enable the console logging method 124 | * @default true 125 | */ 126 | enabled?: boolean; 127 | }; 128 | /** 129 | * File logging configuration 130 | */ 131 | file?: { 132 | /** 133 | * Indicates whether to enable the file logging method 134 | * @default true 135 | */ 136 | enabled?: boolean; 137 | /** 138 | * Absolute path of the logging directory 139 | * @default path.join(os.tmpdir(), 'ssr-proxy-js/logs') 140 | */ 141 | dirPath?: string; 142 | }; 143 | }; 144 | 145 | // SSR Build 146 | 147 | export interface BuildResult { 148 | text?: string; 149 | status?: number; 150 | headers?: HttpHeaders; 151 | urlPath: string; 152 | filePath: string; 153 | encoding: BufferEncoding; 154 | } 155 | 156 | export interface BuildParams { 157 | method?: string; 158 | targetUrl: URL; 159 | headers: HttpHeaders; 160 | } 161 | 162 | /** 163 | * Build config 164 | * @public 165 | */ 166 | export interface SsrBuildConfig { 167 | /** 168 | * File server http port 169 | * @default 8080 170 | */ 171 | httpPort?: number; 172 | /** 173 | * Proxy server hostname 174 | * @default 'localhost' 175 | */ 176 | hostname?: string; 177 | /** 178 | * Source directory 179 | * @default 'src' 180 | */ 181 | src?: string; 182 | /** 183 | * Build output directory 184 | * @default 'dist' 185 | */ 186 | dist?: string; 187 | /** 188 | * Indicates whether to stop the build process on error (non-200 status code) 189 | * @default false 190 | */ 191 | stopOnError?: boolean; 192 | /** 193 | * Indicates whether to force exit with process.exit on shutdown 194 | * @default false 195 | */ 196 | forceExit?: boolean; 197 | /** 198 | * Custom server middleware 199 | * @default undefined 200 | */ 201 | serverMiddleware?: (req: Request, res: Response, next: NextFunction) => Promise; 202 | /** 203 | * Function for processing the original request before proxying 204 | * @default undefined 205 | */ 206 | reqMiddleware?: (params: BuildParams) => Promise; 207 | /** 208 | * Function for processing the proxy result before serving 209 | * @default undefined 210 | */ 211 | resMiddleware?: (params: BuildParams, result: BuildResult) => Promise; 212 | ssr?: SsrConfig; 213 | job?: SsrJob; 214 | log?: LogConfig; 215 | } 216 | 217 | // SSR Proxy 218 | 219 | export enum ProxyType { 220 | SsrProxy = 'SsrProxy', 221 | HttpProxy = 'HttpProxy', 222 | StaticProxy = 'StaticProxy', 223 | // Redirect = 'Redirect', 224 | } 225 | 226 | export interface ProxyResult { 227 | text?: string; 228 | status?: number; 229 | stream?: Stream; 230 | contentType?: string; 231 | skipped?: boolean; 232 | error?: any; 233 | headers?: HttpHeaders; 234 | } 235 | 236 | export interface ProxyParams { 237 | sourceUrl: string; 238 | method?: string; 239 | headers: HttpHeaders; 240 | targetUrl: URL; 241 | isBot: boolean; 242 | cacheBypass: boolean; 243 | lastError?: any; 244 | } 245 | 246 | export interface ProxyTypeParams extends ProxyParams { 247 | proxyType: ProxyType; 248 | } 249 | 250 | export type SsrBrowerConfig = LaunchOptions & BrowserLaunchArgumentOptions & BrowserConnectOptions & { 251 | product?: Product; 252 | extraPrefsFirefox?: Record; 253 | }; 254 | 255 | /** 256 | * Proxy config 257 | * @public 258 | */ 259 | export interface SsrProxyConfig { 260 | /** 261 | * Proxy server http port 262 | * @default 8080 263 | */ 264 | httpPort?: number; 265 | /** 266 | * Proxy server https port 267 | * @default 8443 268 | */ 269 | httpsPort?: number; 270 | /** 271 | * Proxy server https key 272 | * @default undefined 273 | */ 274 | httpsKey?: string; 275 | /** 276 | * Proxy server https cert 277 | * @default undefined 278 | */ 279 | httpsCert?: string; 280 | /** 281 | * Proxy server hostname 282 | * @default '0.0.0.0' 283 | */ 284 | hostname?: string; 285 | /** 286 | * Target route for SSR and HTTP proxy 287 | * 288 | * With the default configuration, http://0.0.0.0:8080 will proxy to http://localhost:80 289 | * @default 'http://localhost:80' 290 | */ 291 | targetRoute?: string; 292 | /** 293 | * Defines the order which the proxy service will follow in case of errors 294 | * 295 | * For example, if defined as [ProxyType.SsrProxy, ProxyType.HttpProxy, ProxyType.StaticProxy], 296 | * it will try to use Server-Side Rendering first, and in case of an error, will try to use a HTTP Proxy, 297 | * and if that fails, it will fallback to Static File Serving 298 | * 299 | * Note: "error" in this context can mean an actual exception, or "shouldUse" returning false 300 | * @default [ProxyType.SsrProxy, ProxyType.HttpProxy, ProxyType.StaticProxy] 301 | */ 302 | proxyOrder?: ProxyType[]; 303 | /** 304 | * Custom implementation to define whether the client is a bot (e.g. Googlebot) 305 | * 306 | * @default Defaults to 'https://www.npmjs.com/package/isbot' 307 | */ 308 | isBot?: boolean | ((method: string, url: string, headers: HttpHeaders) => boolean); 309 | /** 310 | * Which HTTP response status code to return in case of an error 311 | * @default 404 312 | */ 313 | failStatus?: number | ((params: ProxyTypeParams) => number); 314 | /** 315 | * Custom error message handler 316 | * @example err => err.toString() 317 | * @default undefined 318 | */ 319 | customError?: string | ((err: any) => string); 320 | /** 321 | * Skip to next proxy type on error 322 | * @default true 323 | */ 324 | skipOnError?: boolean; 325 | /** 326 | * Indicates whether to force exit with process.exit on shutdown 327 | * @default true 328 | */ 329 | forceExit?: boolean; 330 | /** 331 | * Function for processing the original request before proxying 332 | * @default undefined 333 | */ 334 | reqMiddleware?: (params: ProxyParams) => Promise; 335 | /** 336 | * Function for processing the proxy result before serving 337 | * @default undefined 338 | */ 339 | resMiddleware?: (params: ProxyParams, result: ProxyResult) => Promise; 340 | /** 341 | * Server-Side Rendering configuration 342 | */ 343 | ssr?: SsrConfig & { 344 | /** 345 | * Indicates if the SSR Proxy should be used 346 | * @default params => params.isBot && (/\.html$/.test(params.targetUrl.pathname) || !/\./.test(params.targetUrl.pathname)) 347 | */ 348 | shouldUse?: boolean | ((params: ProxyParams) => boolean); 349 | /** 350 | * Cron expression for closing the shared browser instance 351 | * @default undefined 352 | */ 353 | cleanUpCron?: string; 354 | /** 355 | * Tz for cleanUpCron 356 | * @default 'Etc/UTC' 357 | */ 358 | cleanUpTz?: string; 359 | }; 360 | /** 361 | * HTTP Proxy configuration 362 | */ 363 | httpProxy?: { 364 | /** 365 | * Indicates if the HTTP Proxy should be used 366 | * @default true 367 | */ 368 | shouldUse?: boolean | ((params: ProxyParams) => boolean); 369 | /** 370 | * Which query string params to include in the url before proxying 371 | * @example 372 | * [{ key: 'headless', value: 'false' }] 373 | * @default 374 | * [] 375 | */ 376 | queryParams?: { 377 | key: string; 378 | value: string; 379 | }[]; 380 | /** 381 | * Ignore https errors via rejectUnauthorized=false 382 | * @default false 383 | */ 384 | unsafeHttps?: boolean | ((params: ProxyParams) => boolean); 385 | /** 386 | * Timeout 387 | * @default 60000 388 | */ 389 | timeout?: number; 390 | }; 391 | /** 392 | * Static File Serving configuration 393 | */ 394 | static?: { 395 | /** 396 | * Indicates if the Static File Serving should be used 397 | * @default false 398 | */ 399 | shouldUse?: boolean | ((params: ProxyParams) => boolean); 400 | /** 401 | * Absolute path of the directory to serve 402 | * @default 'public' 403 | */ 404 | dirPath?: string; 405 | /** 406 | * Indicates whether to use the default index file 407 | * @default path => path.endsWith('/') 408 | */ 409 | useIndexFile?: (path: string) => boolean; 410 | /** 411 | * Default index file to use 412 | * @default 'index.html' 413 | */ 414 | indexFile?: string; 415 | }; 416 | /** 417 | * Logging configuration 418 | */ 419 | log?: LogConfig; 420 | /** 421 | * Caching configuration 422 | */ 423 | cache?: { 424 | /** 425 | * Indicates if the caching should be used 426 | * @default params => params.proxyType === ProxyType.SsrProxy 427 | */ 428 | shouldUse?: boolean | ((params: ProxyTypeParams) => boolean); 429 | /** 430 | * Defines the maximum number of pages to cache 431 | * @default 50 432 | */ 433 | maxEntries?: number; 434 | /** 435 | * Defines the maximum size of the cache in bytes 436 | * @default 50 * 1000 * 1000 // 50MB 437 | */ 438 | maxByteSize?: number; 439 | /** 440 | * Defines the expiration time for each cached page 441 | * @default 25 * 60 * 60 * 1000 // 25h 442 | */ 443 | expirationMs?: number; 444 | /** 445 | * Auto refreshing configuration 446 | * 447 | * Auto refresh will access the configured pages periodically, and cache the result to be used on following access 448 | */ 449 | autoRefresh?: SsrJob & { 450 | /** 451 | * Enable auto refreshing 452 | * @default false 453 | */ 454 | enabled?: boolean; 455 | /** 456 | * Indicates if the auto refresh should be used 457 | * @default true 458 | */ 459 | shouldUse?: boolean | (() => boolean); 460 | /** 461 | * Defines the order which the proxy service will follow in case of errors, similar to 'config.proxyOrder' 462 | * @default [ProxyType.SsrProxy] 463 | */ 464 | proxyOrder?: ProxyType[]; 465 | /** 466 | * Whether to access routes as bot while auto refreshing 467 | * @default true 468 | */ 469 | isBot?: boolean; 470 | /** 471 | * Delay before first refresh 472 | * @default 5 * 1000 // 5s 473 | */ 474 | initTimeoutMs?: number; 475 | /** 476 | * Cron expression for interval between refreshes 477 | * @default '0 0 3 * * *' // every day at 3am 478 | */ 479 | intervalCron?: string; 480 | /** 481 | * Tz for intervalCron 482 | * @default 'Etc/UTC' 483 | */ 484 | intervalTz?: string; 485 | /** 486 | * Whether to close the shared browser instance after refreshing the cache 487 | * @default true 488 | */ 489 | closeBrowser?: boolean; 490 | }; 491 | }; 492 | } 493 | 494 | // Proxy Cache 495 | 496 | export interface CacheItem { 497 | text: string; 498 | contentType: string; 499 | } 500 | 501 | export interface CacheDeletion { 502 | key: string; 503 | reason: string; 504 | } 505 | 506 | export interface InternalCacheItem { 507 | text: string; 508 | status: number; 509 | contentType: string; 510 | hits: number; 511 | date: Date; 512 | } 513 | 514 | // Logger 515 | 516 | export enum LogLevel { 517 | None = 0, 518 | Error = 1, 519 | Info = 2, 520 | Debug = 3 521 | } -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Stream } from 'stream'; 2 | 3 | export function getOrCall(obj: T | ((...args: any[]) => T), ...args: any[]): T; 4 | export function getOrCall(obj?: T | ((...args: any[]) => T), ...args: any[]): T | undefined { 5 | return typeof obj === 'function' ? (obj as (...args: any[]) => T)?.(...args) : obj; 6 | } 7 | 8 | export function streamToString(stream: Stream): Promise { 9 | const chunks: Buffer[] = []; 10 | return new Promise((res, rej) => { 11 | if (!stream?.on) return res(stream as any); 12 | stream.on('data', chunk => chunks.push(Buffer.from(chunk))); 13 | stream.on('error', err => rej(err)); 14 | stream.on('end', () => res(Buffer.concat(chunks as any).toString('utf8'))); 15 | }); 16 | } 17 | 18 | export function promiseParallel(tasks: (() => Promise)[], concurrencyLimit: number, noReject: boolean = false): Promise<(T | TRej)[]> { 19 | return new Promise<(T | TRej)[]>((res, rej) => { 20 | if (tasks.length === 0) res([]); 21 | 22 | const results: (T | TRej)[] = []; 23 | const pool: Promise[] = []; 24 | let canceled: boolean = false; 25 | 26 | tasks.slice(0, concurrencyLimit).map(async (e) => await runPromise(e)); 27 | 28 | function runPromise(task: () => Promise): Promise { 29 | let promise: Promise = task(); 30 | 31 | pool.push(promise); 32 | 33 | if (noReject) promise = promise.catch((e: TRej) => e); 34 | 35 | promise = promise.then(async r => { 36 | if (canceled) return r; 37 | 38 | results.push(r); 39 | 40 | const poolIndex = pool.indexOf(promise); 41 | pool.splice(poolIndex, 1); 42 | 43 | if (tasks.length === results.length) 44 | res(results); 45 | 46 | const nextIndex = concurrencyLimit + results.length - 1; 47 | const nextTask = tasks[nextIndex]; 48 | 49 | if (!nextTask) return r; 50 | 51 | return await runPromise(nextTask); 52 | }); 53 | 54 | if (!noReject) promise = promise.catch(err => { canceled = true; rej(err); return err; }); 55 | 56 | return promise; 57 | } 58 | }); 59 | } 60 | 61 | export async function promiseRetry(func: () => Promise, maxRetries: number, onError?: (err: any) => void): Promise { 62 | try { 63 | return await func(); 64 | } catch (err) { 65 | onError?.(err); 66 | const funcAny = (func as any); 67 | funcAny._retries = (funcAny._retries as number ?? 0) + 1; 68 | if (funcAny._retries >= maxRetries) throw err; 69 | else return await promiseRetry(func, maxRetries, onError); 70 | } 71 | } 72 | 73 | export function promiseDeferred(): { promise: Promise, resolve: (value: T) => void, reject: (reason?: any) => void } { 74 | let resolve: (value: T) => void; 75 | let reject: (reason?: any) => void; 76 | const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); 77 | return { promise, resolve: resolve!, reject: reject! }; 78 | } 79 | 80 | export const createLock = () => { 81 | const queue: (() => Promise)[] = []; 82 | 83 | let active = false; 84 | 85 | return (fn: () => Promise) => { 86 | const { promise, resolve, reject } = promiseDeferred(); 87 | 88 | // call function then next on queue 89 | const exec = async () => { 90 | await fn().then(resolve, reject); 91 | if (queue.length > 0) { 92 | queue.shift()!(); // call next function 93 | } else { 94 | active = false; 95 | } 96 | }; 97 | 98 | // call current or add to queue 99 | if (active) { 100 | queue.push(exec); 101 | } else { 102 | active = true; 103 | exec(); 104 | } 105 | 106 | return promise; 107 | }; 108 | }; 109 | 110 | export function timeoutAsync(callback: () => void | Promise, timeout: number) { 111 | return new Promise((res, rej) => { 112 | setTimeout(async () => { 113 | await callback(); 114 | res(); 115 | }, timeout); 116 | }); 117 | } 118 | 119 | export function intervalAsync(callback: () => boolean | Promise, timeout: number, eager: boolean = false) { 120 | return new Promise(async (res, rej) => { 121 | if (eager && await callback()) return res(); 122 | const interval = setInterval(async () => { 123 | if (await callback()) { 124 | clearInterval(interval); 125 | return res(); 126 | } 127 | }, timeout); 128 | }); 129 | } 130 | 131 | export function sleep(timeout: number) { 132 | return timeoutAsync(async () => {}, timeout); 133 | } -------------------------------------------------------------------------------- /test/build.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os'; 2 | import * as path from 'path'; 3 | import { LogLevel, SsrBuild, SsrBuildConfig } from 'ssr-proxy-js-local'; // ssr-proxy-js or ssr-proxy-js-local 4 | 5 | const config: SsrBuildConfig = { 6 | httpPort: 8080, 7 | hostname: 'localhost', 8 | src: 'public', 9 | dist: 'dist', 10 | // stopOnError: true, 11 | serverMiddleware: async (req, res, next) => { 12 | // res.sendFile(path.join(__dirname, 'public/index.html')); 13 | // res.sendFile(path.join(__dirname, 'public', req.path)); 14 | next(); 15 | }, 16 | reqMiddleware: async (params) => { 17 | params.headers['Referer'] = 'http://google.com'; 18 | return params; 19 | }, 20 | resMiddleware: async (params, result) => { 21 | if (result.text == null) return result; 22 | result.text = result.text.replace('', '\n\t
MIDDLEWARE
\n'); 23 | result.text = result.text.replace(/]*>[\s\S]*?<\/style>/gi, ''); 24 | return result; 25 | }, 26 | ssr: { 27 | browserConfig: { headless: false, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], timeout: 60000 }, 28 | sharedBrowser: true, 29 | queryParams: [{ key: 'headless', value: 'true' }], 30 | allowedResources: ['document', 'script', 'xhr', 'fetch'], 31 | waitUntil: 'networkidle0', 32 | timeout: 60000, 33 | sleep: 1000, 34 | }, 35 | log: { 36 | level: LogLevel.Info, 37 | console: { 38 | enabled: true, 39 | }, 40 | file: { 41 | enabled: true, 42 | dirPath: path.join(os.tmpdir(), 'ssr-proxy-js/logs'), 43 | }, 44 | }, 45 | job: { 46 | retries: 3, 47 | parallelism: 3, 48 | routes: [ 49 | { method: 'GET', url: '/' }, 50 | { method: 'GET', url: '/nested' }, 51 | { method: 'GET', url: '/page.html' }, 52 | { method: 'GET', url: '/iframe.html' }, 53 | { method: 'GET', url: '/fail' }, 54 | ], 55 | }, 56 | }; 57 | 58 | const ssrBuild = new SsrBuild(config); 59 | 60 | ssrBuild.start(); -------------------------------------------------------------------------------- /test/docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | web-server: 3 | container_name: web-server 4 | image: web-server 5 | restart: always 6 | build: 7 | context: ../ 8 | dockerfile: ./docker/nginx.Dockerfile 9 | ports: 10 | - 8080:8080 11 | networks: 12 | - default 13 | depends_on: 14 | - ssr-proxy 15 | 16 | ssr-proxy: 17 | container_name: ssr-proxy 18 | image: ssr-proxy 19 | restart: always 20 | build: 21 | context: ../../ 22 | dockerfile: ./test/docker/ssr.Dockerfile 23 | ports: 24 | - 8081:8081 25 | networks: 26 | - default 27 | 28 | networks: 29 | default: 30 | driver: bridge -------------------------------------------------------------------------------- /test/docker/nginx.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.27.0-bookworm 2 | 3 | WORKDIR /app 4 | 5 | RUN apt-get update 6 | RUN apt-get install -y net-tools nano 7 | RUN rm -rf /var/lib/apt/lists/* 8 | 9 | COPY ./public/ ./public/ 10 | COPY ./docker/nginx.conf /etc/nginx/ 11 | 12 | EXPOSE 8080 13 | 14 | CMD nginx -g 'daemon off;' 15 | -------------------------------------------------------------------------------- /test/docker/nginx.conf: -------------------------------------------------------------------------------- 1 | # https://stackoverflow.com/a/59846239 2 | 3 | user nginx; 4 | worker_processes auto; 5 | 6 | error_log /var/log/nginx/error.log notice; 7 | pid /var/run/nginx.pid; 8 | 9 | events { 10 | worker_connections 1024; 11 | } 12 | 13 | http { 14 | include /etc/nginx/mime.types; 15 | default_type application/octet-stream; 16 | 17 | # Security headers 18 | 19 | add_header X-Frame-Options "SAMEORIGIN" always; 20 | add_header X-Content-Type-Options "nosniff" always; 21 | add_header Referrer-Policy "no-referrer-when-downgrade" always; 22 | 23 | # Logging 24 | 25 | log_format main '$remote_addr - $remote_user [$time_local] [$request_time] "$request" ' 26 | '$status $body_bytes_sent "$http_referer" ' 27 | '"$http_user_agent" "$http_x_forwarded_for"'; 28 | access_log /var/log/nginx/access.log main; 29 | 30 | # Basic settings 31 | 32 | sendfile on; 33 | tcp_nopush on; 34 | tcp_nodelay on; 35 | keepalive_timeout 65; 36 | types_hash_max_size 2048; 37 | server_tokens off; 38 | client_max_body_size 1M; 39 | 40 | # Check Bots 41 | 42 | map $http_user_agent $is_bot_agent { 43 | default 0; 44 | "~*(googlebot|Google-InspectionTool|bingbot|yandex|baiduspider|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterest|slackbot|vkShare|W3C_Validator|mj12bot|ahrefsbot|semrushbot|dotbot|applebot|duckduckbot|sogou|exabot|petalbot|ia_archiver|alexabot|msnbot|archive.org_bot|screaming frog|proximic|yahoo! slurp)" 1; 45 | } 46 | 47 | map $request_uri $is_bot_query { 48 | default 0; 49 | "~*[\?&]isbot(?:=|&|$)" 1; 50 | } 51 | 52 | map "$is_bot_agent$is_bot_query" $should_proxy { 53 | default 0; 54 | ~*1 1; 55 | } 56 | 57 | # Server 58 | 59 | server { 60 | listen 8080; 61 | server_name _; 62 | http2 on; 63 | 64 | root /app/public; 65 | index index.html; 66 | 67 | # Static Files 68 | 69 | error_page 404 /_not-found.html; 70 | location = /_not-found.html { 71 | internal; 72 | return 200 "404 Not Found"; 73 | } 74 | 75 | location /404 { 76 | if ($should_proxy) { 77 | proxy_pass http://ssr-proxy:8081; 78 | break; 79 | } 80 | 81 | return 404; 82 | } 83 | 84 | location /301 { 85 | if ($should_proxy) { 86 | proxy_pass http://ssr-proxy:8081; 87 | break; 88 | } 89 | 90 | return 301 $scheme://$http_host/; 91 | } 92 | 93 | location / { 94 | if ($should_proxy) { 95 | proxy_pass http://ssr-proxy:8081; 96 | break; 97 | } 98 | 99 | try_files $uri /index.html; 100 | } 101 | } 102 | } -------------------------------------------------------------------------------- /test/docker/ssr-proxy-js.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "httpPort": 8081, 3 | "hostname": "0.0.0.0", 4 | "targetRoute": "http://web-server:8080", 5 | "proxyOrder": ["SsrProxy","HttpProxy"], 6 | "isBot": true, 7 | "ssr": { 8 | "browserConfig": { 9 | "headless": true, 10 | "args": ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"] 11 | }, 12 | "queryParams": [{ "key": "headless", "value": "true" }], 13 | "allowedResources": ["document", "script", "xhr", "fetch"], 14 | "waitUntil": "networkidle0" 15 | }, 16 | "httpProxy": { 17 | "shouldUse": true 18 | }, 19 | "static": { 20 | "shouldUse": false 21 | }, 22 | "log": { 23 | "level": 3, 24 | "console": { 25 | "enabled": true 26 | }, 27 | "file": { 28 | "enabled": true, 29 | "dirPath": "/tmp/ssr-proxy/logs" 30 | } 31 | }, 32 | "cache": { 33 | "shouldUse": true, 34 | "maxEntries": 50, 35 | "maxByteSize": 52428800, 36 | "expirationMs": 14400000, 37 | "autoRefresh": { 38 | "enabled": true, 39 | "shouldUse": true, 40 | "proxyOrder": ["SsrProxy","HttpProxy"], 41 | "initTimeoutMs": 5000, 42 | "intervalCron": "0 0 3 * * *", 43 | "intervalTz": "Etc/UTC", 44 | "parallelism": 2, 45 | "isBot": true, 46 | "routes": [ 47 | { "method": "GET", "url": "/" }, 48 | { "method": "GET", "url": "/nested" }, 49 | { "method": "GET", "url": "/nested.dot/index.html" } 50 | ] 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /test/docker/ssr.Dockerfile: -------------------------------------------------------------------------------- 1 | # curl -fsSL -A 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' http://localhost/ > output-bot.html 2 | # curl -fsSL -A 'bingbot/2.0' https://investtester.com/ > output-bot.html 3 | # Chrome DevTools -> Network conditions -> User agent -> bingbot/2.0 / Console -> Ctrl + Shift + P -> Disable JavaScript 4 | 5 | # BUILD 6 | 7 | FROM node:20.12.2-bookworm AS build 8 | 9 | WORKDIR /app 10 | 11 | COPY package*.json ./ 12 | RUN npm install 13 | 14 | COPY . . 15 | RUN npm run build 16 | 17 | # RUN 18 | 19 | FROM node:20.12.2-bookworm AS run 20 | 21 | WORKDIR /app 22 | 23 | RUN apt-get update 24 | # RUN apt search ^chromium$ && exit 1 25 | RUN apt-get install -y chromium 26 | RUN apt-get install -y gettext-base moreutils 27 | RUN rm -rf /var/lib/apt/lists/* 28 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true 29 | ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium 30 | 31 | # # https://stackoverflow.com/a/71128432 32 | # RUN apt-get update 33 | # RUN apt-get install -y ca-certificates fonts-liberation \ 34 | # libappindicator3-1 libasound2 libatk-bridge2.0-0 libatk1.0-0 \ 35 | # libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 \ 36 | # libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 \ 37 | # libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 \ 38 | # libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 \ 39 | # libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release wget xdg-utils 40 | # RUN rm -rf /var/lib/apt/lists/* 41 | 42 | COPY --from=build /app/dist/ ./dist/ 43 | COPY --from=build /app/node_modules/ ./node_modules/ 44 | COPY --from=build /app/bin/cli.js ./bin/ 45 | COPY ./test/docker/ssr-proxy-js.config.json . 46 | 47 | EXPOSE 8081 48 | 49 | CMD node ./bin/cli.js -c ./ssr-proxy-js.config.json 50 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ssr-proxy-js-test", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "init": "npm install", 6 | "build": "cd .. && npm run build", 7 | "start": "npm run build && nodemon --watch ./ --watch ../ proxy.js", 8 | "start:ts": "npm run build && ts-node-dev --project tsconfig.json proxy.ts", 9 | "start:build": "rimraf dist && cpx 'public/**/*' dist/ && npm run build && ts-node-dev --project tsconfig.json build.ts", 10 | "start:docker": "sudo docker compose -f ./docker/docker-compose.yml up --build", 11 | "start:cli": "npm run build && node ./node_modules/ssr-proxy-js-local/bin/cli.js -c ./ssr-proxy-js.config.json", 12 | "start:cli-build": "npm run build && node ./node_modules/ssr-proxy-js-local/bin/cli.js --mode build -c ./ssr-build-js.config.json", 13 | "start:cli-build-args": "npm run build && node ./node_modules/ssr-proxy-js-local/bin/cli.js --mode=build --httpPort=8080 --src=./public --dist=./dist --job.routes='[{\"url\":\"/\"},{\"url\":\"/nested\"}]'", 14 | "start:npx": "npm_config_yes=true npx --yes ssr-proxy-js -c ./ssr-proxy-js.config.json", 15 | "start:npx-build": "npm_config_yes=true npx --yes ssr-proxy-js --mode build -c ./ssr-build-js.config.json", 16 | "test:request": "node test-request.js", 17 | "test:stream": "node test-stream.js", 18 | "test:curl-bot": "curl -fsSL -A 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' http://localhost:8080/ > output.html && xdg-open output.html", 19 | "serve": "npm_config_yes=true npx --yes http-server ./public -p 8080 -a 0.0.0.0", 20 | "serve:dist": "npm_config_yes=true npx --yes http-server ./dist -p 8080 -a 0.0.0.0", 21 | "docker": "sudo docker build -f Dockerfile -t ssr-proxy-js . && sudo docker run -it --rm -p 8080:8080 ssr-proxy-js" 22 | }, 23 | "dependencies": { 24 | "axios": "^0.24.0", 25 | "express": "^4.17.1", 26 | "ssr-proxy-js": "^1.0.1", 27 | "ssr-proxy-js-local": "file:../" 28 | }, 29 | "devDependencies": { 30 | "cpx": "^1.5.0", 31 | "nodemon": "^2.0.15", 32 | "rimraf": "^6.0.1", 33 | "ts-node-dev": "^1.1.8", 34 | "typescript": "^4.4.4" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /test/proxy.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | const path = require('path'); 3 | const { SsrProxy } = require('ssr-proxy-js-local'); // ssr-proxy-js or ssr-proxy-js-local 4 | 5 | const BASE_PROXY_PORT = '8080'; 6 | const BASE_PROXY_ROUTE = `http://localhost:${BASE_PROXY_PORT}`; 7 | const STATIC_FILES_PATH = path.join(process.cwd(), 'public'); 8 | const LOGGING_PATH = path.join(os.tmpdir(), 'ssr-proxy/logs'); 9 | 10 | console.log(`\nLogging at: ${LOGGING_PATH}`); 11 | 12 | // Proxy 13 | 14 | const ssrProxy = new SsrProxy({ 15 | httpPort: 8081, 16 | hostname: '0.0.0.0', 17 | targetRoute: BASE_PROXY_ROUTE, 18 | proxyOrder: ['SsrProxy', 'HttpProxy', 'StaticProxy'], 19 | isBot: (method, url, headers) => true, 20 | failStatus: params => 404, 21 | customError: err => err.toString(), 22 | skipOnError: false, 23 | ssr: { 24 | shouldUse: params => params.isBot && (/\.html$/.test(params.targetUrl.pathname) || !/\./.test(params.targetUrl.pathname)), 25 | browserConfig: { headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], timeout: 60000 }, 26 | queryParams: [{ key: 'headless', value: 'true' }], 27 | allowedResources: ['document', 'script', 'xhr', 'fetch'], 28 | waitUntil: 'networkidle0', 29 | timeout: 60000, 30 | }, 31 | httpProxy: { 32 | shouldUse: params => true, 33 | timeout: 60000, 34 | }, 35 | static: { 36 | shouldUse: params => true, 37 | dirPath: STATIC_FILES_PATH, 38 | useIndexFile: path => path.endsWith('/'), 39 | indexFile: 'index.html', 40 | }, 41 | log: { 42 | level: 3, 43 | console: { 44 | enabled: true, 45 | }, 46 | file: { 47 | enabled: true, 48 | dirPath: LOGGING_PATH, 49 | }, 50 | }, 51 | cache: { 52 | shouldUse: params => params.proxyType === 'SsrProxy', 53 | maxEntries: 50, 54 | maxByteSize: 50 * 1024 * 1024, // 50MB 55 | expirationMs: 25 * 60 * 60 * 1000, // 25h 56 | autoRefresh: { 57 | enabled: true, 58 | shouldUse: () => true, 59 | proxyOrder: ['SsrProxy', 'HttpProxy'], 60 | initTimeoutMs: 5 * 1000, // 5s 61 | intervalCron: '0 0 3 * * *', // every day at 3am 62 | intervalTz: 'Etc/UTC', 63 | retries: 3, 64 | parallelism: 5, 65 | closeBrowser: true, 66 | isBot: true, 67 | routes: [ 68 | { method: 'GET', url: '/' }, 69 | { method: 'GET', url: '/login' }, 70 | ], 71 | }, 72 | }, 73 | }); 74 | 75 | ssrProxy.start(); 76 | 77 | // Server 78 | 79 | const express = require('express'); 80 | const app = express(); 81 | 82 | app.get('/', (req, res) => { 83 | res.send('Hello World!'); 84 | }); 85 | 86 | app.get('/301', (req, res) => { 87 | res.redirect(301, '/'); 88 | }); 89 | 90 | app.get('/302', (req, res) => { 91 | res.redirect(302, '/'); 92 | }); 93 | 94 | app.listen(BASE_PROXY_PORT, () => { 95 | console.log(`Express listening at ${BASE_PROXY_ROUTE}`); 96 | }); -------------------------------------------------------------------------------- /test/proxy.ts: -------------------------------------------------------------------------------- 1 | // Run "npm run serve" in parallel 2 | 3 | import { LogLevel, SsrProxy, SsrProxyConfig } from 'ssr-proxy-js-local'; // ssr-proxy-js or ssr-proxy-js-local 4 | 5 | const BASE_PROXY_PORT = '8080'; 6 | const BASE_PROXY_ROUTE = `http://localhost:${BASE_PROXY_PORT}`; 7 | 8 | // Proxy 9 | 10 | const config: SsrProxyConfig = { 11 | httpPort: 8081, 12 | hostname: '0.0.0.0', 13 | targetRoute: BASE_PROXY_ROUTE, 14 | isBot: true, 15 | reqMiddleware: async (params) => { 16 | params.targetUrl.search = ''; 17 | params.targetUrl.pathname = params.targetUrl.pathname.replace(/\/+$/, '') || '/'; 18 | return params; 19 | }, 20 | resMiddleware: async (params, result) => { 21 | if (result.text == null) return result; 22 | result.text = result.text.replace('', '\n\t
MIDDLEWARE
\n'); 23 | result.text = result.text.replace(/]*>[\s\S]*?<\/style>/gi, ''); 24 | return result; 25 | }, 26 | log: { level: LogLevel.Debug }, 27 | }; 28 | 29 | const ssrProxy = new SsrProxy(config); 30 | 31 | ssrProxy.start(); 32 | 33 | // Server 34 | 35 | import * as express from 'express'; 36 | const app = express(); 37 | 38 | // Serve Static Files 39 | app.use(express.static('public')); 40 | 41 | app.listen(BASE_PROXY_PORT, () => { 42 | console.log(`Express listening at ${BASE_PROXY_ROUTE}`); 43 | }); -------------------------------------------------------------------------------- /test/public/iframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 |

12 | Ipsum tempor non amet amet tempor fugiat consequat voluptate Lorem veniam culpa. Nostrud et voluptate ea excepteur veniam exercitation. Reprehenderit nulla irure tempor laborum ut velit. 13 |

14 | 15 | -------------------------------------------------------------------------------- /test/public/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #222222; 3 | } 4 | 5 | p { 6 | margin: 50px; 7 | color: #f5f5f5; 8 | } -------------------------------------------------------------------------------- /test/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 | 16 | 17 | 18 |

19 | Ipsum tempor non amet amet tempor fugiat consequat voluptate Lorem veniam culpa. Nostrud et voluptate ea excepteur veniam exercitation. Reprehenderit nulla irure tempor laborum ut velit. 20 |

21 |

22 | Qui nulla proident aliquip dolore adipisicing sunt Lorem laborum. Culpa eiusmod minim nostrud in est labore reprehenderit mollit eiusmod do. Velit incididunt pariatur sunt qui aliqua anim. Qui laboris incididunt pariatur aliqua occaecat pariatur excepteur consectetur irure et nisi nostrud elit. 23 |

24 |

25 | Mollit tempor amet amet deserunt ullamco tempor eu ipsum dolor commodo. Pariatur ut qui magna aliquip qui occaecat mollit voluptate reprehenderit excepteur id mollit. In cillum esse voluptate est cillum. Enim esse qui sint magna non Lorem proident proident ex. Duis deserunt esse nisi voluptate. Culpa consequat incididunt Lorem eiusmod dolor ullamco sit laboris voluptate elit occaecat et. 26 |

27 |

28 | Commodo aliqua non occaecat nisi voluptate ea ipsum. Fugiat eiusmod fugiat adipisicing commodo id dolore aliqua laboris id aute. Mollit dolore minim magna ullamco officia eu elit dolore aliquip labore. In deserunt proident fugiat Lorem incididunt eu excepteur. Eiusmod anim anim enim esse. 29 |

30 |

31 | Reprehenderit anim amet nostrud esse non nisi dolor veniam velit incididunt. Nulla et id laborum officia ut dolor sint ea elit cupidatat. Nulla officia dolore amet laborum anim. 32 |

33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /test/public/index.js: -------------------------------------------------------------------------------- 1 | document.body.innerHTML = `\n\t

JS Imported!

${document.body.innerHTML}`; -------------------------------------------------------------------------------- /test/public/nested.dot/index.dot.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #777777 !important; 3 | } 4 | 5 | p { 6 | margin: 50px; 7 | color: #f5f5f5; 8 | } -------------------------------------------------------------------------------- /test/public/nested.dot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 | 12 | 13 |

14 | Ipsum tempor non amet amet tempor fugiat consequat voluptate Lorem veniam culpa. Nostrud et voluptate ea excepteur veniam exercitation. Reprehenderit nulla irure tempor laborum ut velit. 15 |

16 |

17 | Qui nulla proident aliquip dolore adipisicing sunt Lorem laborum. Culpa eiusmod minim nostrud in est labore reprehenderit mollit eiusmod do. Velit incididunt pariatur sunt qui aliqua anim. Qui laboris incididunt pariatur aliqua occaecat pariatur excepteur consectetur irure et nisi nostrud elit. 18 |

19 |

20 | Mollit tempor amet amet deserunt ullamco tempor eu ipsum dolor commodo. Pariatur ut qui magna aliquip qui occaecat mollit voluptate reprehenderit excepteur id mollit. In cillum esse voluptate est cillum. Enim esse qui sint magna non Lorem proident proident ex. Duis deserunt esse nisi voluptate. Culpa consequat incididunt Lorem eiusmod dolor ullamco sit laboris voluptate elit occaecat et. 21 |

22 |

23 | Commodo aliqua non occaecat nisi voluptate ea ipsum. Fugiat eiusmod fugiat adipisicing commodo id dolore aliqua laboris id aute. Mollit dolore minim magna ullamco officia eu elit dolore aliquip labore. In deserunt proident fugiat Lorem incididunt eu excepteur. Eiusmod anim anim enim esse. 24 |

25 |

26 | Reprehenderit anim amet nostrud esse non nisi dolor veniam velit incididunt. Nulla et id laborum officia ut dolor sint ea elit cupidatat. Nulla officia dolore amet laborum anim. 27 |

28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /test/public/nested/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #777777 !important; 3 | } 4 | 5 | p { 6 | margin: 50px; 7 | color: #f5f5f5; 8 | } -------------------------------------------------------------------------------- /test/public/nested/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 | 12 | 13 |

14 | Ipsum tempor non amet amet tempor fugiat consequat voluptate Lorem veniam culpa. Nostrud et voluptate ea excepteur veniam exercitation. Reprehenderit nulla irure tempor laborum ut velit. 15 |

16 |

17 | Qui nulla proident aliquip dolore adipisicing sunt Lorem laborum. Culpa eiusmod minim nostrud in est labore reprehenderit mollit eiusmod do. Velit incididunt pariatur sunt qui aliqua anim. Qui laboris incididunt pariatur aliqua occaecat pariatur excepteur consectetur irure et nisi nostrud elit. 18 |

19 |

20 | Mollit tempor amet amet deserunt ullamco tempor eu ipsum dolor commodo. Pariatur ut qui magna aliquip qui occaecat mollit voluptate reprehenderit excepteur id mollit. In cillum esse voluptate est cillum. Enim esse qui sint magna non Lorem proident proident ex. Duis deserunt esse nisi voluptate. Culpa consequat incididunt Lorem eiusmod dolor ullamco sit laboris voluptate elit occaecat et. 21 |

22 |

23 | Commodo aliqua non occaecat nisi voluptate ea ipsum. Fugiat eiusmod fugiat adipisicing commodo id dolore aliqua laboris id aute. Mollit dolore minim magna ullamco officia eu elit dolore aliquip labore. In deserunt proident fugiat Lorem incididunt eu excepteur. Eiusmod anim anim enim esse. 24 |

25 |

26 | Reprehenderit anim amet nostrud esse non nisi dolor veniam velit incididunt. Nulla et id laborum officia ut dolor sint ea elit cupidatat. Nulla officia dolore amet laborum anim. 27 |

28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /test/public/page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 | 16 | 17 | 18 |

19 | Ipsum tempor non amet amet tempor fugiat consequat voluptate Lorem veniam culpa. Nostrud et voluptate ea excepteur veniam exercitation. Reprehenderit nulla irure tempor laborum ut velit. 20 |

21 |

22 | Qui nulla proident aliquip dolore adipisicing sunt Lorem laborum. Culpa eiusmod minim nostrud in est labore reprehenderit mollit eiusmod do. Velit incididunt pariatur sunt qui aliqua anim. Qui laboris incididunt pariatur aliqua occaecat pariatur excepteur consectetur irure et nisi nostrud elit. 23 |

24 |

25 | Mollit tempor amet amet deserunt ullamco tempor eu ipsum dolor commodo. Pariatur ut qui magna aliquip qui occaecat mollit voluptate reprehenderit excepteur id mollit. In cillum esse voluptate est cillum. Enim esse qui sint magna non Lorem proident proident ex. Duis deserunt esse nisi voluptate. Culpa consequat incididunt Lorem eiusmod dolor ullamco sit laboris voluptate elit occaecat et. 26 |

27 |

28 | Commodo aliqua non occaecat nisi voluptate ea ipsum. Fugiat eiusmod fugiat adipisicing commodo id dolore aliqua laboris id aute. Mollit dolore minim magna ullamco officia eu elit dolore aliquip labore. In deserunt proident fugiat Lorem incididunt eu excepteur. Eiusmod anim anim enim esse. 29 |

30 |

31 | Reprehenderit anim amet nostrud esse non nisi dolor veniam velit incididunt. Nulla et id laborum officia ut dolor sint ea elit cupidatat. Nulla officia dolore amet laborum anim. 32 |

33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /test/ssr-build-js.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "httpPort": 8080, 3 | "hostname": "localhost", 4 | "src": "public", 5 | "dist": "dist", 6 | "ssr": { 7 | "browserConfig": { "headless": true, "args": ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"], "timeout": 60000 }, 8 | "sharedBrowser": false, 9 | "queryParams": [{ "key": "headless", "value": "true" }], 10 | "allowedResources": ["document", "script", "xhr", "fetch"], 11 | "waitUntil": "networkidle0", 12 | "timeout": 60000 13 | }, 14 | "log": { 15 | "level": 2, 16 | "console": { 17 | "enabled": true 18 | }, 19 | "file": { 20 | "enabled": false 21 | } 22 | }, 23 | "job": { 24 | "retries": 3, 25 | "parallelism": 5, 26 | "routes": [ 27 | { "method": "GET", "url": "/" }, 28 | { "method": "GET", "url": "/nested" }, 29 | { "method": "GET", "url": "/page.html" }, 30 | { "method": "GET", "url": "/iframe.html" } 31 | ] 32 | } 33 | } -------------------------------------------------------------------------------- /test/ssr-proxy-js.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "httpPort": 8081, 3 | "hostname": "0.0.0.0", 4 | "targetRoute": "https://react.dev", 5 | "proxyOrder": ["SsrProxy","HttpProxy"], 6 | "isBot": true, 7 | "ssr": { 8 | "browserConfig": { 9 | "headless": true, 10 | "args": ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"] 11 | }, 12 | "allowedResources": ["document", "script", "xhr", "fetch"], 13 | "waitUntil": "networkidle0" 14 | }, 15 | "httpProxy": { 16 | "shouldUse": true, 17 | "unsafeHttps": true 18 | }, 19 | "static": { 20 | "shouldUse": false 21 | }, 22 | "log": { 23 | "level": 2, 24 | "console": { 25 | "enabled": true 26 | }, 27 | "file": { 28 | "enabled": false 29 | } 30 | }, 31 | "cache": { 32 | "shouldUse": false 33 | } 34 | } -------------------------------------------------------------------------------- /test/test-request.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | axios.get('http://localhost:3000/login') 4 | .then(r => r.data) 5 | .catch(err => err.response ? err.response.data : err.toString()) 6 | .then(console.log); -------------------------------------------------------------------------------- /test/test-stream.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const Transform = require('stream').Transform; 3 | 4 | const parser = new Transform(); 5 | parser._transform = (chunk, encoding, callback) => { 6 | const str = chunk.toString(); 7 | const error = null; // new Error('test'); 8 | 9 | console.log('\n--- CHUNK ---\n', str, '\n', error); 10 | 11 | callback(error, str); 12 | }; 13 | 14 | console.log('\n--- BEGIN STREAM ---'); 15 | 16 | // Create and Transform Stream 17 | let stream = fs.createReadStream('../../../build/index.html'); // Create 18 | stream = stream.pipe(parser); // Transform 19 | stream = stream.on('end', () => console.log('\n--- END STREAM ---')); // Runs after all data is read 20 | 21 | // Read Stream 22 | streamToString(stream).then(str => console.log('\n--- FULL DATA ---\n', str)).catch(e => e); 23 | 24 | 25 | 26 | function streamToString(stream) { 27 | const chunks = []; 28 | return new Promise((res, rej) => { 29 | if (!stream?.on) return res(stream); 30 | stream.on('data', chunk => chunks.push(Buffer.from(chunk))); 31 | stream.on('error', err => rej(err)); 32 | stream.on('end', () => res(Buffer.concat(chunks).toString('utf8'))); 33 | }); 34 | } -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "esnext" 5 | }, 6 | "exclude": [ "node_modules" ] 7 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 5 | "target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNext'. */ 6 | // "lib": [ "es2019", "dom" ], /* Specify library files to be included in the compilation */ 7 | "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | "declarationMap": true, /* Generates corresponding '.d.ts.map' file. */ 12 | "declarationDir": "./dist", /* Specifies the declaration files destination */ 13 | "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | // "outDir": "./dist", /* Redirect output structure to the directory. */ 16 | "removeComments": false, /* Do not emit comments to output. */ 17 | // "noEmit": true, /* Do not emit outputs. */ 18 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 19 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 20 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 21 | 22 | /* Strict Type-Checking Options */ 23 | "strict": true, /* Enable all strict type-checking options. */ 24 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 25 | // "strictNullChecks": true, /* Enable strict null checks. */ 26 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 27 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 28 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 29 | // "skipLibCheck": true, /* Skip type checking of all declaration files (*.d.ts). */ 30 | 31 | /* Additional Checks */ 32 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 33 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 34 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 35 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 36 | 37 | /* Module Resolution Options */ 38 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 39 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 40 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 41 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 42 | "typeRoots": [ /* List of folders to include type definitions from. */ 43 | "./src/@types", 44 | "./node_modules/@types" 45 | ], 46 | // "types": [], /* Type declaration files to be included in compilation. */ 47 | "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 48 | "esModuleInterop": true, /* Allows modules compatibility (commonjs, es6, ...) */ 49 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 50 | 51 | /* Source Map Options */ 52 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 53 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 54 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 55 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 56 | 57 | /* Experimental Options */ 58 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 59 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 60 | }, 61 | "include": [ "src" ], 62 | "exclude": [ "node_modules" ] 63 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const pkg = require('./package.json'); 3 | const nodeExternals = require('webpack-node-externals'); 4 | 5 | module.exports = { 6 | // mode: 'production', 7 | target: 'node', // in order to ignore built-in modules like path, fs, etc. 8 | entry: path.resolve(__dirname, 'src/index.ts'), 9 | module: { 10 | rules: [{ 11 | test: /\.ts$/, 12 | include: /src/, 13 | use: [{ 14 | loader: 'ts-loader' , 15 | options: { configFile: 'tsconfig.json' }, 16 | }], 17 | }], 18 | }, 19 | resolve: { 20 | extensions: ['.ts', '.js'], 21 | }, 22 | output: { 23 | library: pkg.name, 24 | libraryTarget: 'umd', 25 | filename: 'index.js', 26 | path: path.resolve(__dirname, 'dist'), 27 | }, 28 | externals: [ 29 | nodeExternals(), // in order to ignore all modules in node_modules folder 30 | ], 31 | }; --------------------------------------------------------------------------------