Getting started
Build the workspace, then run the demos in order. Prefer Composition over ad-hoc Runtime::add_tcp_listener calls — either the Rust builder or declarative XML that desugars into the same builder.
Build and demos
cargo check --workspace
cargo run -p echo -- 127.0.0.1:8080
# other terminal: nc 127.0.0.1 8080Echo (plain TCP)
examples/echo starts a Runtime, binds with Runtime::add_tcp_listener, and echoes bytes in a ProtocolHandler. See echo.
TLS echo
cargo run -p tls-echo -- 127.0.0.1:8443TLS-from-accept on a plain ProtocolHandler: acceptor_from_pem → TcpListenerConfig::with_tls; handlers see plaintext after security_established. See TLS → Architecture and tls-echo.
HTTP hello
cargo run -p http-hello -- 127.0.0.1:8080
curl -v http://127.0.0.1:8080/Cleartext H1 plus h2c (prior-knowledge and Upgrade). With --tls, ALPN selects h2 / http/1.1. Peer client: http-get. See HTTP overview.
Rust Composition
Instead of calling add_tcp_listener ad hoc, queue binds on Composition:
use hopf_core::{Composition, RuntimeConfig, TcpListenerConfig, ProtocolHandler};
let mut comp = Composition::new(RuntimeConfig::default())?;
comp.listen_tcp(TcpListenerConfig::new(addr, || {
Box::new(MyHandler) as Box<dyn ProtocolHandler>
}))?;
let bound = comp.primary_addr().unwrap();
// … later …
comp.remove_binding(comp.bindings[0]);
comp.shutdown();XML (below) produces the same builder; pick whichever fits the deployment.
XML composition
Declarative composition files are elements and attributes only — no text content between tags. Parsing is via tractrix. The document desugars into Composition::new(…) + listen_tcp / dial_tcp; handler names resolve through a closed CompositionRegistry, not reflection or classpath scanning.
Mental model
XML file ──parse──► Runtime starts, bindings applied
│
CompositionRegistry ───────┘ (handler="echo" → factory)
│
running Composition
- In Rust, register every
handlername you will mention in XML. - Optionally use the
_with_telemetryload variant (telemetry is not in XML v1, and must be known before the Runtime starts). - Load with
Composition::from_xml_str/from_xml/from_xml_path(or their_with_telemetrysiblings) — this starts the Runtime and applies every binding immediately. There is no separate build step.
Register handlers, then load
handler="…" is a connection-level ProtocolHandler factory (Fn() -> Box<dyn ProtocolHandler>). For HTTP you typically register a CleartextHttpEndpoint / AlpnHttpEndpoint factory under a short name — not an application Stream handler name.
use std::sync::Arc;
use hopf_core::{Composition, CompositionRegistry, ProtocolHandler};
struct Echo;
impl ProtocolHandler for Echo {
fn connected(&mut self, _: &mut dyn hopf_core::Endpoint) {}
fn receive(&mut self, endpoint: &mut dyn hopf_core::Endpoint, data: &mut &[u8]) {
endpoint.send(data);
*data = &[];
}
fn disconnected(&mut self, _: &mut dyn hopf_core::Endpoint) {}
fn error(&mut self, _: &mut dyn hopf_core::Endpoint, _: &std::io::Error) {}
}
let mut reg = CompositionRegistry::new();
reg.register(
"echo",
Arc::new(|| Box::new(Echo) as Box<dyn ProtocolHandler>),
);
let xml = std::fs::read_to_string("composition.xml")?;
let comp = Composition::from_xml_str(&xml, ®)?; // already runningUnknown handler names fail with CompositionXmlError::Registry. Missing required attributes or unknown elements/attributes fail with CompositionXmlError::Schema.
Load APIs:
| API | Input |
|---|---|
Composition::from_xml_str(s, ®) |
&str |
Composition::from_xml(bytes, ®) |
&[u8] |
Composition::from_xml_path(path, ®) |
filesystem path |
Minimal example
Bind an ephemeral echo listener (:0 = OS-assigned port):
<?xml version="1.0"?>
<composition worker-threads="2">
<listen-tcp addr="127.0.0.1:0" handler="echo"/>
</composition>let comp = Composition::from_xml_str(xml, ®)?;
let addr = comp.primary_addr().unwrap(); // actual bound addressFull example (listen + dial + ACL)
<?xml version="1.0"?>
<composition worker-threads="4" storage-threads="4">
<listen-tcp addr="0.0.0.0:8080" handler="echo"
max-net-in="1048576" max-net-out="4194304"
idle-timeout-ms="60000">
<!-- Peer ACL: deny wins over allow. Empty allow = open (modulo deny). -->
<allow cidr="10.0.0.0/8"/>
<allow cidr="192.168.0.0/16"/>
<deny cidr="192.0.2.0/24"/>
<!-- Accept rate limit: per-source and optional global (0 = unlimited axis). -->
<rate-limit per-source="100" window-ms="1000" global="0"/>
</listen-tcp>
<!-- Outbound peer on the same Runtime (listen and dial are equal). -->
<dial-tcp addr="127.0.0.1:9090" handler="echo"
max-net-in="1048576" idle-timeout-ms="30000"/>
</composition>Multiple <listen-tcp> / <dial-tcp> children are allowed; order is preserved into the builder queues. primary_addr() is the first successful listen bind.
Document shape
| Rule | Detail |
|---|---|
| Root | Exactly one <composition> |
| Content model | Elements + attributes only; non-whitespace character data is rejected |
| Children of root | <listen-tcp> and <dial-tcp> (zero or more each) |
| Children of listen | <allow>, <deny>, <rate-limit> (optional; at most one rate-limit) |
| Children of dial | none |
| Nesting | Bindings must not nest inside each other |
<composition> attributes
| Attribute | Type | Maps to | Notes |
|---|---|---|---|
worker-threads |
usize |
RuntimeConfig::worker_threads |
0 → auto (CPU count) |
storage-threads |
usize |
StorageConfig::threads |
clamped to at least 1 |
Both are optional; omitted values keep RuntimeConfig::default().
<listen-tcp> / <dial-tcp> attributes
| Attribute | Type | Required | Default | Maps to |
|---|---|---|---|---|
addr |
socket address (host:port) |
yes | — | bind / peer |
handler |
registry name | yes | — | HandlerFactory |
max-net-in |
usize bytes |
no | 1 MiB | inbound buffer cap |
max-net-out |
usize bytes |
no | 4 MiB | outbound buffer cap |
idle-timeout-ms |
u64 |
no | unset | idle_timeout |
connect-timeout-ms |
u64 |
no | unset | connect_timeout (<dial-tcp> only) |
addr must parse as a SocketAddr (e.g. 127.0.0.1:8080, [::1]:8080). Hostnames are not resolved here — use numeric addresses (or dial-by-name APIs outside this XML schema).
Listen-only children
| Element | Attributes | Effect |
|---|---|---|
<allow cidr="…"/> |
cidr only |
append to PeerAcl.allow |
<deny cidr="…"/> |
cidr only |
append to PeerAcl.deny (deny wins) |
<rate-limit …/> |
per-source, window-ms, optional global |
AcceptRateLimit |
<rate-limit> requires per-source and window-ms. global defaults to 0 (unlimited on that axis). Duplicate <rate-limit> on one listen is an error.
CIDR examples: 10.0.0.0/8, 192.168.1.0/24, 2001:db8::/32.
See access control for ACL / rate-limit semantics.
What XML v1 does not cover
| Topic | Status |
|---|---|
TLS / secure / cert paths |
Rejected if present — wire TLS inside the registered factory or use TcpListenerConfig::with_tls in Rust |
| Telemetry | Use the _with_telemetry load variant instead of the plain from_xml_* entry point |
| QUIC / HTTP/3 | No XML elements — use listen_h3 / RuntimeQuicExt in Rust |
| TrustPolicy / DNS | Attach in the registered factory / protocol layer |
| Dynamic plugins | Closed registry only |
Example: load XML with telemetry attached (must go through the _with_telemetry entry point, since telemetry has to be known before the Runtime starts):
let comp = Composition::from_xml_path_with_telemetry(
"composition.xml",
®,
Some(hook), // Arc<dyn TelemetryHook>
)?;XML ↔︎ Rust cheat sheet
| XML | Rust |
|---|---|
<composition> |
Composition::new(RuntimeConfig) |
worker-threads |
RuntimeConfig::worker_threads |
storage-threads |
StorageConfig::threads |
<listen-tcp> |
Composition::listen_tcp |
<dial-tcp> |
Composition::dial_tcp |
handler="name" |
registry.get("name") |
<allow> / <deny> / <rate-limit> |
TcpListenerConfig ACL / rate limit |
Full attribute reference and binding lifecycle: Composition.