Skip to content

Instantly share code, notes, and snippets.

@sc0ttj
Last active April 28, 2023 12:12
Show Gist options
  • Save sc0ttj/9c23f604f3a906b32f7d355a793312b1 to your computer and use it in GitHub Desktop.
Save sc0ttj/9c23f604f3a906b32f7d355a793312b1 to your computer and use it in GitHub Desktop.
Vanilla JS Router
/* 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
@sc0ttj
Copy link
Author

sc0ttj commented Mar 28, 2020

Changelog

  • Revision 2

    • new: works as router for HTTP server
    • new: adds a res.send() method.. similar API to express
      • sets appropriate 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
        • application/octet-stream - if given a Buffer
    • new: supports adding "middleware".. similar API to express
      • adds a router.use() method - takes a function that runs on each request
  • Revision 1

    • initial version

@sc0ttj
Copy link
Author

sc0ttj commented Mar 28, 2020

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)
    }
  })
}

@mk0y
Copy link

mk0y commented Apr 28, 2023

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