├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── examples ├── counter │ ├── app.js │ └── flux.js ├── react-hot │ ├── .eslintrc │ ├── client │ │ ├── Application.js │ │ ├── app.js │ │ └── dispatcher.js │ ├── index.html │ ├── package.json │ ├── server.js │ └── webpack.config.js └── react-iso │ ├── .eslintrc │ ├── client │ ├── app.js │ ├── components │ │ ├── Application.js │ │ ├── Error404.js │ │ ├── Film.js │ │ ├── Films.js │ │ ├── Home.js │ │ ├── Planet.js │ │ └── Planets.js │ ├── data.js │ ├── dispatcher.js │ ├── routes.js │ ├── stores │ │ ├── films.js │ │ ├── planets.js │ │ └── routing.js │ └── swapi.js │ ├── index.html │ ├── package.json │ ├── server-render.js │ ├── server.js │ └── webpack.config.js ├── fluctuations.js ├── package.json └── test ├── .eslintrc └── test.js /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "rules": { 6 | "strict": 0, 7 | "quotes": 0, 8 | "indent": [2, 2], 9 | "curly": [2, "multi-line"], 10 | "no-use-before-define": "func", 11 | "no-unused-vars": [2, "all"], 12 | "no-mixed-requires": [1, true], 13 | "max-depth": [1, 5], 14 | "max-len": [1, 80, 4], 15 | "max-params": [1, 6], 16 | "max-statements": [1, 20], 17 | "eqeqeq": 0, 18 | "new-cap": 0, 19 | "no-else-return": 1, 20 | "no-eq-null": 1, 21 | "no-lonely-if": 1, 22 | "no-path-concat": 0, 23 | "comma-dangle": 0, 24 | "complexity": [1, 20], 25 | "no-floating-decimal": 1, 26 | "no-void": 1, 27 | "no-sync": 1, 28 | "consistent-this": [1, "nope-dont-capture-this"], 29 | "max-nested-callbacks": [2, 3], 30 | "no-nested-ternary": 1, 31 | "space-after-keywords": [1, "always"], 32 | "space-before-function-paren": [1, "never"], 33 | "spaced-line-comment": [1, "always"] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | examples 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - "0.10" 5 | - "0.12" 6 | - "iojs" 7 | script: "npm run travis" 8 | after_script: "cat ./coverage/lcov.info | ./node_modules/.bin/coveralls" 9 | matrix: 10 | allow_failures: 11 | - node_js: "iojs" 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Glen Mailer 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 all 13 | 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 THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fluctuations 2 | 3 | Yet another flux implementation 4 | 5 | Formerly known as `flux-redux`. 6 | 7 | [![npm version](https://img.shields.io/npm/v/fluctuations.svg)](https://www.npmjs.com/package/fluctuations) [![Build Status](https://img.shields.io/travis/glenjamin/fluctuations/master.svg)](https://travis-ci.org/glenjamin/fluctuations) [![Coverage Status](https://coveralls.io/repos/glenjamin/fluctuations/badge.svg?branch=master)](https://coveralls.io/r/glenjamin/fluctuations?branch=master) ![MIT Licensed](https://img.shields.io/npm/l/fluctuations.svg) 8 | 9 | ## Goals 10 | 11 | * Simple Implementation 12 | * Small API 13 | * Flexible 14 | * Functional rather than OO 15 | * Reducer-style stores 16 | * Actions as simple data 17 | * Action interceptors for async stuff 18 | * No singletons 19 | * Easy to use with Immutable.js 20 | * Easy to run isomorphically 21 | * Easy to use with hot reloading 22 | 23 | ## Install 24 | 25 | ```sh 26 | npm install fluctuations 27 | ``` 28 | 29 | ## Usage 30 | 31 | ```js 32 | var fluctuations = require('fluctuations'); 33 | 34 | var store = fluctuations.createStore( 35 | function() { 36 | return { initial: 'data', number: 0 }; 37 | }, 38 | { 39 | CHANGE_MESSAGE: function(state, payload) { 40 | state.initial = payload.value; 41 | return state; 42 | }, 43 | INC_NUMBER: function(state) { 44 | state.number += 1; 45 | return state; 46 | } 47 | } 48 | ); 49 | 50 | var interceptor = fluctuations.createInterceptor({ 51 | FETCH_MESSAGE: function(emit, payload) { 52 | emit("FETCH_MESSAGE_BEGIN"); 53 | setTimeout(function() { 54 | emit("CHANGE_MESSAGE", { value: "whatever" }); 55 | }, 2000); 56 | } 57 | }); 58 | 59 | var flux = fluctuations.createDispatcher(); 60 | flux.addInterceptor('stuff', interceptor); 61 | flux.addStore('stuff', store); 62 | 63 | flux.listen("logging", function() { 64 | console.log(flux.get()); 65 | }); 66 | 67 | flux.dispatch("INC_NUMBER"); 68 | ``` 69 | 70 | ## Concepts 71 | 72 | **Fluctuations** is based around the Flux architecture as laid out by facebook. See the [flux documentation](https://facebook.github.io/flux/docs/overview.html#structure-and-data-flow) for more information. We keep the concepts defined by facebook, but make a few tweaks. Most notably **Action Creators** are removed, and **Action Interceptors** are introduced to perform a similar role. 73 | 74 | ### Action Interceptors 75 | 76 | In early explanations of flux, the role of actions was a bit blurred. They seem to behave like commands and like events. As implementations were further clarified, Action Creators were explained as representing the command portion, while the data representation they sent to the dispatcher is referred to as the action. For many simple actions, this results in boilerplate code which translates a function call into a data payload. More complicated actions can use this layer of indirection to perform multiple actions, do asynchronous lookups etc. 77 | 78 | The goal of action interceptors is to retain this capability, but remove the boilerplate code required in the common case. The **dispatcher** remains the central point for all communication. Stores and interceptors are attached to a dispatcher instance. Subscriptions are managed via the dispatcher, and the UI is expected to be able to call `dispatch()` directly. 79 | 80 | Unlike creators, Interceptors sit behind the dispatcher. The actions which are dispatched into the dispatcher are intended to be treated like commands. If no interceptor exists then the action is treated like an event, and forwarded to all stores. If an interceptor chooses to handle the command, it is then freeto translate it into whatever event-like actions it wants to. Interceptors are also able to re-dispatch new commands and read the state of stores. This allows them full flexibility when deciding what events they must produce. 81 | 82 | To summarise the key points here: 83 | 84 | * **Stores** receive actions which should be treated like *events* 85 | * The **Dispatcher** receives actions which should be treated like *commands* 86 | * **Action Interceptors** capture a *command* and produce *events* 87 | * If no interceptor exists, a *command* becomes an *event* 88 | 89 | ### Hot Reloading 90 | 91 | The practice of hot reloading is making a system which can receive new code at runtime, and incorporate it into itself - ideally behaving the same as if it had been started afresh. The goal being to reduce the feedback cycle between changes. 92 | 93 | The simplest way to make code hot reloadable is to make it pure (stateless), as soon as state is introduced, we have to decide what to do with it when reloading. 94 | 95 | To make hot reloading easier, fluctuations minimises the number of places state is held - everything is kept in the dispatcher. In addition, every time something is attached to the dispatcher it is required to pass a `key` which names it uniquely. This is used to ensure the same item is never duplicated. 96 | 97 | To hot reload fluctuations, you just need to re-use the dispatcher instance every time, like in the following webpack example: 98 | 99 | ```js 100 | var dispatcher = flux.createDispatcher(); 101 | if (module.hot) { 102 | if (module.hot.data) { 103 | dispatcher = module.hot.data.dispatcher; 104 | } 105 | module.hot.accept(); 106 | module.hot.dispose((data) => data.dispatcher = dispatcher); 107 | } 108 | ``` 109 | 110 | # Docs 111 | 112 | In addition to these API docs, there are a few examples you can look at. 113 | 114 | * [Counter](examples/counter/) - Basic data handling 115 | * [React Hot](examples/react-hot/) - React w/ hot module reloading and async data fetching 116 | * [React Isomorphic](examples/react-iso) - React w/ hot module reloading, routing, route-aware async data fetching & server rendering 117 | 118 | ## API 119 | 120 | ### `fluctuations` 121 | 122 | ```js 123 | var fluctuations = require('fluctuations'); 124 | ``` 125 | 126 | #### `.createDispatcher(options)` 127 | 128 | Create yourself a shiny new dispatcher instance. 129 | 130 | * `options` *{object}* - additional creation options 131 | * `options.state` *{object}* - pass this to reuse state from a previous dispatcher 132 | 133 | Returns [*{Dispatcher}*](#dispatcher) 134 | 135 | #### `.createStore(initial, handlers, merge)` 136 | 137 | Create yourself a shiny new store representation. 138 | 139 | Store representations by themselves don't do anything, they should be attached to your friendly neighbourhood dispatcher instance to make things work. 140 | 141 | * `initial` *{function() => state}* - will be called when attaching a store to a dispatcher. The return value will become the initial state. 142 | * `handlers` *{object}* - mapping of action-name to handler function, where handler functions are *{function(state, payload) => newState}*. See [Store Handlers](#store-handlers) below. 143 | * `merge` (optional) *{function(state, newState) => state}* - will be called when a store is being replaced, and can be used to combine the old and new states. The return value will become the new store state. 144 | 145 | Returns [*{StoreSpec}*](#addstorekey-store) 146 | 147 | ##### Store Handlers 148 | 149 | Store handlers map incoming actions to state changes that should be made. The handler function will receive the current state of the store and any action payload, and should return a new state for the store. 150 | 151 | For example, this set of handlers will increment and decrement the a number in the store as actions are passed in. 152 | ```js 153 | { 154 | INC: function(state, n) { 155 | return { n: state.n + n }; 156 | }, 157 | DEC: function(state, n) { 158 | return { n: state.n - n }; 159 | } 160 | } 161 | ``` 162 | In general you are likely to want to use a merge function to ensure you don't replace properties you're not interested in, or look into using something like [Immutable](http://facebook.github.io/immutable-js/) here instead. 163 | 164 | #### `.createInterceptor(handlers)` 165 | 166 | Create yourself a shiny new interceptor representation. 167 | 168 | Interceptor representations by themselves don't do anything, they should be attached to your friendly neighbourhood dispatcher instance to make things work. 169 | 170 | * `handlers` *{object}* - mapping of action-name to handler function, where handler functions are `function(emit, payload)`. See [Interceptor Handlers](#interceptor-handlers) below. 171 | 172 | Returns [*{InterceptorSpec}*](#addinterceptorkey-interceptor) 173 | 174 | #### Interceptor Handlers 175 | 176 | > TODO: flesh this out properly 177 | 178 | Handlers come in two flavours, the first is the simple common case, the second provides more flexibility. 179 | 180 | `function(emit, payload)` 181 | * `emit = function(action, payload)` send action to stores 182 | * `payload` the data for the incoming action 183 | 184 | `function(system, payload)` 185 | * `system.emit = function(action, payload)` send action to stores 186 | * `system.redispatch = function(action, payload)` send action back to dispatcher so it can be re-intercepted 187 | * `system.state` the state of the system when the action was intercepted 188 | * `payload` the data for the incoming action 189 | 190 | ### `Dispatcher` 191 | 192 | The dispatcher is the central point of the application's data flow, everything else plugs into it. 193 | 194 | #### `.addStore(key, store)` 195 | 196 | Attach a *{StoreSpec}* as created by `createStore` into the dispatcher. This will delegate management of the value at `key` to the store's handlers. 197 | 198 | If attaching a store with the same `key` as a previous store, it will be overwritten, using the merge strategy to combine the new initial and the current state. This is very useful when hot reloading. 199 | 200 | * `key` *{string}* - unique name to identify this store 201 | * `store` *{StoreSpec}* - the store details, as produced by createStore 202 | 203 | #### `.addInterceptor(key, interceptor)` 204 | 205 | Attach an *{InterceptorSpec}* as create by `createInterceptor` into the dispatcher. This will cause the interceptor to capture any action matched by it's handlers, and allow it to emit multiple actions over time instead. 206 | 207 | If attaching an interceptor with the same `key` as a previous interceptor, it will be overwritten. This is very useful when hot reloading. 208 | 209 | #### `.dispatch(action, payload)` 210 | 211 | Send an action into the dispatcher. 212 | 213 | * `action` *{string}* - name of the action 214 | * `payload` *{any}* - extra data associated with the action, usually an object 215 | 216 | #### `.listen(key, listener)` 217 | 218 | Attach a listener to the dispatcher which will be called whenever the state of the system changes. It is expected that you'll want to call [get()](#get) within this listener. 219 | 220 | If attaching a listener with the same `key` as a previous listener, it will be overwritten. This can be very useful when hot reloading, as it means you don't need to clean up old listeners. 221 | 222 | * `key` *{string}* - unique name to identify this listener 223 | * `listener` *{function()}* - function to be called when state changes 224 | 225 | #### `.get()` 226 | 227 | Retreive the current state of the whole system. This will be an object with a `key` for each store containing the last known state of that store. 228 | 229 | Returns *{object}* 230 | 231 | 232 | # TODO 233 | 234 | * High level tests 235 | * Low level tests 236 | * Cycle detection? 237 | * Separate dispatcher definition and instances? 238 | * Benchmarking / profiling 239 | * Granular subscriptions 240 | * tidy up docs 241 | -------------------------------------------------------------------------------- /examples/counter/app.js: -------------------------------------------------------------------------------- 1 | var dispatch = require('./flux'); 2 | 3 | dispatch('INC'); 4 | dispatch('INC'); 5 | dispatch('INC'); 6 | 7 | dispatch('SLOW_INC'); 8 | dispatch('SLOW_INC', { delay: 5000 }); 9 | -------------------------------------------------------------------------------- /examples/counter/flux.js: -------------------------------------------------------------------------------- 1 | var fluctuations = require('../..'); 2 | 3 | function copy(a, b) { 4 | var ret = {}; 5 | function add(k) { ret[k] = this[k]; } 6 | Object.keys(a).forEach(add, a); 7 | Object.keys(b).forEach(add, b); 8 | return ret; 9 | } 10 | 11 | var store = fluctuations.createStore( 12 | function() { 13 | return { number: 0, pending: 0 }; 14 | }, 15 | { 16 | INC_WAIT: function(state) { 17 | return copy(state, { pending: state.pending + 1 }); 18 | }, 19 | INC_DONE: function(state) { 20 | return copy(state, { 21 | number: state.number + 1, 22 | pending: state.pending - 1 23 | }); 24 | }, 25 | INC: function(state) { 26 | return copy(state, { number: state.number + 1 }); 27 | } 28 | } 29 | ); 30 | 31 | var interceptor = fluctuations.createInterceptor({ 32 | SLOW_INC: function(emit, payload) { 33 | emit("INC_WAIT"); 34 | setTimeout(function() { 35 | emit("INC_DONE"); 36 | }, payload.delay || 1000); 37 | } 38 | }); 39 | 40 | var flux = fluctuations.createDispatcher(); 41 | 42 | flux.addInterceptor('n', interceptor); 43 | flux.addStore('n', store); 44 | 45 | flux.listen("logging", function() { 46 | var stores = flux.get(); 47 | console.log("%d%s", 48 | stores.n.number, 49 | stores.n.pending > 0 ? ' (pending)' : ''); 50 | }); 51 | 52 | module.exports = flux.dispatch; 53 | -------------------------------------------------------------------------------- /examples/react-hot/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "parser": "babel-eslint", 6 | "ecmaFeatures": { 7 | "jsx": true 8 | }, 9 | "plugins": [ 10 | "no-class", 11 | "react" 12 | ], 13 | "rules": { 14 | "strict": 0, 15 | "quotes": 0, 16 | "indent": [2, 2], 17 | "curly": [2, "multi-line"], 18 | "no-use-before-define": "func", 19 | "no-unused-vars": [2, "all"], 20 | "no-mixed-requires": [1, true], 21 | "max-depth": [1, 5], 22 | "max-len": [1, 80, 4], 23 | "max-params": [1, 6], 24 | "max-statements": [1, 20], 25 | "eqeqeq": 0, 26 | "new-cap": 0, 27 | "no-else-return": 1, 28 | "no-eq-null": 1, 29 | "no-lonely-if": 1, 30 | "no-path-concat": 0, 31 | "comma-dangle": 0, 32 | "complexity": [1, 20], 33 | "no-floating-decimal": 1, 34 | "no-void": 1, 35 | "no-sync": 1, 36 | "consistent-this": [1, "nope-dont-capture-this"], 37 | "max-nested-callbacks": [2, 3], 38 | "no-nested-ternary": 1, 39 | "space-after-keywords": [1, "always"], 40 | "space-before-function-paren": [1, "never"], 41 | "spaced-line-comment": [1, "always"], 42 | "no-class/no-class": 2, 43 | "react/jsx-boolean-value": 1, 44 | "react/jsx-quotes": 0, 45 | "react/jsx-no-undef": 1, 46 | "react/jsx-sort-props": 0, 47 | "react/jsx-sort-prop-types": 0, 48 | "react/jsx-uses-react": 1, 49 | "react/jsx-uses-vars": 1, 50 | "react/no-did-mount-set-state": 1, 51 | "react/no-did-update-set-state": 1, 52 | "react/no-multi-comp": 0, 53 | "react/no-unknown-property": 1, 54 | "react/prop-types": 1, 55 | "react/react-in-jsx-scope": 1, 56 | "react/self-closing-comp": 0, 57 | "react/wrap-multilines": 1 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /examples/react-hot/client/Application.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | 3 | var Application = React.createClass({ 4 | propTypes: { 5 | dispatch: React.PropTypes.func, 6 | stores: React.PropTypes.object 7 | }, 8 | render() { 9 | var {swapi} = this.props.stores; 10 | var {films = []} = swapi; 11 | return ( 12 |
13 | 18 | {swapi.loading && "Loading..."} 19 |
20 | {films.map(this.renderFilm)} 21 |
22 |
23 | ); 24 | }, 25 | renderFilm({id, name}) { 26 | return

{name}

; 27 | } 28 | }); 29 | 30 | module.exports = Application; 31 | -------------------------------------------------------------------------------- /examples/react-hot/client/app.js: -------------------------------------------------------------------------------- 1 | /*eslint-env browser*/ 2 | 3 | var React = require('react'); 4 | 5 | var Application = require('./Application'); 6 | 7 | var dispatcher = require('./dispatcher'); 8 | 9 | dispatcher.listen('react', render); 10 | 11 | render(); 12 | 13 | function render() { 14 | React.render( 15 | , 19 | document.getElementById('root') 20 | ); 21 | } 22 | 23 | if (module.hot) { 24 | module.hot.accept(); 25 | } 26 | -------------------------------------------------------------------------------- /examples/react-hot/client/dispatcher.js: -------------------------------------------------------------------------------- 1 | var flux = require('fluctuations'); 2 | 3 | var merge = require('deep-extend'); 4 | var request = require('browser-request'); 5 | 6 | /** 7 | * Hot reloading stores! 8 | * 9 | * The trick is to always re-use the `dispatcher` instance 10 | * 11 | * dispatcher.addStore will use the stores' merge functions 12 | * when re-adding another store with the same key 13 | */ 14 | var dispatcher = flux.createDispatcher(); 15 | if (module.hot) { 16 | if (module.hot.data) { 17 | dispatcher = module.hot.data.dispatcher; 18 | } 19 | module.hot.accept(); 20 | module.hot.dispose((data) => data.dispatcher = dispatcher); 21 | } 22 | 23 | dispatcher.addInterceptor('swapi', flux.createInterceptor({ 24 | FETCH(emit, type) { 25 | emit("LOADING"); 26 | var options = { 27 | uri: "http://swapi.co/api/" + encodeURIComponent(type), 28 | json: true 29 | }; 30 | request(options, (err, res, body) => { 31 | if (err) { 32 | return emit("ERROR", err); 33 | } 34 | emit("DATA_" + type.toUpperCase(), { data: body }); 35 | }); 36 | } 37 | })); 38 | 39 | var initial = () => ({ loading: false }); 40 | dispatcher.addStore('swapi', flux.createStore( 41 | initial, 42 | { 43 | LOADING() { 44 | var state = initial(); 45 | state.loading = true; 46 | return state; 47 | }, 48 | DATA_FILMS(state, { data }) { 49 | state.loading = false; 50 | state.films = data.results.map(x => ( 51 | { id: x.episode_id, name: x.title } 52 | )); 53 | return state; 54 | } 55 | }, 56 | merge 57 | )); 58 | 59 | module.exports = dispatcher; 60 | -------------------------------------------------------------------------------- /examples/react-hot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Sample App 4 | 5 | 6 |
7 |
8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/react-hot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fluctuations-example-react-hot", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "React example", 6 | "author": "Glen Mailer ", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "babel-core": "^5.4.7", 10 | "babel-eslint": "^3.1.9", 11 | "babel-loader": "^5.1.3", 12 | "babel-runtime": "^5.4.7", 13 | "browser-request": "^0.3.3", 14 | "deep-extend": "^0.4.0", 15 | "eslint": "^0.21.2", 16 | "eslint-plugin-no-class": "^0.1.0", 17 | "eslint-plugin-react": "^2.3.0", 18 | "fluctuations": "file:../..", 19 | "node-libs-browser": "^0.5.2", 20 | "react": "^0.13.3", 21 | "react-hot-loader": "^1.2.7", 22 | "webpack": "^1.9.10", 23 | "webpack-dev-server": "^1.9.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/react-hot/server.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var WebpackDevServer = require('webpack-dev-server'); 3 | var config = require('./webpack.config'); 4 | 5 | var compiler = webpack(config); 6 | 7 | compiler.plugin("compile", function() { 8 | console.log("webpack building..."); 9 | }); 10 | compiler.plugin("done", function(stats) { 11 | stats = stats.toJson(); 12 | console.log("webpack built %s in %dms", stats.hash, stats.time); 13 | }); 14 | 15 | var server = new WebpackDevServer(compiler, { 16 | publicPath: config.output.publicPath, 17 | hot: true, 18 | noInfo: true, 19 | historyApiFallback: true 20 | }); 21 | 22 | server.listen(3000, 'localhost', function(err) { 23 | if (err) throw err; 24 | 25 | var addr = server.listeningApp.address(); 26 | 27 | console.log('Listening at http://%s:%d', addr.address, addr.port); 28 | }); 29 | -------------------------------------------------------------------------------- /examples/react-hot/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: '#source-map', 6 | entry: [ 7 | 'webpack-dev-server/client?http://localhost:3000', 8 | 'webpack/hot/dev-server', 9 | './client/app.js' 10 | ], 11 | output: { 12 | path: __dirname, 13 | filename: 'bundle.js', 14 | publicPath: '/' 15 | }, 16 | plugins: [ 17 | new webpack.optimize.OccurenceOrderPlugin(), 18 | new webpack.HotModuleReplacementPlugin(), 19 | new webpack.NoErrorsPlugin() 20 | ], 21 | resolve: { 22 | extensions: ['', '.js'] 23 | }, 24 | module: { 25 | loaders: [{ 26 | test: /\.js$/, 27 | loaders: ['react-hot', 'babel'], 28 | include: path.join(__dirname, 'client') 29 | }] 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /examples/react-iso/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "parser": "babel-eslint", 6 | "ecmaFeatures": { 7 | "jsx": true 8 | }, 9 | "plugins": [ 10 | "no-class", 11 | "react" 12 | ], 13 | "rules": { 14 | "strict": 0, 15 | "quotes": 0, 16 | "indent": [2, 2], 17 | "curly": [2, "multi-line"], 18 | "no-use-before-define": "func", 19 | "no-unused-vars": [2, "all"], 20 | "no-mixed-requires": [1, true], 21 | "max-depth": [1, 5], 22 | "max-len": [1, 80, 4], 23 | "max-params": [1, 6], 24 | "max-statements": [1, 20], 25 | "eqeqeq": 0, 26 | "new-cap": 0, 27 | "no-else-return": 1, 28 | "no-eq-null": 1, 29 | "no-lonely-if": 1, 30 | "no-path-concat": 0, 31 | "comma-dangle": 0, 32 | "complexity": [1, 20], 33 | "no-floating-decimal": 1, 34 | "no-void": 1, 35 | "no-sync": 1, 36 | "consistent-this": [1, "nope-dont-capture-this"], 37 | "max-nested-callbacks": [2, 3], 38 | "no-nested-ternary": 1, 39 | "space-after-keywords": [1, "always"], 40 | "space-before-function-paren": [1, "never"], 41 | "spaced-line-comment": [1, "always"], 42 | "no-class/no-class": 2, 43 | "react/jsx-boolean-value": 1, 44 | "react/jsx-quotes": 0, 45 | "react/jsx-no-undef": 1, 46 | "react/jsx-sort-props": 0, 47 | "react/jsx-sort-prop-types": 0, 48 | "react/jsx-uses-react": 1, 49 | "react/jsx-uses-vars": 1, 50 | "react/no-did-mount-set-state": 1, 51 | "react/no-did-update-set-state": 1, 52 | "react/no-multi-comp": 0, 53 | "react/no-unknown-property": 1, 54 | "react/prop-types": 1, 55 | "react/react-in-jsx-scope": 1, 56 | "react/self-closing-comp": 0, 57 | "react/wrap-multilines": 1 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /examples/react-iso/client/app.js: -------------------------------------------------------------------------------- 1 | /*eslint-env browser*/ 2 | 3 | var React = require('react'); 4 | 5 | var Application = require('./components/Application'); 6 | 7 | var dispatcher = require('./dispatcher'); 8 | window.dev = {dispatcher, debug: require('debug')}; 9 | 10 | dispatcher.listen('react', render); 11 | function render() { 12 | React.render( 13 | , 17 | document.getElementById('root') 18 | ); 19 | } 20 | 21 | window.addEventListener('popstate', setUrlFromLocation); 22 | function setUrlFromLocation() { 23 | dispatcher.dispatch("SET_URL", window.location.pathname); 24 | } 25 | 26 | dispatcher.dispatch("INIT_URL", window.location.pathname); 27 | 28 | 29 | if (module.hot) { 30 | module.hot.accept(); 31 | } 32 | -------------------------------------------------------------------------------- /examples/react-iso/client/components/Application.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | 3 | var Application = React.createClass({ 4 | propTypes: { 5 | dispatch: React.PropTypes.func, 6 | stores: React.PropTypes.object 7 | }, 8 | captureLinks(event) { 9 | var a = findAnchor(event.target); 10 | if (!a) return; 11 | var href = a.getAttribute('href'); 12 | if (!href) return; 13 | event.preventDefault(); 14 | this.props.dispatch("OPEN_URL", href); 15 | }, 16 | render() { 17 | var {dispatch, stores} = this.props; 18 | var Component = stores.routing.component || 'hr'; 19 | return ( 20 |
21 | 26 | {stores.routing.loading && "L O A D I N G"} 27 | 30 |
31 | ); 32 | }, 33 | }); 34 | 35 | function findAnchor(node) { 36 | while (node.nodeName.toLowerCase() != 'a') { 37 | if (!node.parentNode) return false; 38 | node = node.parentNode; 39 | } 40 | return node; 41 | } 42 | 43 | module.exports = Application; 44 | -------------------------------------------------------------------------------- /examples/react-iso/client/components/Error404.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | 3 | var Error404 = React.createClass({ 4 | render() { 5 | return

404

; 6 | }, 7 | }); 8 | 9 | module.exports = Error404; 10 | -------------------------------------------------------------------------------- /examples/react-iso/client/components/Film.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | 3 | var Film = React.createClass({ 4 | propTypes: { 5 | films: React.PropTypes.object, 6 | routing: React.PropTypes.object 7 | }, 8 | statics: { 9 | fetchData(params) { 10 | return { film: params.id }; 11 | } 12 | }, 13 | render() { 14 | var {films, routing} = this.props; 15 | var id = routing.params.id; 16 | var film = films.film[id] || {}; 17 | return ( 18 |
19 |

{film.title}

20 |

{film.intro}

21 |
22 | ); 23 | } 24 | }); 25 | 26 | module.exports = Film; 27 | -------------------------------------------------------------------------------- /examples/react-iso/client/components/Films.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | 3 | var Films = React.createClass({ 4 | propTypes: { 5 | films: React.PropTypes.object 6 | }, 7 | statics: { 8 | fetchData() { 9 | return { films: 'all' }; 10 | } 11 | }, 12 | render() { 13 | var {films} = this.props; 14 | return ( 15 |
16 |

Films

17 |
    18 | {films.films.map(this.renderFilm)} 19 |
20 |
21 | ); 22 | }, 23 | renderFilm(film) { 24 | return ( 25 |
  • 26 | 27 | {film.title} 28 | 29 |
  • 30 | ); 31 | } 32 | }); 33 | 34 | module.exports = Films; 35 | -------------------------------------------------------------------------------- /examples/react-iso/client/components/Home.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | 3 | var Home = React.createClass({ 4 | render() { 5 | return

    Home

    ; 6 | }, 7 | }); 8 | 9 | module.exports = Home; 10 | -------------------------------------------------------------------------------- /examples/react-iso/client/components/Planet.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | 3 | var Planet = React.createClass({ 4 | propTypes: { 5 | planets: React.PropTypes.object, 6 | routing: React.PropTypes.object 7 | }, 8 | statics: { 9 | fetchData(params) { 10 | return { planet: params.id }; 11 | } 12 | }, 13 | render() { 14 | var {planets, routing} = this.props; 15 | var id = routing.params.id; 16 | var planet = planets.planet[id] || {}; 17 | return ( 18 |
    19 |

    {planet.name}

    20 |
    21 |
    Diameter
    22 |
    {planet.diameter}
    23 |
    Climate
    24 |
    {planet.climate}
    25 |
    Gravity
    26 |
    {planet.gravity}
    27 |
    Terrain
    28 |
    {planet.terrain}
    29 |
    Population
    30 |
    {planet.population}
    31 |
    32 |
    33 | ); 34 | } 35 | }); 36 | 37 | module.exports = Planet; 38 | -------------------------------------------------------------------------------- /examples/react-iso/client/components/Planets.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | 3 | var Planets = React.createClass({ 4 | propTypes: { 5 | planets: React.PropTypes.object 6 | }, 7 | statics: { 8 | fetchData() { 9 | return { planets: 'all' }; 10 | } 11 | }, 12 | render() { 13 | var {planets} = this.props; 14 | return ( 15 |
    16 |

    Planets

    17 |
      18 | {planets.planets.map(this.renderPlanet)} 19 | {planets.morePlanets &&
    • loading...
    • } 20 |
    21 |
    22 | ); 23 | }, 24 | renderPlanet(planet) { 25 | return ( 26 |
  • 27 | 28 | {planet.name} 29 | 30 |
  • 31 | ); 32 | } 33 | }); 34 | 35 | module.exports = Planets; 36 | -------------------------------------------------------------------------------- /examples/react-iso/client/data.js: -------------------------------------------------------------------------------- 1 | var handlers = {}; 2 | 3 | exports.addHandlers = addHandlers; 4 | function addHandlers(obj) { 5 | Object.keys(obj || {}).forEach(k => handlers[k] = obj[k]); 6 | } 7 | 8 | /** 9 | * Turn a data description into bunch of store dispatches 10 | * @param {object} descriptor description of requested data 11 | * @param {function} emit emit actions to stores 12 | * @param {function} callback to be called when complete 13 | */ 14 | exports.fetch = fetch; 15 | function fetch(descriptor, emit, callback) { 16 | var entities = Object.keys(descriptor); 17 | asyncEach(entities, (entity, next) => { 18 | if (handlers[entity]) { 19 | return handlers[entity](descriptor[entity], emit, next); 20 | } 21 | console.warn("Unknown handler %s - ", entity, descriptor[entity]); 22 | return next(); 23 | }, callback); 24 | } 25 | 26 | function asyncEach(arr, iterator, callback) { 27 | var i = arr.length; 28 | arr.forEach(x => iterator(x, next)); 29 | function next() { 30 | if (--i == 0) return callback(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/react-iso/client/dispatcher.js: -------------------------------------------------------------------------------- 1 | var flux = require('fluctuations'); 2 | 3 | /** 4 | * To take over from server rendering, we must re-use the data that the 5 | * server used, by passing it into the dispatcher init 6 | */ 7 | /*global initialStoreData*/ 8 | var state = typeof initialStoreData == 'object' ? initialStoreData : {}; 9 | 10 | var dispatcher = flux.createDispatcher({state}); 11 | 12 | /** 13 | * Hot reloading stores! 14 | * 15 | * The trick is to always re-use the `dispatcher` instance 16 | * 17 | * dispatcher.addStore will use the stores' merge functions 18 | * when re-adding another store with the same key 19 | */ 20 | if (module.hot) { 21 | if (module.hot.data) { 22 | dispatcher = module.hot.data.dispatcher; 23 | } 24 | module.hot.accept(); 25 | module.hot.dispose(hot => hot.dispatcher = dispatcher); 26 | } 27 | 28 | 29 | var data = require('./data'); 30 | 31 | var routing = require('./stores/routing'); 32 | dispatcher.addInterceptor('routing', routing.interceptor); 33 | dispatcher.addStore('routing', routing.store); 34 | 35 | var films = require('./stores/films'); 36 | data.addHandlers(films.handlers); 37 | dispatcher.addStore('films', films.store); 38 | 39 | var planets = require('./stores/planets'); 40 | data.addHandlers(planets.handlers); 41 | dispatcher.addStore('planets', planets.store); 42 | 43 | module.exports = dispatcher; 44 | -------------------------------------------------------------------------------- /examples/react-iso/client/routes.js: -------------------------------------------------------------------------------- 1 | var routes = require('routes')(); 2 | routes.addRoute("/", require('./components/Home')); 3 | routes.addRoute("/films", require('./components/Films')); 4 | routes.addRoute("/films/:id", require('./components/Film')); 5 | routes.addRoute("/planets", require('./components/Planets')); 6 | routes.addRoute("/planets/:id", require('./components/Planet')); 7 | routes.addRoute("*", require('./components/Error404')); 8 | 9 | module.exports = routes; 10 | -------------------------------------------------------------------------------- /examples/react-iso/client/stores/films.js: -------------------------------------------------------------------------------- 1 | var flux = require('fluctuations'); 2 | var merge = require('deep-extend'); 3 | 4 | var swapi = require('../swapi'); 5 | 6 | var handlers = exports.handlers = {}; 7 | 8 | handlers.films = (query, emit, callback) => { 9 | if (query === 'all') { 10 | swapi({ pathname: 'films/' }, (err, res, body) => { 11 | if (err) { 12 | emit("FILMS_ERROR", err); 13 | return callback(); 14 | } 15 | emit("FILMS_DATA", body); 16 | return callback(); 17 | }); 18 | } else { 19 | return callback(); 20 | } 21 | }; 22 | handlers.film = (id, emit, callback) => { 23 | swapi({ pathname: 'films/' + id + '/' }, (err, res, body) => { 24 | if (err) { 25 | emit("FILM_ERROR", {id, err}); 26 | return callback(); 27 | } 28 | emit("FILM_DATA", {id, film: body}); 29 | return callback(); 30 | }); 31 | }; 32 | 33 | exports.store = flux.createStore( 34 | () => ({ 35 | films: [], 36 | film: {} 37 | }), 38 | { 39 | FILMS_DATA(state, films) { 40 | state.films = films.results.map(formatFilm); 41 | return state; 42 | }, 43 | FILM_DATA(state, {id, film}) { 44 | state.film[id] = formatFilm(film); 45 | return state; 46 | } 47 | }, 48 | merge 49 | ); 50 | 51 | function formatFilm(rawFilm) { 52 | return { 53 | id: rawFilm.url.match(/(\d+)\/$/)[1], 54 | episode: rawFilm.episode_id, 55 | title: rawFilm.title, 56 | intro: rawFilm.opening_crawl 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /examples/react-iso/client/stores/planets.js: -------------------------------------------------------------------------------- 1 | var flux = require('fluctuations'); 2 | var merge = require('deep-extend'); 3 | 4 | var swapi = require('../swapi'); 5 | 6 | var handlers = exports.handlers = {}; 7 | 8 | handlers.planets = (query, emit, callback) => { 9 | if (query !== 'all') { 10 | return callback(); 11 | } 12 | var planets = []; 13 | swapi({ pathname: 'planets/' }, appendResults); 14 | function appendResults(err, res, body) { 15 | if (err) { 16 | emit("PLANETS_ERROR", err); 17 | return callback(); 18 | } 19 | planets = planets.concat(body.results); 20 | emit("PLANETS_DATA", {planets, more: body.next}); 21 | if (body.next) { 22 | swapi({ uri: body.next }, appendResults); 23 | } 24 | callback(); 25 | callback = () => 0; 26 | } 27 | }; 28 | handlers.planet = (id, emit, callback) => { 29 | swapi({ pathname: 'planets/' + id + '/' }, (err, res, body) => { 30 | if (err) { 31 | emit("PLANET_ERROR", {id, err}); 32 | return callback(); 33 | } 34 | emit("PLANET_DATA", {id, planet: body}); 35 | return callback(); 36 | }); 37 | }; 38 | 39 | exports.store = flux.createStore( 40 | () => ({ 41 | planets: [], 42 | morePlanets: false, 43 | planet: {} 44 | }), 45 | { 46 | PLANETS_DATA(state, {planets, more}) { 47 | state.planets = planets.map(formatPlanet); 48 | state.morePlanets = more; 49 | return state; 50 | }, 51 | PLANET_DATA(state, {id, planet}) { 52 | state.planet[id] = formatPlanet(planet); 53 | return state; 54 | } 55 | }, 56 | merge 57 | ); 58 | 59 | function formatPlanet(rawPlanet) { 60 | return { 61 | id: rawPlanet.url.match(/(\d+)\/$/)[1], 62 | name: rawPlanet.name, 63 | diameter: rawPlanet.diameter, 64 | climate: rawPlanet.climate, 65 | gravity: rawPlanet.gravity, 66 | terrain: rawPlanet.terrain, 67 | population: rawPlanet.population 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /examples/react-iso/client/stores/routing.js: -------------------------------------------------------------------------------- 1 | /*global history*/ 2 | 3 | var flux = require('fluctuations'); 4 | var merge = require('deep-extend'); 5 | 6 | var data = require('../data'); 7 | 8 | var routes = require('../routes'); 9 | 10 | function route(emit, path, {skipFetch} = {}) { 11 | var match = routes.match(path); 12 | if (!match) { 13 | return emit("404"); 14 | } 15 | var {params, fn: component} = match; 16 | if (!component.fetchData || skipFetch) { 17 | emitRoute(); 18 | } else { 19 | emit("ROUTE_LOADING", path); 20 | data.fetch( 21 | component.fetchData(params), 22 | emit, 23 | emitRoute 24 | ); 25 | } 26 | function emitRoute() { 27 | emit("ROUTE", { path, params, component}); 28 | } 29 | } 30 | 31 | exports.interceptor = flux.createInterceptor({ 32 | OPEN_URL(emit, path) { 33 | route(emit, path); 34 | history.pushState({}, '', path); 35 | }, 36 | SET_URL(emit, path) { 37 | route(emit, path); 38 | }, 39 | INIT_URL(emit, path) { 40 | route(emit, path, {skipFetch: true}); 41 | } 42 | }); 43 | 44 | exports.store = flux.createStore( 45 | () => ({ 46 | loading: false, 47 | path: null, 48 | params: null, 49 | component: null 50 | }), 51 | { 52 | ROUTE_LOADING(state, path) { 53 | state.loading = path; 54 | return state; 55 | }, 56 | ROUTE(state, {path, params, component}) { 57 | state.loading = false; 58 | state.path = path; 59 | state.params = params; 60 | state.component = component; 61 | return state; 62 | } 63 | }, 64 | merge 65 | ); 66 | -------------------------------------------------------------------------------- /examples/react-iso/client/swapi.js: -------------------------------------------------------------------------------- 1 | var request = require('request'); 2 | 3 | var swapiBaseUrl = 'http://swapi.co/api/'; 4 | var swapiRequest = request.defaults({ json: true }); 5 | function swapi(options, ...args) { 6 | if (options.pathname) { 7 | options.uri = swapiBaseUrl + options.pathname; 8 | } 9 | return swapiRequest(options, ...args); 10 | } 11 | 12 | module.exports = swapi; 13 | -------------------------------------------------------------------------------- /examples/react-iso/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Sample App 4 | 5 | 6 |
    7 | 8 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/react-iso/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fluctuations-example-react-iso", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "React example", 6 | "author": "Glen Mailer ", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "babel": "^5.4.7", 10 | "babel-core": "^5.4.7", 11 | "babel-eslint": "^3.1.9", 12 | "babel-loader": "^5.1.3", 13 | "babel-runtime": "^5.4.7", 14 | "browser-request": "^0.3.3", 15 | "deep-extend": "^0.4.0", 16 | "eslint": "^0.21.2", 17 | "eslint-plugin-no-class": "^0.1.0", 18 | "eslint-plugin-react": "^2.3.0", 19 | "express": "^4.12.4", 20 | "fluctuations": "file:../..", 21 | "node-libs-browser": "^0.5.2", 22 | "react": "^0.13.3", 23 | "react-hot-loader": "^1.2.7", 24 | "request": "^2.57.0", 25 | "routes": "^2.0.0", 26 | "webpack": "^1.9.10", 27 | "webpack-dev-middleware": "^1.0.11", 28 | "webpack-hot-middleware": "^1.1.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/react-iso/server-render.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | 3 | var React = require('react'); 4 | 5 | var Application = require('./client/components/Application'); 6 | var dispatcher = require('./client/dispatcher'); 7 | 8 | var template = new Promise(function(resolve, reject) { 9 | fs.readFile(__dirname + '/index.html', 'utf8', function(err, data) { 10 | if (err) return reject(err); 11 | return resolve(data); 12 | }); 13 | }); 14 | 15 | function renderReact(path) { 16 | return new Promise(function(resolve) { 17 | dispatcher.listen('routed', function() { 18 | var stores = dispatcher.get(); 19 | // Wait until app is no longer loading 20 | if (stores.routing.loading) { 21 | return false; 22 | } 23 | var rendered = React.renderToString( 24 | React.createElement(Application, { 25 | stores: dispatcher.get(), 26 | dispatch: function(){} 27 | }) 28 | ); 29 | return resolve({rendered, stores}); 30 | }); 31 | 32 | dispatcher.dispatch("SET_URL", path); 33 | }); 34 | } 35 | 36 | module.exports = function(path, callback) { 37 | var render = renderReact(path); 38 | Promise.all([template, render]) 39 | .then(function([tpl, {rendered, stores}]) { 40 | var page = tpl 41 | .replace('', rendered) 42 | .replace('"-- STORES --"', JSON.stringify(stores)); 43 | callback(null, page); 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /examples/react-iso/server.js: -------------------------------------------------------------------------------- 1 | require('babel/register'); 2 | 3 | var express = require('express'); 4 | 5 | var app = express(); 6 | 7 | var webpack = require('webpack'); 8 | var config = require('./webpack.config'); 9 | var compiler = webpack(config); 10 | app.use(require("webpack-dev-middleware")(compiler, { 11 | noInfo: true, publicPath: config.output.publicPath 12 | })); 13 | app.use(require("webpack-hot-middleware")(compiler)); 14 | 15 | var serverRender = require('./server-render'); 16 | app.get('*', function(req, res, next) { 17 | serverRender(req.path, function(err, page) { 18 | if (err) return next(err); 19 | res.send(page); 20 | }); 21 | }); 22 | 23 | var http = require('http'); 24 | var server = http.createServer(app); 25 | server.listen(3000, 'localhost', function(err) { 26 | if (err) throw err; 27 | 28 | var addr = server.address(); 29 | 30 | console.log('Listening at http://%s:%d', addr.address, addr.port); 31 | }); 32 | -------------------------------------------------------------------------------- /examples/react-iso/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: '#eval-source-map', 6 | entry: [ 7 | 'webpack-hot-middleware/client', 8 | 'webpack/hot/dev-server', 9 | './client/app.js' 10 | ], 11 | output: { 12 | path: __dirname, 13 | filename: 'bundle.js', 14 | publicPath: '/' 15 | }, 16 | plugins: [ 17 | new webpack.optimize.OccurenceOrderPlugin(), 18 | new webpack.HotModuleReplacementPlugin(), 19 | new webpack.NoErrorsPlugin() 20 | ], 21 | resolve: { 22 | extensions: ['', '.js'], 23 | alias: { 24 | routes: 'routes/index', 25 | request: 'browser-request' 26 | } 27 | }, 28 | module: { 29 | loaders: [{ 30 | test: /\.js$/, 31 | loaders: ['react-hot', 'babel'], 32 | include: path.join(__dirname, 'client') 33 | }] 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /fluctuations.js: -------------------------------------------------------------------------------- 1 | var debug = require('debug')('fluctuations'); 2 | var verbose = require('debug')('fluctuations:verbose'); 3 | 4 | exports.createDispatcher = createDispatcher; 5 | 6 | exports.createStore = createStore; 7 | exports.createInterceptor = createInterceptor; 8 | 9 | function createDispatcher(options) { 10 | 11 | options = options || {}; 12 | 13 | var state = options.state || {}; 14 | var stores = {}; 15 | var interceptors = {}; 16 | var listeners = {}; 17 | 18 | function addInterceptor(key, interceptor) { 19 | interceptors[key] = interceptor; 20 | } 21 | function addStore(key, store) { 22 | stores[key] = store; 23 | state[key] = key in state ? 24 | store.merge(store.initial(), state[key]) : store.initial(); 25 | } 26 | 27 | function listen(key, listener) { 28 | listeners[key] = listener; 29 | } 30 | 31 | function dispatch(action, payload) { 32 | payload = typeof payload !== 'undefined' ? payload : {}; 33 | if (!dispatchToInterceptors(action, payload)) { 34 | emitToStores(action, payload); 35 | } 36 | } 37 | 38 | function dispatchToInterceptors(action, payload) { 39 | debug("Dispatching %s to interceptors", action); 40 | var intercepted = false; 41 | emitToStores.state = get(); 42 | each(interceptors, function(interceptor, key) { 43 | if (interceptor.handlers[action]) { 44 | debug("Intercepted %s with interceptor '%s'", action, key); 45 | interceptor.handlers[action](emitToStores, payload); 46 | intercepted = true; 47 | return 'break'; 48 | } 49 | }); 50 | return intercepted; 51 | } 52 | 53 | function emitToStores(action, payload) { 54 | debug("Dispatching %s to stores", action); 55 | 56 | var handled = false; 57 | each(stores, function(store, key) { 58 | if (store.handlers[action]) { 59 | debug("Dispatching %s to store '%s'", action, key); 60 | verbose("State in: %j", state[key]); 61 | state[key] = store.handlers[action](state[key], payload); 62 | verbose("State out: %j", state[key]); 63 | handled = true; 64 | } 65 | }); 66 | 67 | if (handled) { 68 | notify(); 69 | } else { 70 | console.warn("Unknown action: %s", action, payload); 71 | } 72 | } 73 | // Alternative interceptor API 74 | emitToStores.redispatch = dispatch; 75 | emitToStores.dispatch = function(action, payload) { 76 | console.warn("Deprecated: use `emit` instead of `dispatch` in interceptor"); 77 | emitToStores(action, payload); 78 | }; 79 | emitToStores.emit = emitToStores; 80 | emitToStores.state = get(); 81 | 82 | function notify() { 83 | debug('Notifying all listeners'); 84 | each(listeners, function(listener) { 85 | listener(); 86 | }); 87 | } 88 | 89 | function get() { 90 | return state; 91 | } 92 | 93 | return { 94 | addInterceptor: addInterceptor, 95 | addStore: addStore, 96 | 97 | dispatch: dispatch, 98 | 99 | listen: listen, 100 | get: get 101 | }; 102 | 103 | } 104 | 105 | function createStore(initial, handlers, merge) { 106 | return { 107 | initial: initial, 108 | handlers: handlers, 109 | merge: merge || overwrite 110 | }; 111 | } 112 | 113 | function createInterceptor(handlers) { 114 | return { 115 | handlers: handlers 116 | }; 117 | } 118 | 119 | function overwrite(state, newState) { 120 | return newState; 121 | } 122 | 123 | 124 | /** 125 | * Call fn for each item in obj. 126 | * Stops iterating if fn returns the string "break" 127 | * 128 | * @param {object} obj 129 | * @param {Function} fn(value, key) 130 | * @return void 131 | */ 132 | function each(obj, fn) { 133 | Object.keys(obj).some(function(k) { 134 | if (fn(obj[k], k) === 'break') { 135 | return true; 136 | } 137 | }); 138 | } 139 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fluctuations", 3 | "version": "0.8.0", 4 | "description": "Yet another flux implementation", 5 | "main": "fluctuations.js", 6 | "scripts": { 7 | "test": "mocha", 8 | "travis": "istanbul cover _mocha --" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/glenjamin/fluctuations.git" 13 | }, 14 | "author": "Glen Mailer ", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/glenjamin/fluctuations/issues" 18 | }, 19 | "homepage": "https://github.com/glenjamin/fluctuations", 20 | "dependencies": { 21 | "debug": "^2.2.0" 22 | }, 23 | "devDependencies": { 24 | "chai": "^2.3.0", 25 | "coveralls": "^2.11.2", 26 | "istanbul": "^0.3.14", 27 | "mocha": "^2.2.5", 28 | "sinon": "^1.14.1", 29 | "sinon-chai": "^2.7.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "max-nested-callbacks": 0 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | var sinon = require('sinon'); 4 | var chai = require('chai'); 5 | chai.use(require('sinon-chai')); 6 | var expect = chai.expect; 7 | 8 | var fluctuations = require('../'); 9 | 10 | describe("fluctuations", function() { 11 | var flux, listener, s; 12 | beforeEach(function() { 13 | s = sinon.sandbox.create(); 14 | listener = s.spy(); 15 | flux = fluctuations.createDispatcher(); 16 | flux.listen('listener', listener); 17 | }); 18 | afterEach(function() { 19 | s.restore(); 20 | }); 21 | 22 | describe("1 store", function() { 23 | beforeEach(function() { 24 | var store = fluctuations.createStore( 25 | function() { return 0; }, 26 | { 27 | INC: function(n) { return n + 1; }, 28 | DEC: function(n) { return n - 1; }, 29 | ADD: function(n, i) { return n + i; } 30 | } 31 | ); 32 | flux.addStore('store', store); 33 | s.stub(console, 'warn'); 34 | }); 35 | 36 | it("should begin on initial value", function() { 37 | expect(flux.get().store).to.eql(0); 38 | }); 39 | 40 | it("should merge state from the previous store", function() { 41 | flux.dispatch("INC"); 42 | var store2 = fluctuations.createStore( 43 | function() { 44 | return 0; 45 | }, {}); 46 | flux.addStore('store', store2); 47 | expect(flux.get().store).to.eq(1); 48 | }); 49 | 50 | it("should use the merge strategy", function() { 51 | flux.dispatch("ADD", 4); 52 | var store2 = fluctuations.createStore( 53 | function() { 54 | return 3; 55 | }, {}, function(x, y){ 56 | return x + y; 57 | }); 58 | flux.addStore('store', store2); 59 | expect(flux.get().store).to.eq(7); 60 | }); 61 | 62 | it("should call listener after dispatch", function() { 63 | flux.dispatch("INC"); 64 | expect(listener).to.have.callCount(1); 65 | }); 66 | 67 | it("should call listener after every dispatch", function() { 68 | flux.dispatch("INC"); 69 | flux.dispatch("INC"); 70 | expect(listener).to.have.callCount(2); 71 | flux.dispatch("INC"); 72 | expect(listener).to.have.callCount(3); 73 | }); 74 | 75 | it("should have updated state when calling listener", function(done) { 76 | flux.listen('listener', function() { 77 | expect(flux.get().store).to.eql(1); 78 | done(); 79 | }); 80 | flux.dispatch("INC"); 81 | }); 82 | 83 | it("should update store's state after each dispatch", function() { 84 | flux.dispatch("INC"); 85 | expect(flux.get().store).to.eql(1); 86 | flux.dispatch("INC"); 87 | expect(flux.get().store).to.eql(2); 88 | flux.dispatch("DEC"); 89 | expect(flux.get().store).to.eql(1); 90 | flux.dispatch("INC"); 91 | expect(flux.get().store).to.eql(2); 92 | }); 93 | 94 | it("should pass action payload along to handler", function() { 95 | flux.dispatch("ADD", 3); 96 | expect(flux.get().store).to.eql(3); 97 | }); 98 | 99 | it("should not warn on a matching action", function() { 100 | flux.dispatch("INC"); 101 | expect(console.warn).to.have.callCount(0); 102 | }); 103 | 104 | it("should warn if dispatching an unknown action", function() { 105 | flux.dispatch("NOTHING"); 106 | expect(console.warn).to.have.callCount(1); 107 | }); 108 | 109 | it("should not call listener if unknown action", function() { 110 | flux.dispatch("NOTHING"); 111 | expect(listener).to.have.callCount(0); 112 | }); 113 | }); 114 | 115 | describe("multiple overlapping stores", function() { 116 | beforeEach(function() { 117 | flux.addStore('a', fluctuations.createStore( 118 | function() { return -10; }, 119 | { 120 | BUMP: function(n) { return n + 1; }, 121 | JUMP: function(n) { return n + 10; } 122 | } 123 | )); 124 | flux.addStore('b', fluctuations.createStore( 125 | function() { return 10; }, 126 | { 127 | BUMP: function(n) { return n - 1; }, 128 | LUMP: function(n) { return n - 10; } 129 | } 130 | )); 131 | s.stub(console, 'warn'); 132 | }); 133 | 134 | it("should begin on initial values", function() { 135 | expect(flux.get().a).to.eql(-10); 136 | expect(flux.get().b).to.eql(10); 137 | }); 138 | 139 | it("should call listener if one matches", function() { 140 | flux.dispatch("JUMP"); 141 | expect(listener).to.have.callCount(1); 142 | }); 143 | it("should call listener if another matches", function() { 144 | flux.dispatch("LUMP"); 145 | expect(listener).to.have.callCount(1); 146 | }); 147 | it("should call listener once if both match", function() { 148 | flux.dispatch("BUMP"); 149 | expect(listener).to.have.callCount(1); 150 | }); 151 | it("should update state of one if matching", function() { 152 | flux.dispatch("JUMP"); 153 | expect(flux.get().a).to.eql(0); 154 | expect(flux.get().b).to.eql(10); 155 | flux.dispatch("LUMP"); 156 | expect(flux.get().a).to.eql(0); 157 | expect(flux.get().b).to.eql(0); 158 | }); 159 | it("should update state of all matching", function() { 160 | flux.dispatch("BUMP"); 161 | expect(flux.get().a).to.eql(-9); 162 | expect(flux.get().b).to.eql(9); 163 | }); 164 | 165 | it("should not warn on a single matching action", function() { 166 | flux.dispatch("LUMP"); 167 | expect(console.warn).to.have.callCount(0); 168 | }); 169 | 170 | it("should not warn on a multiple matching action", function() { 171 | flux.dispatch("BUMP"); 172 | expect(console.warn).to.have.callCount(0); 173 | }); 174 | 175 | it("should warn if dispatching an unknown action", function() { 176 | flux.dispatch("NOTHING"); 177 | expect(console.warn).to.have.callCount(1); 178 | }); 179 | 180 | it("should not call listener if unknown action", function() { 181 | flux.dispatch("NOTHING"); 182 | expect(listener).to.have.callCount(0); 183 | }); 184 | }); 185 | 186 | describe("1 store with interceptor", function() { 187 | var storeInc, interceptions; 188 | beforeEach(function() { 189 | interceptions = []; 190 | storeInc = s.spy(function(n) { return n + 1; }); 191 | flux.addStore('n', fluctuations.createStore( 192 | function() { return 0; }, 193 | { 194 | QUICK_INC: function(n) { return n + 1; }, 195 | INC: storeInc, 196 | SUB: function(n, i) { return n - i; }, 197 | START_INC: function(n) { return n + 0.1; }, 198 | END_INC: function(n) { return n + 0.9; } 199 | } 200 | )); 201 | flux.addInterceptor('slow', fluctuations.createInterceptor({ 202 | INC: function(emit) { 203 | emit("START_INC"); 204 | }, 205 | ADD: function(emit, n) { 206 | while (n--) emit("INC"); 207 | }, 208 | SUB5: function(emit) { 209 | emit("SUB", 5); 210 | }, 211 | AT_LEAST: function(dispatcher, n) { 212 | if (n > dispatcher.state.n) { 213 | var i = n - dispatcher.state.n; 214 | while (i--) dispatcher.emit("INC"); 215 | } 216 | }, 217 | HIJACK: function(emit) { 218 | interceptions.push(emit); 219 | } 220 | })); 221 | s.stub(console, 'warn'); 222 | }); 223 | 224 | it("should send non-intercepted actions to store", function() { 225 | flux.dispatch("QUICK_INC"); 226 | expect(listener).to.have.callCount(1); 227 | expect(flux.get().n).to.eql(1); 228 | }); 229 | 230 | it("should not send intercepted actions to store", function() { 231 | flux.dispatch("HIJACK"); 232 | expect(storeInc).to.have.callCount(0); 233 | }); 234 | 235 | it("should emit from interceptor to store", function() { 236 | flux.dispatch("INC"); 237 | expect(listener).to.have.callCount(1); 238 | expect(flux.get().n).to.eql(0.1); 239 | }); 240 | 241 | it("should not emit from interceptor to interceptor", function() { 242 | flux.dispatch("HIJACK"); 243 | expect(interceptions).to.have.length(1); // captured interception 244 | interceptions[0]("HIJACK"); 245 | expect(console.warn).to.have.callCount(1); 246 | }); 247 | 248 | it("should allow redispatch from interceptor to interceptor", function() { 249 | flux.dispatch("HIJACK"); 250 | expect(interceptions).to.have.length(1); // captured interception 251 | interceptions[0].redispatch("HIJACK"); 252 | expect(interceptions).to.have.length(2); 253 | }); 254 | 255 | it("should warn when calling dispatch in interceptor", function() { 256 | flux.dispatch("HIJACK"); 257 | expect(interceptions).to.have.length(1); // captured interception 258 | interceptions[0].dispatch("INC"); 259 | expect(console.warn).to.have.callCount(1); 260 | expect(console.warn).to.be.calledWithMatch(/deprecated/i); 261 | }); 262 | 263 | it("should allow calling emit in interceptor to hit store", function() { 264 | flux.dispatch("HIJACK"); 265 | expect(interceptions).to.have.length(1); // captured interception 266 | interceptions[0].emit("INC"); 267 | expect(console.warn).to.have.callCount(0); 268 | expect(flux.get().n).to.eql(1); 269 | }); 270 | 271 | it("should allow async stuff via interceptors", function(done) { 272 | flux.dispatch("HIJACK"); 273 | expect(interceptions).to.have.length(1); // captured interception 274 | setTimeout(function() { 275 | interceptions[0]("END_INC"); 276 | }, 0); 277 | 278 | flux.listen('listener', function() { 279 | expect(flux.get().n).to.eql(0.9); 280 | done(); 281 | }); 282 | }); 283 | 284 | it("should receive payloads in interceptors", function() { 285 | flux.dispatch("ADD", 5); 286 | expect(flux.get().n).to.eql(5); 287 | expect(storeInc).to.have.callCount(5); 288 | }); 289 | 290 | it("should allow interceptors to emit payloads to stores", function() { 291 | flux.dispatch("SUB5"); 292 | expect(flux.get().n).to.eql(-5); 293 | }); 294 | 295 | it("should allow interceptors to read state", function() { 296 | flux.dispatch("AT_LEAST", 3); 297 | expect(flux.get().n).to.eql(3); 298 | expect(storeInc).to.have.callCount(3); 299 | flux.dispatch("SUB5"); 300 | flux.dispatch("AT_LEAST", 3); 301 | expect(flux.get().n).to.eql(3); 302 | expect(storeInc).to.have.callCount(8); 303 | }); 304 | }); 305 | 306 | describe("multiple overlapping interceptors", function() { 307 | beforeEach(function() { 308 | flux.addStore('n', fluctuations.createStore( 309 | function() { return 0; }, 310 | { 311 | INC: function(n) { return n + 1; }, 312 | DEC: function(n) { return n - 1; }, 313 | } 314 | )); 315 | flux.addInterceptor('one', fluctuations.createInterceptor({ 316 | DO_INC: function(emit) { 317 | emit("INC"); 318 | } 319 | })); 320 | flux.addInterceptor('two', fluctuations.createInterceptor({ 321 | DO_INC: function(emit) { 322 | emit("DEC"); 323 | } 324 | })); 325 | }); 326 | it("should only call first matching interceptor", function() { 327 | flux.dispatch("DO_INC"); 328 | expect(flux.get().n).to.eql(1); 329 | }); 330 | }); 331 | 332 | describe("rehydration", function() { 333 | var store; 334 | beforeEach(function() { 335 | store = fluctuations.createStore( 336 | function() { return 0; }, 337 | { INC: function(n) { return n + 1; } } 338 | ); 339 | flux.addStore('store', store); 340 | s.stub(console, 'warn'); 341 | }); 342 | 343 | it("should begin with state if told to", function() { 344 | flux.dispatch("INC"); 345 | var newFlux = fluctuations.createDispatcher({ state: flux.get() }); 346 | newFlux.addStore('store', store); 347 | expect(newFlux.get()).to.deep.eql(flux.get()); 348 | }); 349 | }); 350 | }); 351 | --------------------------------------------------------------------------------