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}`; + return; + } + + let didCloseOpeningTag = false; + + let children = []; + if (props && getChildren(children, props.children).length) { + for (let i=0; i'; + } + + yield chunk; + } + } + } + } + } + + yield didCloseOpeningTag + ? `` + : `${isVoid || opts.xml ? '/>' : `>`}`; +}; + +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!'); + }); + }); + }); + }); +});