From 0be23376054c8b9f51a2f396c7f951149726b5f9 Mon Sep 17 00:00:00 2001 From: Yoshua Wuyts Date: Sun, 22 May 2016 20:54:39 +0900 Subject: [PATCH 1/6] docs: improve examples & concepts --- README.md | 189 +++++++++++++++++++++++++++++++++++++++++++----------- index.js | 8 +-- 2 files changed, 152 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 0136ad7..8c3341a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ productive package. - [Demos](#demos) - [Usage](#usage) - [Concepts](#concepts) + - [Models](#models) - [Effects](#effects) - [HTTP](#http) - [Subscriptions](#subscriptions) @@ -23,6 +24,7 @@ productive package. - [API](#api) - [FAQ](#faq) - [Installation](#installation) +- [See Also](#see-also) - [License](#license) ## Features @@ -51,15 +53,16 @@ productive package. const choo = require('choo') const app = choo() -app.model('title', { +app.model({ + namespace: 'input', state: { - title: 'my-demo-app' + title: 'my demo app' }, reducers: { - 'update': (action, state) => ({ title: action.payload }) + update: (action, state) => ({ title: action.payload }) }, effects: { - 'update': (action, state, send) => (document.title = action.payload) + update: (action, state, send) => (document.title = action.payload) } }) @@ -69,8 +72,8 @@ const mainView = (params, state, send) => choo.view` send('title:update', { payload: e.target.value })}> + placeholder=${state.input.title} + oninput=${(e) => send('input:update', { payload: e.target.value })}> ` @@ -83,13 +86,14 @@ document.body.appendChild(tree) ``` ## Concepts +`choo` is a complete framework. It has an answer to pretty most points - __user:__ 🙆 - __DOM:__ the [Document Object Model][dom] is what is currently displayed in your browser - __actions:__ a named event with optional properties attached. Used to call `effects` and `reducers` that have been registered in `models` -- __model:__ optionally namespaced object containing `subscriptions`, `effects` - and `reducers` +- __model:__ optionally namespaced object containing `subscriptions`, + `effects`, `reducers` and initial `state` - __subscriptions:__ read-only data sources that emit `actions` - __effects:__ asynchronous functions that emit an `action` when done - __reducers:__ synchronous functions that modify `state` @@ -116,6 +120,40 @@ document.body.appendChild(tree) └────────┘ └────────┘ ``` +## Models +`models` are objects that contain initial `state`, `subscriptions`, `effects` +and `reducers`. They're generally grouped around a theme (or domain, if you +like). To provide some sturdiness to your `models`, they can either be +namespaced or not. Namespacing means that only actions and state inside the +model can be called. + +So say we have a `todos` namespace, an `add` reducer and a `todos` model. +Outside the model they're called by `send('todos:add')` and +`state.todos.todos`. Inside the namespaced model they're called by +`send('add')` and `state.todos`. An example namespaced model: +```js +const app = choo() +app.model({ + namespace: 'todos', + model: { todos: [] }, + reducers: { + add: (state, action) => ({ todos: state.todos.concat(action.payload) }) + } +}) +``` + +In most cases using namespaces is beneficial, as having clear boundries makes +it easier to follow logic. But sometimes you need to call `actions` that +operate over multiple domains (such as a "logout" `action`), or have a +`subscription` that might trigger multiple `reducers` (such as a `websocket` +that calls a different `action` based on the incoming data). + +In these cases you probably want to have a `model` that doesn't use namespaces, +and has access to the full application state. Try and keep the logic in these +`models` to a minimum, and declare as few `reducers` as possible. That way the +bulk of your logic will safely shielded, with only a few points touching every +part of your application. + ## Effects Side effects are done through `effects` declared in `app.model()`. Unlike `reducers` they cannot modify the state by returning objects, but get a @@ -136,25 +174,49 @@ A typical `effect` flow looks like: that weighs only `2.4kb`: ```js const http = require('choo/http') +const choo = require('choo') +const app = choo() -// GET JSON -http.get('/my-endpoint', { json: true }, function (err, res, body) { - if (err) throw err - if (res.statusCode !== 200 || !body) throw new Error('something went wrong') +app.model({ + effects: { + 'app:error': (state, event_ => console.error(`error: ${event.payload}`)), + 'app:print': (state, event) => console.log(`http: ${event.payload}`), + 'http:get_json': getJson, + 'http:post_json': postJson, + 'http:delete': httpDelete + } }) -// POST JSON -const body = { foo: 'bar' } -http.post('/my-endpoint', { json: body }, function (err, res, body) { - if (err) throw err - if (res.statusCode !== 200 || !body) throw new Error('something went wrong') -}) +function getJson (state, action, send) { + http.get('/my-endpoint', { json: true }, function (err, res, body) { + if (err) return send('app:error', { payload: err.message }) + if (res.statusCode !== 200 || !body) { + return send('app:error', { payload:'something went wrong' }) + } + send('app:print', { payload: body }) + }) +} -// DELETE -http.del('/my-endpoint', function (err, res) { - if (err) throw err - if (res.statusCode !== 200) throw new Error('something went wrong') -}) +function postJson (state, action, send) { + const body = { foo: 'bar' } + http.post('/my-endpoint', { json: body }, function (err, res, body) { + if (err) return send('app:error', { payload: err.message }) + if (res.statusCode !== 200 || !body) { + return send('app:error', { payload:'something went wrong' }) + } + send('app:print', { payload: body }) + }) +} + +function httpDelete (state, action, send) { + const body = { foo: 'bar' } + http.post('/my-endpoint', { json: body }, function (err, res, body) { + if (err) return send('app:error', { payload: err.message }) + if (res.statusCode !== 200) { + return send('app:error', { payload:'something went wrong' }) + } + }) +} ``` Note that `http` only runs in the browser to prevent accidental requests when rendering in Node. For more details view the [`raynos/xhr` @@ -165,6 +227,20 @@ Subscriptions are a way of receiving data from a source. For example when listening for events from a server using `SSE` or `Websockets` for a chat app, or when catching keyboard input for a videogame. +An example subscription that logs `"dog?"` every second: +```js +const app = choo() +choo.model({ + subscriptions: [ + (send) => setTimeout(() => send('app:print', { payload: 'dog?' }), 1000) + ], + effects: { + 'app:print': (state, action) => console.log(action.payload) + } +}) +``` + + ### Server Sent Events (SSE) [Server Sent Events (SSE)][sse] allow servers to push data to the browser. They're the unidirectional cousin of `websockets` and compliment `HTTP` @@ -176,17 +252,20 @@ const stream = new document.EventSource('/sse') app.model({ subscriptions: [ function (send) { - stream.onerror = (e) => send('error', { payload: JSON.stringify(e) }) - stream.onmessage = (e) => send('print', { payload: e.data }) + stream.onerror = (e) => send('app:error', { payload: JSON.stringify(e) }) + stream.onmessage = (e) => send('app:print', { payload: e.data }) } ], effects: { 'sse:close': () => stream.close() - error: (state, event_ => console.error(`error: ${event.payload}`)), - print: (state, event) => console.log(`pressed key num: ${event.payload}`) + 'app:error': (state, event_ => console.error(`error: ${event.payload}`)), + 'app:print': (state, event) => console.log(`sse: ${event.payload}`) } }) ``` +This code does not handle reconnects, server timeouts, exponential backoff and +queueing data. You might want to use a package from `npm` or [write your +own][sse-reconnect] if you're building something for production. ### Keyboard Most browsers have [basic support for keyboard events][keyboard-support]. To @@ -195,11 +274,11 @@ capture keyboard events, setup a `subscription`: app.model({ subscriptions: [ function (send) { - keyboard.onkeypress = (e) => send('print', { payload: e.keyCode }) + keyboard.onkeypress = (e) => send('app:print', { payload: e.keyCode }) } ], effects: { - print: (state, event) => console.log(`pressed key num: ${event.payload}`) + 'app:print': (state, event) => console.log(`pressed key: ${event.payload}`) } }) ``` @@ -213,18 +292,21 @@ const socket = new document.WebSocket('ws://localhost:8081') app.model({ subscriptions: [ function (send) { - socket.onerror = (e) => send('error', { payload: JSON.stringify(e) }) - socket.onmessage = (e) => send('print', { payload: e.data }) + socket.onerror = (e) => send('app:error', { payload: JSON.stringify(e) }) + socket.onmessage = (e) => send('app:print', { payload: e.data }) } ], effects: { 'ws:close': () => socket.close(), 'ws:send': (state, event) => socket.send(JSON.stringify(event.payload)), - error: (state, event_ => console.error(`error: ${event.payload}`)), - print: (state, event) => console.log(`pressed key num: ${event.payload}`) + 'app:error': (state, event_ => console.error(`error: ${event.payload}`)), + 'app:print': (state, event) => console.log(`ws: ${event.payload}`) } }) ``` +This code does not handle reconnects, server timeouts, exponential backoff and +queueing data. You might want to use a package from `npm` or [write your +own][ws-reconnect] if you're building something for production. ## Rendering in Node Sometimes it's necessary to render code inside of Node; for serving first @@ -273,9 +355,12 @@ else document.body.appendChild(app.start()) ### app = choo() Create a new `choo` app -### app.model(name?, obj) -Create a new model. Models modify data and perform IO. Obj takes the following +### app.model(obj) +Create a new model. Models modify data and perform IO. Takes the following arguments: +- __namespace:__ optional namespace that prefixes the keys in `state`, + `reducers` and `effects`. Also limits `actions` called by `send()` to + in-namespace only. - __state:__ object. Key value store of initial values - __reducers:__ object. Syncronous functions that modify state. Each function has a signature of `(action, state)` @@ -283,10 +368,6 @@ arguments: a signature of `(action, state, send)` where `send` is a reference to `app.send()` -If a `name` string is passed as a first argument, `reducers` and `signatures` -will be prefixed by the name. So if name is "user" and a reducer called -"update" is registered, it would be accessed as `'user:update'` in `send()`. - ### choo.view\`html\` Tagged template string HTML builder. See [`yo-yo`](https://github.com/maxogden/yo-yo) for full documentation. Views @@ -316,6 +397,25 @@ Start the application. Returns a tree of DOM nodes that can be mounted using accordingly. ## FAQ +### Why did you build this? +`choo` is nothing but a formalization of how I've been building my applications +for the past year. I originally used `virtual-dom` with `virtual-app` and +`wayfarer` where now it's `yo-yo` with `send-action` and `sheet-router`. The +main benefit of using `choo` over these technologies separately is that it +becomes easier for teams to pick up and gather around. The code base for `choo` +itself is super petite (`~150` LOC) and mostly acts to enforce structure around +some excellent npm packages. This is my take on modular frameworks; I hope +you'll find it pleasant. + +### Why is it called choo? +Because I thought it sounded cute. All these programs talk about being +"performant", "rigid", "robust" - I like programming to be light, fun and +non-scary. `choo` embraces that. + +Also imagine telling some business people you chose to rewrite something +critical to the company using the `choo` framework. +:steam_locomotive::train::train::train: + ### How does choo compare to X? Ah, so this is where I get to rant. `choo` (_chugga-chugga-chugga-choo-choo!_) was built because other options didn't quite cut it for me, so instead of @@ -382,6 +482,10 @@ transforms: - [uglifyify](https://github.com/hughsk/uglifyify) - minify your code using UglifyJS2. Use as a `--global` transform +## Hey, doesn't this look a lot like Elm? +Yup, it's greatly inspired by the `elm` architecture. But contrary to `elm`, +`choo` doesn't introduce a completely new language to build web applications. + ### Is it production ready? Sure. @@ -390,6 +494,11 @@ Sure. $ npm install choo ``` +## See Also +- [budo](https://github.com/mattdesl/budo) - quick prototyping tool for + `browserify` +- [stack.gl](http://stack.gl/) - open software ecosystem for WebGL + ## License [MIT](https://tldrlegal.com/license/mit-license) @@ -416,3 +525,5 @@ $ npm install choo [morphdom]: https://github.com/patrick-steele-idem/morphdom [morphdom-bench]: https://github.com/patrick-steele-idem/morphdom#benchmarks [module-parent]: https://nodejs.org/dist/latest-v6.x/docs/api/modules.html#modules_module_parent +[sse-reconnect]: http://stackoverflow.com/questions/24564030/is-an-eventsource-sse-supposed-to-try-to-reconnect-indefinitely +[ws-reconnect]: http://stackoverflow.com/questions/13797262/how-to-reconnect-to-websocket-after-close-connection diff --git a/index.js b/index.js index 2a7d956..0cb0e23 100644 --- a/index.js +++ b/index.js @@ -124,12 +124,8 @@ function choo (opts) { // obj -> obj function appInit (opts) { const model = { - state: { - location: document.location.href - }, - reducers: { - location: setLocation - }, + state: { location: document.location.href }, + reducers: { location: setLocation }, subscriptions: [] } From 24b9be7c8df843b30d4556eff438cb0e8723657c Mon Sep 17 00:00:00 2001 From: Yoshua Wuyts Date: Mon, 23 May 2016 02:45:15 +0900 Subject: [PATCH 2/6] models: sturdify namespaces --- CHANGELOG.md | 10 +++++++ README.md | 23 +++++++++-------- examples/title/client.js | 35 ++++++++++++++----------- index.js | 56 +++++++++++++++++++++++++--------------- 4 files changed, 77 insertions(+), 47 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fe99c89 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +## `2.0.0` +### breaking changes +- namespaces are now enforced more strictly +- models now only accept a single argument +- the `namespace` key was introduced inside of models (was prior the leading + string in models) +- namespaced models can now only operate within themselves + +## `1.0.0` +- first version of choo diff --git a/README.md b/README.md index 8c3341a..fa0a6e9 100644 --- a/README.md +++ b/README.md @@ -66,16 +66,18 @@ app.model({ } }) -const mainView = (params, state, send) => choo.view` -
-

${state.title}

- - send('input:update', { payload: e.target.value })}> -
-` +const mainView = (params, state, send) => { + return choo.view` +
+

${state.input.title}

+ + send('input:update', { payload: e.target.value })}> +
+ ` +} app.router((route) => [ route('/', mainView) @@ -240,7 +242,6 @@ choo.model({ }) ``` - ### Server Sent Events (SSE) [Server Sent Events (SSE)][sse] allow servers to push data to the browser. They're the unidirectional cousin of `websockets` and compliment `HTTP` diff --git a/examples/title/client.js b/examples/title/client.js index d1d2274..7b6a348 100644 --- a/examples/title/client.js +++ b/examples/title/client.js @@ -1,26 +1,31 @@ -const choo = require('../') +const choo = require('../../') const app = choo() -app.model('title', { - state: { title: 'my-demo-app' }, +app.model({ + namespace: 'input', + state: { + title: 'my demo app' + }, reducers: { - 'update': (action, state) => ({ title: action.payload }) + update: (action, state) => ({ title: action.payload }) }, effects: { - 'update': (action, state, send) => (document.title = action.payload) + update: (action, state, send) => (document.title = action.payload) } }) -const mainView = (params, state, send) => choo.view` -
-

${state.title}

- - send('title:update', { payload: e.target.value })}> -
-` +const mainView = (params, state, send) => { + return choo.view` +
+

${state.input.title}

+ + send('input:update', { payload: e.target.value })}> +
+ ` +} app.router((route) => [ route('/', mainView) diff --git a/index.js b/index.js index 0cb0e23..5edc432 100644 --- a/index.js +++ b/index.js @@ -11,9 +11,7 @@ module.exports = choo // A framework for creating sturdy web applications // null -> fn -function choo (opts) { - opts = opts || {} - const name = opts.name || 'choo' +function choo () { const _models = [] var _router = null @@ -40,17 +38,19 @@ function choo (opts) { } // start the application - // null -> DOMNode - function start () { + // obj -> DOMNode + function start (opts) { + opts = opts || {} + const name = opts.name || 'choo' const initialState = {} const reducers = {} const effects = {} _models.push(appInit(opts)) _models.forEach(function (model) { - if (model.state) apply(model.name, model.state, initialState) - if (model.reducers) apply(model.name, model.reducers, reducers) - if (model.effects) apply(model.name, model.effects, effects) + if (model.state) apply(model.namespace, model.state, initialState) + if (model.reducers) apply(model.namespace, model.reducers, reducers) + if (model.effects) apply(model.namespace, model.effects, effects) }) const send = sendAction({ @@ -68,7 +68,7 @@ function choo (opts) { }) const rootId = name + '-root' - const tree = _router(send.state().location, send.state(), send) + const tree = _router(send.state().app.location, send.state(), send) tree.setAttribute('id', rootId) return tree @@ -77,14 +77,26 @@ function choo (opts) { var _effects = false var newState = null - if (reducers[action.type]) { - newState = xtend(state, reducers[action.type](action, state)) + if (/:/.test(action.type)) { + const arr = action.type.split(':') + var ns = arr[0] + action.type = arr[1] + } + + const nsReducers = ns ? reducers[ns] : reducers + if (nsReducers[action.type]) { + if (ns) { + state[ns] = reducers[ns][action.type](action, state[ns]) + newState = state + } else { + newState = xtend(state, reducers[action.type](action, state)) + } _reducers = true } - if (effects[action.type]) { - effects[action.type](action, newState || state, send) - newState = newState || state + const nsEffects = ns ? effects[ns] : effects + if (nsEffects) { + nsEffects[action.type](action, newState || state) _effects = true } @@ -92,13 +104,13 @@ function choo (opts) { throw new Error('Could not find action ' + action.type) } - return newState + return newState || state } // update on every change function onchange (action, state) { const oldTree = document.querySelector('#' + rootId) - const newTree = _router(state.location, state, send) + const newTree = _router(state.app.location, state, send) newTree.setAttribute('id', rootId) yo.update(oldTree, newTree) } @@ -113,9 +125,7 @@ function choo (opts) { // create a new model // (str?, obj) -> null - function model (name, model) { - if (!model) model = name - if (typeof name === 'string') model.name = name + function model (model) { _models.push(model) } } @@ -124,6 +134,7 @@ function choo (opts) { // obj -> obj function appInit (opts) { const model = { + namespace: 'app', state: { location: document.location.href }, reducers: { location: setLocation }, subscriptions: [] @@ -158,7 +169,10 @@ function appInit (opts) { // (str, obj, obj) -> null function apply (name, source, target) { Object.keys(source).forEach(function (key) { - if (name) target[name + ':' + key] = source[key] - else target[key] = source[key] + if (name) { + if (!target[name]) target[name] = {} + target[name][key] = source[key] + target[name][key].namespace = name + } else target[key] = source[key] }) } From 300b6325a9f687d13a44a0612ff4ded0728e45e8 Mon Sep 17 00:00:00 2001 From: Yoshua Wuyts Date: Mon, 23 May 2016 04:00:29 +0900 Subject: [PATCH 3/6] examples: update mailbox --- examples/mailbox/client.js | 6 +++--- examples/mailbox/elements/email-list.js | 2 +- examples/mailbox/elements/email.js | 2 +- examples/mailbox/elements/mailbox.js | 7 ++++--- examples/mailbox/elements/nav.js | 7 +++---- examples/mailbox/elements/pathname.js | 3 ++- examples/mailbox/models/inbox.js | 1 + examples/mailbox/models/sent.js | 1 + examples/mailbox/models/spam.js | 1 + index.js | 11 +++++------ 10 files changed, 22 insertions(+), 19 deletions(-) diff --git a/examples/mailbox/client.js b/examples/mailbox/client.js index f2c1f5e..e71b15c 100644 --- a/examples/mailbox/client.js +++ b/examples/mailbox/client.js @@ -6,9 +6,9 @@ sf('tachyons') const app = choo() -app.model('inbox', require('./models/inbox')) -app.model('spam', require('./models/spam')) -app.model('sent', require('./models/sent')) +app.model(require('./models/inbox')) +app.model(require('./models/spam')) +app.model(require('./models/sent')) app.router((route) => [ route('/', require('./views/empty')), diff --git a/examples/mailbox/elements/email-list.js b/examples/mailbox/elements/email-list.js index e80a6f1..4a03b5e 100644 --- a/examples/mailbox/elements/email-list.js +++ b/examples/mailbox/elements/email-list.js @@ -3,7 +3,7 @@ const choo = require('../../../') module.exports = function (params, state, send) { const mailbox = params.mailbox - const messages = state[mailbox + ':messages'] + const messages = state[mailbox].messages return choo.view`
diff --git a/examples/mailbox/elements/email.js b/examples/mailbox/elements/email.js index 361f37b..04d9815 100644 --- a/examples/mailbox/elements/email.js +++ b/examples/mailbox/elements/email.js @@ -4,7 +4,7 @@ module.exports = function (params, state, send) { const mailbox = params.mailbox const message = params.message - const email = state[mailbox + ':messages'].filter(function (msg) { + const email = state[mailbox].messages.filter(function (msg) { return String(msg.id) === message })[0] diff --git a/examples/mailbox/elements/mailbox.js b/examples/mailbox/elements/mailbox.js index e435b2e..23a8f0e 100644 --- a/examples/mailbox/elements/mailbox.js +++ b/examples/mailbox/elements/mailbox.js @@ -5,9 +5,10 @@ module.exports = function () { return function (params, state, send) { const mailbox = params.mailbox const message = params.message + const messages = state[mailbox].messages if (message) { - const email = state[mailbox + ':messages'].filter(function (msg) { + const email = state[mailbox].messages.filter(function (msg) { return String(msg.id) === message })[0] @@ -15,7 +16,7 @@ module.exports = function () {
${createHeader()} - ${state[mailbox + ':messages'].map(function (msg) { + ${messages.map(function (msg) { return createMessage(msg, mailbox) })}
@@ -28,7 +29,7 @@ module.exports = function () { return choo.view`
${createHeader()} - ${state[mailbox + ':messages'].map(function (msg) { + ${messages.map(function (msg) { return createMessage(msg, mailbox) })}
diff --git a/examples/mailbox/elements/nav.js b/examples/mailbox/elements/nav.js index 2d51362..249ae80 100644 --- a/examples/mailbox/elements/nav.js +++ b/examples/mailbox/elements/nav.js @@ -3,16 +3,15 @@ const choo = require('../../../') const mailboxes = [ 'inbox', 'spam', 'sent' ] module.exports = function (params, state, send) { - const mailbox = params.mailbox - return choo.view` diff --git a/examples/mailbox/elements/pathname.js b/examples/mailbox/elements/pathname.js index d9eb157..7006900 100644 --- a/examples/mailbox/elements/pathname.js +++ b/examples/mailbox/elements/pathname.js @@ -2,9 +2,10 @@ const pathname = require('pathname-match') const choo = require('../../../') module.exports = function (params, state, send) { + const location = state.app.location return choo.view` - URL: ${pathname(state.location) || '/'} + URL: ${pathname(location) || '/'} ` } diff --git a/examples/mailbox/models/inbox.js b/examples/mailbox/models/inbox.js index c055a8c..440d852 100644 --- a/examples/mailbox/models/inbox.js +++ b/examples/mailbox/models/inbox.js @@ -1,4 +1,5 @@ module.exports = { + namespace: 'inbox', state: { messages: [ { diff --git a/examples/mailbox/models/sent.js b/examples/mailbox/models/sent.js index 0dc4a60..72acae0 100644 --- a/examples/mailbox/models/sent.js +++ b/examples/mailbox/models/sent.js @@ -1,4 +1,5 @@ module.exports = { + namespace: 'sent', state: { messages: [ { diff --git a/examples/mailbox/models/spam.js b/examples/mailbox/models/spam.js index 451e74d..754ff55 100644 --- a/examples/mailbox/models/spam.js +++ b/examples/mailbox/models/spam.js @@ -1,4 +1,5 @@ module.exports = { + namespace: 'spam', state: { messages: [ { diff --git a/index.js b/index.js index 5edc432..aad8e4e 100644 --- a/index.js +++ b/index.js @@ -84,7 +84,7 @@ function choo () { } const nsReducers = ns ? reducers[ns] : reducers - if (nsReducers[action.type]) { + if (nsReducers && nsReducers[action.type]) { if (ns) { state[ns] = reducers[ns][action.type](action, state[ns]) newState = state @@ -95,7 +95,7 @@ function choo () { } const nsEffects = ns ? effects[ns] : effects - if (nsEffects) { + if (nsEffects && nsEffects[action.type]) { nsEffects[action.type](action, newState || state) _effects = true } @@ -143,7 +143,7 @@ function appInit (opts) { if (opts.href !== false) { model.subscriptions.push(function (send) { href(function (href) { - send('location', { location: href }) + send('app:location', { location: href }) }) }) } @@ -151,7 +151,7 @@ function appInit (opts) { if (opts.history !== false) { model.subscriptions.push(function (send) { history(function (href) { - send('location', { location: href }) + send('app:location', { location: href }) }) }) } @@ -160,8 +160,7 @@ function appInit (opts) { // handle href links function setLocation (action, state) { - const location = action.location.replace(/#.*/, '') - return xtend(state, { location: location }) + return { location: action.location.replace(/#.*/, '') } } } From 0cc0caa8e1a022920a95ec8908dd360f7870bb03 Mon Sep 17 00:00:00 2001 From: Yoshua Wuyts Date: Mon, 23 May 2016 10:44:32 +0900 Subject: [PATCH 4/6] examples/http: update for ns --- examples/http/client.js | 2 +- examples/http/models/api.js | 39 ++++-------- examples/http/models/error.js | 5 +- examples/http/views/main.js | 6 +- index.js | 113 ++++++++++++++++++++-------------- 5 files changed, 89 insertions(+), 76 deletions(-) diff --git a/examples/http/client.js b/examples/http/client.js index 31370a2..c1473a5 100644 --- a/examples/http/client.js +++ b/examples/http/client.js @@ -5,7 +5,7 @@ const mainView = require('./views/main') const app = choo() app.model(require('./models/error')) -app.model('api', require('./models/api')) +app.model(require('./models/api')) app.router((route) => [ route('/', mainView) diff --git a/examples/http/models/api.js b/examples/http/models/api.js index 37907e3..73daf8a 100644 --- a/examples/http/models/api.js +++ b/examples/http/models/api.js @@ -1,45 +1,32 @@ const http = require('../../../http') module.exports = { + namespace: 'api', state: { title: 'Button pushing machine 3000' }, reducers: { - set: function (action, state) { - return { 'api:title': action.payload } - } + set: (action, state) => ({ 'title': action.payload }) }, effects: { - good: performGoodRequest, - bad: performBadRequest + good: (action, state, send) => request('/good', send), + bad: (action, state, send) => request('/bad', send) } } -function performGoodRequest (action, state, send) { - http('/good', { json: true }, function (err, res, body) { - if (err) return send('error', { payload: 'HTTP error' }) - if (res.statusCode !== 200) { - return send('error', { payload: body.payload }) - } - if (!body) { - return send('error', { payload: 'fatal: no body received' }) - } - send('api:set', { payload: body.message }) - }) -} - -function performBadRequest (action, state, send) { - http('/bad', { json: true }, function (err, res, body) { - if (err) return send('error', { payload: 'HTTP error' }) +function request (uri, send) { + http(uri, { json: true }, function (err, res, body) { + if (err) return send('app:error', { payload: 'HTTP error' }) if (res.statusCode !== 200) { const message = (body && body.message) - ? body.message - : 'unknown server error' - return send('error', { payload: message }) + ? body.message + : 'unknown server error' + return send('app:error', { payload: message }) } if (!body) { - return send('error', { payload: 'fatal: no body received' }) + console.log('req made!') + return send('app:error', { payload: 'fatal: no body received' }) } - send('api:set', { payload: body.title }) + send('api:set', { payload: body.message || body.title }) }) } diff --git a/examples/http/models/error.js b/examples/http/models/error.js index 4bc3694..7d05ce5 100644 --- a/examples/http/models/error.js +++ b/examples/http/models/error.js @@ -8,6 +8,7 @@ const ERROR_TIMEOUT = 1000 module.exports = { + namespace: 'app', state: { error: [], errorTimeDone: null, @@ -33,9 +34,9 @@ module.exports = { }, effects: { error: function (action, state, send) { - const timeout = state.errorTimeDone - Date.now() + const timeout = state.app.errorTimeDone - Date.now() setTimeout(function () { - send('error:delete') + send('app:error:delete') }, timeout) } } diff --git a/examples/http/views/main.js b/examples/http/views/main.js index 22c5414..4d0d435 100644 --- a/examples/http/views/main.js +++ b/examples/http/views/main.js @@ -1,10 +1,12 @@ const choo = require('../../../') module.exports = function (params, state, send) { + const error = state.app.error[0] + const title = state.api.title return choo.view`
-

${state['api:title']}

-

Latest error: ${state.error[0]}

+

${title}

+

Latest error: ${error}

diff --git a/index.js b/index.js index aad8e4e..3e012dd 100644 --- a/index.js +++ b/index.js @@ -3,13 +3,14 @@ const sheetRouter = require('sheet-router') const document = require('global/document') const href = require('sheet-router/href') const sendAction = require('send-action') +const assert = require('assert') const xtend = require('xtend') const yo = require('yo-yo') choo.view = yo module.exports = choo -// A framework for creating sturdy web applications +// framework for creating sturdy web applications // null -> fn function choo () { const _models = [] @@ -23,6 +24,7 @@ function choo () { return start // render the application to a string + // (str, obj) -> str function toString (route, state) { const initialState = {} @@ -53,61 +55,79 @@ function choo () { if (model.effects) apply(model.namespace, model.effects, effects) }) + // send() is used to trigger actions inside + // views, effects and subscriptions const send = sendAction({ onaction: handleAction, onchange: onchange, state: initialState }) + // subscriptions are loaded after sendAction() is called + // because they both need access to send() and can't + // react to actions (read-only) _models.forEach(function (model) { if (model.subscriptions) { + assert.ok(Array.isArray(model.subscriptions, 'subs must be an array')) model.subscriptions.forEach(function (sub) { sub(send) }) } }) + // the rootId is determined to find the application root + // on update. Since the DOM nodes change between updates, + // we must call document.querySelector() to find the root. + // Use different names when loading multiple choo applications + // on the same page const rootId = name + '-root' const tree = _router(send.state().app.location, send.state(), send) tree.setAttribute('id', rootId) return tree + // handle an action by either reducers, effects + // or both - return the new state when done + // (obj, obj, fn) -> obj function handleAction (action, state, send) { - var _reducers = false - var _effects = false - var newState = null + var reducersCalled = false + var effectsCalled = false + // validate if a namespace exists. Namespaces + // are delimited by the first ':'. Perhaps + // we'll allow recursive namespaces in the + // future - who knows if (/:/.test(action.type)) { const arr = action.type.split(':') - var ns = arr[0] - action.type = arr[1] + var ns = arr.shift() + action.type = arr.join(':') } - const nsReducers = ns ? reducers[ns] : reducers - if (nsReducers && nsReducers[action.type]) { + const _reducers = ns ? reducers[ns] : reducers + if (_reducers && _reducers[action.type]) { if (ns) { - state[ns] = reducers[ns][action.type](action, state[ns]) - newState = state + const newState = reducers[ns][action.type](action, state[ns]) + state[ns] = xtend(state[ns], newState) } else { - newState = xtend(state, reducers[action.type](action, state)) + state = xtend(state, reducers[action.type](action, state)) } - _reducers = true + reducersCalled = true } - const nsEffects = ns ? effects[ns] : effects - if (nsEffects && nsEffects[action.type]) { - nsEffects[action.type](action, newState || state) - _effects = true + const _effects = ns ? effects[ns] : effects + if (_effects && _effects[action.type]) { + _effects[action.type](action, state, send) + effectsCalled = true } - if (!_reducers && !_effects) { + if (!reducersCalled && !effectsCalled) { throw new Error('Could not find action ' + action.type) } - return newState || state + return state } - // update on every change + // update the DOM after every state mutation + // (obj, obj) -> null function onchange (action, state) { const oldTree = document.querySelector('#' + rootId) const newTree = _router(state.app.location, state, send) @@ -116,7 +136,7 @@ function choo () { } } - // register all routes + // register all routes on the router // [obj|fn] -> null function router (cb) { _router = sheetRouter(cb) @@ -136,42 +156,45 @@ function appInit (opts) { const model = { namespace: 'app', state: { location: document.location.href }, - reducers: { location: setLocation }, - subscriptions: [] + subscriptions: [], + reducers: { + // handle href links + location: function setLocation (action, state) { + return { + location: action.location.replace(/#.*/, '') + } + } + } } - if (opts.href !== false) { - model.subscriptions.push(function (send) { - href(function (href) { - send('app:location', { location: href }) - }) - }) - } - - if (opts.history !== false) { - model.subscriptions.push(function (send) { - history(function (href) { - send('app:location', { location: href }) - }) - }) - } + // enable catching links + // enable HTML5 history API + if (opts.history !== false) pushLocationSub(href) + if (opts.history !== false) pushLocationSub(history) return model - // handle href links - function setLocation (action, state) { - return { location: action.location.replace(/#.*/, '') } + // create a new subscription that modifies + // 'app:location' and push it to the subs + // fn -> null + function pushLocationSub (cb) { + model.subscriptions.push(function (send) { + cb(function (href) { + send('app:location', { location: href }) + }) + }) } } // compose an object conditionally +// optionally contains a namespace +// which is used to nest properties. // (str, obj, obj) -> null -function apply (name, source, target) { +function apply (ns, source, target) { Object.keys(source).forEach(function (key) { - if (name) { - if (!target[name]) target[name] = {} - target[name][key] = source[key] - target[name][key].namespace = name + if (ns) { + if (!target[ns]) target[ns] = {} + target[ns][key] = source[key] } else target[key] = source[key] }) } From dc840059a46e483184a43df809bb3c67b995726d Mon Sep 17 00:00:00 2001 From: Yoshua Wuyts Date: Mon, 23 May 2016 14:40:52 +0900 Subject: [PATCH 5/6] models: fix state namespace --- README.md | 8 +++++++- examples/http/models/api.js | 3 +-- examples/http/models/error.js | 2 +- examples/http/server.js | 2 +- examples/mailbox/server.js | 2 +- examples/server/server.js | 34 +++++++++++++++++++++++----------- index.js | 11 +++++------ site/server.js | 2 +- 8 files changed, 40 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index fa0a6e9..647c279 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,6 @@ document.body.appendChild(tree) ``` ## Concepts -`choo` is a complete framework. It has an answer to pretty most points - __user:__ 🙆 - __DOM:__ the [Document Object Model][dom] is what is currently displayed in your browser @@ -417,6 +416,13 @@ Also imagine telling some business people you chose to rewrite something critical to the company using the `choo` framework. :steam_locomotive::train::train::train: +### Why is it a framework, and not a library? +I love small libraries that do one thing well, but when working in a team, +having an undocumented combination of packages often isn't great. `choo()` is a +small set of packages that work well together, wrapped in an an architectural +pattern. This means you get all the benefits of small packages, but get to be +productive right from the start. + ### How does choo compare to X? Ah, so this is where I get to rant. `choo` (_chugga-chugga-chugga-choo-choo!_) was built because other options didn't quite cut it for me, so instead of diff --git a/examples/http/models/api.js b/examples/http/models/api.js index 73daf8a..65be0db 100644 --- a/examples/http/models/api.js +++ b/examples/http/models/api.js @@ -14,7 +14,7 @@ module.exports = { } } -function request (uri, send) { +function request (uri, send, state) { http(uri, { json: true }, function (err, res, body) { if (err) return send('app:error', { payload: 'HTTP error' }) if (res.statusCode !== 200) { @@ -24,7 +24,6 @@ function request (uri, send) { return send('app:error', { payload: message }) } if (!body) { - console.log('req made!') return send('app:error', { payload: 'fatal: no body received' }) } send('api:set', { payload: body.message || body.title }) diff --git a/examples/http/models/error.js b/examples/http/models/error.js index 7d05ce5..632d0e5 100644 --- a/examples/http/models/error.js +++ b/examples/http/models/error.js @@ -34,7 +34,7 @@ module.exports = { }, effects: { error: function (action, state, send) { - const timeout = state.app.errorTimeDone - Date.now() + const timeout = state.errorTimeDone - Date.now() setTimeout(function () { send('app:error:delete') }, timeout) diff --git a/examples/http/server.js b/examples/http/server.js index c98ca55..b0da21e 100644 --- a/examples/http/server.js +++ b/examples/http/server.js @@ -6,7 +6,7 @@ const http = require('http') const PORT = 8080 const server = http.createServer(createRouter()) -server.listen(PORT, () => console.log(`listening on port ${PORT}`)) +server.listen(PORT, () => process.stdout.write(`listening on port ${PORT}\n`)) var index = 0 const errors = [ diff --git a/examples/mailbox/server.js b/examples/mailbox/server.js index 85d6517..ebf3755 100644 --- a/examples/mailbox/server.js +++ b/examples/mailbox/server.js @@ -6,7 +6,7 @@ const http = require('http') const PORT = 8080 const server = http.createServer(createRouter()) -server.listen(PORT, () => console.log(`listening on port ${PORT}`)) +server.listen(PORT, () => process.stdout.write(`listening on port ${PORT}\n`)) function createRouter () { const router = serverRouter('/404') diff --git a/examples/server/server.js b/examples/server/server.js index 051d06c..93e138f 100644 --- a/examples/server/server.js +++ b/examples/server/server.js @@ -2,21 +2,33 @@ const serverRouter = require('server-router') const http = require('http') const PORT = 8080 - const client = require('./client') -const server = http.createServer(createRouter()) -server.listen(PORT, () => console.log(`listening on port ${PORT}`)) +// If an incoming request accepts "text/html", render the +// appropriate HTML. Else use the API server +const apiRouter = createRouter() +const server = http.createServer(function (req, res) { + if (/text\/html/.test(req.headers.accept)) handleHtml(req, res) + else apiRouter(req, res) +}) +server.listen(PORT, () => process.stdout.write(`listening on port ${PORT}\n`)) +// create a new router +// null -> fn function createRouter () { - const router = serverRouter('/404') - - router.on('/404', (req, res) => res.end('not found')) - router.on('/', function (req, res, params) { - const html = client.toString('/', { message: 'hello server!' }) - res.setHeader('Content-Type', 'text/html; charset=utf-8') - res.end(html) + const apiRouter = serverRouter('/404') + apiRouter.on('/404', (req, res) => res.end('not found')) + apiRouter.on('/', (req, res) => { + res.end(JSON.stringify({ routes: [ '/', '/404' ] })) }) + return apiRouter +} - return router +// render the client to string +// based on the requested url +// (obj, obj) -> null +function handleHtml (req, res) { + const html = client.toString(req.url, { message: 'hello server!' }) + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.end(html) } diff --git a/index.js b/index.js index 3e012dd..b054b7e 100644 --- a/index.js +++ b/index.js @@ -105,17 +105,16 @@ function choo () { const _reducers = ns ? reducers[ns] : reducers if (_reducers && _reducers[action.type]) { if (ns) { - const newState = reducers[ns][action.type](action, state[ns]) + const newState = _reducers[action.type](action, state[ns]) state[ns] = xtend(state[ns], newState) - } else { - state = xtend(state, reducers[action.type](action, state)) - } + } else state = xtend(state, reducers[action.type](action, state)) reducersCalled = true } const _effects = ns ? effects[ns] : effects if (_effects && _effects[action.type]) { - _effects[action.type](action, state, send) + if (ns) _effects[action.type](action, state[ns], send) + else _effects[action.type](action, state, send) effectsCalled = true } @@ -175,7 +174,7 @@ function appInit (opts) { return model // create a new subscription that modifies - // 'app:location' and push it to the subs + // 'app:location' and push it to be loaded // fn -> null function pushLocationSub (cb) { model.subscriptions.push(function (send) { diff --git a/site/server.js b/site/server.js index 85d6517..ebf3755 100644 --- a/site/server.js +++ b/site/server.js @@ -6,7 +6,7 @@ const http = require('http') const PORT = 8080 const server = http.createServer(createRouter()) -server.listen(PORT, () => console.log(`listening on port ${PORT}`)) +server.listen(PORT, () => process.stdout.write(`listening on port ${PORT}\n`)) function createRouter () { const router = serverRouter('/404') From ab96d51a895898904e782f97e1e9ac462369b3e0 Mon Sep 17 00:00:00 2001 From: Yoshua Wuyts Date: Mon, 23 May 2016 14:45:24 +0900 Subject: [PATCH 6/6] examples/server -> examples/server-rendering --- README.md | 7 ++++--- examples/{server => server-rendering}/client.js | 0 examples/{server => server-rendering}/package.json | 0 examples/{server => server-rendering}/server.js | 0 examples/{server => server-rendering}/views/main.js | 0 5 files changed, 4 insertions(+), 3 deletions(-) rename examples/{server => server-rendering}/client.js (100%) rename examples/{server => server-rendering}/package.json (100%) rename examples/{server => server-rendering}/server.js (100%) rename examples/{server => server-rendering}/views/main.js (100%) diff --git a/README.md b/README.md index 647c279..73254c8 100644 --- a/README.md +++ b/README.md @@ -125,13 +125,14 @@ document.body.appendChild(tree) `models` are objects that contain initial `state`, `subscriptions`, `effects` and `reducers`. They're generally grouped around a theme (or domain, if you like). To provide some sturdiness to your `models`, they can either be -namespaced or not. Namespacing means that only actions and state inside the -model can be called. +namespaced or not. Namespacing means that only state within the model can be +accessed. Models can still trigger actions on other models, though it's +recommeded to keep that to a minimum. So say we have a `todos` namespace, an `add` reducer and a `todos` model. Outside the model they're called by `send('todos:add')` and `state.todos.todos`. Inside the namespaced model they're called by -`send('add')` and `state.todos`. An example namespaced model: +`send('todos:add')` and `state.todos`. An example namespaced model: ```js const app = choo() app.model({ diff --git a/examples/server/client.js b/examples/server-rendering/client.js similarity index 100% rename from examples/server/client.js rename to examples/server-rendering/client.js diff --git a/examples/server/package.json b/examples/server-rendering/package.json similarity index 100% rename from examples/server/package.json rename to examples/server-rendering/package.json diff --git a/examples/server/server.js b/examples/server-rendering/server.js similarity index 100% rename from examples/server/server.js rename to examples/server-rendering/server.js diff --git a/examples/server/views/main.js b/examples/server-rendering/views/main.js similarity index 100% rename from examples/server/views/main.js rename to examples/server-rendering/views/main.js