Skip to content

Commit 3c7092c

Browse files
authored
feat: side-channel body passing via host functions (#49)
* fix: use binary bodies (Vec<u8>) in plugin SDK instead of String Change Request/Response body from Option<String> to Option<Vec<u8>> with base64 JSON encoding, fixing data loss for binary payloads like multipart/form-data file uploads. Updates all plugins and the WASM runtime to use the new binary-safe body type. * fix: update remaining plugins and macro to use binary body type Fix #[barbacane_dispatcher] macro error responses and update http-log, oidc-auth, oauth2-auth, opa-authz HttpRequest structs to use Option<Vec<u8>> with base64 encoding, matching the host-side expectation. Also fix http-upstream test assertions to use from_slice. * test: add comprehensive binary body coverage across SDK, WASM, and integration SDK unit tests (20 new): - Response base64 roundtrip (text, binary, none) - Empty body vs None distinction - Helper methods: body_string, set_body_text, Response::text - Null bytes and all-256-byte-values roundtrip - Middleware short-circuit with binary body - Request body → Response body dispatcher roundtrip WASM chain tests (2 new): - parse_middleware_output with base64-encoded binary body (Continue) - parse_middleware_output with binary body (ShortCircuit) WASM cache tests (3 new): - Binary body cache storage/retrieval - CacheEntry JSON roundtrip with binary body - CacheEntry JSON roundtrip with None body Integration tests (3 new): - Binary body through auth middleware to upstream (wiremock) - Binary body direct dispatch without middleware (wiremock) - Binary response body from upstream to client (wiremock) * docs: update body type references from Option<String> to Option<Vec<u8>> - docs/contributing/plugins.md: update SDK type signatures and add helper method docs, base64 encoding note - adr/0023-wasm-plugin-streaming.md: fix Response type reference - tests/fixture-plugins/streaming-echo: update HttpRequest body field to use base64_body serde encoding * Fix write_to_memory OOM: write at __data_end instead of top of memory The old approach wrote host data at mem_size - data.len() (top of linear memory), which is exactly where dlmalloc's heap lives. This caused OOM crashes in plugins that use host_http_call (e.g. oidc-auth) because the heap allocation would overwrite the input data. Now reads the __data_end WASM global at instantiation and writes host data there — between static data and the stack. This is safe because write_to_memory is only called before the WASM function executes (stack is empty at that point). * Add __heap_base bounds check to write_to_memory Read both __data_end and __heap_base WASM globals at instantiation. Input data is now bounded to the [__data_end..__heap_base] region, preventing oversized payloads from silently corrupting the heap. * fix: resolve WASM OOM by allocating input buffers via plugin's dlmalloc The previous write_to_memory approaches (writing at top of memory, then at __data_end, then via Memory::grow) all placed data in regions that dlmalloc considered its heap, causing corruption during deserialization. The fix exports alloc/dealloc from each WASM plugin (via the proc macros) so the host allocates input buffers through the plugin's own allocator. This ensures dlmalloc tracks the region and won't reuse it. Additional optimizations to reduce peak memory: - Free input buffer after deserialization (dealloc in on_request/ on_response/dispatch before output serialization) - Replace serde_json::json!() intermediate Value tree with direct ActionOutput struct serialization - Scale WASM fuel with payload size for large body processing Also adds 3 integration tests covering 500KB and 2MB body payloads through middleware + dispatcher chains, and fixes a pre-existing apikey-auth config format bug in the streaming test. * feat: add body_access capability to strip request body from middleware that doesn't need it Middleware plugins that only inspect headers (auth, rate-limit, etc.) no longer receive the base64-encoded request body, avoiding unnecessary copies of large payloads into each WASM instance. - Add `body_access` bool to plugin manifest capabilities - Compiler reads `body_access` from plugin.toml, carries it through artifact format - Engine/pool propagate the flag per compiled module - Data plane strips body (sets to null) before calling middleware without body_access, re-attaches it after; middleware with body_access can read and modify the body - Mark request-transformer, cel, request-size-limit as body_access = true - Add SPEC-008 design document * docs: update changelog for binary body support and body_access capability * perf(http-upstream): reduce peak WASM memory via move-not-clone and manual serialization Destructure Request to avoid cloning the body, pre-encode to base64 before building JSON output, and use manual SerializeMap to control allocation. Peak memory drops from ~body×3.7 to ~body×2.7, allowing 3MB uploads to fit within the 16MB WASM instance budget. Add integration test for 3MB body through middleware + dispatch. * refactor: extract BodyAccessControl with split-once pattern and 19 unit tests Replace per-middleware JSON parse/strip/reattach cycles with a single upfront body extraction. Non-body middlewares get the pre-built body-less JSON directly; body-access middlewares get one inject/extract cycle. - New `BodyAccessControl` struct in barbacane-wasm with split/request_for/ update_after_middleware/finalize API - 19 unit tests covering split, inject, update, finalize, full chain scenarios (mixed, all-no-access, all-access), and edge cases - Simplify execute_middleware_on_request to 3 lines per middleware - Document body_access capability in CLAUDE.md * fix: add base64_body serde to host HttpRequest/HttpResponse and all plugin HttpResponse structs The host-side HttpRequest/HttpResponse in http_client.rs was missing #[serde(with = "base64_body")], causing body encoding mismatch with WASM plugins after the Vec<u8> body migration. Plugins would receive a base64 string but try to deserialize it as a byte array (or vice versa), causing silent corruption or deserialization failures. Also scale WASM memory to max(16MB, max_body_size * 4) so dispatchers have headroom for large file uploads. Affected plugins: http-upstream, lambda, ai-proxy, s3, opa-authz, oauth2-auth, oidc-auth. Add 5 unit tests for host↔plugin base64 body serde compatibility. * fix: strip response body for non-body-access middleware (prevents WASM OOM) Mirror SPEC-008 body_access stripping for the on_response path. Middleware without body_access = true now receives responses with body: null, preventing large upstream responses (e.g. file downloads) from being base64-encoded into every middleware's WASM linear memory. Also strips response body in the streaming on_response path (observability-only, modifications are discarded anyway). Adds body_access = true to response-transformer plugin manifest since it modifies response bodies and needs access to them. * test: add workload integration tests and serde contract tests Add body-echo fixture plugin (dispatcher that echoes request body/metadata), 14 workload integration tests covering body integrity through middleware chains, large payloads, and response body passthrough, plus 10 serde compatibility tests verifying wire format between host and plugin types. These tests would have caught the base64_body serde mismatch, body corruption through middleware chains, and response body OOM issues proactively. * docs: update test count to 1421 * feat: side-channel body passing via host functions (eliminates WASM OOM) Bodies now travel as raw bytes via dedicated host functions instead of base64-encoded inside JSON. This eliminates the ~3.65x memory overhead per boundary crossing, allowing 10MB+ bodies within the 16MB default WASM memory limit. New host functions: host_body_len, host_body_read, host_body_set, host_body_clear, host_http_response_body_len, host_http_response_body_read, host_http_request_body_set. Request/Response.body is now #[serde(skip)] — proc macros handle the side-channel protocol transparently. base64_body module retained for host-side HTTP types and cache entries. All 27 plugins migrated. WASM memory formula: max(16MB, body + 4MB). * docs: update changelog and plugin guide for side-channel body passing - Changelog: document side-channel host functions, body_access fix, WASM memory formula change, and plugin migration - Plugin guide: update body docs from base64 to side-channel, update HTTP call example, update WASM memory limit table * docs: update test count to 1388 * docs: update ADR-0023 and SPEC-008 for side-channel body passing - ADR-0023: remove outdated "base64-encoded in JSON" reference - SPEC-008: mark as Implemented, update problem statement, pseudocode, examples, and performance table to reflect side-channel protocol
1 parent 287a6df commit 3c7092c

99 files changed

Lines changed: 5090 additions & 957 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **wasm**: side-channel body passing via 7 new host functions (`host_body_len`, `host_body_read`, `host_body_set`, `host_body_clear`, `host_http_response_body_len`, `host_http_response_body_read`, `host_http_request_body_set`)
12+
- Bodies travel as raw bytes instead of base64-encoded inside JSON
13+
- Eliminates ~3.65× memory overhead per boundary crossing
14+
- 10MB+ bodies work within the default 16MB WASM memory limit
15+
- **plugin-sdk**: `barbacane_plugin_sdk::body` module — side-channel body helpers (`read_request_body`, `set_response_body`, `clear_response_body`, `read_http_response_body`, `set_http_request_body`)
16+
17+
### Fixed
18+
- **wasm**: fix WASM OOM on large request bodies — input buffers now allocated via plugin's own dlmalloc instead of writing to top-of-memory
19+
- **wasm**: short-circuit response bodies now always collected regardless of `body_access` flag (fixes empty error responses from middleware like opa-authz)
20+
21+
### Changed
22+
- **plugin-sdk**: `Request.body` and `Response.body` now use `#[serde(skip)]` — bodies travel via side-channel, not JSON. Proc macros handle the protocol transparently.
23+
- **plugin-sdk**: request/response body is now `Option<Vec<u8>>` (was `Option<String>`), enabling binary payload support across all plugins
24+
- **wasm**: `body_access` capability — middleware that doesn't declare `body_access = true` receives `null` body, reducing WASM memory usage
25+
- `request-transformer`, `response-transformer`, `cel`, `request-size-limit` declare `body_access = true`; all other middleware plugins skip the body
26+
- **wasm**: WASM memory formula changed from `max(16MB, body × 4)` to `max(16MB, body + 4MB)`
27+
- **plugins**: all 27 plugins migrated to side-channel body protocol; `http-upstream` removed `base64` dependency
28+
1029
## [0.3.1] - 2026-03-13
1130

1231
### Fixed

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ context_get = true
105105
| `verify_signature` | `host_verify_signature` |
106106
| `rate_limit` | `host_rate_limit_check`, `host_rate_limit_read_result` |
107107
| `cache` | `host_cache_get`, `host_cache_set`, `host_cache_read_result` |
108+
| `body_access` | *(none — controls whether `on_request` receives the request body)* |
109+
110+
`body_access` is not a host function capability — it's a manifest-level flag (SPEC-008). When `false` (default for middleware), the host strips the request body before calling `on_request`, reducing WASM memory usage. Dispatchers always receive the body regardless. Set `body_access = true` only for middleware that reads or modifies `request.body`.
108111

109112
### Building Plugins
110113

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
<p align="center">
1010
<a href="https://github.com/barbacane-dev/barbacane/actions/workflows/ci.yml"><img src="https://github.com/barbacane-dev/barbacane/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
1111
<a href="https://docs.barbacane.dev"><img src="https://img.shields.io/badge/docs-docs.barbacane.dev-blue" alt="Documentation"></a>
12-
<img src="https://img.shields.io/badge/unit%20tests-399%20passing-brightgreen" alt="Unit Tests">
13-
<img src="https://img.shields.io/badge/plugin%20tests-649%20passing-brightgreen" alt="Plugin Tests">
14-
<img src="https://img.shields.io/badge/integration%20tests-192%20passing-brightgreen" alt="Integration Tests">
12+
<img src="https://img.shields.io/badge/unit%20tests-444%20passing-brightgreen" alt="Unit Tests">
13+
<img src="https://img.shields.io/badge/plugin%20tests-636%20passing-brightgreen" alt="Plugin Tests">
14+
<img src="https://img.shields.io/badge/integration%20tests-237%20passing-brightgreen" alt="Integration Tests">
1515
<img src="https://img.shields.io/badge/cli%20tests-16%20passing-brightgreen" alt="CLI Tests">
1616
<img src="https://img.shields.io/badge/ui%20tests-44%20passing-brightgreen" alt="UI Tests">
1717
<img src="https://img.shields.io/badge/e2e%20tests-11%20passing-brightgreen" alt="E2E Tests">

adr/0023-wasm-plugin-streaming.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
## Context
77

8-
The current dispatch contract requires plugins to return a fully buffered `Response` (status + headers + `Option<String>` body). This works for typical API proxying but breaks down for use cases that require streaming responses to clients:
8+
The current dispatch contract requires plugins to return a fully buffered `Response` (status + headers + `Option<Vec<u8>>` body). Bodies travel via side-channel host functions as raw bytes (see SPEC-008). This works for typical API proxying but breaks down for use cases that require streaming responses to clients:
99

1010
- **LLM completions** — Chat APIs (OpenAI, Anthropic) stream tokens via SSE, often taking 10–60 seconds. Buffering the entire response before sending it to the client defeats the purpose of streaming and creates unacceptable latency.
1111
- **Event streams** — Server-Sent Events, webhook relays.

crates/barbacane-compiler/src/artifact.rs

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,27 @@ pub struct BundledPlugin {
113113
pub wasm_path: String,
114114
/// SHA-256 hash of the WASM file.
115115
pub sha256: String,
116+
/// Plugin capabilities declared in plugin.toml.
117+
pub capabilities: PluginCapabilities,
118+
}
119+
120+
/// Plugin capabilities stored in the artifact manifest.
121+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
122+
pub struct PluginCapabilities {
123+
/// Whether the middleware receives the request body in `on_request`.
124+
#[serde(default)]
125+
pub body_access: bool,
126+
}
127+
128+
/// A plugin loaded from a .bca artifact, ready for compilation.
129+
#[derive(Debug, Clone)]
130+
pub struct LoadedPlugin {
131+
/// Plugin version.
132+
pub version: String,
133+
/// WASM binary content.
134+
pub wasm_bytes: Vec<u8>,
135+
/// Whether this plugin needs the request body.
136+
pub body_access: bool,
116137
}
117138

118139
/// Metadata about a source spec included in the artifact.
@@ -222,6 +243,7 @@ pub fn compile_with_manifest(
222243
version: p.version.unwrap_or_else(|| "0.1.0".to_string()),
223244
plugin_type: p.plugin_type.unwrap_or_else(|| "plugin".to_string()),
224245
wasm_bytes: p.wasm_bytes,
246+
body_access: p.body_access,
225247
})
226248
.collect();
227249

@@ -302,10 +324,8 @@ pub fn load_specs(artifact_path: &Path) -> Result<HashMap<String, String>, Compi
302324
}
303325

304326
/// Load all bundled plugins from a .bca artifact.
305-
/// Returns a map of plugin name -> (version, WASM bytes).
306-
pub fn load_plugins(
307-
artifact_path: &Path,
308-
) -> Result<HashMap<String, (String, Vec<u8>)>, CompileError> {
327+
/// Returns a map of plugin name -> LoadedPlugin.
328+
pub fn load_plugins(artifact_path: &Path) -> Result<HashMap<String, LoadedPlugin>, CompileError> {
309329
// First load manifest to get plugin metadata
310330
let manifest = load_manifest(artifact_path)?;
311331

@@ -315,21 +335,28 @@ pub fn load_plugins(
315335

316336
let mut plugins = HashMap::new();
317337

318-
// Build a map of wasm_path -> (name, version) from manifest
319-
let plugin_info: HashMap<String, (String, String)> = manifest
338+
// Build a map of wasm_path -> plugin info from manifest
339+
let plugin_info: HashMap<String, (&BundledPlugin,)> = manifest
320340
.plugins
321341
.iter()
322-
.map(|p| (p.wasm_path.clone(), (p.name.clone(), p.version.clone())))
342+
.map(|p| (p.wasm_path.clone(), (p,)))
323343
.collect();
324344

325345
for entry in archive.entries()? {
326346
let mut entry = entry?;
327347
let path_str = entry.path()?.to_string_lossy().into_owned();
328348

329-
if let Some((name, version)) = plugin_info.get(&path_str) {
349+
if let Some((bundled,)) = plugin_info.get(&path_str) {
330350
let mut wasm_bytes = Vec::new();
331351
entry.read_to_end(&mut wasm_bytes)?;
332-
plugins.insert(name.clone(), (version.clone(), wasm_bytes));
352+
plugins.insert(
353+
bundled.name.clone(),
354+
LoadedPlugin {
355+
version: bundled.version.clone(),
356+
wasm_bytes,
357+
body_access: bundled.capabilities.body_access,
358+
},
359+
);
333360
}
334361
}
335362

@@ -346,6 +373,8 @@ pub struct PluginBundle {
346373
pub plugin_type: String,
347374
/// WASM binary content.
348375
pub wasm_bytes: Vec<u8>,
376+
/// Whether this plugin needs the request body.
377+
pub body_access: bool,
349378
}
350379

351380
/// Parse spec files into (ApiSpec, content, sha256) tuples.
@@ -583,6 +612,9 @@ fn compile_inner(
583612
plugin_type: plugin.plugin_type.clone(),
584613
wasm_path,
585614
sha256,
615+
capabilities: PluginCapabilities {
616+
body_access: plugin.body_access,
617+
},
586618
});
587619
}
588620

@@ -1280,6 +1312,7 @@ paths:
12801312
version: "1.0.0".to_string(),
12811313
plugin_type: "middleware".to_string(),
12821314
wasm_bytes: fake_wasm.clone(),
1315+
body_access: false,
12831316
}];
12841317

12851318
let result = compile(
@@ -1302,9 +1335,9 @@ paths:
13021335
// Load plugins back
13031336
let loaded = load_plugins(&output_path).unwrap();
13041337
assert_eq!(loaded.len(), 1);
1305-
let (version, wasm_bytes) = loaded.get("test-plugin").unwrap();
1306-
assert_eq!(version, "1.0.0");
1307-
assert_eq!(wasm_bytes, &fake_wasm);
1338+
let plugin = loaded.get("test-plugin").unwrap();
1339+
assert_eq!(plugin.version, "1.0.0");
1340+
assert_eq!(plugin.wasm_bytes, fake_wasm);
13081341
}
13091342

13101343
#[test]

crates/barbacane-compiler/src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ pub mod spec_parser;
1010

1111
pub use artifact::{
1212
compile, compile_with_manifest, load_manifest, load_plugins, load_routes, load_specs,
13-
BundledPlugin, CompileOptions, CompileResult, CompiledOperation, CompiledRoutes, Manifest,
14-
PluginBundle, Provenance, SourceSpec, ARTIFACT_VERSION, COMPILER_VERSION,
13+
BundledPlugin, CompileOptions, CompileResult, CompiledOperation, CompiledRoutes, LoadedPlugin,
14+
Manifest, PluginBundle, PluginCapabilities, Provenance, SourceSpec, ARTIFACT_VERSION,
15+
COMPILER_VERSION,
1516
};
1617
pub use error::{CompileError, CompileWarning};
1718
pub use manifest::{

crates/barbacane-compiler/src/manifest.rs

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ use crate::error::CompileError;
1515
#[derive(Debug, Deserialize)]
1616
struct PluginToml {
1717
plugin: PluginMeta,
18+
#[serde(default)]
19+
capabilities: PluginTomlCapabilities,
1820
}
1921

2022
#[derive(Debug, Deserialize)]
@@ -24,12 +26,29 @@ struct PluginMeta {
2426
plugin_type: String,
2527
}
2628

29+
#[derive(Debug, Default, Deserialize)]
30+
struct PluginTomlCapabilities {
31+
#[serde(default)]
32+
body_access: bool,
33+
}
34+
35+
/// Plugin metadata extracted from plugin.toml.
36+
struct PluginMetadata {
37+
version: String,
38+
plugin_type: String,
39+
body_access: bool,
40+
}
41+
2742
/// Try to read plugin metadata from plugin.toml in the same directory as the WASM file.
28-
fn read_plugin_metadata(wasm_path: &Path) -> Option<(String, String)> {
43+
fn read_plugin_metadata(wasm_path: &Path) -> Option<PluginMetadata> {
2944
let plugin_toml_path = wasm_path.parent()?.join("plugin.toml");
3045
let content = std::fs::read_to_string(&plugin_toml_path).ok()?;
3146
let parsed: PluginToml = toml::from_str(&content).ok()?;
32-
Some((parsed.plugin.version, parsed.plugin.plugin_type))
47+
Some(PluginMetadata {
48+
version: parsed.plugin.version,
49+
plugin_type: parsed.plugin.plugin_type,
50+
body_access: parsed.capabilities.body_access,
51+
})
3352
}
3453

3554
/// Resolve a WASM path from a plugin source, relative to a base path.
@@ -79,22 +98,21 @@ fn resolve_plugin(
7998
}
8099

81100
// Try to read plugin metadata from plugin.toml
82-
let (version, plugin_type) = match source {
101+
let metadata = match source {
83102
PluginSource::Path(path_source) => {
84103
let wasm_path = resolve_wasm_path(path_source, base_path);
85104
read_plugin_metadata(&wasm_path)
86-
.map(|(v, t)| (Some(v), Some(t)))
87-
.unwrap_or((None, None))
88105
}
89-
PluginSource::Url(_) => (None, None),
106+
PluginSource::Url(_) => None,
90107
};
91108

92109
Ok(ResolvedPlugin {
93110
name: name.to_string(),
94111
source: source.description(),
95112
wasm_bytes,
96-
version,
97-
plugin_type,
113+
version: metadata.as_ref().map(|m| m.version.clone()),
114+
plugin_type: metadata.as_ref().map(|m| m.plugin_type.clone()),
115+
body_access: metadata.as_ref().is_some_and(|m| m.body_access),
98116
})
99117
}
100118

@@ -155,6 +173,8 @@ pub struct ResolvedPlugin {
155173
pub version: Option<String>,
156174
/// Plugin type: "middleware" or "dispatcher" (from plugin.toml if available).
157175
pub plugin_type: Option<String>,
176+
/// Whether this plugin needs the request body in `on_request`.
177+
pub body_access: bool,
158178
}
159179

160180
impl ProjectManifest {

crates/barbacane-control/src/compiler/worker.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,8 @@ async fn resolve_project_plugins(
262262
version: plugin_with_binary.version.clone(),
263263
plugin_type: plugin_with_binary.plugin_type.clone(),
264264
wasm_bytes: plugin_with_binary.wasm_binary,
265+
// TODO: read body_access from DB once the plugins table has a capabilities column
266+
body_access: false,
265267
});
266268
}
267269

0 commit comments

Comments
 (0)