A cute and tight router and application state controller
npm install @dharmax/state-routerThis package contains a tiny, functional router and a web‑application state manager.
The router captures URL changes and triggers a handler for the first matching route pattern. It supports both hash (#/path) and history (/path) modes, and passes route parameters and query data to your handler.
The state manager provides a minimal semantic state layer on top of the router: define named states, their route, and optional mode(s); listen for changes; and gate transitions with async guards.
- Static files: the router ignores common static file extensions (e.g. .css, .js, .png, .svg, .webp, .json, .md, .txt, .ejs, .jsm). You can customize router.staticFilters to adjust.
- Modes: use router.listen('hash' | 'history'). For static file serving (file:// or a simple static server), prefer hash.
Install as usual and build the TypeScript sources:
```
npm install
npm run build
`ts
import { router, StateManager } from '@dharmax/state-router'
// Router: match params and use query context
router
.add(/^user\/(\d+)$/, function (id: string) {
// this holds query params from the current URL
// @ts-ignore
console.log('user', id, 'q=', this.queryParams?.q)
})
.listen('hash')
// State Manager: define states and react
const sm = new StateManager('hash')
sm.addState('home', 'home', /^home$/)
sm.addState('post', 'post', /^post\/(\w+)$/)
sm.onChange((event, state) => {
console.log('state changed to', state.name, 'context=', sm.context)
})
// Navigate
router.navigate('home')
router.navigate('post/hello')
`
- router.add(pattern: RegExp | RouteHandler, handler?: RouteHandler)pattern
- If is a RegExp, captured groups are passed as handler arguments.pattern
- If is omitted (i.e., you pass only a function), it becomes a catch‑all route.this
- The handler’s contains queryParams built from window.location.search.'/users/:id'
- String patterns with named params are supported: produces this.params = { id: '...' } and still passes the captured values as handler args.
- router.listen(mode?: 'hash' | 'history')history
- In mode, internal clicks and Enter on focused links are intercepted.closest('a')
- Interception is hardened: uses ; ignores modified clicks (meta/ctrl/shift), non‑left clicks, target=_blank, download, rel=noreferrer, and external origins or different hostnames; and skips static‑looking URLs (by extension).
- router.navigate(path: string, opts?: { replace?: boolean })opts.replace
- Navigates according to the active mode and triggers routing.
- If is true (history mode), uses history.replaceState instead of pushState.
- router.replace(path: string)router.navigate(path, { replace: true })
- Shorthand for .
- router.resetRoot(root: string)
- Set a base root for history URL calculation.
- router.unlisten()listen()
- Removes all listeners previously attached by (click, keydown, popstate/hashchange). Useful for cleanup and tests.
- router.onNotFound(handler)true
- Registers a fallback called when no routes match. Returns from handleChange() after invoking the hook.
- router.getQueryParams(search?: string)this
- Returns a parsed query map from the current URL (or from a provided search string). Useful if you’d rather not use handler .
- router.setDecodeParams(boolean)decodeURIComponent
- Optionally route parameters before passing them to handlers and this.params.
Notes: the router lazily accesses window/document to be SSR‑safe; outside a browser environment, listeners are not attached and navigation no‑ops.
- createRouter()
- Factory that returns a fresh Router instance. Useful for testing or isolating multiple routers.
- new StateManager(mode?: 'hash' | 'history', autostart = true, routerInstance = router)autostart
- When is true, calls router.listen(mode) automatically.createRouter()
- You can pass a custom router instance (e.g., from ) for isolation.sm.stop()
- Call to unlisten the router.
- addState(name, pageName?, route?: RegExp | string, mode?: string | string[])route
- If is a string and contains %, each % is expanded to a non‑mandatory capture ?(.) for “the rest of the path”. For example, 'docs%' becomes ^docs?(.)$ and the first capture is provided as the state context (e.g., '/guide').route
- If is a RegExp, the first capturing group is passed as the state context.
- setState(name, context?)
- Programmatically set the state and optional context (e.g., a sub‑state or id).
- getState() / previous / context
- Access current, previous state, and the last context value.
- Context can be a string, an array (for multi‑capture regex), or an object (for named params).
- onChange(handler)state:changed
- Subscribes to events via @dharmax/pubsub.
- onBeforeChange(handler) / onAfterChange(handler)onBeforeChange
- Optional hooks around transitions. can veto by returning false (sync or async). onAfterChange runs after a successful transition.
- onNotFound(handler)
- Subscribe to router‐level notFound events via the state manager for convenience.
- registerChangeAuthority(authority: (target) => Promisetrue
- All registered authorities must return to allow a transition.
- restoreState(defaultState)
- Attempts to restore from current URL; otherwise navigates to the default state (hash mode).
- createStateManager(mode?: 'hash' | 'history', autostart = true, routerInstance = router)
- Factory returning a new StateManager; pass a custom router if desired.
- Route parameters: each capturing group in your route RegExp is passed to the route handler as an argument in order. For ^user\/(\d+)$, the handler receives the user id string.this.queryParams
- Query params: inside a route handler, exposes an object of the URL’s query parameters (e.g., { q: 'hello' }).addState
- State context: when a route defined via matches, the first capture group is forwarded to the StateManager as the state “context”. Access it via stateManager.context after the transition.
`ts
// 1) Params + query
router.add(/^user\/(\d+)$/, function (id) {
// @ts-ignore
const { q } = this.queryParams
console.log('id=', id, 'q=', q)
})
// 2) State context from route
sm.addState('docs', 'docs', 'docs%') // captures the suffix as context, e.g. '/guide'
// 3) Async guard
sm.registerChangeAuthority(async (target) => {
return target.name !== 'admin-only'
})
`
- Build: npm run build → compiles TypeScript into dist/.test/
- Manual demo: serve (e.g., npx http-server test) after build. Use hash mode for static servers.npm test
- Automated tests: Vitest + jsdom
- Run once with coverage: npm run test:watch
- Watch mode: vi.resetModules()
- Notes:
- Tests use dynamic imports with to isolate the singleton router/state manager between cases.URLSearchParams
- Some tests mock to simulate query strings in jsdom without full navigation.history.pushState
- Tests use history mode in jsdom via and popstate events; avoid direct window.location.search = '...' (jsdom limitation).
When using history mode, your server must serve your SPA entry (e.g., index.html) for application routes to avoid 404s on refresh or deep links. Static assets should still be served normally.
Examples:
- Node/Express
- Serve static first, then a catch‑all returning index.html.app.use(express.static('public'))
- app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'public/index.html')))
-
- Nginx
- In your location / block: try_files $uri /index.html;
- Apache
- Use FallbackResource /index.html or an .htaccess rewrite.
Tip: Keep router.staticFilters tuned so links to real files (e.g., /assets/app.css) are not intercepted.
If present, the following globals will be invoked on successful state changes:
- window.pageChangeHandler('send', 'pageview', '/window.ga('send', 'pageview', '/
-
These are optional and ignored if missing.