Skip to content

Commit dce8af0

Browse files
authored
Merge pull request #11 from grrowl/verify-async
Adds option `verifyAsync` of signature `(callback, action, getStateHistory) => {}`
2 parents 4eb737a + 5d9940c commit dce8af0

10 files changed

+215
-26
lines changed

CHANGELOG.md

+8
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
# 0.3.0
2+
3+
* Internally, we now use the redux history for gossiping with other
4+
scuttlebutts, instead of maintaining a separate history.
5+
* Adds dispatcher option `verifyAsync`. This allows flexible validation of
6+
actions.
7+
* Adds Dispatcher unit tests
8+
19
# 0.2.1
210

311
* Adds `SECURE_SB` env variable support to the server, so you can connect and

README.md

+43-2
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ scuttlebutt({
7777
// the Primus object, can be switched out with any compatible transport.
7878
primus: (typeof window === 'object' && window.Primus),
7979

80-
// options passed through to the dispatcher
80+
// options passed through to the dispatcher (and their defaults)
8181
dispatcherOptions: {
8282
// the default will batch-reduce actions by the hundred, firing redux's
8383
// subscribe method on the last one, triggering the actual rendering on the
@@ -88,11 +88,52 @@ scuttlebutt({
8888
// returns whether an action's type should be broadcast to the network.
8989
// (returns false for @@INIT and internal @@scuttlebutt-prefixed action
9090
// types)
91-
isGossipType: isGossipType(actionType), // (actionType) => bool
91+
isGossipType: isGossipType, // (actionType) => bool
92+
93+
// if specified, the specified function must call the callback with false or
94+
// true, depending on whether the action is valid.
95+
verifyAsync, // (callback, action, getStateHistory) => {}
9296
},
9397
})
9498
```
9599

100+
### verifyAsync
101+
102+
The dispatcher option `verifyAsync` allows you to filter actions dispatched or
103+
gossiped about through scuttlebutt. You could validate an action's contents
104+
against a cryptographic signature, or rate limit, or an arbitrary rule:
105+
106+
```js
107+
import { UPDATE_ACTION } from 'redux-scuttlebutt'
108+
109+
function verifyAsync(callback, action, getStateHistory) {
110+
const history = getStateHistory(),
111+
prevUpdate = history[history.length - 1],
112+
prevAction = prevUpdate && prevUpdate[UPDATE_ACTION]
113+
114+
if (
115+
// if this message doesn't include an e
116+
action && action.payload
117+
&& action.payload.indexOf('e') === -1
118+
// and the previously message didn't include an e
119+
&& prevAction && prevAction && prevAction.payload
120+
&& prevAction.payload.indexOf('e') === -1
121+
) {
122+
callback(false)
123+
} else {
124+
callback(true)
125+
}
126+
}
127+
```
128+
129+
The `getStateHistory` parameter returns an array of the form
130+
`[UPDATE_ACTION, UPDATE_TIMESTAMP, UPDATE_SOURCE, UPDATE_SNAPSHOT]`. These
131+
`UPDATE_*` constants are exported from scuttlebutt.
132+
133+
Note, if your verification is computationally expensive, you are responsible for
134+
throttling/delay (like you might for
135+
[getDelayedDispatch](https://github.com/grrowl/redux-scuttlebutt/blob/4eb737a65e442f388cc1c69c917c8f7b1ee11271/src/dispatcher.js#L23)).
136+
96137
## conflict-free reducers
97138

98139
While `redux-scuttlebutt` facilitates action sharing and enhancing the store,

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "redux-scuttlebutt",
3-
"version": "0.2.1",
3+
"version": "0.3.0",
44
"description": "Redux distributed dispatcher",
55
"main": "lib/index.js",
66
"files": [
@@ -45,6 +45,7 @@
4545
"eslint-plugin-react": "^6.4.1",
4646
"expect": "^1.6.0",
4747
"express": "^4.13.3",
48+
"sinon": "^1.17.6",
4849
"tap-spec": "^4.1.1",
4950
"tape": "^4.6.0"
5051
},

src/constants.js

+1-4
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,4 @@ export const META_SOURCE = '@@scuttlebutt/SOURCE'
66
export const UPDATE_ACTION = 0,
77
UPDATE_TIMESTAMP = 1,
88
UPDATE_SOURCE = 2,
9-
STATE_ACTION = 0,
10-
STATE_TIMESTAMP = 1,
11-
STATE_SOURCE = 2,
12-
STATE_SNAPSHOT = 3
9+
UPDATE_SNAPSHOT = 3

src/dispatcher.js

+14-5
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ import * as orderedHistory from './orderedHistory'
44

55
import {
66
// action constants
7-
UPDATE_TIMESTAMP,
8-
UPDATE_SOURCE,
7+
UPDATE_ACTION,
98

109
META_TIMESTAMP,
1110
META_SOURCE
@@ -64,6 +63,7 @@ function getDelayedDispatch(dispatcher) {
6463
const defaultOptions = {
6564
customDispatch: getDelayedDispatch,
6665
isGossipType: isGossipType,
66+
verifyAsync: undefined,
6767
}
6868

6969
export default class Dispatcher extends Scuttlebutt {
@@ -77,6 +77,7 @@ export default class Dispatcher extends Scuttlebutt {
7777

7878
this._isGossipType = this.options.isGossipType
7979

80+
this._verifyAsync = this.options.verifyAsync
8081

8182
// redux methods to wrap
8283
this._reduxDispatch = () => {
@@ -114,7 +115,7 @@ export default class Dispatcher extends Scuttlebutt {
114115

115116
// wraps the initial state, if any, into the first snapshot
116117
wrapInitialState(initialState) {
117-
return initialState && [,,initialState]
118+
return orderedHistory.getInitialState(initialState)
118119
}
119120

120121
// rewinds history when it changes
@@ -143,17 +144,25 @@ export default class Dispatcher extends Scuttlebutt {
143144
}
144145
}
145146

146-
// add our metadata to the action as non-enumerable properties
147+
// add our metadata to the action as non-enumerable properties. This is so
148+
// they won't be serialised into JSON when sent over the network to peers in
149+
// this.history(), and can be added back by other peers as they receive
150+
// them
147151
Object.defineProperty(localAction.meta, META_TIMESTAMP, {
148152
enumerable: false,
149153
value: timestamp
150154
})
155+
151156
Object.defineProperty(localAction.meta, META_SOURCE, {
152157
enumerable: false,
153158
value: source
154159
})
155160

156-
dispatch(true)
161+
if (this._verifyAsync) {
162+
this._verifyAsync(dispatch, localAction, this._reduxGetState)
163+
} else {
164+
dispatch(true)
165+
}
157166

158167
// recieved message succesfully. if false, peers may retry the message.
159168
return true

src/index.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import Dispatcher from './dispatcher'
22

33
export { isGossipType } from './dispatcher'
4-
export { META_SOURCE, META_TIMESTAMP, REWIND_ACTION } from './constants'
5-
4+
export {
5+
META_SOURCE, META_TIMESTAMP, REWIND_ACTION,
6+
UPDATE_ACTION, UPDATE_TIMESTAMP, UPDATE_SOURCE, UPDATE_SNAPSHOT
7+
} from './constants'
68

79
// Applies default options.
810
const defaultOptions = {

src/orderedHistory.js

+21-11
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,28 @@ import {
22
META_TIMESTAMP,
33
META_SOURCE,
44

5-
STATE_ACTION,
6-
STATE_TIMESTAMP,
7-
STATE_SOURCE,
8-
STATE_SNAPSHOT
5+
UPDATE_ACTION,
6+
UPDATE_TIMESTAMP,
7+
UPDATE_SOURCE,
8+
UPDATE_SNAPSHOT
99
} from './constants'
1010

11+
// Formats an initial state
12+
export const getInitialState = (state) => {
13+
if (state !== undefined) {
14+
const wrappedState = []
15+
16+
wrappedState[UPDATE_SNAPSHOT] = state
17+
18+
return [wrappedState]
19+
}
20+
}
1121

1222
// Returns the state at this point in time
1323
export const getState = (state) => {
1424
const lastState = state[state.length - 1]
1525

16-
return lastState && lastState[STATE_SNAPSHOT]
26+
return lastState && lastState[UPDATE_SNAPSHOT]
1727
}
1828

1929
// sort by timestamp, then by source
@@ -35,9 +45,9 @@ export const reducer = (reducer) => (currentState = [], action) => {
3545
// replay actions which occurred after it (if any)
3646
// rewind to -1 for "before the start of time"
3747
for (stateIndex = currentState.length - 1; stateIndex >= -1; stateIndex--) {
38-
const thisTimestamp = currentState[stateIndex] && currentState[stateIndex][STATE_TIMESTAMP],
39-
thisSource = stateIndex === -1 ? undefined : currentState[stateIndex][STATE_SOURCE],
40-
thisSnapshot = stateIndex === -1 ? undefined : currentState[stateIndex][STATE_SNAPSHOT]
48+
const thisTimestamp = currentState[stateIndex] && currentState[stateIndex][UPDATE_TIMESTAMP],
49+
thisSource = stateIndex === -1 ? undefined : currentState[stateIndex][UPDATE_SOURCE],
50+
thisSnapshot = stateIndex === -1 ? undefined : currentState[stateIndex][UPDATE_SNAPSHOT]
4151

4252
// thisTimestamp will be undefined until the first timestamped action.
4353
// if this action has no timestamp, we're before the start of time, or,
@@ -75,12 +85,12 @@ export const reducer = (reducer) => (currentState = [], action) => {
7585
// skip the inserted action (index + 1)
7686
for (stateIndex = stateIndex + 2; stateIndex < currentState.length; stateIndex++) {
7787
const thisState = currentState[stateIndex],
78-
thisAction = thisState[STATE_ACTION],
88+
thisAction = thisState[UPDATE_ACTION],
7989
lastState = currentState[stateIndex - 1],
80-
lastSnapshot = lastState ? lastState[STATE_SNAPSHOT] : undefined
90+
lastSnapshot = lastState ? lastState[UPDATE_SNAPSHOT] : undefined
8191

8292
// update each state snapshot
83-
thisState[STATE_SNAPSHOT] = reducer(lastSnapshot, thisAction)
93+
thisState[UPDATE_SNAPSHOT] = reducer(lastSnapshot, thisAction)
8494
}
8595

8696
// if we're here, the currentState history has been updated

test/dispatcher.spec.js

+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import tape from 'tape'
2+
import { spy } from 'sinon'
3+
4+
import Dispatcher from '../src/dispatcher'
5+
import * as orderedHistory from '../src/orderedHistory'
6+
7+
function createAction(payload, type = 'ACTION') {
8+
return ({
9+
type,
10+
payload: payload,
11+
})
12+
}
13+
14+
tape('dispatcher.wrapDispatch', function (t) {
15+
const dispatcher = new Dispatcher(),
16+
dispatch = spy(),
17+
wrappedDispatch = dispatcher.wrapDispatch(dispatch)
18+
19+
spy(dispatcher, 'localUpdate')
20+
21+
const privateAction = createAction(1, '@@privateType'),
22+
publicAction = createAction(10, 'REAL_ACTION')
23+
24+
wrappedDispatch(privateAction)
25+
wrappedDispatch(publicAction)
26+
27+
t.ok(dispatch.calledWith(privateAction), 'dispatch called for gossip action')
28+
t.ok(dispatch.calledWith(privateAction), 'dispatch called for private action')
29+
30+
t.ok(dispatch.calledTwice, 'dispatch called twice')
31+
t.ok(dispatcher.localUpdate.calledOnce, 'localUpdate called once')
32+
33+
dispatcher.localUpdate.restore()
34+
35+
t.end()
36+
})
37+
38+
tape('dispatcher.wrapGetState (initialState)', function (t) {
39+
const dispatcher = new Dispatcher(),
40+
getState = () => orderedHistory.getInitialState(42)
41+
42+
spy(orderedHistory, 'getState')
43+
44+
// should call orderedHistory.getState for transform
45+
t.equal(dispatcher.wrapGetState(getState)(), 42, 'states are equal')
46+
47+
t.ok(orderedHistory.getState.calledOnce, 'orderedHistory.getState called')
48+
49+
orderedHistory.getState.restore();
50+
51+
t.end()
52+
})
53+
54+
tape('dispatcher.wrapInitialState', function (t) {
55+
const dispatcher = new Dispatcher(),
56+
initialState = { 'favs': 'dogs' },
57+
state = dispatcher.wrapInitialState(initialState)
58+
59+
t.ok(orderedHistory.getState(state), 'getState is ok')
60+
t.equal(orderedHistory.getState(state).favs, 'dogs', 'favs is dogs')
61+
62+
t.end()
63+
})
64+
65+
tape('dispatcher.wrapReducer', function (t) {
66+
const dispatcher = new Dispatcher(),
67+
rootReducer = spy((state = [], action) => [ ...state, action.payload]),
68+
reducer = dispatcher.wrapReducer(rootReducer)
69+
70+
let state = dispatcher.wrapInitialState(['hey'])
71+
72+
state = reducer(state, createAction('new'))
73+
state = reducer(state, createAction('yeah'))
74+
75+
t.ok(rootReducer.calledTwice, 'called rootReducer twice')
76+
77+
t.equal(orderedHistory.getState(state).length, 3, 'should have three entries')
78+
t.equal(orderedHistory.getState(state)[1], 'new', '"new" should be the second entry')
79+
80+
t.end()
81+
})
82+
83+
tape('dispatcher({ verifyAsync })', function (t) {
84+
const
85+
invalid = ['new', 'yeah'], valid = ['what', 'up'],
86+
verifyAsync = (callback, action, getHistory) => {
87+
t.ok(Array.isArray(getHistory()), 'getHistory() returns an array')
88+
89+
setTimeout(() => {
90+
// payloads containing 'e' are invalid
91+
if (action && action.payload && action.payload.indexOf('e') !== -1) {
92+
t.ok(invalid.includes(action.payload), 'invalid action is invalid')
93+
callback(false)
94+
} else {
95+
t.ok(action.payload && valid.includes(action.payload), 'valid action is valid')
96+
callback(true)
97+
}
98+
}, 5)
99+
},
100+
dispatcher = new Dispatcher({ verifyAsync }),
101+
dispatch = spy(),
102+
wrappedDispatch = dispatcher.wrapDispatch(dispatch),
103+
getState = spy(() => []) // becomes getHistory, returns array
104+
105+
dispatcher.wrapGetState(getState)
106+
107+
wrappedDispatch(createAction(invalid[0]))
108+
wrappedDispatch(createAction(invalid[1]))
109+
wrappedDispatch(createAction(valid[0]))
110+
wrappedDispatch(createAction(valid[1]))
111+
112+
setTimeout(() => {
113+
t.ok(dispatch.calledTwice, 'called dispatch twice')
114+
// first call
115+
t.equal(dispatch.getCall(0).args[0].payload, valid[0], 'called dispatch with valid action 1')
116+
// second call
117+
t.equal(dispatch.getCall(1).args[0].payload, valid[1], 'called dispatch with valid action 2')
118+
t.equal(getState.callCount, 4, 'getState was called for each getHistory')
119+
t.end()
120+
}, 20)
121+
})

test/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
require('babel-register')
22

33
require('./orderedHistory.spec.js')
4+
require('./dispatcher.spec.js')

test/orderedHistory.spec.js

-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import tape from 'tape'
2-
import Dispatcher, { REWIND_ACTION } from '../src/dispatcher'
32
import { reducer, sort, getState } from '../src/orderedHistory'
43

54
import {

0 commit comments

Comments
 (0)