├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── index.js ├── package.json └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | tmp/ 4 | npm-debug.log* 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | node_js: 2 | - "4" 3 | - "6" 4 | sudo: false 5 | language: node_js 6 | script: "npm run test:cov" 7 | after_script: "npm i -g codecov.io && cat ./coverage/lcov.info | codecov" 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Yoshua Wuyts 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # choo-pull [![stability][0]][1] 2 | [![npm version][2]][3] [![build status][4]][5] [![test coverage][6]][7] 3 | [![downloads][8]][9] [![js-standard-style][10]][11] 4 | 5 | Wrap handlers to use [pull-stream](pull-stream.github.io) in a `choo` plugin. 6 | This is intended to go beyond basic `choo` usage, and tread into the domain of 7 | managing asynchronous complexity using streams / FRP. 8 | 9 | While streams code takes longer to write up front, resulting code is generally 10 | stateless, pretty damn fast and surprisingly reusable. `pull-streams` are a 11 | minimal version of streams that weigh 200 bytes and handle backpressure 12 | phenomenally. 13 | 14 | ## Usage 15 | ```js 16 | const pull = require('choo-pull') 17 | const choo = require('choo') 18 | 19 | const app = choo() 20 | app.use(pull()) 21 | 22 | const tree = app.start() 23 | document.body.appendChild(tree) 24 | ``` 25 | 26 | Now each handler in a `model` expects a valid `through` pull-stream to be 27 | returned synchronously. Initial data will be passed as the source, errors 28 | handling and `done()` calls are appended in the sink: 29 | ```js 30 | const through = require('pull-through') 31 | const ws = require('pull-ws') 32 | const xhr = require('xhr') 33 | 34 | module.exports = { 35 | namespace: 'my-model', 36 | state: { 37 | count: 0 38 | }, 39 | reducers: { 40 | increment: (data, state) => ({ count: state.count + data }), 41 | decrement: (data, state) => ({ count: state.count - data }), 42 | }, 43 | subscriptions: { 44 | getDataFromSocket: (Send$) => { 45 | const ws$ = Ws$('wss://echo.websocket.org') 46 | return pull(ws$, Deserialize$(), Send$('performXhr')) 47 | } 48 | }, 49 | effects: { 50 | performXhr: (state, Send$) => pull(Xhr$(), Deserialize$()) 51 | } 52 | } 53 | 54 | function Xhr$ () { 55 | return through((data, cb) => { 56 | xhr('/foo/bar', { data: data }, (err, res) => { 57 | if (err) return cb(err) 58 | cb(null, res) 59 | }) 60 | }) 61 | } 62 | 63 | function Deserialize$ () { 64 | return through((data, cb) { 65 | try { 66 | cb(null, JSON.parse(data)) 67 | } catch (e) { 68 | cb(e) 69 | } 70 | }) 71 | } 72 | 73 | function Ws$ (url) { 74 | return ws(new window.WebSocket(url)) 75 | } 76 | ``` 77 | 78 | ## Using send() 79 | Like all other API methods, so too does the `send()` method become a 80 | `pull-stream`. More specifically it becomes a `through` stream that takes the 81 | `action` name as the sole arugment, and pushes any results into any a 82 | connecting `through` or `sink` stream: 83 | 84 | ```js 85 | const through = require('pull-through') 86 | 87 | module.exports = { 88 | state: { 89 | count: 0 90 | }, 91 | reducers: { 92 | bar: (state) => ({ state.count + data }) 93 | }, 94 | effects: { 95 | callBar: (state, prev, Send$) => Send$('bar'), 96 | callFoo: (state, prev, Send$) => Send$('foo') 97 | } 98 | } 99 | 100 | // send('callFoo', 1) 101 | // => state.count = 1 102 | ``` 103 | 104 | ## API 105 | ### hooks = pull(opts) 106 | Create an object of hooks that can be passed to `app.use()`. Internally ties 107 | into the following hooks: 108 | - __wrapSubscriptions:__ changes the API of `subscriptions` to be `(Send$)` 109 | - __wrapEffects:__ changes the API of `effects` to be `(state, Send$)` 110 | 111 | The following options can be passed: 112 | - __opts.subscriptions:__ default: `true`. Determine if `subscriptions` should 113 | be wrapped 114 | - __opts.effects:__ default: `true`. Determine if `effects` should be wrapped 115 | 116 | Incrementally enabling options can be useful when incrementally upgrading from 117 | a CSP-style codebase to a reactive / streaming one. 118 | 119 | ### pull.subscription(subscription) 120 | Wrap a single `subscription`. Useful to incrementally upgrade a CSP-style 121 | codebase to a reactive / streaming one. 122 | 123 | ### pull.effect(effect) 124 | Wrap a single `effect`. Useful to incrementally upgrade a CSP-style 125 | codebase to a reactive / streaming one. 126 | 127 | ## FAQ 128 | ### Why aren't reducers wrapped in pull-streams? 129 | In `choo@3` the internal workings demand that data always be returned 130 | synchronously. Because `pull-stream` returns data in a callback, `reducers` 131 | cannot be wrapped. Perhaps at some point we'll allow for a hybrid API, but at 132 | this point it's frankly not possible. 133 | 134 | ## Installation 135 | ```sh 136 | $ npm install choo-pull 137 | ``` 138 | 139 | ## License 140 | [MIT](https://tldrlegal.com/license/mit-license) 141 | 142 | [0]: https://img.shields.io/badge/stability-experimental-orange.svg?style=flat-square 143 | [1]: https://nodejs.org/api/documentation.html#documentation_stability_index 144 | [2]: https://img.shields.io/npm/v/choo-pull.svg?style=flat-square 145 | [3]: https://npmjs.org/package/choo-pull 146 | [4]: https://img.shields.io/travis/yoshuawuyts/choo-pull/master.svg?style=flat-square 147 | [5]: https://travis-ci.org/yoshuawuyts/choo-pull 148 | [6]: https://img.shields.io/codecov/c/github/yoshuawuyts/choo-pull/master.svg?style=flat-square 149 | [7]: https://codecov.io/github/yoshuawuyts/choo-pull 150 | [8]: http://img.shields.io/npm/dm/choo-pull.svg?style=flat-square 151 | [9]: https://npmjs.org/package/choo-pull 152 | [10]: https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square 153 | [11]: https://github.com/feross/standard 154 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const pull = require('pull-stream') 2 | const assert = require('assert') 3 | 4 | module.exports = chooPull 5 | chooPull.subscription = wrapSubscriptions 6 | chooPull.effect = wrapEffects 7 | 8 | // Wrap handlers to accept pull streams 9 | // obj? -> obj 10 | function chooPull (opts) { 11 | opts = opts || {} 12 | assert.equal(typeof opts, 'object', 'choo-pull: opts should be an object or undefined') 13 | 14 | const hooks = {} 15 | if (opts.effects !== false) { 16 | hooks.wrapEffects = wrapEffects 17 | } 18 | 19 | if (opts.subscriptions !== false) { 20 | hooks.wrapSubscriptions = wrapSubscriptions 21 | } 22 | 23 | return hooks 24 | } 25 | 26 | // Wrap subscriptions to accept pull streams 27 | // fn -> fn 28 | function wrapSubscriptions (cb) { 29 | assert.equal(typeof cb, 'function', 'choo-pull.subscriptions: cb should be a function') 30 | return function (send, done) { 31 | const send$ = createSend$(send) 32 | const source$ = cb(send$) 33 | const sink$ = createSink$(done) 34 | pull(source$, sink$) 35 | } 36 | } 37 | 38 | // Wrap effects to accept pull streams 39 | // fn -> fn 40 | function wrapEffects (cb) { 41 | assert.equal(typeof cb, 'function', 'choo-pull.effects: cb should be a function') 42 | return function (data, state, prev, send, done) { 43 | const send$ = createSend$(send) 44 | const through$ = cb(state, prev, send$) 45 | const source$ = createSource$(data) 46 | const sink$ = createSink$(done) 47 | pull(source$, through$, sink$) 48 | } 49 | } 50 | 51 | // Create a source stream that wraps data 52 | // and pushes it to the next stream 53 | // obj -> source$ 54 | function createSource$ (data) { 55 | var called = false 56 | 57 | return function (end, cb) { 58 | if (end) return cb(end) 59 | if (called === true) return cb(true) 60 | 61 | called = true 62 | cb(null, data) 63 | } 64 | } 65 | 66 | // Create a sink stream that calls done() 67 | // on error or when done 68 | // fn -> sink$ 69 | function createSink$ (done) { 70 | return function (read) { 71 | read(null, function next (end, data) { 72 | // Once cancellation works, 'true' should call the "cancel" method 73 | if (end === true) return 74 | if (end) return done(end) 75 | 76 | done(null, data) 77 | read(null, next) 78 | }) 79 | } 80 | } 81 | 82 | // Transform the send() function into a valid source stream 83 | // fn -> through$ 84 | function createSend$ (send) { 85 | return function send$ (actionName) { 86 | return function send$Sink (read) { 87 | return function send$Source (end, cb) { 88 | read(end, function (end, data) { 89 | if (end === true) return cb(true) 90 | if (end) return cb(end) 91 | 92 | send(actionName, data, cb) 93 | }) 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "choo-pull", 3 | "version": "1.0.0", 4 | "description": "Wrap handlers to use pull-stream", 5 | "main": "index.js", 6 | "scripts": { 7 | "deps": "dependency-check . && dependency-check . --extra --no-dev", 8 | "test": "standard && npm run deps && NODE_ENV=test node test", 9 | "test:cov": "standard && npm run deps && NODE_ENV=test istanbul cover test.js" 10 | }, 11 | "repository": "yoshuawuyts/choo-pull", 12 | "keywords": [ 13 | "pull", 14 | "pull-stream", 15 | "choo", 16 | "bel", 17 | "yo-yo", 18 | "plugin", 19 | "tiny" 20 | ], 21 | "license": "MIT", 22 | "dependencies": { 23 | "pull-stream": "^3.4.3" 24 | }, 25 | "devDependencies": { 26 | "dependency-check": "^2.5.3", 27 | "istanbul": "^0.4.4", 28 | "noop2": "^2.0.0", 29 | "standard": "^8.4.0", 30 | "tape": "^4.6.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | const pull = require('pull-stream') 2 | const noop = require('noop2') 3 | const test = require('tape') 4 | const chooPull = require('./') 5 | 6 | test('hooks = pull()', (t) => { 7 | t.test('should assert input types', function (t) { 8 | t.plan(1) 9 | t.throws(chooPull.bind(null, 123), /object/) 10 | }) 11 | 12 | t.test('should return an object of hooks', (t) => { 13 | t.plan(2) 14 | const hooks = chooPull() 15 | t.equal(typeof hooks.wrapSubscriptions, 'function', 'subs exist') 16 | t.equal(typeof hooks.wrapEffects, 'function', 'effects exist') 17 | }) 18 | 19 | t.test('should accept options to disable hooks', (t) => { 20 | t.plan(2) 21 | 22 | const hooks1 = chooPull({ subscriptions: false }) 23 | t.notOk(hooks1.wrapSubscriptions, 'no subs') 24 | 25 | const hooks3 = chooPull({ effects: false }) 26 | t.notOk(hooks3.wrapEffects, 'no effects') 27 | }) 28 | }) 29 | 30 | test('hook:wrapSubscription', (t) => { 31 | t.test('should transform into a pull-stream', (t) => { 32 | t.plan(2) 33 | 34 | var called = false 35 | const fn = chooPull.subscription((send$) => (end, cb) => { 36 | if (end) return cb(end) 37 | if (called) return cb(true) 38 | 39 | called = true 40 | cb(null, 'hey!') 41 | }) 42 | 43 | fn(noop, done) 44 | 45 | function done (err, res) { 46 | t.ifError(err, 'no err') 47 | t.equal(res, 'hey!') 48 | } 49 | }) 50 | 51 | t.test('should handle errors', (t) => { 52 | t.plan(3) 53 | 54 | var called = false 55 | const fn = chooPull.subscription((send$) => (end, cb) => { 56 | if (end) return cb(end) 57 | if (called) return cb(true) 58 | 59 | called = true 60 | cb(new Error('oh no!')) 61 | }) 62 | 63 | fn(noop, done) 64 | 65 | function done (err, res) { 66 | t.ok(err, 'err exists') 67 | t.notOk(res, 'no res') 68 | t.equal(err.message, 'oh no!') 69 | } 70 | }) 71 | 72 | t.test('should have a send$ method', (t) => { 73 | t.plan(2) 74 | 75 | var sourceCalled = false 76 | var sendCalled = false 77 | var called = false 78 | 79 | const fn = chooPull.subscription((Send$) => (end, cb) => { 80 | if (end) return cb(end) 81 | if (called) return cb(true) 82 | 83 | called = true 84 | // create source$ 85 | const source$ = function source (end, cb) { 86 | if (end) return cb(end) 87 | if (sourceCalled) return cb(true) 88 | sourceCalled = true 89 | cb(null, 'oi') 90 | } 91 | const send$ = Send$('foobar') 92 | const sink$ = function sink (read) { 93 | read(null, function next (end, data) { 94 | if (end) return 95 | if (sendCalled) return 96 | 97 | sendCalled = true 98 | t.pass('send called a sink') 99 | t.equal(data, 'oi', 'data was equal') 100 | read(null, next) 101 | }) 102 | } 103 | 104 | pull(source$, send$, sink$) 105 | }) 106 | 107 | fn(send, done) 108 | 109 | function send (name, data, done) { 110 | done(null, data) 111 | } 112 | 113 | function done (err, res) { 114 | t.ok(err, 'err exists') 115 | t.notOk(res, 'no res') 116 | t.equal(err.message, 'oh no!') 117 | } 118 | }) 119 | }) 120 | 121 | test('hook:wrapEffect', (t) => { 122 | t.test('should transform into a pull-stream', (t) => { 123 | t.plan(3) 124 | 125 | var called = false 126 | const fn = chooPull.effect((state, prev, send$) => { 127 | return (read) => (end, cb) => read(end, (end, data) => { 128 | if (end) return cb(end) 129 | if (called) return cb(true) 130 | 131 | t.deepEqual(state, { foo: 'bar' }, 'state is what it should be') 132 | called = true 133 | cb(null, 'oi') 134 | }) 135 | }) 136 | 137 | const data = 'hey' 138 | const state = { foo: 'bar' } 139 | const prev = {} 140 | const send = noop 141 | const done = (err, res) => { 142 | t.ifError(err, 'no err') 143 | t.equal(res, 'oi', 'res is equal') 144 | } 145 | fn(data, state, prev, send, done) 146 | }) 147 | t.test('should handle errors') 148 | t.test('should have a send$ method') 149 | }) 150 | --------------------------------------------------------------------------------