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<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:
/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 |
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:
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<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:
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<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:
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.
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:
[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