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.
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:
- 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. - 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_callover analk/callconnection) and TCP+TLS (alk/channels) both require reachability the deployment may not have; WSS rides standard HTTPS infrastructure. - alkcall provides both halves.
ChannelClient(alkcall ADR-043) is the channels consumer;from_call(alkcall ADR-028) is the same-protocol importer pattern.from_wsscomposes 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_wssitself additionally requires theclientfeature 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, ALPNalk/channels) → run the consumer side of the channels session (alkcallChannelClientmachinery: install channel 0, run the client dispatch loop) →services/list+services/schemaover channel 0 → one forwardingHandlerRegistrationper discovered op (provenanceFromCall, leaf,Internalby default — ADR-015, ADR-022). -
Forwarding at call time: each imported op's handler serializes the call input as a
call.requestedframe, writes it (length-prefixed JSON, alkcall ADR-014) into channel 0's write half, and correlatescall.responded/call.completed/call.abortedbyidvia the pending map — identical tofrom_call's forwarding shape, because the protocol is the same protocol. -
Auth: the WSS upgrade request carries
Authorization: Bearer <token>; the token comes fromOperationContext.capabilitiesat handler construction (the no-env-vars path — ADR-014), never fromstd::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 (
ChannelClientconsumer 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
HttpAdapterupgrade route (ADR-067); no separate server type. from_wssover non-TLSws://— plaintext WS is refused when a Bearer token is present unlessFromWss::allow_plaintextwas 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::closein 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 retryableCONNECTION_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
wssis 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
OperationAdaptercontract - 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