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); 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. * Return an object whose keys are fleck paths and values are the result of invoking the hook.
* @param {string} hook * @param {string} hook

View File

@ -48,6 +48,7 @@
"babel-merge": "^3.0.0", "babel-merge": "^3.0.0",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"history": "^5.3.0", "history": "^5.3.0",
"htmlparser2": "^9.1.0",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^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 {WritableStream} from 'htmlparser2/lib/WritableStream';
import {D} from '@flecks/core';
import React from 'react'; 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'; import root from './root';
const debug = D('@flecks/react/root'); export default async (stream, req, flecks) => {
const {
class Ssr extends Transform { appMountId,
base,
constructor(flecks, req) { icon,
super(); meta,
this.flecks = flecks; title,
this.req = req; } = 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 onopentag(tagName, attribs) {
async _transform(chunk, encoding, done) { if ('script' === tagName) {
const string = chunk.toString('utf8'); isInScript += 1;
const {appMountId} = this.flecks.get('@flecks/web/server'); if ('ignore' === attribs?.['data-flecks']) {
if (-1 !== string.indexOf(`<div id="${appMountId}">`)) { isSkipping = true;
try { }
const renderedRoot = ReactDOMServer.renderToString( else if (attribs.src) {
React.createElement(await root(this.flecks, this.req)), 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( return new Promise((resolve) => {
`<div id="${appMountId}">`, const rendered = renderToPipeableStream(
`<div id="${appMountId}">${renderedRoot}`, 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> <!DOCTYPE html>
<html lang="<%= htmlWebpackPlugin.options.lang %>"> <html lang="<%= htmlWebpackPlugin.options.lang %>">
<head> <head>
<base href="/" />
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no" />
<title><%= htmlWebpackPlugin.options.title %></title> <title><%= htmlWebpackPlugin.options.title %></title>
<%= htmlWebpackPlugin.tags.headTags %> <%= htmlWebpackPlugin.tags.headTags %>
</head> </head>

View File

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

View File

@ -50,6 +50,7 @@ class InlineConfig extends Transform {
[ [
'<body>', '<body>',
`<div id="${appMountId}-container">`, `<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>`, `<script>${await configSource(this.flecks, this.req)}</script>`,
`<div id="${appMountId}"></div>`, `<div id="${appMountId}"></div>`,
'</div>', '</div>',

View File

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

View File

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