diff --git a/README.md b/README.md
index 01257bbd..b679c4ac 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,8 @@ Render JSX and [Preact] components to an HTML string.
Works in Node & the browser, making it useful for universal/isomorphic rendering.
+Supports rendering to a Node.js stream using `renderToNodeStream`.
+
\>\> **[Cute Fox-Related Demo](http://codepen.io/developit/pen/dYZqjE?editors=001)** _(@ CodePen)_ <<
@@ -90,6 +92,22 @@ app.get('/:fox', (req, res) => {
```
+### Render JSX / Preact / Whatever to a Node.js stream
+
+```js
+import { renderToNodeStream } from 'preact-render-to-string/stream';
+import { h } from 'preact';
+/** @jsx h */
+
+let vdom =
content
;
+
+let stream = renderToNodeStream(vdom);
+stream.on('data', (chunk) => {
+ console.log(chunk.toString('utf8')); // '', 'content', '
'
+});
+```
+
+
---
diff --git a/package.json b/package.json
index 5307788a..a37c2401 100644
--- a/package.json
+++ b/package.json
@@ -8,11 +8,12 @@
"module": "dist/index.module.js",
"jsnext:main": "dist/index.module.js",
"scripts": {
- "build": "npm run -s transpile && npm run -s transpile:jsx && npm run -s copy-typescript-definition",
+ "build": "npm run -s transpile && npm run -s transpile:jsx && npm run -s transpile:stream && npm run -s copy-typescript-definition",
"transpile": "echo 'export const ENABLE_PRETTY = false;'>env.js && microbundle src/index.js -f es,umd --target web --external preact",
"transpile:jsx": "echo 'export const ENABLE_PRETTY = true;'>env.js && microbundle src/jsx.js -o dist/jsx.js --target web --external none && microbundle dist/jsx.js -o dist/jsx.js -f cjs",
+ "transpile:stream": "echo 'export const ENABLE_PRETTY = true;'>env.js && microbundle dist/stream.js --external preact,stream -o dist/stream.js -f cjs",
"copy-typescript-definition": "copyfiles -f src/index.d.ts dist",
- "test": "eslint src test && mocha --compilers js:babel-register test/**/*.js",
+ "test": "eslint src test && mocha --compilers js:@babel/register test/**/*.js",
"prepublish": "npm run build",
"release": "npm run build && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish"
},
@@ -40,16 +41,23 @@
},
"babel": {
"presets": [
- "env"
+ [
+ "@babel/env",
+ {
+ "exclude": [
+ "@babel/plugin-transform-regenerator"
+ ]
+ }
+ ]
],
"plugins": [
[
- "transform-react-jsx",
+ "@babel/transform-react-jsx",
{
"pragma": "h"
}
],
- "transform-object-rest-spread"
+ "@babel/proposal-object-rest-spread"
]
},
"author": "Jason Miller ",
@@ -62,10 +70,11 @@
"preact": ">=10 || ^10.0.0-alpha.0 || ^10.0.0-beta.0"
},
"devDependencies": {
- "babel-plugin-transform-object-rest-spread": "^6.26.0",
- "babel-plugin-transform-react-jsx": "^6.24.1",
- "babel-preset-env": "^1.7.0",
- "babel-register": "^6.26.0",
+ "@babel/core": "^7.4.4",
+ "@babel/plugin-proposal-object-rest-spread": "^7.4.4",
+ "@babel/plugin-transform-react-jsx": "^7.3.0",
+ "@babel/preset-env": "^7.4.4",
+ "@babel/register": "^7.4.4",
"chai": "^3.5.0",
"copyfiles": "^1.2.0",
"eslint": "^4.19.1",
diff --git a/src/stream.js b/src/stream.js
new file mode 100644
index 00000000..df068cb0
--- /dev/null
+++ b/src/stream.js
@@ -0,0 +1,264 @@
+import { encodeEntities, styleObjToCss, assign, getChildren } from './util';
+import { options, Fragment } from 'preact';
+import stream from 'stream';
+
+// components without names, kept as a hash for later comparison to return consistent UnnamedComponentXX names.
+const UNNAMED = [];
+
+const VOID_ELEMENTS = /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/;
+
+
+function PreactReadableStream(vnode, context, opts) {
+ if (opts && opts.pretty) {
+ throw new Error('pretty is not supported in renderToNodeStream!');
+ }
+
+ stream.Readable.call(this, opts && opts.readable);
+
+ this.vnode = vnode;
+ this.context = context || {};
+ this.opts = opts || {};
+}
+
+PreactReadableStream.prototype = new stream.Readable();
+PreactReadableStream.prototype.constructor = PreactReadableStream;
+PreactReadableStream.prototype._read = function _read() {
+ try {
+ if (!this._generator) {
+ this._generator = this._generate(this.vnode, this.context, this.opts);
+ }
+ else if (this.reading) {
+ console.warn(new Error('You should not call PreactReadableStream#_read when a read is in progress').stack);
+ }
+
+ this.reading = true;
+
+ for (const chunk of this._generator) {
+ if (!this.push(chunk)) {
+ this.reading = false;
+ // high water mark reached, pause the stream until _read is called again...
+ return;
+ }
+ }
+ }
+ catch (e) {
+ this.emit('error', e);
+ this.push(null);
+ return;
+ }
+
+ // end the stream
+ this.push(null);
+};
+
+PreactReadableStream.prototype._generate = function *_generate(vnode, context, opts, inner, isSvgMode, selectValue) {
+ if (vnode==null || typeof vnode==='boolean') {
+ yield '';
+ return;
+ }
+
+ let nodeName = vnode.type,
+ props = vnode.props,
+ isComponent = false;
+ context = context || {};
+ opts = opts || {};
+
+ // #text nodes
+ if (typeof vnode!=='object' && !nodeName) {
+ yield encodeEntities(vnode);
+ return;
+ }
+
+ // components
+ if (typeof nodeName==='function') {
+ isComponent = true;
+ if (opts.shallow && (inner || opts.renderRootComponent===false)) {
+ nodeName = getComponentName(nodeName);
+ }
+ else if (nodeName===Fragment) {
+ let children = [];
+ getChildren(children, vnode.props.children);
+
+ for (let i = 0; i < children.length; i++) {
+ for (const chunk of this._generate(children[i], context, opts, opts.shallowHighOrder!==false, isSvgMode, selectValue)) {
+ yield chunk;
+ }
+ }
+
+ return;
+ }
+ else {
+ let c = vnode.__c = { __v: vnode, context, props: vnode.props };
+ if (options.render) options.render(vnode);
+
+ let renderedVNode;
+
+ if (!nodeName.prototype || typeof nodeName.prototype.render!=='function') {
+ // Necessary for createContext api. Setting this property will pass
+ // the context value as `this.context` just for this component.
+ let cxType = nodeName.contextType;
+ let provider = cxType && context[cxType.__c];
+ let cctx = cxType != null ? (provider ? provider.props.value : cxType._defaultValue) : context;
+
+ // stateless functional components
+ renderedVNode = nodeName.call(vnode.__c, props, cctx);
+ }
+ else {
+ // class-based components
+ // c = new nodeName(props, context);
+ c = vnode.__c = new nodeName(props, context);
+ c.__v = vnode;
+ // turn off stateful re-rendering:
+ c._dirty = c.__d = true;
+ c.props = props;
+ c.context = context;
+ if (nodeName.getDerivedStateFromProps) c.state = assign(assign({}, c.state), nodeName.getDerivedStateFromProps(c.props, c.state));
+ else if (c.componentWillMount) c.componentWillMount();
+
+ renderedVNode = c.render(c.props, c.state || {}, c.context);
+ }
+
+ if (c.getChildContext) {
+ context = assign(assign({}, context), c.getChildContext());
+ }
+
+ for (const chunk of this._generate(renderedVNode, context, opts, opts.shallowHighOrder!==false, isSvgMode, selectValue)) {
+ yield chunk;
+ }
+
+ return;
+ }
+ }
+
+ // render JSX to HTML
+ let s = '', html;
+
+ if (props) {
+ let attrs = Object.keys(props);
+
+ // allow sorting lexicographically for more determinism (useful for tests, such as via preact-jsx-chai)
+ if (opts && opts.sortAttributes===true) attrs.sort();
+
+ for (let i=0; i]/)) continue;
+
+ if (!(opts && opts.allAttributes) && (name==='key' || name==='ref')) continue;
+
+ if (name==='className') {
+ if (props.class) continue;
+ name = 'class';
+ }
+ else if (isSvgMode && name.match(/^xlink:?./)) {
+ name = name.toLowerCase().replace(/^xlink:?/, 'xlink:');
+ }
+
+ if (name==='style' && v && typeof v==='object') {
+ v = styleObjToCss(v);
+ }
+
+ let hooked = opts.attributeHook && opts.attributeHook(name, v, context, opts, isComponent);
+ if (hooked || hooked==='') {
+ s += hooked;
+ continue;
+ }
+
+ if (name==='dangerouslySetInnerHTML') {
+ html = v && v.__html;
+ }
+ else if ((v || v===0 || v==='') && typeof v!=='function') {
+ if (v===true || v==='') {
+ v = name;
+ // in non-xml mode, allow boolean attributes
+ if (!opts || !opts.xml) {
+ s += ' ' + name;
+ continue;
+ }
+ }
+
+ if (name==='value') {
+ if (nodeName==='select') {
+ selectValue = v;
+ continue;
+ }
+ else if (nodeName==='option' && selectValue==v) {
+ s += ` selected`;
+ }
+ }
+ s += ` ${name}="${encodeEntities(v)}"`;
+ }
+ }
+ }
+
+ let isVoid = String(nodeName).match(VOID_ELEMENTS);
+
+ s = `<${nodeName}${s}`;
+ if (String(nodeName).match(/[\s\n\\/='"\0<>]/)) throw s;
+
+ yield s;
+
+ if (html) {
+ yield `>${html}${nodeName}>`;
+ return;
+ }
+
+ let didCloseOpeningTag = false;
+
+ let children = [];
+ if (props && getChildren(children, props.children).length) {
+ for (let i=0; i';
+ }
+
+ yield chunk;
+ }
+ }
+ }
+ }
+ }
+
+ yield didCloseOpeningTag
+ ? `${nodeName}>`
+ : `${isVoid || opts.xml ? '/>' : `>${nodeName}>`}`;
+};
+
+function getComponentName(component) {
+ return component.displayName || component!==Function && component.name || getFallbackComponentName(component);
+}
+
+function getFallbackComponentName(component) {
+ let str = Function.prototype.toString.call(component),
+ name = (str.match(/^\s*function\s+([^( ]+)/) || '')[1];
+ if (!name) {
+ // search for an existing indexed name for the given component:
+ let index = -1;
+ for (let i=UNNAMED.length; i--; ) {
+ if (UNNAMED[i]===component) {
+ index = i;
+ break;
+ }
+ }
+ // not found, create a new indexed name:
+ if (index<0) {
+ index = UNNAMED.push(component) - 1;
+ }
+ name = `UnnamedComponent${index}`;
+ }
+ return name;
+}
+
+
+export default function renderToNodeStream(vnode, context, opts) {
+ return new PreactReadableStream(vnode, context, opts);
+}
diff --git a/test/stream.js b/test/stream.js
new file mode 100644
index 00000000..140048f6
--- /dev/null
+++ b/test/stream.js
@@ -0,0 +1,170 @@
+import renderToNodeStream from '../src/stream';
+import { h, Component } from 'preact';
+import chai, { expect } from 'chai';
+import sinonChai from 'sinon-chai';
+chai.use(sinonChai);
+
+describe('stream.render', () => {
+ describe('Basic JSX', () => {
+ it('should render JSX', () => {
+ let stream = renderToNodeStream(bar
),
+ expectedParts = ['','bar','
'],
+ expected = expectedParts.join('');
+
+ return new Promise((resolve, reject) => {
+ let chunks = [];
+ stream.on('data', (chunk) => {
+ chunks.push(chunk);
+ });
+ stream.on('end', () => {
+ const parts = chunks.map(c => c.toString('utf8'));
+ const final = Buffer.concat(chunks).toString('utf8');
+
+ expect(final).to.equal(expected);
+ expect(parts).to.deep.equal(expectedParts);
+
+ resolve();
+ });
+ stream.on('error', reject);
+ });
+ });
+
+ it('should render components', () => {
+ function FuncComp(props) {
+ return (
+
+ Hello Functional
+ More content
+ {props.children}
+
+ );
+ }
+
+ class ClassComp extends Component {
+ render(props) {
+ return (
+
+ Hello Class
+ Even more
+ {props.children}
+
+ );
+ }
+ }
+
+ let stream = renderToNodeStream(
+
+
+ With children
+
+
+ Also with children
+
+
+ ),
+ expectedParts = [
+ '',
+ '',
+ '',
+ 'Hello Functional',
+ '
',
+ '',
+ 'More content',
+ '
',
+ '',
+ 'With children',
+ '
',
+ '',
+ '',
+ '',
+ 'Hello Class',
+ '
',
+ '',
+ 'Even more',
+ '
',
+ '',
+ 'Also with children',
+ '
',
+ '',
+ ''
+ ],
+ expected = expectedParts.join('');
+
+ return new Promise((resolve, reject) => {
+ let chunks = [];
+ stream.on('data', (chunk) => {
+ chunks.push(chunk);
+ });
+ stream.on('end', () => {
+ const parts = chunks.map(c => c.toString('utf8'));
+ const final = Buffer.concat(chunks).toString('utf8');
+
+ expect(parts).to.deep.equal(expectedParts);
+ expect(final).to.equal(expected);
+
+ resolve();
+ });
+ stream.on('error', reject);
+ });
+ });
+
+ it('should handle backpressure', () => {
+ const highWaterMark = 64;
+ let stream = renderToNodeStream(
+
+ {[...Array(20)].map(() => )}
+ ,
+ undefined,
+ {
+ readable: {
+ highWaterMark
+ }
+ }
+ );
+
+ return new Promise((res, rej) => {
+ stream.read();
+ setTimeout(() => {
+ expect(stream._readableState.length).to.be.lessThan(highWaterMark + 1);
+ res();
+ }, 100);
+ stream.on('error', (e) => {
+ console.error('Stream emitted error:', e);
+ rej(e);
+ });
+ stream.on('end', () => {
+ rej('Didn\'t expect stream to end as highWaterMark should be reached...');
+ });
+ });
+ });
+
+ it('should support pausing', () => {
+ let stream = renderToNodeStream(
+
+ {[...Array(20)].map(() => )}
+
+ );
+
+ return new Promise((res, rej) => {
+ let chunks = [];
+ stream.on('error', (e) => {
+ console.error('Stream emitted error:', e);
+ rej(e);
+ });
+ stream.on('data', (chunk) => {
+ chunks.push(chunk);
+ if (chunks.length === 10) {
+ stream.pause();
+ setTimeout(() => {
+ expect(chunks.length).to.equal(10);
+ res();
+ }, 100);
+ }
+ });
+ stream.on('end', () => {
+ rej('Didn\'t expect stream to end, as it should be paused!');
+ });
+ });
+ });
+ });
+});