url_service_router 0.1.1 copy "url_service_router: ^0.1.1" to clipboard
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 #

CI license: MIT

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 #

  • 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 a serviceName does" belongs to the caller (the composition root) — the classic separation of match (this library) from dispatch (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 — resolve returns a miss and canHandle / isRegisteredNode return false. 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 meta annotation).

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":

  1. 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]
  2. 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).
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 / resolveUri always return a RouteResult (a miss carries endNode + params for fallback / degradation / reporting).
  • canHandle / isRegisteredNode always return a bool.
  • Of the string-input APIs, only RouteRequest.parse may throw, and only ArgumentError — never a leaked FormatException. (The raw kernel constructor RouteRequest(uri) is the deliberate exception: it surfaces the underlying FormatException on invalid percent-encoding so that resolveUri can catch it and degrade to a miss — see its dartdoc if you build requests yourself for resolveRequest.)

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 pattern you 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. See registerRegex.

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/segment joined by /, already rewritten by pre-parsers — including root redirects and segments dropped by a consuming pre-parser, see matchOriginalPath below), 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 priority from high to low (patterns are precompiled at registration time).
  • Order-sensitive: when several match, the higher priority wins first — take care to avoid mutual shadowing.
  • Selectable parameter source: useOriginalParams (default true) 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 to false to 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 (default false) matches against the pre-parser-rewritten path by default (reflecting root redirects and dropped segments); set it to true to 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 skips Uri.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 synchronous resolve call, 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 register function 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 serviceName dispatch 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.dart is 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() and resolve().isHit answer 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.
  • decision callbacks (CPS) → a synchronous return value ParserDecision, with traversal as plain recursion.
  • Singleton → an instantiable router; names drop the URLService* prefix (UrlRouter / RouteNode / RouteRequest / NodeParser).
  • resolve returns a RouteResult synchronously, 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.0 requires ^3.1.0 — and the floor is pinned by the CI matrix)
  • Depends only on meta at runtime (the @internal annotation, zero runtime cost); the core logic is pure dart:core
0
likes
160
points
6
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A synchronous, pure URL resolution/dispatch kernel for Dart: URL -> serviceName + params. No navigation, no side effects, no Flutter dependency.

Repository (GitHub)
View/report issues
Contributing

Topics

#routing #url #deep-linking

License

MIT (license)

Dependencies

meta

More

Packages that depend on url_service_router