-
Notifications
You must be signed in to change notification settings - Fork 5
/
tossr.js
170 lines (153 loc) · 6.09 KB
/
tossr.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
const { JSDOM } = require('jsdom')
const { dirname, resolve } = require('path')
const { existsSync, readFileSync } = require('fs')
const process = require('process')
const onetime = require('onetime')
const fetch = require('node-fetch')
const { configent } = require('configent')
const getBundlePath = script => resolve(dirname(script), '__roxi-ssr-bundle.js')
/** @type {Config} */
const defaults = {
host: 'http://jsdom.ssr',
eventName: 'app-loaded',
beforeEval(dom) { },
afterEval(dom) { },
silent: false,
inlineDynamicImports: false,
timeout: 5000,
dev: false,
errorHandler: (err, url, ctx) => {
console.log('[tossr] url:', url)
throw Error(err)
},
disableCatchUnhandledRejections: false
}
// Intercept unhandled rejections in the Node process:
// https://nodejs.org/api/process.html#process_event_uncaughtexception.
//
// This is generally a bad idea in Node, but there is no other way to avoid
// errors in jsdom causing Node to exit, which a browser would be okay with. In
// this case, since the tossr process is probably only handling SSR requests, it
// should be okay. To be extra safe, we don't start doing this until the first
// time tossr is called.
//
// For more info see:
// - https://github.com/jsdom/jsdom/issues/2346
// - https://github.com/roxiness/routify-starter/issues/97
const catchUnhandledRejections = onetime(function () {
process.on('unhandledRejection', (reason, promise) => {
console.log(`[tossr] Error on url: ${this.url}`)
console.log(`[tossr] Unhandled promise rejection:`)
console.error('[tossr]', reason)
});
})
/**
* Renders an HTML page from a HTML template, an app bundle and a path
* @param {string} template Html template (or path to a HTML template).
* @param {string} script Bundled JS app (or path to bundled bundle JS app).
* @param {string} url Path to render. Ie. /blog/breathing-oxygen-linked-to-staying-alive
* @param {Partial<Config>=} options Options
* @returns {Promise<string>}
*/
async function tossr(template, script, url, options) {
const start = Date.now()
const {
host,
eventName,
beforeEval,
afterEval,
silent,
inlineDynamicImports,
timeout,
dev,
errorHandler,
disableCatchUnhandledRejections
} = options = configent(defaults, options, { module })
if (!disableCatchUnhandledRejections)
catchUnhandledRejections.bind({ url })()
// is this the content of the file or the path to the file?
template = existsSync(template) ? readFileSync(template, 'utf8') : template
script = inlineDynamicImports ? await inlineScript(script, dev)
: isFile(script) ? readFileSync(script, 'utf8') : script
return new Promise(async (resolve, reject) => {
try {
const dom = await new JSDOM(template, { runScripts: "outside-only", url: host + url })
shimDom(dom)
if (eventName) {
const eventTimeout = setTimeout(() => {
if (dom.window._document) {
console.log(`[tossr] ${url} Waited for the event "${eventName}", but timed out after ${timeout} ms.`);
resolveHtml()
}
}, timeout)
dom.window.addEventListener(eventName, resolveHtml)
dom.window.addEventListener(eventName, () => clearTimeout(eventTimeout))
}
await beforeEval(dom)
stampWindow(dom)
dom.window.eval(script)
if (!eventName)
resolveHtml()
function resolveHtml() {
afterEval(dom)
const html = dom.serialize()
resolve(html)
dom.window.close()
if (!silent) console.log(`[tossr] ${url} - ${Date.now() - start}ms ${(inlineDynamicImports && dev) ? '(rebuilt bundle)' : ''}`)
}
} catch (err) { errorHandler(err, url, { options }) }
})
}
async function inlineScript(script, dev = false) {
const bundlePath = getBundlePath(script)
if (!existsSync(bundlePath) || dev) {
const { build } = require('esbuild')
await build({ entryPoints: [script], outfile: bundlePath, bundle: true })
}
return readFileSync(bundlePath, 'utf-8')
}
function shimDom(dom) {
dom.window.rendering = true;
dom.window.alert = (_msg) => { };
dom.window.scrollTo = () => { }
dom.window.requestAnimationFrame = () => { }
dom.window.cancelAnimationFrame = () => { }
dom.window.TextEncoder = TextEncoder
dom.window.TextDecoder = TextDecoder
dom.window.fetch = fetch
}
function stampWindow(dom) {
const scriptElem = dom.window.document.createElement('script')
scriptElem.innerHTML = 'window.__ssrRendered = true'
dom.window.__ssrRendered = true
dom.window.document.head.appendChild(scriptElem)
}
function isFile(str) {
const hasIllegalPathChar = str.match(/[<>:"|?*]/g);
const hasLineBreaks = str.match(/\n/g)
const isTooLong = str.length > 4096
const isProbablyAFile = !hasIllegalPathChar && !hasLineBreaks && !isTooLong
const exists = existsSync(str)
if (isProbablyAFile && !exists)
console.log(`[tossr] the script "${str}" looks like a filepath, but the file didn't exit`)
return exists
}
/**
* @typedef {object} Config
* @prop {string} host hostname to use while rendering. Defaults to http://jsdom.ssr
* @prop {string} eventName event to wait for before rendering app. Defaults to 'app-loaded'
* @prop {Eval} beforeEval Executed before script is evaluated.
* @prop {Eval} afterEval Executed after script is evaluated.
* @prop {boolean} silent Don't print timestamps
* @prop {boolean} inlineDynamicImports required for apps with dynamic imports
* @prop {number} timeout required for apps with dynamic imports
* @prop {boolean} dev disables caching of inlinedDynamicImports bundle
* @prop {function} errorHandler
* @prop {boolean} disableCatchUnhandledRejections
*/
/**
* Called before/after the app script is evaluated
* @callback Eval
* @param {object} dom The DOM object
**/
module.exports = { tossr, inlineScript }