superouter

superouter ----------

Usage no npm install needed!

<script type="module">
  import superouter from 'https://cdn.skypack.dev/superouter';
</script>

README

superouter

pipeline status coverage report

Quick Start

npm install superouter

const superouter = require('superouter')

const Route = 
    superouter.type('Route', {
        Home: '/',
        Post: '/posts/:post',
        Settings: '/settings/...rest'
    })

Route.Home() 
//=> { type: 'Route', tag: 'Home' }
Route.Post({ post }) 
//=> { type: 'Route', tag: 'Post', value: { post }}
Route.Settings({ rest: '/a/b/c' })
// => { type: 'Route', tag: 'Settings', value: { rest: '/a/b/c' } }

const view = 
    Route.fold(
        { Home: () => <Home/>
        , Post: ({ post }) => <Post post={post} />
        , Settings: ({ rest }) => <Settings subpath={rest} />
        }
    )

view ( Route.Home() ) //=> <Home />

Route.matchOr( () => Route.Home(), '/settings/1/2/3' )
//=> Route.Settings({ rest: '1/2/3' })

const renderRoute = () => {
    const url = window.location.pathname

    React.render( 
        view( Route.matchOr( () => Route.Home(), url ) )
        , document.body 
    )
}

// Respond to history
history.onpopstate( () => renderRoute() )

// Render initial route
renderRoute()

What?

  • Data Driven
  • Pure
  • Predictable
  • Simple
  • Composeable
  • Server + Client
  • Serializable

Predictable

In the land of SPA's, routing bugs are probably the most frustrating to debug, because you are often losing context as the page switches.

The standard approach to routing is to generate a regex and pick the first match that works.

What this library does differently is handle the logic of routing with a parser that can justify and rank its decisions which can lead to better matches that don't rely on definition order.

E.g. the following patterns can match the same URL, but one is more specific.

Type Pattern
Less Specific /accounts/:account_id
More Specific /accounts/create

If we had a url /accounts/create both patterns will match, but clearly a particular pattern is the intended match. Most routers rely on definition order but this library will rank matches by specificity and return the best match.

You can then take that stream of known structures and very easily connect it to the history API or server side routing. It's exactly the kind of thing you don't realise you need until you do.

Data Driven

This library uses a specification for its data structures called stags. You can take the output and generate your own framework or application behaviour in a standard structured way. You can also persist / serialize and transfer these data structures because reference equality is never relied upon.

This means you can do fun things like have your API define its endpoints with superouter and then send type information over the wire to generate constructors for a client side SDK for better validation.

Or you can store route information as analytics and replay them later without losing accompanying state in the deserialization process.

Routes are the heart of our applications but by compressing them into strings and regular expressions we are throwing away valuable information that can be used in various parts of the application.

Pure

This library exposes pure functions, it's up to you to connect the routing data structures to your own framework or native browser API. That at first may sound like a chore, but it's exactly what you need when you decide you want to do some fancy hydration, SSR or custom routing transitions.

When a library manages the routing side effects, you may find yourself hacking around the edges to do the thing you actually want to do.

Simple

All this library does is handle conversions between different types of data, it doesn't perform any side effects directly so it's easy to explore and test and therefore reliable to build upon.

Composeable

Because the data structures used by this library are part of the exposed API, you can compose this library's functions with your own to create something new and interesting without having to submit a PR.

API

superouter.type

superouter.type(typename: String, { [tagName]: patternString })

Defines a Route type for your application.


const Route = 
    superouter.type('Route', {
        Home: '/',
        Settings: '/settings/:page',
        Messages: '/messages/:user/:thread'
    })

The patternString can include the following forms.

Type Format Explanation
Path text A static segment of a route path.
Part :text A dynamic segment of a route path
Variadic ...text 1 or more dynamic segments of a route path

patternString types cannot be mixed. So :...text is not valid.


Route

Route is a type that represents the various pages in your application.

You can create more than 1 Route type for different sections or layers of your app. You can define all your Routes centrally or cascade your Route matching in layers in a typed manner.

The Route type will have a constructor function for each tag of Route you specified in its definition under the namespace of. These Route constructor functions will throw if a property specified in the pattern was not passed in at initialization.

To safely create a Route instance from a url, use Route.matchOr.


const Route =
    superouter.type('Route', {
        Home: '/',
        Settings: '/settings/:section/:setting'
    })

Route.Settings({ section: 'ci', settings: 'access' })
// Route.Settings({ section: 'ci', settings: 'access' })

Route.Settings({ missing: 1, things: 2 })
//=> TypeError: ...

Route.fold

({ [routetagNames]: ({ ...routeArgs }) => a }) => Route => a

const view = 
    Route.fold({
        Home: () => <Home/>,
        Post: ({ name }) => <Post postName={name} />
    })

view ( Route.Post({ name: 'A Perfect API' }) )
// => <Post postname="A Perfect API"/>

Used to define functions that handle all the potential routes in your application.

For some more advanced error checking try stags's fold instead.

stags will throw if you have missed tags or specified too many tags.

Route.fold is especially useful to map Route to views in your application in a manner similar to <Switch> in react-router.


Route.matchOr

( (Error[]) => Route, url ) => Route

matchOr accepts a callback to handle url's that can't be matched and a url to try to match.

If no match can be found the callback is executed to allow the user to return from unexpected url's.

const Route = type('Route', {
    Home: '/',
    Settings: '/settings/:settings',
    Album: '/album/:album_id',
    AlbumPhoto: '/album/:album_id/photo/:file_id',
    Tag: '/tag/:tag',
    TagList: '/tag',
    TagFile: '/tag/:tag/photo/:file_id'
})

Route.matchOr( 
    () => Route.Home(),
    '/album/abc123/photo/123'
)
//=> Route.AlbumPhoto({ album_id: 'abc123', file_id: '123' })


Route.matchOr( 
    () => Route.Home(),
    '/unknown/route'
)
//=> Route.Home()

matchOr also passes in all the errors keyed by the tag name of the routes to the callback, so you can have custom logic that returns different default branches depending on the matching errors.

Route.matchOr(
    errs => errs.TagFile.find( x => x.tag === 'ExcessPattern' )
        ? Route.TagList()
        : Route.Home()
    , url
)

Route.matches

string => Valid.Y( Route[] ) | Valid.N ( StrMap( tagName, Error[] ) )

Route.matches is a lower level alternative to Route.matchOr which either returns all the valid matching routes (if there are any) or all the errors that prevented matches keyed by the name of the route tags.


Route.toURL

Route => string

const Route = type('Route', {
    Home: '/',
    Settings: '/settings/:settings',
    Album: '/album/:album_id',
    AlbumPhoto: '/album/:album_id/photo/:file_id',
    Tag: '/tag/:tag',
    TagFile: '/tag/:tag/photo/:file_id'
})

Route.toURL( Route.Tag({ tag: 'beach' }) )
//=> /tag/beach

Advanced

🚨 Warning 🚨

There is absolutely no need to ever use any of the functionality below. You can very happily and safely only use the above API. Everything below is the primitives used to create the higher level API. It's exposed because there's no danger in doing so, and it's documented because it's exposed.


Valid

This library uses a sum-type Valid to safely model invalid route matches. The user friendly API traverses this type and throws on errors. But if one wants to safely analyze all the invalid patterns without using a try {} catch(e){...} Valid can be extremely useful.

You'll encounter Valid if you interact with some more advanced functions exposed by the library including tokenizePattern, tokenizeURL, type$safe, or Route.matches.

Valid is an example of stags usage. It's simply an object with 2 constructors Y and N.

Valid.Y(x) will return an object { type: 'Valid', tag: 'Y', value: x }

And Valid.N(x) will return an object { type: 'Valid', tag: 'N', value: x }.

It's simply a way to annotate that there's some kind of error branching in a function without throwing an error.

Valid includes some helper functions like fold, bifold and map.


tokenizePattern

string -> Valid.Y( PatternToken[] ) | Valid.N( PatternToken.Error[] )

Convert a pattern string into an array of tokens or an array of PatternToken errors.

The response is wrapped in a Valid to model the branching behaviour.

PatternToken.Error is documented further in the Error Types section.


tokenizeURL

(PatternToken[], string) -> Valid.Y( URLToken[] ) | Valid.N( URLToken.Error[] )

Convert an array of PatternToken's and a url into an array of URLToken's.

URLToken.Error is documented further in the Error Types section.

The response is wrapped in a Valid to model the branching behaviour.


PatternToken

A data structure that models the different types of supported patterns used by the superouter.type constructor.

There are 3 types of patterns:

data PatternToken 
    = Path string 
    | Part string 
    | Variadic string

URLToken

A data structure that models the matching of segments of a URL to segments of a PatternToken.

There are 5 types of patterns:

data URLToken 
    = Unmatched { expected::string, actual::string } 
    | ExcessSegment string 
    | Path string 
    | Part { key::string, value::string }
    | Variadic { key::string, value::string }
    

Each URLToken has a specificity which helps guide higher level functions to choose the most accurate route match.


Error Types

You will encounter different types of errors when using the more advanced aspects of superouter. Below is a brief explanation of each type of error and the circumstance that would trigger them.

Type tag When
PatternToken DuplicateDef When two patterns in a route defintion are effectively the same.
PatternToken DuplicatePart When two bindings within a pattern have the same name.
PatternToken VariadicPosition When a variadic pattern is not in the final position.
PatternToken VariadicCount When there is more than one variadic in a pattern.
URLToken UnmatchedPaths When a path segment was found but it did not match the expected value.
URLToken ExcessSegment When a URL had more segments than a pattern had expected and there was no variadic to consume the excess segments.
URLToken ExcessPattern When a URL did not have enough segments to satisfy a pattern.