├── .agignore ├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── package.json ├── router.js └── test ├── harness.js ├── index.html ├── lazy-app.js ├── main-app.js ├── router.spec.js └── server.js /.agignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .git 4 | Chrome* 5 | *swp 6 | .nyc_output 7 | .sauce-cred 8 | .zuulrc 9 | coverage.json 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*js] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | Chrome 4 | Chrome* 5 | *swp 6 | coverage 7 | .nyc_output 8 | sauce_connect.log 9 | .sauce-cred 10 | .sauce-credentials.json 11 | .zuulrc 12 | coverage.json 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/* 2 | .gitignore 3 | .editorconfig 4 | .agignore 5 | .npmignore 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | script: npm test 3 | node_js: 4 | - node 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Todd Kennedy & Scriptol LLC 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License.` 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lazy-router 2 | 3 | [![Greenkeeper badge](https://badges.greenkeeper.io/scriptoLLC/lazy-router.svg)](https://greenkeeper.io/) 4 | [![Build Status](https://travis-ci.org/scriptoLLC/lazy-router.svg?branch=master)](https://travis-ci.org/scriptoLLC/lazy-router) 5 | 6 | A client-side router based on [wayfarer](https://github.com/yoshuawyuts/wayfarer) that allows for lazy-loading of client bundles for routes that might not yet be available. 7 | 8 | _n.b._ despite the use of ES6 syntax in the examples and tests, the router itself is implemented in ES3 (as a CommonJS module). 9 | 10 | ## Usage 11 | main.js: 12 | ```js 13 | const lr = require('lazy-router') 14 | const defaultAction = (path, routeObj, state) => render404() 15 | const resolver = (routeObj) => routeObj.pathname + '/bundle.js' 16 | lr('/404', {defaultAction, resolver}) 17 | lr.on('/my-route', (pathname, routeObj) => console.log(pathname, routeObj)) 18 | lr.push('/not-loaded-yet') 19 | ``` 20 | 21 | not-loaded-yet.js: 22 | ```js 23 | const lr = require('lazy-router') 24 | lr('/404') // arguments in subsequent calls are ignored after the first call 25 | lr.on('/not-loaded-yet', (pathname, routeObj) => console.log('i got lazy loaded')) 26 | ``` 27 | 28 | So long as `not-loaded-yet.js` is served from `/not-loaded-yet/not-loaded-yet.bundle.js`, when 29 | `main.js` receieves a request for `/not-loaded-yet`, it'll load the bundle and then 30 | trigger the route when the bundle is loaded. 31 | 32 | 33 | ## API 34 | #### `const lr = lazyRouter(defaultRoute?: string, opts?: {defaultAction?: (pathname: string, Location: object, state: object) -> void, resolver: (Location: object) -> string}) -> router` 35 | Create or get the current router instance. 36 | 37 | The first time this method is called you must supply the `defaultRoute` argument. 38 | 39 | You can supply a default renderer (if you know that the function will always be 40 | available) via the options hash property `defaultAction`. The arguments are the same 41 | as for a normal route. 42 | 43 | You can also override the default bundle resolver by supplying your own. By default 44 | `lazy-router` will attempt to find the bundle at: `route.pathname + '/bundle.js'`. 45 | Your function should be supplied via the `resolver` property and receives the `Location` object 46 | as it's argument. It should return a string. 47 | 48 | All arguments are ignored on subsequent calls, which will return the instantiated 49 | router. 50 | 51 | #### `lr.on(pathname: string, (pathname: string, Location: object, state: object) -> void) -> void` 52 | Attach a route to the router, and provide a function for calling that route. The 53 | router uses [pathname-match](https://github.com/yoshuawyuts/pathname-match) to handle 54 | matching, so any query string, hash, etc on the route will be ignored. 55 | 56 | The callback receives three parameters: 57 | * the pathname that was used for the match, 58 | * a [Location](https://developer.mozilla.org/en-US/docs/Web/API/Location) object and 59 | * the `state` provided (if any) to the `push` or `replace` method 60 | 61 | #### `lr.push(path: string, state: object)` 62 | Push a new URL into the browser history and invoke the handler for this route. 63 | If the route is not available in the current router, this will attempt to 64 | load a new bundle that contains a handler for the route. If this fails, the 65 | client will attempt to load the route defined in `defaultRoute` from the server. 66 | 67 | #### `lr.replace(path: string, state: object)` 68 | Replace the current URL with the new URL provided and invoke the handler for that 69 | route. The same loading rules apply as for `push` 70 | 71 | ## Singleton 72 | This module attempts to make itself a singleton by attaching its instance to the 73 | `window` object, and then returning that if it exists. This means no matter 74 | what you're using for bundling, splitting, etc, you should always get a single 75 | instance of this router (so long as the code is all running in the same window) 76 | 77 | ## TODO 78 | * Figure out coverage for the iframe 79 | 80 | ## License 81 | Copyright © 2017 Scripto, LLC. Apache-2.0 licensed. 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lazy-router", 3 | "version": "1.0.1", 4 | "description": "a lazy-loading router for client applications", 5 | "main": "router.js", 6 | "scripts": { 7 | "dep": "dependency-check . && dependency-check . --no-dev --unused", 8 | "test": "standard && npm run dep && node test/harness.js test", 9 | "gen-cover": "node test/harness.js cover", 10 | "cover": "standard && npm run dep && npm run gen-cover && istanbul report" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/scriptoLLC/lazy-router.git" 15 | }, 16 | "keywords": [ 17 | "router", 18 | "history", 19 | "client", 20 | "client-router", 21 | "react", 22 | "react-router" 23 | ], 24 | "author": "Todd Kennedy ", 25 | "license": "Apache-2.0", 26 | "bugs": { 27 | "url": "https://github.com/scriptoLLC/lazy-router/issues" 28 | }, 29 | "homepage": "https://github.com/scriptoLLC/lazy-router#readme", 30 | "dependencies": { 31 | "global": "^4.3.1", 32 | "load-script": "^1.0.0", 33 | "pathname-match": "^1.2.0", 34 | "wayfarer": "^6.3.0" 35 | }, 36 | "devDependencies": { 37 | "browserify": "^14.4.0", 38 | "browserify-istanbul": "^2.0.0", 39 | "dependency-check": "^2.7.0", 40 | "istanbul": "^0.4.5", 41 | "standard": "^10.0.2", 42 | "tape": "^4.6.3", 43 | "tape-istanbul": "^1.0.4", 44 | "tape-run": "^3.0.0", 45 | "uglifyify": "^4.0.1", 46 | "uglifyjs": "^2.4.10" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /router.js: -------------------------------------------------------------------------------- 1 | var wayfarer = require('wayfarer') 2 | var match = require('pathname-match') 3 | var loadScript = require('load-script') 4 | var root = require('global') 5 | 6 | var router 7 | var cache = {} 8 | var dft = function () {} 9 | 10 | var bundleResolve = function (routeObj) { 11 | var appPath = match(routeObj.pathname) 12 | var bundle = appPath + '/bundle.js' 13 | return bundle 14 | } 15 | 16 | function render (routeObj, opts) { 17 | router(match(routeObj.pathname), routeObj, opts) 18 | } 19 | 20 | function loadRoute (defaultParams, routeObj) { 21 | var bundle = bundleResolve(routeObj) 22 | if (!cache[bundle]) { 23 | cache[bundle] = true 24 | loadScript(bundle, function (err) { 25 | if (err) { 26 | return dft() 27 | } 28 | render(routeObj) 29 | }) 30 | } else { 31 | dft() 32 | } 33 | } 34 | 35 | function lazyRouter (defaultRoute, opts) { 36 | if (!router) { 37 | opts = opts || {} 38 | if (typeof defaultRoute !== 'string') { 39 | throw new Error('You must supply a default route') 40 | } 41 | 42 | if (typeof opts.defaultAction === 'function') { 43 | dft = opts.defaultAction 44 | } 45 | 46 | if (typeof opts.resolver === 'function') { 47 | bundleResolve = opts.resolver 48 | } 49 | 50 | root.onpopstate = function (evt) { 51 | render(document.location, evt.state || {}) 52 | } 53 | 54 | router = wayfarer(defaultRoute) 55 | router.on(defaultRoute, loadRoute) 56 | 57 | router.push = function (route, opts) { 58 | opts = opts || {} 59 | var routeObj = document.createElement('a') 60 | routeObj.href = route 61 | root.history.pushState(opts, '', route) 62 | render(routeObj, opts) 63 | } 64 | 65 | router.replace = function (route, opts) { 66 | opts = opts || {} 67 | var routeObj = document.createElement('a') 68 | routeObj.href = route 69 | root.history.replaceState(opts, '', route) 70 | render(routeObj, opts) 71 | } 72 | 73 | root.__router = router 74 | } 75 | return router 76 | } 77 | 78 | module.exports = lazyRouter 79 | -------------------------------------------------------------------------------- /test/harness.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const fork = require('child_process').fork 3 | const path = require('path') 4 | 5 | const browserify = require('browserify') 6 | const run = require('tape-run') 7 | const cover = require('tape-istanbul') 8 | 9 | const server = fork(path.join(process.cwd(), 'test', 'server')) 10 | 11 | server.on('message', runTests) 12 | 13 | function killServer () { 14 | server.kill() 15 | server.disconnect() 16 | } 17 | 18 | function runTests () { 19 | const b = browserify(path.join(process.cwd(), 'test', 'router.spec.js')) 20 | let out = process.stdout 21 | if (process.argv[2] === 'cover') { 22 | b.plugin('tape-istanbul/plugin') 23 | out = cover() 24 | } 25 | b.bundle() 26 | .pipe(run()) 27 | .on('error', (err) => { 28 | console.log(err) 29 | killServer() 30 | process.exit(1) 31 | }) 32 | .on('end', killServer) 33 | .pipe(out) 34 | } 35 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | test 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /test/lazy-app.js: -------------------------------------------------------------------------------- 1 | const lr = require('router') 2 | const router = lr() 3 | router.on('/lazy-load', () => { 4 | document.body.appendChild(document.createTextNode('I have loaded')) 5 | window.parent.postMessage('loaded1', '*') 6 | router.push('/beepboop') 7 | }) 8 | -------------------------------------------------------------------------------- /test/main-app.js: -------------------------------------------------------------------------------- 1 | const lr = require('router') 2 | const defaultAction = () => window.parent.postMessage('loaded2', '*') 3 | const router = lr('/404', {defaultAction}) 4 | router.push('/lazy-load') 5 | -------------------------------------------------------------------------------- /test/router.spec.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | const lazyRouter = require('../') 3 | 4 | test('router', (t) => { 5 | t.ok(typeof window.__router === 'undefined', 'no router') 6 | t.throws(() => { 7 | lazyRouter() 8 | }, 'no default route -> throws') 9 | const router = lazyRouter('/404') 10 | t.ok(typeof window.__router !== 'undefined', 'router available') 11 | t.ok(router === window.__router, 'router is attached to the window') 12 | const router2 = lazyRouter() 13 | t.ok(router === router2, 'is singleton') 14 | t.end() 15 | }) 16 | 17 | test('routing', (t) => { 18 | const frame = document.createElement('iframe') 19 | frame.src = 'http://localhost:8000' 20 | document.body.appendChild(frame) 21 | window.addEventListener('message', (evt) => { 22 | if (evt.data === 'loaded1') { 23 | t.ok(true, 'route loaded and fired') 24 | } else if (evt.data === 'loaded2') { 25 | t.ok(true, 'default route played') 26 | document.body.removeChild(frame) 27 | t.end() 28 | } 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | const http = require('http') 2 | const fs = require('fs') 3 | const path = require('path') 4 | 5 | const browserify = require('browserify') 6 | 7 | const server = http.createServer((req, res) => { 8 | console.log(`Serving ${req.url}`) 9 | if (req.url === '/' || req.url === '/index.html' || req.url === '/lazy-load') { 10 | return fs.createReadStream(path.join(process.cwd(), 'test', 'index.html')).pipe(res) 11 | } else if (req.url === '/router.bundle.js') { 12 | const b = browserify() 13 | b.require(path.join(process.cwd(), 'router'), {expose: 'router'}) 14 | .bundle() 15 | .pipe(res) 16 | } else if (req.url === '/main-app.bundle.js') { 17 | const b = browserify() 18 | b.add(path.join(process.cwd(), 'test', 'main-app')) 19 | .external('router') 20 | .bundle() 21 | .pipe(res) 22 | } else if (req.url === '/lazy-load/bundle.js') { 23 | const b = browserify() 24 | b.add(path.join(process.cwd(), 'test', 'lazy-app')) 25 | .external('router') 26 | .bundle() 27 | .pipe(res) 28 | } else { 29 | res.statusCode = 404 30 | res.end(`error: no handler for ${req.url}`) 31 | } 32 | }) 33 | 34 | server.listen(8000, () => { 35 | process.send && process.send('start') 36 | console.log('listening on http://localhost:8000') 37 | }) 38 | --------------------------------------------------------------------------------