Both request and response use the same length-prefixed layout:
bytes 0..4 : u32 BE = header_json byte length N
bytes 4..4+N : UTF-8 JSON
(request) { "v":1, "method", "path",
"query"?, "headers"? }
(response) { "v":1, "status", "headers",
"metadata", "validation_errors"? }
bytes 4+N.. : raw body bytes (UTF-8 text or binary —
no encoding applied)
Key properties:
"v":1 is the protocol version; mismatched versions return a 400 wire response"validation_errors" is an optional array hoisted from 422 JSON bodies — Java decoders read validation errors from the header without parsing the bodyVesperaBridge exposes seven native methods — all sharing the same wire format, the same registered router, and the same panic-safe catch_unwind discipline:
| Method | Mode | Java return | Memory |
|---|---|---|---|
dispatchBytes(byte[]) | sync | byte[] (header + body) | full body in memory |
dispatchAsync(CompletableFuture, byte[]) | async | void (future completes) | full body in memory |
dispatchStreaming(byte[], OutputStream) | sync, response-streaming | byte[] (header only) | chunk-bounded response |
dispatchFullStreaming(byte[], InputStream, OutputStream) | sync, bidirectional streaming | byte[] (header only) | chunk-bounded both ways |
dispatchStreamingWithHeader(byte[], Consumer, OutputStream) | sync, response-streaming | void (header via callback) | chunk-bounded response |
dispatchFullStreamingWithHeader(byte[], Consumer, InputStream, OutputStream) | sync, bidirectional streaming | void (header via callback) | chunk-bounded both ways |
dispatchDirect(ByteBuffer, int, ByteBuffer) | sync, direct buffers | int (response length / overflow code) | no Java heap arrays |
dispatchBytesdispatchDirect / dispatchDirectPooleddispatchAsync + CompletableFuturedispatchStreaming + OutputStreamdispatchFullStreaming + InputStream + OutputStream*WithHeader variants let Spring-style controllers commit status/headers before the first body byte is writtenThe autoconfigured default since vespera-bridge 0.2.0 picks the cheapest safe path per request. Measured on a GET /health round-trip through the real JNI boundary:
| Request shape | Mode | ns / round-trip |
|---|---|---|
| Small/bodyless + idempotent (GET/HEAD/PUT/DELETE/OPTIONS, ≤ 256 KiB) | DIRECT | ~2,200 |
| Small (≤ 256 KiB Content-Length) + non-idempotent (POST/PATCH) | SYNC | ~3,200 |
| Large or unknown-length body | BIDIRECTIONAL_STREAMING | ~24,100 |
Trade-offs:
ByteBuffer (64 KiB → vespera.direct.maxBufferBytes, default 4 MiB). Responses larger than the pooled buffer trigger a single retry that re-runs the Rust handler — which is why DIRECT is gated on idempotent methods only.Restore the pre-0.2.0 default (every request that may carry a body streams both ways, ~24 µs uniform):
vespera:
bridge:
dispatch-mode: bidirectional-streamingdispatchDirect(ByteBuffer in, int inLen, ByteBuffer out) eliminates the two JNI GetByteArrayRegion/SetByteArrayRegion copies that dispatchBytes pays. The response is streamed straight into the out buffer — no intermediate Vec. Measured at 1.4–3.4× per round-trip versus dispatchBytes depending on payload size.
Contract:
ByteBuffer.allocateDirect); heap buffers are rejected with IllegalArgumentExceptionin[0..inLen] — the buffer's position/limit are ignored; inLen is authoritative>= 0: a complete wire response occupies out[0..n]< 0: -(requiredSize) — the response did not fit; retrying re-runs the Rust handler, so only retry idempotent requestsInteger.MIN_VALUE: response exceeds 2 GiBdispatchDirectPooled(byte[] wireRequest, boolean retryOnOverflow) wraps the raw call with per-thread reusable direct buffers (64 KiB initial, doubling up to vespera.direct.maxBufferBytes, default 4 MiB).
import com.devfive.vespera.bridge.VesperaBridge;
import com.devfive.vespera.bridge.VesperaBridge.DecodedResponse;
// 1. Initialise once at startup
VesperaBridge.init("my_rust_lib");
// 2. Encode a request
byte[] wireRequest = VesperaBridge.encodeRequest(
"POST",
"/documents/validate",
/* query */ null,
Map.of("content-type", "application/json"),
"{\"title\":\"…\"}".getBytes(StandardCharsets.UTF_8));
// 3. Dispatch through Rust
byte[] wireResponse = VesperaBridge.dispatchBytes(wireRequest);
// 4. Decode
DecodedResponse resp = VesperaBridge.decodeResponse(wireResponse);
System.out.println(resp.status()); // 200
System.out.println(resp.headers()); // { "content-type": "application/json", … }
System.out.println(new String(resp.bodyBytes())); // copies the raw response body0.2.0 breaking change:
DecodedResponse.body()now returns a read-onlyjava.nio.ByteBuffer(zero-copy view over the wire bytes). The ownedbyte[]materialisation moved toDecodedResponse.bodyBytes(). Callers that previously usedbody()asbyte[]must switch tobodyBytes().
CompletableFuture<byte[]> future = VesperaBridge.dispatch(wireRequest);
future.thenAccept(wireResponse -> {
DecodedResponse resp = VesperaBridge.decodeResponse(wireResponse);
System.out.println("Status: " + resp.status());
});The future is always completed with a valid wire response, even on Rust panics or JNI conversion failures. You will never see a dangling future.
byte[] wireRequest = VesperaBridge.encodeRequest(
"GET", "/files/large.pdf", null, Map.of(), new byte[0]);
try (ByteArrayOutputStream sink = new ByteArrayOutputStream()) {
byte[] headerOnly = VesperaBridge.dispatchStreaming(wireRequest, sink);
DecodedResponse meta = VesperaBridge.decodeResponse(headerOnly);
System.out.println("Status: " + meta.status());
System.out.println("Body size: " + sink.size());
}try (InputStream upload = Files.newInputStream(Path.of("huge.mp4"));
OutputStream download = Files.newOutputStream(Path.of("transcoded.mp4"))) {
byte[] wireHeader = VesperaBridge.encodeRequestHeader(
"POST", "/transcode", null,
Map.of("content-type", "video/mp4"));
byte[] respHeader = VesperaBridge.dispatchFullStreaming(
wireHeader, upload, download);
DecodedResponse meta = VesperaBridge.decodeResponse(respHeader);
System.out.println("Status: " + meta.status());
}A 1 GiB upload paired with a 1 GiB download runs in low-single-digit MiB resident memory on each side. Backpressure is enforced naturally — if Axum reads slowly, InputStream.read() blocks on the bounded channel.