Routing & History
Wins
- Flexible URL route parsing with clean semantic path formatting.
- History/Back/Forward button integration with Route signaling.
- Parameter-driven route mapping and tokenization without coding.
- Integration w/TIBET StateMachine for complex application states.
- Configuration/Feature flag support built in to URL parsing.
Contents
Concepts
- URL Components
- Routing Triggers
- The Router
- Route Signals
- Route Definition (via Config)
- Route Definition (via Code)
- Default Routes
- Custom Routes
- Pattern Processing
- Signal Processing
- State Machines
Cookbook
Code
Concepts
Server-side routing maps an incoming URL request to an appropriate route handler and is a core feature of how many modern server-side web frameworks function.
Client-side routing similarly maps changes to the URL to a handler, however, using routing in your client-side code is not something to undertake lightly.
TIBET provides the URL mapping and client-side persistence APIs needed to implement routes in your applications but we encourage you to be very deliberate in your design.
When you design client-side routes you're really designing bookmarks. Each route presents the user with a URL they can store and trigger directly. This means routes can introduce a lot of complexity in applications that aren't relying on storing all their state on the server.
To fully reproduce a client-side route's state your handler code may have to authenticate the user, restore state, navigate among "screens", or perform other tasks, all while taking into consideration whether the user is running offline. Keep that in mind as you design your routes.
Routing, as we've said, is the process of reacting to changes in the URL and responding appropriately. Let's take a look at the format of a URL to see how this plays out in practice.
URL Components
A standard HTTP(S) scheme URL has the following basic structure:
scheme://host[:port]/path[?params][#fragment]
Browsers never send the fragment portion to the server, it's specifically
reserved for the client. As a result the URL fragment has traditionally been
used to drive client-side functionality via onhashchange
notifications etc.
While it is possible to change the path
portion of a URL via the pushState
call in HTML5 browsers, we don't recommend overloading the server portion of the
URL with client-focused bookmarks since it blurs server and client
responsibilities.
Bookmarks which leverage a "synthetic" path
may trigger 404s when the user
accesses them since the server may not know the path. Avoiding this problem
requires the server to handle every possible synthetic route, increasing
maintenance overhead and server dependencies.
Instead of synthetic path
manipulation we recommend you use the URL
fragment which keeps reponsibilities clearly partitioned in accordance with the
URL standard.
That said, there's no formal specification for the format of a URL fragment
(outside of XPointer). That opens the possibility of treating the fragment as a
path[?params]
block itself:
scheme://host[:port]/path[?params][#[fragment_path][?boot_params]]
In many cases you can simplify that URL dramatically, reducing it to:
scheme://host/path#fragment_path
Regardless of whether you're using fragment-based client URLs or the more recent
HTML5 history.pushState
approach, TIBET looks at the URL components in a
consistent fashion as described below.
Base Components
Scheme
Scheme is a fixed aspect of the URL, even with history.pushState
so we do not
use scheme values in the computation of client-side routes.
Host
Host (domain) is a fixed aspect of the URL, even with history.pushState
so we
do not use host/domain values in the computation of client-side routes.
Port
Port is a fixed aspect of the URL, even with history.pushState
so we
do not use port values in the computation of client-side routes.
Path
If you're using history.pushState
you can manipulate the path portion of your
application launch URL to be any value you like. We don't recommend this
practice for TIBET.
For your TIBET code we recommend you rely solely on aspects of the URL fragment since that portion of the URL is never sent to the server and keeps the semantic meaning clearer.
Parameters
Explicit parameters for a server request are provided by adding ?
and the
desired key/value pairs to the end of the path component:
http://example.com/help?t=foo => Help params: {t: 'foo'}
NOTE that these "legacy URL" forms are not routed by TIBET. Parameters in the server portion of the URL are never used by the TIBET client.
Fragment Components
The URL fragment, often referred to as the "hash" (officially an 'octothorpe'), is the recommended control point for driving client-side routing activity in TIBET.
In the context of routing TIBET treats the fragment as a path[?params]
section. In other words, TIBET splits the fragment at the first ?
as if it
were a standard URL path[?params]
segment.
Any portion in front of the first ?
in the fragment is referred to as the
"fragment path". Any portion after ?
is referred to as the "fragment
parameters".
URLs containing "path-only" fragments follow a form similar to:
http://127.0.0.1:1407#path
http://127.0.0.1:1407#/path
http://127.0.0.1:1407#path/path
http://127.0.0.1:1407#/path/path
URLs containing "parameter-only" fragments look like this:
http://127.0.0.1:1407#?param
http://127.0.0.1:1407#?param=value
http://127.0.0.1:1407#?param=value¶m2=value
Combining both fragment path and parameter values gives you URLs of the form:
http://127.0.0.1:1407#path?param=value
http://127.0.0.1:1407#/path?param=value
http://127.0.0.1:1407#path/path?param=value¶m2=value
http://127.0.0.1:1407#/path/path?param=value¶m2=value
Fragment Paths
Like other routing solutions, TIBET allows you to map different fragment path patterns to a handler. The syntax of these mapping patterns is similar to that found in Rails, which pioneered much of modern web URL routing.
Explicit portions of a URL path can be named explicitly. Parameterized portions of the path typically use "tokens", values which start with a colon.
For example, a mapping for http://foo.example.com/authors/11/lname
might be:
/authors/:authorId/lname
A route matching the tokenized pattern above will parse the route and create
a parameter named authorId
whose value is 11
for use by any handler.
We'll cover TIBET's URL mapping syntax and handler invocation in detail in a moment but first let's take a look at fragment parameters.
Fragment Parameters
Fragment parameters are a concept based on treating the fragment as a kind of
URL in itself, one that can have its own path and parameter segments split by ?
.
Fragment parameters are never used for routing by TIBET. We only route based on changes to the "clean" or "semantic" URL fragment path segment.
Fragment parameters are reserved for use during TIBET startup. Parameters on the fragment configure the application's configuration variables during initial startup. This lets you easily support feature flags and other configuration-driven behavior during development.
For example, you can turn on trace-level logging during startup using a fragment
parameter of boot.level
as shown below:
http://127.0.0.1:1407#?boot.level=trace
In the URL above the #
starts the fragment and the ?
starts the
fragment parameter section. Additional parameters are separated with &
just as
with standard URL parameters.
http://127.0.0.1:1407#?boot.level=trace&fluffy
Note that fluffy
above has no ={value}
portion. This shorthand is used to
define a true
value for any flag. The URL above sets the boot log level to
trace and fluffy
to true
.
Production configurations never parse boot parameters on the URL so you can normally rely on just a clean semantic path being presented to users. Setting flags for production is done entirely through configuration files or settings within the code of your production package.
Routing Triggers
URL changes which can impact a TIBET route are limited to the path and the
fragment path. These two segments of the URL can be changed either by the user
or by your code via HTML5's pushState
call.
Since both hashchange
and popstate
events can be used to trigger routing,
TIBET provides a configuration variable, uri.route
, which takes either
hashchange
or popstate
to define which event to rely on.
By default TIBET routes hashchange
notifications and ignores popstate
for
routing. This default is based on our recommendation that you keep client routes
and bookmarks restricted to client-only portions of the URL.
TIBET's low-level handling of routing events is managed by the TP.core.History
type. This type is the default handler for all history-related activity and is
available via TP.sys.getHistory()
or from the application via getHistory()
.
When hashchange
or popstate
events occur TIBET will automatically invoke the
current application's router to handle the routing process. You can also trigger
routing by signaling LocationChange or by invoking the router's route()
call.
The Router
All TIBET applications provide a default router via TP.sys.getRouter()
or from
the application via getRouter()
.
The default router is TP.core.URIRouter
, a type which handles route definition
and resolution processing using an approach that helps simplify route definition
and keep route handler code organized efficiently.
While you can define your own router it's not recommended for most projects. The
discussions which follow regarding route definition and route resolution
describe the TP.core.URIRouter
version of routing.
Route Signals
In typical routing solutions you map a pattern to a function, usually in an order-dependent fashion. The first pattern matched determines the function to invoke and any tokenized portions of the pattern are provided as parameters.
TIBET manages routes a little differently. Instead of invoking handler functions directly TIBET converts route matches into signal names and fires those signals.
For example, by default, the route /
is mapped to the concept of 'Home' in
TIBET by default through the route.root
configuration flag value.
Any time the router detects the route has changed to /
it will signal
'HomeRoute'. This signal can be handled by any observer in TIBET but it will
normally be handled by the Application
instance for the application:
APP.hello.Application.Inst.defineHandler('HomeRoute', function(aSignal) {
APP.trace('HomeRoute signaled');
// do the right thing to reset app to the home page ui etc.
});
To be more explicit, when route()
is invoked it actually runs the list of
explicitly-defined route patterns, takes all of the routes which matched, sorts
them using TP.uri.URIRouter
's BEST_ROUTE_SORT
by default, and then selects
the one the sort function prioritizes.
If no explicitly-defined route pattern match is found TIBET takes the path and
generates a signal name from it automatically. The path /foo/bar
will result
is a signal name of FooBarRoute
and so on, meaning you don't have to define
route/signal mappings for common paths.
Once a route is chosen, TIBET fires the matching signal with a payload that includes any token values from the route pattern. This approach blends routing with TIBET signal handling.
The advantage of tying routing to signal handling is that you can leverage the responder chain, inheritance and state-sensitive handler lookups, signal-driven route testing, and all the other incredible features of TIBET Signaling in support of your application routes.
Route Definition (via Config)
You can express interest in a route using TIBET configuration parameters, in
particular the route.paths
configuration parameter.
Here's a simple example:
"route": {
"paths": {
"/foo/:id/bar": "SignalMe"
}
}
You can define as many paths as you like. Each will be processed through the
definePath
method as described in the next cookbook item.
Note that the second value is used as the route name and the explicit signal name unless otherwise defined. You can remap a signal name using the following syntax:
"route": {
"paths": {
"/foo/:id/bar": "SignalMe"
},
"map": {
"SignalMe": {
"signal": "NoNoSignalMe"
}
}
}
There are a number of other parameters you can set for a specific route. See the section on Route Maps for more information.
Route Definition (via Code)
Interest in a URL path is expressed using the definePath
router method.
Assume we want to process routes of the form:
http://127.0.0.1:1407#all
TIBET can handle that route by default, but let's assume it couldn't. We define our interest in that route as follows:
TP.sys.getRouter().definePath('/all');
// or, if we want to specify a custom signal name...
TP.sys.getRouter().definePath('/all', 'SignalThis');
In both examples you might be asking "Where's the handler function?"
The answer is "Where you'd expect it to be for a signal named 'AllRoute'" (or 'SignalThis').
Since routing in TIBET is effectively signaling you can manage the handlers in the same place you handle all other signal responders, somewhere in the responder chain.
For simple applications that normally means the application controller itself:
APP.hello.Application.Inst.defineHandler('AllRoute',
function(aSignal) {
APP.info('handling ' + aSignal.getSignalName());
});
Leveraging the responder chain and signaling means your route handler functions stay organized along with all your other event handlers. It also means you can trigger them simply by signaling the appropriate RouteChange signal.
Default Routes
TIBET's default route definitions can often handle simple routing patterns without the need for you to do anything but define a signal handler.
Let's look at a few examples of default route usage.
Root Routes
The default route definitions are designed to handle "root" routes, eg. routes
without any path value, by mapping them to a signal name based on the value of
the router's 'root' property, which defaults to route.root
or 'Home'.
Any time the URL path changes to an empty value a signal for {root}Route will be fired. Since this is built in you can handle root route requests in TIBET simply by defining a handler for that signal.
APP.hello.Application.Inst.defineHandler('HomeRoute',
function(aSignal) {
APP.info('handling ' + aSignal.getSignalName());
});
If we want to tie our home route to 'All' instead of 'Home' we can do that by changing the root value for the router via configuration values:
APP.hello.Application.defineMethod('initialize', function() {
TP.sys.setcfg('route.root', 'All');
});
You can also use a simple configuration parameter setting in tibet.json
:
"route": {
"root": "All"
}
Once you remap route.root
you can set up a handler to match the new signal:
APP.hello.Application.Inst.defineHandler('AllRoute',
function(aSignal) {
APP.info('handling ' + aSignal.getName());
});
Note however that definePath
and setting the route.root
have slightly
different results. When you use definePath
any signal name you provide is used
exactly as is. So definePath('/', 'All')
signals 'All' while using a
route.root
of 'All' signals 'AllRoute'.
Path Routes
For URLs with a valid path value TIBET will automatically convert the path into a camel-cased signal name, extracting any segments which are not valid JS identifier values and treating them like parameters.
For example:
http://127.0.0.1:1407#all/the/things
will signal AllTheThingsRoute
, while:
http://127.0.0.1:1407#all/23/things
will signal 'AllThingsRoute' and give it a payload containing a parameter named 'arg0' with a value of 23.
Keep in mind this process happens without any need to actually define the path in question. TIBET simply takes the path apart and assumes any valid JS identifier values are part of the signal name and anything else is a parameter.
Custom Routes
When a default route isn't enough TIBET gives you several options for defining specific route patterns. The simplest of these leverages built-in support for routing "tokens", path segments prefixed with a colon much like Rails.
Tokenized Routes
TIBET supports simple tokenized paths via definePath
. Any token names in the
path are treated as parameter names for the values which match those segments.
TP.sys.getRouter().definePath('/all/:count/things');
With this route definition in place http://127.0.0.1:1407#all/23/things will
still signal AllThingsRoute, but now the parameter name will be count
rather
than arg0
. Note however that the value of count will be a string, not a
number due to the nature of regular expression matching.
Token Patterns
If you have a token you want to use that requires special pattern processing you
can define that using the defineToken
method.
For example, if we want to force our :count
token from the previous example to
be numeric we might define the following token pattern:
TP.sys.getRouter().defineToken('count', /\d+/);
With the token definition above our route of /all/:count/things
will only
match if the value for count matches the regular expression we've defined for
that token but the result will be the parsed value, a number, when it does
match.
As with other routing-related definitions you can define tokens in tibet.json
:
"route": {
"tokens": {
"formid": "/[a-zA-Z0-9]+/",
"submissionid": "/[a-zA-Z0-9]+/"
}
}
Note that tokens are a top-level construct you can reuse across all of your path definitions.
Path Expressions
You can use defineToken
to create regular expression "segments" but you can
also define an entire path as a regular expression:
TP.sys.getRouter().definePath(/\/(.+)\/(\d+)/);
In cases where you're using regular expressions each captured portion will be assigned to arg{N} where N is the index of that capturing block.
For the path above that means our first match will be arg0 and the one-or-more-digits portion of the match will be named arg1.
If none of the approaches presented so far is sufficient you can provide your own pattern-to-signal conversion function.
Pattern Processing
You can change how URLs are converted into signals by provided your own pattern
processing function to definePath
.
For example, you might decide any path that's just a number is a "global navigation ID" which should trigger navigation to a specific screen in an application of dozens or more screens. (We had a customer use this approach for an application where their users knew the 3-digit codes for instantly jumping to a desired target screen out of hundreds of possible screens).
Let's define a path and match processing function for global navigation:
TP.sys.getRouter().definePath(/^\/(\d+)$/,
function(path, match, names) {
return TP.ac('GoToScreen', TP.hc('screen', match[1]));
});
In the example above we've defined a pattern consisting of a leading / for the path followed by one or more digits. When the path matches this pattern our function will be invoked to return signal and parameter data.
The function we provide for pattern processing is passed the original path, the results of running the route's regular expression match() call, and a list of any token names found while processing the original path.
In our example's case we don't have tokens so we can't name the parameter and there's no text to help us define a signal name to fire. Our best choice is to custom-build the signal/payload pair.
Our single-line implementation returns a signal name of GoToScreen
and
names the first match value screen
since we want handlers to be provided with
the screen number to navigate to.
The handler function itself might be defined as:
APP.hello.Application.Inst.defineHandler('GoToScreen',
function(aSignal) {
TP.info('handling: ' + aSignal.getSignalName() + ' screen: ' +
aSignal.at('screen'));
});
Signal Processing
There's nothing "special" about how TIBET processes routing signals but knowing how they are constructed can help you do some interesting things as you define your routes and handlers.
The match-handing function for a path is intended to return an ordered pair containing a signal type name and a hash containing signal payload parameters.
TIBET takes the signal type name and attempts to find that type and construct an
instance of it. If the name doesn't represent a unique type then TIBET creates
an instance of RouteFinalize
and sets the signal name instead.
RouteFinalize
signals, like all other Change signals in TIBET, are fired using
what we call inheritance firing, a dispatch policy which traverses the
inheritance chain of the signal looking for the best handler match.
An interesting implication is that you can create your own RouteFinalize signal hierarchy and implement handlers at whatever levels make sense for your application giving you either chaining or fallback processing as needed.
Why RouteFinalize
though? What's the 'finalize' part for? Well, it turns out
TIBET actually signals 3 times for routes.
When a route is about to change TIBET signals a RouteExit
signal for the route
about to be left. Next TIBET will signal a RouteEnter
, and eventually a
RouteFinalize
letting the system know all exit/enter processing is in place.
The exit and enter variations are useful for ensuring things setup and teardown cleanly when changing routes. Simple routes can rely on RouteFinalize subtypes.
State Machines
As you may know, TIBET includes a full implementation of a signal-driven State Machine. This allows you to manage functionality within your application based on application states…and one of the more prominent "states" in a web application is "the current route".
The default implementation of your Application object's RouteFinalize
handler
will check for any application state machine. If you've assigned a state
machine to your application that handler will automatically check to see
if, given the current state of your state machine, it can transition to a new
state which matches the route name. If so that state transition will be
triggered.
By letting you implement your route-change logic as part of your application's state machine you can be sure that logic stays centralized and modularized.
Route Maps
As some of the previous sections have shown, you can do most of your route
definition work directly in the tibet.json
file for your project without
having to write explicit code.
Here's the full description of what you can put in a route entry:
"route": {
"controller": "a single controller type name",
"paths": {
"pattern": "route/signal name",
...
},
"tokens": {
"name": "properly escaped regular expression string",
...
},
"map" : {
"{{routeName}}": {
"signal": "theSignalNameToFire",
"content": "string/tagname/url to set in 'target'",
"target": "urlOrElementIdOfTargetElement",
"reroute": "someRouteName",
"redirect": "someURL"
"deeproot": "trueIfRouteShouldNotRedirectHome",
"controller": "aRouteControllerTypeName"
},
...
}
}
Each of the settings in a route map entry can be used to help configure how TIBET responds to your route change.
You can map a specific signal name to the route. You can map a
specific controller type to handle that route. You can tell the system to
redirect or "reroute" when this route is triggered. You can tell the system not
to redirect to /
but treat the route as a valid deep link. Finally, you can
tell TIBET you want to put a particular bit of content into a particular
element.
This latter feature can be particularly nice in reducing route-handling code.
For example, if you set up an xctrls:panelbox
with an ID you might use
content
and target
to change what you see based on route changes. You can
tell each route to render a particular tag when the route changes:
"route": {
"map": {
"Page1": {
"target": "panelbox",
"content": "<app:page1/>"
},
"Page2": {
"target": "panelbox",
"content": "<app:page2/>"
},
...
}
}
Using the approach above, coupled with custom route controllers per page, you can easily have TIBET configure the proper content and controller stack in response to changes to the URL.
Cookbook
Define A Route
Defining a route is something you typically only need to do if a) your route requires specialized parsing of the URL (non-string values or special patterns) or b) you want to define a custom signal name to an otherwise-default route.
To define a route use the definePath
method of the TP.core.Router
type.
You'll want to get a handle to the current router instance first by using
TP.sys.getRouter()
and then you can message it to define your route path and
optional signal:
TP.sys.getRouter().definePath('/all');
// or, if we want to specify a custom signal name...
TP.sys.getRouter().definePath('/all', 'SignalThis');
That's all there is to it.
Define A Token
When you're going to require custom parsing of a part of the URL you can use
defineToken
to essentially create a named regular expression:
router = TP.sys.getRouter();
router.defineToken('fluffy', /\d{3}/);
Once you've defined a token you can reference it in paths by prefixing it with a colon (:):
Define A Tokenized Route
To define a tokenized route first be sure you've defined your token using
defineToken
, then use definePath
to create a path and reference your
token(s) within that path by prefixing each one with a colon (:):
router = TP.sys.getRouter();
router.defineToken('fluffy', /\d{3}/);
router.definePath('/foo/:fluffy/bar', 'SignalMe');
When the 'SignalMe' signal arrives it will contain a 'fluffy' parameter whose value is set to the numerical result parsed by the token's regular expression.
Handle Home Route
The 'Home' route is a default route triggered whenever your application lands on
/
.
The actual name of the route in this case is defined by the TIBET configuration
parameter route.root
whose default value is Home
.
To handle the 'Home' route you typically define a signal handler on your
application instance for the 'HomeRoute' signal. If you changed route.root
to
some over value you'd set your handler up for {value}Route
instead:
APP.hello.Application.Inst.defineHandler('HomeRoute',
function(aSignal) {
APP.info('handling ' + aSignal.getSignalName());
});
Handle Custom Route
Handling custom route changes is as easy as handling any other signal. Just define a signal handler that will be on the responder chain when the route is triggered.
APP.hello.Application.Inst.defineHandler('MyFavoriteRoute',
function(aSignal) {
APP.info('handling ' + aSignal.getSignalName());
});
Code
The "router" TP.uri.URIRouter
is defined in ~lib/src/tibet/kernel/TIBETURITypes.js
The RouteChange
-related signals are in ~lib/src/tibet/kernel/TIBETNotification.js
TP.core.RouteController
is defined in
~/lib/src/tibet/kernel/TIBETWorkflowTypes.js
. This type defines the common
handlers for certain route signals and is the default type for all route
controllers managed by the TIBET responder chain logic.
Routing tests are found in ~lib/test/src/tibet/routing
.