Skip to content

Instantly share code, notes, and snippets.

@mattmccray
Last active October 16, 2022 17:14
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mattmccray/c022024012861e294444c1f3f0d371c5 to your computer and use it in GitHub Desktop.
Save mattmccray/c022024012861e294444c1f3f0d371c5 to your computer and use it in GitHub Desktop.
A zero dependency, tightly focused, micro router.
interface PathProvider {
getPath(): string
setPath(path: string): void
onPathChange(callback: (path: string) => void): void
}
interface CurrentMatch {
path: string
route: Route | null
params: Record<string, string>
}
interface CurrentMatchHandler {
(info: CurrentMatch): void
}
const NOOP = (params: any) => { }
export class Router {
protected routes = new Map<string, Route>()
protected provider?: PathProvider
protected lastMatchState: string | null = null
protected changeListeners = new Set<CurrentMatchHandler>()
current: CurrentMatch = {
path: '',
route: null,
params: {},
}
define = (name: string, path: string, callback: (params: Record<string, string>) => void = NOOP) => {
assert(!this.routes.has(name), "[Router] Name %s is already defined.", name)
this.routes.set(name, new Route(name, path, callback))
return this
}
resolve(name: string, params: Record<string, any> = {}) {
const route = this.routes.get(name)
assert(!!route, "[Router] Route named %s not found.", name)
return route!.resolve(params)
}
process = (path: any) => {
for (let route of this.routes.values()) {
const params = route.match(path)
if (params !== null) {
const paramsJson = JSON.stringify(params)
if (paramsJson == this.lastMatchState) {
return
}
this.current = {
path, route, params
}
route.callback(params)
this.lastMatchState = paramsJson
this.changeListeners.forEach(cb => cb(this.current))
return true
}
}
return false
}
navigate(name: string, params: Record<string, any> = {}) {
const path = this.resolve(name, params)
this.navigateToPath(path)
}
navigateToPath(path: string) {
this.provider?.setPath(path)
}
onChange(callback: CurrentMatchHandler) {
this.changeListeners.add(callback)
return () => {
this.changeListeners.delete(callback)
}
}
clearListeners() { this.changeListeners.clear() }
start(provider: PathProvider = new HashPathProvider()) {
this.provider = provider
this.process(provider.getPath())
provider.onPathChange(this.process)
}
}
export class HashPathProvider implements PathProvider {
getPath(): string {
return String(document.location.hash || "#/").slice(1)
}
setPath(path: string): void {
document.location.hash = '#' + path
}
onPathChange(callback: (path: string) => void) {
window.addEventListener('hashchange', e => {
const path = this.getPath()
callback(path)
})
}
}
class Route {
namedParameters!: string[]
protected reString!: string
protected regexp!: RegExp
constructor(
public name: string,
public definition: string,
public callback: (params: Record<string, string>) => void
) {
this._parseDefinition()
}
resolve(params: Record<string, any> = {}) {
const missing = this.namedParameters.filter(name => !Reflect.has(params, name))
assert(missing.length == 0, "[Route] Missing parameters for route %s: '%s'", this.name, missing.join("', '"))
let path = this.definition.
replace(/\/:\w+\?/g, (i: any) => {
let param = params[i.slice(2).slice(0, -1)]
if (param) {
return '/' + encodeURIComponent(param)
} else {
return ''
}
})
.replace(/\/:\w+/g, i => '/' + encodeURIComponent(params[i.slice(2)]))
const extraKeys = Reflect
.ownKeys(params)
.map(String)
.filter((key) => !this.namedParameters.includes(key))
if (extraKeys.length > 0) {
path += '?' + extraKeys.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join("&")
}
return path
}
match(targetPath: string): Record<string, string> | null {
let [path, query] = targetPath.split('?')
const match = path.match(this.regexp)
if (!match) return null
const matches = match.slice(1)
const params = matches.reduce((params, match, index) => {
params[this.namedParameters[index]] = decodeURIComponent(match)
return params
}, {} as Record<string, string>)
if (!!query) query.split('&').forEach(pair => {
const [name, value] = pair.split('=')
params[decodeURIComponent(name)] = decodeURIComponent(value)
})
return params
}
protected _parseDefinition() {
let value = this.definition.replace(/\/$/g, '') || '/'
this.namedParameters = (value.match(/\/:\w+/g) || []).map(i => i.slice(2))
this.reString = value
.replace(/[\s!#$()+,.:<=?[\\\]^{|}]/g, '\\$&')
.replace(/\/\\:\w+\\\?/g, '/?([^/]*)')
.replace(/\/\\:\w+/g, '/([^/]+)')
this.regexp = RegExp('^' + this.reString + '$', 'i')
}
}
export function assert(condition: boolean, format?: string, a?: any, b?: any, c?: any, d?: any, e?: any, f?: any) {
if (!condition) {
let error: Error
if (format === undefined) { // TODO: if you are using a build tool, an env=='development' check here would be good.
error = new Error("Unexpected state detected. (Use dev version for full error messages)")
}
else {
var args = [a, b, c, d, e, f]
var argIndex = 0
error = new AssertError(
format.replace(/%s/g, function () { return args[argIndex++] })
)
error.name = 'Assertion Failure'
}
//@ts-ignore
error.framesToPop = 1
throw error
}
}
export class AssertError extends Error { }
@mattmccray
Copy link
Author

mattmccray commented Oct 16, 2022

I use this in SPA projects (thus only shipping with HashPathProvider).

Example usage:

import { signal } from '@preact/signals'

const currentView = signal({
  view: () => null,
  props: {},
})

const router = new Router()

router.define('home', '/', () => {
  currentView.value = {
    view: HomeScreen, // Defined elsewhere
    props: {}
  }
})

router.define('user', '/user/:id', (params) => {
  currentView.value = {
    view: UserScreen, // Defined elsewhere
    props: params
  }
})

router.start()

// Elsewhere...

function App() {
  const { props, view: View } = currentView.value
  return (
    <div>
      <View {...props} />
    </div>
  )
}

You can create link paths using resolve.

router.resolve('user', { id: 'whatever' }) //=> "/user/whatever"

// Extra params are added to as query params:
router.resolve('user', { id: 'bob', section:'email' }) //=> "/user/bob?section=email"

// Query string params, by the way, are merged into the route handler params object.
router.define('user', '/user/:id', (params) => {
  // Given the path /user/bob?section=email params would look like:
  // { id: "bob", section: "email" }
})

// If you try to resolve a path without all the named parameters, an assertion failure will be thrown...
router.resolve('user') //=> AssertError(`[Route] Missing parameters for route user: 'id')

If you care about every byte.... You can remove the assertion checks and function to get it to a 1.0 KB min/gzip size.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment