Files
alkhttp/docs/architecture/decisions/070-from-wss-consumer-adapter.md
T
glm-5.3-flash a80f9948b8 feat(build): feature-sided builds — server/client sides independently selectable (ADR-039 Amendment 1)
Split the feature graph so consumers pulling only the import adapters
(from_openapi / from_jsonschema / from_mcp) no longer compile the axum
/ hyper server stack, and server-only deployments no longer compile
reqwest. One crate, one import path — sides cut by features, not by a
crate split.

Feature graph:
- server (default): axum host, gateway, WS upgrade, to_openapi, to_mcp
- client (default): client host, forward, from_jsonschema, from_openapi
- openapi: shared OpenAPISpec model (implied by both sides)
- mcp: from_mcp needs client, to_mcp needs server
- wss: tungstenite transport (from_wss); tungstenite half of the
  shared WS↔byte-stream adapter
- h2/http1: hyper protocol features; imply server

Wire-contract neutral: gateway endpoints, ALPNs, and all public API
shapes unchanged; defaults keep both sides on.

Supporting changes:
- forward.rs drops its axum::body::Bytes type leak (bytes crate types)
- bounded_join + error-echo caps move to input_validation (usable by
  both sides; openapi_spec no longer imports from forward)
- byte_adapter: axum flavor compiles under server, tungstenite under
  wss; the generic pumps stay shared (WS-11)
- input_validation / openapi_spec import-only internals gated to the
  side that consumes them
- http-body-util moves to dev-dependencies (was test-only)
- integration-test required-features updated for the new sides
- from_wss unit tests (axum producer harness) gated to server

Verified: cargo test (defaults, 453) and --all-features (575) pass;
lean side builds (client / server / client,mcp / client,wss /
server,wss / openapi-only) build clean with zero warnings;
clippy -D warnings clean across all feature combinations; fmt clean.
2026-08-31 17:19:05 +00:00

8.1 KiB

ADR-070: from_wss — the WSS Consumer Adapter

Status

Accepted

Context

The alknet design named from_wss as a future, out-of-scope adapter (alknet websocket.md §"Future"): importing a remote alk node's operations over a WebSocket connection, mirroring from_call's pattern with WSS as the transport. It was deferred there because no concrete consumer existed.

Extraction changes the calculus. The browser path is now channels-over-WebSocket (ADR-067), which means:

  1. The server half of WS-channels is being built anyway. The consumer half (ChannelClient-over-WS) is the mirror image sharing the same WS↔byte-stream adapter (OQ-01) — building it later means re-validating the adapter independently.
  2. A deployment pattern became concrete: a Rust process behind a restrictive network (outbound-only HTTPS/WSS allowed, no QUIC reachability) that needs to consume another node's operations. QUIC (from_call over an alk/call connection) and TCP+TLS (alk/channels) both require reachability the deployment may not have; WSS rides standard HTTPS infrastructure.
  3. alkcall provides both halves. ChannelClient (alkcall ADR-043) is the channels consumer; from_call (alkcall ADR-028) is the same-protocol importer pattern. from_wss composes them over a WSS transport.

Decision

alkhttp ships from_wss: a consumer adapter that connects to a remote node's WSS endpoint, runs the channels-over-WS session, and imports the remote node's operations as forwarding handlers — the same-protocol importer, with WSS as the transport instead of QUIC.

  • Feature gate: wss = ["dep:tokio-tungstenite"] (not default — a process that never consumes over WSS should not compile a WS client). from_wss itself additionally requires the client feature side (ADR-039 Amendment 1).

  • Shape: implements OperationAdapter (ADR-017; record: alkcall ADR-022):

    pub struct FromWss {
        endpoint: url::Url,          // wss://host/alk/channels
        auth_token: Option<String>,  // injected via Capabilities at registration
        namespace: Option<String>,   // local-naming sugar, as from_call's namespace_prefix
    }
    
    #[async_trait]
    impl OperationAdapter for FromWss {
        async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
    }
    
  • Import flow: dial WSS → wrap the tungstenite stream as a Connection (Connection::from_bidi, ALPN alk/channels) → run the consumer side of the channels session (alkcall ChannelClient machinery: install channel 0, run the client dispatch loop) → services/list + services/schema over channel 0 → one forwarding HandlerRegistration per discovered op (provenance FromCall, leaf, Internal by default — ADR-015, ADR-022).

  • Forwarding at call time: each imported op's handler serializes the call input as a call.requested frame, writes it (length-prefixed JSON, alkcall ADR-014) into channel 0's write half, and correlates call.responded/call.completed/call.aborted by id via the pending map — identical to from_call's forwarding shape, because the protocol is the same protocol.

  • Auth: the WSS upgrade request carries Authorization: Bearer <token>; the token comes from OperationContext.capabilities at handler construction (the no-env-vars path — ADR-014), never from std::env::var.

  • Relationship to the server path: the WS↔byte-stream adapter is shared with the server-side upgrade handler (OQ-01) — one implementation, used in both directions. The session itself is alkcall's (ChannelClient consumer half + channel 0 dispatch loop), not forked.

Not in scope

  • Reconnection policy semantics (auto-reconnect, stale registration invalidation): tracked in OQ-03; v1 surfaces a connection-drop as imported-op call failures (INTERNAL, retryable) and leaves policy to the assembly layer.
  • Serving WSS from the consumer side — the producer side of a WSS session is the HttpAdapter upgrade route (ADR-067); no separate server type.
  • from_wss over non-TLS ws:// — plaintext WS is refused when a Bearer token is present unless FromWss::allow_plaintext was called explicitly (review-001 CON-03): a long-lived credential must not ride an unencrypted connection without an explicit opt-in. Plaintext for local/test use without a token stays allowed.

Explicit session-lifetime limitation (v1)

There is no teardown handle on FromWss in v1 (review-001 CON-08/CON-09): import() detaches the session fire-and-forget so the imported handlers keep working off the Arc'd CallConnection, and nothing closes the underlying server-side session when the assembly layer is done with the import. Consequences, stated explicitly:

  • Calling import() again (e.g. on a reconnect timer) stacks a second full WS session over the first: duplicate op names in the registry, and the original session is never torn down.
  • v1 disposition: import once per process; treat the imported surface as live for the process lifetime. A reconnecting assembly layer should tear down its whole registry and re-import, accepting the accumulated server-side sessions until the remote times them out.
  • Close/teardown handle: future work; there is no WssSession::close in v1 (OQ-03). The connection-drop monitor is however self-limiting (review-002 CON-18): once WS read EOF is observed, pending calls fail with retryable CONNECTION_CLOSED, registrations racing the drop are drained by a fast-fail sweep (50 ms interval) during a bounded post-EOF grace window (8 consecutive empty drains), and the monitor task then ends — a dead import leaves no permanent task behind.

Consequences

Positive:

  • Consumers behind outbound-only HTTPS get the full operation surface of a remote node without QUIC/TCP reachability.
  • The WS↔byte-stream adapter is validated in both directions by construction (server upgrade path + consumer path share it).
  • Same-protocol import means zero translation: the remote node's ops appear in the local registry with their real specs and error schemas.
  • A dead import (peer gone) self-cleans: after EOF the drop monitor fails everything pending, drains drop-racing registrations for a bounded grace window, and ends — no per-dead-session task leak scales with import count (review-002 CON-18).

Negative:

  • One more WS client dependency in the tree when wss is enabled (tokio-tungstenite).
  • Long-lived WSS connections behind proxies/load balancers need keepalive/timeout tuning — deployment concern, but worth documenting.
  • Reconnection semantics are initially minimal (OQ-03); a consumer wanting hot re-registration must wait for or build the policy.
  • Registrations landing in the pending map after the bounded post-EOF grace window elapses wait for the 30 s sweeper deadline instead of failing fast — acceptable because registration-vs-EOF races resolve well within the window in practice, and the mux write-failure path covers the rest.

References

  • websocket.md — the channels-over-WS session spec (framing shared with this adapter)
  • ADR-067 — the server-side counterpart (upgrade path /alk/channels)
  • ADR-017 — the OperationAdapter contract
  • ADR-014 — credential injection (the WSS bearer token path)
  • ADR-048 — why the consumer speaks the native session, not the gateway shape
  • alkcall ADR-022 (adapter contract), ADR-028 (from_call — the same-protocol importer pattern this mirrors), ADR-043 (ChannelClient), ADR-014 (framing), ADR-034/035 (channels wire)
  • open-questions.md OQ-01, OQ-03