diff --git a/packages/core/src/flecks.js b/packages/core/src/flecks.js index d4c62e7..ed7409d 100644 --- a/packages/core/src/flecks.js +++ b/packages/core/src/flecks.js @@ -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 diff --git a/packages/react/package.json b/packages/react/package.json index 10daf5d..a383531 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -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", diff --git a/packages/react/src/document.jsx b/packages/react/src/document.jsx new file mode 100644 index 0000000..ef1dabf --- /dev/null +++ b/packages/react/src/document.jsx @@ -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 ( + + + + {title} + {icon && } + {css.map((href) => )} + { + Object.entries(meta) + .map(([key, value]) => ( + React.createElement('meta', {[fixupKeys(key)]: value, key}) + )) + } + + +
+ {config} +
{root}
+
+ + + ); +} + +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; diff --git a/packages/react/src/ssr.js b/packages/react/src/ssr.js index 0673f84..bbda45f 100644 --- a/packages/react/src/ssr.js +++ b/packages/react/src/ssr.js @@ -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; - } - - // 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(`
`)) { - try { - const renderedRoot = ReactDOMServer.renderToString( - React.createElement(await root(this.flecks, this.req)), - ); - const rendered = string.replaceAll( - `
`, - `
${renderedRoot}`, - ); - this.push(rendered); +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; } - catch (error) { - debug('React SSR failed: %O', error); - this.push(string); + }, + onopentag(tagName, attribs) { + if ('script' === tagName) { + isInScript += 1; + if ('ignore' === attribs?.['data-flecks']) { + isSkipping = true; + } + else if (attribs.src) { + js.push(attribs.src); + } } - } - else { - this.push(string); - } - done(); - } - -} - -export default (stream, req, flecks) => stream.pipe(new Ssr(flecks, req)); + 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), + }, + ); + return new Promise((resolve) => { + const rendered = renderToPipeableStream( + DocumentElement, + { + bootstrapScripts: js, + bootstrapScriptContent: inline, + onError() { + resolve(stream); + }, + onShellError() { + resolve(stream); + }, + onShellReady() { + resolve(rendered); + }, + }, + ); + }); +}; diff --git a/packages/web/src/server/build/template.ejs b/packages/web/src/server/build/template.ejs index ba65a92..38aa13c 100644 --- a/packages/web/src/server/build/template.ejs +++ b/packages/web/src/server/build/template.ejs @@ -1,9 +1,6 @@ - - - <%= htmlWebpackPlugin.options.title %> <%= htmlWebpackPlugin.tags.headTags %> diff --git a/packages/web/src/server/build/web.webpack.config.js b/packages/web/src/server/build/web.webpack.config.js index d3fde49..be8b48f 100644 --- a/packages/web/src/server/build/web.webpack.config.js +++ b/packages/web/src/server/build/web.webpack.config.js @@ -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,29 +111,24 @@ 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( - '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); + assetTags.headTags.unshift( + createTag( + ...isProduction + ? [ + 'style', + {'data-href': `/${styleChunkFiles[j]}`}, + asset.source(), + ] + : [ + 'link', + { + href: `/${styleChunkFiles[j]}`, + rel: 'stylesheet', + type: 'text/css', + }, + ], + ), + ); } } } @@ -130,7 +143,7 @@ module.exports = async (env, argv, flecks) => { }, }; }, - title: id, + title: flecks.interpolate(title), ...htmlTemplateConfig, })); }); diff --git a/packages/web/src/server/config.js b/packages/web/src/server/config.js index 615880f..8be8373 100644 --- a/packages/web/src/server/config.js +++ b/packages/web/src/server/config.js @@ -50,6 +50,7 @@ class InlineConfig extends Transform { [ '', `
`, + ``, ``, `
`, '
', diff --git a/packages/web/src/server/http.js b/packages/web/src/server/http.js index b745c8b..70cf611 100644 --- a/packages/web/src/server/http.js +++ b/packages/web/src/server/http.js @@ -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) => { diff --git a/packages/web/src/server/index.js b/packages/web/src/server/index.js index 26051c0..ee73c5a 100644 --- a/packages/web/src/server/index.js +++ b/packages/web/src/server/index.js @@ -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. *