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);
|
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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
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 {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));
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>',
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
Loading…
Reference in New Issue
Block a user