Custom Router

The Custom Router capability inserts plugin-defined routing logic before bext's built-in router. It exists for the cases the framework router deliberately does not handle: legacy URL schemes imported from other CMSes, traefik-style label-based matching, bulk redirect tables, maintenance mode.

When To Use It

  • You are migrating off WordPress / Drupal / Ghost and need to honor existing permalinks (/?p=123, /node/42, /2024/03/hello-world/). - You need a traefik-style label system where deploys annotate routes at push time instead of at config time. - You want a 410 Gone list for URLs that no longer exist. - You want a maintenance-mode router that short-circuits every request to a holding page while a migration runs.

If your routing lives in a static config file and uses modern /posts/:slug style, the built-in router already handles it. Reach for a custom router when the rules are dynamic, legacy, or label-driven.

The Trait

pub trait CustomRouterPlugin: Send + Sync {
    fn name(&self) -> &str;
    fn order(&self) -> i32 { 0 } // lower runs first; negative allowed
    fn route(&self, req: &RouterRequest) -> Result<RouteDecision, RouterError>;
}

Multiple routers may be installed simultaneously. They run in ascending order() order; the first router to return anything other than Pass wins. A router with order() = -1000 front-runs every other router — useful for maintenance mode.

Key Types

Type Purpose
RouterRequest ABI-flat request snapshot: method, host, path, query, headers.
RouteDecision::Match { handler_id, params } — request is resolved.
RouteDecision::Pass This router declines; next one runs.
RouteDecision::Rewrite { new_path } — mutate path and restart the pipeline.
RouteDecision::Respond { status, headers, body } — short-circuit with a full response.
RouterError Malformed (400), Backend (500).

RouterRequest::header(name) does case-insensitive lookup and returns the first match.

Decision Variants In Practice

Variant Example use case
Match WordPress permalink resolved to posts.show handler.
Pass Router doesn't recognise the URL; let the next one try.
Rewrite Legacy /old/blog/:slug/posts/:slug.
Respond 301 redirect, 410 Gone, maintenance-mode holding page.

Rewrite restarts the entire router pipeline with the new path. The runtime enforces a loop guard so a router that always rewrites to the same path cannot wedge the request.

Warning

RouteDecision::Rewrite restarts the entire router pipeline from the first plugin. A chain of two routers that each rewrite the same path will loop until the runtime's loop guard kills the request. Always ensure the rewritten path does not match the same rule again.

Reference Implementation: @bext/router-legacy

@bext/router-legacy matches URL patterns with :param wildcards and either rewrites or redirects. Rules live in config:

[[plugins]]
name = "router-legacy"
source = "@bext/router-legacy"
order = -100  # run before the built-in router

# WordPress-style permalinks → new post handler
[[plugins.rules]]
match = "/old/blog/:slug"
action = "rewrite"
target = "/posts/:slug"

# Year-based archive → section
[[plugins.rules]]
match = "/archive/:year/:month"
action = "rewrite"
target = "/posts?year=:year&month=:month"

# Deleted content → 410 Gone
[[plugins.rules]]
match = "/retired/*"
action = "respond"
status = 410
body = "This page has been retired."

The same effect in Rust:

use bext_plugin_api::router::*;
use std::collections::HashMap;

struct LegacyRouter;

impl CustomRouterPlugin for LegacyRouter {
    fn name(&self) -> &str { "legacy" }
    fn order(&self) -> i32 { -100 }

    fn route(&self, req: &RouterRequest) -> Result<RouteDecision, RouterError> {
        if let Some(slug) = req.path.strip_prefix("/old/blog/") {
            return Ok(RouteDecision::Rewrite {
                new_path: format!("/posts/{slug}"),
            });
        }
        if req.path.starts_with("/retired/") {
            return Ok(RouteDecision::Respond {
                status: 410,
                headers: vec![("Content-Type".into(), "text/plain".into())],
                body: b"This page has been retired.".to_vec(),
            });
        }
        Ok(RouteDecision::Pass)
    }
}

Feature Flag

None. The Router trait and types live in bext-plugin-api and are always available; no cargo feature gates them.

See Also

- @bext/router-legacy — pattern-based legacy URL handler.

- Routing guide — the built-in router's matching rules that run after all custom routers return Pass.

- nginx migration guide — migrating legacy nginx rewrite rules to bext custom routers.

- Frameworks routing — file-system routing for PRISM and other framework integrations.

- Capabilities overview — the full list of pluggable capabilities.