├── .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 | 2 | 8 | 14 | middle 15 | router 16 | 17 | 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 | [![Greenkeeper badge](https://badges.greenkeeper.io/vanwagonet/middle-router.svg)](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 | --------------------------------------------------------------------------------