├── .gitignore ├── CHANGELOG.md ├── README.md ├── package-lock.json ├── package.json ├── src ├── index.ts └── lib │ ├── actionCreator.ts │ ├── middleware.test.ts │ └── middleware.ts ├── tsconfig.json ├── tsconfig.module.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | .nyc_output -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | 6 | ## [1.1.1](https://github.com/holochain/hc-redux-middleware/compare/v1.1.0...v1.1.1) (2019-04-09) 7 | 8 | 9 | 10 | 11 | ## [1.1.1](https://github.com/holochain/hc-redux-middleware/compare/v1.0.9...v1.1.1) (2019-04-09) 12 | 13 | 14 | 15 | 16 | ## [1.0.9](https://github.com/holochain/hc-redux-middleware/compare/v1.0.8...v1.0.9) (2019-01-16) 17 | 18 | 19 | 20 | 21 | ## [1.0.8](https://github.com/holochain/hc-redux-middleware/compare/v1.0.7...v1.0.8) (2018-12-18) 22 | 23 | 24 | 25 | 26 | ## [1.0.7](https://github.com/holochain/hc-redux-middleware/compare/v1.0.6...v1.0.7) (2018-12-11) 27 | 28 | 29 | 30 | 31 | ## [1.0.6](https://github.com/holochain/hc-redux-middleware/compare/v1.0.5...v1.0.6) (2018-12-11) 32 | 33 | 34 | 35 | 36 | ## [1.0.5](https://github.com/holochain/hc-redux-middleware/compare/v1.0.4...v1.0.5) (2018-12-09) 37 | 38 | 39 | 40 | 41 | ## [1.0.4](https://github.com/holochain/hc-redux-middleware/compare/v1.0.3...v1.0.4) (2018-12-09) 42 | 43 | 44 | 45 | 46 | ## [1.0.3](https://github.com/holochain/hc-redux-middleware/compare/v1.0.2...v1.0.3) (2018-12-09) 47 | 48 | 49 | 50 | 51 | ## [1.0.2](https://github.com/holochain/hc-redux-middleware/compare/v1.0.1...v1.0.2) (2018-12-09) 52 | 53 | 54 | 55 | 56 | ## 1.0.1 (2018-12-09) 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hc-redux-middleware 2 | 3 | To install: `npm install --save @holochain/hc-redux-middleware` 4 | 5 | ## Usage 6 | 7 | Configure the store to use the middleware 8 | 9 | ``` 10 | import { createStore, combineReducers, applyMiddleware } from 'redux' 11 | 12 | import { connect } from '@holochain/hc-web-client' 13 | import { holochainMiddleware } from '@holochain/hc-redux-middleware' 14 | 15 | // this url should use the same port set up the holochain container 16 | const url = 'ws://localhost:3000' 17 | const hcWc = connect({ url }) 18 | 19 | const middleware = [holochainMiddleware(hcWc)] 20 | 21 | ... 22 | 23 | let store = createStore(reducer, applyMiddleware(...middleware)) 24 | ``` 25 | 26 | Lets look an example action to see how the middleware works 27 | 28 | ``` 29 | { 30 | type: 'CALL_HOLOCHAIN_FUNC', 31 | payload: { ... } 32 | meta: { 33 | holochainZomeCallAction: true, 34 | instanceId: 'someInstance', 35 | zome: 'someZome', 36 | func: 'someFunc' 37 | } 38 | } 39 | ``` 40 | 41 | The first thing to notice is the `meta.holochainZomeCallAction = True`. This is how the middleware detects which actions it should use to make calls to holochain. 42 | 43 | The payload will be passed directly to the call to the holochain function and must have fields that match the holochain function signature. 44 | 45 | ### Action creators 46 | 47 | To abstract away from manually creating these actions this module also provides helpers for doing that. For a particular holochain function call it will create an action creator that you can call later with parameters 48 | 49 | ``` 50 | import { createHolochainZomeCallAsyncAction } from '@holochain/hc-redux-middleware' 51 | 52 | const someFuncActionCreator = createHolochainZomeCallAsyncAction('someInstance', 'someZome', 'someFunc') 53 | 54 | // later on when you want to create dispatch an action to call the function with some params 55 | const action = someFuncActionCreator.create(params) 56 | dispatch(action) // this returns a promise that resolves with the response or fails with the error 57 | 58 | ``` 59 | 60 | This will autocomplete the type field using the format `someApp/someZome/someFunc`. You can also use the autogenerated `holochainAction.success().type` and `holochainAction.failure().type` 61 | 62 | It is also possible to create actions to make conductor admin calls. These are via a different action creator `createHolochainAdminAsyncAction`. These take a single call string such as `admin/instances/list` or `admin/dna/install`. See the [conductor documentation](https://developer.holochain.org/api/latest/holochain_conductor_api/interface/struct.ConductorApiBuilder.html#method.with_admin_dna_functions) for the full admin functionality. This will not be required for most hApps. 63 | 64 | ### Response Actions 65 | 66 | When a successful call to holochain is completed the middleware will dispatch an action containing the response. These actions have a type that is the same as the call but with `_SUCCESS` or `_FAILURE` appended. 67 | 68 | ``` 69 | { 70 | type: 'CALL_HOLOCHAIN_FUNC_SUCCESS', 71 | payload: { ... } // this contains the function call result 72 | } 73 | 74 | { 75 | type: 'someApp/someZome/someCapability/someFunc_FAILURE', 76 | payload: { ... } // this contains details of the error 77 | } 78 | ``` 79 | 80 | These can then be handler by the reducer to update the state 81 | 82 | ### Special actions 83 | 84 | There are also several actions that the middleware will dispatch automatically. Currently these are 85 | 86 | #### Typescript support 87 | 88 | We love typescript and so the action creators ship with typescript definitions and type support out of the box! The typed actions are based off the [typesafe actions](https://github.com/piotrwitek/typesafe-actions) model. Holochain action creators can be made using generics to match the function parameters and return type of the zome function 89 | 90 | ``` 91 | import { createHolochainZomeCallAsyncAction } from '@holochain/hc-redux-middleware' 92 | 93 | export const someFuncActionCreator = createHolochainZomeCallAsyncAction('someApp', 'someZome', 'someFunc') 94 | 95 | // The params must match the ParamType or this will error 96 | const action = someFuncActionCreator.create(params) 97 | ``` 98 | This is even more useful in the reducer. 99 | 100 | ``` 101 | import { getType } from 'typesafe-actions' 102 | 103 | export function reducer (state = initialState, action: AnyAction) { 104 | switch (action.type) { 105 | case getType(someFuncActionCreator.success): 106 | // The type checker now knows that action.payload has type 107 | // set in the definition using the generic 108 | // You literally cant go wrong! 109 | 110 | ... 111 | } 112 | ``` 113 | See [https://github.com/piotrwitek/typesafe-actions] for all the details of using typesafe actions 114 | 115 | 116 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@holochain/hc-redux-middleware", 3 | "version": "1.1.1", 4 | "description": "Redux middleware for seamless integration with Holochain", 5 | "main": "build/main/index.js", 6 | "typings": "build/main/index.d.ts", 7 | "module": "build/module/index.js", 8 | "files": [ 9 | "build/main" 10 | ], 11 | "repository": "https://github.com/holochain/hc-redux-middleware", 12 | "license": "GPL-3.0", 13 | "keywords": [], 14 | "scripts": { 15 | "describe": "npm-scripts-info", 16 | "build": "run-s clean && run-p build:*", 17 | "build:main": "tsc -p tsconfig.json", 18 | "build:module": "tsc -p tsconfig.module.json", 19 | "fix": "run-s fix:*", 20 | "fix:prettier": "prettier \"src/**/*.ts\" --write", 21 | "fix:tslint": "tslint --fix --project .", 22 | "test": "run-s build test:*", 23 | "lint": "tslint --project . && prettier \"src/**/*.ts\" --list-different", 24 | "test:unit": "nyc --silent ava", 25 | "version": "standard-version", 26 | "clean": "trash build test", 27 | "all": "run-s test", 28 | "prepare-release": "run-s all version" 29 | }, 30 | "scripts-info": { 31 | "info": "Display information about the package scripts", 32 | "build": "Clean and rebuild the project", 33 | "fix": "Try to automatically fix any linting problems", 34 | "test": "Lint and unit test the project", 35 | "watch": "Watch and rebuild the project on save, then rerun relevant tests", 36 | "version": "Bump package.json version, update CHANGELOG.md, tag release", 37 | "prepare-release": "One-step: clean, build, test, publish docs, and prep a release" 38 | }, 39 | "engines": { 40 | "node": ">=8.9" 41 | }, 42 | "dependencies": { 43 | "redux": "^4.0.1", 44 | "typesafe-actions": "^2.0.4" 45 | }, 46 | "devDependencies": { 47 | "@types/sinon": "^5.0.7", 48 | "ava": "1.0.0-beta.7", 49 | "codecov": "^3.1.0", 50 | "cz-conventional-changelog": "^2.1.0", 51 | "gh-pages": "^2.0.1", 52 | "npm-run-all": "^4.1.5", 53 | "nyc": "^13.1.0", 54 | "opn-cli": "^4.0.0", 55 | "prettier": "^1.15.2", 56 | "sinon": "^7.1.1", 57 | "standard-version": "^4.4.0", 58 | "trash-cli": "^1.4.0", 59 | "tslint": "^5.11.0", 60 | "tslint-config-prettier": "^1.17.0", 61 | "tslint-config-standard": "^8.0.1", 62 | "tslint-immutable": "^5.0.0", 63 | "typedoc": "^0.13.0", 64 | "typescript": "^3.1.6" 65 | }, 66 | "ava": { 67 | "failFast": true, 68 | "files": [ 69 | "build/main/**/*.test.js" 70 | ], 71 | "sources": [ 72 | "build/main/**/*.js" 73 | ] 74 | }, 75 | "config": { 76 | "commitizen": { 77 | "path": "cz-conventional-changelog" 78 | } 79 | }, 80 | "prettier": { 81 | "singleQuote": true 82 | }, 83 | "nyc": { 84 | "exclude": [ 85 | "**/*.test.js" 86 | ] 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { holochainMiddleware } from './lib/middleware' 2 | export { createHolochainZomeCallAsyncAction, createHolochainAdminAsyncAction } from './lib/actionCreator' 3 | -------------------------------------------------------------------------------- /src/lib/actionCreator.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncAction } from 'typesafe-actions' 2 | 3 | /** 4 | * 5 | * Function that creates action creators for holochain zome function calls 6 | * 7 | */ 8 | export const createHolochainZomeCallAsyncAction = ( 9 | instanceId: string, 10 | zome: string, 11 | func: string 12 | ) => { 13 | 14 | const callString = [instanceId, zome, func].join('/') 15 | 16 | const action = createAsyncAction( 17 | callString, 18 | callString + '_SUCCESS', 19 | callString + '_FAILURE') 20 | () 21 | 22 | const newAction = action as (typeof action & { 23 | create: (param: ParamType) => any, 24 | sig: (param: ParamType) => Promise 25 | }) 26 | 27 | // the action creators that are produced 28 | newAction.create = (params: ParamType) => { 29 | return { 30 | type: callString, 31 | meta: { 32 | holochainZomeCallAction: true, 33 | instanceId, 34 | zome, 35 | func 36 | }, 37 | payload: params 38 | } 39 | } 40 | 41 | return newAction 42 | } 43 | 44 | /** 45 | * 46 | * Function that creates action creators for holochain conductor admin calls 47 | * 48 | */ 49 | export const createHolochainAdminAsyncAction = ( 50 | ...segments: Array 51 | ) => { 52 | 53 | const callString = segments.length === 1 ? segments[0] : segments.join('/') 54 | 55 | const action = createAsyncAction( 56 | callString, 57 | callString + '_SUCCESS', 58 | callString + '_FAILURE') 59 | () 60 | 61 | const newAction = action as (typeof action & { 62 | create: (param: ParamType) => any, 63 | sig: (param: ParamType) => Promise 64 | }) 65 | 66 | // the action creators that are produced 67 | newAction.create = (params: ParamType) => { 68 | return { 69 | type: callString, 70 | meta: { 71 | holochainAdminAction: true, 72 | callString 73 | }, 74 | payload: params 75 | } 76 | } 77 | 78 | return newAction 79 | } 80 | -------------------------------------------------------------------------------- /src/lib/middleware.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import sinon from 'sinon' 3 | 4 | import { holochainMiddleware } from './middleware' 5 | import { createHolochainZomeCallAsyncAction, createHolochainAdminAsyncAction } from './actionCreator' 6 | 7 | const mockWebClient = (callResponse: string) => Promise.resolve({ 8 | call: (callStr: string) => (params: any) => { 9 | return Promise.resolve(callResponse) 10 | }, 11 | callZome: (instanceId: string, zome: string, func: string) => (params: any) => { 12 | return Promise.resolve(callResponse) 13 | }, 14 | close: () => Promise.resolve('closed'), 15 | ws: { 16 | subscribe: sinon.spy(), 17 | on: sinon.spy() 18 | } 19 | }) 20 | 21 | const create = (callResponse: any) => { 22 | const store = { 23 | getState: sinon.spy(() => ({})), 24 | dispatch: sinon.spy() 25 | } 26 | const next = sinon.spy() 27 | const invoke = (action: any) => holochainMiddleware(mockWebClient(callResponse))(store)(next)(action) 28 | 29 | return { store, next, invoke } 30 | } 31 | 32 | test('It passes non-holochain actions to the next reducer', async t => { 33 | let { next, invoke } = create('') 34 | 35 | const nonHolochainAction = { type: 'not-holochain-action' } 36 | await invoke(nonHolochainAction) 37 | 38 | t.true(next.calledWith(nonHolochainAction)) 39 | }) 40 | 41 | test('It passes holochain actions and dispatches new action on success. Ok is unwrapped ', async t => { 42 | let { next, invoke, store } = create(JSON.stringify({ Ok: 'success' })) 43 | 44 | const holochainAction = createHolochainZomeCallAsyncAction('happ', 'zome','func') 45 | const result = await invoke(holochainAction.create({})) 46 | 47 | t.deepEqual(result, 'success') 48 | t.true(next.calledWith(holochainAction.create({}))) 49 | t.true(store.dispatch.calledWith(holochainAction.success('success'))) 50 | }) 51 | 52 | test('It passes holochain actions and dispatches new error action on holochain error. Err is unwrapped ', async t => { 53 | let { next, invoke, store } = create(JSON.stringify({ Err: 'fail' })) 54 | 55 | const holochainAction = createHolochainZomeCallAsyncAction('happ', 'zome','func') 56 | 57 | try { 58 | await invoke(holochainAction.create({})) 59 | } catch (result) { 60 | t.deepEqual(result, Error('fail')) 61 | t.true(next.calledWith(holochainAction.create({}))) 62 | t.deepEqual(store.dispatch.lastCall.args[0], holochainAction.failure(Error('fail'))) 63 | } 64 | }) 65 | 66 | test('It passes holochain actions and dispatches new action on success. Raw return is passed directly ', async t => { 67 | let { next, invoke, store } = create(JSON.stringify({ someField: 'success' })) 68 | 69 | const holochainAction = createHolochainZomeCallAsyncAction('happ', 'zome','func') 70 | const result = await invoke(holochainAction.create({})) 71 | 72 | t.deepEqual(result, { someField: 'success' }) 73 | t.true(next.calledWith(holochainAction.create({}))) 74 | t.true(store.dispatch.calledWith(holochainAction.success({ someField: 'success' }))) 75 | }) 76 | 77 | test('can accept container admin style responses which return unstringified json objects', async t => { 78 | let { next, invoke, store } = create({ someField: 'success' }) 79 | 80 | const holochainAction = createHolochainAdminAsyncAction('admin', 'dna', 'list') 81 | const result = await invoke(holochainAction.create({})) 82 | 83 | t.deepEqual(result, { someField: 'success' }) 84 | t.true(next.calledWith(holochainAction.create({}))) 85 | t.true(store.dispatch.calledWith(holochainAction.success({ someField: 'success' }))) 86 | }) 87 | 88 | test('can accept container admin style responses which return unstringified json arrays', async t => { 89 | let { next, invoke, store } = create(['item1', 'item2']) 90 | 91 | const holochainAction = createHolochainAdminAsyncAction('admin', 'dna', 'list') 92 | const result = await invoke(holochainAction.create({})) 93 | 94 | t.deepEqual(result, ['item1', 'item2']) 95 | t.true(next.calledWith(holochainAction.create({}))) 96 | t.true(store.dispatch.calledWith(holochainAction.success(['item1', 'item2']))) 97 | }) 98 | 99 | test('can accept container admin style responses which return plain strings which cannot be parsed to JSON', async t => { 100 | let { next, invoke, store } = create('test string') 101 | 102 | const holochainAction = createHolochainAdminAsyncAction('admin', 'instance', 'list') 103 | const result = await invoke(holochainAction.create({})) 104 | 105 | t.deepEqual(result, 'test string') 106 | t.true(next.calledWith(holochainAction.create({}))) 107 | t.true(store.dispatch.calledWith(holochainAction.success('test string'))) 108 | }) 109 | -------------------------------------------------------------------------------- /src/lib/middleware.ts: -------------------------------------------------------------------------------- 1 | import { Middleware, AnyAction } from 'redux' 2 | 3 | type hcWebClientConnect = Promise<{ 4 | call: (callStr: string) => (params: any) => Promise, 5 | callZome: (instance: string, zome: string, func: string) => (params: any) => Promise, 6 | close: () => Promise, 7 | ws: any 8 | }> 9 | 10 | export const holochainMiddleware = (hcWc: hcWebClientConnect): Middleware => store => { 11 | // stuff here has the same life as the store! 12 | // this is how we persist a websocket connection 13 | 14 | const connectPromise = hcWc.then(({ call, callZome, ws }) => { 15 | store.dispatch({ type: 'HOLOCHAIN_WEBSOCKET_CONNECTED' }) 16 | 17 | ws.on('close', () => { 18 | store.dispatch({ type: 'HOLOCHAIN_WEBSOCKET_DISCONNECTED' }) 19 | }) 20 | 21 | return { call, callZome } 22 | }) 23 | 24 | return next => (action: AnyAction) => { 25 | if (action.meta && (action.meta.holochainZomeCallAction || action.meta.holochainAdminAction)) { 26 | next(action) // resend the original action so the UI can change based on requests 27 | 28 | return connectPromise.then(({ call, callZome }) => { 29 | 30 | let callFunction 31 | if (action.meta.holochainZomeCallAction) { 32 | const { instanceId, zome, func } = action.meta 33 | callFunction = callZome(instanceId, zome, func) 34 | } else { 35 | callFunction = call(action.meta.callString) 36 | } 37 | 38 | return callFunction(action.payload) 39 | .then((rawResult: string) => { 40 | 41 | // holochain calls will strings (possibly stringified JSON) 42 | // while container admin calls will return parsed JSON 43 | let result 44 | try { 45 | result = JSON.parse(rawResult) 46 | } catch (e) { 47 | result = rawResult 48 | } 49 | 50 | if (result.Err !== undefined) { // holochain error 51 | store.dispatch({ 52 | type: action.type + '_FAILURE', 53 | payload: result.Err 54 | }) 55 | return Promise.reject(Error(result.Err)) 56 | } else if (result.Ok !== undefined) { // holochain Ok 57 | store.dispatch({ 58 | type: action.type + '_SUCCESS', 59 | payload: result.Ok 60 | }) 61 | return result.Ok 62 | } else { // unknown. Return raw result as success 63 | store.dispatch({ 64 | type: action.type + '_SUCCESS', 65 | payload: result 66 | }) 67 | return result 68 | } 69 | }) 70 | .catch((err: Error) => { // websocket error 71 | store.dispatch({ 72 | type: action.type + '_FAILURE', 73 | payload: err 74 | }) 75 | return Promise.reject(err) 76 | }) 77 | }) 78 | } else { 79 | return next(action) 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "outDir": "build/main", 5 | "rootDir": "src", 6 | "moduleResolution": "node", 7 | "module": "commonjs", 8 | "declaration": true, 9 | "inlineSourceMap": true, 10 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 11 | 12 | // "strict": true /* Enable all strict type-checking options. */, 13 | 14 | /* Strict Type-Checking Options */ 15 | // "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 16 | // "strictNullChecks": true /* Enable strict null checks. */, 17 | // "strictFunctionTypes": true /* Enable strict checking of function types. */, 18 | // "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */, 19 | // "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, 20 | // "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 21 | 22 | /* Additional Checks */ 23 | "noUnusedLocals": true /* Report errors on unused locals. */, 24 | // "noUnusedParameters": true /* Report errors on unused parameters. */, 25 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 26 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 27 | 28 | /* Debugging Options */ 29 | "traceResolution": false /* Report module resolution log messages. */, 30 | "listEmittedFiles": false /* Print names of generated files part of the compilation. */, 31 | "listFiles": false /* Print names of files part of the compilation. */, 32 | "pretty": true /* Stylize errors and messages using color and context. */, 33 | 34 | /* Experimental Options */ 35 | // "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 36 | // "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, 37 | 38 | "lib": ["es2017"], 39 | "types": [], 40 | "typeRoots": ["node_modules/@types", "src/types"] 41 | }, 42 | "include": ["src/**/*.ts"], 43 | "exclude": ["node_modules/**"], 44 | "compileOnSave": false 45 | } 46 | -------------------------------------------------------------------------------- /tsconfig.module.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "outDir": "build/module", 6 | "module": "esnext" 7 | }, 8 | "exclude": [ 9 | "node_modules/**" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-config-standard"], 3 | "linterOptions": { 4 | "exclude": [ 5 | "_test.ts", 6 | "config/**/*.js", 7 | "node_modules/**/*.ts", 8 | "coverage/lcov-report/*.js" 9 | ] 10 | } 11 | } 12 | --------------------------------------------------------------------------------