├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── package.json ├── src ├── _sync │ ├── constants.ts │ ├── diffing-middleware.ts │ ├── find-changes.ts │ ├── get-new-versions.ts │ ├── protocol.ts │ ├── reducer.ts │ └── track-rehydration-middleware.ts ├── client.ts ├── common.ts ├── rpc │ ├── client.ts │ ├── index.ts │ └── server.ts ├── server.ts └── sync.ts ├── test ├── mocks │ └── socket.ts ├── private.d.ts ├── specs │ ├── _sync │ │ ├── find-changes.ts │ │ ├── get-new-versions.ts │ │ ├── middlewares.ts │ │ ├── protocol.ts │ │ └── reducer.ts │ ├── client.ts │ ├── rpc │ │ ├── client.ts │ │ └── server.ts │ ├── server.ts │ └── sync.ts └── tsconfig.base.json ├── tsconfig.base.json ├── tslint.json ├── typings.d.ts └── typings.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .tmp/ 2 | dist/ 3 | lib/ 4 | node_modules/ 5 | test/bin/ 6 | typings/**/* 7 | tsconfig.json 8 | npm-debug.log 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .tmp/ 2 | dist/ 3 | src/ 4 | node_modules/ 5 | typings/**/* 6 | tsconfig.json 7 | npm-debug.log 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "stable" 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #redux-websocket 2 | [![Build Status](https://travis-ci.org/Pajn/redux-websocket.svg?branch=master)](https://travis-ci.org/Pajn/redux-websocket) 3 | 4 | This module provides communication over a WebSocket between the server and clients in a 5 | fullstack Redux application. 6 | 7 | It consists of three different concepts. While they are intended to be used together they 8 | are all self standing and can be used in any combination you like and you can even plugin 9 | in your own or replace the bundled ones. 10 | 11 | ### Action delivery 12 | Actions can be declared to be sent over the WebSocket using meta information. 13 | This makes it possible to communicate using normal Redux actions and as the information 14 | as stored in a separate object an application can even be retrofitted to dispatch actions 15 | over the network. 16 | 17 | ### Remote Procedure Calls 18 | Function calls from the client to the server is used as action creators in cases where the 19 | server need to do validations or similar things that the client can not be trusted with. 20 | These are configured using decorators which makes them as easy to both write and call as 21 | any other function. 22 | 23 | ### State synchronization 24 | If you simply want same of the state exactly the same on the server and the client a store 25 | enhancer can be configured to automatically dispatch diffs to the client when the server state 26 | changes. It also keeps track of versions so that a client get the newest state when it connects. 27 | 28 | ## Usage 29 | Set up a server by instantiating `WebSocketServer` from `redux-websocket/lib/server`. 30 | Set up a client by instantiating `WebSocketClient` from `redux-websocket/lib/client`. 31 | 32 | ### Action delivery 33 | Apply `websocketMiddleware` from both `redux-websocket/lib/server` and 34 | `redux-websocket/lib/client` on the corresponding side. 35 | 36 | TODO: More description and detail pages 37 | 38 | ## Authorization 39 | Currently not implemented 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-websocket", 3 | "version": "0.2.0-dev.7", 4 | "description": "Aid for fullstack Redux applications", 5 | "main": "index.js", 6 | "scripts": { 7 | "configure": "npm-run-all configure:*", 8 | "configure:tsdm": "tsdm rewire", 9 | "configure:mkdir-tmp": "mkdir -p .tmp", 10 | "configure:mkdir-test": "mkdir -p test/node_modules", 11 | "configure:clean": "rm -f test/node_modules/redux-websocket", 12 | "configure:link-tests": "ln -s ../.. test/node_modules/redux-websocket", 13 | "prepublish": "npm-run-all prepublish:*", 14 | "prepublish:configure": "npm run configure", 15 | "prepublish:copy-base-config": "cp tsconfig.base.json tsconfig.json", 16 | "prepublish:copy-test-base-config": "cp test/tsconfig.base.json test/tsconfig.json", 17 | "prepublish:tsconfig": "tsconfig -i 2", 18 | "prepublish:test-tsconfig": "cd test; tsconfig -i 2", 19 | "prepublish:typescript": "tsc", 20 | "prepublish:babel": "babel --out-dir lib .tmp --presets es2015", 21 | "watch": "npm-run-all --parallel watch:*", 22 | "watch:typescript": "tsc --watch", 23 | "watch:babel": "babel --out-dir lib .tmp --presets es2015 --watch", 24 | "test": "tsc -p test && mocha --harmony -r babel-polyfill --recursive test/bin/specs", 25 | "test-watch": "tsc -p test --watch & mocha --harmony -r babel-polyfill --watch --recursive test/bin/specs" 26 | }, 27 | "author": "", 28 | "license": "Apache-2.0", 29 | "dependencies": { 30 | "node-uuid": "^1.4.7", 31 | "redux-decorated": "^0.2.0-dev.3" 32 | }, 33 | "peerDependencies": { 34 | "redux": "3.x" 35 | }, 36 | "devDependencies": { 37 | "babel-cli": "^6.3.17", 38 | "babel-core": "^6.4.5", 39 | "babel-polyfill": "^6.5.0", 40 | "babel-preset-es2015": "^6.3.13", 41 | "babel-runtime": "^6.5.0", 42 | "chai": "^3.5.0", 43 | "chai-as-promised": "^5.2.0", 44 | "mocha": "^2.4.5", 45 | "mock-functions": "^0.1.1", 46 | "npm-run-all": "^1.4.0", 47 | "redux": "^3.3.1", 48 | "retyped-chai-as-promised-tsd-ambient": "^0.0.0-1", 49 | "retyped-chai-tsd-ambient": "^3.4.0-0", 50 | "retyped-mocha-tsd-ambient": "^2.2.5-0", 51 | "retyped-node-tsd-ambient": "^1.5.3-0", 52 | "retyped-node-uuid-tsd-ambient": "0.0.0-0", 53 | "retyped-promises-a-plus-tsd-ambient": "^0.0.0-0", 54 | "retyped-redux-tsd-ambient": "^1.0.0-0", 55 | "retyped-websocket-tsd-ambient": "^0.0.0-0", 56 | "tsconfig-glob": "^0.3.3", 57 | "tsdm": "0.1.0-3", 58 | "tslint": "^3.3.0", 59 | "typescript": "^1.9.0-dev.20160305" 60 | }, 61 | "typescript": { 62 | "definition": "typings.d.ts" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/_sync/constants.ts: -------------------------------------------------------------------------------- 1 | import {ClientProtocol, ServerProtocol, WebSocketConnection} from '../common' 2 | 3 | export const dispatchAction = 'dispatchAction' 4 | export const checkVersion = 'checkVersion' 5 | 6 | export type Settings = { 7 | socket: WebSocketConnection 8 | keys: string[] 9 | skipVersion?: string[] 10 | waitForAction?: string 11 | } 12 | 13 | export type InitialSyncPayload = { 14 | versions: {[key: string]: number} 15 | state: Object 16 | } 17 | 18 | export type SyncClientProtocol = ClientProtocol & { 19 | setRehydrationCompleted(): void 20 | maybeCheckVersion(): void 21 | } 22 | 23 | export type SyncServerProtocol = ServerProtocol & { 24 | sendToStoreClients(message): void 25 | } 26 | 27 | export const actions = { 28 | initialSyncedState: { 29 | type: 'initialSyncedState', 30 | meta: { 31 | toClient: true, 32 | }, 33 | }, 34 | updateSyncedState: { 35 | type: 'updateSyncedState', 36 | meta: { 37 | toClient: true, 38 | }, 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /src/_sync/diffing-middleware.ts: -------------------------------------------------------------------------------- 1 | import {actions, dispatchAction, Settings, SyncServerProtocol} from './constants' 2 | import {findVersionedChanges} from './find-changes' 3 | 4 | export const diffingMiddleware = ({keys, skipVersion}: Settings, protocol: SyncServerProtocol) => 5 | store => next => action => { 6 | const oldState = store.getState() 7 | const returnValue = next(action) 8 | const newState = store.getState() 9 | 10 | const updates = findVersionedChanges(newState, oldState, keys) 11 | 12 | if (updates.length) { 13 | protocol.sendToStoreClients({ 14 | type: dispatchAction, 15 | payload: Object.assign({payload: updates}, actions.updateSyncedState), 16 | }) 17 | } 18 | 19 | return returnValue 20 | } 21 | -------------------------------------------------------------------------------- /src/_sync/find-changes.ts: -------------------------------------------------------------------------------- 1 | import {removeIn, updateIn} from 'redux-decorated' 2 | 3 | export type Changes = Array<{ 4 | path: string[] 5 | value?: any 6 | removed?: boolean 7 | }> 8 | 9 | export type VersionedChanges = Array<{ 10 | key: string 11 | changes: Changes 12 | version: number 13 | }> 14 | 15 | export function findChanges(newState = {}, oldState = {}, path = []): Changes { 16 | const newKeys = Object.keys(newState) 17 | const oldKeys = Object.keys(oldState) 18 | 19 | let newChanges = [] 20 | 21 | for (const key of newKeys) { 22 | 23 | if (newChanges.length > 2 && newChanges.length / newKeys.length > 0.4) { 24 | break 25 | } 26 | 27 | if (newState[key] !== oldState[key]) { 28 | if (typeof newState[key] !== 'object' || newState[key] === null || 29 | typeof oldState[key] !== 'object' || oldState[key] === null || 30 | typeof oldState[key] === undefined) { 31 | newChanges.push({ 32 | path: [...path, key], 33 | value: newState[key], 34 | }) 35 | } else { 36 | newChanges = newChanges.concat( 37 | findChanges(newState[key], oldState[key], [...path, key]) 38 | ) 39 | } 40 | } 41 | } 42 | 43 | for (const key of oldKeys) { 44 | 45 | if (newChanges.length > 2 && newChanges.length / newKeys.length > 0.4) { 46 | break 47 | } 48 | 49 | if (newState[key] === undefined) { 50 | newChanges.push({ 51 | path: [...path, key], 52 | removed: true, 53 | }) 54 | } 55 | } 56 | 57 | if (newChanges.length > 2 && newChanges.length / newKeys.length > 0.4) { 58 | return [{path, value: newState}] 59 | } 60 | 61 | return newChanges 62 | } 63 | 64 | export function findVersionedChanges(newState, oldState, keysToSync): VersionedChanges { 65 | if (!newState || !oldState) return [] 66 | 67 | const newVersions = newState.versions || {} 68 | const updates = [] 69 | 70 | for (const key of keysToSync) { 71 | const changes = findChanges(newState[key], oldState[key]) 72 | if (changes.length > 0) { 73 | updates.push({key, changes, version: newVersions[key]}) 74 | } 75 | } 76 | 77 | return updates; 78 | } 79 | 80 | export function applyChanges(oldState, versionedChanges: VersionedChanges, keysToSync) { 81 | const stateVersions = oldState['versions'] || {} 82 | let shouldCheckVersions = false 83 | let state = oldState 84 | 85 | versionedChanges = versionedChanges.filter(({key}) => keysToSync.indexOf(key) !== -1) 86 | 87 | for (const {key, version, changes} of versionedChanges) { 88 | if (version !== stateVersions[key] + 1) { 89 | shouldCheckVersions = true 90 | continue 91 | } 92 | 93 | state = updateIn(['versions', key], version, state) 94 | 95 | for (const {path, value, removed} of changes) { 96 | state = removed 97 | ? removeIn([key, ...path], state) 98 | : updateIn([key, ...path], value, state) 99 | } 100 | } 101 | 102 | return {shouldCheckVersions, state} 103 | } 104 | -------------------------------------------------------------------------------- /src/_sync/get-new-versions.ts: -------------------------------------------------------------------------------- 1 | export function getNewVersions(clientVersions, getState: () => any, skipVersion: string[]) { 2 | const state = getState() 3 | const stateVersions = state.versions || {} 4 | const newVersions = {versions: {}, state: {}} 5 | let updated = false 6 | 7 | Object.keys(stateVersions).forEach(key => { 8 | if (stateVersions[key] !== clientVersions[key] || 9 | // If there exists no version we need to push out the initial state 10 | clientVersions[key] === 0) { 11 | newVersions.versions[key] = stateVersions[key] 12 | newVersions.state[key] = state[key] 13 | updated = true 14 | } 15 | }) 16 | 17 | if (skipVersion) { 18 | skipVersion.forEach(key => { 19 | newVersions.state[key] = state[key] 20 | updated = true 21 | }) 22 | } 23 | 24 | return updated && newVersions 25 | } 26 | -------------------------------------------------------------------------------- /src/_sync/protocol.ts: -------------------------------------------------------------------------------- 1 | import { 2 | actions, 3 | checkVersion, 4 | dispatchAction, 5 | SyncClientProtocol, 6 | SyncServerProtocol, 7 | } from './constants' 8 | import {getNewVersions} from './get-new-versions' 9 | 10 | export type CheckVersionFunction = ( 11 | getState: () => any, 12 | clientVersions, 13 | respond: (message) => void 14 | ) => void 15 | 16 | export function checkVersionFunction(skipVersion: string[]): CheckVersionFunction { 17 | return (getState, clientVersions, respond) => { 18 | const newVersions = getNewVersions(clientVersions, getState, skipVersion) 19 | 20 | if (newVersions) { 21 | respond({ 22 | type: dispatchAction, 23 | payload: Object.assign({payload: newVersions}, actions.initialSyncedState), 24 | }) 25 | } 26 | } 27 | } 28 | 29 | export function createClientProtocol( 30 | getState: () => any, 31 | dispatch: (action) => void 32 | ) { 33 | let rehydrationCompleted = false 34 | let webSocketOpened = false 35 | 36 | const protocol: SyncClientProtocol = { 37 | onopen() { 38 | webSocketOpened = true 39 | this.maybeCheckVersion() 40 | }, 41 | 42 | onmessage({type, payload}, respond) { 43 | switch (type) { 44 | case dispatchAction: 45 | if (actions[payload.type]) { 46 | dispatch(payload) 47 | } 48 | break 49 | } 50 | }, 51 | 52 | setRehydrationCompleted() { 53 | rehydrationCompleted = true 54 | }, 55 | 56 | maybeCheckVersion() { 57 | if (webSocketOpened && rehydrationCompleted) { 58 | this.send({type: checkVersion, payload: {versions: getState().versions}}) 59 | } 60 | }, 61 | } 62 | 63 | return protocol 64 | } 65 | 66 | export function createServerProtocol( 67 | checkVersionFunction: CheckVersionFunction, 68 | getState: () => any 69 | ) { 70 | const connections = {} 71 | 72 | const protocol: SyncServerProtocol = { 73 | onclose(connectionId) { 74 | delete connections[connectionId] 75 | }, 76 | 77 | onmessage({type, payload}, respond) { 78 | switch (type) { 79 | case checkVersion: 80 | checkVersionFunction(getState, payload.versions, respond) 81 | break 82 | } 83 | }, 84 | 85 | sendToStoreClients(message) { 86 | Object.keys(connections).forEach(connectionId => this.sendTo(connectionId, message)) 87 | }, 88 | } 89 | 90 | return protocol 91 | } 92 | 93 | -------------------------------------------------------------------------------- /src/_sync/reducer.ts: -------------------------------------------------------------------------------- 1 | import {updateIn} from 'redux-decorated' 2 | import {actions, InitialSyncPayload, Settings, SyncClientProtocol} from './constants' 3 | import {applyChanges} from './find-changes' 4 | 5 | export const syncReducer = ( 6 | {keys, skipVersion}: Settings, 7 | protocol: SyncClientProtocol, 8 | reducer 9 | ) => { 10 | function maintainVersion(key) { 11 | return !skipVersion || skipVersion.indexOf(key) === -1 12 | } 13 | 14 | return (actualState, action) => { 15 | const oldState = actualState || {} 16 | const stateVersions = oldState['versions'] || {} 17 | 18 | switch (action.type) { 19 | case actions.initialSyncedState.type: 20 | const {state: initialState, versions} = action.payload as InitialSyncPayload 21 | 22 | return Object.assign({}, oldState, initialState, { 23 | versions: Object.assign({}, oldState['versions'], versions) 24 | }) 25 | 26 | case actions.updateSyncedState.type: 27 | const {shouldCheckVersions, state} = applyChanges(oldState, action.payload, keys) 28 | 29 | if (shouldCheckVersions) { 30 | protocol.maybeCheckVersion() 31 | } 32 | 33 | return state 34 | 35 | default: 36 | let newState = reducer(actualState, action) 37 | 38 | for (const key of keys) { 39 | if (oldState[key] !== newState[key] && maintainVersion(key)) { 40 | const nextVersion = (stateVersions[key] || 0) + 1 41 | newState = updateIn(['versions', key], nextVersion, newState) 42 | } 43 | } 44 | 45 | return newState 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/_sync/track-rehydration-middleware.ts: -------------------------------------------------------------------------------- 1 | import {Settings, SyncClientProtocol} from './constants' 2 | 3 | export const trackRehydrationMiddleware = ( 4 | {waitForAction}: Settings, 5 | protocol: SyncClientProtocol 6 | ) => store => next => { 7 | if (!waitForAction) { 8 | protocol.setRehydrationCompleted() 9 | protocol.maybeCheckVersion() 10 | } 11 | 12 | return action => { 13 | if (waitForAction && action.type === waitForAction) { 14 | protocol.setRehydrationCompleted() 15 | protocol.maybeCheckVersion() 16 | } 17 | 18 | return next(action) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import {Action, Actions, ClientProtocol, WebSocketConnection} from './common' 2 | 3 | export class WebSocketClient implements WebSocketConnection { 4 | readonly isServer = false 5 | protocols = {} 6 | socket: WebSocket 7 | open = false 8 | private messagesToSend = [] 9 | private timeoutId 10 | 11 | constructor({url, onOpen}: {url: string, onOpen: Function}) { 12 | this.connect(url, onOpen) 13 | } 14 | 15 | registerProtocol(name: string, protocol: ClientProtocol) { 16 | protocol.send = message => this.send(JSON.stringify({type: name, data: message})) 17 | 18 | this.protocols[name] = protocol 19 | 20 | if (this.open && protocol.onopen) { 21 | protocol.onopen() 22 | } 23 | } 24 | 25 | send(message: string) { 26 | if (this.socket.readyState === this.socket.OPEN) { 27 | this.socket.send(message) 28 | } else { 29 | if (this.timeoutId) clearTimeout(this.timeoutId) 30 | 31 | this.timeoutId = setTimeout(() => this.sendBuffered(), 500) 32 | this.messagesToSend.push(message) 33 | } 34 | } 35 | 36 | sendBuffered() { 37 | if (this.socket.readyState === this.socket.OPEN) { 38 | this.messagesToSend.forEach(message => this.socket.send(message)) 39 | this.messagesToSend = [] 40 | } else { 41 | if (this.timeoutId) clearTimeout(this.timeoutId) 42 | 43 | this.timeoutId = setTimeout(() => this.sendBuffered(), 500) 44 | } 45 | } 46 | 47 | private connect(url, onOpen) { 48 | this.socket = new WebSocket(url, 'redux-websocket') 49 | 50 | this.socket.onopen = () => { 51 | this.open = true 52 | if (onOpen) { 53 | onOpen() 54 | } 55 | Object.keys(this.protocols).forEach(protocolName => { 56 | const protocol = this.protocols[protocolName] 57 | 58 | if (protocol.onopen) { 59 | protocol.onopen() 60 | } 61 | }) 62 | } 63 | 64 | this.socket.onclose = () => { 65 | setTimeout(() => this.connect(url, onOpen), 1000) 66 | } 67 | 68 | this.socket.onmessage = event => { 69 | const message = JSON.parse(event.data) 70 | const protocol = this.protocols[message.type] 71 | if (protocol) { 72 | protocol.onmessage(message.data) 73 | } 74 | } 75 | } 76 | } 77 | 78 | type Settings = { 79 | actions?: Actions 80 | socket: WebSocketClient 81 | id?: string 82 | } 83 | 84 | export const websocketMiddleware = ({socket, actions, id}: Settings) => store => next => { 85 | if (!actions) { 86 | actions = {} 87 | } 88 | 89 | const protocol: ClientProtocol = { 90 | onmessage({action}) { 91 | next(action) 92 | }, 93 | } 94 | 95 | socket.registerProtocol(`action-${id}`, protocol) 96 | 97 | return (action: Action) => { 98 | const meta = action.meta || (actions[action.type] && actions[action.type].meta) 99 | if (meta && meta.toServer) { 100 | protocol.send({action}) 101 | } 102 | return next(action) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | export interface Protocol { 2 | onopen?: () => void 3 | send?: (message: Object) => void 4 | } 5 | 6 | export interface ClientProtocol extends Protocol { 7 | onopen?: () => void 8 | onmessage: ( 9 | message: any, 10 | respond: (message: Object) => void 11 | ) => void 12 | } 13 | 14 | export interface ServerProtocol extends Protocol { 15 | onconnection?: (connectionId: string) => void 16 | onclose?: (connectionId: string) => void 17 | onmessage: ( 18 | message: any, 19 | respond: (message: Object) => void, 20 | connectionId: string 21 | ) => void 22 | 23 | send?: (message: Object, predicate?: (connectionId: string) => boolean) => void 24 | sendTo?: ( 25 | connectionId: string, 26 | message: Object, 27 | predicate?: (connectionId: string) => boolean 28 | ) => void 29 | } 30 | 31 | export interface WebSocketConnection { 32 | isServer: boolean 33 | registerProtocol(name: string, protocol: Protocol): void 34 | } 35 | 36 | export interface Actions { 37 | [type: string]: Action 38 | } 39 | 40 | export enum ClientMode { 41 | broadcast, 42 | sameStore, 43 | } 44 | 45 | export interface Action { 46 | type: string 47 | meta?: { 48 | toServer?: boolean|((action, connectionId: string) => boolean) 49 | toClient?: boolean|((action, connectionId: string) => boolean) 50 | toClientMode?: ClientMode 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/rpc/client.ts: -------------------------------------------------------------------------------- 1 | import {nameSymbol, timeoutSymbol} from './index' 2 | import {WebSocketClient} from '../client' 3 | import {ClientProtocol} from '../common' 4 | 5 | export type RpcClientSettings = { 6 | /** 7 | * Optional id to use for connecting to an RPC server with a non-empty id 8 | */ 9 | id?: string|number 10 | socket: WebSocketClient 11 | rpcObjects: Object[] 12 | } 13 | 14 | export function createRpcClient({socket, id, rpcObjects}: RpcClientSettings) { 15 | let nextCallId = 0 16 | 17 | const waitingCalls = {} 18 | const webSocketProtocol: ClientProtocol = { 19 | onmessage({id, error, value}) { 20 | const call = waitingCalls[id] 21 | if (call) { 22 | call(error, value) 23 | } 24 | }, 25 | } 26 | 27 | socket.registerProtocol(`rpc${id || ''}`, webSocketProtocol) 28 | 29 | rpcObjects.forEach(rpcObject => { 30 | const constructor = rpcObject.constructor 31 | const className = constructor[nameSymbol] || constructor.name 32 | const timeout = constructor[timeoutSymbol] === undefined ? 10000 : constructor[timeoutSymbol] 33 | const methods = Object.getOwnPropertyNames(constructor.prototype) 34 | .filter(key => key !== 'constructor') 35 | .filter(key => typeof constructor.prototype[key] === 'function') 36 | 37 | methods.forEach(methodName => { 38 | rpcObject[methodName] = (...args) => { 39 | const callId = nextCallId++ 40 | 41 | webSocketProtocol.send({ 42 | id: callId, 43 | className, 44 | methodName, 45 | args, 46 | }) 47 | 48 | return new Promise((resolve, reject) => { 49 | const timeoutId = setTimeout(() => { 50 | delete waitingCalls[callId] 51 | reject('timeout reached') 52 | }, timeout) 53 | 54 | waitingCalls[callId] = (error, value) => { 55 | clearTimeout(timeoutId) 56 | delete waitingCalls[callId] 57 | if (error) { 58 | reject(error) 59 | } else { 60 | resolve(value) 61 | } 62 | } 63 | }) 64 | } 65 | }) 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /src/rpc/index.ts: -------------------------------------------------------------------------------- 1 | export type RpcSettings = { 2 | /** 3 | * Name for the RPC namespace, if not specified the classname will be used. 4 | */ 5 | name?: string 6 | /** 7 | * Timeout in ms for how long the client will wait for a response before rejecting. 8 | * Defaults to 10000. 9 | */ 10 | timeout?: number 11 | } 12 | 13 | export type RpcContext = { 14 | connectionId: string 15 | } 16 | 17 | export const nameSymbol = Symbol('name') 18 | export const timeoutSymbol = Symbol('timeout') 19 | 20 | export function clientError(message: string) { 21 | return { 22 | name: 'clientError', 23 | message: message, 24 | clientError: message, 25 | } 26 | } 27 | 28 | export function remoteProcedures({name, timeout = 10000}: RpcSettings = {}): ClassDecorator { 29 | return target => { 30 | target[nameSymbol] = name 31 | target[timeoutSymbol] = timeout 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/rpc/server.ts: -------------------------------------------------------------------------------- 1 | import {clientError, nameSymbol, RpcContext} from './index' 2 | import {ServerProtocol} from '../common' 3 | import {WebSocketServer} from '../server' 4 | export {clientError} 5 | 6 | export type RpcServerSettings = { 7 | /** 8 | * Optional id to use when handling multiple RPC servers on a single WebSocketServer. 9 | * The same id must be specified on the client. 10 | */ 11 | id?: string|number 12 | socket: WebSocketServer 13 | rpcObjects: Object[] 14 | logger?: { 15 | info(message?: any, ...optionalParams: any[]): void 16 | warn(message?: any, ...optionalParams: any[]): void 17 | } 18 | } 19 | 20 | export function createRpcServer({socket, id, rpcObjects, logger}: RpcServerSettings) { 21 | const procedures = {} 22 | const rpcId = `rpc${id || ''}` 23 | 24 | const webSocketProtocol: ServerProtocol = { 25 | async onmessage({id, className, methodName, args}, respond, connectionId): Promise { 26 | const object = procedures[className] 27 | if (!object) return respond({id, error: 'no such class'}) 28 | const procedure = object[methodName] 29 | if (!procedure) return respond({id, error: 'no such method'}) 30 | 31 | try { 32 | const context: RpcContext = {connectionId} 33 | const value = await procedure.apply(context, args) 34 | respond({id, value}) 35 | } catch (error) { 36 | if (logger) { 37 | logger.warn(`${rpcId}: ${className}.${methodName}:`, error, error && error.stack) 38 | } 39 | error = (error && error.clientError) || 'Unkown Error' 40 | respond({id, error}) 41 | } 42 | }, 43 | } 44 | 45 | socket.registerProtocol(rpcId, webSocketProtocol) 46 | 47 | rpcObjects.forEach(rpcObject => { 48 | const constructor = rpcObject.constructor 49 | const className = constructor[nameSymbol] || constructor.name 50 | 51 | if (logger) { 52 | logger.info(`${rpcId}: register [${className}]`) 53 | } 54 | 55 | procedures[className] = rpcObject 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import * as uuid from 'node-uuid' 2 | import {updateIn} from 'redux-decorated' 3 | import {server as WebSocket, connection} from 'websocket' 4 | import {Action, Actions, ClientMode, ServerProtocol, WebSocketConnection} from './common' 5 | 6 | export class WebSocketServer implements WebSocketConnection { 7 | readonly isServer = true 8 | readonly connections: {[connectionId: string]: connection} = {} 9 | protocols = {} 10 | 11 | constructor(server: WebSocket) { 12 | server.on('request', request => { 13 | const connection = request.accept('redux-websocket', request.origin) 14 | const connectionId = uuid.v1() 15 | this.connections[connectionId] = connection 16 | 17 | Object.keys(this.protocols).forEach(protocolName => { 18 | const protocol = this.protocols[protocolName] 19 | if (protocol.onconnection) { 20 | protocol.onconnection(connectionId) 21 | } 22 | }) 23 | 24 | connection.on('message', message => { 25 | try { 26 | if (message.type === 'utf8') { 27 | const data = JSON.parse(message.utf8Data) 28 | 29 | const protocol = this.protocols[data.type] 30 | if (protocol) { 31 | protocol.onmessage( 32 | data.data, 33 | message => connection.send(JSON.stringify({type: data.type, data: message})), 34 | connectionId 35 | ) 36 | } 37 | } 38 | } catch (e) { 39 | connection.send(JSON.stringify({error: e && e.message})) 40 | } 41 | }) 42 | 43 | connection.on('close', () => { 44 | delete this.connections[connectionId] 45 | 46 | Object.keys(this.protocols).forEach(protocolName => { 47 | const protocol = this.protocols[protocolName] 48 | if (protocol.onclose) { 49 | protocol.onclose(connectionId) 50 | } 51 | }) 52 | }) 53 | }) 54 | } 55 | 56 | registerProtocol(name: string, protocol: ServerProtocol) { 57 | protocol.send = (message, predicate) => { 58 | Object.keys(this.connections).forEach(connectionId => { 59 | if (!predicate || predicate(connectionId)) { 60 | this.connections[connectionId].send(JSON.stringify({type: name, data: message})) 61 | } 62 | }) 63 | } 64 | protocol.sendTo = (connectionId, message, predicate) => { 65 | if (!predicate || predicate(connectionId)) { 66 | this.connections[connectionId].send(JSON.stringify({type: name, data: message})) 67 | } 68 | } 69 | this.protocols[name] = protocol 70 | } 71 | } 72 | 73 | type Settings = { 74 | actions: Actions 75 | socket: WebSocketServer 76 | connections?: {[connectionId: string]: any} 77 | id?: string 78 | } 79 | 80 | export const websocketMiddleware = ({socket, actions, connections = {}, id}: Settings) => 81 | store => next => { 82 | 83 | const protocol: ServerProtocol = { 84 | onconnection(connectionId) { 85 | connections[connectionId] = true 86 | }, 87 | 88 | onclose(connectionId) { 89 | delete connections[connectionId] 90 | }, 91 | 92 | onmessage({action}, _, connectionId) { 93 | if (actions[action.type]) { 94 | const {meta} = actions[action.type] 95 | if (meta && meta.toServer) { 96 | const {toServer} = meta 97 | if (typeof toServer !== 'function' || toServer(action, connectionId)) { 98 | next(action) 99 | } 100 | } 101 | } 102 | }, 103 | } 104 | 105 | socket.registerProtocol(`action-${id}`, protocol) 106 | 107 | return (action: Action) => { 108 | const meta = action.meta || (actions[action.type] && actions[action.type].meta) 109 | if (meta && meta.toClient) { 110 | const {toClient, toClientMode} = meta 111 | if (toClientMode === ClientMode.sameStore) { 112 | Object.keys(connections).forEach(connectionId => { 113 | protocol.sendTo( 114 | connectionId, 115 | {action: updateIn(['meta', 'fromServer'], true, action)}, 116 | typeof toClient === 'function' && toClient.bind(null, action) 117 | ) 118 | }) 119 | } else if (toClientMode === ClientMode.broadcast) { 120 | protocol.send( 121 | {action: updateIn(['meta', 'fromServer'], true, action)}, 122 | typeof toClient === 'function' && toClient.bind(null, action) 123 | ) 124 | } else { 125 | throw Error('toClientMode must be set when toClient is set') 126 | } 127 | } 128 | return next(action) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/sync.ts: -------------------------------------------------------------------------------- 1 | import {Store} from 'redux' 2 | import {Settings} from './_sync/constants' 3 | import {diffingMiddleware} from './_sync/diffing-middleware' 4 | import {checkVersionFunction, createClientProtocol, createServerProtocol} from './_sync/protocol' 5 | import {syncReducer} from './_sync/reducer' 6 | import {trackRehydrationMiddleware} from './_sync/track-rehydration-middleware' 7 | import {ServerProtocol} from './common' 8 | export {Settings} 9 | 10 | export const syncStoreEnhancer = (settings: Settings) => next => (reducer, initialState) => { 11 | let store: Store 12 | const protocol = createClientProtocol( 13 | () => store.getState(), 14 | action => store.dispatch(action) 15 | ) 16 | 17 | reducer = syncReducer(settings, protocol, reducer) 18 | store = next(reducer, initialState) 19 | 20 | const dispatch = trackRehydrationMiddleware(settings, protocol)(store)(store.dispatch) 21 | 22 | settings.socket.registerProtocol('sync', protocol) 23 | 24 | return Object.assign({}, store, {dispatch}) 25 | } 26 | 27 | export function noopReducer(state) { 28 | return state || {} 29 | } 30 | 31 | export function createSyncServer(settings: Settings) { 32 | const connectionToProtocol: {[connectionId: string]: ServerProtocol} = {} 33 | const globalProtocol: ServerProtocol = { 34 | onclose(connectionId: string) { 35 | const protocol = connectionToProtocol[connectionId] 36 | if (protocol) { 37 | connectionToProtocol[connectionId].onclose(connectionId) 38 | delete connectionToProtocol[connectionId] 39 | } 40 | }, 41 | 42 | onmessage(message, respond, connectionId: string) { 43 | const protocol = connectionToProtocol[connectionId] 44 | if (protocol) { 45 | connectionToProtocol[connectionId].onmessage(message, respond, connectionId) 46 | } 47 | }, 48 | } 49 | 50 | settings.socket.registerProtocol('sync', globalProtocol) 51 | 52 | return { 53 | createSyncMiddleware() { 54 | let protocol 55 | 56 | function addConnection(connectionId) { 57 | connectionToProtocol[connectionId] = protocol 58 | } 59 | 60 | const syncMiddleware = store => next => { 61 | protocol = createServerProtocol( 62 | checkVersionFunction(settings.skipVersion), 63 | () => store.getState() 64 | ) 65 | 66 | return diffingMiddleware(settings, protocol)(store)(next) 67 | } 68 | 69 | return {addConnection, syncMiddleware} 70 | }, 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /test/mocks/socket.ts: -------------------------------------------------------------------------------- 1 | import {WebSocketClient} from 'redux-websocket/lib/client' 2 | import {Protocol} from 'redux-websocket/lib/common' 3 | import {WebSocketServer} from 'redux-websocket/lib/server' 4 | 5 | export function createMockSocket(): any { 6 | return { 7 | protocols: {}, 8 | 9 | registerProtocol(name: string, protocol: Protocol) { 10 | this.protocols[name] = protocol 11 | }, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/private.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'redux-websocket/lib/_sync/constants' { 2 | import {createActions} from 'redux-decorated' 3 | import {Protocol, WebSocketConnection} from 'redux-websocket/lib/common' 4 | 5 | export const dispatchAction 6 | export const checkVersion 7 | 8 | export type Settings = { 9 | socket: WebSocketConnection, 10 | keys: string[], 11 | skipVersion?: string[], 12 | waitForAction?: string, 13 | } 14 | 15 | export type InitialSyncPayload = { 16 | versions: {[key: string]: number}, 17 | state: Object, 18 | } 19 | 20 | export type SyncProtocol = Protocol & { 21 | setRehydrationCompleted(): void, 22 | maybeCheckVersion(): void, 23 | } 24 | 25 | export const actions: { 26 | initialSyncedState: {type: string}, 27 | updateSyncedState: {type: string}, 28 | } 29 | } 30 | 31 | 32 | declare module 'redux-websocket/lib/_sync/find-changes' { 33 | type Changes = Array<{ 34 | path: string[], 35 | value?: any, 36 | removed?: boolean, 37 | }> 38 | 39 | type VersionedChanges = Array<{ 40 | key: string, 41 | changes: Changes, 42 | version: number, 43 | }> 44 | 45 | export function findChanges(newState?, oldState?, path?): Object; 46 | export function findVersionedChanges(newState, oldState, keysToSync): VersionedChanges 47 | export function applyChanges(oldState, versionedChanges: VersionedChanges, keysToSync): { 48 | shouldCheckVersions: boolean, 49 | state: any, 50 | } 51 | } 52 | 53 | declare module 'redux-websocket/lib/_sync/get-new-versions' { 54 | export function getNewVersions(clientVersions, getState: () => any, skipVersion: string[]): Object 55 | } 56 | 57 | declare module 'redux-websocket/lib/_sync/middlewares' { 58 | import {Settings} from 'redux-websocket/lib/sync' 59 | type Middleware = (store) => (next) => (action) => void 60 | 61 | export function trackRehydrationMiddleware(s: {waitForAction?: string}, protocol: any): Middleware 62 | export function diffingMiddleware(s: {keys: string[], skipVersion?: string[]}, protocol: any): Middleware 63 | } 64 | 65 | declare module 'redux-websocket/lib/_sync/protocol' { 66 | type CheckVersionFunction = ( 67 | getState: () => any, 68 | clientVersions, 69 | respond: (message) => void 70 | ) => void 71 | 72 | export function checkVersionFunction(skipVersion: string[]): CheckVersionFunction 73 | 74 | export function createClientProtocol( 75 | getState: () => any, 76 | dispatch: (action) => void 77 | ) 78 | 79 | export function createServerProtocol( 80 | checkVersionFunction: CheckVersionFunction, 81 | getState: () => any 82 | ) 83 | } 84 | 85 | declare module 'redux-websocket/lib/_sync/reducer' { 86 | export function syncReducer(s: {keys: string[], skipVersion?: string[]}, protocol: any, reducer): 87 | (state, action) => any 88 | } 89 | -------------------------------------------------------------------------------- /test/specs/_sync/find-changes.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import {expect} from 'chai' 4 | import {findChanges} from 'redux-websocket/lib/_sync/find-changes' 5 | 6 | 7 | describe('sync/find-changes', () => { 8 | describe('findChanges', () => { 9 | it('should find added properties', () => { 10 | const changes = findChanges({prop: 1}, {}) 11 | 12 | expect(changes).to.deep.equal([{path: ['prop'], value: 1}]) 13 | }) 14 | 15 | it('should find modified properties', () => { 16 | const changes = findChanges({prop: 2}, {prop: 1}) 17 | 18 | expect(changes).to.deep.equal([{path: ['prop'], value: 2}]) 19 | }) 20 | 21 | it('should find removed properties', () => { 22 | const changes = findChanges({}, {prop: 1}) 23 | 24 | expect(changes).to.deep.equal([{path: ['prop'], removed: true}]) 25 | }) 26 | 27 | it('should find added nested properties', () => { 28 | const changes = findChanges({nested: {prop: 1}}, {nested: {}}) 29 | 30 | expect(changes).to.deep.equal([{path: ['nested', 'prop'], value: 1}]) 31 | }) 32 | 33 | it('should find modified nested properties', () => { 34 | const changes = findChanges({nested: {prop: 2}}, {nested: {prop: 1}}) 35 | 36 | expect(changes).to.deep.equal([{path: ['nested', 'prop'], value: 2}]) 37 | }) 38 | 39 | it('should find removed nested properties', () => { 40 | const changes = findChanges({nested: {}}, {nested: {prop: 1}}) 41 | 42 | expect(changes).to.deep.equal([{path: ['nested', 'prop'], removed: true}]) 43 | }) 44 | 45 | it('should simplify complex changes', () => { 46 | const changes = findChanges( 47 | {nested: {a: 5, b: 6, c: 7, d: 3}}, 48 | {nested: {a: 1, b: 2, c: 3, d: 4}} 49 | ) 50 | 51 | expect(changes).to.deep.equal([{path: ['nested'], value: {a: 5, b: 6, c: 7, d: 3}}]) 52 | }) 53 | }) 54 | 55 | describe('findVersionedChanges', () => { 56 | }) 57 | 58 | describe('applyChanges', () => { 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /test/specs/_sync/get-new-versions.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import {expect} from 'chai' 4 | import {getNewVersions} from 'redux-websocket/lib/_sync/get-new-versions' 5 | 6 | describe('sync/getNewVersions', () => { 7 | }) 8 | -------------------------------------------------------------------------------- /test/specs/_sync/middlewares.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import {expect} from 'chai' 4 | import {actions, dispatchAction} from 'redux-websocket/lib/_sync/constants' 5 | import {diffingMiddleware, trackRehydrationMiddleware} from 'redux-websocket/lib/_sync/middlewares' 6 | import {createMockFunction} from 'mock-functions' 7 | 8 | describe('sync/middlewares', () => { 9 | describe('diffingMiddleware', () => { 10 | it('should pass through dispatched actions', () => { 11 | const dispatchMock = createMockFunction() 12 | const store = {getState: createMockFunction()} 13 | const dispatch = diffingMiddleware({keys: []}, null)(store)(dispatchMock) 14 | 15 | dispatch({type: 'dispatched'}) 16 | 17 | expect(dispatchMock.calls.length).to.equal(1) 18 | expect(dispatchMock.calls[0].args).to.deep.equal([{type: 'dispatched'}]) 19 | }) 20 | 21 | it('should not send when there are no changes', () => { 22 | const dispatchMock = createMockFunction() 23 | const store = {getState: createMockFunction().returns({})} 24 | const protocol = {send: createMockFunction()} 25 | const dispatch = diffingMiddleware({keys: []}, protocol)(store)(dispatchMock) 26 | 27 | dispatch({type: 'dispatched'}) 28 | 29 | expect(protocol.send.calls.length).to.equal(0) 30 | }) 31 | 32 | it('should send changes in keys to check', () => { 33 | const dispatchMock = createMockFunction() 34 | const store = { 35 | getState: createMockFunction() 36 | .returns(0, {}) 37 | .returns(1, {prop: 'new', versions: {prop: 2}}), 38 | } 39 | const protocol = {sendToStoreClients: createMockFunction()} 40 | const dispatch = diffingMiddleware({keys: ['prop']}, protocol)(store)(dispatchMock) 41 | 42 | dispatch({type: 'dispatched'}) 43 | 44 | expect(protocol.sendToStoreClients.calls.length).to.equal(1) 45 | expect(protocol.sendToStoreClients.calls[0].args).to.deep.equal([{ 46 | type: dispatchAction, 47 | payload: { 48 | type: actions.updateSyncedState.type, 49 | meta: { 50 | toClient: true, 51 | }, 52 | payload: [{ 53 | changes: [{ 54 | path: [], 55 | value: 'new', 56 | }], 57 | key: 'prop', 58 | version: 2, 59 | }], 60 | }, 61 | }]) 62 | }) 63 | 64 | it('should not send changes in keys to ignore', () => { 65 | const dispatchMock = createMockFunction() 66 | const store = {getState: createMockFunction().returns(0, {}).returns(1, {prop: 'new'})} 67 | const protocol = {send: createMockFunction()} 68 | const dispatch = diffingMiddleware({keys: []}, protocol)(store)(dispatchMock) 69 | 70 | dispatch({type: 'dispatched'}) 71 | 72 | expect(protocol.send.calls.length).to.equal(0) 73 | }) 74 | }) 75 | 76 | describe('trackRehydrationMiddleware', () => { 77 | it('should pass through dispatched actions', () => { 78 | const dispatchMock = createMockFunction() 79 | const protocol = { 80 | setRehydrationCompleted: createMockFunction(), 81 | maybeCheckVersion: createMockFunction(), 82 | } 83 | const store = {getState: createMockFunction()} 84 | const dispatch = trackRehydrationMiddleware({}, protocol)(store)(dispatchMock) 85 | 86 | dispatch({type: 'dispatched'}) 87 | 88 | expect(dispatchMock.calls.length).to.equal(1) 89 | expect(dispatchMock.calls[0].args).to.deep.equal([{type: 'dispatched'}]) 90 | }) 91 | 92 | it('should directly call maybeCheckVersion if waitForAction is null', () => { 93 | const protocol = { 94 | setRehydrationCompleted: createMockFunction(), 95 | maybeCheckVersion: createMockFunction(), 96 | } 97 | trackRehydrationMiddleware({waitForAction: null}, protocol)(null)(null) 98 | 99 | expect(protocol.setRehydrationCompleted.calls.length).to.equal(1) 100 | expect(protocol.maybeCheckVersion.calls.length).to.equal(1) 101 | }) 102 | 103 | it('should not call maybeCheckVersion for other actions than waitForAction', () => { 104 | const dispatchMock = createMockFunction() 105 | const protocol = { 106 | setRehydrationCompleted: createMockFunction(), 107 | maybeCheckVersion: createMockFunction(), 108 | } 109 | const dispatch = trackRehydrationMiddleware({waitForAction: 'action'}, protocol)(null)(dispatchMock) 110 | 111 | dispatch({type: 'other'}) 112 | 113 | expect(protocol.setRehydrationCompleted.calls.length).to.equal(0) 114 | expect(protocol.maybeCheckVersion.calls.length).to.equal(0) 115 | }) 116 | 117 | it('should call maybeCheckVersion for waitForAction', () => { 118 | const dispatchMock = createMockFunction() 119 | const protocol = { 120 | setRehydrationCompleted: createMockFunction(), 121 | maybeCheckVersion: createMockFunction(), 122 | } 123 | const dispatch = trackRehydrationMiddleware({waitForAction: 'action'}, protocol)(null)(dispatchMock) 124 | 125 | dispatch({type: 'action'}) 126 | 127 | expect(protocol.setRehydrationCompleted.calls.length).to.equal(1) 128 | expect(protocol.maybeCheckVersion.calls.length).to.equal(1) 129 | }) 130 | }) 131 | }) 132 | -------------------------------------------------------------------------------- /test/specs/_sync/protocol.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import {expect} from 'chai' 4 | import {actions, checkVersion, dispatchAction} from 'redux-websocket/lib/_sync/constants' 5 | import { 6 | checkVersionFunction, 7 | createClientProtocol, 8 | createServerProtocol, 9 | } from 'redux-websocket/lib/_sync/protocol' 10 | import {createMockFunction} from 'mock-functions' 11 | 12 | describe('sync/protocol', () => { 13 | describe('checkVersionFunction', () => { 14 | }) 15 | 16 | describe('createProtocol', () => { 17 | describe('onmessage', () => { 18 | it('should call the checkVersionFunction when checkVersion are received', () => { 19 | const checkVersionMock = createMockFunction() 20 | const getStateMock = createMockFunction() 21 | const protocol = createServerProtocol( 22 | checkVersionMock, 23 | getStateMock 24 | ) 25 | const versions = {} 26 | const respondMock = createMockFunction() 27 | 28 | protocol.onmessage({type: checkVersion, payload: {versions}}, respondMock) 29 | 30 | expect(checkVersionMock.calls.length).to.equal(1) 31 | expect(checkVersionMock.calls[0].args.length).to.equal(3) 32 | expect(checkVersionMock.calls[0].args[0]).to.equal(getStateMock) 33 | expect(checkVersionMock.calls[0].args[1]).to.equal(versions) 34 | expect(checkVersionMock.calls[0].args[2]).to.equal(respondMock) 35 | }) 36 | 37 | it('should dispatch actions defined by this module', () => { 38 | const dispatchMock = createMockFunction() 39 | const protocol = createClientProtocol( 40 | createMockFunction(), 41 | dispatchMock 42 | ) 43 | 44 | for (const type of Object.keys(actions)) { 45 | protocol.onmessage({type: dispatchAction, payload: {type}}) 46 | } 47 | 48 | expect(dispatchMock.calls.length).to.equal(Object.keys(actions).length) 49 | }) 50 | 51 | it('should not dispatch actions not defined by this module', () => { 52 | const dispatchMock = createMockFunction() 53 | const protocol = createClientProtocol( 54 | createMockFunction(), 55 | dispatchMock 56 | ) 57 | 58 | protocol.onmessage({type: dispatchAction, payload: {type: 'action'}}) 59 | 60 | expect(dispatchMock.calls.length).to.equal(0) 61 | }) 62 | }) 63 | 64 | describe('maybeCheckVersion', () => { 65 | it('should call send only after both onopen and setRehydrationCompleted have been called', () => { 66 | const protocol1 = createClientProtocol(createMockFunction().returns({}), null) 67 | const protocol2 = createClientProtocol(createMockFunction().returns({}), null) 68 | protocol1.send = createMockFunction() 69 | protocol2.send = createMockFunction() 70 | 71 | protocol1.maybeCheckVersion() 72 | protocol2.maybeCheckVersion() 73 | 74 | expect(protocol1.send.calls.length).to.equal(0) 75 | expect(protocol2.send.calls.length).to.equal(0) 76 | 77 | protocol1.onopen() 78 | protocol2.setRehydrationCompleted() 79 | protocol1.maybeCheckVersion() 80 | protocol2.maybeCheckVersion() 81 | 82 | expect(protocol1.send.calls.length).to.equal(0) 83 | expect(protocol2.send.calls.length).to.equal(0) 84 | 85 | protocol1.setRehydrationCompleted() 86 | protocol2.onopen() 87 | protocol1.maybeCheckVersion() 88 | 89 | expect(protocol1.send.calls.length).to.equal(1) 90 | expect(protocol2.send.calls.length).to.equal(1) 91 | }) 92 | 93 | it('should send the current versions', () => { 94 | const protocol = createClientProtocol( 95 | createMockFunction().returns({key: {prop: 1}, versions: {key: 1}}), 96 | null 97 | ) 98 | protocol.send = createMockFunction() 99 | protocol.setRehydrationCompleted() 100 | protocol.onopen() 101 | 102 | expect(protocol.send.calls.length).to.equal(1) 103 | expect(protocol.send.calls[0].args).to.deep.equal([{ 104 | type: checkVersion, 105 | payload: {versions: {key: 1}}, 106 | }]) 107 | }) 108 | }) 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /test/specs/_sync/reducer.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import {expect} from 'chai' 4 | import {actions} from 'redux-websocket/lib/_sync/constants' 5 | import {syncReducer} from 'redux-websocket/lib/_sync/reducer' 6 | import {createMockFunction, trackCalls} from 'mock-functions' 7 | 8 | describe('sync/syncReducer', () => { 9 | describe('action: initialSyncedState', () => { 10 | it('should apply state and versions from the server', () => { 11 | const reducer = syncReducer({ 12 | keys: ['old', 'current', 'new', 'skip'], 13 | skipVersion: ['skip'], 14 | }, null, null) 15 | 16 | const result = reducer({ 17 | old: 1, 18 | current: 1, 19 | versions: {old: 1, current: 2}, 20 | }, { 21 | type: actions.initialSyncedState.type, 22 | payload: { 23 | state: { 24 | old: 2, 25 | new: 1, 26 | // TODO: Handle ignored 27 | // ignored: 1, 28 | // skip: 1, 29 | }, 30 | versions: { 31 | old: 2, 32 | new: 1, 33 | // ignored: 1, 34 | // skip: 1, 35 | }, 36 | }, 37 | }) 38 | 39 | expect(result).to.deep.equal({ 40 | old: 2, 41 | current: 1, 42 | new: 1, 43 | versions: {old: 2, current: 2, new: 1}, 44 | }) 45 | }) 46 | }) 47 | 48 | describe('action: updateSyncedState', () => { 49 | it('should apply state and versions from the server', () => { 50 | const reducer = syncReducer({keys: ['old', 'current']}, null, null) 51 | 52 | const result = reducer({ 53 | old: 1, 54 | current: 1, 55 | ignored: 1, 56 | versions: {old: 1, current: 2}, 57 | }, { 58 | type: actions.updateSyncedState.type, 59 | payload: [ 60 | { 61 | key: 'old', 62 | version: 2, 63 | changes: [{path: [], value: 2}], 64 | }, 65 | { 66 | key: 'ignored', 67 | version: 1, 68 | changes: [{path: [], value: 2}], 69 | }, 70 | ], 71 | }) 72 | 73 | expect(result).to.deep.equal({ 74 | old: 2, 75 | current: 1, 76 | ignored: 1, 77 | versions: {old: 2, current: 2}, 78 | }) 79 | }) 80 | 81 | it('should call maybeCheckVersion if versions differ', () => { 82 | const protocol = {maybeCheckVersion: createMockFunction()} 83 | const reducer = syncReducer({keys: ['old']}, protocol, null) 84 | 85 | const result = reducer( 86 | {old: 1, versions: {old: 1}}, 87 | { 88 | type: actions.updateSyncedState.type, 89 | payload: [{ 90 | key: 'old', 91 | version: 3, 92 | changes: [{path: [], value: 2}], 93 | }], 94 | } 95 | ) 96 | 97 | expect(protocol.maybeCheckVersion.calls.length).to.equal(1) 98 | expect(result).to.deep.equal({ 99 | old: 1, 100 | versions: {old: 1}, 101 | }) 102 | }) 103 | }) 104 | 105 | describe('other actions', () => { 106 | it('should pass through an undefined state to the reducer', () => { 107 | const reducerMock = trackCalls(() => ({})) 108 | const reducer = syncReducer({keys: []}, null, reducerMock) 109 | 110 | const result = reducer(undefined, {}) 111 | 112 | expect(reducerMock.calls.length).to.equal(1) 113 | expect(reducerMock.calls[0].args[0]).to.equal(undefined) 114 | expect(result).to.deep.equal({}) 115 | }) 116 | 117 | it('should pass through to the passed reducer', () => { 118 | const reducerMock = trackCalls(() => ({prop: 2})) 119 | const reducer = syncReducer({keys: []}, null, reducerMock) 120 | 121 | const result = reducer({prop: 1}, {type: 'other'}) 122 | 123 | expect(reducerMock.calls.length).to.equal(1) 124 | expect(reducerMock.calls[0].args).to.deep.equal([{prop: 1}, {type: 'other'}]) 125 | expect(result).to.deep.equal({prop: 2}) 126 | }) 127 | 128 | it('should bump the version of changed key', () => { 129 | const reducerMock = trackCalls(state => ({ 130 | ignore: 2, skip: 2, bump: 2, new: 1, keep: state.keep, 131 | })) 132 | const reducer = syncReducer( 133 | {keys: ['skip', 'keep', 'bump', 'new'], skipVersion: ['skip']}, 134 | null, 135 | reducerMock 136 | ) 137 | 138 | const result = reducer( 139 | {ignore: 1, skip: 1, keep: 1, bump: 1, versions: {bump: 1}}, 140 | {type: 'other'} 141 | ) 142 | 143 | expect(result).to.deep.equal({ 144 | ignore: 2, skip: 2, bump: 2, keep: 1, new: 1, versions: {bump: 2, new: 1}, 145 | }) 146 | }) 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /test/specs/client.ts: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | import {WebSocketClient, websocketMiddleware} from 'redux-websocket/lib/client' 3 | import {createMockFunction} from 'mock-functions' 4 | import {createMockSocket} from '../mocks/socket' 5 | 6 | describe('WebSocketClient', () => { 7 | let socketMock 8 | 9 | beforeEach(() => { 10 | global['WebSocket'] = function WebSocket(url, protocol) { 11 | this.url = url 12 | this.protocol = protocol 13 | this.send = createMockFunction() 14 | 15 | socketMock = this 16 | } 17 | }) 18 | 19 | afterEach(() => { 20 | delete global['WebSocket'] 21 | }) 22 | 23 | it('should connect to the passed url', () => { 24 | new WebSocketClient({url: 'ws://test'}) 25 | 26 | expect(socketMock.url).to.equal('ws://test') 27 | expect(socketMock.protocol).to.equal('redux-websocket') 28 | }) 29 | 30 | it('should call onOpen if provided', () => { 31 | const onOpen = createMockFunction() 32 | new WebSocketClient({url: 'ws://test', onOpen}) 33 | 34 | socketMock.onopen() 35 | 36 | expect(onOpen.calls.length).to.equal(1) 37 | }) 38 | 39 | it('should call onopen on protocols', () => { 40 | const onopen = createMockFunction() 41 | const socket = new WebSocketClient({url: 'ws://test'}) 42 | socket.registerProtocol('test', {onopen, onmessage: createMockFunction()}) 43 | 44 | socketMock.onopen() 45 | 46 | expect(onopen.calls.length).to.equal(1) 47 | }) 48 | 49 | it('should send messages to the socket with the protocol specified', () => { 50 | const protocol = {} as any 51 | const socket = new WebSocketClient({url: 'ws://test'}) 52 | socket.registerProtocol('test', protocol) 53 | 54 | protocol.send('message') 55 | 56 | expect(socketMock.send.calls.length).to.equal(1) 57 | expect(socketMock.send.calls[0].args).to.deep.equal([ 58 | JSON.stringify({type: 'test', data: 'message'}), 59 | ]) 60 | }) 61 | 62 | it('should send messages from the socket to the correct protocol', () => { 63 | const protocol = {onmessage: createMockFunction()} 64 | const socket = new WebSocketClient({url: 'ws://test'}) 65 | socket.registerProtocol('test', protocol) 66 | socket.registerProtocol('test2', protocol) 67 | 68 | socketMock.onmessage({data: JSON.stringify({type: 'test', data: 'message'})}) 69 | 70 | expect(protocol.onmessage.calls.length).to.equal(1) 71 | expect(protocol.onmessage.calls[0].args).to.deep.equal(['message']) 72 | }) 73 | 74 | describe('', () => { 75 | let realSetTimeout 76 | 77 | beforeEach(() => { 78 | realSetTimeout = setTimeout 79 | global.setTimeout = (fn, time) => realSetTimeout(fn, time / 1000) 80 | }) 81 | 82 | afterEach(() => { 83 | global.setTimeout = realSetTimeout 84 | }) 85 | 86 | it('should reconnect when the connection is lost', done => { 87 | new WebSocketClient({url: 'ws://test'}) 88 | const oldSocketMock = socketMock; 89 | 90 | socketMock.onclose() 91 | 92 | setTimeout(() => { 93 | expect(socketMock).not.to.equal(oldSocketMock) 94 | 95 | done() 96 | }, 2000) 97 | }) 98 | }) 99 | }) 100 | 101 | describe('clientMiddleware', () => { 102 | it('should dispatch actions received through the socket', () => { 103 | const socket = createMockSocket() 104 | const dispatchMock = createMockFunction() 105 | websocketMiddleware({socket})(null)(dispatchMock) 106 | 107 | socket.protocols['action'].onmessage({action: {type: 'socket'}}) 108 | 109 | expect(dispatchMock.calls.length).to.equal(1) 110 | const firstCall = dispatchMock.calls[0] 111 | expect(firstCall.args).to.deep.equal([{type: 'socket'}]) 112 | }) 113 | 114 | it('should pass through dispatched actions', () => { 115 | const socket = createMockSocket() 116 | const dispatchMock = createMockFunction() 117 | const dispatch = websocketMiddleware({socket})(null)(dispatchMock) 118 | socket.protocols['action'].send = createMockFunction() 119 | 120 | dispatch({type: 'dispatched', meta: {toServer: true}}) 121 | 122 | expect(dispatchMock.calls.length).to.equal(1) 123 | const firstCall = dispatchMock.calls[0] 124 | expect(firstCall.args).to.deep.equal([{type: 'dispatched', meta: {toServer: true}}]) 125 | }) 126 | 127 | it('should send dispatched actions with meta.toServer to the socket', () => { 128 | const socket = createMockSocket() 129 | const dispatch = websocketMiddleware({socket})(null)(createMockFunction()) 130 | const sendMock = socket.protocols['action'].send = createMockFunction() 131 | 132 | dispatch({type: 'dispatched', meta: {toServer: true}}) 133 | 134 | expect(sendMock.calls.length).to.equal(1) 135 | const firstCall = sendMock.calls[0] 136 | expect(firstCall.args).to.deep.equal([{action: {type: 'dispatched', meta: {toServer: true}}}]) 137 | }) 138 | 139 | it('should not send dispatched actions without meta.toServer to the socket', () => { 140 | const socket = createMockSocket() 141 | const dispatch = websocketMiddleware({socket})(null)(createMockFunction()) 142 | const sendMock = socket.protocols['action'].send = createMockFunction() 143 | 144 | dispatch({type: 'dispatched'}) 145 | 146 | expect(sendMock.calls.length).to.equal(0) 147 | }) 148 | 149 | it('should send dispatched actions with meta.toServer in actions to the socket', () => { 150 | const socket = createMockSocket() 151 | const actions = {dispatched: {meta: {toServer: true}}} 152 | const dispatch = websocketMiddleware({socket, actions})(null)(createMockFunction()) 153 | const sendMock = socket.protocols['action'].send = createMockFunction() 154 | 155 | dispatch({type: 'dispatched'}) 156 | 157 | expect(sendMock.calls.length).to.equal(1) 158 | const firstCall = sendMock.calls[0] 159 | expect(firstCall.args).to.deep.equal([{action: {type: 'dispatched'}}]) 160 | }) 161 | }) 162 | -------------------------------------------------------------------------------- /test/specs/rpc/client.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import {expect} from 'chai' 4 | import {timeoutSymbol} from 'redux-websocket/lib/rpc' 5 | import {createRpcClient} from 'redux-websocket/lib/rpc/client' 6 | import {createMockFunction, trackCalls} from 'mock-functions' 7 | import {createMockSocket} from '../../mocks/socket' 8 | 9 | import chai = require('chai') 10 | import chaiAsPromised = require('chai-as-promised') 11 | chai.use(chaiAsPromised) 12 | 13 | function respond(protocol, response?) { 14 | return message => setTimeout(() => { 15 | protocol.onmessage(Object.assign({id: message.id}, response || {})) 16 | }) 17 | } 18 | 19 | describe('rpc', () => { 20 | describe('client', () => { 21 | it('should register a protocol id if passed', () => { 22 | const socket = createMockSocket() 23 | 24 | createRpcClient({socket, id: 'test', rpcObjects: []}) 25 | 26 | expect(socket.protocols['rpc']).to.be.undefined 27 | expect(socket.protocols['rpctest']).to.exist 28 | }) 29 | 30 | it('should send the correct class name, method name and arguments', async () => { 31 | const object = new class Class { 32 | method(...args) {} 33 | } 34 | 35 | const socket = createMockSocket() 36 | createRpcClient({socket, rpcObjects: [object]}) 37 | const protocol = socket.protocols['rpc'] 38 | const sendMock = protocol.send = trackCalls(respond(protocol)) 39 | 40 | await object.method(1, 2, 3) 41 | 42 | expect(sendMock.calls.length).to.equal(1) 43 | expect(sendMock.calls[0].args).to.deep.equal([{ 44 | id: 0, 45 | className: 'Class', 46 | methodName: 'method', 47 | args: [1, 2, 3], 48 | }]) 49 | }) 50 | 51 | it('should respond with the value of the RPC', async () => { 52 | const object = new class Class { 53 | method() {} 54 | } 55 | 56 | const socket = createMockSocket() 57 | createRpcClient({socket, rpcObjects: [object]}) 58 | const protocol = socket.protocols['rpc'] 59 | protocol.send = trackCalls(respond(protocol, {value: 'server'})) 60 | 61 | return expect(object.method()).to.eventually.become('server') 62 | }) 63 | 64 | it('should reject with the error of the RPC', async () => { 65 | const object = new class Class { 66 | method() {} 67 | } 68 | 69 | const socket = createMockSocket() 70 | createRpcClient({socket, rpcObjects: [object]}) 71 | const protocol = socket.protocols['rpc'] 72 | protocol.send = trackCalls(respond(protocol, {error: 'server'})) 73 | 74 | return expect(object.method()).to.eventually.be.rejectedWith('server') 75 | }) 76 | 77 | it('should timeout if no response is given', async () => { 78 | class Class { 79 | method() {} 80 | } 81 | Class[timeoutSymbol] = 1 82 | 83 | const object = new Class() 84 | 85 | const socket = createMockSocket() 86 | createRpcClient({socket, rpcObjects: [object]}) 87 | const protocol = socket.protocols['rpc'] 88 | protocol.send = createMockFunction() 89 | 90 | // @remoteProcedures({name: 'Class', timeout: 1}) 91 | 92 | return expect(object.method()).to.eventually.be.rejectedWith('timeout reached') 93 | }) 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /test/specs/rpc/server.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import {expect} from 'chai' 4 | import {clientError, createRpcServer} from 'redux-websocket/lib/rpc/server' 5 | import {createMockFunction} from 'mock-functions' 6 | import {createMockSocket} from '../../mocks/socket' 7 | 8 | describe('rpc', () => { 9 | describe('server', () => { 10 | it('should register a protocol id if passed', () => { 11 | const socket = createMockSocket() 12 | 13 | createRpcServer({socket, id: 'test', rpcObjects: []}) 14 | 15 | expect(socket.protocols['rpc']).to.be.undefined 16 | expect(socket.protocols['rpctest']).to.exist 17 | }) 18 | 19 | it('should respond with an error if the class is missing', async () => { 20 | const socket = createMockSocket() 21 | createRpcServer({socket, rpcObjects: []}) 22 | const respondMock = createMockFunction() 23 | 24 | await socket.protocols['rpc'].onmessage({ 25 | id: 1, 26 | className: 'missing', 27 | methodName: 'missing', 28 | args: [], 29 | }, respondMock) 30 | 31 | expect(respondMock.calls.length).to.equal(1) 32 | expect(respondMock.calls[0].args).to.deep.equal([{ 33 | id: 1, 34 | error: 'no such class', 35 | }]) 36 | }) 37 | 38 | it('should respond with an error if the method is missing', async () => { 39 | const object = new class Class {} 40 | 41 | const socket = createMockSocket() 42 | createRpcServer({socket, rpcObjects: [object]}) 43 | const respondMock = createMockFunction() 44 | 45 | await socket.protocols['rpc'].onmessage({ 46 | id: 1, 47 | className: 'Class', 48 | methodName: 'missing', 49 | args: [], 50 | }, respondMock) 51 | 52 | expect(respondMock.calls.length).to.equal(1) 53 | expect(respondMock.calls[0].args).to.deep.equal([{ 54 | id: 1, 55 | error: 'no such method', 56 | }]) 57 | }) 58 | 59 | it('should respond with the value of the method', async () => { 60 | const object = new class Class { 61 | method() { 62 | return 'returnValue' 63 | } 64 | } 65 | 66 | const socket = createMockSocket() 67 | createRpcServer({socket, rpcObjects: [object]}) 68 | const respondMock = createMockFunction() 69 | 70 | await socket.protocols['rpc'].onmessage({ 71 | id: 1, 72 | className: 'Class', 73 | methodName: 'method', 74 | args: [], 75 | }, respondMock) 76 | 77 | expect(respondMock.calls.length).to.equal(1) 78 | expect(respondMock.calls[0].args).to.deep.equal([{ 79 | id: 1, 80 | value: 'returnValue', 81 | }]) 82 | }) 83 | 84 | it('should respond with unkown error when the method thows', async () => { 85 | const object = new class Class { 86 | method() { 87 | throw 'error' 88 | } 89 | } 90 | 91 | const socket = createMockSocket() 92 | createRpcServer({socket, rpcObjects: [object]}) 93 | const respondMock = createMockFunction() 94 | 95 | await socket.protocols['rpc'].onmessage({ 96 | id: 1, 97 | className: 'Class', 98 | methodName: 'method', 99 | args: [], 100 | }, respondMock) 101 | 102 | expect(respondMock.calls.length).to.equal(1) 103 | expect(respondMock.calls[0].args).to.deep.equal([{ 104 | id: 1, 105 | error: 'Unkown Error', 106 | }]) 107 | }) 108 | 109 | it('should respond with the client error thrown by the method', async () => { 110 | const object = new class Class { 111 | method() { 112 | throw clientError('other error') 113 | } 114 | } 115 | 116 | const socket = createMockSocket() 117 | createRpcServer({socket, rpcObjects: [object]}) 118 | const respondMock = createMockFunction() 119 | 120 | await socket.protocols['rpc'].onmessage({ 121 | id: 1, 122 | className: 'Class', 123 | methodName: 'method', 124 | args: [], 125 | }, respondMock) 126 | 127 | expect(respondMock.calls.length).to.equal(1) 128 | expect(respondMock.calls[0].args).to.deep.equal([{ 129 | id: 1, 130 | error: 'other error', 131 | }]) 132 | }) 133 | 134 | it('should call the method with provided args', async () => { 135 | const method = createMockFunction() 136 | const object = new class Class { 137 | method(...args) { 138 | method(args) 139 | } 140 | } 141 | 142 | const socket = createMockSocket() 143 | createRpcServer({socket, rpcObjects: [object]}) 144 | const respondMock = createMockFunction() 145 | 146 | await socket.protocols['rpc'].onmessage({ 147 | id: 1, 148 | className: 'Class', 149 | methodName: 'method', 150 | args: [1, 2, 3], 151 | }, respondMock) 152 | 153 | expect(method.calls.length).to.equal(1) 154 | expect(method.calls[0].args).to.deep.equal([[1, 2, 3]]) 155 | }) 156 | }) 157 | }) 158 | -------------------------------------------------------------------------------- /test/specs/server.ts: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | import {ClientMode} from 'redux-websocket/lib/common' 3 | import {WebSocketServer, websocketMiddleware} from 'redux-websocket/lib/server' 4 | import {createMockFunction} from 'mock-functions' 5 | import {createMockSocket} from '../mocks/socket' 6 | 7 | function on(event, fn) { 8 | this[`on${event}`] = fn 9 | } 10 | 11 | describe('WebSocketServer', () => { 12 | let socketMock 13 | 14 | beforeEach(() => { 15 | socketMock = {on} 16 | }) 17 | 18 | it('should accept redux-websocket connections', () => { 19 | const connection = {on} as any 20 | const request = {accept: createMockFunction().returns(connection), origin: 'origin'} 21 | new WebSocketServer(socketMock) 22 | 23 | socketMock.onrequest(request) 24 | 25 | expect(request.accept.calls.length).to.equal(1) 26 | expect(request.accept.calls[0].args).to.deep.equal(['redux-websocket', 'origin']) 27 | }) 28 | 29 | it('should send messages to all the sockets with the protocol specified', () => { 30 | const connection = {on, send: createMockFunction()} as any 31 | const connection2 = {on, send: createMockFunction()} as any 32 | const request = {accept: createMockFunction().returns(connection)} 33 | const request2 = {accept: createMockFunction().returns(connection2)} 34 | const protocol = {} as any 35 | const socket = new WebSocketServer(socketMock) 36 | socket.registerProtocol('test', protocol) 37 | 38 | socketMock.onrequest(request) 39 | socketMock.onrequest(request2) 40 | 41 | protocol.send('message') 42 | 43 | expect(connection.send.calls.length).to.equal(1) 44 | expect(connection2.send.calls.length).to.equal(1) 45 | expect(connection.send.calls[0].args).to.deep.equal([ 46 | JSON.stringify({type: 'test', data: 'message'}), 47 | ]) 48 | expect(connection2.send.calls[0].args).to.deep.equal([ 49 | JSON.stringify({type: 'test', data: 'message'}), 50 | ]) 51 | }) 52 | 53 | it('should send messages from a connection to the correct protocol', () => { 54 | const connection = {on} as any 55 | const request = {accept: createMockFunction().returns(connection)} 56 | const protocol = {onmessage: createMockFunction()} 57 | const socket = new WebSocketServer(socketMock) 58 | socket.registerProtocol('test', protocol) 59 | socket.registerProtocol('test2', protocol) 60 | 61 | socketMock.onrequest(request) 62 | connection.onmessage({ 63 | type: 'utf8', 64 | utf8Data: JSON.stringify({type: 'test', data: 'message'}), 65 | }) 66 | 67 | expect(protocol.onmessage.calls.length).to.equal(1) 68 | expect(protocol.onmessage.calls[0].args.length).to.equal(3) 69 | expect(protocol.onmessage.calls[0].args[0]).to.equal('message') 70 | expect(protocol.onmessage.calls[0].args[1]).to.be.a('function') 71 | expect(protocol.onmessage.calls[0].args[2]).to.be.a('string') 72 | }) 73 | 74 | it('should provide a response function to the protocol', () => { 75 | const connection = {on, send: createMockFunction()} as any 76 | const request = {accept: createMockFunction().returns(connection)} 77 | const protocol = {onmessage: createMockFunction()} 78 | const socket = new WebSocketServer(socketMock) 79 | socket.registerProtocol('test', protocol) 80 | 81 | socketMock.onrequest(request) 82 | connection.onmessage({ 83 | type: 'utf8', 84 | utf8Data: JSON.stringify({type: 'test', data: 'message'}), 85 | }) 86 | 87 | const response = protocol.onmessage.calls[0].args[1] 88 | 89 | response('message2') 90 | 91 | expect(connection.send.calls.length).to.equal(1) 92 | expect(connection.send.calls[0].args).to.deep.equal([JSON.stringify({ 93 | type: 'test', 94 | data: 'message2', 95 | })]) 96 | }) 97 | 98 | it('should only delete the connection that was closed', () => { 99 | const connection = {on} as any 100 | const connection2 = {on} as any 101 | const request = {accept: createMockFunction().returns(connection)} 102 | const request2 = {accept: createMockFunction().returns(connection2)} 103 | const socket = new WebSocketServer(socketMock) as any 104 | 105 | socketMock.onrequest(request) 106 | socketMock.onrequest(request2) 107 | 108 | connection.onclose() 109 | 110 | expect(Object.keys(socket.connections).length).to.equal(1) 111 | expect(socket.connections[Object.keys(socket.connections)[0]]).to.equal(connection2) 112 | }) 113 | }) 114 | 115 | describe('serverMiddleware', () => { 116 | it('should dispatch actions received through the socket with meta.toServer', () => { 117 | const socket = createMockSocket() 118 | const actions = {socket: {meta: {toServer: true}}} 119 | const dispatchMock = createMockFunction() 120 | websocketMiddleware({socket, actions})(null)(dispatchMock) 121 | 122 | socket.protocols['action'].onmessage({action: {type: 'socket'}}) 123 | 124 | expect(dispatchMock.calls.length).to.equal(1) 125 | const firstCall = dispatchMock.calls[0] 126 | expect(firstCall.args).to.deep.equal([{type: 'socket'}]) 127 | }) 128 | 129 | it('should not dispatch actions received through the socket without meta.toServer', () => { 130 | const socket = createMockSocket() 131 | const dispatchMock = createMockFunction() 132 | websocketMiddleware({socket, actions: {}})(null)(dispatchMock) 133 | 134 | socket.protocols['action'].onmessage({action: {type: 'socket'}}) 135 | 136 | expect(dispatchMock.calls.length).to.equal(0) 137 | }) 138 | 139 | it('should pass through dispatched actions', () => { 140 | const socket = createMockSocket() 141 | const dispatchMock = createMockFunction() 142 | const dispatch = websocketMiddleware({socket, actions: {}})(null)(dispatchMock) 143 | socket.protocols['action'].send = createMockFunction() 144 | 145 | dispatch({type: 'dispatched', meta: {toClient: true, toClientMode: ClientMode.sameStore}}) 146 | 147 | expect(dispatchMock.calls.length).to.equal(1) 148 | const firstCall = dispatchMock.calls[0] 149 | expect(firstCall.args).to.deep.equal([ 150 | {type: 'dispatched', meta: {toClient: true, toClientMode: ClientMode.sameStore}}, 151 | ]) 152 | }) 153 | 154 | it('should send dispatched actions with meta.toClient to the socket', () => { 155 | const socket = createMockSocket() 156 | const dispatch = websocketMiddleware({socket, actions: {}})(null)(createMockFunction()) 157 | const sendMock = socket.protocols['action'].send = createMockFunction() 158 | 159 | dispatch({type: 'dispatched', meta: {toClient: true, toClientMode: ClientMode.broadcast}}) 160 | 161 | expect(sendMock.calls.length).to.equal(1) 162 | const firstCall = sendMock.calls[0] 163 | expect(firstCall.args[0]).to.deep.equal({action: {type: 'dispatched', meta: { 164 | toClient: true, 165 | toClientMode: ClientMode.broadcast, 166 | fromServer: true, 167 | }}}) 168 | expect(firstCall.args[1]).not.to.be.a('function') 169 | }) 170 | 171 | it('should not send dispatched actions without meta.toServer to the socket', () => { 172 | const socket = createMockSocket() 173 | const dispatch = websocketMiddleware({socket, actions: {}})(null)(createMockFunction()) 174 | const sendMock = socket.protocols['action'].send = createMockFunction() 175 | 176 | dispatch({type: 'dispatched'}) 177 | 178 | expect(sendMock.calls.length).to.equal(0) 179 | }) 180 | 181 | it('should send dispatched actions with meta.toClient in actions to the socket', () => { 182 | const socket = createMockSocket() 183 | const actions = {dispatched: {meta: {toClient: true, toClientMode: ClientMode.broadcast}}} 184 | const dispatch = websocketMiddleware({socket, actions})(null)(createMockFunction()) 185 | const sendMock = socket.protocols['action'].send = createMockFunction() 186 | 187 | dispatch({type: 'dispatched'}) 188 | 189 | expect(sendMock.calls.length).to.equal(1) 190 | const firstCall = sendMock.calls[0] 191 | expect(firstCall.args[0]).to.deep.equal({action: {type: 'dispatched', meta: {fromServer: true}}}) 192 | expect(firstCall.args[1]).not.to.be.a('function') 193 | }) 194 | }) 195 | -------------------------------------------------------------------------------- /test/specs/sync.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pajn/redux-websocket/1df4f9d051f17954decaaa3394b9867c33be2428/test/specs/sync.ts -------------------------------------------------------------------------------- /test/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "emitDecoratorMetadata": true, 5 | "target": "es6", 6 | "module": "commonjs", 7 | "outDir": "bin" 8 | }, 9 | "filesGlob": [ 10 | "./private.d.ts", 11 | "./specs/**/*.ts", 12 | "./mocks/**/*.ts", 13 | "../typings.d.ts", 14 | "../typings/tsd.d.ts" 15 | ], 16 | "files": [], 17 | "formatCodeOptions": { 18 | "indentSize": 2, 19 | "tabSize": 2, 20 | "newLineCharacter": "\n", 21 | "convertTabsToSpaces": true, 22 | "insertSpaceAfterCommaDelimiter": true, 23 | "insertSpaceAfterSemicolonInForStatements": true, 24 | "insertSpaceBeforeAndAfterBinaryOperators": true, 25 | "insertSpaceAfterKeywordsInControlFlowStatements": true, 26 | "insertSpaceAfterFunctionKeywordForAnonymousFunctions": false, 27 | "insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": false, 28 | "placeOpenBraceOnNewLineForFunctions": false, 29 | "placeOpenBraceOnNewLineForControlBlocks": false 30 | }, 31 | "compileOnSave": false, 32 | "atom": { 33 | "rewriteTsconfig": true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "emitDecoratorMetadata": true, 5 | "target": "es6", 6 | "module": "commonjs", 7 | "outDir": ".tmp" 8 | }, 9 | "filesGlob": [ 10 | "*.ts", 11 | "src/**/*.ts", 12 | "typings/*.ts" 13 | ], 14 | "files": [], 15 | "formatCodeOptions": { 16 | "indentSize": 2, 17 | "tabSize": 2, 18 | "newLineCharacter": "\n", 19 | "convertTabsToSpaces": true, 20 | "insertSpaceAfterCommaDelimiter": true, 21 | "insertSpaceAfterSemicolonInForStatements": true, 22 | "insertSpaceBeforeAndAfterBinaryOperators": true, 23 | "insertSpaceAfterKeywordsInControlFlowStatements": true, 24 | "insertSpaceAfterFunctionKeywordForAnonymousFunctions": false, 25 | "insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": false, 26 | "placeOpenBraceOnNewLineForFunctions": false, 27 | "placeOpenBraceOnNewLineForControlBlocks": false 28 | }, 29 | "compileOnSave" : false 30 | } 31 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "class-name": true, 4 | "comment-format": [true, 5 | "check-whitespace", 6 | "check-uppercase" 7 | ], 8 | "curly": false, 9 | "eofline": false, 10 | "forin": true, 11 | "indent": [true, "spaces", 2], 12 | "label-position": true, 13 | "label-undefined": true, 14 | "max-line-length": [true, 100], 15 | "member-ordering": [true, 16 | "public-before-private", 17 | "variables-before-functions" 18 | ], 19 | "no-arg": true, 20 | "no-bitwise": true, 21 | "no-console": [true, 22 | "debug", 23 | "info", 24 | "time", 25 | "timeEnd", 26 | "trace" 27 | ], 28 | "no-consecutive-blank-lines": true, 29 | "no-construct": true, 30 | "no-debugger": true, 31 | "no-duplicate-key": true, 32 | "no-duplicate-variable": true, 33 | "no-empty": false, 34 | "no-eval": true, 35 | "no-string-literal": false, 36 | "no-switch-case-fall-through": true, 37 | "no-trailing-whitespace": true, 38 | "no-unused-variable": true, 39 | "no-unreachable": true, 40 | "no-use-before-declare": true, 41 | "no-var-keyword": true, 42 | "one-line": [true, 43 | "check-open-brace", 44 | "check-catch", 45 | "check-else", 46 | "check-whitespace" 47 | ], 48 | "quotemark": [true, "single", "avoid-escape"], 49 | "radix": true, 50 | "semicolon": [true, "never"], 51 | "triple-equals": [true, "allow-null-check"], 52 | "typedef-whitespace": [true, { 53 | "call-signature": "nospace", 54 | "index-signature": "nospace", 55 | "parameter": "nospace", 56 | "property-declaration": "nospace", 57 | "variable-declaration": "nospace" 58 | }], 59 | "variable-name": false, 60 | "whitespace": [true, 61 | "check-branch", 62 | "check-decl", 63 | "check-operator", 64 | "check-module", 65 | "check-separator", 66 | "check-type" 67 | ], 68 | "trailing-comma": [true, { 69 | "multiline": "always", 70 | "singleline": "never" 71 | }] 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'redux-websocket/lib/common' { 2 | export interface Protocol { 3 | onopen?: () => void 4 | send?: (message: Object) => void 5 | } 6 | 7 | export interface ClientProtocol extends Protocol { 8 | onopen?: () => void 9 | onmessage: ( 10 | message: any, 11 | respond: (message: Object) => void 12 | ) => void 13 | } 14 | 15 | export interface ServerProtocol extends Protocol { 16 | onconnection?: (connectionId: string) => void 17 | onclose?: (connectionId: string) => void 18 | onmessage: ( 19 | message: any, 20 | respond: (message: Object) => void, 21 | connectionId: string 22 | ) => void 23 | 24 | send?: (message: Object, predicate?: (connectionId: string) => boolean) => void 25 | sendTo?: ( 26 | connectionId: string, 27 | message: Object, 28 | predicate?: (connectionId: string) => boolean 29 | ) => void 30 | } 31 | 32 | export interface WebSocketConnection { 33 | isServer: boolean 34 | registerProtocol(name: string, protocol: Protocol): void 35 | } 36 | 37 | export interface Actions { 38 | [type: string]: Action 39 | } 40 | 41 | export enum ClientMode { 42 | broadcast, 43 | sameStore, 44 | } 45 | 46 | export interface Action { 47 | type?: string 48 | meta?: { 49 | toServer?: boolean|((action, connectionId: string) => boolean) 50 | toClient?: boolean|((action, connectionId: string) => boolean) 51 | toClientMode?: ClientMode 52 | } 53 | } 54 | } 55 | 56 | declare module 'redux-websocket/lib/client' { 57 | import {Actions, ClientProtocol, WebSocketConnection} from 'redux-websocket/lib/common' 58 | 59 | export class WebSocketClient implements WebSocketConnection { 60 | isServer: boolean 61 | protocols: {} 62 | private socket: WebSocket 63 | constructor(url: any) 64 | registerProtocol(name: string, protocol: ClientProtocol): void 65 | } 66 | 67 | export function websocketMiddleware(settings: { 68 | actions?: Actions 69 | socket: WebSocketClient 70 | /** 71 | * Optional id to use for connecting to an redux websocket server with a non-empty id 72 | */ 73 | id?: string, 74 | }): (store: any) => (next: any) => (action: any) => any 75 | } 76 | 77 | declare module 'redux-websocket/lib/server' { 78 | import {Actions, ServerProtocol, WebSocketConnection} from 'redux-websocket/lib/common' 79 | import {server as WebSocket} from 'websocket' 80 | 81 | export class WebSocketServer implements WebSocketConnection { 82 | isServer: boolean 83 | constructor(webSocket: WebSocket) 84 | registerProtocol(name: string, protocol: ServerProtocol): void 85 | } 86 | 87 | export function websocketMiddleware(settings: { 88 | actions: Actions 89 | socket: WebSocketServer 90 | /** 91 | * Optional id to use when handling multiple redux websocket servers on a single 92 | * WebSocketServer. 93 | * The same id must be specified on the client. 94 | */ 95 | id?: string 96 | /** 97 | * Optinally pass in existing connections when created 98 | */ 99 | connections?: {[connectionId: string]: any} 100 | }): (store: any) => (next: any) => (action: any) => any 101 | } 102 | 103 | declare module 'redux-websocket/lib/sync' { 104 | import {WebSocketConnection} from 'redux-websocket/lib/common' 105 | 106 | type Settings = { 107 | socket: WebSocketConnection 108 | keys: string[] 109 | skipVersion?: string[] 110 | waitForAction?: string 111 | } 112 | 113 | export function syncStoreEnhancer(settings: Settings): (next) => (reducer, initialState) => any 114 | 115 | export function noopReducer(state) 116 | 117 | export function createSyncServer(settings: Settings): { 118 | createSyncMiddleware(): { 119 | addConnection(connectionId: string) 120 | syncMiddleware(store): (next) => (action) => void 121 | } 122 | } 123 | } 124 | 125 | declare module 'redux-websocket/lib/rpc' { 126 | export type RpcSettings = { 127 | /** 128 | * Name for the RPC namespace, if not specified the classname will be used. 129 | */ 130 | name?: string 131 | /** 132 | * Timeout in ms for how long the client will wait for a response before rejecting. 133 | * Defaults to 10000. 134 | */ 135 | timeout?: number 136 | } 137 | 138 | export type RpcContext = { 139 | connectionId: string 140 | } 141 | 142 | export const nameSymbol: symbol 143 | export const timeoutSymbol: symbol 144 | 145 | export function clientError(message: string) 146 | 147 | export function remoteProcedures(settings?: RpcSettings): ClassDecorator 148 | } 149 | 150 | declare module 'redux-websocket/lib/rpc/client' { 151 | import {WebSocketClient} from 'redux-websocket/lib/client' 152 | import {clientError, RpcSettings} from 'redux-websocket/lib/rpc' 153 | export {clientError} 154 | 155 | type RpcClientSettings = { 156 | /** 157 | * Optional id to use for connecting to an RPC server with a non-empty id 158 | */ 159 | id?: string|number 160 | socket: WebSocketClient 161 | rpcObjects: Object[] 162 | } 163 | 164 | export function createRpcClient(setting: RpcClientSettings): void 165 | } 166 | 167 | declare module 'redux-websocket/lib/rpc/server' { 168 | import {WebSocketServer} from 'redux-websocket/lib/server' 169 | import {clientError, RpcSettings} from 'redux-websocket/lib/rpc' 170 | export {clientError} 171 | 172 | type RpcServerSettings = { 173 | /** 174 | * Optional id to use when handling multiple RPC servers on a single WebSocketServer. 175 | * The same id must be specified on the client. 176 | */ 177 | id?: string|number 178 | socket: WebSocketServer 179 | rpcObjects: Object[] 180 | logger?: { 181 | info(message?: any, ...optionalParams: any[]): void 182 | warn(message?: any, ...optionalParams: any[]): void 183 | } 184 | } 185 | 186 | export function createRpcServer(setting: RpcServerSettings): void 187 | } 188 | -------------------------------------------------------------------------------- /typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ambientDependencies": { 3 | "node": "github:DefinitelyTyped/DefinitelyTyped/node/node.d.ts#aee0039a2d6686ec78352125010ebb38a7a7d743", 4 | "redux": "github:DefinitelyTyped/DefinitelyTyped/redux/redux.d.ts#a820bbdead70913f0c750802b367ed54cb99b442", 5 | "websocket": "github:DefinitelyTyped/DefinitelyTyped/websocket/websocket.d.ts#56532a6b84a85f0bede3f0d5582d838cad6ff563" 6 | }, 7 | "ambientDevDependencies": { 8 | "chai": "github:DefinitelyTyped/DefinitelyTyped/chai/chai.d.ts#9c25433c84251bfe72bf0030a95edbbb2c81c9d5", 9 | "mocha": "github:DefinitelyTyped/DefinitelyTyped/mocha/mocha.d.ts#d6dd320291705694ba8e1a79497a908e9f5e6617" 10 | } 11 | } 12 | --------------------------------------------------------------------------------