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.

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

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

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:

/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

Lifecycle hooks

Plugins can observe and transform resolution at four points:

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:

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

Or attached dynamically:

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

Hook patterns

Analytics / observability:

struct AnalyticsHook {
    metrics: Arc,
}

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:

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:

struct CspHook {
    allowed_patterns: Vec,
}

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:

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:

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:

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:

# 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.

Sandbox permissions

Plugin resolvers run with declared permissions:

[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):

#[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:

#[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:

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

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

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

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

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

- Overview — top-level include system

- Building a Plugin — full plugin guide

- Plugin System — bext's plugin architecture

- Capabilities — shared interfaces for swap-in plugins