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