8.3 KiB
Network Architecture
Canonical capability spec: openspec/specs/network/spec.md (NET-01..NET-125).
The five core abstractions
| Abstraction | Role | Spec |
|---|---|---|
NetworkPlugin |
The universal extension contract every plugin implements | NET-01 |
NetworkNode |
The single entry-point — owns lifecycle, bus, router, transports | NET-02 |
MessageEnvelope |
Typed wire format for every message | NET-03 |
Transport |
The contract every transport plugin satisfies | NET-04 |
NetworkBus |
The cross-plugin event broker (no plugin imports another plugin) | NET-05 |
These are the only five things network-core ships. Everything else — even WebRTC — is a plugin.
NetworkPlugin (NET-01)
interface NetworkPlugin {
readonly name: string;
install(node: NetworkNode): Promise<void> | void;
start(): Promise<void> | void;
stop(): Promise<void> | void;
dispose?(): Promise<void> | void;
}
Lifecycle is strict: install → start → ... → stop → dispose. The core calls them in that order; plugins must be idempotent across start/stop (you can restart without disposing). Plugins do not import each other. Cross-plugin communication goes through NetworkBus.
NetworkNode (NET-02)
class NetworkNode {
use(plugin: NetworkPlugin): this;
start(): Promise<void>;
stop(): Promise<void>;
send(targetId: PeerId, envelope: MessageEnvelope): Promise<void>;
broadcast(envelope: MessageEnvelope): Promise<void>;
on<E extends NetworkEvents>(event: E, handler: Handler<E>): () => void;
readonly bus: NetworkBus;
readonly router: MessageRouter;
bootstrap(options: BootstrapOptions): Promise<void>; // NET-113
}
The node owns plugin lifecycle, transport pool, and message routing. Sending is transport-agnostic — the node hands the envelope to whichever active transport can reach the target peer.
MessageEnvelope (NET-03)
interface MessageEnvelope {
id: string; // unique
type: string; // e.g. 'cd:request', 'election:vote'
senderId: PeerId;
targetId: PeerId | "*"; // "*" = broadcast
payload: Uint8Array; // already-encoded; protocol plugin handles serialisation
timestamp: number; // injectable Clock
headers: Record<string, string>;
}
Validated via Zod before routing. The 13-byte binary header from network-plugin-protocol (NET-15) prefixes this when serialising for the wire: version(1) | flags(1) | messageType(1) | timestamp(8) | payloadLength(2).
Transport (NET-04)
interface Transport {
connect(peerId: PeerId, address: string): Promise<void>;
disconnect(peerId: PeerId): Promise<void>;
send(peerId: PeerId, envelope: MessageEnvelope): Promise<void>;
onMessage(handler: (env: MessageEnvelope) => void): () => void;
getConnectedPeers(): PeerId[];
}
Concrete transports: WebRTC (network-plugin-webrtc), WebSocket (network-websockets), HTTPS (planned), libp2p (network-libp2p — pending), Hyperswarm (network-hyperswarm — pending), Trystero (network-discovery-trystero — pending), GunDB (network-discovery-gundb — pending), MemoryTransport (test-only, in network-testing).
network-plugin-transport-manager (NET-23..NET-26) maintains multiple active transports per peer simultaneously, exchanges capability bitmaps during handshake (NET-24), and routes messages by priority across them (NET-25). CRITICAL messages may go redundantly across two transports; BULK uses backpressure-aware selection.
NetworkBus (NET-05)
Typed pub/sub with FIFO delivery, bounded-queue backpressure, and interceptor support. Topics are constants from the frozen NetworkEvents enum (NET-55):
node:created, node:connected, node:disconnected, node:updated, node:removed,
message:sent, message:received, message:routed, message:dropped,
hierarchy:changed, hierarchy:parent-changed, hierarchy:child-added, ...,
plugin:registered, plugin:unregistered,
peer:joined, peer:left, peer:updated, peer:latency,
transport:connected, transport:disconnected,
election:vote, election:completed, election:cancelled,
failover:triggered, failover:completed,
topology:changed, latency:rebalance,
cd:advertise, cd:request, cd:response,
partition:detected, partition:healed,
qos:congestion, monitor:alert, system:error
Plugins subscribe; plugins publish; the bus enforces ordering and backpressure. No plugin ever imports another plugin's code. That is the wire that lets network-suite compose 21 plugins without circular deps.
MessageRouter
Owns:
- Per-message type → handler dispatch.
- TTL decrement (
NET-58); drop whenttl <= 0. - Loop detection via
visitedNodesheader (NET-58). - Broadcast deduplication via
messageIdbloom filter. - Route caching for frequently-used
(source, target) → nextHopmappings (NET-59); invalidated ontopology:changed.
How a plugin actually talks to another plugin
Concrete example — drive-mesh wants a chunk from a peer:
drive-mesh.fetchChunk(fileId, chunkId)
↓
networkNode.bus.publish('cd:request', { fileId, chunkId, requesterId })
↓
network-plugin-content-delivery.onCdRequest(...)
↓
resolves a peer holding the chunk via gossip index
↓
networkNode.send(holderPeerId, { type: 'cd:fetch', payload: { fileId, chunkId } })
↓
(transport-manager picks WebRTC, WebSocket, or HTTPS based on capability negotiation)
↓
holder runs network-plugin-content-delivery.onCdFetch and sends back chunk bytes
↓
networkNode.bus.publish('cd:response', { fileId, chunkId, bytes })
↓
drive-mesh.onCdResponse(...) → verify sig + AEAD via chunks plugin → return to caller
Three plugins coordinated; none imported any other; all communication via bus topics. Replace network-plugin-content-delivery with a different chunk-distribution implementation and the rest of the chain doesn't notice.
BootstrapOptions (NET-113, NET-114)
interface BootstrapOptions {
mode: 'cold' | 'warm';
initialPeers?: PeerAddress[];
bootstrapServers?: string[];
connectionConcurrency?: number;
discoveryTimeout?: number;
prioritizeLocalNetwork?: boolean;
}
NetworkNode.bootstrap(options) brings up the node from a clean state — does discovery, opens initial connections, and (with ConnectionPreWarmer) pre-opens transports to known peers before they are needed.
PluginRegistry and PluginDiscovery (NET-116)
class PluginRegistry {
register(plugin: NetworkPlugin): void;
unregister(name: string): void;
has(name: string): boolean;
get(name: string): NetworkPlugin | undefined;
list(): NetworkPlugin[];
}
class PluginDiscovery {
findPluginsByCapability(capability: string): NetworkPlugin[];
}
Runtime plugin discovery and registration — the same pattern the rest of the monorepo uses (storage factory, db factory, etc.).
TransportTester PDK (NET-115)
Third-party transport plugins are validated by a conformance harness:
class TransportTester {
testBasicFunctionality(transport: Transport): Promise<TestReport>;
testReliability(transport: Transport): Promise<TestReport>;
testBackpressure(transport: Transport): Promise<TestReport>;
testReconnection(transport: Transport): Promise<TestReport>;
}
Anyone shipping a custom transport (e.g., a corporate VPN tunnel transport) runs TransportTester against it before registering with network-plugin-transport-manager. This is what makes "swap WebRTC for $YOUR_TRANSPORT" a one-line change.
Why this shape
The core's job is to do as little as possible. It owns plugin lifecycle, the bus, the router, the envelope shape, and the transport contract — and stops. Every protocol decision (binary header layout, encryption, signing, election algorithm, partition recovery) lives in a plugin where it can be replaced.
This is the lesson the old planning corpus was groping toward but never quite landed: a "core + plugins" model where the core is genuinely small and the plugins genuinely don't depend on each other. The network-suite change folder finally delivers it; the canonical spec encodes it; this is the design genesis ships.
Reading further
topology-and-failover.md— the resilience model.plugins-catalog.md— what each plugin does.security-and-tokens.md— the trust model.