baconjs-router
Version:
Bacon.js page router
204 lines (167 loc) • 6.89 kB
JavaScript
import bacon from 'baconjs';
import pathToRegexp from 'path-to-regexp';
import tail from 'lodash/tail';
import noop from 'lodash/noop';
import isEqual from 'lodash/isEqual';
let pauseUpdating = false;
let historyBus;
/**
* baconRouter from intial baseUrl and initialPath, updating browser URL location and states automagically.
*
* Routes:
* - PathMatching String or Regex,
* - Called function (return stream),
* - PathMatching String or Regex,
* - Called function (return stream),
*
* should look like
* [
* '',
* () => bacon.later(0, {pageType: '404'}),
*
* /(.+)\/supercoach/,
* (matchId) => bacon.later(0, {matchId, pageType: 'supercoach'}),
* ]
*
* @param {String} baseUrl Base Path (to be ignored from URL.location)
* @param {String} initialPath Starting Path (should match one of your routes)
* @param {...Mixed} routesAndReturns (String|Regex, Function, n+) Route + Function to call on match
* @return {Observable} EventStream that returns your matched route stream per route.
*/
export default function baconRouter(baseUrl, initialPath, ...routesAndReturns) {
// @TODO BaseUrl, Initial Path, And RoutesAndReturns should be objects.
// Required next is '404' or missed routes perhaps. Currently the returned stream
// is just 'nothing', means we won't hit anything.
let hasBaconRouterBooted = false;
const historyBus = getBaconRouterHistoryBus();
const history = bacon.update(
{
location: baseUrl + '/' + initialPath,
state: null,
title: null
},
[historyBus], ((previous, newHistory) => newHistory)
).doAction(({state, title, location, shouldReplaceState}) => {
if (pauseUpdating || !process || !process.browser) {
return;
}
const thisHistory = { // For first render, history will have no values so take from the window
state,
title: title || window.document.title,
location: location || window.location.href
};
window.document.title = thisHistory.title;
if (hasBaconRouterBooted && shouldReplaceState) {
window.history.replaceState(thisHistory, title, location);
} else if (hasBaconRouterBooted) {
window.history.pushState(thisHistory, title, location);
} else {
window.history.replaceState(thisHistory, title);
hasBaconRouterBooted = true;
}
}).skipDuplicates(isEqual);
listenToPopState(historyBus);
return history.flatMapLatest((history) => {
let {location/*, state*/} = history; // eslint-disable-line spaced-comment
const currentRoute = location.replace(baseUrl, ''); // @TODO Less hacky.
const [, encodedPath = '', search = '', hash = ''] = /^([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/.exec(currentRoute);
let path;
try {
path = decodeURIComponent(encodedPath);
} catch (error) {
// URL path isn't valid
return new bacon.Error('Malformed URL');
}
let route, routeReturns;
// Because the routes and functions are 'paired', loop in increments of 2, first section is a route
// where the second section is the function to call and return.
for (let i = 0; i < routesAndReturns.length; i += 2) {
route = routesAndReturns[i];
routeReturns = routesAndReturns[i + 1];
if (typeof routeReturns !== 'function') {
throw `baconRouter: Unexpected input ${typeof routeReturns} at argument ${i}.
Format is <base>, <initialPath>, <route-match>, <route-response-function>, <route-match>...`;
}
if (typeof route === 'string') {
const keys = [];
const regexp = pathToRegexp(route, keys);
const matches = regexp.exec(path);
if (matches) {
const params = keys.reduce((acc, {name}, index) => Object.assign(acc, {[name]: matches[index + 1]}), {});
const query = search
.split('&')
.reduce((acc, search) => {
if (!search) {
return acc;
}
let key, value;
try {
[key, value] = search
.split('=', 2)
.map(decodeURIComponent);
} catch (error) {
// Ignore malformed query param
return acc;
}
return key ? Object.assign(acc, {[key]: value}) : acc;
}, {});
return routeReturns({params, query, hash});
}
} else if (route instanceof RegExp) {
const matches = route.exec(currentRoute);
if (matches) {
return routeReturns(...tail(matches)); // First item is the string that matched, not the capture groups.
}
} else {
throw 'baconRouter: Unknown route test method';
}
}
return bacon.never();
});
}
/**
* The bacon router history bus can be used to push locations into browser history
*
* @return {Observable} A bus which expects objects like {location, state, title}
*/
export function getBaconRouterHistoryBus() {
if (process && process.browser) {
if (!historyBus) {
historyBus = new bacon.Bus();
}
return historyBus;
} else {
// Always recreate the history bus for node.
return new bacon.Bus();
}
}
export function listenToPopState(historyBus) {
if (!process || !process.browser) {
return;
}
let originalOnPopState = window.onpopstate || noop;
let originalUnload = window.onbeforeunload || noop;
window.onpopstate = ((event) => {
// If a navigation attempt occurs other than via historyBus, reload the page at the new location.
if (!event.state) {
event.target.location.reload();
return;
}
const stateData = event.state;
pauseUpdating = true;
historyBus.push({
state: stateData.state,
title: stateData.title,
location: stateData.location
});
window.document.title = stateData.title || window.document.title;
setTimeout(() => {
pauseUpdating = false;
});
originalOnPopState(event);
});
window.onbeforeunload = (() => {
pauseUpdating = true;
originalUnload(arguments);
});
}