matrix-public-archive/server/hydrogen-render/render-hydrogen-to-string-u...

121 lines
3.8 KiB
JavaScript

'use strict';
// Server-side render Hydrogen to a string using a browser-like context thanks
// to `linkedom`. We use a VM so we can put all of the browser-like globals in
// place.
//
// Note: This is marked as unsafe because the render script is run in a VM which
// doesn't exit after we get the result (Hydrogen keeps running). There isn't a
// way to stop, terminate, or kill a vm script or vm context so in order to be
// safe, we need to run this inside of a child_process which we can kill after.
// This is why we have the `1-render-hydrogen-to-string.js` layer to handle
// this.
const assert = require('assert');
const vm = require('vm');
const path = require('path');
const { readFile } = require('fs').promises;
const crypto = require('crypto');
const { parseHTML } = require('linkedom');
// Setup the DOM context with any necessary shims/polyfills and ensure the VM
// context global has everything that a normal document does so Hydrogen can
// render.
function createDomAndSetupVmContext() {
const dom = parseHTML(`
<!doctype html>
<html>
<head></head>
<body>
<div id="app" class="hydrogen"></div>
</body>
</html>
`);
if (!dom.requestAnimationFrame) {
dom.requestAnimationFrame = function (cb) {
setTimeout(cb, 0);
};
}
const vmContext = vm.createContext(dom);
// Make the dom properties available in sub-`require(...)` calls
vmContext.global.window = dom.window;
vmContext.global.document = dom.document;
vmContext.global.Node = dom.Node;
vmContext.global.navigator = dom.navigator;
vmContext.global.DOMParser = dom.DOMParser;
// Make sure `webcrypto` exists since it was only introduced in Node.js v17
assert(crypto.webcrypto);
// Only assign vmContext.global.crypto if it's undefined
// (Node.js v19 has crypto set on the global already)
if (!vmContext.global.crypto) {
vmContext.global.crypto = crypto.webcrypto;
}
// So require(...) works in the vm
vmContext.global.require = require;
// So we can see logs from the underlying vm
vmContext.global.console = console;
return {
dom,
vmContext,
};
}
async function _renderHydrogenToStringUnsafe(renderOptions) {
assert(renderOptions);
assert(renderOptions.vmRenderScriptFilePath);
assert(renderOptions.vmRenderContext);
assert(renderOptions.pageOptions);
assert(renderOptions.pageOptions.locationHref);
assert(renderOptions.pageOptions.cspNonce);
const { dom, vmContext } = createDomAndSetupVmContext();
// A small `window.location` stub
if (!dom.window.location) {
const locationUrl = new URL(renderOptions.pageOptions.locationHref);
dom.window.location = {};
[
'hash',
'host',
'hostname',
'href',
'origin',
'password',
'pathname',
'port',
'protocol',
'search',
'username',
].forEach((key) => {
dom.window.location[key] = locationUrl[key];
});
}
// Define this for the SSR context
dom.window.matrixPublicArchiveContext = {
...renderOptions.vmRenderContext,
};
const vmRenderScriptFilePath = renderOptions.vmRenderScriptFilePath;
const hydrogenRenderScriptCode = await readFile(vmRenderScriptFilePath, 'utf8');
const hydrogenRenderScript = new vm.Script(hydrogenRenderScriptCode, {
filename: path.basename(vmRenderScriptFilePath),
});
// Note: The VM does not exit after the result is returned here and is why
// this should be run in a `child_process` that we can exit.
const vmResult = hydrogenRenderScript.runInContext(vmContext);
// Wait for everything to render
// (waiting on the promise returned from the VM render script)
await vmResult;
const documentString = dom.document.body.toString();
assert(documentString, 'Document body should not be empty after we rendered Hydrogen');
return documentString;
}
module.exports = _renderHydrogenToStringUnsafe;