refactor: HTTP stream + 18 SSR
This commit is contained in:
parent
1a57157d3e
commit
6501933c2c
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
56
packages/react/src/document.jsx
Normal file
56
packages/react/src/document.jsx
Normal 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;
|
|
@ -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));
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
}));
|
||||
});
|
||||
|
|
|
@ -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>',
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
Loading…
Reference in New Issue
Block a user