refactor: HTTP stream + 18 SSR

This commit is contained in:
cha0s 2024-01-12 05:00:32 -06:00
parent 1a57157d3e
commit 6501933c2c
9 changed files with 215 additions and 73 deletions

View File

@ -444,6 +444,15 @@ export default class Flecks {
return get(this.config, path, defaultValue);
}
/**
* Interpolate a string with flecks configurtaion values.
* @param {string} string
* @returns The interpolated string.
*/
interpolate(string) {
return string.replace(/\[(.*?)\]/g, (match) => this.get(match));
}
/**
* Return an object whose keys are fleck paths and values are the result of invoking the hook.
* @param {string} hook

View File

@ -48,6 +48,7 @@
"babel-merge": "^3.0.0",
"classnames": "^2.3.1",
"history": "^5.3.0",
"htmlparser2": "^9.1.0",
"prop-types": "^15.7.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View File

@ -0,0 +1,56 @@
import PropTypes from 'prop-types';
import React from 'react';
const fixupKeys = (key) => (
Object.entries({
charset: 'charSet',
})
.reduce((key, [from, to]) => key.replace(from, to), key)
);
function Document({
appMountId,
base,
config,
css,
meta,
icon,
root,
title,
}) {
return (
<html lang="en">
<base href={base} />
<head>
<title>{title}</title>
{icon && <link rel="icon" href={icon} />}
{css.map((href) => <link href={href} key={href} rel="stylesheet" />)}
{
Object.entries(meta)
.map(([key, value]) => (
React.createElement('meta', {[fixupKeys(key)]: value, key})
))
}
</head>
<body>
<div id={`${appMountId}-container`}>
{config}
<div id={appMountId}>{root}</div>
</div>
</body>
</html>
);
}
Document.propTypes = {
appMountId: PropTypes.string.isRequired,
base: PropTypes.string.isRequired,
config: PropTypes.element.isRequired,
css: PropTypes.arrayOf(PropTypes.string).isRequired,
icon: PropTypes.string.isRequired,
meta: PropTypes.objectOf(PropTypes.string).isRequired,
root: PropTypes.element.isRequired,
title: PropTypes.string.isRequired,
};
export default Document;

View File

@ -1,47 +1,93 @@
import {Transform} from 'stream';
import {D} from '@flecks/core';
import {WritableStream} from 'htmlparser2/lib/WritableStream';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import {renderToPipeableStream} from 'react-dom/server';
import {configSource} from '@flecks/web/server';
import Document from './document';
import root from './root';
const debug = D('@flecks/react/root');
class Ssr extends Transform {
constructor(flecks, req) {
super();
this.flecks = flecks;
this.req = req;
export default async (stream, req, flecks) => {
const {
appMountId,
base,
icon,
meta,
title,
} = flecks.get('@flecks/web/server');
// Extract assets.
const css = [];
let inline = '';
let isInScript = 0;
let isSkipping = false;
const js = [];
const parserStream = new WritableStream({
onclosetag(tagName) {
if ('script' === tagName) {
isInScript -= 1;
isSkipping = false;
}
// eslint-disable-next-line no-underscore-dangle
async _transform(chunk, encoding, done) {
const string = chunk.toString('utf8');
const {appMountId} = this.flecks.get('@flecks/web/server');
if (-1 !== string.indexOf(`<div id="${appMountId}">`)) {
try {
const renderedRoot = ReactDOMServer.renderToString(
React.createElement(await root(this.flecks, this.req)),
},
onopentag(tagName, attribs) {
if ('script' === tagName) {
isInScript += 1;
if ('ignore' === attribs?.['data-flecks']) {
isSkipping = true;
}
else if (attribs.src) {
js.push(attribs.src);
}
}
if ('style' === tagName && attribs['data-href']) {
css.push(attribs['data-href']);
}
if ('link' === tagName && 'stylesheet' === attribs.rel && attribs.href) {
css.push(attribs.href);
}
},
ontext(text) {
if (isInScript > 0 && !isSkipping) {
inline += text;
}
},
});
await new Promise((resolve, reject) => {
const piped = stream.pipe(parserStream);
piped.on('error', reject);
piped.on('finish', resolve);
});
// Render document.
const DocumentElement = React.createElement(
Document,
{
appMountId: flecks.interpolate(appMountId),
base: flecks.interpolate(base),
config: React.createElement(
'script',
{dangerouslySetInnerHTML: {__html: await configSource(flecks, req)}},
),
css,
icon,
meta,
root: React.createElement(await root(flecks, req)),
title: flecks.interpolate(title),
},
);
const rendered = string.replaceAll(
`<div id="${appMountId}">`,
`<div id="${appMountId}">${renderedRoot}`,
return new Promise((resolve) => {
const rendered = renderToPipeableStream(
DocumentElement,
{
bootstrapScripts: js,
bootstrapScriptContent: inline,
onError() {
resolve(stream);
},
onShellError() {
resolve(stream);
},
onShellReady() {
resolve(rendered);
},
},
);
this.push(rendered);
}
catch (error) {
debug('React SSR failed: %O', error);
this.push(string);
}
}
else {
this.push(string);
}
done();
}
}
export default (stream, req, flecks) => stream.pipe(new Ssr(flecks, req));
});
};

View File

@ -1,9 +1,6 @@
<!DOCTYPE html>
<html lang="<%= htmlWebpackPlugin.options.lang %>">
<head>
<base href="/" />
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no" />
<title><%= htmlWebpackPlugin.options.title %></title>
<%= htmlWebpackPlugin.tags.headTags %>
</head>

View File

@ -19,13 +19,16 @@ const {
} = process.env;
module.exports = async (env, argv, flecks) => {
const {id} = flecks.get('@flecks/core');
const {
appMountId,
base,
devHost,
devPort,
devStats,
meta,
icon,
port,
title,
} = flecks.get('@flecks/web/server');
const isProduction = 'production' === argv.mode;
const plugins = [
@ -73,13 +76,28 @@ module.exports = async (env, argv, flecks) => {
// @todo source maps working?
entry[name] = [entryPoint];
plugins.push(new HtmlWebpackPlugin({
appMountId,
appMountId: flecks.interpolate(appMountId),
base: flecks.interpolate(base),
chunks: [name],
filename: `${name}.html`,
inject: false,
lang: 'en',
meta,
template: flecks.buildConfig('template.ejs', name),
templateParameters: (compilation, assets, assetTags, options) => {
function createTag(tagName, attributes, content) {
const tag = HtmlWebpackPlugin.createHtmlTagObject(
tagName,
attributes,
content,
{plugin: '@flecks/web/server'},
);
tag.toString = () => htmlTagObjectToString(tag, false);
return tag;
}
if (icon) {
assetTags.headTags.push('link', {rel: 'icon', href: icon});
}
if ('index' === name) {
const styleChunks = Array.from(compilation.chunks)
.filter((chunk) => chunk.idNameHints.has('flecksCompiled'));
@ -93,30 +111,25 @@ module.exports = async (env, argv, flecks) => {
if (asset) {
assetTags.headTags = assetTags.headTags
.filter(({attributes}) => attributes?.href !== styleChunkFiles[j]);
let tag;
if (isProduction) {
tag = HtmlWebpackPlugin.createHtmlTagObject(
assetTags.headTags.unshift(
createTag(
...isProduction
? [
'style',
{'data-href': `/${styleChunkFiles[j]}`},
asset.source(),
{plugin: '@flecks/web/server'},
);
}
else {
tag = HtmlWebpackPlugin.createHtmlTagObject(
]
: [
'link',
{
href: `/${styleChunkFiles[j]}`,
rel: 'stylesheet',
type: 'text/css',
},
undefined,
{plugin: '@flecks/web/server'},
],
),
);
}
tag.toString = () => htmlTagObjectToString(tag, false);
assetTags.headTags.unshift(tag);
}
}
}
}
@ -130,7 +143,7 @@ module.exports = async (env, argv, flecks) => {
},
};
},
title: id,
title: flecks.interpolate(title),
...htmlTemplateConfig,
}));
});

View File

@ -50,6 +50,7 @@ class InlineConfig extends Transform {
[
'<body>',
`<div id="${appMountId}-container">`,
`<script data-flecks="ignore">window.document.querySelector('#${appMountId}-container').style.display = 'none'</script>`,
`<script>${await configSource(this.flecks, this.req)}</script>`,
`<div id="${appMountId}"></div>`,
'</div>',

View File

@ -15,8 +15,8 @@ const {
const debug = D('@flecks/web/server/http');
const deliverHtmlStream = (stream, flecks, req, res) => {
flecks.invokeComposed('@flecks/web/server.stream.html', stream, req).pipe(res);
const deliverHtmlStream = async (stream, flecks, req, res) => {
(await flecks.invokeComposedAsync('@flecks/web/server.stream.html', stream, req)).pipe(res);
};
export const createHttpServer = async (flecks) => {

View File

@ -14,7 +14,7 @@ const {
const debug = D('@flecks/web/server');
export {augmentBuild};
export {augmentBuild, configSource};
export const hooks = {
'@flecks/core.build': augmentBuild,
@ -122,6 +122,10 @@ export const hooks = {
* The ID of the root element on the page.
*/
appMountId: 'root',
/**
* Base tag path.
*/
base: '/',
/**
* (webpack-dev-server) Disable the host check.
*
@ -161,10 +165,21 @@ export const hooks = {
* Host to bind.
*/
host: '0.0.0.0',
/**
* Path to icon.
*/
icon: '',
/**
* Port to bind.
*/
port: 32340,
/**
* Meta tags.
*/
meta: {
charset: 'utf-8',
viewport: 'width=device-width, user-scalable=no',
},
/**
* Public path to server.
*/
@ -176,6 +191,10 @@ export const hooks = {
colors: true,
errorDetails: true,
},
/**
* HTML title.
*/
title: '[@flecks/core.id]',
/**
* Proxies to trust.
*