Last active
April 28, 2023 12:12
-
-
Save sc0ttj/9c23f604f3a906b32f7d355a793312b1 to your computer and use it in GitHub Desktop.
Vanilla JS Router
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* Vanilla JS router | |
* | |
* An isomorphic router - works client-side (browser), or server-side (NodeJS) | |
* | |
* It resolves URL paths like '/profile/1' to route patterns such as '/profile/:id', | |
* and generates a params object that is passed to each route. | |
* | |
* Features: | |
* | |
* - Easy setup, zero dependencies | |
* | |
* - Works client-side, in browsers: | |
* - as a router for single page applications (SPAs) | |
* | |
* - Works server-side, in Node: | |
* - as a router for an HTTP server (express-like API, also supports "middleware") | |
* - as a router for a command-line tool (accepts first arg as the URL/path) | |
* | |
* | |
* Basic usage: | |
* | |
* router({ | |
* | |
* '/profile': (params) => { ... }, | |
* | |
* '/profile/:id': (params) => { ... }, | |
* | |
* '/profile/:id/user/:uId': (params) => { ... }, | |
* | |
* }); | |
* | |
*/ | |
function router(routes, res, req) { | |
var urlPath | |
var isBrowser = | |
typeof window !== "undefined" && typeof window.document !== "undefined" | |
var isNode = | |
typeof process !== "undefined" && | |
process.versions !== null && | |
process.versions.node !== null | |
var isNodeServer = | |
isNode && typeof req !== "undefined" && typeof res !== "undefined" | |
if (isNodeServer) { | |
// get the URL requested in the HTTP request | |
if (typeof req != "undefined" && req.url) urlPath = "#" + req.url | |
} | |
if (isNode && !isNodeServer) { | |
// get the URL from the first argument passed to this script | |
if (process && process.argv) urlPath = "#" + process.argv[2] | |
} | |
if (isBrowser) { | |
// Get current URL path - everything after the domain name | |
urlPath = window.location.href.toString().split(window.location.host)[1] | |
} | |
var routeFromUrl = urlPath.split("#")[1] | |
var matchedRoute = false | |
// Parse URLs (Browser) ...adapted from https://vanillajstoolkit.com/helpers/router/ | |
var getParams = function(url) { | |
var params = {} | |
var parser | |
var query | |
if (isBrowser) { | |
parser = document.createElement("a") | |
parser.href = url | |
query = parser.search.substring(1) | |
} | |
if (isNodeServer) { | |
parser = {} | |
parser.href = req.url || request.url | |
query = parser.href.substring(1) | |
} | |
var vars = query + "&".replace("&&", "&").split("&") | |
for (var i = 0; i < vars.length; i++) { | |
var pair = vars[i].split("=") | |
if (pair[1] == undefined) continue | |
params[pair[0]] = decodeURIComponent(pair[1]) | |
} | |
return params | |
} | |
// generate object of params from a location hash, like "#/profile/1" | |
var getHashParams = function(url) { | |
var hashParams = {} | |
var hashString = url.split("#")[1] | |
if (hashString) { | |
hashString.split("&").map(pair => { | |
var key = pair.split("=")[0] | |
var val = pair.split("=")[1] | |
hashParams[key] = decodeURIComponent(val) | |
}) | |
} | |
return hashParams || {} | |
} | |
// Parse CLI args (Node as local program) | |
var getArgs = function() { | |
if (typeof process === "undefined") return {} | |
var args = {} | |
process.argv.slice(2, process.argv.length).forEach(function(arg) { | |
// long arg | |
if (arg.slice(0, 2) === "--") { | |
var longArg = arg.split("=") | |
var longArgFlag = longArg[0].slice(2, longArg[0].length) | |
var longArgValue = longArg.length > 1 ? longArg[1] : true | |
args[longArgFlag] = longArgValue | |
} | |
// flags | |
else if (arg[0] === "-") { | |
var flags = arg.slice(1, arg.length).split("") | |
flags.forEach(function(flag) { | |
args[flag] = true | |
}) | |
} | |
}) | |
return args || {} | |
} | |
// Converts "/page/:id/user/:id" to a regex, returns the regex | |
// (from Backbone.js, via https://gist.github.com/gcpantazis/5631831) | |
var routeToRegExp = function(routePattern) { | |
var optionalParam = /\((.*?)\)/g, | |
namedParam = /(\(\?)?:\w+/g, | |
splatParam = /\*\w+/g, | |
escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g | |
var route = routePattern | |
.replace(escapeRegExp, "\\$&") | |
.replace(optionalParam, "(?:$1)?") | |
.replace(namedParam, function(match, optional) { | |
return optional ? match : "([^/]+)" | |
}) | |
.replace(splatParam, "(.*?)") | |
return new RegExp("^" + route + "$") | |
//return new RegExp('^' + route); | |
} | |
// Checks if the given URL path matches the given route pattern, | |
// using regex produced by routeToRegExp | |
var urlMatchesRoute = function(path, routePattern) { | |
return !!path.match(routeToRegExp(routePattern)) | |
} | |
var routeToParams = function(routePattern, url) { | |
// get an array of the params in the route pattern [ ":id", ":tabId", ... ] | |
var routeParams = routePattern | |
.split("/") | |
.filter(i => i.replace(":", "") !== i) | |
// get an array the parameters from hashed part of the URL (/profile/1/tab/3) | |
var urlParamsArr = url | |
.split("#")[1] | |
.split("/") | |
.slice(1) | |
// map the keys from the route pattern to the values from the URL | |
params = {} | |
routeParams.map((key, i) => { | |
params[key.replace(":", "")] = urlParamsArr[i + i + 1] | |
}) | |
return params | |
} | |
// converts "#/profile/1" to "/profile/:id" | |
router.getRoutePatternFromUrl = function(url) { | |
var pattern | |
var urlPath = | |
url || | |
res.url || | |
window.location.href.toString().split(window.location.host)[1] | |
var routeFromUrl = urlPath.split("#")[1] | |
Object.keys(routes).forEach(routePattern => { | |
if (!urlMatchesRoute(routeFromUrl, routePattern)) return | |
pattern = routePattern | |
}) | |
return pattern | |
} | |
// takes a URL (hash path), loads the correct route, | |
// passing in the resolved parameters | |
router.href = function(path) { | |
// if we're parsing URLs (browser): | |
var url = path.replace("#", "") || "/home" | |
// get routePattern from URL | |
var routePattern = router.getRoutePatternFromUrl("#" + url) | |
// Combine all our params: | |
// - hash params override query strings | |
// - query strings override http requests | |
// - http requests override cli arguments | |
// - cli arguments override default settings, defined in script | |
var params = { | |
...getArgs(), | |
...getParams(urlPath), | |
...routeToParams(routePattern, "#" + url) | |
} | |
// load the function for this route, passing in all params | |
routes[routePattern](params) | |
location.hash = "#" + url | |
} | |
// on router init, load the correct route, | |
// matched against the current URL path | |
Object.keys(routes).forEach(routePattern => { | |
if (!urlMatchesRoute(routeFromUrl, routePattern)) return | |
if (matchedRoute) return | |
matchedRoute = true | |
// Combine all our params: | |
// - hash params override query strings | |
// - query strings override http requests | |
// - http requests override cli arguments | |
// - cli arguments override default settings, defined in script | |
var params = { | |
...getArgs(), | |
...getParams(urlPath), | |
...routeToParams(routePattern, urlPath) | |
} | |
// if Node server (processing HTTP requests - res, req), | |
// create a new method to be used inside the routes, | |
// based on express - res.send() | |
if (isNodeServer) { | |
// load "middleware" - just functions that execute with each request | |
var opts = {} | |
router.middleware.forEach(func => func(res, req, params, opts)) | |
// set a default status code | |
res.statusCode = 200 | |
res.status = function(status) { | |
res.statusCode = status | |
} | |
// res.send() - an express-like method that simplifies HTTP requests. | |
// It is just a wrapper around res.status(), res.writeHead(), | |
// res.write() and res.end(). | |
res.send = function(content) { | |
// - set appropriate header status to 200 (if res.status not used) | |
// - set appropriate content type: | |
// * text/html - if given a string | |
// * application/json - if given an object, array or JSON | |
// * application/octet-stream - if given a Buffer | |
var contentType = "text/html" | |
var c = typeof content | |
if (c === "object" || c === "array") { | |
contentType = "application/json" | |
// * auto pretty prints JSON output | |
content = JSON.stringify(content, null, 2) | |
} else if (c === "buffer") { | |
contentType = "application/octet-stream" | |
} | |
// add params to res | |
res.params = res.params ? { ...res.params, ...params } : params | |
// write the header | |
res.writeHead(res.statusCode, { "Content-Type": contentType }) | |
// the content to return | |
res.write(content) | |
// end the response | |
res.end() | |
} | |
} | |
// load the function for this route, passing in all params | |
routes[routePattern](params) | |
if (isNodeServer) res.end() | |
}) | |
} | |
router.middleware = [] | |
// lets user register functions as middleware | |
router.use = function(fn) { | |
router.middleware.push(fn) | |
} | |
// ------------------------------------------------------------------------ | |
module.exports = router |
Usage
Client-side router (in browser)
<!DOCTYPE html>
<html>
<head>Router example</head>
<body>
<p>Try and replace me when the router runs!</p>
<script src="path/to/router.umd.js"></script>
<script>
router({
"/home": () => console.log("HOME PAGE"),
"/profile/:id/tab/:tabId": params =>
console.log(`Profile id: ${params.id}`),
"/profile/:id": params => {
console.log("PROFILE PAGE", params)
}
})
// How to bind the router to URL changes in the browser:
// 1. capture link clicks for links such as <a href="#/profile/1">some text</a>
window.addEventListener(
"click",
function handleLink(e) {
if (!e.target.matches("a")) return
if (!e.target.href.matches(/^#/)) return
e.preventDefault()
location.hash = e.target.hash
},
false
)
// 2. monitor changes to window.location.hash,
// run the router when it changes
window.addEventListener(
"hashchange",
function(e) {
router.href(window.location.hash)
},
false
)
</script>
</body>
</html>
HTTP web server router (NodeJS)
// Router usage: NodeJS-based web server
//
// BACKGROUND INFO:
//
// NodeJS 'http' module provides:
//
// res.write() : Writes a response to the client and can be called multiple times.
// res.end() : Ends the response process.
//
// router() adds:
//
// res.send() : A convenience wrapper around res.write() and res.end(), called once (like express).
//
// See http://zetcode.com/javascript/http/
var http = require("http")
var router = require("../src/router.js")
// start the server on given port, then run cb()
http
.createServer((req, res) => {
//
router(
{
"/home": params => {
console.log("home!", params)
// set header to "200, text/html", set content, end the response
res.send("<p>some string</p>")
},
"/user/:userId": params => {
// * router added req.params to the params received here
// * router provides res.send() and res.status()
// * res.status() : set the header status (optional)
// * res.send() :
// - sets header status to 200 (if res.status not used)
// - sets appropriate content type:
// * text/html - if given a string
// * application/json - if given an object, array or JSON (as below)
// * application/octet-stream - if given a Buffer
// - sanitises the content:
// * auto pretty prints JSON output
// - ends the response
res.status(200)
res.send(params)
}
},
// for servers, you must pass in 'res' and 'req' after the routes object
res,
req
)
})
.listen("8181")
//
// ----------------------- Middleware examples -------------------------
//
// OPTIONAL HTTP Router usage: Middleware
//
// The "middleware" is a function that receives res, req,
// and is executed on each route match
//
// Define some middleware as a function
var getRequestTime = function(res, req) {
req.time = Date.now()
console.log("middleware: added req.time: ", req.time)
}
// and just pass the middleware function to router.use()
router.use(getRequestTime)
//
// OPTIONAL HTTP Router usage: Configurable middleware
//
// wrap your middleware in a function that takes
// opts, and returns your middleware
function configurableMiddleware(opts) {
// do middleware config stuff here
// then return the "middleware" function
return function theActualMiddleware(res, req) {
params = { ...params, ...opts }
console.log("middleware: added to params: ", params)
}
}
// pass the middleware function to router.use(), with your options
router.use(configurableMiddleware({ foo: "bar" }))
//
// ------------- @Todo Ad-hoc routing examples --------------
//
// OPTIONAL HTTP Router usage: Ad-hoc routing
//
// - like `journey`, `director` or `express`.
// - pass a path as a string to route.get()
// - the data from the path is available to the given function, in `params`
//router.get("/foo/1", someFunc) // NOT READY!
// where..
var someFunc = params => {
// params contains data from the path passed to router.get(),
// returns some HTML, JSON, etc
}
CLI args routing (NodeJS)
// Router usage: Node as a local command-line tool
// simply define routes in the main script of your command-line tool
// and pass them in as the first option:
//
// Example usage:
//
// node examples/cli-router.js /profile/1 --foo=bar
//
// Example File "myprogram.js":
var router = require("../src/router.js")
if (typeof process !== "undefined" && process.argv) {
// make sure we're running in Node
router({
// 'params' will contain all command-line arguments
// that were passed to this script
"/profile/:id": params => {
console.log(params)
}
})
}
It doesn't work when calling /home?param=1
otherwise nice work :)
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Changelog
Revision 2
res.send()
method.. similar API to expressrouter.use()
method - takes a function that runs on each requestRevision 1