├── .gitignore ├── .npmrc ├── diff ├── .npmrc ├── package.json └── src │ └── diff.js ├── license.mit ├── link.sh ├── package.json ├── proxy ├── .npmrc ├── package.json └── src │ └── proxy.js ├── readme.md ├── simple ├── .babelrc ├── .npmrc ├── package.json ├── spec │ ├── store-spec.js │ └── support │ │ └── jasmine.json └── src │ └── simple.js ├── state ├── .npmrc ├── package.json └── src │ └── state.js └── store ├── .npmrc ├── package.json └── src └── store.js /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | *.log 3 | npm-debug.log* 4 | 5 | pids 6 | *.pid 7 | *.seed 8 | 9 | node_modules 10 | dist 11 | bin 12 | 13 | .npm 14 | .node_repl_history 15 | 16 | # Idea 17 | *.iml 18 | .idea/ 19 | *.ipr 20 | *.iws 21 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | access = public 2 | prefix = .npm-packages 3 | -------------------------------------------------------------------------------- /diff/.npmrc: -------------------------------------------------------------------------------- 1 | access = public 2 | prefix = ../.npm-packages 3 | -------------------------------------------------------------------------------- /diff/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@statex/diff", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "src/diff.js", 6 | "scripts": { 7 | "init": "npm install" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "deep-diff": "^0.3.4" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /diff/src/diff.js: -------------------------------------------------------------------------------- 1 | const deepDiff = require('deep-diff').diff; 2 | 3 | function isObject(val) { 4 | return val !== null && typeof val === 'object'; 5 | } 6 | 7 | function accessor(val, prop) { 8 | return Array.isArray(val) && !isNaN(prop) ? parseInt(prop, 10) : prop; 9 | } 10 | 11 | function traverse(val, path) { 12 | if (!isObject(val)) { 13 | return [path]; 14 | } 15 | 16 | return Object.keys(val).reduce((arr, key) => { 17 | return arr.concat(traverse(val[key], path.concat(accessor(val, key)))); 18 | }, []); 19 | } 20 | 21 | function extract(difference) { 22 | if (difference.kind === 'D') { 23 | return traverse(difference.lhs, difference.path); 24 | } 25 | if (difference.kind === 'N') { 26 | return traverse(difference.rhs, difference.path); 27 | } 28 | return [difference.path]; 29 | } 30 | 31 | module.exports = function diff(obj1, obj2, path) { 32 | const differences = deepDiff(obj1, obj2) || []; 33 | return differences.reduce((arr, difference) => arr.concat(extract(difference)), []); 34 | }; 35 | -------------------------------------------------------------------------------- /license.mit: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017, Tapio Rautonen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /link.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cd diff && npm link && cd .. 4 | cd proxy && npm link && cd .. 5 | cd state && npm link && cd .. 6 | cd store && npm link @statex/state && npm link && cd .. 7 | cd simple && npm link @statex/diff && npm link @statex/proxy && npm link @statex/state && npm link @statex/store && cd .. 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "statex", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "global": "npm install --global npm-run-all babel-cli", 8 | "link": "./link.sh", 9 | "init:diff": "cd diff && npm run init", 10 | "init:proxy": "cd proxy && npm run init", 11 | "init:state": "cd state && npm run init", 12 | "init:store": "cd store && npm run init", 13 | "init:simple": "cd simple && npm run init", 14 | "init": "npm run global && .npm-packages/bin/npm-run-all link init:diff init:proxy init:state init:store init:simple" 15 | }, 16 | "author": "", 17 | "license": "ISC" 18 | } 19 | -------------------------------------------------------------------------------- /proxy/.npmrc: -------------------------------------------------------------------------------- 1 | access = public 2 | prefix = ../.npm-packages 3 | -------------------------------------------------------------------------------- /proxy/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@statex/proxy", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "src/proxy.js", 6 | "scripts": { 7 | "init": "npm install" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /proxy/src/proxy.js: -------------------------------------------------------------------------------- 1 | function accessor(val, prop) { 2 | return Array.isArray(val) && !isNaN(prop) ? parseInt(prop, 10) : prop; 3 | } 4 | 5 | function listener(beforeChange, afterChange, path) { 6 | return { 7 | set(obj, prop, value) { 8 | const propPath = path.concat(accessor(obj, prop)); 9 | beforeChange(propPath, value); 10 | obj[prop] = proxy(value, beforeChange, afterChange, propPath); 11 | afterChange(propPath, value); 12 | return value; 13 | }, 14 | get(obj, prop) { 15 | if (prop === '__isProxy') { 16 | return true; 17 | } 18 | return obj[prop]; 19 | } 20 | }; 21 | } 22 | 23 | function proxy(value, beforeChange, afterChange, path = []) { 24 | if (value !== null && typeof value === 'object' && !value.__isProxy) { 25 | for (const prop of Object.keys(value)) { 26 | value[prop] = proxy(value[prop], beforeChange, afterChange, path.concat(accessor(value, prop))); 27 | } 28 | return Array.isArray(value) ? value : new Proxy(value, listener(beforeChange, afterChange, path)); 29 | } 30 | return value; 31 | } 32 | 33 | module.exports = function createProxy(value, beforeChange, afterChange) { 34 | return proxy(value, beforeChange, afterChange); 35 | }; 36 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | StateX 2 | ====== 3 | 4 | Simple and concise state management library for Node.js and browsers. Heavily inspired by 5 | [VueX](https://github.com/vuejs/vuex). 6 | 7 | 8 | ### Development 9 | 10 | StateX uses micromodule architecture where different logical parts of the library are provided 11 | as scoped modules. This makes development experience a bit bad. NPM links help with this and the 12 | root project provides an initialization script to set up everything for development. 13 | 14 | Just run `npm run init` in this root directory. The initialization script changes global node modules 15 | directory to local `.npm-packages` and creates links for all submodules. The script also runs 16 | install for all submodules so everything is ready after this. 17 | 18 | 19 | ### Todo 20 | 21 | * proper build with ci 22 | * ES6 codebase with packaging to ES6/ES5 23 | * some tests 24 | * code cleanup 25 | 26 | 27 | ### License 28 | 29 | MIT 30 | -------------------------------------------------------------------------------- /simple/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["transform-es2015-modules-commonjs"] 3 | } 4 | -------------------------------------------------------------------------------- /simple/.npmrc: -------------------------------------------------------------------------------- 1 | access = public 2 | prefix = ../.npm-packages 3 | -------------------------------------------------------------------------------- /simple/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@statex/simple", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/simple.js", 6 | "scripts": { 7 | "init": "npm install", 8 | "test": "../.npm-packages/bin/babel-node node_modules/.bin/jasmine" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "@statex/diff": "*", 14 | "@statex/proxy": "*", 15 | "@statex/state": "*", 16 | "@statex/store": "*", 17 | "lodash": "^4.17.4" 18 | }, 19 | "devDependencies": { 20 | "babel-plugin-transform-es2015-modules-commonjs": "^6.23.0", 21 | "jasmine": "^2.5.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /simple/spec/store-spec.js: -------------------------------------------------------------------------------- 1 | import createStore from '../src/simple' 2 | 3 | describe('store', () => { 4 | 5 | it("should dispatch change for mutation", (done) => { 6 | const store = createStore({ 7 | initial: { 8 | root: 'hello' 9 | }, 10 | mutations: { 11 | update: (state, value) => { 12 | state.root = value; 13 | } 14 | } 15 | }); 16 | 17 | store.subscribe('root', root => { 18 | expect(root).toEqual('world'); 19 | done(); 20 | }); 21 | 22 | store.commit('update', 'world'); 23 | }); 24 | 25 | it("should dispatch change for nested mutation", (done) => { 26 | const store = createStore({ 27 | initial: { 28 | root: { 29 | child1: 'a', 30 | child2: 'b' 31 | } 32 | }, 33 | mutations: { 34 | child1: (state, value) => { 35 | state.root.child1 = value; 36 | }, 37 | child2: (state, value) => { 38 | state.root.child2 = value; 39 | } 40 | } 41 | }); 42 | 43 | store.subscribe('root.child2', value => { 44 | expect(value).toEqual('d'); 45 | done(); 46 | }); 47 | 48 | store.commit('child1', 'c'); 49 | store.commit('child2', 'd'); 50 | }); 51 | 52 | it("should dispatch multiple changes in batch", (done) => { 53 | const store = createStore({ 54 | initial: { 55 | root: { 56 | child1: 'a', 57 | child2: 'b' 58 | } 59 | }, 60 | mutations: { 61 | child: (state, value) => { 62 | state.root.child1 = value; 63 | }, 64 | root: (state, value) => { 65 | state.root = value; 66 | } 67 | } 68 | }); 69 | 70 | store.subscribe('root', root => { 71 | expect(root).toEqual({ child1: 'a', child2: 'c' }); 72 | done(); 73 | }); 74 | 75 | store.commit('child', 'e'); 76 | store.commit('root', { child1: 'a', child2: 'c' }) 77 | }); 78 | 79 | }); 80 | -------------------------------------------------------------------------------- /simple/spec/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "spec", 3 | "spec_files": [ 4 | "**/*-spec.js" 5 | ], 6 | "helpers": [ 7 | "helpers/**/*.js" 8 | ], 9 | "stopSpecOnExpectationFailure": false, 10 | "random": false 11 | } 12 | -------------------------------------------------------------------------------- /simple/src/simple.js: -------------------------------------------------------------------------------- 1 | const diff = require('@statex/diff'); 2 | const proxy = require('@statex/proxy'); 3 | const store = require('@statex/store'); 4 | const clone = require('lodash/cloneDeep'); 5 | 6 | module.exports = function createStore(options) { 7 | if (!options.diff) { 8 | options.diff = diff; 9 | } 10 | if (!options.proxy) { 11 | options.proxy = proxy; 12 | } 13 | if (!options.clone) { 14 | options.clone = clone; 15 | } 16 | return store(options); 17 | }; 18 | -------------------------------------------------------------------------------- /state/.npmrc: -------------------------------------------------------------------------------- 1 | access = public 2 | prefix = ../.npm-packages 3 | -------------------------------------------------------------------------------- /state/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@statex/state", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "src/state.js", 6 | "scripts": { 7 | "init": "npm install" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "next-tick": "^1.0.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /state/src/state.js: -------------------------------------------------------------------------------- 1 | const nextTick = require('next-tick'); 2 | 3 | function isFunction(val) { 4 | return val && typeof val === 'function'; 5 | } 6 | 7 | function createState(options = {}) { 8 | const { 9 | initial = {}, 10 | maxHistory = 1, 11 | handler, 12 | proxy, 13 | clone 14 | } = options; 15 | 16 | if (!isFunction(handler)) { 17 | throw new Error('handler is not defined'); 18 | } 19 | 20 | if (!isFunction(proxy)) { 21 | throw new Error('proxy is not defined'); 22 | } 23 | 24 | if (!isFunction(clone)) { 25 | throw new Error('clone is not defined'); 26 | } 27 | 28 | let states = []; 29 | let changes = []; 30 | let batching = null; 31 | 32 | function dispatch() { 33 | if (changes.length > 0) { 34 | handler(states[0], states[1], changes); 35 | changes = []; 36 | } 37 | batching = null; 38 | } 39 | 40 | function beforeChange(path) { 41 | if (!batching) { 42 | version(); 43 | batching = nextTick(() => dispatch()) 44 | } 45 | changes.push(path); 46 | } 47 | 48 | function afterChange() { 49 | // noop 50 | } 51 | 52 | function current() { 53 | return states[0]; 54 | } 55 | 56 | function version() { 57 | if (states.length > maxHistory) { 58 | states.pop(); 59 | } 60 | const state = current(); 61 | const cloned = clone(state); 62 | states.splice(1, 0, cloned); 63 | return state; 64 | } 65 | 66 | function revert() { 67 | if (states.length > 1) { 68 | states.shift(); 69 | states[0] = proxy(states[0], beforeChange, afterChange); 70 | } 71 | return current(); 72 | } 73 | 74 | function init(state) { 75 | states.push(proxy(state, beforeChange, afterChange)); 76 | return { 77 | current, 78 | version, 79 | revert 80 | }; 81 | } 82 | 83 | const state = isFunction(initial) ? initial() : initial; 84 | return init(state); 85 | } 86 | 87 | module.exports = createState; 88 | -------------------------------------------------------------------------------- /store/.npmrc: -------------------------------------------------------------------------------- 1 | access = public 2 | prefix = ../.npm-packages 3 | -------------------------------------------------------------------------------- /store/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@statex/store", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "src/store.js", 6 | "scripts": { 7 | "init": "npm install" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@statex/state": "*", 13 | "lodash": "^4.17.4" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /store/src/store.js: -------------------------------------------------------------------------------- 1 | const createState = require('@statex/state'); 2 | 3 | function isFunction(val) { 4 | return val && typeof val === 'function'; 5 | } 6 | 7 | function isPromise(val) { 8 | return val && isFunction(val.then); 9 | } 10 | 11 | function getNestedValue(obj, path) { 12 | const props = Array.isArray(path) ? path : path.split('.'); 13 | return props.reduce((val, prop) => val[prop], obj); 14 | } 15 | 16 | function forwardSubscriptions(subscriptions, state, paths) { 17 | for (const subscription of subscriptions) { 18 | const match = paths.some(path => { 19 | return subscription.path.reduce((truth, segment, idx) => { 20 | return truth && segment === path[idx]; 21 | }, true); 22 | }); 23 | if (match) { 24 | subscription.handler(getNestedValue(state, subscription.path)); 25 | } 26 | } 27 | } 28 | 29 | function createStore(options) { 30 | const { 31 | mutations = {}, 32 | actions = {}, 33 | diff 34 | } = options; 35 | 36 | let subscriptions = []; 37 | 38 | options.handler = (newState, oldState, changes) => { 39 | const differences = diff(oldState, newState); 40 | if (differences.length > 0) { 41 | forwardSubscriptions(subscriptions, newState, differences); 42 | } 43 | }; 44 | 45 | const state = createState(options); 46 | 47 | function commit(type, payload) { 48 | const entry = mutations[type]; 49 | entry(state.current(), payload); 50 | } 51 | 52 | function dispatch(type, payload) { 53 | const entry = actions[type]; 54 | const context = { 55 | state: state.current(), 56 | commit: commit, 57 | dispatch: dispatch 58 | }; 59 | const result = entry(context, payload); 60 | return isPromise(result) ? result : Promise.resolve(result); 61 | } 62 | 63 | function subscribe(path, handler) { 64 | if (subscriptions.findIndex(sub => sub.handler === handler) === -1) { 65 | subscriptions.push({ 66 | path: Array.isArray(path) ? path : path.split('.'), 67 | handler: handler 68 | }); 69 | } 70 | return () => { 71 | const idx = subscriptions.findIndex(sub => sub.handler === handler); 72 | if (idx !== -1) { 73 | subscriptions.splice(idx, 1) 74 | } 75 | } 76 | } 77 | 78 | return { 79 | commit, 80 | dispatch, 81 | subscribe 82 | } 83 | } 84 | 85 | module.exports = createStore; 86 | --------------------------------------------------------------------------------