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 8080

Echo (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:8443

TLS-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
  1. In Rust, register every handler name you will mention in XML.
  2. Optionally use the _with_telemetry load variant (telemetry is not in XML v1, and must be known before the Runtime starts).
  3. Load with Composition::from_xml_str / from_xml / from_xml_path (or their _with_telemetry siblings) — 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, &reg)?; // already running

Unknown 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, &reg) &str
Composition::from_xml(bytes, &reg) &[u8]
Composition::from_xml_path(path, &reg) 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, &reg)?;
let addr = comp.primary_addr().unwrap(); // actual bound address

Full 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",
    &reg,
    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.

Next steps