├── .npmrc
├── .npmignore
├── .travis.yml
├── .gitignore
├── jsdom.conf.js
├── logo.svg
├── CONTRIBUTING.md
├── lib
├── wrap-middleware.js
├── url.js
├── __tests__
│ ├── arguments.test.js
│ ├── client.test.js
│ ├── server.test.js
│ └── links.test.js
├── router.js
└── history.js
├── LICENSE-MIT
├── CODE_OF_CONDUCT.md
├── package.json
├── karma.conf.js
├── CHANGELOG.md
└── README.md
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .babelrc
2 | .travis.yml
3 | CODE_OF_CONDUCT.md
4 | CONTRIBUTING.md
5 | coverage
6 | karma.conf.js
7 | logo.svg
8 | __tests__
9 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | dist: trusty
2 | sudo: required
3 | language: node_js
4 | node_js:
5 | - lts/*
6 | - stable
7 | before_script:
8 | - export DISPLAY=:99.0
9 | - sh -e /etc/init.d/xvfb start
10 | - sleep 3
11 | addons:
12 | firefox: latest
13 | apt:
14 | sources:
15 | - google-chrome
16 | packages:
17 | - google-chrome-stable
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # ignore system files
2 |
3 | # OS X
4 | .DS_Store
5 | .Spotlight-V100
6 | .Trashes
7 | ._*
8 |
9 | # Win
10 | Thumbs.db
11 | Desktop.ini
12 |
13 | # vim
14 | *~
15 | .swp
16 | .*.sw[a-z]
17 | Session.vim
18 |
19 | # Node
20 | node_modules
21 | npm-debug.log
22 | package-lock.json
23 |
24 | # generated
25 | browser.js
26 | browser.js.map
27 | coverage
28 |
--------------------------------------------------------------------------------
/jsdom.conf.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | const { JSDOM } = require('jsdom')
3 |
4 | const html = `
5 |
6 |
7 |
8 | `
9 |
10 | const { window } = new JSDOM(html, { url: 'http://example.com/' })
11 | for (const key of Object.keys(window)) {
12 | if (!(key in global)) global[key] = window[key]
13 | }
14 | global.window = window
15 |
--------------------------------------------------------------------------------
/logo.svg:
--------------------------------------------------------------------------------
1 |
18 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Contributions are always welcome, no matter how large or small. Before
4 | contributing, please read the
5 | [code of conduct](https://github.com/thetalecrafter/middle-router/blob/master/CODE_OF_CONDUCT.md).
6 |
7 | ## Developing
8 |
9 | #### Workflow
10 |
11 | * Fork the repository
12 | * Clone your fork and change directory to it (`git clone git@github.com:yourUserName/middle-router.git && cd middle-router`)
13 | * Install the project dependencies (`npm install`)
14 | * Link your forked clone (`npm link`)
15 | * Develop your changes ensuring you're fetching updates from upstream often
16 | * Ensure the test are passing (`npm test`)
17 | * Create new pull request explaining your proposed change or reference an issue
18 | in your commit message
19 |
20 | #### Code Standards
21 |
22 | * Use ES5 syntax.
23 | * Follow [JavaScript Standard Style](https://github.com/feross/standard).
24 |
25 | ## Testing
26 |
27 | $ npm test
28 |
29 | ## Linting
30 |
31 | $ npm run lint
32 |
--------------------------------------------------------------------------------
/lib/wrap-middleware.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | var pathToRegexp = require('path-to-regexp')
4 |
5 | function clone (object) {
6 | var copy = {}
7 | var keys = Object.keys(object)
8 | for (var i = 0, ii = keys.length; i < ii; ++i) {
9 | copy[keys[i]] = object[keys[i]]
10 | }
11 | return copy
12 | }
13 |
14 | module.exports = function wrapMiddleware (matchPath, isRouter, middleware) {
15 | if (!matchPath || matchPath === '*') return middleware
16 |
17 | if (isRouter) matchPath = matchPath.replace(/\/+$/, '') + '/(.*)?'
18 | var keys = []
19 | var regex = pathToRegexp(matchPath, keys)
20 |
21 | return function (step) {
22 | var matches = regex.exec(decodeURIComponent(step.path))
23 | if (!matches) return
24 |
25 | if (isRouter) {
26 | step.path = '/' + (matches[matches.length - 1] || '').replace(/^\/+/, '')
27 | }
28 |
29 | var params = step.params = clone(step.params)
30 | for (var i = 1, ii = matches.length; i < ii; ++i) {
31 | var key = keys[i - 1]
32 | if (key) params[key.name] = matches[i]
33 | }
34 |
35 | return middleware(step)
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/LICENSE-MIT:
--------------------------------------------------------------------------------
1 | Copyright (c) 2015 Andy VanWagoner
2 |
3 | Permission is hereby granted, free of charge, to any person
4 | obtaining a copy of this software and associated documentation
5 | files (the "Software"), to deal in the Software without
6 | restriction, including without limitation the rights to use,
7 | copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | copies of the Software, and to permit persons to whom the
9 | Software is furnished to do so, subject to the following
10 | conditions:
11 |
12 | The above copyright notice and this permission notice shall be
13 | included in all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22 | OTHER DEALINGS IN THE SOFTWARE.
23 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Code of Conduct
2 |
3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities.
4 |
5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion.
6 |
7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct.
8 |
9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team.
10 |
11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers.
12 |
13 | This Code of Conduct is adapted from the [Contributor Covenant](http:contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/)
14 |
--------------------------------------------------------------------------------
/lib/url.js:
--------------------------------------------------------------------------------
1 | /* global document */
2 | 'use strict'
3 |
4 | var doc = typeof document === 'undefined' ? null : document
5 | var anchor
6 |
7 | exports.parse = function parse (url, shouldParseQs) {
8 | var location = doc.location
9 |
10 | if (url) {
11 | location = anchor || (anchor = doc.createElement('a'))
12 | location.href = url
13 | // help IE do the right thing
14 | location.href = location.href // eslint-disable-line
15 | }
16 |
17 | var pathname = location.pathname
18 | if (pathname.charAt(0) !== '/') pathname = '/' + pathname
19 |
20 | var query = parseQS(shouldParseQs && location.search)
21 |
22 | return {
23 | href: location.href,
24 | protocol: location.protocol,
25 | host: location.host,
26 | hostname: location.hostname,
27 | port: location.port,
28 | pathname: pathname,
29 | search: location.search,
30 | hash: location.hash,
31 | query: query
32 | }
33 | }
34 |
35 | function parseQS (query) {
36 | var parsed = {}
37 | if (!query) return parsed
38 | var parts = query.slice(1).split('&')
39 | for (var i = 0, length = parts.length; i < length; ++i) {
40 | var kvpair = parts[i].split('=')
41 | var key = decodeURIComponent(kvpair[0])
42 | var value = decodeURIComponent(kvpair.slice(1).join('='))
43 | if (parsed[key] == null) {
44 | parsed[key] = value
45 | } else if (Array.isArray(parsed[key])) {
46 | parsed[key].push(value)
47 | } else {
48 | parsed[key] = [ parsed[key], value ]
49 | }
50 | }
51 | return parsed
52 | }
53 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "middle-router",
3 | "version": "2.2.0",
4 | "description": "Route urls on both client and server through middleware",
5 | "keywords": [
6 | "router",
7 | "url",
8 | "middleware",
9 | "universal",
10 | "isomorphic"
11 | ],
12 | "main": "lib/router.js",
13 | "dependencies": {
14 | "eventemitter3": "^4.0.0",
15 | "middle-run": "^2.0.0",
16 | "path-to-regexp": "^3.0.0"
17 | },
18 | "devDependencies": {
19 | "browserify": "^16.0.0",
20 | "jsdom": "^15.0.0",
21 | "karma": "^4.0.0",
22 | "karma-browserify": "^6.0.0",
23 | "karma-chrome-launcher": "^3.0.0",
24 | "karma-firefox-launcher": "^1.0.0",
25 | "karma-mocha": "^1.2.0",
26 | "karma-safari-launcher": "^1.0.0",
27 | "karma-spec-reporter": "^0.0.32",
28 | "mocha": "^6.0.0",
29 | "standard": "^12.0.0",
30 | "synthetic-dom-events": "^0.3.0"
31 | },
32 | "scripts": {
33 | "lint": "standard lib/**/*.js",
34 | "test": "npm run lint && npm run test-server && npm run test-client && npm run test-jsdom",
35 | "test-server": "mocha lib/__tests__/server.test.js",
36 | "test-client": "karma start",
37 | "test-jsdom": "mocha -r jsdom.conf.js lib/__tests__/client.test.js"
38 | },
39 | "repository": {
40 | "type": "git",
41 | "url": "http://github.com/vanwagonet/middle-router.git"
42 | },
43 | "author": {
44 | "name": "Andy VanWagoner",
45 | "email": "andy@vanwago.net"
46 | },
47 | "license": "MIT",
48 | "bugs": {
49 | "url": "https://github.com/vanwagonet/middle-router/issues"
50 | },
51 | "homepage": "https://github.com/vanwagonet/middle-router"
52 | }
53 |
--------------------------------------------------------------------------------
/karma.conf.js:
--------------------------------------------------------------------------------
1 | // Karma configuration
2 | // Generated on Mon Oct 26 2015 15:08:26 GMT-0600 (MDT)
3 |
4 | module.exports = function (config) {
5 | config.set({
6 |
7 | // base path that will be used to resolve all patterns (eg. files, exclude)
8 | basePath: '',
9 |
10 | // frameworks to use
11 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
12 | frameworks: [ 'browserify', 'mocha' ],
13 |
14 | // list of files / patterns to load in the browser
15 | files: [
16 | 'lib/__tests__/client.test.js'
17 | ],
18 |
19 | // list of files to exclude
20 | exclude: [],
21 |
22 | // preprocess matching files before serving them to the browser
23 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
24 | preprocessors: {
25 | 'lib/__tests__/**/*.js': [ 'browserify' ]
26 | },
27 |
28 | browserify: {
29 | debug: true
30 | },
31 |
32 | // test results reporter to use
33 | // possible values: 'dots', 'progress'
34 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter
35 | reporters: [ 'spec' ],
36 |
37 | // web server port
38 | port: 9876,
39 |
40 | // enable / disable colors in the output (reporters and logs)
41 | colors: true,
42 |
43 | // level of logging
44 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
45 | logLevel: config.LOG_WARN,
46 |
47 | // enable / disable watching file and executing tests whenever any file changes
48 | autoWatch: false,
49 |
50 | customLaunchers: {
51 | ChromeHeadless: {
52 | base: 'Chrome',
53 | flags: [ '--headless', '--disable-gpu', '--remote-debugging-port=9222' ]
54 | }
55 | },
56 |
57 | // start these browsers
58 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
59 | browsers: process.env.CONTINUOUS_INTEGRATION
60 | ? [ 'ChromeHeadless', 'Firefox' ]
61 | : [ 'Chrome', 'Firefox', 'Safari' ],
62 |
63 | captureTimeout: 120000,
64 | browserNoActivityTimeout: 120000,
65 |
66 | // Continuous Integration mode
67 | // if true, Karma captures browsers, runs the tests and exits
68 | singleRun: true,
69 |
70 | // Concurrency level
71 | // how many browser should be started simultanous
72 | concurrency: 5
73 | })
74 | }
75 |
--------------------------------------------------------------------------------
/lib/__tests__/arguments.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env mocha */
2 | const assert = require('assert')
3 | const Router = require('../router')
4 |
5 | describe('Route middleware arguments', () => {
6 | it('has location with a parsed url', async () => {
7 | let router = Router()
8 | .use(({ location, resolve }) => {
9 | assert.strictEqual(location.href, 'https://test.example:886/path?search=query&a=0&a=1&a=2#hash', 'href must contain the whole url')
10 | assert.strictEqual(location.protocol, 'https:', 'location must have correct protocol')
11 | assert.strictEqual(location.host, 'test.example:886', 'location must have correct host')
12 | assert.strictEqual(location.hostname, 'test.example', 'location must have correct hostname')
13 | assert.strictEqual(location.port, '886', 'location must have correct port')
14 | assert.strictEqual(location.pathname, '/path', 'location must have correct pathname')
15 | assert.strictEqual(location.search, '?search=query&a=0&a=1&a=2', 'location must have correct search')
16 | assert.strictEqual(location.query.search, 'query', 'location must have correct query parsed from search')
17 | assert.deepStrictEqual(location.query.a, [ '0', '1', '2' ], 'multiple values with the same name make an array')
18 | assert.strictEqual(location.hash, '#hash', 'location must have correct hash')
19 | resolve()
20 | })
21 | await router.route('https://test.example:886/path?search=query&a=0&a=1&a=2#hash')
22 | })
23 |
24 | it('has path with the pathname', async () => {
25 | let router = Router()
26 | .use('/hash', () => {
27 | assert.fail('must not match from hash when pathname routing')
28 | })
29 | .use(({ path }) => {
30 | assert.strictEqual(path, '/path/route', 'path must be from pathname')
31 | })
32 | .use('/path', Router().use(({ path, location, resolve }) => {
33 | assert.strictEqual(path, '/route', 'path is relative to mount point')
34 | assert.strictEqual(location.pathname, '/path/route', 'pathname is unchanged by mounting')
35 | resolve()
36 | }))
37 | await router.route('/path/route#/hash')
38 | })
39 |
40 | it('has no exiting promise when not listening', async () => {
41 | let router = Router()
42 | .use(({ exiting, resolve }) => {
43 | assert.strictEqual(exiting, undefined, 'exiting must not be defined when not listening')
44 | resolve()
45 | })
46 | await router.route('/')
47 | })
48 |
49 | it('has router pointing to the top-level router', async () => {
50 | let topRouter = Router()
51 | .use(Router().use(({ router, resolve }) => {
52 | assert.strictEqual(router, topRouter, 'router must be the top-level router')
53 | resolve()
54 | }))
55 | await topRouter.route('/')
56 | })
57 |
58 | it('has a beforeExit function', async () => {
59 | let router = Router()
60 | .use(({ beforeExit, resolve }) => {
61 | assert.strictEqual(typeof beforeExit, 'function', 'beforeExit must be a function')
62 | resolve()
63 | })
64 | await router.route('/')
65 | })
66 |
67 | it('has a context object', async () => {
68 | let router = Router()
69 | .use(({ context, resolve }) => {
70 | assert.strictEqual(typeof context, 'object', 'context must be an object by default')
71 | resolve()
72 | })
73 | await router.route('/')
74 | })
75 |
76 | it('uses context passed to Router', async () => {
77 | let example = { a: 'b' }
78 | let router = Router({ context: example })
79 | .use(({ context, resolve }) => {
80 | assert.strictEqual(context, example, 'context must be the object passed into Router')
81 | resolve()
82 | })
83 | await router.route('/')
84 | })
85 | })
86 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 2.2.0
4 |
5 | * **New Feature**
6 | * Added `context` option to `Router` to use in middleware instead of always starting with `{}`
7 | * **Polish**
8 | * Removed console.error when nothing matches
9 | * **Bug Fix**
10 | * npmignore the .babelrc file used internally for tests
11 |
12 | ## 2.1.1
13 |
14 | * **Bug Fix**
15 | * Make `lazy` always match just like routers
16 |
17 | ## 2.1.0
18 |
19 | * **New Feature**
20 | * Added `lazy` for defining a route handler to be loaded on demand
21 |
22 | ## 2.0.2
23 |
24 | * **Bug Fix**
25 | * Fixed bad routing for `download`, `target`, and `rel` containing links.
26 |
27 | ## 2.0.1
28 |
29 | * **Bug Fix**
30 | * Fixed
tag being detected as a link when using routeLinks
31 |
32 | ## 2.0.0
33 |
34 | * **New Feature**
35 | * Added `back` and `forward` methods for easy history manipulation
36 | * Added `beforeroute` event for manipulating the middleware arguments, so `route` event handlers can can assume the arguments are complete
37 | * Added `beforeExit` for registering handlers that can prevent navigation
38 | * **Breaking Change**
39 | * Moved `routeLinks` option from `start` method to `Router` construction
40 | * Changed `routeLinks` to default to `true`, you must pass `routeLinks: false` to disable
41 | * Changed `start` and `stop` to return current `routing` promise
42 | * Removed redundant `get` method
43 | * Changes `navigate` to return a promise that resolves when finished routing the new path
44 | * Changed `replace` to return undefined, since no routing is performed
45 | * **Internal**
46 | * Moved all event listening / client-only code to it's own file
47 | * Drop testing IE10
48 | * Add testing Safari 8 & 9
49 |
50 | ## 1.1.0
51 |
52 | * **New Feature**
53 | * Added `router` to middleware args object, pointing to top-level Router instance
54 | * Added `routing` promise to top-level Router while running
55 | * **Bug Fix**
56 | * Define properties like classes do, so they can be overwritten
57 | * Bind private `handleEvent` method to work around a jsdom bug
58 | * **Internal**
59 | * Switch testing to mocha and power-assert
60 | * Use Firefox instead of PhantomJS for local testing
61 | * Drop node 0.10 in automated testing
62 |
63 | ## 1.0.2
64 |
65 | * **Bug Fix**
66 | * Removed unnecessary link intercepting when using hash routing
67 | * Fixed bad routing of link clicks that had default prevented
68 |
69 | ## 1.0.0
70 |
71 | * **Bug Fix**
72 | * Fixed hash routing query parameters
73 | * Improved hash routing support
74 |
75 | ## 0.2.0
76 |
77 | * **New Feature**
78 | * Added a `location` object passed to middleware
79 | * Made routers event emitters and added a `'route'` event
80 | * Added an `exiting` promise passed to middleware
81 | * **Breaking Change**
82 | * Removed `onRoute` option in `Route` constructor
83 | * Removed automatic fallback to hash routing when `pushState` is not supported
84 | * Renamed `go` to `navigate`
85 |
86 | ## 0.1.0
87 |
88 | * **New Feature**
89 | * Async middleware and route handling via middle-run
90 | * Added onRoute option, called with the route promise each time route is invoked
91 | * Added expressHandler() to get an express-compatible handler to mount a router
92 | * **Breaking Change**
93 | * browser.js is no longer built, consumers are expected to use browserify, webpack, rollup, etc
94 | * Router#route returns a promise that resolves when complete
95 | * Router#run returns a promise, and the function is added to the list of middleware run
96 | * Routes with a path create a new context with copied properties instead of inheriting from the previous context
97 |
98 | ## 0.0.4
99 |
100 | * **Bug Fix**
101 | * Fixed nested routers
102 |
103 | ## 0.0.1
104 |
105 | * **New Feature**
106 | * Middleware-based universal url routing
107 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ![middle-router][logo]
2 |
3 | [![npm Version][npm-image]][npm]
4 | [](https://greenkeeper.io/)
5 | [![Build Status][build-image]][build]
6 |
7 | [![MIT License][license-image]][LICENSE]
8 | [![JS Standard Style][style-image]][style]
9 |
10 | _Route urls through middleware functions on both client and server._
11 |
12 | middle-router is a universal front-end router for routing urls changes through a series of async middleware functions. This allows you to perform multiple tasks when the url changes, instead of just updating the view.
13 |
14 | `middle-router` uses [`path-to-regexp`][path-to-regexp] to match paths, so it should behave much like express 4.x paths.
15 | It also uses [`middle-run`][middle-run] to run the middleware series.
16 |
17 |
18 | Usage
19 | -----
20 |
21 | router.js
22 | ```js
23 | import Router from 'middle-router'
24 |
25 | export default Router()
26 | .use(async ({ context, next }) => {
27 | let start = Date.now()
28 | await next() // start next middleware, wait for control to return
29 | context.totalMs = Date.now() - start
30 | })
31 |
32 | .use('/accounts', Router()
33 | .use('/users/:userId', async ({ params, resolve, beforeExit, exiting }) => {
34 | setupListeners()
35 | beforeExit(event => {
36 | if (isFormDirty) return 'Are you sure you want to leave?'
37 | })
38 |
39 | resolve(UserView({ id: params.userId })) // yields control back upstream
40 |
41 | await exiting // only do this after resolve, or it won't resolve until next url change!!
42 | cleanupListeners()
43 | })
44 | )
45 | ```
46 |
47 | server-client.js
48 | ```js
49 | import router from './router.js'
50 | import { Router } from 'express'
51 | import ReactDOMServer from 'react-dom/server'
52 |
53 | export default Router()
54 | .use(async (req, res, next) {
55 | try {
56 | let view = await router.route(req.url, res.locals.state)
57 | res.send(ReactDOMServer.renderToString(view))
58 | } catch (err) {
59 | next(err)
60 | }
61 | })
62 | ```
63 |
64 | client.js
65 | ```js
66 | import router from './router.js'
67 | import ReactDOM from 'react-dom'
68 |
69 | router
70 | .on('route', async (args, routing) => {
71 | try {
72 | let view = await routing
73 | ReactDOM.render(view, document.getElementById('view'))
74 | } catch (error) {
75 | ReactDOM.render(, document.getElementById('view'))
76 | }
77 | })
78 | .start()
79 | ```
80 |
81 | Note: _These usage examples use Express and React, and resolve each url to a React element. middle-router has no dependency on these, and can be used with whatever libraries you like._
82 |
83 |
84 | API
85 | ---
86 |
87 | Full API documentation is in the [GitHub Wiki][wiki]
88 |
89 |
90 | Async Middleware
91 | ----------------
92 |
93 | middle-router can work with any promised-based async middleware, but it was designed specifically for async functions. Inspired by [koa][koa]'s `yield next`, middle-router allows you to `await next()` so you can `next()` "downstream" and the `await` for control to flow back "upstream".
94 |
95 |
96 | License
97 | -------
98 |
99 | This software is free to use under the MIT license. See the [LICENSE-MIT file][LICENSE] for license text and copyright information.
100 |
101 |
102 | [logo]: https://cdn.rawgit.com/vanwagonet/middle-router/f15320d/logo.svg
103 | [npm]: https://www.npmjs.org/package/middle-router
104 | [npm-image]: https://img.shields.io/npm/v/middle-router.svg
105 | [build]: https://travis-ci.org/vanwagonet/middle-router
106 | [build-image]: https://img.shields.io/travis/vanwagonet/middle-router.svg
107 | [style]: https://github.com/feross/standard
108 | [style-image]: https://img.shields.io/badge/code%20style-standard-brightgreen.svg
109 | [license-image]: https://img.shields.io/npm/l/middle-router.svg
110 | [path-to-regexp]: https://github.com/pillarjs/path-to-regexp
111 | [middle-run]: https://github.com/vanwagonet/middle-run
112 | [wiki]: https://github.com/vanwagonet/middle-router/wiki/api
113 | [koa]: http://koajs.com
114 | [LICENSE]: https://github.com/vanwagonet/middle-router/blob/master/LICENSE-MIT
115 |
--------------------------------------------------------------------------------
/lib/router.js:
--------------------------------------------------------------------------------
1 | /* global window */
2 | 'use strict'
3 |
4 | var EventEmitter = require('eventemitter3')
5 | var middleRun = require('middle-run')
6 | var wrapMiddleware = require('./wrap-middleware')
7 | var history = require('./history')
8 | var parseUrl = typeof window === 'undefined'
9 | ? require('url').parse
10 | : require('./url').parse
11 |
12 | var slice = Array.prototype.slice
13 |
14 | function Router (options) {
15 | if (!(this instanceof Router)) return new Router(options)
16 |
17 | options = options || {}
18 | var hash = options.hash === true ? '#' : options.hash
19 | hash = (hash && hash.charAt(0) === '#') ? hash : false
20 |
21 | EventEmitter.call(this)
22 |
23 | this.middleware = []
24 | this.routing = null
25 | this.isListening = false
26 | this.unlisten = null
27 | this.hash = hash
28 | this.routeLinks = options.routeLinks !== false
29 | this.confirm = options.confirm
30 | this.context = options.context || {}
31 |
32 | this.stack = null
33 | this.stackIndex = -1
34 | this.onBeforeUnload = null
35 | }
36 |
37 | function method (fn) {
38 | return {
39 | configurable: true,
40 | enumerable: false,
41 | writable: true,
42 | value: fn
43 | }
44 | }
45 |
46 | function lazyLoad (step) {
47 | var self = this
48 | if (self.loaded) return self.fn(step)
49 | return Promise.resolve(self.fn(step)).then(function (fn) {
50 | if (fn instanceof Router) fn = middleRun(fn.middleware)
51 | self.loaded = true
52 | return (self.fn = fn)(step)
53 | })
54 | }
55 |
56 | Router.prototype = Object.create(EventEmitter.prototype, {
57 |
58 | start: method(function start () {
59 | if (this.isListening) this.stop() // remove previous listeners
60 | this.isListening = true
61 | this.unlisten = history.listen(this)
62 | return this.routing
63 | }),
64 |
65 | stop: method(function stop () {
66 | if (this.isListening) {
67 | this.isListening = false
68 | this.unlisten()
69 | this.unlisten = null
70 | }
71 | return this.routing
72 | }),
73 |
74 | navigate: method(function navigate (path, state, title) {
75 | return history.navigate(this, path, state, title)
76 | }),
77 |
78 | replace: method(function replace (path, state, title) {
79 | return history.replace(this, path, state, title)
80 | }),
81 |
82 | back: method(function back () {
83 | return history.back(this)
84 | }),
85 |
86 | forward: method(function forward () {
87 | return history.forward(this)
88 | }),
89 |
90 | use: method(function use (path) {
91 | var callbacks = slice.call(arguments, 1)
92 | if (path && typeof path !== 'string') {
93 | callbacks.unshift(path)
94 | path = null
95 | }
96 |
97 | var middleware = this.middleware
98 | callbacks.forEach(function (fn) {
99 | var isRouter = fn instanceof Router
100 | if (isRouter) fn = middleRun(fn.middleware)
101 | middleware.push(wrapMiddleware(path, isRouter, fn))
102 | })
103 |
104 | return this
105 | }),
106 |
107 | lazy: method(function lazy (path) {
108 | var callbacks = slice.call(arguments, 1)
109 | if (path && typeof path !== 'string') {
110 | callbacks.unshift(path)
111 | path = null
112 | }
113 |
114 | var middleware = this.middleware
115 | callbacks.forEach(function (fn) {
116 | fn = lazyLoad.bind({ fn: fn, loaded: false })
117 | middleware.push(wrapMiddleware(path, true, fn))
118 | })
119 |
120 | return this
121 | }),
122 |
123 | route: method(function route (path, state) {
124 | var self = this
125 | var exit
126 | var location = parseUrl(path, true)
127 | var args = {
128 | location: location,
129 | path: location.pathname,
130 | params: {},
131 | state: state,
132 | router: this,
133 | context: this.context,
134 | beforeExit: !this.isListening
135 | ? function () {}
136 | : history.listenBefore.bind(null, this)
137 | }
138 |
139 | if (this.isListening) {
140 | // only provide an exiting promise if listening for client url changes
141 | // `await exiting` is still safe, but immediately resolves to undefined
142 | args.exiting = new Promise(function (resolve) { exit = resolve })
143 | }
144 |
145 | // start asynchronously
146 | var promise = Promise.resolve(args)
147 | .then(middleRun(this.middleware))
148 |
149 | self.routing = promise
150 | function unsetRouting () { self.routing = null }
151 | promise.then(unsetRouting, unsetRouting)
152 |
153 | this.emit('beforeroute', args, promise)
154 | this.emit('route', args, promise)
155 | if (exit) this.once('route', exit)
156 |
157 | return promise
158 | }),
159 |
160 | expressHandler: method(function expressHandler () {
161 | var self = this
162 | return function (req, res, next) {
163 | self.once('beforeroute', function (args, routing) {
164 | args.request = req
165 | args.response = res
166 | args.params = req.params
167 | args.next = function () { next() } // discard arguments
168 | routing.catch(next)
169 | })
170 | self.route(req.url)
171 | }
172 | })
173 | })
174 |
175 | module.exports = Router
176 |
--------------------------------------------------------------------------------
/lib/__tests__/client.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env mocha, browser */
2 | const assert = require('assert')
3 | const Router = require('../router')
4 |
5 | // run same tests as on server
6 | require('./server.test')
7 | require('./links.test')
8 |
9 | describe('Router#start using push/pop state', () => {
10 | it('routes from the current location.pathname', async () => {
11 | let called = 0
12 | let router = new Router()
13 | .use('/foo/:bar', ({ params, resolve }) => {
14 | ++called
15 | assert.strictEqual(params.bar, 'bar', 'param bar should be set')
16 | resolve()
17 | })
18 |
19 | history.replaceState(null, document.title, '/foo/bar')
20 | await router.start()
21 | router.stop()
22 | assert.strictEqual(called, 1, 'matching route should be called')
23 | })
24 |
25 | it('listens to popstate events', async () => {
26 | let called = 0
27 | let router = new Router()
28 | .use('/foo/:bar', ({ params }) => {
29 | ++called
30 | assert.strictEqual(params.bar, 'bas', 'param bar should be set')
31 | })
32 | .use(({ resolve }) => { resolve() })
33 |
34 | history.replaceState(null, document.title, '/foo/bas')
35 | await router.start() // called 1
36 | await router.navigate('/') // not called
37 | await router.back() // called 2
38 | await router.forward() // not called
39 | await router.back() // called 3
40 |
41 | router.stop()
42 | assert.strictEqual(called, 3, 'matching route should be called for each matching location')
43 | })
44 | })
45 |
46 | describe('Router#start using hash', () => {
47 | it('routes from the current location.hash', async () => {
48 | let called = 0
49 | let router = new Router({ hash: true })
50 | .use('/foo/:bar', ({ params, resolve }) => {
51 | ++called
52 | assert.strictEqual(params.bar, 'bat', 'param bar should be set')
53 | resolve()
54 | })
55 |
56 | history.replaceState(null, document.title, '#/foo/bat')
57 | await router.start()
58 | router.stop()
59 | assert.strictEqual(called, 1, 'matching route should be called')
60 | })
61 |
62 | it('listens to hash changing', async () => {
63 | let called = 0
64 | let router = new Router({ hash: true })
65 | .use('/foo/:bar', ({ params }) => {
66 | ++called
67 | assert.strictEqual(params.bar, 'bax', 'param bar should be set')
68 | })
69 | .use(({ resolve }) => { resolve() })
70 |
71 | history.replaceState(null, document.title, '#/foo/bax')
72 | await router.start() // called 1
73 | await router.navigate('/') // not called
74 | await router.back() // called 2
75 | await router.forward() // not called
76 | await router.back() // called 3
77 |
78 | router.stop()
79 | assert.strictEqual(called, 3, 'matching route should be called for each matching location')
80 | })
81 |
82 | it('supports hash routes with pseudo query params', async () => {
83 | let called = 0
84 | let router = new Router({ hash: true })
85 | .use('/login', ({ location }) => {
86 | ++called
87 | assert.strictEqual(location.query.foo, 'bar', 'param bar should be set')
88 | })
89 | .use(({ resolve }) => { resolve() })
90 |
91 | history.replaceState(null, document.title, '#/login?foo=bar')
92 | await router.start() // called 1
93 |
94 | router.stop()
95 | assert.strictEqual(called, 1, 'matching route should be called')
96 | })
97 |
98 | it('uses the path inside hash', async () => {
99 | let called = 0
100 | let router = Router({ hash: '#$!' })
101 | .use('/path', () => {
102 | assert.fail('must not match from pathname when hash routing')
103 | })
104 | .use(({ path }) => {
105 | ++called
106 | assert.strictEqual(path, '/hash/route', 'path must be from hash')
107 | })
108 | .use('/hash', Router().use(({ path, location, resolve }) => {
109 | assert.strictEqual(path, '/route', 'path is relative to mount point')
110 | assert.strictEqual(location.hash, '', 'hash is empty')
111 | resolve()
112 | }))
113 | history.replaceState(null, document.title, '/path#$!/hash/route')
114 | await router.start()
115 |
116 | router.stop()
117 | assert.strictEqual(called, 1, 'matching route should be called')
118 | })
119 | })
120 |
121 | describe('Route middleware arguments on client', () => {
122 | it('has an exiting promise when listening', async () => {
123 | let stage = 'before'
124 | let router = Router({ hash: true })
125 | .use('/', async ({ exiting, next, resolve }) => {
126 | assert(exiting instanceof Promise, 'exiting must be a promise when listening')
127 | await next()
128 | stage = 'resolved'
129 | resolve() // call resolve or this will wait indefinitely
130 | await exiting
131 | stage = 'after'
132 | })
133 | .use(({ resolve }) => { resolve() })
134 |
135 | history.replaceState(null, document.title, '#/')
136 | router.start()
137 | assert.strictEqual(stage, 'before', 'before route promise completes, exiting must not be resolved')
138 |
139 | await router.routing
140 | assert.strictEqual(stage, 'resolved', 'after route promise completes, exiting must not be resolved')
141 |
142 | await router.navigate('/nowhere')
143 | assert.strictEqual(stage, 'after', 'after next route, exiting must be resolved')
144 | })
145 | })
146 |
147 | describe('Trying to navigate without listening first', () => {
148 | it('throws when navigate is called', () => {
149 | assert.throws(() => Router().navigate(), 'should throw when trying to navigate')
150 | })
151 |
152 | it('throws when replace is called', () => {
153 | assert.throws(() => Router().replace(), 'should throw when trying to replace')
154 | })
155 |
156 | it('throws when back is called', () => {
157 | assert.throws(() => Router().back(), 'should throw when trying to go back')
158 | })
159 |
160 | it('throws when forward is called', () => {
161 | assert.throws(() => Router().forward(), 'should throw when trying to go forward')
162 | })
163 | })
164 |
--------------------------------------------------------------------------------
/lib/history.js:
--------------------------------------------------------------------------------
1 | /* global window */
2 | 'use strict'
3 |
4 | var win = typeof window === 'undefined' ? null : window
5 |
6 | function once (eventname) {
7 | return new Promise(function (resolve) {
8 | win.addEventListener(eventname, function handle () {
9 | win.removeEventListener(eventname, handle, false)
10 | resolve()
11 | }, false)
12 | })
13 | }
14 |
15 | function assertListening (router) {
16 | if (!router.isListening) {
17 | throw new Error('Router can only navigate if listening')
18 | }
19 | }
20 |
21 | exports.listenBefore = function listenBefore (router, callback, context) {
22 | // ensure no duplicate handler
23 | win.removeEventListener('beforeunload', router.onBeforeUnload, false)
24 | win.addEventListener('beforeunload', router.onBeforeUnload, false)
25 | router.on('beforeexit', function (event) {
26 | var returnValue = callback.call(context, event)
27 | if (typeof returnValue === 'string') {
28 | event.returnValue = returnValue
29 | }
30 | })
31 | }
32 |
33 | exports.listen = function listen (router) {
34 | if (!win) {
35 | router.isListening = false
36 | throw new Error('Can only listen for navigation events in a browser')
37 | }
38 | router.stack = [ '' ]
39 | router.stackIndex = 0
40 | router.onBeforeUnload = function (event) {
41 | // true if there were listeners
42 | if (router.emit('beforeexit', event)) {
43 | return event.returnValue
44 | }
45 | }
46 |
47 | var path = getPath(router, win.location.href)
48 | exports.replace(router, path, win.history.state)
49 | router.route(path, win.history.state)
50 |
51 | var onpopstate = didPopState.bind(null, router)
52 | win.addEventListener('popstate', onpopstate, false)
53 |
54 | var onclick
55 | if (router.routeLinks) {
56 | onclick = didClick.bind(null, router)
57 | win.addEventListener('click', onclick, false)
58 | }
59 |
60 | return function () {
61 | win.removeEventListener('popstate', onpopstate, false)
62 | if (onclick) {
63 | win.removeEventListener('click', onclick, false)
64 | }
65 | }
66 | }
67 |
68 | exports.navigate = function navigate (router, path, state, title) {
69 | if (!shouldNavigate(router)) return Promise.resolve()
70 | state = state || {}
71 | state.navigationKey = genKey()
72 | if (router.hash) state.hash = router.hash + path
73 | win.history.pushState(
74 | state,
75 | title || win.document.title,
76 | router.hash ? router.hash + path : path
77 | )
78 | router.stack[++router.stackIndex] = state.navigationKey
79 | router.stack.length = router.stackIndex + 1
80 | return router.route(path, state)
81 | }
82 |
83 | exports.replace = function replace (router, path, state, title) {
84 | if (!shouldNavigate(router)) return Promise.resolve()
85 | state = state || {}
86 | state.navigationKey = genKey()
87 | if (router.hash) state.hash = router.hash + path
88 | win.history.replaceState(
89 | state,
90 | title || win.document.title,
91 | router.hash ? router.hash + path : path
92 | )
93 | router.stack[router.stackIndex] = state.navigationKey
94 | }
95 |
96 | exports.back = function back (router) {
97 | assertListening(router)
98 | var popping = once('popstate')
99 | win.history.back()
100 | return popping.then(function () {
101 | return ignorePopState ? once('popstate') : null
102 | }).then(function () {
103 | return router.routing
104 | })
105 | }
106 |
107 | exports.forward = function forward (router) {
108 | assertListening(router)
109 | var popping = once('popstate')
110 | win.history.forward()
111 | return popping.then(function () {
112 | return ignorePopState ? once('popstate') : null
113 | }).then(function () {
114 | return router.routing
115 | })
116 | }
117 |
118 | function genKey () {
119 | return Math.random().toString(36).slice(2)
120 | }
121 |
122 | var ignorePopState = false
123 | function didPopState (router, event) {
124 | var state = event.state || {}
125 | var index = router.stack.lastIndexOf(state.navigationKey)
126 | if (index < 0) index = 0 // assume unknown is start state
127 | var delta = router.stackIndex - index
128 | router.stackIndex = index
129 | // workaround weird IE11 bug that doesn't do hash state right
130 | if (router.hash && state.hash && !win.location.hash) {
131 | win.history.replaceState(state, win.document.title, state.hash)
132 | }
133 |
134 | if (ignorePopState) {
135 | ignorePopState = false
136 | } else if (shouldNavigate(router)) {
137 | var path = getPath(router, win.location.href)
138 | router.route(path, state)
139 | } else if (delta) {
140 | ignorePopState = true
141 | win.history.go(delta)
142 | }
143 | }
144 |
145 | function didClick (router, event) {
146 | // ignore if it could open a new window, if a right click
147 | if (
148 | event.metaKey || event.shiftKey || event.ctrlKey || event.altKey ||
149 | event.which === 3 || event.button === 2 || event.defaultPrevented
150 | ) return
151 |
152 | // ignore if not a link click
153 | var html = win.document.documentElement
154 | var target = event.target
155 | while (target && target.nodeName.toLowerCase() !== 'a' && target !== html) {
156 | target = target.parentNode
157 | }
158 |
159 | if (
160 | !target ||
161 | target.nodeName.toLowerCase() !== 'a' ||
162 | target.hasAttribute('target') ||
163 | target.hasAttribute('download') ||
164 | target.hasAttribute('rel')
165 | ) return
166 |
167 | // ignore if not the same origin as the page
168 | var location = win.location
169 | var origin = location.origin || (location.protocol + '//' + location.host)
170 | if (target.href.slice(0, origin.length) !== origin) return
171 |
172 | var path = getPath(router, target.href)
173 |
174 | event.preventDefault()
175 | router.navigate(path)
176 | }
177 |
178 | function shouldNavigate (router) {
179 | assertListening(router)
180 | if (!win) return true
181 | var event = {
182 | target: win.document,
183 | type: 'beforeexit',
184 | returnValue: null,
185 | preventDefault: function () {
186 | event.defaultPrevented = true
187 | }
188 | }
189 | router.emit('beforeexit', event)
190 | var defaultPrevented = event.defaultPrevented || (
191 | event.returnValue && typeof event.returnValue === 'string'
192 | )
193 | var confirm = router.confirm || win.confirm
194 | var confirmed = !defaultPrevented || confirm(event.returnValue || '')
195 | if (confirmed) {
196 | win.removeEventListener('beforeunload', router.onBeforeUnload, false)
197 | router.removeAllListeners('beforeexit')
198 | }
199 | return confirmed
200 | }
201 |
202 | function getPath (router, href) {
203 | var path = href
204 | var hash = router.hash
205 | if (hash && path.indexOf(hash) >= 0) {
206 | path = path.slice(path.indexOf(hash) + hash.length)
207 | }
208 | return path
209 | }
210 |
--------------------------------------------------------------------------------
/lib/__tests__/server.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env mocha */
2 | const assert = require('assert')
3 | const Router = require('../router')
4 |
5 | // run args tests
6 | require('./arguments.test')
7 |
8 | function sleep (time) {
9 | return new Promise(resolve => setTimeout(resolve, time))
10 | }
11 |
12 | describe('Router', () => {
13 | describe('#constructor', () => {
14 | it('can be called as a function', async () => {
15 | let router = Router()
16 | assert(router instanceof Router, 'Router() should return an instanceof Router')
17 | assert.strictEqual(router.hash, false, 'hash should be false by default')
18 | assert.strictEqual(router.routing, null, 'routing should be null by default')
19 | })
20 |
21 | it('can be called as a constructor', async () => {
22 | let router = new Router()
23 | assert(router instanceof Router, 'new Router() should return an instanceof Router')
24 | assert.strictEqual(router.hash, false, 'hash should be false by default')
25 | assert.strictEqual(router.routing, null, 'routing should be null by default')
26 | })
27 | })
28 |
29 | describe('#use', () => {
30 | it('returns the router', async () => {
31 | let router = new Router()
32 | assert.strictEqual(router.use(), router, 'use should return the router')
33 | })
34 |
35 | it('adds each callback to the middleware', async () => {
36 | let router = new Router()
37 | .use(() => {}, () => {})
38 | assert.strictEqual(router.middleware.length, 2, 'should have a middleware per callback')
39 | })
40 |
41 | it('accepts a path as the first arg', async () => {
42 | let router = new Router()
43 | .use('/form', () => {}, () => {})
44 | assert.strictEqual(router.middleware.length, 2, 'should have a middleware per callback')
45 | })
46 | })
47 |
48 | describe('#lazy', () => {
49 | it('returns the router', async () => {
50 | let router = new Router()
51 | assert.strictEqual(router.lazy(), router, 'use should return the router')
52 | })
53 |
54 | it('adds the callback to the middleware', async () => {
55 | let router = new Router()
56 | .lazy(() => {})
57 | assert.strictEqual(router.middleware.length, 1, 'should have a middleware for the callback')
58 | })
59 |
60 | it('accepts a path as the first arg', async () => {
61 | let router = new Router()
62 | .lazy('/form', () => {})
63 | assert.strictEqual(router.middleware.length, 1, 'should have a middleware for the callback')
64 | })
65 | })
66 |
67 | describe('#route', () => {
68 | it('returns a promise', async () => {
69 | let router = new Router().use('/', ({ resolve }) => { resolve() })
70 | let value = router.route('/')
71 | assert(value.then && value.catch, 'route should return a promise')
72 | assert.strictEqual(router.routing, value, 'routing should be the same promise returned by route()')
73 | await value
74 | assert.strictEqual(router.routing, null, 'routing should be null after finished')
75 | })
76 |
77 | it('routes through the matching middleware', async () => {
78 | let called = 0
79 | let router = new Router()
80 | .use('/foo/:bar', () => {
81 | ++called
82 | })
83 | .use('/somewhere/else', ({ resolve }) => {
84 | assert.fail('should not run non-matching middleware')
85 | resolve()
86 | })
87 | .use(({ resolve }) => {
88 | ++called
89 | resolve()
90 | })
91 | .use(() => ++called)
92 | await router.route('/foo/bar')
93 | assert.strictEqual(called, 2, 'should only be called for matching routes up until resolve() is called')
94 | })
95 |
96 | it('has an object containing all the parameters', async () => {
97 | let called = 0
98 | let router = new Router()
99 | .use('/foo/:bar', ({ params, resolve }) => {
100 | ++called
101 | assert.strictEqual(params.bar, 'bar', 'params should contain the path parameter')
102 | resolve()
103 | })
104 | await router.route('/foo/bar')
105 | assert.strictEqual(called, 1, 'matching route should be called')
106 | })
107 |
108 | it('passes the state in the arguments', async () => {
109 | let called = 0
110 | let ostate = { foo: 'bar' }
111 | let router = new Router()
112 | .use('/foo/:bar', ({ state, resolve }) => {
113 | ++called
114 | assert.strictEqual(state, ostate, 'state should be unaltered in the arguments')
115 | resolve()
116 | })
117 | await router.route('/foo/bar', ostate)
118 | assert.strictEqual(called, 1, 'matching route should be called')
119 | })
120 |
121 | it('passes parameters to nested routers', async () => {
122 | let called = 0
123 | let router = new Router()
124 | .use('/:foo', new Router()
125 | .use('/', ({ params, resolve }) => {
126 | ++called
127 | assert.strictEqual(params.foo, 'foo', 'parameters should propagate through nested routes')
128 | resolve()
129 | })
130 | )
131 | await router.route('/foo/')
132 | assert.strictEqual(called, 1, 'matching route should be called')
133 | })
134 |
135 | it('routes through arbitrarily deep nested routers', async () => {
136 | let called = 0
137 | let router = Router()
138 | .use('/:foo', Router().use(Router().use(Router()
139 | .use('/bar', Router().use(Router()
140 | .use('/:baz', ({ params, resolve }) => {
141 | ++called
142 | assert.strictEqual(params.foo, 'foo', 'param foo should be propagated')
143 | assert.strictEqual(params.baz, 'bar', 'param baz should be propagated')
144 | resolve()
145 | })
146 | ))
147 | )))
148 | await router.route('/foo/bar/bar')
149 | assert.strictEqual(called, 1, 'matching route should be called')
150 | })
151 |
152 | it('nested router can match parent path even when no trailing slash', async () => {
153 | let called = 0
154 | let router = Router()
155 | .use('/:foo', Router()
156 | .use('/bar', Router()
157 | .use('/', ({ params, resolve }) => {
158 | ++called
159 | assert.strictEqual(params.foo, 'foo', 'param foo should be propagated')
160 | resolve()
161 | })
162 | )
163 | )
164 | await router.route('/foo/bar')
165 | assert.strictEqual(called, 1, 'matching route should be called')
166 | })
167 |
168 | it('routes can be asynchronous', async () => {
169 | let called = 0
170 | let router = Router()
171 | .use('/', async ({ next }) => {
172 | await sleep(10)
173 | assert.strictEqual(++called, 1, 'first matching route should happen first')
174 | await next()
175 | assert.strictEqual(++called, 3, 'after next should happen last')
176 | })
177 | .use('/bogus', () => {
178 | assert.fail('should not call a non-matching route')
179 | })
180 | .use('/', Router().use(({ resolve }) => {
181 | assert.strictEqual(++called, 2, 'last matching route should happen second')
182 | resolve()
183 | }))
184 | await router.route('/')
185 | assert.strictEqual(called, 3, 'matching routes should be called')
186 | })
187 |
188 | it('routes can be lazy', async () => {
189 | let called = 0
190 | let router = Router()
191 | .lazy('/lazy', () => {
192 | assert.strictEqual(++called, 1, 'lazy matching route should happen first')
193 | return Promise.resolve(({ resolve }) => {
194 | ++called
195 | resolve('jit ftw')
196 | })
197 | })
198 | .use('/lazy', () => {
199 | assert.fail('should never get here')
200 | })
201 | assert.strictEqual(await router.route('/lazy'), 'jit ftw')
202 | assert.strictEqual(called, 2, 'matching routes should be called')
203 |
204 | assert.strictEqual(await router.route('/lazy'), 'jit ftw')
205 | assert.strictEqual(called, 3, 'wrapper should not be called after loaded')
206 | })
207 |
208 | it('lazy routes can resolve to routers', async () => {
209 | let called = 0
210 | let router = Router()
211 | .lazy('/lazy', () => {
212 | assert.strictEqual(++called, 1, 'lazy matching route should happen first')
213 | return Promise.resolve(Router().use(({ resolve }) => {
214 | ++called
215 | resolve('jit ftw')
216 | }))
217 | })
218 | .use('/lazy', () => {
219 | assert.fail('should never get here')
220 | })
221 | assert.strictEqual(await router.route('/lazy/123'), 'jit ftw')
222 | assert.strictEqual(called, 2, 'matching routes should be called')
223 |
224 | assert.strictEqual(await router.route('/lazy/123'), 'jit ftw')
225 | assert.strictEqual(called, 3, 'wrapper should not be called after loaded')
226 | })
227 | })
228 |
229 | if (typeof window === 'undefined') {
230 | describe('#start', () => {
231 | it('cannot listen without a window object', () => {
232 | let router = Router()
233 | assert.throws(() => router.start(), 'start should throw without a window')
234 | assert.strictEqual(router.isListening, false, 'isListening should still be false')
235 | })
236 | })
237 |
238 | describe('#stop', () => {
239 | it('is a no op since you cannot start', () => {
240 | let router = Router()
241 | router.stop()
242 | assert.strictEqual(router.isListening, false, 'isListening should still be false')
243 | })
244 | })
245 | }
246 | })
247 |
--------------------------------------------------------------------------------
/lib/__tests__/links.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env mocha, browser */
2 | const assert = require('assert')
3 | const Router = require('../router')
4 | const event = require('synthetic-dom-events')
5 |
6 | function clickTo (url, prevent, attributes) {
7 | let link = document.body.appendChild(document.createElement('a'))
8 | link.href = url
9 | if (prevent) {
10 | link.addEventListener('click', evt => evt.preventDefault())
11 | }
12 | if (attributes) {
13 | Object.keys(attributes).forEach(name => link.setAttribute(name, attributes[name]))
14 | }
15 | link.dispatchEvent(event('click', { bubbles: true, cancelable: true }))
16 | document.body.removeChild(link)
17 | }
18 |
19 | describe('Router#routeLinks', () => {
20 | it('listens to link clicks if routeLinks is true', async () => {
21 | let called = 0
22 | let router = new Router({ routeLinks: true })
23 | .use('/start', ({ resolve }) => resolve())
24 | .use('/linked/:to', ({ params, resolve }) => {
25 | ++called
26 | assert.strictEqual(params.to, 'location', 'param to should be set')
27 | resolve()
28 | })
29 |
30 | history.replaceState(null, document.title, '/start')
31 | await router.start()
32 |
33 | clickTo('/linked/location')
34 | await router.routing
35 |
36 | router.stop()
37 | assert.strictEqual(called, 1, 'matching route should be called')
38 | })
39 |
40 | it('correctly identifies the link href', async () => {
41 | let called = 0
42 | let router = new Router({ routeLinks: true })
43 | .use('/start', ({ resolve }) => resolve())
44 | .use('/linked/:to', ({ params, resolve }) => {
45 | ++called
46 | assert.strictEqual(params.to, 'location', 'param to should be set')
47 | resolve()
48 | })
49 |
50 | history.replaceState(null, document.title, '/start')
51 | await router.start()
52 |
53 | let link = document.body.appendChild(document.createElement('a'))
54 | link.href = '/linked/location'
55 | let img = document.createElement('img')
56 | img.src = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='
57 | link.appendChild(img)
58 | img.dispatchEvent(event('click', { bubbles: true, cancelable: true }))
59 | document.body.removeChild(link)
60 | await router.routing
61 |
62 | router.stop()
63 | assert.strictEqual(called, 1, 'matching route should be called')
64 | })
65 |
66 | it('triggers beforeExit handlers on link clicks', async () => {
67 | let called = 0
68 | let beforeExitCalled = 0
69 | let router = new Router()
70 | .use('/start', ({ resolve, beforeExit }) => {
71 | beforeExit(evt => {
72 | ++beforeExitCalled
73 | assert.strictEqual(typeof evt, 'object', 'beforeExit should pass an event object')
74 | assert.strictEqual(evt.type, 'beforeexit', 'beforeExit event object is of type "beforeexit"')
75 | })
76 | resolve()
77 | })
78 | .use('/linked/:to', ({ params, resolve }) => {
79 | ++called
80 | resolve()
81 | })
82 |
83 | history.replaceState(null, document.title, '/start')
84 | await router.start()
85 |
86 | clickTo('/linked/location')
87 | await router.routing
88 |
89 | router.stop()
90 | assert.strictEqual(called, 1, 'matching route should be called')
91 | assert.strictEqual(beforeExitCalled, 1, 'beforeExit handler should be called')
92 | })
93 |
94 | it('confirms exit if beforeExit handlers prevents default', async () => {
95 | let called = 0
96 | let confirmCalled = 0
97 | let beforeExitCalled = 0
98 | let confirm = message => {
99 | ++confirmCalled
100 | assert.strictEqual(message, '', 'the confirm message should be empty')
101 | return false
102 | }
103 | let router = new Router({ confirm })
104 | .use('/start', ({ resolve, beforeExit }) => {
105 | beforeExit(evt => {
106 | ++beforeExitCalled
107 | evt.preventDefault()
108 | })
109 | resolve()
110 | })
111 | .use('/linked/:to', ({ params, resolve }) => {
112 | ++called
113 | resolve()
114 | })
115 |
116 | history.replaceState(null, document.title, '/start')
117 | await router.start()
118 |
119 | clickTo('/linked/location')
120 | await router.routing
121 |
122 | router.stop()
123 | assert.strictEqual(called, 0, 'matching route should not be called')
124 | assert.strictEqual(confirmCalled, 1, 'confirm should be called')
125 | assert.strictEqual(beforeExitCalled, 1, 'beforeExit handler should be called')
126 | })
127 |
128 | it('reverts to the correct location when navigation canceled', async () => {
129 | let called = 0
130 | let lastTo
131 | let router = new Router({ hash: '#', confirm: () => true })
132 | .use('/go/:to', ({ params, beforeExit, resolve }) => {
133 | ++called
134 | lastTo = params.to
135 | beforeExit(evt => evt.preventDefault())
136 | resolve()
137 | })
138 |
139 | history.replaceState(null, document.title, '#/go/0')
140 | await router.start() // called 1
141 |
142 | async function go (to, confirm) {
143 | router.confirm = () => confirm
144 | await clickTo(to)
145 | await router.routing
146 | }
147 |
148 | async function back (confirm) {
149 | router.confirm = () => confirm
150 | await router.back()
151 | }
152 |
153 | async function forward (confirm) {
154 | router.confirm = () => confirm
155 | await router.forward()
156 | }
157 |
158 | await go('#/go/1', true) // called 2
159 | await go('#/go/2', true) // called 3
160 |
161 | // hits prevention logic in navigate
162 | await go('#/go/3', false)
163 | assert.strictEqual(location.hash, '#/go/2', 'should stay on 2')
164 | assert.strictEqual(lastTo, '2', 'should have last run 2')
165 | assert.strictEqual(called, 3, 'route should not be called when prevented')
166 |
167 | await back(true) // called 4
168 | assert.strictEqual(location.hash, '#/go/1', 'should go back to 1')
169 | assert.strictEqual(lastTo, '1', 'should have last run 1')
170 | assert.strictEqual(called, 4, 'route should run on confirmed back')
171 |
172 | await forward(true) // called 5
173 | assert.strictEqual(location.hash, '#/go/2', 'should go forward to 2')
174 | assert.strictEqual(lastTo, '2', 'should have last run 2')
175 | assert.strictEqual(called, 5, 'route should run on confirmed forward')
176 |
177 | await go('#/go/1', true) // called 6
178 | assert.strictEqual(location.hash, '#/go/1', 'should go to 1')
179 | assert.strictEqual(lastTo, '1', 'should have last run 1')
180 | assert.strictEqual(called, 6, 'route should run on confirmed navigation')
181 |
182 | await back(true) // called 7
183 | assert.strictEqual(location.hash, '#/go/2', 'should go back to 2')
184 | assert.strictEqual(lastTo, '2', 'should have last run 2')
185 | assert.strictEqual(called, 7, 'route should run on confirmed back')
186 |
187 | // hits revert logic in didPopState
188 | await back(false)
189 | assert.strictEqual(location.hash, '#/go/2', 'should revert to 2')
190 | assert.strictEqual(lastTo, '2', 'should have last run 2')
191 | assert.strictEqual(called, 7, 'route should not be called when reverted')
192 |
193 | await forward(false)
194 | assert.strictEqual(location.hash, '#/go/2', 'should revert to 2')
195 | assert.strictEqual(lastTo, '2', 'should have last run 2')
196 | assert.strictEqual(called, 7, 'route should not be called when reverted')
197 |
198 | router.stop()
199 | })
200 |
201 | it('confirms exit if beforeExit handlers sets a returnValue', async () => {
202 | let called = 0
203 | let confirmCalled = 0
204 | let beforeExitCalled = 0
205 | let confirm = message => {
206 | ++confirmCalled
207 | assert.strictEqual(message, 'U Shure?', 'the confirm message should be set')
208 | return true
209 | }
210 | let router = new Router({ confirm })
211 | .use('/start', ({ resolve, beforeExit }) => {
212 | beforeExit(evt => {
213 | ++beforeExitCalled
214 | evt.returnValue = 'U Shure?'
215 | })
216 | resolve()
217 | })
218 | .use('/linked/:to', ({ params, resolve }) => {
219 | ++called
220 | resolve()
221 | })
222 |
223 | history.replaceState(null, document.title, '/start')
224 | await router.start()
225 |
226 | clickTo('/linked/location')
227 | await router.routing
228 |
229 | router.stop()
230 | assert.strictEqual(called, 1, 'matching route should be called since confirmed')
231 | assert.strictEqual(confirmCalled, 1, 'confirm should be called')
232 | assert.strictEqual(beforeExitCalled, 1, 'beforeExit handler should be called')
233 | })
234 |
235 | it('confirms exit if beforeExit handlers returns a confirm message', async () => {
236 | let called = 0
237 | let confirmCalled = 0
238 | let beforeExitCalled = 0
239 | let confirm = message => {
240 | ++confirmCalled
241 | assert.strictEqual(message, 'U Shure?', 'the confirm message should be set')
242 | return true
243 | }
244 | let router = new Router({ confirm })
245 | .use('/start', ({ resolve, beforeExit }) => {
246 | beforeExit(evt => {
247 | ++beforeExitCalled
248 | return 'U Shure?'
249 | })
250 | resolve()
251 | })
252 | .use('/linked/:to', ({ params, resolve }) => {
253 | ++called
254 | resolve()
255 | })
256 |
257 | history.replaceState(null, document.title, '/start')
258 | await router.start()
259 |
260 | clickTo('/linked/location')
261 | await router.routing
262 |
263 | router.stop()
264 | assert.strictEqual(called, 1, 'matching route should be called since confirmed')
265 | assert.strictEqual(confirmCalled, 1, 'confirm should be called')
266 | assert.strictEqual(beforeExitCalled, 1, 'beforeExit handler should be called')
267 | })
268 |
269 | it('removes beforeExit handlers after confirming exit', async () => {
270 | let called = 0
271 | let confirmCalled = 0
272 | let beforeExitCalled = 0
273 | let confirm = message => {
274 | ++confirmCalled
275 | return true
276 | }
277 | let router = new Router({ confirm })
278 | .use('/start', ({ resolve, beforeExit }) => {
279 | beforeExit(evt => {
280 | ++beforeExitCalled
281 | evt.preventDefault()
282 | })
283 | resolve()
284 | })
285 | .use('/linked/:to', ({ params, resolve }) => {
286 | ++called
287 | resolve()
288 | })
289 |
290 | history.replaceState(null, document.title, '/start')
291 | await router.start()
292 |
293 | clickTo('/linked/location')
294 | await router.routing
295 |
296 | clickTo('/linked/elsewhere')
297 | await router.routing
298 |
299 | router.stop()
300 | assert.strictEqual(called, 2, 'matching route should be called since confirmed')
301 | assert.strictEqual(confirmCalled, 1, 'confirm should be called only once')
302 | assert.strictEqual(beforeExitCalled, 1, 'beforeExit handler should be called only once')
303 | })
304 |
305 | it('ignores prevented link clicks', async () => {
306 | let called = 0
307 | let router = new Router({ routeLinks: true })
308 | .use('/start', ({ resolve }) => resolve())
309 | .use('/linked/:to', ({ params, resolve }) => {
310 | ++called
311 | assert.fail('should not route due to default prevented')
312 | resolve()
313 | })
314 |
315 | history.replaceState(null, document.title, '/start')
316 | await router.start()
317 |
318 | clickTo('/linked/location', true)
319 | await router.routing
320 |
321 | router.stop()
322 | assert.strictEqual(called, 0, 'matching route should not be called')
323 | })
324 |
325 | it('ignores links that have a target attribute', async () => {
326 | let called = 0
327 | let clickCalled = 0
328 | let router = new Router()
329 | .use('/start', ({ resolve }) => resolve())
330 | .use('/linked/:to', ({ params, resolve }) => {
331 | ++called
332 | assert.fail('should not route due to target attribute')
333 | })
334 |
335 | history.replaceState(null, document.title, '/start')
336 | await router.start()
337 |
338 | // runs after router's listener
339 | window.addEventListener('click', function checkClick (event) {
340 | ++clickCalled
341 | event.preventDefault()
342 | window.removeEventListener('click', checkClick, false)
343 | }, false)
344 |
345 | clickTo('/linked/location', false, { target: '_blank' })
346 | await router.routing
347 | await new Promise(resolve => setTimeout(resolve, 10))
348 |
349 | router.stop()
350 | assert.strictEqual(called, 0, 'matching route should not be called')
351 | assert.strictEqual(clickCalled, 1, 'browser handler should be called')
352 | })
353 |
354 | it('ignores links that have a download attribute', async () => {
355 | let called = 0
356 | let clickCalled = 0
357 | let router = new Router({ hash: '#', routeLinks: true })
358 | .use('/start', ({ resolve }) => resolve())
359 | .use('/linked/:to', ({ params, resolve }) => {
360 | ++called
361 | assert.fail('should not route due to download attribute')
362 | })
363 |
364 | history.replaceState(null, document.title, '#/start')
365 | await router.start()
366 |
367 | // runs after router's listener
368 | window.addEventListener('click', function checkClick (event) {
369 | ++clickCalled
370 | event.preventDefault()
371 | window.removeEventListener('click', checkClick, false)
372 | }, false)
373 |
374 | clickTo('#/linked/location', false, { download: true })
375 | await router.routing
376 |
377 | router.stop()
378 | assert.strictEqual(called, 0, 'matching route should not be called')
379 | assert.strictEqual(clickCalled, 1, 'browser handler should be called')
380 | })
381 |
382 | it('ignores links that have a rel attribute', async () => {
383 | let called = 0
384 | let clickCalled = 0
385 | let router = new Router({ hash: '#', routeLinks: true })
386 | .use('/start', ({ resolve }) => resolve())
387 | .use('/linked/:to', ({ params, resolve }) => {
388 | ++called
389 | assert.fail('should not route due to target attribute')
390 | })
391 |
392 | history.replaceState(null, document.title, '#/start')
393 | await router.start()
394 |
395 | // runs after router's listener
396 | window.addEventListener('click', function checkClick (event) {
397 | ++clickCalled
398 | event.preventDefault()
399 | window.removeEventListener('click', checkClick, false)
400 | }, false)
401 |
402 | clickTo('#/linked/location', false, { rel: 'external' })
403 | await router.routing
404 |
405 | router.stop()
406 | assert.strictEqual(called, 0, 'matching route should not be called')
407 | assert.strictEqual(clickCalled, 1, 'browser handler should be called')
408 | })
409 | })
410 |
--------------------------------------------------------------------------------