bext.dev
DocsServer-Side Includes
Server-Side Includes6 min read
On this page

Plugin API

The built-in resolvers handle /__bext/* sources, filesystem partials, V8 renders, and proxy fetches. For domain-specific sources — CMS content, database rows, third-party APIs — you write a custom resolver. The plugin API makes this declarative via pattern matching plus lifecycle hooks.

rust
use bext_core::include::{IncludeResolver, IncludeDirective, IncludeContext, PluginResolver};

let cms_resolver = PluginResolver::new("/cms/*", |directive, ctx| {
    let slug = directive.src.strip_prefix("/cms/")?;
    let locale = ctx.vary_value("locale").unwrap_or("en");
    cms_client::fetch_block(slug, locale).ok()
});

chain_builder.with_plugin(Arc::new(cms_resolver));

Now <bext-include src="/cms/homepage-hero"> routes to your CMS resolver. Caching, auth gating, vary — all work because the resolver sits inside the same pipeline.

Resolver registration#

rust
PluginResolver::new(
    pattern,    // glob string: "/cms/*", "/products/*", "/api/feed/**"
    handler,    // Fn(&IncludeDirective, &IncludeContext) -> Option<String>
)

The handler is called for any include whose src matches the pattern. Returns Some(html) on success, None to pass to the next resolver.

Pattern specificity#

Plugin resolvers are ordered by pattern specificity (most literal characters first). More specific patterns run before less specific ones:

code
/cms/posts/*      ← 10 literal chars, runs first
/cms/*            ← 4 literal chars
/*                ← 0 literal chars, runs last

This lets you layer specialized handlers on top of a catch-all.

Pattern syntax#

Same glob syntax as config rules:

  • * matches any characters except /.
  • ** matches across path separators.
  • Literal characters match literally.
Pattern Matches
/cms/* /cms/post-1, /cms/hero
/cms/** /cms/post-1, /cms/posts/2025/jan
/api/v*/users /api/v1/users, /api/v2/users
Tip

Plugin resolvers sit between the auth/experiment resolvers and the built-in content resolvers in the chain. This means auth gating and experiment source-rewriting happen automatically before your resolver is called — you don't need to re-implement them.

Lifecycle hooks#

Plugins can observe and transform resolution at four points:

rust
pub trait IncludeHook: Send + Sync {
    fn on_resolve(&self, directive: &mut IncludeDirective, ctx: &IncludeContext);
    fn on_resolved(&self, directive: &IncludeDirective, html: &mut String, ctx: &IncludeContext);
    fn on_cache_hit(&self, directive: &IncludeDirective, ctx: &IncludeContext);
    fn on_error(&self, directive: &IncludeDirective, error: &IncludeError, ctx: &IncludeContext);
}
Hook When Mutable?
on_resolve Before resolution directive
on_resolved After resolution, before cache html
on_cache_hit On cache hit (skips resolution) read-only
on_error On resolution failure read-only

Hooks attach to a specific PluginResolver:

rust
let resolver = PluginResolver::new("/cms/*", handler)
    .with_hook(Arc::new(AnalyticsHook))
    .with_hook(Arc::new(MinifierHook));

Or attached dynamically:

rust
resolver.add_hook(Arc::new(DebugHook));

Hook patterns#

Analytics / observability:

rust
struct AnalyticsHook {
    metrics: Arc<MetricsClient>,
}

impl IncludeHook for AnalyticsHook {
    fn on_resolved(&self, d: &IncludeDirective, _html: &mut String, _ctx: &IncludeContext) {
        self.metrics.increment("include.resolved", &[("src", d.src.as_str())]);
    }

    fn on_error(&self, d: &IncludeDirective, err: &IncludeError, _ctx: &IncludeContext) {
        self.metrics.increment("include.error", &[
            ("src", d.src.as_str()),
            ("kind", err.kind()),
        ]);
    }
}

Output transformation:

rust
struct MinifierHook;

impl IncludeHook for MinifierHook {
    fn on_resolved(&self, _d: &IncludeDirective, html: &mut String, _ctx: &IncludeContext) {
        *html = html_minifier::minify(html);
    }
}

Validation / CSP enforcement:

rust
struct CspHook {
    allowed_patterns: Vec<String>,
}

impl IncludeHook for CspHook {
    fn on_resolve(&self, d: &mut IncludeDirective, _ctx: &IncludeContext) {
        let permitted = self.allowed_patterns.iter().any(|p| glob_match(p, &d.src));
        if !permitted {
            tracing::warn!(src = %d.src, "include blocked by CSP policy");
            d.src = "/_partials/csp-blocked.html".into();
        }
    }
}

Attribute injection:

rust
struct AnalyticsAttributeHook;

impl IncludeHook for AnalyticsAttributeHook {
    fn on_resolved(&self, d: &IncludeDirective, html: &mut String, _ctx: &IncludeContext) {
        // Add data-analytics-src to the root element.
        if let Some(pos) = html.find('>') {
            html.insert_str(pos, &format!(" data-analytics-src=\"{}\"", d.src));
        }
    }
}

Error types#

The on_error hook receives a structured error:

rust
pub enum IncludeError {
    Unresolved,              // no resolver produced a result
    Forbidden,               // blocked by security policy
    Timeout,                 // resolution took too long
    CircuitOpen,             // circuit breaker skipped resolution
    Custom(String),          // resolver-specific error
}

Errors are for observability only — the pipeline handles fallback chains automatically based on include attributes.

Chain integration#

Register plugins via the chain builder:

rust
use bext_core::include::{ChainBuilder, PluginResolver};

let chain = ChainBuilder::new()
    .with_cache(cache)
    .with_auth(true)
    .with_plugin(Arc::new(cms_resolver))
    .with_plugin(Arc::new(products_resolver))
    .with_plugin(Arc::new(legacy_api_resolver))
    .with_file(file_resolver)
    .with_fallback(true)
    .build();

Plugin resolvers sit between auth/experiment resolvers and the built-in content resolvers. This means plugins benefit from auth gating and experiment rewriting automatically, without having to re-implement them.

External plugin systems#

The plugin API works with bext's WASM and V8 plugin runtimes. A WASM plugin can register include resolvers via the plugin manifest:

toml
# plugin.toml
[plugin]
name = "cms-includes"
version = "1.0.0"

[include]
resolvers = [
  { pattern = "/cms/*",     handler = "resolve_cms" },
  { pattern = "/products/*", handler = "resolve_product" },
]
hooks = ["on_resolved"]

[include.config]
cms_api_url = { type = "string", required = true }
cms_api_key = { type = "string", required = true, secret = true }

[plugin.permissions]
network = ["api.contentful.com"]   # allowed outbound hosts
filesystem = []
cache = true
realtime = false

The plugin's WASM module exports resolve_cms and resolve_product functions. bext loads the plugin at startup, registers the resolvers with their patterns, and routes matching includes to the WASM runtime.

See Building a Plugin for the full plugin authoring guide.

Warning

WASM and V8 plugins run under declared sandbox permissions. A plugin that attempts to fetch from a host not in its network allowlist gets a permission error at runtime — not a silent no-op. Declare all required hosts in [plugin.permissions] before deploying.

Sandbox permissions#

Plugin resolvers run with declared permissions:

toml
[plugin.permissions]
network = ["api.cms.example.com", "api.products.example.com"]
filesystem = []          # no FS access
cache = true             # can use bext.cache
realtime = false         # cannot publish to channels
secrets = ["cms_key"]    # can read specific secrets

The sandbox enforces these at the WASM/V8 runtime level — a plugin that tries to fetch from a non-allowlisted host gets a permission error, not silent failure.

Dynamic registration#

Plugins can register resolvers at runtime (not just at startup):

rust
#[bext_plugin::on_server_start]
fn init(ctx: &PluginContext) {
    let resolver = PluginResolver::new("/cms/*", handle_cms);
    ctx.register_include_resolver(Arc::new(resolver));
}

#[bext_plugin::on_reload]
fn on_config_reload(ctx: &PluginContext) {
    // Unregister old, register new — for hot-reloadable patterns.
}

This is how you build things like "CMS editors can add new include patterns via an admin UI without a deploy."

Testing plugin resolvers#

Plugin resolvers are just IncludeResolver implementations. Test them directly:

rust
#[test]
fn test_cms_resolver() {
    let r = PluginResolver::new("/cms/*", |d, _ctx| {
        Some(format!("[cms:{}]", d.src.strip_prefix("/cms/").unwrap()))
    });

    let d = IncludeDirective {
        src: "/cms/hero".into(),
        attrs: Default::default(),
        fallback_content: None,
    };
    let ctx = IncludeContext::new();

    assert_eq!(r.resolve(&d, &ctx).as_deref(), Some("[cms:hero]"));
}

For integration tests, build a small chain with your resolver and exercise the full pipeline:

rust
let chain = ChainBuilder::new()
    .with_auth(false)
    .with_plugin(my_resolver)
    .with_fallback(true)
    .build();

let out = resolve_all(html, &chain, &IncludeContext::new());

Practical examples#

Contentful / Sanity / Strapi include resolver#

rust
let cms = PluginResolver::new("/cms/*", move |d, ctx| {
    let slug = d.src.strip_prefix("/cms/")?;
    let locale = ctx.vary_value("locale").unwrap_or("en");
    let client = CMSClient::get();
    client.render_block(slug, locale).ok()
});

Product card from database#

rust
let products = PluginResolver::new("/products/*", move |d, _ctx| {
    let sku = d.src.strip_prefix("/products/")?;
    let product = db::fetch_product(sku).ok()?;
    Some(render_product_card(&product))
});

Third-party API with response shaping#

rust
let stripe_widget = PluginResolver::new("/stripe/pricing*", move |_d, ctx| {
    let user_id = ctx.session.as_ref()?.user_id.as_ref()?;
    let customer = stripe::get_customer(user_id).ok()?;
    Some(render_pricing_for(&customer))
});

Legacy API routing#

rust
let legacy = PluginResolver::new("/legacy-api/*", |d, _ctx| {
    let path = d.src.strip_prefix("/legacy-api/")?;
    let url = format!("https://old-api.internal.example.com/{}", path);
    http::get(&url).ok().and_then(|r| r.text().ok())
});

See also#

Edit this page ↗Need a hand? ↗
FIND YOUR NEXT STEP

Start with a topic, a command, or a question.