Merge pull request #8 from yoshuawuyts/model-update

model: add namespaces
This commit is contained in:
Yoshua Wuyts
2016-05-23 17:26:47 +09:00
25 changed files with 356 additions and 186 deletions
+10
View File
@@ -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
+156 -37
View File
@@ -13,6 +13,7 @@ productive package.
- [Demos](#demos) - [Demos](#demos)
- [Usage](#usage) - [Usage](#usage)
- [Concepts](#concepts) - [Concepts](#concepts)
- [Models](#models)
- [Effects](#effects) - [Effects](#effects)
- [HTTP](#http) - [HTTP](#http)
- [Subscriptions](#subscriptions) - [Subscriptions](#subscriptions)
@@ -23,6 +24,7 @@ productive package.
- [API](#api) - [API](#api)
- [FAQ](#faq) - [FAQ](#faq)
- [Installation](#installation) - [Installation](#installation)
- [See Also](#see-also)
- [License](#license) - [License](#license)
## Features ## Features
@@ -51,28 +53,31 @@ productive package.
const choo = require('choo') const choo = require('choo')
const app = choo() const app = choo()
app.model('title', { app.model({
namespace: 'input',
state: { state: {
title: 'my-demo-app' title: 'my demo app'
}, },
reducers: { reducers: {
'update': (action, state) => ({ title: action.payload }) update: (action, state) => ({ title: action.payload })
}, },
effects: { effects: {
'update': (action, state, send) => (document.title = action.payload) update: (action, state, send) => (document.title = action.payload)
} }
}) })
const mainView = (params, state, send) => choo.view` const mainView = (params, state, send) => {
return choo.view`
<main class="app"> <main class="app">
<h1>${state.title}</h1> <h1>${state.input.title}</h1>
<label>Set the title</label> <label>Set the title</label>
<input <input
type="text" type="text"
placeholder=${state.title} placeholder=${state.input.title}
oninput=${(e) => send('title:update', { payload: e.target.value })}> oninput=${(e) => send('input:update', { payload: e.target.value })}>
</main> </main>
` `
}
app.router((route) => [ app.router((route) => [
route('/', mainView) route('/', mainView)
@@ -88,8 +93,8 @@ document.body.appendChild(tree)
your browser your browser
- __actions:__ a named event with optional properties attached. Used to call - __actions:__ a named event with optional properties attached. Used to call
`effects` and `reducers` that have been registered in `models` `effects` and `reducers` that have been registered in `models`
- __model:__ optionally namespaced object containing `subscriptions`, `effects` - __model:__ optionally namespaced object containing `subscriptions`,
and `reducers` `effects`, `reducers` and initial `state`
- __subscriptions:__ read-only data sources that emit `actions` - __subscriptions:__ read-only data sources that emit `actions`
- __effects:__ asynchronous functions that emit an `action` when done - __effects:__ asynchronous functions that emit an `action` when done
- __reducers:__ synchronous functions that modify `state` - __reducers:__ synchronous functions that modify `state`
@@ -116,6 +121,41 @@ 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 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('todos: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 ## Effects
Side effects are done through `effects` declared in `app.model()`. Unlike Side effects are done through `effects` declared in `app.model()`. Unlike
`reducers` they cannot modify the state by returning objects, but get a `reducers` they cannot modify the state by returning objects, but get a
@@ -136,25 +176,49 @@ A typical `effect` flow looks like:
that weighs only `2.4kb`: that weighs only `2.4kb`:
```js ```js
const http = require('choo/http') const http = require('choo/http')
const choo = require('choo')
const app = choo()
// GET JSON app.model({
http.get('/my-endpoint', { json: true }, function (err, res, body) { effects: {
if (err) throw err 'app:error': (state, event_ => console.error(`error: ${event.payload}`)),
if (res.statusCode !== 200 || !body) throw new Error('something went wrong') 'app:print': (state, event) => console.log(`http: ${event.payload}`),
'http:get_json': getJson,
'http:post_json': postJson,
'http:delete': httpDelete
}
}) })
// POST JSON 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 })
})
}
function postJson (state, action, send) {
const body = { foo: 'bar' } const body = { foo: 'bar' }
http.post('/my-endpoint', { json: body }, function (err, res, body) { http.post('/my-endpoint', { json: body }, function (err, res, body) {
if (err) throw err if (err) return send('app:error', { payload: err.message })
if (res.statusCode !== 200 || !body) throw new Error('something went wrong') if (res.statusCode !== 200 || !body) {
return send('app:error', { payload:'something went wrong' })
}
send('app:print', { payload: body })
}) })
}
// DELETE function httpDelete (state, action, send) {
http.del('/my-endpoint', function (err, res) { const body = { foo: 'bar' }
if (err) throw err http.post('/my-endpoint', { json: body }, function (err, res, body) {
if (res.statusCode !== 200) throw new Error('something went wrong') 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 Note that `http` only runs in the browser to prevent accidental requests when
rendering in Node. For more details view the [`raynos/xhr` rendering in Node. For more details view the [`raynos/xhr`
@@ -165,6 +229,19 @@ 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 listening for events from a server using `SSE` or `Websockets` for a
chat app, or when catching keyboard input for a videogame. 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)
[Server Sent Events (SSE)][sse] allow servers to push data to the browser. [Server Sent Events (SSE)][sse] allow servers to push data to the browser.
They're the unidirectional cousin of `websockets` and compliment `HTTP` They're the unidirectional cousin of `websockets` and compliment `HTTP`
@@ -176,17 +253,20 @@ const stream = new document.EventSource('/sse')
app.model({ app.model({
subscriptions: [ subscriptions: [
function (send) { function (send) {
stream.onerror = (e) => send('error', { payload: JSON.stringify(e) }) stream.onerror = (e) => send('app:error', { payload: JSON.stringify(e) })
stream.onmessage = (e) => send('print', { payload: e.data }) stream.onmessage = (e) => send('app:print', { payload: e.data })
} }
], ],
effects: { effects: {
'sse:close': () => stream.close() 'sse:close': () => stream.close()
error: (state, event_ => console.error(`error: ${event.payload}`)), 'app:error': (state, event_ => console.error(`error: ${event.payload}`)),
print: (state, event) => console.log(`pressed key num: ${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 ### Keyboard
Most browsers have [basic support for keyboard events][keyboard-support]. To Most browsers have [basic support for keyboard events][keyboard-support]. To
@@ -195,11 +275,11 @@ capture keyboard events, setup a `subscription`:
app.model({ app.model({
subscriptions: [ subscriptions: [
function (send) { function (send) {
keyboard.onkeypress = (e) => send('print', { payload: e.keyCode }) keyboard.onkeypress = (e) => send('app:print', { payload: e.keyCode })
} }
], ],
effects: { effects: {
print: (state, event) => console.log(`pressed key num: ${event.payload}`) 'app:print': (state, event) => console.log(`pressed key: ${event.payload}`)
} }
}) })
``` ```
@@ -213,18 +293,21 @@ const socket = new document.WebSocket('ws://localhost:8081')
app.model({ app.model({
subscriptions: [ subscriptions: [
function (send) { function (send) {
socket.onerror = (e) => send('error', { payload: JSON.stringify(e) }) socket.onerror = (e) => send('app:error', { payload: JSON.stringify(e) })
socket.onmessage = (e) => send('print', { payload: e.data }) socket.onmessage = (e) => send('app:print', { payload: e.data })
} }
], ],
effects: { effects: {
'ws:close': () => socket.close(), 'ws:close': () => socket.close(),
'ws:send': (state, event) => socket.send(JSON.stringify(event.payload)), 'ws:send': (state, event) => socket.send(JSON.stringify(event.payload)),
error: (state, event_ => console.error(`error: ${event.payload}`)), 'app:error': (state, event_ => console.error(`error: ${event.payload}`)),
print: (state, event) => console.log(`pressed key num: ${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 ## Rendering in Node
Sometimes it's necessary to render code inside of Node; for serving first Sometimes it's necessary to render code inside of Node; for serving first
@@ -273,9 +356,12 @@ else document.body.appendChild(app.start())
### app = choo() ### app = choo()
Create a new `choo` app Create a new `choo` app
### app.model(name?, obj) ### app.model(obj)
Create a new model. Models modify data and perform IO. Obj takes the following Create a new model. Models modify data and perform IO. Takes the following
arguments: 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 - __state:__ object. Key value store of initial values
- __reducers:__ object. Syncronous functions that modify state. Each function - __reducers:__ object. Syncronous functions that modify state. Each function
has a signature of `(action, state)` has a signature of `(action, state)`
@@ -283,10 +369,6 @@ arguments:
a signature of `(action, state, send)` where `send` is a reference to a signature of `(action, state, send)` where `send` is a reference to
`app.send()` `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\` ### choo.view\`html\`
Tagged template string HTML builder. See Tagged template string HTML builder. See
[`yo-yo`](https://github.com/maxogden/yo-yo) for full documentation. Views [`yo-yo`](https://github.com/maxogden/yo-yo) for full documentation. Views
@@ -316,6 +398,32 @@ Start the application. Returns a tree of DOM nodes that can be mounted using
accordingly. accordingly.
## FAQ ## 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:
### 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? ### How does choo compare to X?
Ah, so this is where I get to rant. `choo` (_chugga-chugga-chugga-choo-choo!_) 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 was built because other options didn't quite cut it for me, so instead of
@@ -382,6 +490,10 @@ transforms:
- [uglifyify](https://github.com/hughsk/uglifyify) - minify your code using - [uglifyify](https://github.com/hughsk/uglifyify) - minify your code using
UglifyJS2. Use as a `--global` transform 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? ### Is it production ready?
Sure. Sure.
@@ -390,6 +502,11 @@ Sure.
$ npm install choo $ 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 ## License
[MIT](https://tldrlegal.com/license/mit-license) [MIT](https://tldrlegal.com/license/mit-license)
@@ -416,3 +533,5 @@ $ npm install choo
[morphdom]: https://github.com/patrick-steele-idem/morphdom [morphdom]: https://github.com/patrick-steele-idem/morphdom
[morphdom-bench]: https://github.com/patrick-steele-idem/morphdom#benchmarks [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 [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
+1 -1
View File
@@ -5,7 +5,7 @@ const mainView = require('./views/main')
const app = choo() const app = choo()
app.model(require('./models/error')) app.model(require('./models/error'))
app.model('api', require('./models/api')) app.model(require('./models/api'))
app.router((route) => [ app.router((route) => [
route('/', mainView) route('/', mainView)
+10 -24
View File
@@ -1,45 +1,31 @@
const http = require('../../../http') const http = require('../../../http')
module.exports = { module.exports = {
namespace: 'api',
state: { state: {
title: 'Button pushing machine 3000' title: 'Button pushing machine 3000'
}, },
reducers: { reducers: {
set: function (action, state) { set: (action, state) => ({ 'title': action.payload })
return { 'api:title': action.payload }
}
}, },
effects: { effects: {
good: performGoodRequest, good: (action, state, send) => request('/good', send),
bad: performBadRequest bad: (action, state, send) => request('/bad', send)
} }
} }
function performGoodRequest (action, state, send) { function request (uri, send, state) {
http('/good', { json: true }, function (err, res, body) { http(uri, { json: true }, function (err, res, body) {
if (err) return send('error', { payload: 'HTTP error' }) if (err) return send('app: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' })
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
const message = (body && body.message) const message = (body && body.message)
? body.message ? body.message
: 'unknown server error' : 'unknown server error'
return send('error', { payload: message }) return send('app:error', { payload: message })
} }
if (!body) { if (!body) {
return send('error', { payload: 'fatal: no body received' }) return send('app:error', { payload: 'fatal: no body received' })
} }
send('api:set', { payload: body.title }) send('api:set', { payload: body.message || body.title })
}) })
} }
+2 -1
View File
@@ -8,6 +8,7 @@
const ERROR_TIMEOUT = 1000 const ERROR_TIMEOUT = 1000
module.exports = { module.exports = {
namespace: 'app',
state: { state: {
error: [], error: [],
errorTimeDone: null, errorTimeDone: null,
@@ -35,7 +36,7 @@ module.exports = {
error: function (action, state, send) { error: function (action, state, send) {
const timeout = state.errorTimeDone - Date.now() const timeout = state.errorTimeDone - Date.now()
setTimeout(function () { setTimeout(function () {
send('error:delete') send('app:error:delete')
}, timeout) }, timeout)
} }
} }
+1 -1
View File
@@ -6,7 +6,7 @@ const http = require('http')
const PORT = 8080 const PORT = 8080
const server = http.createServer(createRouter()) 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 var index = 0
const errors = [ const errors = [
+4 -2
View File
@@ -1,10 +1,12 @@
const choo = require('../../../') const choo = require('../../../')
module.exports = function (params, state, send) { module.exports = function (params, state, send) {
const error = state.app.error[0]
const title = state.api.title
return choo.view` return choo.view`
<section> <section>
<h1>${state['api:title']}</h1> <h1>${title}</h1>
<h2>Latest error: ${state.error[0]}</h2> <h2>Latest error: ${error}</h2>
<button onclick=${(e) => send('api:good')}>OK!</button> <button onclick=${(e) => send('api:good')}>OK!</button>
<button onclick=${(e) => send('api:bad')}>Naughty</button> <button onclick=${(e) => send('api:bad')}>Naughty</button>
</section> </section>
+3 -3
View File
@@ -6,9 +6,9 @@ sf('tachyons')
const app = choo() const app = choo()
app.model('inbox', require('./models/inbox')) app.model(require('./models/inbox'))
app.model('spam', require('./models/spam')) app.model(require('./models/spam'))
app.model('sent', require('./models/sent')) app.model(require('./models/sent'))
app.router((route) => [ app.router((route) => [
route('/', require('./views/empty')), route('/', require('./views/empty')),
+1 -1
View File
@@ -3,7 +3,7 @@ const choo = require('../../../')
module.exports = function (params, state, send) { module.exports = function (params, state, send) {
const mailbox = params.mailbox const mailbox = params.mailbox
const messages = state[mailbox + ':messages'] const messages = state[mailbox].messages
return choo.view` return choo.view`
<div> <div>
<div class="db cf w-100"> <div class="db cf w-100">
+1 -1
View File
@@ -4,7 +4,7 @@ module.exports = function (params, state, send) {
const mailbox = params.mailbox const mailbox = params.mailbox
const message = params.message 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 return String(msg.id) === message
})[0] })[0]
+4 -3
View File
@@ -5,9 +5,10 @@ module.exports = function () {
return function (params, state, send) { return function (params, state, send) {
const mailbox = params.mailbox const mailbox = params.mailbox
const message = params.message const message = params.message
const messages = state[mailbox].messages
if (message) { if (message) {
const email = state[mailbox + ':messages'].filter(function (msg) { const email = state[mailbox].messages.filter(function (msg) {
return String(msg.id) === message return String(msg.id) === message
})[0] })[0]
@@ -15,7 +16,7 @@ module.exports = function () {
<section class="fl mt4 w-80 db"> <section class="fl mt4 w-80 db">
<div> <div>
${createHeader()} ${createHeader()}
${state[mailbox + ':messages'].map(function (msg) { ${messages.map(function (msg) {
return createMessage(msg, mailbox) return createMessage(msg, mailbox)
})} })}
</div> </div>
@@ -28,7 +29,7 @@ module.exports = function () {
return choo.view` return choo.view`
<section class="fl mt4 w-80 db"> <section class="fl mt4 w-80 db">
${createHeader()} ${createHeader()}
${state[mailbox + ':messages'].map(function (msg) { ${messages.map(function (msg) {
return createMessage(msg, mailbox) return createMessage(msg, mailbox)
})} })}
</section> </section>
+3 -4
View File
@@ -3,16 +3,15 @@ const choo = require('../../../')
const mailboxes = [ 'inbox', 'spam', 'sent' ] const mailboxes = [ 'inbox', 'spam', 'sent' ]
module.exports = function (params, state, send) { module.exports = function (params, state, send) {
const mailbox = params.mailbox
return choo.view` return choo.view`
<aside class="fl mt4 w-20 db"> <aside class="fl mt4 w-20 db">
<ul> <ul>
<li> <li>
<h2 class="f4 b lh0">Mailbox</h2> <h2 class="f4 b lh0">Mailbox</h2>
</li> </li>
${mailboxes.map(function (name) { ${mailboxes.map(function (mailbox) {
return createLi(name, state[name + ':messages'], mailbox) const messages = mailbox.messages
return createLi(mailbox, messages, mailbox)
})} })}
</ul> </ul>
</aside> </aside>
+2 -1
View File
@@ -2,9 +2,10 @@ const pathname = require('pathname-match')
const choo = require('../../../') const choo = require('../../../')
module.exports = function (params, state, send) { module.exports = function (params, state, send) {
const location = state.app.location
return choo.view` return choo.view`
<span class="fl mt4 w-100 f4 b"> <span class="fl mt4 w-100 f4 b">
URL: ${pathname(state.location) || '/'} URL: ${pathname(location) || '/'}
</span> </span>
` `
} }
+1
View File
@@ -1,4 +1,5 @@
module.exports = { module.exports = {
namespace: 'inbox',
state: { state: {
messages: [ messages: [
{ {
+1
View File
@@ -1,4 +1,5 @@
module.exports = { module.exports = {
namespace: 'sent',
state: { state: {
messages: [ messages: [
{ {
+1
View File
@@ -1,4 +1,5 @@
module.exports = { module.exports = {
namespace: 'spam',
state: { state: {
messages: [ messages: [
{ {
+1 -1
View File
@@ -6,7 +6,7 @@ const http = require('http')
const PORT = 8080 const PORT = 8080
const server = http.createServer(createRouter()) 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 () { function createRouter () {
const router = serverRouter('/404') const router = serverRouter('/404')
+34
View File
@@ -0,0 +1,34 @@
const serverRouter = require('server-router')
const http = require('http')
const PORT = 8080
const client = require('./client')
// 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 apiRouter = serverRouter('/404')
apiRouter.on('/404', (req, res) => res.end('not found'))
apiRouter.on('/', (req, res) => {
res.end(JSON.stringify({ routes: [ '/', '/404' ] }))
})
return apiRouter
}
// 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)
}
-22
View File
@@ -1,22 +0,0 @@
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}`))
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)
})
return router
}
+14 -9
View File
@@ -1,26 +1,31 @@
const choo = require('../') const choo = require('../../')
const app = choo() const app = choo()
app.model('title', { app.model({
state: { title: 'my-demo-app' }, namespace: 'input',
state: {
title: 'my demo app'
},
reducers: { reducers: {
'update': (action, state) => ({ title: action.payload }) update: (action, state) => ({ title: action.payload })
}, },
effects: { effects: {
'update': (action, state, send) => (document.title = action.payload) update: (action, state, send) => (document.title = action.payload)
} }
}) })
const mainView = (params, state, send) => choo.view` const mainView = (params, state, send) => {
return choo.view`
<main class="app"> <main class="app">
<h1>${state.title}</h1> <h1>${state.input.title}</h1>
<label>Set the title</label> <label>Set the title</label>
<input <input
type="text" type="text"
placeholder=${state.title} placeholder=${state.input.title}
oninput=${(e) => send('title:update', { payload: e.target.value })}> oninput=${(e) => send('input:update', { payload: e.target.value })}>
</main> </main>
` `
}
app.router((route) => [ app.router((route) => [
route('/', mainView) route('/', mainView)
+87 -56
View File
@@ -3,17 +3,16 @@ const sheetRouter = require('sheet-router')
const document = require('global/document') const document = require('global/document')
const href = require('sheet-router/href') const href = require('sheet-router/href')
const sendAction = require('send-action') const sendAction = require('send-action')
const assert = require('assert')
const xtend = require('xtend') const xtend = require('xtend')
const yo = require('yo-yo') const yo = require('yo-yo')
choo.view = yo choo.view = yo
module.exports = choo module.exports = choo
// A framework for creating sturdy web applications // framework for creating sturdy web applications
// null -> fn // null -> fn
function choo (opts) { function choo () {
opts = opts || {}
const name = opts.name || 'choo'
const _models = [] const _models = []
var _router = null var _router = null
@@ -25,6 +24,7 @@ function choo (opts) {
return start return start
// render the application to a string // render the application to a string
// (str, obj) -> str
function toString (route, state) { function toString (route, state) {
const initialState = {} const initialState = {}
@@ -40,71 +40,102 @@ function choo (opts) {
} }
// start the application // start the application
// null -> DOMNode // obj -> DOMNode
function start () { function start (opts) {
opts = opts || {}
const name = opts.name || 'choo'
const initialState = {} const initialState = {}
const reducers = {} const reducers = {}
const effects = {} const effects = {}
_models.push(appInit(opts)) _models.push(appInit(opts))
_models.forEach(function (model) { _models.forEach(function (model) {
if (model.state) apply(model.name, model.state, initialState) if (model.state) apply(model.namespace, model.state, initialState)
if (model.reducers) apply(model.name, model.reducers, reducers) if (model.reducers) apply(model.namespace, model.reducers, reducers)
if (model.effects) apply(model.name, model.effects, effects) if (model.effects) apply(model.namespace, model.effects, effects)
}) })
// send() is used to trigger actions inside
// views, effects and subscriptions
const send = sendAction({ const send = sendAction({
onaction: handleAction, onaction: handleAction,
onchange: onchange, onchange: onchange,
state: initialState 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) { _models.forEach(function (model) {
if (model.subscriptions) { if (model.subscriptions) {
assert.ok(Array.isArray(model.subscriptions, 'subs must be an array'))
model.subscriptions.forEach(function (sub) { model.subscriptions.forEach(function (sub) {
sub(send) 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 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) tree.setAttribute('id', rootId)
return tree 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) { function handleAction (action, state, send) {
var _reducers = false var reducersCalled = false
var _effects = false var effectsCalled = false
var newState = null
if (reducers[action.type]) { // validate if a namespace exists. Namespaces
newState = xtend(state, reducers[action.type](action, state)) // are delimited by the first ':'. Perhaps
_reducers = true // we'll allow recursive namespaces in the
// future - who knows
if (/:/.test(action.type)) {
const arr = action.type.split(':')
var ns = arr.shift()
action.type = arr.join(':')
} }
if (effects[action.type]) { const _reducers = ns ? reducers[ns] : reducers
effects[action.type](action, newState || state, send) if (_reducers && _reducers[action.type]) {
newState = newState || state if (ns) {
_effects = true const newState = _reducers[action.type](action, state[ns])
state[ns] = xtend(state[ns], newState)
} else state = xtend(state, reducers[action.type](action, state))
reducersCalled = true
} }
if (!_reducers && !_effects) { const _effects = ns ? effects[ns] : effects
if (_effects && _effects[action.type]) {
if (ns) _effects[action.type](action, state[ns], send)
else _effects[action.type](action, state, send)
effectsCalled = true
}
if (!reducersCalled && !effectsCalled) {
throw new Error('Could not find action ' + action.type) throw new Error('Could not find action ' + action.type)
} }
return newState return state
} }
// update on every change // update the DOM after every state mutation
// (obj, obj) -> null
function onchange (action, state) { function onchange (action, state) {
const oldTree = document.querySelector('#' + rootId) const oldTree = document.querySelector('#' + rootId)
const newTree = _router(state.location, state, send) const newTree = _router(state.app.location, state, send)
newTree.setAttribute('id', rootId) newTree.setAttribute('id', rootId)
yo.update(oldTree, newTree) yo.update(oldTree, newTree)
} }
} }
// register all routes // register all routes on the router
// [obj|fn] -> null // [obj|fn] -> null
function router (cb) { function router (cb) {
_router = sheetRouter(cb) _router = sheetRouter(cb)
@@ -113,9 +144,7 @@ function choo (opts) {
// create a new model // create a new model
// (str?, obj) -> null // (str?, obj) -> null
function model (name, model) { function model (model) {
if (!model) model = name
if (typeof name === 'string') model.name = name
_models.push(model) _models.push(model)
} }
} }
@@ -124,45 +153,47 @@ function choo (opts) {
// obj -> obj // obj -> obj
function appInit (opts) { function appInit (opts) {
const model = { const model = {
state: { namespace: 'app',
location: document.location.href state: { location: document.location.href },
}, subscriptions: [],
reducers: { reducers: {
location: setLocation // handle href links
}, location: function setLocation (action, state) {
subscriptions: [] return {
location: action.location.replace(/#.*/, '')
}
}
}
} }
if (opts.href !== false) { // enable catching <href a=""></href> links
model.subscriptions.push(function (send) { // enable HTML5 history API
href(function (href) { if (opts.history !== false) pushLocationSub(href)
send('location', { location: href }) if (opts.history !== false) pushLocationSub(history)
})
})
}
if (opts.history !== false) {
model.subscriptions.push(function (send) {
history(function (href) {
send('location', { location: href })
})
})
}
return model return model
// handle href links // create a new subscription that modifies
function setLocation (action, state) { // 'app:location' and push it to be loaded
const location = action.location.replace(/#.*/, '') // fn -> null
return xtend(state, { location: location }) function pushLocationSub (cb) {
model.subscriptions.push(function (send) {
cb(function (href) {
send('app:location', { location: href })
})
})
} }
} }
// compose an object conditionally // compose an object conditionally
// optionally contains a namespace
// which is used to nest properties.
// (str, obj, obj) -> null // (str, obj, obj) -> null
function apply (name, source, target) { function apply (ns, source, target) {
Object.keys(source).forEach(function (key) { Object.keys(source).forEach(function (key) {
if (name) target[name + ':' + key] = source[key] if (ns) {
else target[key] = source[key] if (!target[ns]) target[ns] = {}
target[ns][key] = source[key]
} else target[key] = source[key]
}) })
} }
+1 -1
View File
@@ -6,7 +6,7 @@ const http = require('http')
const PORT = 8080 const PORT = 8080
const server = http.createServer(createRouter()) 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 () { function createRouter () {
const router = serverRouter('/404') const router = serverRouter('/404')