url_service_router 0.1.1
url_service_router: ^0.1.1 copied to clipboard
A synchronous, pure URL resolution/dispatch kernel for Dart: URL -> serviceName + params. No navigation, no side effects, no Flutter dependency.
url_service_router #
English | 中文
A synchronous, pure URL resolution / dispatch kernel for Dart — not a routing framework, no navigation.
It does exactly one thing: resolve a URL into serviceName + params and hand that off
to your dispatch layer. Navigation, page building, and auth all stay in the caller; this
library never touches them. Ported from the routing module of the Swift
URLServiceRouter (with the "service
execution module" removed).
[URL] --(sync, pure, no side effects)--> [serviceName + params] --(your dispatch)--> navigate / build page / ...
← platform-agnostic, unit-testable, reusable across targets →
Conceptually close to Android's ARouter dispatch kernel, but fully synchronous, cross-platform, and navigation-free — unlike go_router / auto_route, which couple resolution and navigation.
Contents #
- Features
- Positioning: when to use it / when not to
- Install
- Core mechanism: node tree + pre/post bidirectional traversal
- Quick start
- Robustness on untrusted input
- Regex fallback (after a trie miss)
- Performance and scale
- Multi-repo / multi-module integration
- Integrating with Flutter routing frameworks
- API at a glance
- Project layout
- Comparison with other libraries
- Design notes (why it is the way it is)
- Trade-offs from the port of URLServiceRouter (Swift)
- Development
- Environment
Features #
- Synchronous:
resolve(url)returns the result directly — no async, no callbacks, no thread hops. - Decoupled: it produces only a
serviceName(a string) +params; it is unaware of the UI and runs no business logic. "What aserviceNamedoes" belongs to the caller (the composition root) — the classic separation ofmatch(this library) fromdispatch(you). - Programmable traversal: node tree + pre/post bidirectional traversal, where normalization (redirects, parameter extraction) is part of the matching process.
- Safe on untrusted input: a malformed URL or invalid percent-encoding never throws
—
resolvereturns a miss andcanHandle/isRegisteredNodereturnfalse. This contract is locked down by seeded property/fuzz tests, so it is safe to point straight at attacker-controllable deeplinks. See Robustness on untrusted input. - Fast: exact trie descent is O(depth) — one map lookup per segment — and steady-state resolves land at roughly 0.5 µs; regex costs nothing until a trie miss. See Performance and scale.
- Pure Dart: no Flutter dependency — reusable in apps, on the server, or in a CLI.
- Extensible: regex fallback, per-module lazy registration, and multi-repo compose are all built in.
- Trustworthy: 100% line coverage, seeded property/fuzz tests, and zero runtime
dependencies (only the compile-time
metaannotation).
Positioning: when to use it / when not to #
Matching "URL → parameters" is a mature problem (on the web it has even been standardized
by the browser-native URLPattern). This library's value is not "can it match" but its
shape — a navigation-free, cross-platform pure resolution / dispatch kernel.
✅ Good fit
- A routing dispatch hub for large multi-module / multi-repo apps: URL →
serviceName, then the dispatch layer decides where to go. - Reusing one set of routing rules across targets (app / server / CLI) without being locked to a single UI framework.
- Making resolution a unit-testable pure function (synchronous, side-effect-free, race-free).
- URL normalization / graceful fallback / lazy loading of tens of thousands of routes by module.
❌ Not a fit (use these instead)
- You want one-stop routing + page building + transitions in Flutter → go_router.
- You want type-safe route parameters / compile-time checks → auto_route.
- You only want to match a URL into parameters on the web → browser-native URLPattern.
- You need async auth mid-match before deciding where to go → do it in your dispatch layer, outside this library (which is purely synchronous).
See Comparison with other libraries below for a detailed breakdown.
Install #
dart pub add url_service_router
Or add it to your pubspec.yaml manually:
dependencies:
url_service_router: ^0.1.0
Then:
import 'package:url_service_router/url_service_router.dart';
Core mechanism: node tree + pre/post bidirectional traversal #
Unlike the vast majority of routers, which do "flat pattern matching":
- The URL is first split into a node-name array:
scheme + host + path segments(scheme/host lowercased).http://beta.example.net/owner/1/info→[http, beta.example.net, owner, 1, info] - A single traversal runs over the pre-registered node tree:
- Descent (pre-parsers): run top-down, level by level, and may rewrite
nodeNames/params(http→https, staging host→production host, extract path parameters, …). - Backtrack (post-parsers): run bottom-up to match a service as a fallback, short-circuiting on the first hit (like a Responder Chain).
- Descent (pre-parsers): run top-down, level by level, and may rewrite
root ─pre→ https ─pre→ www.example.com ─pre→ owner ─pre(extract id=1)→ info
│ no child node
info ─post(hit user://info)◄──┘
Quick start #
import 'package:url_service_router/url_service_router.dart';
final router = UrlRouter();
// Root-level redirect (runs first on descent).
router.registerRootParsers(() => [
FunctionParser(type: ParserType.pre, onParse: (request, parser) {
final names = request.nodeNames;
if (names.isNotEmpty && names.first == 'http') {
request.replaceNodeNames([...names]..[0] = 'https', parser);
}
return ParserDecision.next;
}),
]);
// Extract the id in /owner/<id>.
router.registerNode('https://www.example.com/owner/', parsersBuilder: () => [
FunctionParser(type: ParserType.pre, onParse: (request, parser) {
final names = request.nodeNames;
final first = names.isEmpty ? null : names.first;
if (first != null && int.tryParse(first) != null) {
final rest = [...names];
request.mergeParams({'id': rest.removeAt(0)}, parser);
request.replaceNodeNames(rest, parser);
}
return ParserDecision.next;
}),
]);
// The terminal node hits a service.
router.registerNode('https://www.example.com/owner/info', parsersBuilder: () => [
FunctionParser(type: ParserType.post,
onParse: (request, parser) => ParserDecision.complete('user://info')),
]);
final r = router.resolve('http://www.example.com/owner/1/info');
r.serviceName; // 'user://info'
r.params; // {id: '1'}
r.isHit; // true
A complete runnable example is in
example/url_service_router_example.dart.
Robustness on untrusted input #
Deeplinks are attacker-controllable, so the resolution entry points are total functions:
they never throw, whatever you feed them. A URL that Uri.tryParse rejects, or one
with invalid percent-encoding in the components resolution consumes — path segments and
query, which only throw on lazy decode — degrades to a miss rather than an exception.
(The fragment and userInfo never participate in resolution and are never decoded, so a
bad encoding only there is simply ignored and the URL resolves normally.)
router.resolve('%%%not a url%%%').isHit; // false — no throw
router.resolve('https://ex.com/%C0%80').isHit; // false — invalid %-encoding, no throw
router.canHandle('://:::'); // false — no throw
router.isRegisteredNode('http://[bad'); // false — no throw
resolve/resolveUrialways return aRouteResult(a miss carriesendNode+paramsfor fallback / degradation / reporting).canHandle/isRegisteredNodealways return abool.- Of the string-input APIs, only
RouteRequest.parsemay throw, and onlyArgumentError— never a leakedFormatException. (The raw kernel constructorRouteRequest(uri)is the deliberate exception: it surfaces the underlyingFormatExceptionon invalid percent-encoding so thatresolveUrican catch it and degrade to a miss — see its dartdoc if you build requests yourself forresolveRequest.)
User-code exceptions are a separate concern. An exception thrown by your parser or lazy module builder is a code defect, not a malformed URL, so it is never silently converted to a miss. By default it propagates (fail-fast — easiest to diagnose). If your production requirement is "the routing entry point must never throw", opt into fail-soft with an explicit error channel:
router.onParserError = (error, stackTrace, request) {
reportToSentry(error, stackTrace, extra: {'url': request.url.toString()});
}; // resolve/canHandle then degrade to a miss after reporting — never silently.
This contract is not just documented but locked by seeded property/fuzz tests
(test/fuzz_test.dart): thousands of malformed, mixed-encoding,
control-character, and Unicode inputs per run, plus a fixed regression corpus, all
asserting "no throw" and the RouteResult shape invariants.
On regex fallback specifically: a
patternyou register runs synchronously against a path of attacker-controllable length, so use only linear-time / RE2-safe patterns — nested quantifiers such as(a+)+can trigger catastrophic backtracking. SeeregisterRegex.
Regex fallback (after a trie miss) #
The trie stays purely exact (fast, predictable); regex serves as a prioritized fallback
after a miss (modeled on WMRouter's RegexAnnotationHandler), well suited to
consolidating legacy URLs and cross-segment patterns.
router.registerRegex(r'^https/m\.example\.com/act/(?<id>\d+)$', 'page://activity', priority: 100);
final r = router.resolve('https://m.example.com/act/42');
r.serviceName; // 'page://activity'
r.params['id']; // '42' — named capture groups flow into params automatically
r.isRegexHit; // true
- Match target: the normalized node-key path (
scheme/host/segmentjoined by/, already rewritten by pre-parsers — including root redirects and segments dropped by a consuming pre-parser, seematchOriginalPathbelow), e.g.https/m.example.com/act/42; anchoring with^...$is recommended. - Zero regex cost on a trie hit; only a miss triggers a linear scan by
priorityfrom high to low (patterns are precompiled at registration time). - Order-sensitive: when several match, the higher
prioritywins first — take care to avoid mutual shadowing. - Selectable parameter source:
useOriginalParams(defaulttrue) uses the original params (query + extras) and does not carry parameters that a pre-parser injected during a failed trie descent, so each regex hit is independent and reproducible; set it tofalseto carry the rewritten params. Named capture groups are layered on top of the base (same name overwrites), and non-participating optional groups (null) are skipped rather than overwriting a same-named param. - Selectable match basis:
matchOriginalPath(defaultfalse) matches against the pre-parser-rewritten path by default (reflecting root redirects and dropped segments); set it totrueto match against the original URL's normalized path, bypassing all pre-parser rewrites so you can hit and recapture segments dropped by a consuming pre-parser (at the cost of root redirects no longer being reflected either — the regex must then cover the original scheme/host itself).
Performance and scale #
The internal collections of a node (child-node map, pre/post parsers) are all lazily
allocated, so leaf / parser-free nodes carry no empty-collection overhead; deduplication
and existence checks go through the tree's isRegistered flags rather than a
whole-table string index.
Recommendations for large-scale registration:
registerSegments(List<String>): prefer it for bulk registration — it skipsUri.tryParse. Segments are matched verbatim: pass scheme/host segments lowercase (the request side lowercases them) and every segment in percent-decoded form (the request side decodes them), or the route is silently unreachable.- Share parser instances: hoist a single parser-logic instance into a shared builder
rather than
new-ing one per node. registerModule/warmModule: lazily register a module's subtree (see below).
Per-module lazy registration #
Eagerly build only the skeleton down to a module's prefix; defer the module subtree until
the first route enters it (or until warmModule pre-warms it):
router.registerModule(['https', 'www.example.com', 'shop'], (m) {
m.register('detail', parsersBuilder: ...); // relative to the module prefix
m.registerSegments(['order', 'list'], parsersBuilder: ...);
});
// Smooth out the cold-start first-frame spike: eagerly warm this deeplink's target
// module, leave the rest lazy.
router.warmModule(['https', 'www.example.com', 'shop']);
Measured A/B (10 modules × 5000 routes = 50k, example/benchmark_lazy.dart, each run
in its own process):
| Metric | Eager (register all) | Lazy (only 2/10 entered this run) |
|---|---|---|
| Startup / skeleton time | 20 ms | ~0 ms |
| Resident memory (RSS delta) | 57.0 MB | skeleton 0.1 MB → 7.0 MB after touching 2/10 |
| First entry into a cold module | — | ~8 ms/module synchronous spike |
| Steady-state resolve | 0.47 µs | 0.57 µs |
Trade-off: lazy registration shrinks memory/startup cost down to "the modules you
actually use" (~1/8 the memory here), at the cost of a one-time synchronous spike the
first time each module is hit — if a cold-start deeplink lands directly in a module, that
spike falls on the first frame, so it is usually paired with warmModule to pre-warm the
target module. It fits "many modules + a large table per module + only a few modules used
per session"; otherwise eager registration is simpler.
Multi-repo / multi-module integration #
The way to answer "who registers what, and when" in a multi-package (multi-repo) app: a
contract package + a registration function exposed per module + a synchronous compose
before runApp. Because both registration and resolution are synchronous,
"register before resolve" degenerates into a simple matter of code order — inherently
race-free.
void main() {
final router = UrlRouter();
registerGlobalRedirects(router); // ① Global redirects owned solely by the shell.
ShopRoutes.register(router); // ② Each business repo registers synchronously.
BlogRoutes.register(router);
runApp(App(router: router)); // ③ From here, every resolve has the full table.
}
Key points:
- Do not rely on import side effects to self-register — with Dart's lazy top-level
initialization + AOT tree-shaking, registration that is never explicitly called will not
run; you must call
register(router)explicitly. - Global pre-parsers are registered exactly once, by the shell; business repos leave them alone, avoiding ordering ambiguity.
- Duplicate registration fails fast: registering the same terminal node (or the same
module prefix / root parsers / regex pattern) twice throws
StateError, so a URL collision between two teams surfaces at bootstrap instead of one route silently shadowing the other. - When startup cost is high, use
registerModule(synchronous skeleton, lazy subtree): after bootstrap every module's resolve capability is present, the lazy build happens within the same synchronousresolvecall, and you never observe a half-built state. - If a module needs async initialization, use a "readiness gate + pending queue" to hold early deeplinks.
A complete runnable sample is in example/multi_module/ (the
app_routing.dart contract + two mock business repos + the shell's bootstrap(), with a
readiness gate and a conflict-audit test).
Why explicit compose across repos: build_runner discovers inputs by "root package" only, so a single builder cannot glob annotations inside dependency packages; Flutter/AOT also has no runtime reflection. "Automatic cross-package annotation aggregation" would require a two-phase combining builder (the same approach as injectable — heavy infrastructure). When the module count is manageable, exporting one
registerfunction per package and listing it once in the shell (one line per package) is both the simplest and the easiest to debug.
Integrating with Flutter routing frameworks #
This library is the match layer (URL → serviceName + params) and does no navigation;
Navigator 1.0 / the Router API / go_router / auto_route are the navigation layer. The two are
upstream and downstream, and integration needs only a single serviceName → navigation
dispatch table. Copy-pasteable glue code for each framework is in
doc/integration.md.
API at a glance #
| Type | Description |
|---|---|
UrlRouter |
The router (not a singleton — the route table lives with the instance). Register: registerNode / registerSegments / registerRootParsers / registerModule / warmModule / registerRegex; resolve: resolve / resolveUri / resolveRequest / canHandle; introspect: isRegisteredNode / allRegisteredNodePaths / rootNode; error policy: onParserError (shell-owned fail-soft channel, see Robustness) |
RouteRequest |
A single request holding mutable nodeNames / params; replaceNodeNames / mergeParams (pre-parsers only — a post-parser call throws StateError) |
NodeParser / FunctionParser |
The parser interface / a callback-based implementation. ParserType.pre (descent) or .post (backtrack); a higher priority runs first |
ParserDecision |
.next (continue) or .complete(serviceName) (hit and short-circuit) |
RouteResult |
RouteResult.hit / .regexHit / .miss. Fields: serviceName / params / isHit / isRegexHit / endNode / hitNode / hitParser / matchedPattern |
ModuleRegistrar |
Handed to registerModule's builder: register / registerSegments, relative to the module prefix (the module never needs to know its own full prefix) |
RouteUri |
An extension on Uri adding nodeNames — the scheme/host/path-segment split both registration and requests are built on (imported together with the barrel) |
Project layout #
lib/
├── url_service_router.dart # public barrel (the only import entry point)
└── src/
├── route_node.dart # RouteNode: node tree + pre/post bidirectional traversal
├── node_parser.dart # NodeParser / ParserType / ParserDecision / FunctionParser
├── route_request.dart # RouteRequest: mutable nodeNames / params
├── route_result.dart # RouteResult.hit / regexHit / miss
├── router.dart # UrlRouter / ModuleRegistrar / registerRegex
└── route_uri.dart # RouteUri: Uri → nodeNames
example/
├── url_service_router_example.dart # basic example (buildRouter)
├── benchmark.dart # scale benchmark
├── benchmark_lazy.dart # A/B: eager vs lazy registration
└── multi_module/ # multi-repo compose sample (contract + mock repos + bootstrap)
test/
├── url_service_router_test.dart # core resolution / regex / lazy modules / registration
├── multi_module_test.dart # multi-module bootstrap / conflict audit
└── fuzz_test.dart # seeded property/fuzz tests: never-throw contract + invariants
Comparison with other libraries #
This library differs not in "can it match a URL into parameters" but in three things: shape (a pure resolution / dispatch kernel, zero navigation), match pipeline (descent rewrite + backtrack fallback + regex layering + lazy modules), and niche (a pure kernel on Dart / mobile, an area few have addressed).
| Library | Target | Scope | Match mechanism | Output |
|---|---|---|---|---|
| url_service_router (this) | Dart / cross-platform | resolution / dispatch only | static trie + pre/post bidirectional traversal + regex fallback | serviceName + params |
| go_router | Flutter | resolution + navigation + page building | per-route path-to-regexp | Widget (Page) |
| auto_route | Flutter | resolution + navigation + page building (codegen) | generated route classes + pattern matching | type-safe Page |
| fluro | Flutter | resolution + navigation | parameter tree (:param) |
Handler → Widget |
| ARouter | Android | resolution + interception + page building | compile-time grouped table (exact lookup) | page Class + params |
| JLRoutes | iOS | resolution + callback dispatch | flat pattern (return NO to pass through) |
block callbacks |
| URLPattern | Web standard | matching only | regex (per URL component) | groups |
| path_to_regexp | JS / Dart | matching only (primitive) | pattern → regex | parameter map |
| parse_route | Dart | resolution + in-memory navigation stack | flat pattern | MatchResult + push/pop |
Key points:
- Only this library fully isolates "resolution" (zero navigation, purely synchronous,
cross-platform): the Flutter frameworks all bundle "resolution + navigation", and even Dart's
closest,
parse_route, ships a navigation stack. - The rewrite / fallback pipeline is a unique combination: in-place rewrite on descent (http→https, host normalization, parameter extraction), fallback matching on backtrack, layered regex fallback, per-module lazy registration — none of the above has all of these at once.
- The output is a
serviceNamedispatch token (ARouter-like semantics), not a handler or Widget — the dispatch logic is entirely yours.
Design notes (why it is the way it is) #
A few deliberate choices — and deliberate non-choices — recorded here.
Why "synchronous + emit only a serviceName" #
Route path-finding has no inherent need for asynchrony (splitting the URL, walking the
tree, rewriting params are all purely synchronous). Once service execution, main-thread
hops, and UI dependencies are stripped away, it is a pure function URL → (serviceName, params): platform-agnostic, unit-testable, and reusable on the server / in a CLI. The
coupling of "what a serviceName actually does" is moved to the caller (the composition
root), not eliminated — which is exactly where separation of concerns belongs.
Why a miss returns RouteResult.miss rather than null #
resolve always returns non-null, and the caller checks .isHit, so the hot path needs no
null-checks scattered around. A miss result still carries endNode (the deepest node
reached on descent) and params, useful for fallback / degradation / breadcrumb
reporting — returning null would throw that diagnostic information away. The hit/miss
fields are locked to their invariants by the named constructors RouteResult.hit /
.regexHit / .miss: a trie hit has hitNode/hitParser/serviceName all non-null
together, a regex hit has serviceName/matchedPattern non-null, and a miss has all of the
above null.
Why there is no :param / * declarative pattern #
Extracting path parameters is already doable with a pre-parser (see the /owner/<id>
example); :param is only syntactic sugar, not a new capability. Introducing it would
cost real complexity: a second set of "exact > param > wildcard" priority and backtracking
semantics, registration-time conflict detection, param-value restoration, and other corner
cases. Wedging a second paradigm into a library whose selling point is a single
programmable traversal, just so the syntax reads more declaratively, is not worth it. When
you need a regex constraint, registerRegex serves as the fallback.
Note: the "50k enumerated nodes" in
benchmark.dartis a stress-test construction — a real route table does not enumerate ID segments. An ID should be caught by a single pre-parser in the first place — which is exactly what the current approach already does. So "param-izing saves a lot of nodes" essentially does not hold in real scenarios.
Matching-semantics highlights #
- Exact-name descent, no backtracking: one map lookup per segment, O(depth), with predictable latency; regex only kicks in as a fallback after a miss.
- Pre rewrites on descent, post falls back on backtrack (Responder Chain-like): normalization (http→https, staging host→production host, parameter extraction) is part of "the matching process", not an after-the-fact callback.
- A registered node ≠ a service hit:
isRegisteredNode()andresolve().isHitanswer two different questions.
Trade-offs from the port of URLServiceRouter (Swift) #
Derived from the routing module of the Swift URLServiceRouter, reworked into idiomatic Dart:
- Removed the service-execution module (
callService/ pre-services) and all UIKit dependencies, keeping only path-finding. decisioncallbacks (CPS) → a synchronous return valueParserDecision, with traversal as plain recursion.- Singleton → an instantiable router; names drop the
URLService*prefix (UrlRouter/RouteNode/RouteRequest/NodeParser). resolvereturns aRouteResultsynchronously, with no main-thread hop and no business execution.
Development #
dart pub get
dart test # full suite (incl. seeded fuzz), 100% line coverage enforced by CI
dart analyze # no warnings
dart run example/url_service_router_example.dart # basic example
dart run example/multi_module/shell_main.dart # multi-repo compose demo
dart run example/benchmark.dart 50000 # scale benchmark
dart run example/benchmark_lazy.dart full # A/B: eager
dart run example/benchmark_lazy.dart lazy # A/B: lazy
Environment #
- Dart SDK
^3.1.0(the library uses Dart 3 class modifiers —sealed/final/abstract interface; the tests also use records and patterns. The binding constraint is the dev toolchain —lints ^4.0.0requires^3.1.0— and the floor is pinned by the CI matrix) - Depends only on
metaat runtime (the@internalannotation, zero runtime cost); the core logic is puredart:core