@marianmeres/simple-router
v3.4.0
Published
[](https://www.npmjs.com/package/@marianmeres/simple-router) [](https://jsr.io/@marianmeres/simple-router)
Readme
@marianmeres/simple-router
A lightweight, framework-agnostic string pattern matcher and router with support for dynamic parameters, wildcards, query strings, and reactive subscriptions.
Can match any string identifiers - URLs, file paths, command names, or custom patterns. Originally inspired by Sapper-like regex routes. Primarily designed for client-side SPA routing, but flexible enough for any pattern matching needs.
Features
- ✨ Dynamic route parameters with optional regex constraints
- 🎯 Wildcard and catch-all routes
- 📦 Spread parameters for multi-segment matching
- 🔍 Query string parsing (with option to disable per route)
- 🔄 Reactive subscriptions (Svelte store contract compatible)
- 🪶 Zero dependencies (except
@marianmeres/pubsubfor subscriptions) - 📘 Full TypeScript support
- 🎨 Framework-agnostic
Installation
deno add "jsr:@marianmeres/simple-router"npm install @marianmeres/simple-routerimport { SimpleRouter } from "@marianmeres/simple-router";Quick Example
import { SimpleRouter } from "@marianmeres/simple-router";
// Routes can be defined via constructor config
const router = new SimpleRouter({
"/": () => HomePage,
"/about": () => AboutPage,
"*": () => NotFoundPage, // catch-all fallback
});
// Or via the "on" API
router.on("/user/[id([0-9]+)]", (params) => {
console.log("User ID:", params?.id);
return UserPage(params?.id);
});
router.on("/article/[id]/[slug]", ({ id, slug }) => {
return ArticlePage(id, slug);
});
// Execute route matching
const component = router.exec("/user/123");
// Use with hash routing
window.onhashchange = () => {
const component = router.exec(location.hash.slice(1));
render(component);
};Route Patterns
Basic Segments
exact- Matches exactly "exact"[name]- Matches any segment, captured as{ name: "value" }[name(regex)]- Matches if regex test passes[name]?- Optional segment[...name]- Spread params (matches multiple segments)*- Wildcard (matches zero or more segments)
Separators
The default separator is /. By default (non-strict mode), multiple consecutive separators are collapsed into a single one, and separators are trimmed from both ends before matching. So /foo//bar and /foo/bar/ both resolve to /foo/bar.
If you need to treat consecutive separators as significant (for example, to canonicalize URLs and prevent accidental shadow-matching), enable strict mode:
// At router level (propagates to every route):
const router = new SimpleRouter({ strict: true, routes: { "/a/b": handler } });
router.exec("/a/b"); // matches
router.exec("/a//b"); // false (no match)
// Per-route override:
router.on("/legacy", handler, { strict: true });
// Or directly on SimpleRoute:
new SimpleRoute("/a/b", { strict: true });Optional segments
Optional segments ([name]?) are only allowed in trailing position (or directly before a trailing wildcard *). Optional-followed-by-required patterns are rejected at construction:
new SimpleRoute("/foo/[bar]?/baz");
// Throws: optional segment '[bar]?' must not be followed by a required segment
// Instead, register two routes:
router.on("/foo/[bar]/baz", handler);
router.on("/foo/baz", handler);Pattern Examples
| Route Pattern | URL Input | Params Result |
| ----------------------------- | ------------------ | -------------------------------- |
| /foo | /bar | null (no match) |
| /foo | (empty) | null |
| /foo/[bar] | /foo | null |
| / | (empty) | {} |
| foo | foo | {} |
| //foo///bar.baz/ | foo/bar.baz | {} |
| /[foo] | /bar | { foo: "bar" } |
| #/[foo]/[bar] | #/baz/bat | { foo: "baz", bar: "bat" } |
| /[id([0-9]+)] | /123 | { id: "123" } |
| /[id([0-9]+)] | /foo | null (regex fails) |
| /foo/[bar]/[id([0-9]+)] | /foo/baz/123 | { bar: "baz", id: "123" } |
| /foo/[bar]? | /foo | {} |
| /foo/[bar]? | /foo/bar | { bar: "bar" } |
| /[...path]/[file] | /foo/bar/baz.js | { path: "foo/bar", file: "baz.js" } |
| /foo/* | /foo/bar/baz.js | {} |
| /[foo]/* | /foo/bar | { foo: "foo" } |
API Reference
For the complete API documentation with all methods, types, and detailed examples, see API.md.
SimpleRouter
Constructor
// Simple config (backwards compatible)
const router = new SimpleRouter({
"/": () => HomePage,
"/about": () => AboutPage
});
// With options object (for logger support)
const router = new SimpleRouter({
routes: {
"/": () => HomePage,
"/about": () => AboutPage
},
logger: myLogger // optional, compatible with @marianmeres/clog
});config- Either aRouterConfigobject mapping route patterns to callbacks, or aRouterOptionsobject withroutes,logger, andstrictproperties
Methods
on(routes, callback, options?)
Register one or more route patterns with a callback.
router.on("/users", () => UsersPage);
// Multiple routes to same handler
router.on(["/", "/home", "/index.html"], () => HomePage);
// With dynamic params
router.on("/user/[id]", (params) => UserPage(params?.id));
// With regex constraint
router.on("/post/[id([0-9]+)]", (params) => PostPage(params?.id));
// With label for debugging
router.on("/admin", () => AdminPage, { label: "admin-dashboard" });
// Disable query param parsing for this route
router.on("/raw", (params) => RawPage, { allowQueryParams: false });Options:
label- Optional label for debugging (visible viainfo())allowQueryParams- Whether to parse query parameters (default:true)strict- Per-route strict matching (overrides router default)
Important: Routes are matched in registration order. First match wins! When SimpleRouter.debug is enabled, a warning is logged at registration time if a new route would be shadowed by an already-registered one.
exec(url, fallbackFn?, options?)
Execute route matching against a URL.
const result = router.exec("/users");
// With fallback
router.exec("/unknown", () => console.log("Not found"));
// With query params
router.exec("/search?q=hello");
// Skip the internal "*" catch-all: returns false on a miss even if "*" is registered
router.exec("/unknown", undefined, { skipCatchAll: true });Returns the value returned by the matched callback, or false if no match.
The optional { skipCatchAll: true } flag matches only real routes and returns false on a miss, instead of falling back to the registered * catch-all. An explicitly passed fallbackFn is unaffected. This is useful when fanning a single lookup across multiple routers ("try real routes in A, then B, … and fire a catch-all only if none matched") — see the catchAll getter for firing the deferred catch-all yourself.
subscribe(callback)
Subscribe to router state changes. Follows the Svelte store contract.
const unsubscribe = router.subscribe((state) => {
console.log("Route:", state.route);
console.log("Params:", state.params);
console.log("Label:", state.label);
});
// Later
unsubscribe();Returns an unsubscribe function directly. The callback is called immediately with the current state, then on every route change.
reset()
Clears all registered routes (except catch-all).
router.reset().on("/new-route", () => NewPage);info()
Returns a map of registered routes to their labels (for debugging).
router.on("/users", () => {}, { label: "users-list" });
console.log(router.info()); // { "/users": "users-list" }Properties
current
Gets the current router state (readonly).
router.exec("/user/123");
console.log(router.current);
// { route: "/user/[id]", params: { id: "123" }, label: null }catchAll
Gets the registered catch-all (*) callback, or null if none was registered (readonly). Does not execute it — this lets you fire the catch-all as a deliberate final step (e.g. after a multi-router exec(url, undefined, { skipCatchAll: true }) pass) without re-scanning routes.
const fallback = router.catchAll;
if (fallback) fallback(null, "*");static debug
Enable/disable debug logging. When enabled, uses the logger instance (if provided) or falls back to console.log.
SimpleRouter.debug = true;
router.exec("/test"); // Logs matching details to console (or custom logger)SimpleRoute
Low-level route parser. Usually you don't need to use this directly.
import { SimpleRoute } from "@marianmeres/simple-router";
const route = new SimpleRoute("/user/[id([0-9]+)]");
const params = route.parse("/user/123");
console.log(params); // { id: "123" }Static Methods
parseQueryString(str)
Parse a query string into an object.
SimpleRoute.parseQueryString("foo=bar&baz=123");
// Returns: { foo: "bar", baz: "123" }TypeScript
Full TypeScript support with generic types for type-safe route callbacks and exec() return values.
Generic Router
SimpleRouter<T> is generic over T, the return type of route callbacks:
// Typed router - all callbacks must return Component, exec() returns Component | false
const router = new SimpleRouter<Component>({
"/": () => HomePage,
"/about": () => AboutPage,
"*": () => NotFoundPage,
});
const result = router.exec("/about"); // Component | false
if (result !== false) {
render(result); // result is Component
}
// Without explicit type - T is inferred from callbacks (or defaults to unknown)
const router2 = new SimpleRouter({
"/": () => "home",
"/about": () => "about",
});Exported Types
import type {
Logger,
RouteParams,
RouteCallback, // RouteCallback<T = unknown>
RouterConfig, // RouterConfig<T = unknown>
RouterCurrent,
RouterOnOptions,
RouterOptions, // RouterOptions<T = unknown>
RouterSubscriber,
RouterUnsubscribe,
SimpleRouteOptions, // { strict?: boolean }
} from "@marianmeres/simple-router";Advanced Examples
SPA with Hash Routing
// Type-safe router with Component return type
const router = new SimpleRouter<Component>({
"/": () => HomePage,
"/about": () => AboutPage,
"/user/[id]": (params) => UserPage(params?.id),
"*": () => NotFoundPage,
});
function render(component: Component) {
document.getElementById("app").innerHTML = component.render();
}
window.addEventListener("hashchange", () => {
const path = location.hash.slice(1) || "/";
const component = router.exec(path); // Component | false
if (component !== false) {
render(component);
}
});
// Trigger initial render
window.dispatchEvent(new HashChangeEvent("hashchange"));Route Priority
Routes are matched in registration order (first match wins):
const router = new SimpleRouter();
// Register generic route first
router.on("/user/[id]", (params) => {
console.log("Generic:", params?.id); // This will match
});
// Specific route registered second (won't match "/user/admin")
router.on("/user/admin", () => {
console.log("Admin"); // This won't be reached
});
router.exec("/user/admin"); // Logs: "Generic: admin"To fix, register more specific routes first:
router.on("/user/admin", () => console.log("Admin"));
router.on("/user/[id]", (params) => console.log("User:", params?.id));Beyond URLs: General Pattern Matching
The router can match any string patterns, not just URLs:
// File path routing
const fileRouter = new SimpleRouter({
"src/[module]/[file].ts": ({ module, file }) =>
console.log(`Module: ${module}, File: ${file}`),
"assets/images/[...path]": ({ path }) =>
console.log(`Image path: ${path}`),
});
fileRouter.exec("src/components/Button.ts");
// Logs: "Module: components, File: Button"
fileRouter.exec("assets/images/icons/user.png");
// Logs: "Image path: icons/user.png"
// Command routing
const cmdRouter = new SimpleRouter({
"user:create": () => createUser(),
"user:delete:[id([0-9]+)]": ({ id }) => deleteUser(id),
"cache:clear:[type(redis|memcached)]?": ({ type = "all" }) =>
clearCache(type),
});
cmdRouter.exec("user:delete:123");
cmdRouter.exec("cache:clear:redis");
cmdRouter.exec("cache:clear"); // type defaults to "all"
// Custom separator (anything that's not a special regex char works)
const dotRouter = new SimpleRouter({
"app.settings.theme": () => "Theme settings",
"app.settings.[section]": ({ section }) => `Settings: ${section}`,
});
dotRouter.exec("app.settings.profile");
// Returns: "Settings: profile"Security notes
Regex constraints inside a route pattern (e.g. [id([0-9]+)]) are compiled with RegExp, so any valid JavaScript regex is accepted. A handful of patterns — typically nested quantifiers such as (a+)+ — can exhibit catastrophic backtracking (ReDoS) on crafted inputs.
- Route patterns are author-controlled: never accept them from untrusted input.
- Prefer simple character classes (
[0-9]+,[a-z]+) over nested quantifiers. - If you need a complex constraint, test it against adversarial inputs first.
Breaking changes
3.2.0
- Optional-followed-by-required is now rejected at construction. Patterns like
/foo/[bar]?/bazpreviously parsed but silently treated the optional marker as required —/foo/baznever matched. Such patterns now throw with a clear error. Register two separate routes instead (/foo/[bar]/bazand/foo/baz). - Parameter names are no longer URI-decoded. A pattern like
/foo/[id%20x]used to produce the keyid xin the params object; it now producesid%20x. Param values are still decoded. Parameter names in a named constraint ([name(regex)]) must now match\w+. - Regex constraints with parentheses now work correctly. Patterns like
/[id((?:a|b)+)]previously produced garbage regexes or threw obscure "Invalid regular expression" errors; they now parse and match as expected.
