npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

isomorphic-app-router

v0.3.0

Published

History-free and dependency-free isomorphic routing library in vanilla JS

Downloads

154

Readme

isomorphic-app-router

Super tiny (1.56kB) dependency-free vanilla JS routing library to represent your routing states in pure JS rather than framework-based stuff.

Because it is framework-agnostic, it can be adapted to every framework.

Recipes

Top-Level Routes

import { RouterBuilder } from '<repo>/library/router'

type YourRoute =
  | { name: 'Home' }
  | { name: 'Product' }
  | null // for not found. You could also provide { name: 'NotFound' }

const router = RouterBuilder<YourRoute>() // let you be guided by the types ;)
  .set('home', '/', () => ({ name: 'Home' }))
  .set('product', '/product/:id', ({ params }) => {
    const id = Number(params.id) // params: { id: string } <- inferred from the path.

    return Number.isNaN(id)
      ? null // not found
      : { name: 'Product', id }
  })
  .or(() => null) // required _at the end_

router.route // YourRoute, the active route
router.onChange((nextRoute, previousRoute) => {})

router.linkTo.home() // parameter-less path, no arg required
router.linkTo.product({ id: '2' }) // TS forcefully asks for the route parameters

Nested Routes

Let’s take the same example as before and add it a base path:

type YourRoute = …
const router = RouterBuilder<YourRoute>()
  .withBasePath('/:locale') // must be provided _first_
  .set('home', '/', ({ params }) => {
    params // { locale: string } <- basePath params also get inferred
  })
  .set('product', '/product/:id', ({ params }) => {
    params // { locale: string, id: string } <- basePath _and_ path params get inferred
  })
  .or(…)

router.linkTo.home({ locale: 'fr' }) // basePath params are also required
router.linkTo.product({ locale: 'fr', id: '2' })

Path Syntax

I based the library on web standards, namely URLPattern. Which itself is based its syntax on path-to-regexp. Therefore, their syntax prevails.

The MDN website is an excellent place to start. Here are a few tips though:

  • /post/* will match /post/, /post/1 & /post/1/2 ; but not /post :warning: To match /post => post{/*}?
  • /post{/:id}? matches /post & /post/1, not /post/1/2
  • Regex groups like /books/(\\d+) can be used but break intellisense of path parameters
  • For nested routers, type the home as {/}? :wink:

Providing a router-level route comparator

It overrides completely a potential global route comparator.

type YourRoute = { name: 'Home' } | { name: 'Product' }

const router = RouterBuilder<YourRoute>()
  .withBasePath('…') // optional
  .compareWith((a, b) => a.name === b.name)
  // (a: YourRoute, b: YourRoute) => boolean

Overriding the history for one router

import { createMemoryHistory } from 'history'

const historyForMyTabs = createMemoryHistory()
const router = RouterBuilder<SomeRoute>(historyForMyTabs) // <- tada

Enforcing route shapes

You can force a general route shape. This is useful to force a stable discriminant. Let’s say our discriminant is "name":

type RouteShape = { name: string }

// See installation steps for more details on `RouterBuilderFactory`

export const RouterBuilder = RouterBuilderFactory<RouteShape>({…})
  // (a: YourRoute, b: YourRoute) => boolean

Providing a global route comparator

This allows you to provide a generic equality function, like _.isEqual or some hash-equality function.

type RouteShape = { name: string, id?: unknown }

// See installation steps for more details on `RouterBuilderFactory`

export const RouterBuilder = RouterBuilderFactory<RouteShape>({
  // (a: RouteShape, b: RouteShape) => boolean
  compare: (a, b) => a.name === b.name && a?.id === b?.id,
  compare: _.isEqual,
  compare: hashEqual,
})

Convinced? Let’s install

With history

With URLPattern resolver

URLPattern is a great native API that still hasn’t reached baseline at the time writing, therefore you’ll need to use polyfill.

npm i -S isomorphic-app-router history urlpattern-polyfill
// <repo>/library/router.ts
import { RouterBuilderFactory, URLPatternResolver } from 'isomorphic-app-router'
import { createBrowserHistory } from 'history'
import "urlpattern-polyfill"

export const history = createBrowserHistory() // to make and export elsewhere.

export const RouterBuilder = RouterBuilderFactory({
  history,
  resolver: URLPatternResolver,
})

With PathToRegex resolver

path-to-regexp is a great tool, URLPattern syntax is actually based on path-to-regexp. It is more lightweight than urlpattern-polyfill but is no web standard.

npm i -S isomorphic-app-router history path-to-regexp
// <repo>/library/router.ts
import { RouterBuilderFactory, PathToRegexpResolver } from 'isomorphic-app-router'
import { createBrowserHistory } from 'history'
import { match } from 'path-to-regexp'

export const history = createBrowserHistory() // to make and export elsewhere.

export const RouterBuilder = RouterBuilderFactory({
  history,
  resolver: PathToRegexpResolver(match),
})

With a custom history

// <repo>/library/history.ts
export const myHistory = {
  pathname: '/',
  addListener: (listener) => {},
  removeListener: (listener) => {},
  push: (newPath) => {…},
  // …
}

// <repo>/library/router.ts
import { RouterBuilderFactory, YourResolver } from 'isomorphic-app-router'
import { myHistory } from '<repo>/library/history'

export const RouterBuilder = RouterBuilderFactory({
  resolver: YourResolver,
  history: {
    location: {
      get pathname() {
        return myHistory.pathname
      }
    }
    listen: (listener) => {
      myHistory.addListener(listener)
      const cleanup = () => myHistory.removeListener(listener)
      return cleanup
    }
  }
})

Framework integrations

React Hook

const useRouter = <R extends Route<any, any>>(router: R) => {
  const [route, setRoute] = useState(router.route)
  useEffect(() => {
    const unsubscribe = router.onChange((newRoute) => {
      setRoute(newRoute)
    })
    return unsubscribe
  }, [router])
  return route
}

Svelte

import { readable } from 'svelte'

const RouterToSvelteStore = <R extends Router<any, any>>(router: R) => {
  return readable(router.route, (set) => {
    const unsubscribe = router.onChange((newRoute) => {
      set(newRoute)
    })
    return unsubscribe
  })
}

Other framework

I am sure you will find a way to make it work.

Reference

Router

type Unsubscribe = () => void

export interface Router<Route, PathByName extends Record<string, string>> {
  /**
   * The current active route
   */
  route: Route
  /**
   * A helper to build links based on the provided path patterns and route name
   * @example
   * const router = RouterBuilder().set('home', '/', () => {…}).
   * router.linkTo // { home: () => string }
   */
  linkTo: { [Name in keyof PathByName]: LinkTo<PathByName[Name]> },
  /**
   * Gets triggered when the active route changed and is different than the previous one
   * according to an optionally provided `isSameRoute`.
   * @example
   * const router = RouterBuilder<Route>().set('home', '/', () => {…})
   * router.onChange((newRoute, previousRoute) => {…})
   */
  onChange: (
    listener: (newRoute: Route, previousRoute: Route) => unknown,
  ) => Unsubscribe // () => void
  /**
   * Removes all listeners, notably to history.
   * Particularly useful for nested routers.
   * @example
   * const router = RouterBuilder<Route>().set('home', '/', () => {…})
   * 
   * const cleanup = () => {
   *   // …
   *   router.destroy()
   * }
   */
  destroy: () => void
}

RouteData<Path>

What is injected in the route handler.

export interface RouteData<BasePath extends string, Path extends string> {
  params: PathParameters<`${BasePath}${Path}`>
  pathname: `${BasePath}${Path}`
}

const router = RouterBuilder<MyRoute>()
  .withBasePath('/:locale')
  .set('home', '{/}?': (data: RouteData<'/:locale{/}?'>) => {
    data // { params: { locale: string }, pathname: '/:locale{/}?' }
  })
  .set('product', '/product/:id': (data: RouteData<'/:locale/product/:id'>) => {
    data // { params: { locale: string, id: string }, pathname: '/:locale/product/:id' }
  })
  .or(() => ({ name: 'NotFound' }))

RouteBuilder

Implementation of the builder pattern to output a Router.

export interface RouterBuilder<
  Route,
  BasePath extends string,
  PathByName extends Record<string, string>,
> {
  withBasePath: <BasePath extends string>(
    basePath: BasePath,
  ) => RouterBuilder<Route, BasePath, PathByName>

  compareWith: (
    compare: (a: Route, b: Route) => boolean,
  ) => Omit<RouterBuilder<Route, BasePath, PathByName>, "withBasePath">

  set: <Name extends string, Path extends string>(
    name: Exclude<Name, keyof PathByName>,
    path: Path,
    handler: (data: RouteData<`${BasePath}${Path}`>) => Route,
  ) => Omit<
    RouterBuilder<
      Route,
      BasePath,
      PathByName & { [Key in Name]: `${BasePath}${Path}` }
    >,
    "withBasePath" | "isSame"
  >

  or: (handler: () => Route) => Router<Route, PathByName>
}

Why yet-another X ?

Because I never encountered one that made sense to me:

[!Important] Routing and history are separate concerns.

A history can be unique or cascaded across the client-side app, it should not impact routing.

My opinion: use one history per app.

You want routing? Fine: provide the history to watch changes, you'll get the active route in return.

You want some nested routing? Perfect, provide the history and a base path, you'll get the active route in return.

You want to mix browser, hash and/or memory routing? Fine: provide a different history per-router.

All in pure JS, testable with no framework, adaptable to every framework. Testable: No jsdom needed, no {your framework}-library, no nothing. Aim at that 3ms test 😉.

Fully type-safe and type-driven for mad-typers. It comes with a double-function cost, but still worth it! Now you have the treat of typed path parameters :stuck_out_tongue:

Contributing

Any contribution is welcome, fork and PR :grin:

# clone the repo, then
npm ci
npm run test