├── .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 |

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 |
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 |
--------------------------------------------------------------------------------