├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── karma.conf.js ├── package.json ├── react-router.js ├── src ├── __tests__ │ └── core-test.js ├── core.js ├── index.js └── react-router │ ├── __tests__ │ ├── nestedRoute-test.js │ └── reactRouter-test.js │ ├── getComponents.js │ ├── index.js │ ├── nestedRoute.js │ ├── routeComponents.js │ └── runHooks.js └── tests.webpack.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "stage-0", 5 | "react" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "eslint-config-airbnb", 4 | "env": { 5 | "browser": true, 6 | "mocha": true, 7 | "node": true 8 | }, 9 | "globals": { 10 | "expect": true, 11 | "sinon": true 12 | }, 13 | "rules": { 14 | "semi": [2, "never"], 15 | "comma-dangle": 0, 16 | "no-wrap-func": 0, 17 | "spaced-comment": 0, 18 | "id-length": 0, 19 | 20 | // Doesn't play nice with chai's assertions 21 | "no-unused-expressions": 0, 22 | 23 | "react/prop-types": 0, 24 | "react/sort-comp": 0, 25 | "react/no-multi-comp": 0, 26 | "react/jsx-uses-react": 2, 27 | "react/jsx-uses-vars": 2, 28 | "react/react-in-jsx-scope": 2, 29 | 30 | "no-unused-vars": [2, { 31 | "args": "after-used", 32 | "argsIgnorePattern": "^_$" 33 | }], 34 | 35 | //Temporarily disabled due to a possible bug in babel-eslint (todomvc example) 36 | "block-scoped-var": 0, 37 | // Temporarily disabled for test/* until babel/babel-eslint#33 is resolved 38 | "padded-blocks": 0 39 | }, 40 | "plugins": [ 41 | "react" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | /**/__tests__ 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5.0.0" 4 | script: 5 | - npm run lint 6 | - npm test 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Router 2 | 3 | [![build status](https://img.shields.io/travis/acdlite/router/master.svg?style=flat-square)](https://travis-ci.org/acdlite/router) 4 | [![npm version](https://img.shields.io/npm/v/@acdlite/router.svg?style=flat-square)](https://www.npmjs.com/package/@acdlite/router) 5 | 6 | An experiment in functional routing for JavaScript applications. 7 | 8 | ``` 9 | npm install --save @acdlite/router 10 | ``` 11 | 12 | The name is intentionally generic because it's still in the experimental phase (perhaps forever). 13 | 14 | Key features: 15 | 16 | - A router is defined using composable middleware functions. 17 | - A router is a function that turns a path into a state object. That's it. This allows for total separation of history management from route matching. 18 | - Because history management is separate, the server-side API is identical to the client-side API. 19 | 20 | **[See below for a proof-of-concept that mimics the React Router API.](https://github.com/acdlite/router#proof-of-concept-react-router-like-api)** 21 | 22 | ## Should I use this? 23 | 24 | No. 25 | 26 | Well, maybe. I'm currently using this in a side project, but I wouldn't recommend it for any production apps. 27 | 28 | ## How it works 29 | 30 | A "router" in the context of this project is a function that accepts a path and a callback. The router turns the path into a state object by passing it through a series of middleware. Once the middleware completes, the callback is called (either synchronously or asynchronously) with the final state object, which can be used to render an app. 31 | 32 | History management is considered a separate concern — just pass the router a string. On the client, use a project like [history](https://github.com/rackt/history). On the server, use your favorite web framework like [Express](http://expressjs.com/en/index.html) or [Koa](https://github.com/koajs/koa). 33 | 34 | ```js 35 | const router = createRouter(...middlewares) 36 | 37 | router('/some/path', (error, state) => { 38 | // Render app using state 39 | }) 40 | ``` 41 | 42 | ### Middleware 43 | 44 | A middleware is a function that accepts Node-style callback (we'll call it a listener) and returns a new Node-style callback with augmented behavior. 45 | 46 | ```js 47 | type Listener = (error: Error, state: Object) => void 48 | type Middleware = (next: Listener) => Listener 49 | ``` 50 | 51 | An important feature of middleware is that they are composable: 52 | 53 | ```js 54 | // Middlewares 1, 2, and 3 will run in sequence from left to right 55 | const combinedMiddleware = compose(middleware1, middleware2, middlware3) 56 | ``` 57 | 58 | Router middleware is much like middleware in Redux. It is used to augment a state object as it passes through a router. Here's an example of a middleware that adds a `query` field: 59 | 60 | ```js 61 | import queryString from 'query-string' 62 | 63 | const parseQuery = next => (error, state) => { 64 | if (error) return next(error) 65 | 66 | next(null, { 67 | ...state, 68 | query: queryString.parse(state.search) 69 | }) 70 | } 71 | ``` 72 | 73 | As with React props and Redux state, we treat router state as immutable. 74 | 75 | ### State object conventions 76 | 77 | All state objects should have the fields `path`, `pathname`, `search`, and `hash`. When you pass a path string to a router function, the remaining fields are extracted from the path. The reverse also works: if instead of a path string you pass an initial state object to a router function with `pathname`, `search`, and `hash`, a `path` field is added. This allows middleware to depend on those fields without having to do their own parsing. 78 | 79 | There are two additional fields which have special meanings: `redirect` and `done`. `redirect` is self-explanatory: a middleware should skip any state object with a `redirect` field by passing it to the next middleware. Similarly, a state object with `done: true` indicates that a previous middleware has already handled it, and it needs no further processing by remaining middleware. (There are some circumstances where it may be appropriate for a middleware to process a `done` state object.) 80 | 81 | Handling all these special cases can get tedious. The `handle()` allows you to create a middleware that handles specific cases. It's a bit like a switch statement, or pattern matching. Example 82 | 83 | ```js 84 | import { handle } from '@acdlite/router' 85 | 86 | const middleware = handle({ 87 | // Handle error 88 | error: next => (error, state) => {...} 89 | 90 | // Handle redirect 91 | redirect: next => (error, state) => {...} 92 | 93 | // Handle done 94 | done: next => (error, state) => {...} 95 | 96 | // Handle all other cases 97 | next: next => (error, state) => {...} 98 | }) 99 | ``` 100 | 101 | `next()` is the most common handler. 102 | 103 | If a handler is omitted, the default behavior is to pass the state object through to the next middleware, unchanged. 104 | 105 | ## Proof-of-concept: React Router-like API 106 | 107 | As a proof-of-concept, the `react-router/` directory includes utilities for implementing a React Router-like API using middleware. It supports: 108 | 109 | - Nested route matching, with params 110 | - Plain object routes or JSX routes 111 | - Asynchronous route fetching, using `config.getChildRoutes()` 112 | - Asynchronous component fetching, using `config.getComponent()` 113 | - `` 114 | - `` 115 | 116 | Diverges from React Router: 117 | 118 | - `onEnter()` hooks are just middlewares that run when a route matches. 119 | - `onLeave()` hooks are not supported because they require keeping track of the previous state, which is (currently) outside the scope of this project. 120 | 121 | Internally, it uses several of React Router's methods, so the route matching behavior should be identical. 122 | 123 | Note that if you were really aiming to replace React Router, you would want to create a stateful abstraction on top of these relatively low-level functions. I have intentionally omitted any kind of state management from this project, for maximum flexibility while I continue to experiment. 124 | 125 | Example: 126 | 127 | ```js 128 | import { createRouter } from '@acdlite/router' 129 | import { reactRoutes } from '@acdlite/router/react-router' 130 | import { Route, IndexRoute } from 'react-router' 131 | import createHistory from 'history/lib/createBrowserHistory' 132 | 133 | const reactRouter = createRouter( 134 | reactRoutes( 135 | 136 | 137 | 138 | 139 | 140 | 141 | ), 142 | // ... add additional middleware, if desired 143 | ) 144 | 145 | const history = createHistory() 146 | 147 | // Listen for location updates 148 | history.listen(location => { 149 | // E.g. after navigating to '/post/123' 150 | // Routers can accept either a path string or an object with `pathname`, 151 | // `query`, and `search`, so we can pass the location object directly. 152 | reactRouter(location, { 153 | // Route was successful 154 | done: (error, state) => { 155 | // Returns a state object with info about the matched routes 156 | expect(state).to.eql({ 157 | params: { id: '123' }, 158 | routes: [...] // Array of matching route config objects 159 | components: [App, Post], // Array of matching components 160 | // ...plus other fields from the location object 161 | }) 162 | 163 | // Render your app using state... 164 | }, 165 | 166 | // Handle redirects 167 | redirect: (error, state) => { 168 | history.replace(state.redirect) 169 | }, 170 | 171 | // Handle errors 172 | error: error => { 173 | throw error 174 | } 175 | } 176 | }) 177 | ``` 178 | 179 | A key thing to note is that the server-side API is exactly the same: instead of using history, just pass a path string directly to the router, and implement `done()`, `redirect()` and `error()` as appropriate. 180 | 181 | Also note that there's no interdependency between history and your routing logic. 182 | 183 | The router returns the matched components, but it's up to you to render them how you like. An easy way to start is using Recompose's [`nest()`](https://github.com/acdlite/recompose/blob/master/docs/API.md#nest) function: 184 | 185 | ```js 186 | const Component = nest(...state.components) 187 | ReactDOM.render() 188 | ``` 189 | 190 | That gets you 90% of the way to parity with React Router. Conveniences like the `` component would need to be re-implemented, but are fairly straightforward. 191 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var webpack = require('webpack') 3 | 4 | module.exports = function(config) { 5 | if (process.env.TRAVIS) { 6 | config.set({ 7 | browsers: ['PhantomJS'], 8 | frameworks: ['phantomjs-shim', 'mocha', 'sinon'], 9 | singleRun: true 10 | }) 11 | } else { 12 | config.set({ 13 | browsers: ['Chrome'], 14 | frameworks: ['mocha', 'sinon'] 15 | }) 16 | } 17 | 18 | config.set({ 19 | reporters: ['mocha'], 20 | 21 | files: [ 22 | 'tests.webpack.js' 23 | ], 24 | 25 | preprocessors: { 26 | 'tests.webpack.js': ['webpack', 'sourcemap'], 27 | }, 28 | 29 | webpack: { 30 | devtool: 'inline-source-map', 31 | module: { 32 | loaders: [{ 33 | test: /\.js$/, 34 | exclude: /node_modules/, 35 | loader: 'babel' 36 | }] 37 | }, 38 | resolve: { 39 | alias: { 40 | '@acdlite/router': path.resolve(__dirname, 'src') 41 | } 42 | }, 43 | plugins: [ 44 | new webpack.DefinePlugin({ 45 | 'process.env.NODE_ENV': JSON.stringify('test') 46 | }) 47 | ] 48 | }, 49 | 50 | webpackMiddleware: { 51 | noInfo: true 52 | } 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@acdlite/router", 3 | "version": "0.2.0", 4 | "description": "An experiment in functional routing.", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "prepublish": "npm run test && npm run build", 8 | "build": "babel src --out-dir lib", 9 | "test": "karma start --single-run", 10 | "lint": "eslint src", 11 | "test:watch": "karma start" 12 | }, 13 | "keywords": [ 14 | "routing", 15 | "router", 16 | "middleware", 17 | "react-router", 18 | "history", 19 | "functional" 20 | ], 21 | "author": "Andrew Clark ", 22 | "license": "MIT", 23 | "devDependencies": { 24 | "babel-cli": "^6.2.0", 25 | "babel-core": "^6.1.21", 26 | "babel-eslint": "^4.1.5", 27 | "babel-loader": "^6.2.0", 28 | "babel-polyfill": "^6.3.14", 29 | "babel-preset-es2015": "^6.1.18", 30 | "babel-preset-react": "^6.1.18", 31 | "babel-preset-stage-0": "^6.1.18", 32 | "chai": "^3.4.1", 33 | "eslint": "^1.9.0", 34 | "eslint-config-airbnb": "^1.0.0", 35 | "eslint-plugin-react": "^3.9.0", 36 | "history": "^1.14.0", 37 | "karma": "^0.13.15", 38 | "karma-chrome-launcher": "^0.2.1", 39 | "karma-mocha": "^0.2.1", 40 | "karma-mocha-reporter": "^1.1.1", 41 | "karma-phantomjs-launcher": "^0.2.1", 42 | "karma-phantomjs-shim": "^1.1.2", 43 | "karma-sinon": "^1.0.4", 44 | "karma-sourcemap-loader": "^0.3.6", 45 | "karma-webpack": "^1.7.0", 46 | "mocha": "^2.3.4", 47 | "phantomjs": "^1.9.18", 48 | "react": "^0.14.3", 49 | "react-addons-test-utils": "^0.14.2", 50 | "react-dom": "^0.14.2", 51 | "react-router": "^1.0.2", 52 | "sinon": "^1.17.2", 53 | "webpack": "^1.12.6" 54 | }, 55 | "dependencies": { 56 | "lodash": "^3.10.1", 57 | "query-string": "^3.0.0", 58 | "react-router": "^1.0.0", 59 | "recompose": "^0.11.2" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /react-router.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/react-router') 2 | -------------------------------------------------------------------------------- /src/__tests__/core-test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { 3 | createRouter, 4 | handle, 5 | listen, 6 | ensureMostRecent, 7 | redirect, 8 | done, 9 | mapState 10 | } from '@acdlite/router' 11 | 12 | describe('Core', () => { 13 | describe('createRouter()', () => { 14 | it('creates a router from middleware', _done => { 15 | const middleware = next => (error, state) => next(null, { 16 | ...state, 17 | some: 'field' 18 | }) 19 | const router = createRouter(middleware) 20 | router('/some/path?query#hash', (error, state) => { 21 | expect(state.path).to.equal('/some/path?query#hash') 22 | expect(state.pathname).to.equal('/some/path') 23 | expect(state.hash).to.equal('#hash') 24 | expect(state.some).to.equal('field') 25 | _done() 26 | }) 27 | }) 28 | 29 | it('can accept a state object', _done => { 30 | const middleware = next => (error, state) => next(null, { 31 | ...state, 32 | some: 'field' 33 | }) 34 | const router = createRouter(middleware) 35 | router('/some/path', (error, state) => { 36 | expect(state.path).to.equal('/some/path') 37 | expect(state.some).to.equal('field') 38 | _done() 39 | }) 40 | }) 41 | 42 | it('if given path, adds pathname, search, and hash', _done => { 43 | const router = createRouter(t => t) 44 | router({ path: '/some/path?query#hash' }, (error, state) => { 45 | expect(state.path).to.equal('/some/path?query#hash') 46 | expect(state.pathname).to.equal('/some/path') 47 | expect(state.search).to.equal('?query') 48 | expect(state.hash).to.equal('#hash') 49 | _done() 50 | }) 51 | }) 52 | 53 | it('if given pathname, search, and hash, adds path', _done => { 54 | const router = createRouter(t => t) 55 | router({ 56 | pathname: '/some/path', 57 | search: '?query', 58 | hash: '#hash' 59 | }, (error, state) => { 60 | expect(state.path).to.equal('/some/path?query#hash') 61 | expect(state.pathname).to.equal('/some/path') 62 | expect(state.search).to.equal('?query') 63 | expect(state.hash).to.equal('#hash') 64 | _done() 65 | }) 66 | }) 67 | }) 68 | 69 | describe('handle()', () => { 70 | it('handles errors', () => { 71 | const middleware = handle({ 72 | error: next => (err, state) => 73 | next(null, { 74 | ...state, 75 | foo: 'bar' 76 | }) 77 | }) 78 | 79 | let state 80 | 81 | middleware( 82 | (error, s) => state = s 83 | )(new Error(), { some: 'field' }) 84 | expect(state).to.eql({ some: 'field', foo: 'bar' }) 85 | 86 | middleware( 87 | (error, s) => state = s 88 | )(null, { some: 'field' }) 89 | expect(state).to.eql({ some: 'field' }) 90 | }) 91 | 92 | it('handles redirects', () => { 93 | const middleware = handle({ 94 | redirect: next => (err, state) => { 95 | const { redirect: _redirect, ...rest } = state 96 | next(null, { 97 | ...rest, 98 | foo: 'bar' 99 | }) 100 | } 101 | }) 102 | 103 | let state 104 | 105 | middleware( 106 | (error, s) => state = s 107 | )(null, { redirect: '/new/path', some: 'field' }) 108 | expect(state).to.eql({ some: 'field', foo: 'bar' }) 109 | 110 | middleware( 111 | (error, s) => state = s 112 | )(null, { some: 'field' }) 113 | expect(state).to.eql({ some: 'field' }) 114 | }) 115 | 116 | it('handles completed states', () => { 117 | const middleware = handle({ 118 | done: next => (err, state) => { 119 | const { done: _done, ...rest } = state 120 | next(null, { 121 | ...rest, 122 | foo: 'bar' 123 | }) 124 | } 125 | }) 126 | 127 | let state 128 | 129 | middleware( 130 | (error, s) => state = s 131 | )(null, { done: true, some: 'field' }) 132 | expect(state).to.eql({ some: 'field', foo: 'bar' }) 133 | 134 | middleware( 135 | (error, s) => state = s 136 | )(null, { some: 'field' }) 137 | expect(state).to.eql({ some: 'field' }) 138 | }) 139 | 140 | it('handles incomplete states', () => { 141 | const middleware = handle({ 142 | next: next => (err, state) => 143 | next(null, { 144 | ...state, 145 | foo: 'bar' 146 | }) 147 | }) 148 | 149 | let state 150 | 151 | middleware( 152 | (error, s) => state = s 153 | )(null, { some: 'field' }) 154 | expect(state).to.eql({ some: 'field', foo: 'bar' }) 155 | 156 | middleware( 157 | (error, s) => state = s 158 | )(null, { done: true, some: 'field' }) 159 | expect(state).to.eql({ done: true, some: 'field' }) 160 | }) 161 | }) 162 | 163 | describe('listen()', () => { 164 | it('works like handle(), but for listeners instead of middleware', () => { 165 | let state 166 | const middleware = listen({ 167 | next: (error, s) => state = s 168 | }) 169 | 170 | middleware()(null, { foo: 'bar' }) 171 | expect(state).to.eql({ 172 | foo: 'bar' 173 | }) 174 | }) 175 | 176 | it('does nothing if no listeners match', () => { 177 | let state 178 | const middleware = listen({}) 179 | 180 | middleware()(null, { foo: 'bar' }) 181 | expect(state).to.be.undefined 182 | }) 183 | 184 | it('accepts a single listener as well', () => { 185 | let error 186 | let state 187 | const middleware = listen( 188 | (e, s) => { 189 | error = e 190 | state = s 191 | } 192 | ) 193 | 194 | middleware()(null, { foo: 'bar' }) 195 | expect(state).to.eql({ foo: 'bar' }) 196 | expect(error).to.be.null 197 | 198 | const e = new Error() 199 | middleware()(e, { foo: 'bar' }) 200 | expect(state).to.eql({ foo: 'bar' }) 201 | expect(error).to.equal(e) 202 | }) 203 | }) 204 | 205 | describe('ensureMostRecent()', () => { 206 | it('wraps middleware and continues only if path matches most recent path', _done => { 207 | const spy1 = sinon.spy() 208 | const spy2 = sinon.spy() 209 | 210 | const router = createRouter( 211 | ensureMostRecent( 212 | next => (error, state) => { 213 | if (state.path === '/1') { 214 | return setImmediate(() => next(error, state)) 215 | } 216 | next(error, state) 217 | } 218 | ) 219 | ) 220 | 221 | router('/1', (error, state) => spy1(state)) 222 | router('/2', (error, state) => spy2(state)) 223 | 224 | setTimeout(() => { 225 | expect(spy1.callCount).to.equal(0) 226 | expect(spy2.callCount).to.equal(1) 227 | expect(spy2.args[0][0].path).to.eql('/2') 228 | _done() 229 | }) 230 | }) 231 | }) 232 | 233 | describe('redirect()', () => { 234 | it('marks state as redirect', () => { 235 | let state 236 | const middleware = redirect('/new/path') 237 | 238 | middleware( 239 | (error, s) => state = s 240 | )(null, { path: '/some/path', foo: 'bar' }) 241 | expect(state).to.eql({ 242 | path: '/some/path', 243 | redirect: '/new/path', 244 | foo: 'bar' 245 | }) 246 | }) 247 | }) 248 | 249 | describe('done()', () => { 250 | it('marks state as done', () => { 251 | let state 252 | const middleware = done 253 | 254 | middleware( 255 | (error, s) => state = s 256 | )(null, { path: '/some/path', foo: 'bar' }) 257 | expect(state).to.eql({ 258 | path: '/some/path', 259 | done: true, 260 | foo: 'bar' 261 | }) 262 | }) 263 | }) 264 | 265 | describe('mapState()', () => { 266 | it('creates a middleware that synchronously maps a state object', () => { 267 | let state 268 | const middleware = mapState(s => ({ ...s, extra: 'field' })) 269 | middleware( 270 | (error, s) => state = s 271 | )(null, { path: '/some/path' }) 272 | expect(state).to.eql({ 273 | path: '/some/path', 274 | extra: 'field' 275 | }) 276 | }) 277 | }) 278 | }) 279 | -------------------------------------------------------------------------------- /src/core.js: -------------------------------------------------------------------------------- 1 | import compose from 'lodash/function/compose' 2 | import mapValues from 'lodash/object/mapValues' 3 | import isFunction from 'lodash/lang/isFunction' 4 | import isUndefined from 'lodash/lang/isUndefined' 5 | 6 | const identity = t => t 7 | const noop = () => {} 8 | 9 | export const handle = handlers => next => (error, state) => { 10 | let handler = identity 11 | 12 | if (error) { 13 | if (handlers.error) handler = handlers.error 14 | } else if (state.redirect) { 15 | if (handlers.redirect) handler = handlers.redirect 16 | } else if (state.done) { 17 | if (handlers.done) handler = handlers.done 18 | } else { 19 | if (handlers.next) handler = handlers.next 20 | } 21 | 22 | handler(next)(error, state) 23 | } 24 | 25 | export const listen = listeners => () => 26 | isFunction(listeners) 27 | ? listeners 28 | : handle(mapValues(listeners, listener => () => listener))(noop) 29 | 30 | export const ensureMostRecent = (...middlewares) => { 31 | const middleware = compose(...middlewares) 32 | let mostRecentPath 33 | return next => (error, state) => { 34 | if (error) return next(error, state) 35 | mostRecentPath = state.path 36 | middleware((error2, state2) => { 37 | if (state2.path && state2.path === mostRecentPath) { 38 | return next(error2, state2) 39 | } 40 | })(error, state) 41 | } 42 | } 43 | 44 | const _parsePath = path => { 45 | let pathname = path 46 | let search = '' 47 | let hash = '' 48 | 49 | const hashIndex = pathname.indexOf('#') 50 | if (hashIndex !== -1) { 51 | hash = pathname.substring(hashIndex) 52 | pathname = pathname.substring(0, hashIndex) 53 | } 54 | 55 | const searchIndex = pathname.indexOf('?') 56 | if (searchIndex !== -1) { 57 | search = pathname.substring(searchIndex) 58 | pathname = pathname.substring(0, searchIndex) 59 | } 60 | 61 | if (pathname === '') { 62 | pathname = '/' 63 | } 64 | 65 | return { pathname, search, hash } 66 | } 67 | 68 | export const parsePath = handle({ 69 | next: next => (error, state) => { 70 | if (state.path) { 71 | return next(null, { 72 | ...state, 73 | ..._parsePath(state.path) 74 | }) 75 | } 76 | 77 | if ( 78 | !isUndefined(state.pathname) && 79 | !isUndefined(state.search) && 80 | !isUndefined(state.hash) 81 | ) { 82 | return next(null, { 83 | ...state, 84 | path: state.pathname + state.search + state.hash 85 | }) 86 | } 87 | 88 | return next(new Error( 89 | 'State object must have either `path` or `pathname`, `search`, ' + 90 | 'and `hash`' 91 | )) 92 | } 93 | }) 94 | 95 | export const createRouter = (...middlewares) => (path, listeners = noop) => { 96 | const initialState = typeof path === 'string' ? { path } : path 97 | return compose( 98 | parsePath, 99 | ...middlewares, 100 | listen(listeners) 101 | )(noop)(null, initialState) 102 | } 103 | 104 | const redirectMiddleware = path => 105 | next => (error, state) => next(null, { ...state, redirect: path }) 106 | 107 | export const redirect = path => handle({ 108 | done: redirectMiddleware(path), 109 | next: redirectMiddleware(path) 110 | }) 111 | 112 | export const done = handle({ 113 | next: next => (error, state) => next(null, { ...state, done: true }) 114 | }) 115 | 116 | export const mapState = func => handle({ 117 | next: next => (error, state) => next(null, func(state)) 118 | }) 119 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | createRouter, 3 | handle, 4 | listen, 5 | ensureMostRecent, 6 | parsePath, 7 | redirect, 8 | done, 9 | mapState 10 | } from './core' 11 | -------------------------------------------------------------------------------- /src/react-router/__tests__/nestedRoute-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { expect } from 'chai' 3 | import { createRouter } from '@acdlite/router' 4 | import { nestedRoute, Route, IndexRoute } from '@acdlite/router/react-router' 5 | 6 | describe('nestedRoute()', () => { 7 | it('matches routes like React Router', done => { 8 | const router = createRouter( 9 | nestedRoute({ 10 | id: 1, 11 | path: '/', 12 | childRoutes: [{ 13 | id: 2, 14 | path: 'post', 15 | childRoutes: [{ 16 | id: 3, 17 | path: ':id' 18 | }] 19 | }] 20 | }) 21 | ) 22 | 23 | router('/post/123', { 24 | done: (error, state) => { 25 | expect(state.routes.map(r => r.id)).to.eql([1, 2, 3]) 26 | expect(state.params).to.eql({ id: '123' }) 27 | done() 28 | } 29 | }) 30 | }) 31 | 32 | it('accepts JSX routes', done => { 33 | const router = createRouter( 34 | nestedRoute( 35 | 36 | 37 | 38 | 39 | 40 | ) 41 | ) 42 | 43 | router('/post/123', { 44 | done: (error, state) => { 45 | expect(state.routes.map(r => r.id)).to.eql([1, 2, 3]) 46 | expect(state.params).to.eql({ id: '123' }) 47 | done() 48 | } 49 | }) 50 | }) 51 | 52 | it('handles partial matches', done => { 53 | const router = createRouter( 54 | nestedRoute( 55 | 56 | 57 | 58 | 59 | ) 60 | ) 61 | 62 | router('/post/123', { 63 | done: (error, state) => { 64 | expect(state.routes.map(r => r.id)).to.eql([1, 3]) 65 | expect(state.params).to.eql({ id: '123' }) 66 | done() 67 | } 68 | }) 69 | }) 70 | 71 | it('matches from root if path begins with forward slash', done => { 72 | const router = createRouter( 73 | nestedRoute( 74 | 75 | 76 | 77 | 78 | 79 | ) 80 | ) 81 | 82 | router('/post/123', { 83 | done: (error, state) => { 84 | expect(state.routes.map(r => r.id)).to.eql([1, 2, 3]) 85 | expect(state.params).to.eql({ id: '123' }) 86 | done() 87 | } 88 | }) 89 | }) 90 | 91 | it('gets child routes asynchronously', done => { 92 | const router = createRouter( 93 | nestedRoute( 94 | 95 | cb(null, [ 96 | 97 | ])}/> 98 | 99 | ) 100 | ) 101 | 102 | router('/post/123', { 103 | done: (error, state) => { 104 | expect(state.routes.map(r => r.id)).to.eql([1, 2, 3]) 105 | expect(state.params).to.eql({ id: '123' }) 106 | done() 107 | } 108 | }) 109 | }) 110 | 111 | it('partially matches routes with no path', done => { 112 | const router = createRouter( 113 | nestedRoute( 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | ) 122 | ) 123 | 124 | router('/post/123', { 125 | done: (error, state) => { 126 | expect(state.routes.map(r => r.id)).to.eql([1, 2, 3, 4]) 127 | expect(state.params).to.eql({ id: '123' }) 128 | done() 129 | } 130 | }) 131 | }) 132 | 133 | it('works with index routes', done => { 134 | const router = createRouter( 135 | nestedRoute( 136 | 137 | 138 | 139 | 140 | 141 | ) 142 | ) 143 | 144 | router('/post/123', { 145 | done: (error, state) => { 146 | expect(state.routes.map(r => r.id)).to.eql([1, 2, 3]) 147 | expect(state.params).to.eql({ id: '123' }) 148 | done() 149 | } 150 | }) 151 | }) 152 | }) 153 | -------------------------------------------------------------------------------- /src/react-router/__tests__/reactRouter-test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import React from 'react' 3 | import createMemoryHistory from 'history/lib/createMemoryHistory' 4 | import useQueries from 'history/lib/useQueries' 5 | import { createRouter, redirect } from '@acdlite/router' 6 | import { reactRoutes, Route, Redirect } from '@acdlite/router/react-router' 7 | 8 | const createHistory = useQueries(createMemoryHistory) 9 | 10 | const delay = (time = 0) => new Promise(res => setTimeout(res, time)) 11 | 12 | describe('Mimic React Router API', () => { 13 | it('works', async () => { 14 | const history = createHistory() 15 | 16 | const App = () => {} 17 | const PostIndex = () => {} 18 | const Post = () => {} 19 | 20 | const routeConfig = { 21 | path: '/', 22 | component: App, 23 | childRoutes: [{ 24 | path: 'post', 25 | indexRoute: { 26 | component: PostIndex 27 | }, 28 | childRoutes: [{ 29 | path: ':id', 30 | getComponent: (state, callback) => 31 | setImmediate(() => callback(null, Post)) 32 | }] 33 | }] 34 | } 35 | 36 | const router = 37 | createRouter( 38 | reactRoutes(routeConfig) 39 | ) 40 | 41 | let state 42 | history.listen(location => { 43 | router(location.pathname + location.search + location.hash, { 44 | done: (error, s) => { 45 | state = s 46 | } 47 | }) 48 | }) 49 | 50 | 51 | history.push('/post/123?foo=bar') 52 | await delay() // One of the components was fetched async 53 | expect(state.components).to.eql([App, Post]) 54 | 55 | history.push('/post') 56 | expect(state.components).to.eql([App, PostIndex]) 57 | }) 58 | 59 | it('attaches router object to components', done => { 60 | const A = () => {} 61 | const B = () => {} 62 | const C = () => {} 63 | 64 | const router = createRouter( 65 | reactRoutes( 66 | 67 | 68 | 69 | 70 | 71 | ) 72 | ) 73 | 74 | router('/post/123', { 75 | done: (error, state) => { 76 | expect(state.components.map(c => c.route.id)).to.eql([1, 2, 3]) 77 | done() 78 | } 79 | }) 80 | }) 81 | 82 | it('runs enter hooks', done => { 83 | const onEnter = redirect('/new/path') 84 | const router = createRouter( 85 | reactRoutes( 86 | 87 | 88 | 89 | 90 | 91 | ) 92 | ) 93 | 94 | router('/post/123', { 95 | redirect: (error, state) => { 96 | expect(state.redirect).to.equal('/new/path') 97 | done() 98 | } 99 | }) 100 | }) 101 | 102 | it('works with redirects', done => { 103 | const router = createRouter( 104 | reactRoutes( 105 | 106 | 107 | 108 | 109 | 110 | ) 111 | ) 112 | 113 | router('/post/123', { 114 | redirect: (error, state) => { 115 | expect(state.redirect).to.equal('/post/foo/123/bar') 116 | done() 117 | } 118 | }) 119 | }) 120 | }) 121 | -------------------------------------------------------------------------------- /src/react-router/getComponents.js: -------------------------------------------------------------------------------- 1 | import { handle } from '../core' 2 | 3 | // Private value that represents the lack of a user-specified component 4 | // for a route. Different than null, because a user may specify null. 5 | const SKIP = {} 6 | 7 | const getComponents = handle({ 8 | done: next => (error, state) => { 9 | if (error) return next(error) 10 | 11 | const { routes } = state 12 | 13 | if (!routes) return next(error, state) 14 | 15 | const result = [] 16 | 17 | // Keep count of components yet to be received 18 | let remaining = routes.length 19 | 20 | // Use to prevent calling next multiple times, which could happen if 21 | // multiple `route.getComponent()` calls result in errors 22 | let didCallNext = false 23 | 24 | const receiveError = err => { 25 | if (didCallNext) return 26 | 27 | didCallNext = true 28 | next(err) 29 | } 30 | 31 | const receiveComponent = (component, index) => { 32 | if (!component) { 33 | return receiveError(new Error( 34 | `Route ${routes[index].name} returned a component that is null or ` + 35 | 'undefined.') 36 | ) 37 | } 38 | 39 | component.route = routes[index] 40 | result[index] = component 41 | if (--remaining === 0) { 42 | next(null, { 43 | ...state, 44 | // Skip routes that don't have `component` or `getComponent()` 45 | components: result.filter(c => c !== SKIP) 46 | }) 47 | } 48 | } 49 | 50 | routes.forEach((route, i) => { 51 | if (route.component) { 52 | return receiveComponent(route.component, i) 53 | } 54 | 55 | if (route.getComponent) { 56 | return route.getComponent(state, (err, component) => { 57 | if (err) return receiveError(err) 58 | receiveComponent(component, i) 59 | }) 60 | } 61 | 62 | receiveComponent(SKIP) 63 | }) 64 | } 65 | }) 66 | 67 | export default getComponents 68 | -------------------------------------------------------------------------------- /src/react-router/index.js: -------------------------------------------------------------------------------- 1 | import compose from 'lodash/function/compose' 2 | import getComponents from './getComponents' 3 | import nestedRoute from './nestedRoute' 4 | import { runHooks, runEnterHooks } from './runHooks' 5 | 6 | export { Route, IndexRoute, Redirect } from './routeComponents' 7 | 8 | export { 9 | getComponents, 10 | nestedRoute, 11 | runHooks, 12 | runEnterHooks 13 | } 14 | 15 | export const reactRoutes = (...configs) => compose( 16 | nestedRoute(...configs), 17 | runEnterHooks, 18 | getComponents 19 | ) 20 | -------------------------------------------------------------------------------- /src/react-router/nestedRoute.js: -------------------------------------------------------------------------------- 1 | import compose from 'lodash/function/compose' 2 | import flatten from 'lodash/array/flatten' 3 | import * as PatternUtils from 'react-router/lib/PatternUtils' 4 | import { createRoutes } from 'react-router/lib/RouteUtils' 5 | import { handle, done, redirect } from '../core' 6 | 7 | /** 8 | * Build a params object given arrays of names and values. 9 | * Adapted from React Router's private `assignParams()`. 10 | * https://github.com/rackt/react-router/blob/master/modules/matchRoutes.js#L47 11 | * @private 12 | */ 13 | const buildParams = (paramNames, paramValues) => 14 | paramNames.reduce((result, paramName, index) => { 15 | const paramValue = paramValues && paramValues[index] 16 | 17 | if (Array.isArray(result[paramName])) { 18 | result[paramName].push(paramValue) 19 | } else if (paramName in result) { 20 | result[paramName] = [ result[paramName], paramValue ] 21 | } else { 22 | result[paramName] = paramValue 23 | } 24 | 25 | return result 26 | }, {}) 27 | 28 | const runChildRoutes = config => handle({ 29 | next: next => (error, state) => { 30 | if (config.childRoutes) { 31 | /* eslint-disable */ 32 | return nestedRoute(...config.childRoutes)(next)(null, state) 33 | /* eslint-enable */ 34 | } else if (config.getChildRoutes) { 35 | config.getChildRoutes(state, (e, childRoutes) => { 36 | if (e) return next(e, state) 37 | /* eslint-disable */ 38 | return compose(nestedRoute(...childRoutes))(next)(null, state) 39 | /* eslint-enable */ 40 | }) 41 | } 42 | 43 | return next(error, state) 44 | } 45 | }) 46 | 47 | const getRoutePattern = (routes, routeIndex) => { 48 | let parentPattern = '' 49 | 50 | for (let i = routeIndex; i >= 0; i--) { 51 | const route = routes[i] 52 | const pattern = route.path || '' 53 | 54 | parentPattern = pattern.replace(/\/*$/, '/') + parentPattern 55 | 56 | if (pattern.indexOf('/') === 0) break 57 | } 58 | 59 | return '/' + parentPattern 60 | } 61 | 62 | const exitBranch = config => handle({ 63 | next: next => (error, state) => { 64 | const { stack, didFullyMatch, ...rest } = state 65 | 66 | // If there was no full match, clean up state and continue 67 | if (!didFullyMatch) { 68 | const newState = { ...rest } 69 | 70 | if (stack.length > 1) { 71 | newState.stack = stack.slice(0) 72 | // Pop last item off the stack 73 | newState.stack.pop() 74 | } 75 | 76 | return next(error, newState) 77 | } 78 | 79 | const routes = stack.map(s => s.route) 80 | const params = buildParams( 81 | flatten(stack.map(s => s.paramNames)), 82 | flatten(stack.map(s => s.paramValues)) 83 | ) 84 | 85 | if (config.redirect) { 86 | const redirectTo = config.redirect 87 | let pathname 88 | if (redirectTo.charAt(0) === '/') { 89 | pathname = PatternUtils.formatPattern(redirectTo, params) 90 | } else if (!redirectTo) { 91 | pathname = state.pathname 92 | } else { 93 | const routeIndex = routes.indexOf(config) 94 | const parentPattern = getRoutePattern(routes, routeIndex - 1) 95 | const pattern = parentPattern.replace(/\/*$/, '/') + redirectTo 96 | pathname = PatternUtils.formatPattern(pattern, params) 97 | } 98 | 99 | return redirect(pathname)(next)(error, state) 100 | } 101 | 102 | const newState = { 103 | ...rest, 104 | routes, 105 | params 106 | } 107 | 108 | // Mark state as done and continue 109 | return done(next)(null, newState) 110 | } 111 | }) 112 | 113 | const nestedRoute = (..._configs) => { 114 | // Convert JSX routes to object routes (or do nothing if already an object) 115 | const configs = createRoutes(_configs) 116 | 117 | const routeMiddlewares = configs.map(config => handle({ 118 | next: next => (error, state) => { 119 | // Stack of parent routes, which partially (but not fully) matched the path 120 | // If this is the top-most route, create an empty array 121 | const stack = state.stack || [] 122 | 123 | // The route's path will be matched against either the full path or the 124 | // remaining pathname as determined by the parent route. Use the full 125 | // path if there is no parent route, or if the path begins with a slash 126 | const isRootPath = (config.path && config.path.charAt(0) === '/') 127 | const pathnameToMatch = (!stack.length || isRootPath) 128 | ? state.pathname 129 | : stack[stack.length - 1].remainingPathname 130 | 131 | const { remainingPathname, paramNames, paramValues } = config.path 132 | ? PatternUtils.matchPattern(config.path, pathnameToMatch) 133 | // Treat a route without a path as a partial match 134 | : { 135 | remainingPathname: pathnameToMatch, 136 | paramNames: [], 137 | paramValues: [] 138 | } 139 | 140 | // If pattern does not match, continue 141 | if (!paramValues) return next(null, state) 142 | 143 | // If there is no remaining pathname, this route fully matched 144 | const didFullyMatch = remainingPathname === '' 145 | 146 | // Pattern matched at least partially, so create new state object 147 | const newState = { 148 | ...state, 149 | stack: [...stack, { 150 | remainingPathname, 151 | // Param names and values will be zipped together at the end 152 | paramNames, 153 | paramValues, 154 | // Store route config on stack 155 | route: config 156 | }], 157 | didFullyMatch 158 | } 159 | 160 | if (didFullyMatch && config.indexRoute) { 161 | newState.stack.push({ 162 | remainingPathname: '', 163 | paramNames: [], 164 | paramValues: [], 165 | route: config.indexRoute 166 | }) 167 | } 168 | 169 | // Array of middlewares to run before continuing 170 | const middlewares = [runChildRoutes(config)] 171 | 172 | // Clean up state before exiting route branch 173 | middlewares.push(exitBranch(config)) 174 | 175 | return compose(...middlewares)(next)(null, newState) 176 | } 177 | })) 178 | 179 | return compose(...routeMiddlewares) 180 | } 181 | 182 | export default nestedRoute 183 | -------------------------------------------------------------------------------- /src/react-router/routeComponents.js: -------------------------------------------------------------------------------- 1 | const throwRenderError = () => { 2 | throw new Error( 3 | ' elements are for router configuration only and should not ' + 4 | 'be rendered' 5 | ) 6 | } 7 | 8 | export { Route, IndexRoute } from 'react-router' 9 | import { createRouteFromReactElement } from 'react-router/lib/RouteUtils' 10 | 11 | export const Redirect = () => throwRenderError() 12 | 13 | Redirect.createRouteFromReactElement = element => { 14 | const route = createRouteFromReactElement(element) 15 | route.path = route.from 16 | route.redirect = route.to 17 | return route 18 | } 19 | -------------------------------------------------------------------------------- /src/react-router/runHooks.js: -------------------------------------------------------------------------------- 1 | import compose from 'lodash/function/compose' 2 | import { handle } from '../core' 3 | 4 | export const runHooks = hookName => handle({ 5 | done: next => (error, state) => { 6 | if (!state.routes) return next(null, state) 7 | const hooks = state.routes.map(r => r[hookName]).filter(Boolean) 8 | return compose(...hooks)(next)(error, state) 9 | } 10 | }) 11 | 12 | export const runEnterHooks = runHooks('onEnter') 13 | -------------------------------------------------------------------------------- /tests.webpack.js: -------------------------------------------------------------------------------- 1 | require('babel-polyfill') 2 | var context = require.context('./src', true, /-test\.js$/) 3 | context.keys().forEach(context) 4 | --------------------------------------------------------------------------------