├── .babelrc ├── .codeclimate.yml ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── package.json ├── readme.md ├── src ├── actions.js ├── eventChannel.js ├── index.js ├── utils.js ├── watchers.js └── workers.js └── test ├── .eslintrc ├── actions ├── socketEmit.js └── socketRequest.js ├── createChannelSubscription.js ├── createEventChannel.js ├── utils.js ├── watchers ├── watchEmits.js ├── watchRemote.js └── watchRequests.js └── workers ├── handleEmit.js └── handleRequest.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-2"] 3 | } 4 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | duplication: 3 | enabled: true 4 | exclude_paths: 5 | - "test/" 6 | config: 7 | languages: 8 | - javascript 9 | eslint: 10 | enabled: true 11 | channel: "eslint-2" 12 | checks: 13 | import/no-unresolved: 14 | enabled: false 15 | fixme: 16 | enabled: true 17 | ratings: 18 | paths: 19 | - "**.js" 20 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "parser": "babel-eslint", 4 | "rules": { 5 | "semi": ["error", "never"], 6 | "import/no-unresolved": ["off"], 7 | "import/no-extraneous-dependencies": ["off"], 8 | "no-plusplus": ["off"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | coverage 3 | lib 4 | node_modules 5 | npm-debug.log 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | src 3 | test 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | dependencies: 4 | pre: 5 | - npm install -g npm@3 6 | node_js: 7 | - "node" 8 | script: "npm test && npm run lint" 9 | after_script: npm run coverage 10 | notifications: 11 | email: false 12 | env: 13 | global: 14 | - NPM_CONFIG_PROGRESS="false" 15 | addons: 16 | code_climate: 17 | repo_token: 6aa7c8b81fa52eb0e00466d7342fded59ce4ed8cdce3fc240103050c72b159de 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-saga-sc", 3 | "version": "2.0.5", 4 | "description": "Provides sagas to easily dispatch redux actions over SocketCluster websockets", 5 | "main": "./lib", 6 | "scripts": { 7 | "build:watch": "npm run build -- --watch", 8 | "build": "mkdirp lib && babel src --out-dir lib", 9 | "clean": "rimraf lib", 10 | "codeclimate": "codeclimate-test-reporter < ./coverage/lcov.info", 11 | "coverage": "npm run coveralls && npm run codeclimate", 12 | "coveralls": "node_modules/.bin/babel-node node_modules/.bin/istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -R spec --recursive test && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js", 13 | "lint": "eslint .", 14 | "prepublish": "npm run clean && npm run build", 15 | "test:watch": "npm test -- --watch --growl", 16 | "test": "mocha --compilers js:babel-register --require babel-polyfill --recursive", 17 | "postversion": "git push origin --tags" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/stipsan/redux-saga-sc.git" 22 | }, 23 | "keywords": [ 24 | "redux", 25 | "redux-saga", 26 | "socketcluster", 27 | "sc", 28 | "realtime", 29 | "websocket", 30 | "cluster", 31 | "scalable", 32 | "saga", 33 | "effects" 34 | ], 35 | "author": "Stian Didriksen ", 36 | "license": "MIT", 37 | "bugs": { 38 | "url": "https://github.com/stipsan/redux-saga-sc/issues" 39 | }, 40 | "homepage": "https://github.com/stipsan/redux-saga-sc#readme", 41 | "peerDependencies": { 42 | "redux-saga": "*" 43 | }, 44 | "devDependencies": { 45 | "babel-cli": "6.23.0", 46 | "babel-eslint": "7.1.1", 47 | "babel-polyfill": "6.23.0", 48 | "babel-preset-es2015": "6.22.0", 49 | "babel-preset-stage-2": "6.22.0", 50 | "babel-register": "6.23.0", 51 | "codeclimate-test-reporter": "0.4.1", 52 | "coveralls": "2.11.16", 53 | "eslint": "3.16.1", 54 | "eslint-config-airbnb-base": "9.0.0", 55 | "eslint-config-xo-space": "0.15.0", 56 | "eslint-plugin-import": "2.2.0", 57 | "expect": "1.20.2", 58 | "growl": "1.9.2", 59 | "istanbul": "1.1.0-alpha.1", 60 | "mkdirp": "0.5.1", 61 | "mocha": "3.2.0", 62 | "redux-saga": "0.14.3", 63 | "rimraf": "2.6.1" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | redux-saga-sc 2 | =============== 3 | 4 | [![Travis branch](https://img.shields.io/travis/stipsan/redux-saga-sc.svg)](https://travis-ci.org/stipsan/redux-saga-sc) 5 | [![Code Climate](https://codeclimate.com/github/stipsan/redux-saga-sc/badges/gpa.svg)](https://codeclimate.com/github/stipsan/redux-saga-sc) 6 | [![Coverage Status](https://coveralls.io/repos/github/stipsan/redux-saga-sc/badge.svg)](https://coveralls.io/github/stipsan/redux-saga-sc) 7 | [![npm package](https://img.shields.io/npm/dm/redux-saga-sc.svg)](https://www.npmjs.com/package/redux-saga-sc) 8 | 9 | [![NPM](https://nodei.co/npm/redux-saga-sc.png?downloadRank=true)](https://www.npmjs.com/package/redux-saga-sc) 10 | [![NPM](https://nodei.co/npm-dl/redux-saga-sc.png?months=3&height=2)](https://nodei.co/npm/redux-saga-sc/) 11 | 12 | 13 | This package provides ready to use sagas to connect SocketCluster clients. 14 | It can be used to let your server dispatch redux actions on the client and vice verca. 15 | Or to sync a shared redux state across multiple nodes or clients. 16 | 17 | # Examples 18 | * [redux-saga-sc-demo](https://github.com/stipsan/redux-saga-sc-demo) - A demo chat app showing redux-saga-sc in action 19 | * [epic](https://github.com/stipsan/epic) - React example project, that takes you from fun development to high quality production 20 | 21 | # Documentation 22 | 23 | * [Sending actions between remote redux stores](#sending-actions-between-remote-redux-stores) 24 | * [Sending notifications with `socketEmit` action creator](#sending-notifications-with-socketemit-action-creator) 25 | * [Requesting data with `socketRequest`](#requesting-data-with-socketrequest) 26 | * [Advanced](#advanced) 27 | * [Using the `createEventChannel` factory to connect to socket events](#using-the-createeventchannel-factory-to-connect-to-socket-events) 28 | * [Using the `createChannelSubscription` factory to connect to socket channels](#using-the-createchannelsubscription-factory-to-connect-to-socket-channels) 29 | 30 | ### Sending actions between remote redux stores 31 | 32 | You'll notice that this guide does not use the terms "server" and "client". Why? You could use this server to server, client to client, it doesn't matter. Instead you have a "sender" and a "receiver". 33 | The "sender" can `emit` something, the "receiver" listens for the `emit` and may decide to `emit` something back in response. 34 | The "sender" can also `request` something from the "receiver" requiring a response of either `successType` or `failureType`. 35 | 36 | #### Sending notifications with `socketEmit` action creator 37 | 38 | You can use `socketEmit` to dispatch simple actions that does not require a response. If there's a network problem, like if the device switch between a 3G to 4G connection, it'll automatically retry sending the `emit` until it gets a delivery notification back from the receiver. 39 | 40 | To use it, wrap your action that you want dispatched on the remote redux store like this: 41 | 42 | `./actions/chat.js` 43 | ```js 44 | import { socketEmit } from 'redux-saga-sc' 45 | 46 | import { 47 | RECEIVE_MESSAGE, 48 | } from '../constants/ActionTypes' 49 | 50 | export const sendMessage = (message, sender) => socketEmit({ 51 | type: RECEIVE_MESSAGE, 52 | payload: { 53 | message, 54 | sender, 55 | }, 56 | }) 57 | ``` 58 | 59 | Next step is to setup the watcher that will take actions created by `socketEmit` and send it over the websocket for us. 60 | 61 | `./sagas/index.js` 62 | ```js 63 | import { watchRequests } from 'redux-saga-sc' 64 | 65 | import socketCluster from 'socketcluster-client' 66 | 67 | const socket = socketCluster.connect() 68 | 69 | export default function *sagas() { 70 | yield [ 71 | ... // your other sagas 72 | watchEmits(socket), 73 | ] 74 | } 75 | ``` 76 | 77 | And the last step is to add the `watchRemote` worker to the receiver, in this example the receiver is a SocketCluster workerController: 78 | `./web/worker.js` 79 | ```js 80 | import express from 'express' 81 | 82 | import createStore from './store' 83 | 84 | export const run = worker => { 85 | const app = express() 86 | 87 | worker.httpServer.on('request', app) 88 | 89 | worker.scServer.on('connection', socket => createStore(socket)) 90 | } 91 | ``` 92 | `./web/store.js` 93 | ```js 94 | import * as reducers from '../reducers' 95 | 96 | import createSagaMiddleware from 'redux-saga' 97 | import { createStore, applyMiddleware, combineReducers } from 'redux' 98 | 99 | import sagas from '../sagas' 100 | 101 | export default (socket) => { 102 | const sagaMiddleware = createSagaMiddleware() 103 | const store = createStore( 104 | combineReducers(reducers), 105 | applyMiddleware(sagaMiddleware) 106 | ) 107 | 108 | sagaMiddleware.run(sagas, socket) 109 | 110 | return store 111 | } 112 | ``` 113 | `./web/sagas/index.js` 114 | ```js 115 | import { watchRemote } from 'redux-saga-sc' 116 | 117 | export default function *sagas(socket) { 118 | yield [ 119 | ... // your other sagas 120 | watchRemote(socket) 121 | ] 122 | } 123 | ``` 124 | 125 | You can setup this on both sides of the websocket if you need the ability to pass actions back and forth. 126 | Actions will dispatch like any other action. 127 | Meaning you can use 128 | ```js 129 | yield take(RECEIVE_MESSAGE) 130 | ``` 131 | in the sagas of your receiver to act on it. And you can also use `RECEIVE_MESSAGE` in your reducers. 132 | The actions you dispatch wrapped in `socketEmit` only dispatch on the receiver, not the local redux store. 133 | It will automatically retry if the server does not respond. 134 | 135 | By default emits will be done on the "dispatch" event on SocketCluster. You can change this by passing a second argument to socketEmit and use whatever event you want. 136 | Just be sure to set the `watchRemote` saga on the receiver to the same event, as that too use 'dispatch' as the default event. 137 | 138 | #### Requesting data with `socketRequest` 139 | 140 | While `socketEmit` is useful for notifications and other stuff that you don't need to know the result, only that it was delivered, there's `socketRequest` that is suitable for more advanced situations. 141 | When you do a `socketRequest` you have to pass both a `successType` and `failureType` that the receiver must emit back in a given timeframe. 142 | 143 | Here's an example: 144 | `./actions/auth.js` 145 | ```js 146 | import { socketRequest } from 'redux-saga-sc' 147 | 148 | import { 149 | AUTHENTICATE_REQUESTED, 150 | AUTHENTICATE_SUCCESS, 151 | AUTHENTICATE_FAILURE, 152 | } from '../constants/ActionTypes' 153 | 154 | export const signInWithEmailAndPassword = credentials => socketRequest({ 155 | type: AUTHENTICATE_REQUESTED, 156 | payload: { 157 | successType: AUTHENTICATE_SUCCESS, 158 | failureType: AUTHENTICATE_FAILURE, 159 | credentials, 160 | }, 161 | }) 162 | ``` 163 | 164 | To dispatch socket requests, setup `watchRequests` the same way you did `watchEmits`. 165 | 166 | You setup the receiver just like you do in the `socketEmit` example, using `watchRemote`. 167 | There are two important differences though. First of all, unlike `socketEmit`, the action you pass in `socketRequest` will also dispatch locally. This is to allow for stuff like creating progress spinners and similar. 168 | The other difference is that you need to setup your own watcher that will act on the `AUTHENTICATE_REQUESTED` in this example, and return either `AUTHENTICATE_SUCCESS` or `AUTHENTICATE_FAILURE`. 169 | 170 | Here's what it should look like: 171 | `./web/sagas/auth.js` 172 | ```js 173 | import { socketEmit } from 'redux-saga-sc' 174 | 175 | import { authenticate } from '../models/user' 176 | import { 177 | AUTHENTICATE_REQUESTED 178 | } from '../constants/ActionTypes' 179 | 180 | export function *watchAuthenticateRequest(socket) { 181 | while (true) { // eslint-disable-line no-constant-condition 182 | const { payload: { 183 | successType, 184 | failureType, 185 | credentials, 186 | } } = yield take(AUTHENTICATE_REQUESTED) 187 | try { 188 | const authToken = yield call(authenticate, credentials) 189 | yield put(socketEmit({ type: successType, payload: authToken })) 190 | } catch(err) { 191 | yield put(socketEmit({ type: failureType, payload: err })) 192 | } 193 | } 194 | } 195 | ``` 196 | 197 | ## Advanced 198 | 199 | A little more info on lower level stuff if you need to do more than what the provided watchers and action creators provides out of the box. 200 | 201 | ### Using the `createEventChannel` factory to connect to socket events 202 | 203 | On the client: 204 | ```js 205 | import { createEventChannel } from 'redux-saga-sc' 206 | import socketCluster from 'socketcluster-client' 207 | 208 | const socket = socketCluster.connect({ 209 | hostname: process.env.SOCKET_HOSTNAME || location.hostname 210 | }) 211 | 212 | export function *watchIncomingActions() { 213 | const chan = yield call(createEventChannel, socket, 'dispatch') 214 | while (true) { 215 | const action = yield take(chan) 216 | yield put(action) 217 | } 218 | } 219 | ``` 220 | 221 | On the server: 222 | 223 | ```js 224 | socket.emit('dispatch', {type: 'MY_ACTION', payload: { foo: 'bar' }}, () => { 225 | console.log('Client dispatched the action!') 226 | }) 227 | ``` 228 | 229 | ### Using the `createChannelSubscription` factory to connect to socket channels 230 | 231 | One of the coolest features of SocketCluster are [channels](http://socketcluster.io/#!/docs/api-scchannel-client). 232 | Especially when you use channels on the [Exchange](http://socketcluster.io/#!/docs/api-exchange) as it allows you to distribute actions both vertically (multiple worker processes) and horizontally (multiple servers). 233 | 234 | Since publishing to a channel is very straightforward there's no utility in `redux-saga-sc` for that, the example below shows you how it's done (`exchange` in these examples are assumed to be `scWorker.exchange` passed to the store from your workerController, that's how it's done in [redux-saga-sc-demo](https://github.com/stipsan/redux-saga-sc-demo)): 235 | 236 | ```js 237 | import { cps, take } from 'redux-saga/effects' 238 | 239 | export function *watchMessages(exchange) { 240 | while (true) { // eslint-disable-line no-constant-condition 241 | const message = yield take('MESSAGE') 242 | yield cps([exchange, exchange.publish], 'chat', message) 243 | } 244 | } 245 | ``` 246 | 247 | And here's how createChannelSubscription is implemented to dispatch actions from the channel: 248 | ```js 249 | import { call, take, put } from 'redux-saga/effects' 250 | import { socketEmit, createChannelSubscription } from 'redux-saga-sc' 251 | 252 | export function *watchExchange(socket, exchange) { 253 | const chan = yield call(createChannelSubscription, exchange, 'chat') 254 | 255 | while (true) { // eslint-disable-line no-constant-condition 256 | const action = yield take(chan) 257 | yield put(socketEmit(action)) 258 | } 259 | } 260 | ``` 261 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | import { sym } from './utils' 2 | 3 | export const EMIT = sym('EMIT') 4 | export const REQUEST = sym('REQUEST') 5 | 6 | export const socketEmit = (payload, event = 'dispatch', autoReconnectOptions) => ({ 7 | type: EMIT, 8 | event, 9 | autoReconnectOptions, 10 | payload, 11 | }) 12 | 13 | 14 | export const socketRequest = (payload, event = 'dispatch', autoReconnectOptions, timeout) => ({ 15 | type: REQUEST, 16 | event, 17 | autoReconnectOptions, 18 | timeout, 19 | payload, 20 | }) 21 | -------------------------------------------------------------------------------- /src/eventChannel.js: -------------------------------------------------------------------------------- 1 | import { buffers, eventChannel } from 'redux-saga' 2 | 3 | export function createEventChannel(socket, event = 'dispatch', buffer = buffers.fixed()) { 4 | return eventChannel((listener) => { 5 | const handleEvent = (action, cb) => { 6 | // notify the sender that the event is received 7 | if (typeof cb === 'function') { 8 | cb() 9 | } 10 | listener(action) 11 | } 12 | socket.on(event, handleEvent) 13 | return () => socket.off(event, handleEvent) 14 | }, buffer) 15 | } 16 | 17 | export function createChannelSubscription(socketOrExchange, channelName, buffer = buffers.fixed()) { 18 | return eventChannel((listener) => { 19 | const handlePublish = (action, cb) => { 20 | // notify the sender that the event is received 21 | if (typeof cb === 'function') { 22 | cb() 23 | } 24 | listener(action) 25 | } 26 | const channel = socketOrExchange.subscribe(channelName) 27 | channel.watch(handlePublish) 28 | 29 | return () => { 30 | channel.unwatch(handlePublish) 31 | // @TODO socketOrExchange.unsubscribe(channelName) 32 | } 33 | }, buffer) 34 | } 35 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './eventChannel' 3 | export * from './watchers' 4 | export * from './workers' 5 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | 2 | export const sym = id => `@@redux-saga-sc/${id}` // eslint-disable-line import/prefer-default-export 3 | -------------------------------------------------------------------------------- /src/watchers.js: -------------------------------------------------------------------------------- 1 | import { takeEvery } from 'redux-saga' 2 | import { call, put, take } from 'redux-saga/effects' 3 | 4 | import { EMIT, REQUEST } from './actions' 5 | import { createEventChannel } from './eventChannel' 6 | import { handleEmit, handleRequest } from './workers' 7 | 8 | export function* watchEmits(socket) { 9 | yield* takeEvery(EMIT, handleEmit, socket) 10 | } 11 | 12 | export function* watchRequests(socket) { 13 | yield* takeEvery(REQUEST, handleRequest, socket) 14 | } 15 | 16 | export function* watchRemote(socket, event = 'dispatch') { 17 | const chan = yield call(createEventChannel, socket, event) 18 | while (true) { // eslint-disable-line no-constant-condition 19 | const action = yield take(chan) 20 | yield put(action) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/workers.js: -------------------------------------------------------------------------------- 1 | import { delay } from 'redux-saga' 2 | import { call, cps, put, race, take } from 'redux-saga/effects' 3 | 4 | export function* handleEmit(socket, { 5 | event, 6 | autoReconnectOptions = socket.autoReconnectOptions || {}, 7 | payload, 8 | }) { 9 | const { 10 | initialDelay = 10000, 11 | randomness = 10000, 12 | multiplier = 1.5, 13 | maxDelay = 60000, 14 | } = autoReconnectOptions 15 | let timeout 16 | let exponent = 0 17 | while (true) { // eslint-disable-line no-constant-condition 18 | try { 19 | return yield cps([socket, socket.emit], event, payload) 20 | } catch (err) { 21 | // @FIXME implement a rethrow if not TimeoutError instead of logging 22 | if ('console' in global) { 23 | console.error('catched error during handleEmit', err) 24 | } 25 | 26 | // prevent memory leaks in socket clients that do not reconnect when connection is lost 27 | if (socket.getState() === 'closed' && socket.pendingReconnect !== true) { 28 | // @TODO turn into a yield put(sym('SOCKET_CLOSED')) 29 | if (process.env.NODE_ENV !== 'production') { 30 | console.error('Socket got closed before the emit was acknowledged') 31 | } 32 | return false 33 | } 34 | 35 | const initialTimeout = Math.round(initialDelay + ((randomness || 0) * Math.random())) 36 | 37 | timeout = Math.round(initialTimeout * Math.pow(multiplier, ++exponent)) 38 | 39 | if (timeout > maxDelay) { 40 | timeout = maxDelay 41 | } 42 | 43 | // @TODO turn into a yield put(sym('SOCKET_TIMEOUT')) 44 | if (process.env.NODE_ENV !== 'production') { 45 | console.error(`Socket emit attempt #${exponent} failed, will retry in ${timeout}ms`) 46 | } 47 | 48 | yield call(delay, timeout) 49 | } 50 | } 51 | } 52 | 53 | export function* handleRequest(socket, { 54 | timeout = socket.ackTimeout, 55 | ...action 56 | }) { 57 | const { payload } = action 58 | const { payload: { successType, failureType } } = payload 59 | yield put(payload) 60 | try { 61 | yield call(handleEmit, socket, action) 62 | const { response } = yield race({ 63 | response: take([successType, failureType]), 64 | timeout: call(delay, timeout), 65 | }) 66 | if (!response) { 67 | const error = new Error('Socket request timed out waiting for a response') 68 | error.name = 'SocketTimeoutError' 69 | throw error 70 | } 71 | } catch (err) { 72 | yield put({ type: failureType, payload: { error: { name: err.name, message: err.message } } }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "parser": "babel-eslint", 4 | "rules": { 5 | "semi": ["error", "never"], 6 | "import/no-unresolved": ["off"], 7 | "import/no-extraneous-dependencies": ["off"] 8 | }, 9 | "env": { 10 | "mocha": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/actions/socketEmit.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | 3 | import { EMIT, socketEmit } from '../../src/actions' 4 | 5 | describe('socketEmit action creator', () => { 6 | it('should return passed in action as payload', () => { 7 | const action = { 8 | type: 'RECEIVE_LIKES', 9 | payload: { 10 | likes: [1, 2, 3], 11 | }, 12 | } 13 | expect( 14 | socketEmit(action) 15 | ).toEqual({ 16 | type: EMIT, 17 | event: 'dispatch', 18 | payload: action, 19 | autoReconnectOptions: undefined, 20 | }) 21 | }) 22 | it('can customize the event name on the WebSocket', () => { 23 | const action = { 24 | type: 'LOOT', 25 | payload: { 26 | coins: 10, 27 | }, 28 | } 29 | expect( 30 | socketEmit(action, 'treasurehunt') 31 | ).toEqual({ 32 | type: EMIT, 33 | event: 'treasurehunt', 34 | payload: action, 35 | autoReconnectOptions: undefined, 36 | }) 37 | }) 38 | it('should optionally pass `autoReconnectOptions`', () => { 39 | const action = { 40 | type: 'SPAM', 41 | payload: { 42 | free: 'money', 43 | }, 44 | } 45 | expect( 46 | socketEmit(action, undefined, { maxDelay: 1000 }) 47 | ).toEqual({ 48 | type: EMIT, 49 | event: 'dispatch', 50 | payload: action, 51 | autoReconnectOptions: { 52 | maxDelay: 1000, 53 | }, 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /test/actions/socketRequest.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | 3 | import { REQUEST, socketRequest } from '../../src/actions' 4 | 5 | describe('socketRequest action creator', () => { 6 | const payload = { 7 | type: 'SERVER_REQUEST', 8 | payload: { 9 | successType: 'SERVER_SUCCESS', 10 | failureType: 'SERVER_FAILURE', 11 | }, 12 | } 13 | it('should return passed in action as payload', () => { 14 | expect( 15 | socketRequest(payload) 16 | ).toEqual({ 17 | type: REQUEST, 18 | event: 'dispatch', 19 | autoReconnectOptions: undefined, 20 | timeout: undefined, 21 | payload, 22 | }) 23 | }) 24 | it('can customize the event name on the WebSocket', () => { 25 | expect( 26 | socketRequest(payload, 'request') 27 | ).toEqual({ 28 | type: REQUEST, 29 | event: 'request', 30 | autoReconnectOptions: undefined, 31 | timeout: undefined, 32 | payload, 33 | }) 34 | }) 35 | it('should optionally pass `autoReconnectOptions`', () => { 36 | expect( 37 | socketRequest(payload, undefined, { maxDelay: 1000 }) 38 | ).toEqual({ 39 | type: REQUEST, 40 | event: 'dispatch', 41 | autoReconnectOptions: { 42 | maxDelay: 1000, 43 | }, 44 | timeout: undefined, 45 | payload, 46 | }) 47 | }) 48 | it('should optionally pass a request `timeout`', () => { 49 | expect( 50 | socketRequest(payload, undefined, undefined, 1000) 51 | ).toEqual({ 52 | type: REQUEST, 53 | event: 'dispatch', 54 | autoReconnectOptions: undefined, 55 | timeout: 1000, 56 | payload, 57 | }) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /test/createChannelSubscription.js: -------------------------------------------------------------------------------- 1 | import expect, { createSpy } from 'expect' 2 | 3 | import { createChannelSubscription } from '../src' 4 | 5 | describe('createChannelSubscription', () => { 6 | const exchange = { 7 | channels: {}, 8 | 9 | subscribe(channelName) { 10 | const channel = { 11 | watch(listener) { 12 | this.listener = listener 13 | }, 14 | unwatch(listener) { 15 | if (this.listener === listener) { 16 | delete this.listener 17 | } 18 | }, 19 | } 20 | this.channels[channelName] = channel 21 | return channel 22 | }, 23 | publish(channelName, data, cb) { 24 | this.channels[channelName].listener(data, cb) 25 | }, 26 | } 27 | const chan = createChannelSubscription(exchange, 'public') 28 | 29 | it('should create an event channel', () => { 30 | const actual = [] 31 | const action = { type: 'TEST', payload: { foo: 'bar' } } 32 | 33 | chan.take(ac => actual.push(ac)) 34 | exchange.publish('public', action) 35 | expect(actual).toContain(action) 36 | exchange.publish('public', action) 37 | expect(actual.length).toBe(1, 'eventChannel should only notify once') 38 | }) 39 | 40 | it('should handle callbacks', () => { 41 | const actual = [] 42 | const action = { type: 'TEST', payload: { foo: 'bar' } } 43 | const spy = createSpy() 44 | 45 | chan.take(ac => actual.push(ac)) 46 | exchange.publish('public', action, spy) 47 | expect(actual).toContain(action) 48 | expect(spy).toHaveBeenCalled() 49 | }) 50 | 51 | it('should handle chan.close()', () => { 52 | expect(exchange.channels.public.listener).toBeTruthy() 53 | chan.close() 54 | expect(exchange.channels.public.listener).toBeFalsy() 55 | }) 56 | 57 | it('should buffer messages on the eventChannel') 58 | }) 59 | -------------------------------------------------------------------------------- /test/createEventChannel.js: -------------------------------------------------------------------------------- 1 | import expect, { createSpy } from 'expect' 2 | 3 | import { createEventChannel } from '../src' 4 | 5 | describe('createEventChannel', () => { 6 | const socket = { 7 | on(event, listener) { 8 | this.listener = listener 9 | }, 10 | off() { 11 | delete this.listener 12 | }, 13 | emit(event, data, cb) { 14 | this.listener(data, cb) 15 | }, 16 | } 17 | const chan = createEventChannel(socket) 18 | 19 | it('should create an event channel', () => { 20 | const actual = [] 21 | const action = { type: 'TEST', payload: { foo: 'bar' } } 22 | 23 | chan.take(ac => actual.push(ac)) 24 | socket.emit('dispatch', action) 25 | expect(actual).toContain(action) 26 | socket.emit('dispatch', action) 27 | expect(actual.length).toBe(1, 'eventChannel should only notify once') 28 | }) 29 | 30 | it('should handle delivery notifications', () => { 31 | const actual = [] 32 | const action = { type: 'TEST', payload: { foo: 'bar' } } 33 | const spy = createSpy() 34 | 35 | chan.take(ac => actual.push(ac)) 36 | socket.emit('dispatch', action, spy) 37 | expect(actual).toContain(action) 38 | expect(spy).toHaveBeenCalled() 39 | }) 40 | 41 | it('should buffer messages on the eventChannel') 42 | }) 43 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | 3 | import { sym } from '../src/utils' 4 | 5 | describe('utils', () => { 6 | describe('sym()', () => { 7 | it('should create a namespaced ActionType constant', () => { 8 | expect(sym('EMIT_TIMEOUT')).toBe('@@redux-saga-sc/EMIT_TIMEOUT') 9 | }) 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /test/watchers/watchEmits.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import { takeEvery } from 'redux-saga' 3 | 4 | import { EMIT, watchEmits } from '../../src' 5 | import { handleEmit } from '../../src/workers' 6 | 7 | describe('watchEmits', () => { 8 | const socket = { emit() {} } 9 | const iterator = watchEmits(socket) 10 | const actual = takeEvery(EMIT, handleEmit, socket) 11 | 12 | it('take every EMIT and pass it to the handleEmit worker', () => { 13 | expect( 14 | iterator.next().value 15 | ).toEqual( 16 | actual.next().value 17 | ) 18 | expect( 19 | iterator.next().value 20 | ).toEqual( 21 | actual.next().value 22 | ) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /test/watchers/watchRemote.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import { call, put, take } from 'redux-saga/effects' 3 | 4 | import { createEventChannel, watchRemote } from '../../src' 5 | 6 | describe('watchRemote', () => { 7 | const socket = { 8 | on(event, listener) { 9 | this.listener = listener 10 | }, 11 | off() { 12 | delete this.listener 13 | }, 14 | emit(event, data, cb) { 15 | this.listener(data, cb) 16 | }, 17 | } 18 | const chan = createEventChannel(socket) 19 | const event = 'foo' 20 | const action = { type: 'RECEIVE_LIKES' } 21 | const iterator = watchRemote(socket, event) 22 | it('should create an event channel to queue incoming external requests', () => { 23 | expect( 24 | iterator.next().value 25 | ).toEqual( 26 | call(createEventChannel, socket, event) 27 | ) 28 | }) 29 | it('should take ation from event channel', () => { 30 | expect( 31 | iterator.next(chan).value 32 | ).toEqual( 33 | take(chan) 34 | ) 35 | }) 36 | it('should put the action', () => { 37 | expect( 38 | iterator.next(action).value 39 | ).toEqual( 40 | put(action) 41 | ) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /test/watchers/watchRequests.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import { takeEvery } from 'redux-saga' 3 | 4 | import { REQUEST, watchRequests } from '../../src' 5 | import { handleRequest } from '../../src/workers' 6 | 7 | describe('watchRequests', () => { 8 | const socket = { emit() {} } 9 | const iterator = watchRequests(socket) 10 | const actual = takeEvery(REQUEST, handleRequest, socket) 11 | 12 | it('take every REQUEST and pass it to the handleRequest worker', () => { 13 | expect( 14 | iterator.next().value 15 | ).toEqual( 16 | actual.next().value 17 | ) 18 | expect( 19 | iterator.next().value 20 | ).toEqual( 21 | actual.next().value 22 | ) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /test/workers/handleEmit.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import { delay } from 'redux-saga' 3 | import { call, cps } from 'redux-saga/effects' 4 | 5 | import { handleEmit, socketEmit } from '../../src' 6 | 7 | describe('handleEmit', () => { 8 | const action = { type: 'TEST', payload: { foo: 'bar' } } 9 | const event = 'dispatch' 10 | const socket = { 11 | autoReconnectOptions: { 12 | initialDelay: 1000, randomness: 1000, multiplier: 1.5, maxDelay: 3000, 13 | }, 14 | emit() {}, 15 | getState() { 16 | return 'open' 17 | }, 18 | } 19 | const iterator = handleEmit(socket, socketEmit(action)) 20 | it('should yield a socket.emit cps effect', () => { 21 | expect( 22 | iterator.next().value 23 | ).toEqual( 24 | cps([socket, socket.emit], event, action) 25 | ) 26 | }) 27 | 28 | it('should yield a delay if error happens, not smaller than initialDelay', () => { 29 | expect( 30 | iterator.throw('error').value.CALL.args[0] 31 | ).toBeGreaterThanOrEqualTo( 32 | call(delay, socket.autoReconnectOptions.initialDelay).CALL.args[0] 33 | ) 34 | }) 35 | 36 | it('should yield a delay if error happens, not larger than maxDelay', () => { 37 | iterator.next() 38 | expect( 39 | iterator.throw('error').value.CALL.args[0] 40 | ).toBeLessThanOrEqualTo( 41 | call(delay, socket.autoReconnectOptions.maxDelay).CALL.args[0] 42 | ) 43 | }) 44 | 45 | it('should yield a delay if error happens with maxDelay', () => { 46 | iterator.next() 47 | expect( 48 | iterator.throw('error').value.CALL.args[0] 49 | ).toBeLessThanOrEqualTo( 50 | call(delay, socket.autoReconnectOptions.maxDelay).CALL.args[0] 51 | ) 52 | }) 53 | 54 | it('should rethrow error if it\'s not an SocketCluster TimeoutError') 55 | }) 56 | -------------------------------------------------------------------------------- /test/workers/handleRequest.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import { delay } from 'redux-saga' 3 | import { call, put, race, take } from 'redux-saga/effects' 4 | 5 | import { handleEmit, handleRequest, socketRequest } from '../../src' 6 | 7 | describe('handleRequest', () => { 8 | const type = 'SERVER_REQUEST' 9 | const successType = 'SERVER_SUCCESS' 10 | const failureType = 'SERVER_FAILURE' 11 | const socket = { 12 | emit() {}, 13 | ackTimeout: 100, 14 | } 15 | const action = socketRequest({ 16 | type, 17 | payload: { 18 | successType, 19 | failureType, 20 | }, 21 | }, undefined, undefined, 100) 22 | const { payload } = action 23 | const { timeout, ...requestAction } = action 24 | 25 | const iterator = handleRequest(socket, action) 26 | it('should put the payload on the store', () => { 27 | expect( 28 | iterator.next().value 29 | ).toEqual( 30 | put(payload) 31 | ) 32 | }) 33 | it('should ask handleEmit to send the request payload trough socket', () => { 34 | expect( 35 | iterator.next().value 36 | ).toEqual( 37 | call(handleEmit, socket, requestAction) 38 | ) 39 | }) 40 | it('should start a timeout race', () => { 41 | expect( 42 | iterator.next().value 43 | ).toEqual( 44 | race({ 45 | response: take([successType, failureType]), 46 | timeout: call(delay, timeout), 47 | }) 48 | ) 49 | }) 50 | it('should put a failureType action on the redux store on timeout', () => { 51 | expect( 52 | iterator.next({ timeout: true }).value 53 | ).toEqual( 54 | put({ 55 | type: failureType, 56 | payload: { 57 | error: { 58 | name: 'SocketTimeoutError', 59 | message: 'Socket request timed out waiting for a response', 60 | }, 61 | }, 62 | }) 63 | ) 64 | }) 65 | }) 66 | --------------------------------------------------------------------------------