├── .babelrc
├── .gitignore
├── .npmignore
├── .travis.yml
├── LICENSE.md
├── README.md
├── examples
└── log.png
├── package.json
├── src
└── index.js
└── test
└── index.spec.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "stage": 0,
3 | "loose": "all"
4 | }
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | *.log
3 | .DS_Store
4 | dist
5 | lib
6 | coverage
7 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | *.log
3 | src
4 | test
5 | examples
6 | coverage
7 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "iojs"
4 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 library-boilerplate-author
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 | # Redux Remotes
2 | Trigger side-effects (e.g. async actions) via dispatch. Vaguely similar to cerebral signals or elm side effects.
3 |
4 | Remotes provides a standard, predicatable API for handling async actions. It is similar to using redux-thunk, except instead of dispatching a function, you dispatch a "command" action which is then handled by one or many remotes. There are potentially a few benefits to this approach:
5 | * Serializable (as opposed to action creator invocation which is not)
6 | * Robust and standardized logging
7 | * Many side effects can be triggered by one action, or one side effect can be triggered by multiple actions
8 |
9 | Additionally because the structure of remotes mirrors that of reducers, the mental model is light and easy to integrate within an existing redux application.
10 |
11 | Not necessarily redux specific, but that is the target architecture.
12 |
13 | ## What does it look like?
14 | Remotes works as follows:
15 | 1. Compose multiple `remotes` into a single `remote` function (just like you do with reducers
16 | 2. Install the middleware. The middleware sends every action to the registered remote before passing it along.
17 | 3. A contract is created for every action that one more remotes handles.
18 | 4. Each remote calls finish() when it is done operating on an action.
19 |
20 | To get a better idea of what this looks like, see the console logging upon contract completion:
21 |
22 |
23 |
24 | ## Usage
25 | ```js
26 | import { createRemote, remotesMiddleware } from 'redux-remotes'
27 | import * as remotes from '../remotes'
28 |
29 | const remote = createRemote(remotes, {log: true})
30 | const remoteMW = remotesMiddleware(remote)
31 | const createStoreWithMiddleware = applyMiddleware(remoteMW)(createStore)
32 | ```
33 | in reducers/someReducer.js
34 | ```js
35 | import { INCREMENT } from '../constants/ActionTypes'
36 |
37 | export default function account({action, getState, finish, dispatch}) {
38 | switch (action.type) {
39 |
40 | case INCREMENT:
41 | //call finish when done operating so the contract can be closed.
42 | setTimeout(finish, 1000)
43 | //return true indicates this remote is going to operate, and the contract should wait for response
44 | return true
45 |
46 | default:
47 | //return false if no operation
48 | return false
49 | }
50 | }
51 | ```
52 |
53 | ## Use Cases
54 | Restful Resource
55 | ```js
56 | export default function profile({action, getState, finish, dispatch}) {
57 | switch (action.type) {
58 |
59 | case PROFILE_CREATE:
60 | let profile = {...action.data, timestamp: Date.now()}
61 | profilePending(profile)
62 | apiClient.createProfile(profile, (err) => {
63 | if(err){ profileFail(profile) }
64 | else{ profileSuccess(profile) }
65 | finish()
66 | })
67 | return true
68 |
69 | default:
70 | return false
71 | }
72 |
73 | function profilePending(profile){
74 | dispatch({ type: PROFILE_CREATE_PENDING, profile: profile})
75 | }
76 | function profileFail(profile){
77 | dispatch({ type: PROFILE_CREATE_FAIL, profile: profile})
78 | }
79 | function profileSuccess(profile){
80 | dispatch({ type: PROFILE_CREATE_SUCCESS, profile: profile })
81 | }
82 | }
83 | ```
84 |
85 | Other times remotes may not need to report their status as actions. For example a remote action logger:
86 | ```js
87 | export default function remoteLogger({action, getState, finish, dispatch}) {
88 | remoteLog(action, () => {
89 | finish()
90 | })
91 | return true
92 | }
93 | ```
94 |
95 | Or a remote stream
96 | ```js
97 | export default function alertPipe({action, getState, finish, dispatch}) {
98 | switch (action.type) {
99 |
100 | let unsubscribe = null
101 |
102 | case SUBSCRIBE_TO_ALERTS:
103 | listener = rethinkDBListener((alert) => {
104 | dispatch({
105 | type: 'ALERT',
106 | alert: alert,
107 | })
108 | })
109 | unsubscribe = () => {
110 | listener.destroy()
111 | finish()
112 | }
113 | //finish is never called, meaning this will always show
114 | return true
115 |
116 | case UNSUBSCRIBE_TO_ALERTS:
117 | unsubscribe && unsubscribe()
118 | finish()
119 | return true
120 |
121 | default:
122 | return false
123 | }
124 | ```
125 |
126 | ## Uncertainties
127 | This may need some tweaking to play well with store enhancers like redux-devtools. Further testing and experimentation is needed.
128 |
--------------------------------------------------------------------------------
/examples/log.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rt2zz/redux-remotes/923ceb113479c48fbf9efd6d9c8c40a11d3daad7/examples/log.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-remotes",
3 | "version": "0.1.8",
4 | "description": "Remotes",
5 | "main": "lib/index.js",
6 | "scripts": {
7 | "clean": "rimraf lib",
8 | "build": "babel src --out-dir lib",
9 | "build:watch": "watch 'npm run build' ./src",
10 | "todo:test": "NODE_ENV=test mocha --compilers js:babel/register --recursive",
11 | "todo:test:watch": "NODE_ENV=test mocha --compilers js:babel/register --recursive --watch",
12 | "test:cov": "babel-node ./node_modules/.bin/isparta cover ./node_modules/.bin/_mocha -- --recursive",
13 | "prepublish": "npm run clean && npm run build"
14 | },
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/rt2zz/redux-remotes.git"
18 | },
19 | "homepage": "https://github.com/rt2zz/redux-remotes",
20 | "keywords": [
21 | "redux",
22 | "middleware",
23 | "redux-middleware",
24 | "flux"
25 | ],
26 | "author": {
27 | "name": "Zack Story",
28 | "email": "zack@root-two.com"
29 | },
30 | "license": "MIT",
31 | "devDependencies": {
32 | "babel": "^5.6.14",
33 | "babel-core": "^5.6.15",
34 | "babel-eslint": "^3.1.20"
35 | },
36 | "dependencies": {
37 | "invariant": "^2.1.0",
38 | "lodash": "^3.10.0"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 | import invariant from 'invariant'
3 |
4 | //@TODO need to figure out how this plays with action replay and other redux enhancers
5 |
6 | export function remotesMiddleware(remote) {
7 | return ({ dispatch, getState }) => {
8 | return next => action => {
9 | remote(action, dispatch, getState)
10 | return next(action)
11 | }
12 | }
13 | }
14 |
15 | export function createRemote(remotes, config){
16 | let finalRemotes = _.pick(remotes, (val) => typeof val === 'function')
17 | config = config || {}
18 |
19 | let contracts = []
20 | let archive = []
21 |
22 | window.logRemotes = function(){
23 | console.log('Contracts:',contracts,'Archive:', archive)
24 | }
25 |
26 | return function combinationRemote(action, dispatch, getState) {
27 |
28 | var keys = _.keys(finalRemotes)
29 | var contract = {
30 | time: { start: Date.now(), end: undefined },
31 | unresolved: keys,
32 | resolved: [],
33 | dispatches: [],
34 | action: action,
35 | }
36 |
37 | contracts.push(contract)
38 |
39 | _.forEach(finalRemotes, (remote, key) => {
40 | let handled = remote({action, getState, dispatch: subdispatch, finish})
41 | //if remote explicitly returns false, assume noop
42 | if(handled === false){
43 | noopResolve(key)
44 | }
45 |
46 | function finish(finalAction) {
47 | if(typeof finalAction === 'object'){
48 | subdispatch(finalAction)
49 | }
50 | resolve(key)
51 | }
52 | })
53 |
54 | function subdispatch(subaction) {
55 | contract.dispatches.push(subaction)
56 | dispatch(subaction)
57 | }
58 |
59 | function resolve(key){
60 | contract.resolved.push(key)
61 | noopResolve(key)
62 | }
63 |
64 | //same as above but do not add to resolved list
65 | function noopResolve(key){
66 | invariant(contract.unresolved.indexOf(key) !== -1, 'Cannot resolve twice for remote: '+key+' for Action: '+action.type+'. You either called finish() twice or returned false and called finish()')
67 | contract.unresolved = _.without(contract.unresolved, key)
68 | if(contract.unresolved.length === 0){
69 | completeContract()
70 | }
71 | }
72 |
73 | function completeContract() {
74 |
75 | contract.time.end = Date.now()
76 |
77 | contracts = _.without(contracts, contract)
78 | //only process if something was handled
79 | if(contract.resolved.length > 0){
80 | archive.unshift(contract)
81 | archive = archive.slice(0, 1000)
82 | if(config.log === true){
83 | let groupable = typeof console.groupCollapsed === 'function'
84 |
85 | var time = new Date()
86 | var formattedTime = " @ " + time.getHours() + ":" + pad(time.getMinutes()) + ":" + pad(time.getSeconds())
87 | var formattedDuration = " in " + (contract.time.end - contract.time.start) + " ms"
88 | var message = "remote " + action.type + formattedTime + formattedDuration
89 |
90 | if(groupable){ console.groupCollapsed(message) }
91 | console.log('resolved %i remotes', contract.resolved.length, contract.resolved)
92 | console.log('dispatched %i child actions', contract.dispatches.length, _.pluck(contract.dispatches, 'type'))
93 | console.log('%i contracts outstanding', contracts.length, _.map(contracts, (contract) => contract.action.type))
94 |
95 | if(groupable){
96 | console.groupCollapsed('more details')
97 | console.log("completed contract:", contract)
98 | console.log("outstanding contracts:", contracts)
99 | console.groupEnd()
100 | }
101 |
102 | if(groupable){ console.groupEnd() }
103 | }
104 | }
105 | }
106 | }
107 | }
108 |
109 | var pad = function pad(num) {
110 | return ("0" + num).slice(-2)
111 | }
112 |
113 | export function remoteActionMap(map){
114 | return (api) => {
115 | if(!map[api.action.type]){
116 | return false
117 | }
118 | map[api.action.type](api)
119 | return true
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/test/index.spec.js:
--------------------------------------------------------------------------------
1 | import expect from 'expect';
2 | import { add } from '../src';
3 |
4 | describe('add', () => {
5 | it('should add 2 and 2', () => {
6 | expect(add(2, 2)).toBe(4);
7 | });
8 | });
9 |
--------------------------------------------------------------------------------