├── .gitignore ├── README.md ├── index.d.ts ├── package.json ├── postBuild.js ├── test └── test.js ├── tossr.js ├── tossr_logo.svg └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .history 2 | node_modules/ 3 | package-lock.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | tossr
3 |
4 | 5 | ### Universal SPA to SSR 6 | 7 | Render HTML from any SPA. 8 | 9 | ### Install 10 | 11 | `npm i tossr` 12 | 13 | ### Usage example 14 | 15 | ```javascript 16 | const { tossr } = require('tossr') 17 | 18 | const template = 'dist/index.html' 19 | const script = 'dist/app.js' 20 | const url = '/blog/ssr-is-fun' 21 | 22 | const html = await tossr(template, script, url) 23 | ``` 24 | 25 | ### Related libraries 26 | 27 | - [Spassr](https://github.com/roxiness/spassr) Small Express server with built in SSR 28 | - [Spank](https://github.com/roxiness/spank) Generate a static site from any SPA 29 | 30 | * * * 31 | 32 | ### API 33 | 34 | 35 | 36 | ##### Table of Contents 37 | 38 | - [tossr](#tossr) 39 | - [Parameters](#parameters) 40 | - [Config](#config) 41 | - [Properties](#properties) 42 | - [Eval](#eval) 43 | - [Parameters](#parameters-1) 44 | 45 | #### tossr 46 | 47 | Renders an HTML page from a HTML template, an app bundle and a path 48 | 49 | ##### Parameters 50 | 51 | - `template` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** Html template (or path to a HTML template). 52 | - `script` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** Bundled JS app (or path to bundled bundle JS app). 53 | - `url` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** Path to render. Ie. /blog/breathing-oxygen-linked-to-staying-alive 54 | - `options` **Partial<[Config](#config)>?** Options 55 | 56 | Returns **[Promise](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)<[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)>** 57 | 58 | #### Config 59 | 60 | Type: [object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object) 61 | 62 | ##### Properties 63 | 64 | - `host` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** hostname to use while rendering. Defaults to 65 | - `eventName` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** event to wait for before rendering app. Defaults to 'app-loaded' 66 | - `beforeEval` **[Eval](#eval)** Executed before script is evaluated. 67 | - `afterEval` **[Eval](#eval)** Executed after script is evaluated. 68 | - `silent` **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** Don't print timestamps 69 | - `inlineDynamicImports` **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** required for apps with dynamic imports 70 | - `timeout` **[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)** required for apps with dynamic imports 71 | - `dev` **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** disables caching of inlinedDynamicImports bundle 72 | - `errorHandler` **[function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)** 73 | 74 | #### Eval 75 | 76 | Called before/after the app script is evaluated 77 | 78 | Type: [Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function) 79 | 80 | ##### Parameters 81 | 82 | - `dom` **[object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** The DOM object 83 | \* 84 | 85 | 86 | --- 87 | 88 | Party vector created by gstudioimagen - www.freepik.com 89 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "tossr" { 2 | export type Config = { 3 | /** 4 | * hostname to use while rendering. Defaults to http://jsdom.ssr 5 | */ 6 | host: string; 7 | /** 8 | * event to wait for before rendering app. Defaults to 'app-loaded' 9 | */ 10 | eventName: string; 11 | /** 12 | * Executed before script is evaluated. 13 | */ 14 | beforeEval: Eval; 15 | /** 16 | * Executed after script is evaluated. 17 | */ 18 | afterEval: Eval; 19 | /** 20 | * Don't print timestamps 21 | */ 22 | silent: boolean; 23 | /** 24 | * required for apps with dynamic imports 25 | */ 26 | inlineDynamicImports: boolean; 27 | /** 28 | * required for apps with dynamic imports 29 | */ 30 | timeout: number; 31 | /** 32 | * disables caching of inlinedDynamicImports bundle 33 | */ 34 | dev: boolean; 35 | errorHandler: Function; 36 | }; 37 | /** 38 | * Called before/after the app script is evaluated 39 | */ 40 | export type Eval = (dom: object) => any; 41 | /** 42 | * Renders an HTML page from a HTML template, an app bundle and a path 43 | * @param {string} template Html template (or path to a HTML template). 44 | * @param {string} script Bundled JS app (or path to bundled bundle JS app). 45 | * @param {string} url Path to render. Ie. /blog/breathing-oxygen-linked-to-staying-alive 46 | * @param {Partial=} options Options 47 | * @returns {Promise} 48 | */ 49 | export function tossr(template: string, script: string, url: string, options?: Partial | undefined): Promise; 50 | export function inlineScript(script: any, dev?: boolean): Promise; 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tossr", 3 | "version": "1.4.2", 4 | "description": "Universal SSR", 5 | "main": "tossr.js", 6 | "scripts": { 7 | "build": "tsc && documentation readme tossr.js -s API && node ./postBuild", 8 | "test": "node test/test.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/roxiness/ssr.git" 13 | }, 14 | "keywords": [ 15 | "spa", 16 | "ssr", 17 | "jsdom", 18 | "router" 19 | ], 20 | "author": "jakobrosenberg@gmail.com", 21 | "license": "ISC", 22 | "bugs": { 23 | "url": "https://github.com/roxiness/ssr/issues" 24 | }, 25 | "homepage": "https://github.com/roxiness/ssr#readme", 26 | "dependencies": { 27 | "bufferutil": "^4.0.1", 28 | "configent": "^2.1.3", 29 | "esbuild": "^0.8.54", 30 | "jsdom": "^16.4.0", 31 | "node-fetch": "^2.6.0", 32 | "onetime": "^5.1.2", 33 | "utf-8-validate": "^5.0.2" 34 | }, 35 | "devDependencies": { 36 | "documentation": "^13.0.2", 37 | "typescript": "^4.0.3" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /postBuild.js: -------------------------------------------------------------------------------- 1 | const {appendFileSync} = require('fs') 2 | const footer = '\n\n---\n\nParty vector created by gstudioimagen - www.freepik.com' 3 | 4 | appendFileSync('./README.md', footer) -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const ssr = require('../tossr').tossr 3 | const process = require('process'); 4 | 5 | const script = (function () { 6 | const div = document.createElement('div'); 7 | div.classList.add(window['_myclass']) 8 | document.body.appendChild(div); 9 | dispatchEvent(new CustomEvent("app-loaded")) 10 | }).toString() 11 | 12 | 13 | try { 14 | ssr(` 15 | `, 16 | `(${script})()`, 17 | '/', { 18 | beforeEval: dom => dom.window._myclass = 'passed-var' 19 | }).then(html => { 20 | const expectedOutput = '
'; 21 | if (html !== expectedOutput) { 22 | console.error(`output differs from expectation: \n expected:\t${expectedOutput}\n actual:\t${html} `) 23 | process.exit(1); 24 | } 25 | process.exit(0); 26 | }).catch(e => { 27 | console.error('ssr promise error', e); 28 | process.exit(1); 29 | }) 30 | } catch (e) { 31 | console.error('ssr call error', e); 32 | process.exit(1); 33 | } 34 | -------------------------------------------------------------------------------- /tossr.js: -------------------------------------------------------------------------------- 1 | const { JSDOM } = require('jsdom') 2 | const { dirname, resolve } = require('path') 3 | const { existsSync, readFileSync } = require('fs') 4 | const process = require('process') 5 | const onetime = require('onetime') 6 | const fetch = require('node-fetch') 7 | const { configent } = require('configent') 8 | const getBundlePath = script => resolve(dirname(script), '__roxi-ssr-bundle.js') 9 | 10 | /** @type {Config} */ 11 | const defaults = { 12 | host: 'http://jsdom.ssr', 13 | eventName: 'app-loaded', 14 | beforeEval(dom) { }, 15 | afterEval(dom) { }, 16 | silent: false, 17 | inlineDynamicImports: false, 18 | timeout: 5000, 19 | dev: false, 20 | errorHandler: (err, url, ctx) => { 21 | console.log('[tossr] url:', url) 22 | throw Error(err) 23 | }, 24 | disableCatchUnhandledRejections: false 25 | } 26 | 27 | // Intercept unhandled rejections in the Node process: 28 | // https://nodejs.org/api/process.html#process_event_uncaughtexception. 29 | // 30 | // This is generally a bad idea in Node, but there is no other way to avoid 31 | // errors in jsdom causing Node to exit, which a browser would be okay with. In 32 | // this case, since the tossr process is probably only handling SSR requests, it 33 | // should be okay. To be extra safe, we don't start doing this until the first 34 | // time tossr is called. 35 | // 36 | // For more info see: 37 | // - https://github.com/jsdom/jsdom/issues/2346 38 | // - https://github.com/roxiness/routify-starter/issues/97 39 | const catchUnhandledRejections = onetime(function () { 40 | process.on('unhandledRejection', (reason, promise) => { 41 | console.log(`[tossr] Error on url: ${this.url}`) 42 | console.log(`[tossr] Unhandled promise rejection:`) 43 | console.error('[tossr]', reason) 44 | }); 45 | }) 46 | 47 | /** 48 | * Renders an HTML page from a HTML template, an app bundle and a path 49 | * @param {string} template Html template (or path to a HTML template). 50 | * @param {string} script Bundled JS app (or path to bundled bundle JS app). 51 | * @param {string} url Path to render. Ie. /blog/breathing-oxygen-linked-to-staying-alive 52 | * @param {Partial=} options Options 53 | * @returns {Promise} 54 | */ 55 | async function tossr(template, script, url, options) { 56 | const start = Date.now() 57 | const { 58 | host, 59 | eventName, 60 | beforeEval, 61 | afterEval, 62 | silent, 63 | inlineDynamicImports, 64 | timeout, 65 | dev, 66 | errorHandler, 67 | disableCatchUnhandledRejections 68 | } = options = configent(defaults, options, { module }) 69 | 70 | if (!disableCatchUnhandledRejections) 71 | catchUnhandledRejections.bind({ url })() 72 | 73 | // is this the content of the file or the path to the file? 74 | template = existsSync(template) ? readFileSync(template, 'utf8') : template 75 | script = inlineDynamicImports ? await inlineScript(script, dev) 76 | : isFile(script) ? readFileSync(script, 'utf8') : script 77 | 78 | 79 | return new Promise(async (resolve, reject) => { 80 | try { 81 | const dom = await new JSDOM(template, { runScripts: "outside-only", url: host + url }) 82 | shimDom(dom) 83 | 84 | if (eventName) { 85 | const eventTimeout = setTimeout(() => { 86 | if (dom.window._document) { 87 | console.log(`[tossr] ${url} Waited for the event "${eventName}", but timed out after ${timeout} ms.`); 88 | resolveHtml() 89 | } 90 | }, timeout) 91 | dom.window.addEventListener(eventName, resolveHtml) 92 | dom.window.addEventListener(eventName, () => clearTimeout(eventTimeout)) 93 | } 94 | await beforeEval(dom) 95 | stampWindow(dom) 96 | dom.window.eval(script) 97 | if (!eventName) 98 | resolveHtml() 99 | 100 | function resolveHtml() { 101 | afterEval(dom) 102 | const html = dom.serialize() 103 | resolve(html) 104 | dom.window.close() 105 | if (!silent) console.log(`[tossr] ${url} - ${Date.now() - start}ms ${(inlineDynamicImports && dev) ? '(rebuilt bundle)' : ''}`) 106 | } 107 | } catch (err) { errorHandler(err, url, { options }) } 108 | }) 109 | } 110 | 111 | async function inlineScript(script, dev = false) { 112 | const bundlePath = getBundlePath(script) 113 | 114 | if (!existsSync(bundlePath) || dev) { 115 | const { build } = require('esbuild') 116 | await build({ entryPoints: [script], outfile: bundlePath, bundle: true }) 117 | } 118 | return readFileSync(bundlePath, 'utf-8') 119 | } 120 | 121 | function shimDom(dom) { 122 | dom.window.rendering = true; 123 | dom.window.alert = (_msg) => { }; 124 | dom.window.scrollTo = () => { } 125 | dom.window.requestAnimationFrame = () => { } 126 | dom.window.cancelAnimationFrame = () => { } 127 | dom.window.TextEncoder = TextEncoder 128 | dom.window.TextDecoder = TextDecoder 129 | dom.window.fetch = fetch 130 | } 131 | 132 | function stampWindow(dom) { 133 | const scriptElem = dom.window.document.createElement('script') 134 | scriptElem.innerHTML = 'window.__ssrRendered = true' 135 | dom.window.__ssrRendered = true 136 | dom.window.document.head.appendChild(scriptElem) 137 | } 138 | 139 | function isFile(str) { 140 | const hasIllegalPathChar = str.match(/[<>:"|?*]/g); 141 | const hasLineBreaks = str.match(/\n/g) 142 | const isTooLong = str.length > 4096 143 | const isProbablyAFile = !hasIllegalPathChar && !hasLineBreaks && !isTooLong 144 | const exists = existsSync(str) 145 | if (isProbablyAFile && !exists) 146 | console.log(`[tossr] the script "${str}" looks like a filepath, but the file didn't exit`) 147 | return exists 148 | } 149 | 150 | /** 151 | * @typedef {object} Config 152 | * @prop {string} host hostname to use while rendering. Defaults to http://jsdom.ssr 153 | * @prop {string} eventName event to wait for before rendering app. Defaults to 'app-loaded' 154 | * @prop {Eval} beforeEval Executed before script is evaluated. 155 | * @prop {Eval} afterEval Executed after script is evaluated. 156 | * @prop {boolean} silent Don't print timestamps 157 | * @prop {boolean} inlineDynamicImports required for apps with dynamic imports 158 | * @prop {number} timeout required for apps with dynamic imports 159 | * @prop {boolean} dev disables caching of inlinedDynamicImports bundle 160 | * @prop {function} errorHandler 161 | * @prop {boolean} disableCatchUnhandledRejections 162 | */ 163 | 164 | /** 165 | * Called before/after the app script is evaluated 166 | * @callback Eval 167 | * @param {object} dom The DOM object 168 | **/ 169 | 170 | module.exports = { tossr, inlineScript } -------------------------------------------------------------------------------- /tossr_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 21 | 22 | 24 | 26 | 164 | 250 | 259 | 266 | 277 | 284 | 295 | 302 | 314 | 321 | 328 | 332 | 335 | 337 | 341 | 343 | 346 | 348 | 350 | 352 | 355 | 357 | 360 | 362 | 364 | 365 | 367 | 369 | 387 | 399 | 417 | 430 | 452 | 465 | 483 | 495 | 505 | 511 | 540 | 555 | 585 | 600 | 604 | 607 | 612 | 616 | 633 | 642 | 661 | 670 | 675 | 678 | 688 | 694 | 699 | 702 | 705 | 707 | 711 | 715 | 719 | 722 | 726 | 728 | 739 | 745 | 757 | 763 | 766 | 768 | 769 | 770 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "allowJs": true, 5 | "checkJs": true, 6 | "emitDeclarationOnly": true, 7 | "outFile": "index.d.ts" 8 | }, 9 | "include": ["*.js"], 10 | "exclude": ["postbuild.js"] 11 | } 12 | --------------------------------------------------------------------------------