wshub — WebSocket aggregation
enc-wshub is a WebSocket aggregator hub for the ENC protocol. It collapses per-enclave client sockets into one client connection, multiplexed over shared, refcounted per-enclave upstream sockets to the node:
client ──(1 WS)──> HubDO ──(1 refcounted WS per enclave)──> enc-node DOsWhy
Cloudflare binds an accepted WebSocket to the single Durable Object that accepted it, and each enclave is its own DO. A client that watches its DM + Personal + N group enclaves needs N+2 sockets; a 100-member group pins 100 sockets to that enclave's DO. Connection count is O(clients × enclaves).
The ENC wire protocol was already built for multiplexing — auth is per-message (every Query
carries its own session) and the node tags every frame with a sub_id. The hub reclaims that
capacity:
- One client socket carries unlimited subscriptions to any mix of enclaves.
- The hub keeps exactly one upstream socket per enclave, shared by every client of this hub shard, refcounted open/closed.
- Query frames are forwarded verbatim —
{session, filter}stays opaque; the node verifies each client's session and enforces per-client RBAC. The hub is a metadata-onlysub_idrouter, never an auth point. sub_ids are translated client-local ↔ hub-global, so two clients can both use"s1"without colliding on the shared upstream.
Result: connection count drops to O(clients) + O(distinct live enclaves). This is the network-aggregation layer described in the litepaper.
Architecture
The hub is a Cloudflare Worker plus one Durable Object class, HubDO:
- The Worker's
fetch()routes WS upgrades to a hub shard (?shard=, defaulthub-0) viaidFromName. Non-WS requests (Commit / Query / Pull / Info POSTs,GET /enclave/<id>/sth) are transparently proxied to the node, so clients can point a single base URL at the hub. HubDOaccepts the WS upgrade as hibernatable and assigns a unique connection id.- On each
Queryit writes a routegid = <connId>.<clientSubId>, ensures an upstream socket for that enclave (opening one if needed, bumping its refcount otherwise), and forwards the Query withsub_id := gid. - On every upstream frame it translates
sub_idback to the client's local id and forwards. OnClosedit drops the route and releases the upstream refcount; an upstream that hits refcount 0 is closed. - If an upstream socket dies, affected clients are notified with a per-sub
Closed { reason: "upstream_…" }so they re-subscribe (and re-backfill). A client disconnect tears down all of that client's routes.
Wire contract
The hub is the boring side of the contract. Because one upstream WS now carries many subs
from different clients, the node must accept the caller-supplied sub_id and echo it on
every frame so the hub can demux. Required node behaviors:
Queryaccepts a caller-suppliedsub_id(used verbatim) and echoes it onEvent/EOSE/Closed/Errorframes.Close { sub_id }removes only that subscription, leaving the other subs on the same WS alive.- DO hibernation preserves all subs on the WS, not just the last one — the full sub-list is restored on wake-up.
- Tags persist across the storage round-trip, so SQL backfill returns the same
tags(["to"],["enclave_id"], …) that strict-wire decryption needs — not just the live in-memory broadcast.
ENC's reference node implementation satisfies all four, with protocol regression guards in its test suite; a node that drops these behaviors will fail them.
Develop
# 1. spawn the mock node (default :18890)
yarn mock-node
# 2. spawn the hub (default :8787 via wrangler dev)
yarn dev
# 3. drive 100 clients through it
N=100 yarn bench
# pure-model numbers (no I/O)
yarn bench:sim
# type-check
yarn typecheckDeploy
# point at the live node first
wrangler secret put ENC_NODE_URL # or set it in wrangler.jsonc vars
yarn deployThe Worker is exposed at enc-wshub.<account>.workers.dev (Cloudflare auto-subdomain).
Clients then use wss://enc-wshub.<account>.workers.dev/?shard=<id> as their WS endpoint and
the same hostname for HTTP — the hub transparently proxies non-WS traffic to ENC_NODE_URL.
Sharding
?shard=<name> picks the HubDO instance; the default hub-0 is a single shard. For
horizontal scale, hash the client's pubkey (or any stable per-client key) and pass it as
shard — each shard is its own DO with its own upstream sockets.
The number of distinct enclaves seen across all clients on a shard is that shard's upstream
socket budget — roughly N_clients_on_shard × avg_enclaves_per_client / overlap_factor.
Client integration
A client integrates the hub behind the same subscribe() call it already uses; a single
HubWS instance per node URL multiplexes every subscription over one socket. Each
subscription gets a caller-supplied sub_id (derived from the enclave id plus a per-instance
counter) that is unique within the HubWS instance, so the switch between per-enclave sockets
and hub multiplexing is transparent to application code.
Heartbeats
Both directions ping every 25 s with a 10 s pong deadline:
- Client → hub — keeps the client WS alive across Cloudflare edge idle timeouts; on a missed pong the client closes and reconnects.
- Hub → upstream node — Cloudflare Workers silently idle-close an outbound WS after ~100 s
without always firing a close event, so the hub force-closes a stale upstream on a missed
pong, notifies routed clients with
Closed { reason: "upstream_closed" }, and they re-subscribe (new upstream + backfill catches up missed events).
Plain 'ping' gets a plain 'pong' (no JSON envelope), and the hub forwards client pings to
the upstream so a silent idle drop surfaces on either side.
Limits
- HubDO hibernation. When all client WSes are idle, Cloudflare can hibernate the
HubDO; the in-memory route/upstream maps are gone on wake-up and the first Query rebuilds them and reconnects a fresh upstream. Clients see aClosed { upstream_closed }on their next frame and re-subscribe. - Single-DO hot spot. A shard handles all its clients in one (single-threaded) DO. For
high-traffic regions, shard horizontally via
?shard=. - No bandwidth metering. The hub doesn't enforce per-client throughput; abuse mitigation lives at the Cloudflare edge (rate limiting / WAF).