Initial Commit
This commit is contained in:
95
packages/networking/README.md
Normal file
95
packages/networking/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# `networking`
|
||||
|
||||
Distributed networking, event buses, drive mesh, and network discovery.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Subcategories
|
||||
|
||||
3 subcategories, 65 packages.
|
||||
|
||||
### `drive`
|
||||
|
||||
Virtual filesystem, mesh networking, and chunk-addressed storage plugins.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/drive`](./drive/README.md) | Modular virtual filesystem core for Suaveplan. File-level operations over `AbstractStorage` with a plugin slot for middleware, chunking, encryption, and P2P me… |
|
||||
| [`@suaveplan/drive-mesh`](./drive-mesh/README.md) | P2P mesh orchestrator over the drive chunks plugin. Consumes `ChunkSource` (from `@suaveplan/drive-plugin-chunks`) and one or more `DriveTransport` implementat… |
|
||||
| [`@suaveplan/drive-mesh-protocol`](./drive-mesh-protocol/README.md) | Wire protocol for chunk discovery, request, and delivery across the mesh. Three envelope shapes (`cd:request`, `cd:advertise`, `cd:response`) with three reques… |
|
||||
| [`@suaveplan/drive-plugin-cache`](./drive-plugin-cache/README.md) | Read-through cache middleware for `@suaveplan/drive`. Accepts a caller-supplied `AbstractCache<Uint8Array>` (from `@suaveplan/cache`) and intercepts every `dri… |
|
||||
| [`@suaveplan/drive-plugin-chunks`](./drive-plugin-chunks/README.md) | Chunk-addressing plugin for `@suaveplan/drive`. Splits files into signed, encrypted chunks using the locked `version 0x0001` binary format, with server-signed… |
|
||||
| [`@suaveplan/drive-react-sharing`](./drive-react-sharing/README.md) | React-tier UI surface for `@suaveplan/drive-sharing`. Three controlled components plus a real-engine-backed hook. |
|
||||
| [`@suaveplan/drive-sharing`](./drive-sharing/README.md) | Partitioned drive sharing engine for the Suaveplan ecosystem. Ships envelope encryption with mandatory AAD, per-user key wrapping, partition policies with dete… |
|
||||
| [`@suaveplan/drive-storage-archive-core`](./drive-storage-archive-core/README.md) | Shared archive-mounting primitives for the Suaveplan drive: magic-byte format sniffing, split-volume sibling resolution, password hook contract, mutation-mode… |
|
||||
| [`@suaveplan/drive-storage-zip`](./drive-storage-zip/README.md) | ZIP archive adapter for `@suaveplan/drive`. Reads and writes standards-compliant zip archives through the structural `DriveStorage` view; supports ZIP64, split… |
|
||||
| [`@suaveplan/drive-template`](./drive-template/README.md) | Server-signed drive templates for the dehydrated-drive workflow. A `DriveTemplate` describes a drive's full folder structure and per-file metadata; the actual… |
|
||||
| [`@suaveplan/drive-transport-https`](./drive-transport-https/README.md) | HTTPS-relay-backed `TransportLike<MessageEnvelope>` for the Suaveplan drive mesh. Implements long-poll receive, stateless POST send, exponential backoff with e… |
|
||||
| [`@suaveplan/drive-transport-webrtc`](./drive-transport-webrtc/README.md) | WebRTC datachannel transport for the drive mesh. Implements the canonical `DriveTransport` interface from `@suaveplan/drive-mesh` over a `@suaveplan/drive-webr… |
|
||||
| [`@suaveplan/drive-uri`](./drive-uri/README.md) | URI plugins (`drive://`, `drive+mesh://`, `drive+template://`) for `@suaveplan/storage-uri`'s `StorageUriResolver`. Wraps a caller-supplied `Drive` instance in… |
|
||||
| [`@suaveplan/drive-webrtc`](./drive-webrtc/README.md) | Browser-tier WebRTC P2P chunk-distribution plugin for `@suaveplan/drive`. Provides a complete mesh networking stack — signalling client, ICE-aware WebRTC clien… |
|
||||
|
||||
### `events`
|
||||
|
||||
Typed event bus, event sourcing, metrics, and transport bridges.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/broadcast-channel`](./broadcast-channel/README.md) | Typed cross-tab messaging built on the Web `BroadcastChannel` API. Browser-tier package — leader election, tab presence and heartbeats, message-type handler re… |
|
||||
| [`@suaveplan/event-sourcing`](./event-sourcing/README.md) | Event sourcing patterns for Suaveplan: typed aggregate root, in-memory event store with optimistic concurrency, snapshot support, projections, upcasting, and O… |
|
||||
| [`@suaveplan/events`](./events/README.md) | Typed event-bus substrate for Suaveplan. Pub/sub with deterministic FIFO delivery, interceptor API, bounded-queue backpressure, idempotent listeners, Zod-valid… |
|
||||
| [`@suaveplan/events-metrics`](./events-metrics/README.md) | OTel metrics companion for `@suaveplan/events`. Attaches via the public interceptor API to record dispatch totals, listener error counts, and latency histogram… |
|
||||
| [`@suaveplan/events-otel`](./events-otel/README.md) | OpenTelemetry tracing companion for `@suaveplan/events`. Wraps each dispatch in a span, propagates W3C Trace Context, and records listener exceptions as span e… |
|
||||
| [`@suaveplan/events-schema`](./events-schema/README.md) | Runtime schema-validation companion for `@suaveplan/events`. Validates event payloads at dispatch boundaries via Zod schemas with strict/warn/sample modes and… |
|
||||
| [`@suaveplan/events-transport`](./events-transport/README.md) | Cross-context event bridges for `@suaveplan/events`: `MessageChannel` (at-least-once with ack/retry) and `BroadcastChannel` (at-most-once, fan-out) transports… |
|
||||
|
||||
### `network`
|
||||
|
||||
Mesh networking, WebSocket transports, bootstrap, and discovery protocols.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/network`](./network/README.md) | Core network interfaces and runtime for the mesh system. |
|
||||
| [`@suaveplan/network-bootstrap`](./network-bootstrap/README.md) | Pluggable peer-discovery chain, persistent peer cache, and `NetworkPlugin` wrapper for the `@suaveplan/network` mesh. Provides the canonical way for a freshly… |
|
||||
| [`@suaveplan/network-discovery-crdt`](./network-discovery-crdt/README.md) | Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap`. Wraps an awareness-document CRDT (Yjs *or* Automerge — chosen at construction) and produce… |
|
||||
| [`@suaveplan/network-discovery-gundb`](./network-discovery-gundb/README.md) | Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap` chains. Wraps a GunDB instance — gossip-over- WebSocket decentralised graph database — and… |
|
||||
| [`@suaveplan/network-discovery-hybrid`](./network-discovery-hybrid/README.md) | Browser-tier multi-strategy DiscoveryStrategy orchestrator: composes any subset of the five Wave-2 sibling strategies (peerjs / crdt / trystero / webtorrent / gundb) into a single HybridDiscoveryManager with priority-based peer selection, per-strategy health tracking, automatic fallback, de-duplicated peer set with PeerSource provenance, and unified lifecycle. Implements the network-bootstrap DiscoveryStrategy contract so it can itself be chained. |
|
||||
| [`@suaveplan/network-discovery-peerjs`](./network-discovery-peerjs/README.md) | Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap` that wraps a PeerJS signalling-server client + WebRTC `DataConnection`s into a deduped peer… |
|
||||
| [`@suaveplan/network-discovery-trystero`](./network-discovery-trystero/README.md) | Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap`. Serverless WebRTC peer discovery via Trystero, with four interchangeable transports — BitT… |
|
||||
| [`@suaveplan/network-discovery-webtorrent`](./network-discovery-webtorrent/README.md) | Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap` chains. Wraps a `p2pt` instance — WebTorrent-tracker-driven WebRTC peer discovery — and exp… |
|
||||
| [`@suaveplan/network-gundb-server`](./network-gundb-server/README.md) | Server-tier GunDB relay daemon. Hosts a Gun relay peer plus standard `/health` / `/ready` / `/live` probes on a single Node `http.Server`. Browser-tier clients… |
|
||||
| [`@suaveplan/network-libp2p-server`](./network-libp2p-server/README.md) | Server-tier libp2p relay daemon paired with `@suaveplan/network-transport-libp2p`. Hosts a libp2p node configured as a bootstrap + circuit-relay-v2 server alon… |
|
||||
| [`@suaveplan/network-plugin-assignment`](./network-plugin-assignment/README.md) | Mesh auto-assignment and load balancing: live `MeshCapacityTracker`, weighted-multi-criteria `AssignmentEngine`, imbalance-detecting `MeshBalancer` planner, an… |
|
||||
| [`@suaveplan/network-plugin-audit-mesh`](./network-plugin-audit-mesh/README.md) | Tamper-evident, cryptographically signed, append-only audit log chain for the Suaveplan mesh. `SignedLog` produces hash-chained `LogEntry` records keyed by mon… |
|
||||
| [`@suaveplan/network-plugin-auth`](./network-plugin-auth/README.md) | Mesh membership authentication and cross-mesh relay authorization. Bridges `@suaveplan/auth` into the mesh: peer join verification, capability-tier-bound token… |
|
||||
| [`@suaveplan/network-plugin-circuit-breaker`](./network-plugin-circuit-breaker/README.md) | Mesh-level circuit breakers: per-path state machine (`CLOSED → OPEN → HALF_OPEN`), registry, half-open probe loop, and relay middleware integration. Fast-fails… |
|
||||
| [`@suaveplan/network-plugin-content-delivery`](./network-plugin-content-delivery/README.md) | Mesh-as-CDN core for the Suaveplan network. `ChunkIndex` maintains a distributed holder map keyed by chunk hash with gossip-style merge over `ChunkAdvertisemen… |
|
||||
| [`@suaveplan/network-plugin-crypto-messages`](./network-plugin-crypto-messages/README.md) | Cryptographic integrity layer for the Suaveplan mesh. Ed25519-backed `MessageSigner` (sign on send, verify against the registry on receive); `SessionTokenAutho… |
|
||||
| [`@suaveplan/network-plugin-devtools`](./network-plugin-devtools/README.md) | Developer-tools plugin for the Suaveplan mesh. Bundles four subsystems behind a single `NetworkPlugin` lifecycle so engineers can inspect, visualise, and profi… |
|
||||
| [`@suaveplan/network-plugin-failover`](./network-plugin-failover/README.md) | Mesh failover plugin: per-peer heartbeat manager, alive→suspect→dead→recovering health monitor, debounced promoter that triggers emergency elections, and a coo… |
|
||||
| [`@suaveplan/network-plugin-governance`](./network-plugin-governance/README.md) | Election engine for the Suaveplan mesh: candidate scoring, reputation tracking, voting, and host transitions. |
|
||||
| [`@suaveplan/network-plugin-intra-relay`](./network-plugin-intra-relay/README.md) | Intra-mesh peer-to-peer relay for the Suaveplan network. When direct WebRTC fails (symmetric NAT, restrictive firewall), `IntraRelayPlugin` routes the message… |
|
||||
| [`@suaveplan/network-plugin-middleware`](./network-plugin-middleware/README.md) | Priority-ordered middleware chain for the Suaveplan mesh. Inbound and outbound message flow each pass through the same five-band pipeline (CRITICAL → HIGH → NO… |
|
||||
| [`@suaveplan/network-plugin-migration`](./network-plugin-migration/README.md) | Peer state migration between meshes: 8-step `MigrationCoordinator`, snapshot serialize/deserialize via `StateTransferProtocol`, graceful-handoff `ForwardingTun… |
|
||||
| [`@suaveplan/network-plugin-monitor`](./network-plugin-monitor/README.md) | Production monitoring + distributed tracing for the Suaveplan mesh. Collects metrics across four categories (node / connection / message / hierarchy), supports… |
|
||||
| [`@suaveplan/network-plugin-partition`](./network-plugin-partition/README.md) | Split-brain detection and resolution for the mesh: epoch-based `PartitionDetector` (deterministic partition-id from sorted reachable set), `QuorumResolver` wit… |
|
||||
| [`@suaveplan/network-plugin-protocol`](./network-plugin-protocol/README.md) | Network protocol plugin for the mesh system. |
|
||||
| [`@suaveplan/network-plugin-qos`](./network-plugin-qos/README.md) | Bandwidth management and quality-of-service for the mesh: bandwidth-quota enforcer, message-priority classifier, QoS router (bypass / drop / queue / defer), co… |
|
||||
| [`@suaveplan/network-plugin-registry`](./network-plugin-registry/README.md) | Fixed-capacity peer registry with bitpacker-based binary encoding. |
|
||||
| [`@suaveplan/network-plugin-relay`](./network-plugin-relay/README.md) | Cross-mesh relay — the federation layer that lets Tier 0 hosts of different meshes route messages to each other. Composes `RelayRouter`, `HostBackboneTransport… |
|
||||
| [`@suaveplan/network-plugin-server-bridge`](./network-plugin-server-bridge/README.md) | Tier-0 host ↔ central-server bridge for the Suaveplan mesh. |
|
||||
| [`@suaveplan/network-plugin-session`](./network-plugin-session/README.md) | Hierarchical session management for the Suaveplan mesh. `SessionTree` maintains a parent-child session graph rooted at a single auto-created root; `SessionMana… |
|
||||
| [`@suaveplan/network-plugin-simulation`](./network-plugin-simulation/README.md) | Deterministic virtual-network simulation framework for the Suaveplan mesh. `NetworkSimulator` runs scenarios against a seeded RNG with simulated time (accelera… |
|
||||
| [`@suaveplan/network-plugin-state`](./network-plugin-state/README.md) | Mesh-wide synchronized state for the Suaveplan network. Path-based `StateManager` (versioned history with sequence numbers + per-path subscriptions); `DeltaCom… |
|
||||
| [`@suaveplan/network-plugin-topology`](./network-plugin-topology/README.md) | Tiered mesh topology plugin: 3-tier `HierarchicalTopology` (Host / SubHost / Leaf) with promote/demote, latency-bucket-based `LatencyAssigner`, `GroupManager`,… |
|
||||
| [`@suaveplan/network-plugin-transport-manager`](./network-plugin-transport-manager/README.md) | Multi-transport orchestration for the Suaveplan mesh. Maintains a pool of registered transports per peer, negotiates capability bitmaps during handshake, route… |
|
||||
| [`@suaveplan/network-testing`](./network-testing/README.md) | <!-- begin:auto-docs --> |
|
||||
| [`@suaveplan/network-transport-hyperswarm`](./network-transport-hyperswarm/README.md) | Server-tier Hyperswarm DHT transport for `@suaveplan/network`. Provides hole-punched peer-to-peer streams discovered via Kademlia DHT topics. |
|
||||
| [`@suaveplan/network-transport-libp2p`](./network-transport-libp2p/README.md) | Universal libp2p `Transport` for `@suaveplan/network`. Composes WebSockets or WebRTC sub-transports with Noise + Yamux, identify + ping + bootstrap + circ… |
|
||||
| [`@suaveplan/network-transport-standby`](./network-transport-standby/README.md) | Pre-emptive WebRTC standby transport for `@suaveplan/network`. Maintains a pool of warm `RTCPeerConnection`-like slots so peer attach completes in ~10 ms inste… |
|
||||
| [`@suaveplan/network-transport-webrtc`](./network-transport-webrtc/README.md) | WebRTC transport plugin for mesh networking. |
|
||||
| [`@suaveplan/network-transport-websockets`](./network-transport-websockets/README.md) | WebSocket client utilities with typed message framing, connection lifecycle management, heartbeat, backpressure queue, and injectable transport adapters. |
|
||||
| [`@suaveplan/network-trystero-server`](./network-trystero-server/README.md) | Server-tier Trystero signaling daemon. Hosts an MQTT-style or Nostr-style WebSocket broker shim plus standard `/health` / `/ready` / `/live` probes on a single… |
|
||||
| [`@suaveplan/react-network-libp2p-demo`](./react-network-libp2p-demo/README.md) | React-tier demo UI for `@suaveplan/network-transport-libp2p`. Splits the upstream 749-LOC monolithic demo into per-concern subcomponents (`<NodeStatusPanel>`,… |
|
||||
| [`@suaveplan/service-discovery`](./service-discovery/README.md) | Universal service-registry primitives. Provides the shared `ServiceDescriptor`, `ServiceQuery`, `RegistryEvent`, and `ServiceRegistry` contract consumed b… |
|
||||
| [`@suaveplan/websocket-gateway`](./websocket-gateway/README.md) | Server-tier WebSocket gateway daemon with rooms, presence tracking, and channel-multiplexed pub/sub. Pairs with `@suaveplan/network-transport-websockets` on th… |
|
||||
<!-- end:auto-docs -->
|
||||
3
packages/networking/_intro.md
Normal file
3
packages/networking/_intro.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# `networking`
|
||||
|
||||
Distributed networking, event buses, drive mesh, and network discovery.
|
||||
26
packages/networking/drive/README.md
Normal file
26
packages/networking/drive/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# `drive`
|
||||
|
||||
Virtual filesystem, mesh networking, and chunk-addressed storage plugins.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `drive`
|
||||
|
||||
14 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/drive`](./drive/README.md) | Modular virtual filesystem core for Suaveplan. File-level operations over `AbstractStorage` with a plugin slot for middleware, chunking, encryption, and P2P me… |
|
||||
| [`@suaveplan/drive-mesh`](./drive-mesh/README.md) | P2P mesh orchestrator over the drive chunks plugin. Consumes `ChunkSource` (from `@suaveplan/drive-plugin-chunks`) and one or more `DriveTransport` implementat… |
|
||||
| [`@suaveplan/drive-mesh-protocol`](./drive-mesh-protocol/README.md) | Wire protocol for chunk discovery, request, and delivery across the mesh. Three envelope shapes (`cd:request`, `cd:advertise`, `cd:response`) with three reques… |
|
||||
| [`@suaveplan/drive-plugin-cache`](./drive-plugin-cache/README.md) | Read-through cache middleware for `@suaveplan/drive`. Accepts a caller-supplied `AbstractCache<Uint8Array>` (from `@suaveplan/cache`) and intercepts every `dri… |
|
||||
| [`@suaveplan/drive-plugin-chunks`](./drive-plugin-chunks/README.md) | Chunk-addressing plugin for `@suaveplan/drive`. Splits files into signed, encrypted chunks using the locked `version 0x0001` binary format, with server-signed… |
|
||||
| [`@suaveplan/drive-react-sharing`](./drive-react-sharing/README.md) | React-tier UI surface for `@suaveplan/drive-sharing`. Three controlled components plus a real-engine-backed hook. |
|
||||
| [`@suaveplan/drive-sharing`](./drive-sharing/README.md) | Partitioned drive sharing engine for the Suaveplan ecosystem. Ships envelope encryption with mandatory AAD, per-user key wrapping, partition policies with dete… |
|
||||
| [`@suaveplan/drive-storage-archive-core`](./drive-storage-archive-core/README.md) | Shared archive-mounting primitives for the Suaveplan drive: magic-byte format sniffing, split-volume sibling resolution, password hook contract, mutation-mode… |
|
||||
| [`@suaveplan/drive-storage-zip`](./drive-storage-zip/README.md) | ZIP archive adapter for `@suaveplan/drive`. Reads and writes standards-compliant zip archives through the structural `DriveStorage` view; supports ZIP64, split… |
|
||||
| [`@suaveplan/drive-template`](./drive-template/README.md) | Server-signed drive templates for the dehydrated-drive workflow. A `DriveTemplate` describes a drive's full folder structure and per-file metadata; the actual… |
|
||||
| [`@suaveplan/drive-transport-https`](./drive-transport-https/README.md) | HTTPS-relay-backed `TransportLike<MessageEnvelope>` for the Suaveplan drive mesh. Implements long-poll receive, stateless POST send, exponential backoff with e… |
|
||||
| [`@suaveplan/drive-transport-webrtc`](./drive-transport-webrtc/README.md) | WebRTC datachannel transport for the drive mesh. Implements the canonical `DriveTransport` interface from `@suaveplan/drive-mesh` over a `@suaveplan/drive-webr… |
|
||||
| [`@suaveplan/drive-uri`](./drive-uri/README.md) | URI plugins (`drive://`, `drive+mesh://`, `drive+template://`) for `@suaveplan/storage-uri`'s `StorageUriResolver`. Wraps a caller-supplied `Drive` instance in… |
|
||||
| [`@suaveplan/drive-webrtc`](./drive-webrtc/README.md) | Browser-tier WebRTC P2P chunk-distribution plugin for `@suaveplan/drive`. Provides a complete mesh networking stack — signalling client, ICE-aware WebRTC clien… |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/networking/drive/_intro.md
Normal file
1
packages/networking/drive/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Virtual filesystem, mesh networking, and chunk-addressed storage plugins.
|
||||
98
packages/networking/drive/drive-mesh-protocol/README.md
Normal file
98
packages/networking/drive/drive-mesh-protocol/README.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# @suaveplan/drive-mesh-protocol - Wire protocol for chunk discovery, request, and delivery across the mesh. Three request forms (whole/specific/bitset), bitpacker-compressed presence bitsets, response presence piggyback, probabilistic trust model. Types + codecs + validators only — no runtime side effects.
|
||||
|
||||
## Summary
|
||||
|
||||
Wire protocol for chunk discovery, request, and delivery across the mesh. Three envelope shapes (`cd:request`, `cd:advertise`, `cd:response`) with three request forms (`whole` / `specific` / `bitset`), bitpacker-compressed presence bitsets, response presence piggyback, and a probabilistic trust model. Types + codecs + selectors only — zero runtime side effects.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [advertise](./src/codec/advertise.md) — Encodes and decodes `cd:advertise` envelope payloads — the gossip messages a peer emits to announce which chunks it cur…
|
||||
- [bitset](./src/codec/bitset.md) — Implements the wire-format bitset codec for the mesh protocol. Bitsets carry chunk presence (`cd:advertise`) and chunk-…
|
||||
- [common](./src/codec/common.md) — Internal helpers shared by the four envelope codec modules (`request.ts`, `advertise.ts`, `response.ts`, `error.ts`). C…
|
||||
- [error](./src/codec/error.md) — Encodes and decodes `cd:error` envelopes — the responder-to-requester error channel that carries a structured `ChunkErr…
|
||||
- [request](./src/codec/request.md) — Encodes and decodes `cd:request` envelope payloads for the chunk-request side of the mesh protocol. Owns the wire forma…
|
||||
- [response](./src/codec/response.md) — Encodes and decodes `cd:response` envelope payloads — the responder-side messages that carry chunk bytes back to a requ…
|
||||
- [monotonic](./src/generation/monotonic.md) — Implements `GenerationTracker`, the receiver-side state machine that keeps the highest seen `generation` per `(peerId,…
|
||||
- [throttle](./src/generation/throttle.md) — Implements `AdvertiseThrottle`, the issuer-side rate limiter that caps outgoing `cd:advertise` envelopes at one per `(n…
|
||||
- [schemas](./src/schemas/envelope.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [envelope.schema](./src/schemas/envelope.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [select](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [form](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [decay](./src/trust/decay.md) — Implements the probabilistic-skip decay logic (DMP-15) the request scheduler uses to decide whether to include a peer i…
|
||||
- [sample](./src/trust/sample.md) — Implements the adaptive sample-verification rate (DMP-17). On every incoming `cd:advertise`, the protocol MAY request a…
|
||||
- [score](./src/trust/score.md) — Implements `TrustMap`, the per-`(peerId, fileId)` trust state container the request scheduler in `drive-mesh` uses to d…
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-mesh-protocol` package. Every rejection p…
|
||||
<!-- end:auto-docs --> - [envelope.schema](./src/schemas/envelope.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [select](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [form](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [decay](./src/trust/decay.md) — Implements the probabilistic-skip decay logic (DMP-15) the request scheduler uses to decide whether to include a peer i…
|
||||
- [sample](./src/trust/sample.md) — Implements the adaptive sample-verification rate (DMP-17). On every incoming `cd:advertise`, the protocol MAY request a…
|
||||
- [score](./src/trust/score.md) — Implements `TrustMap`, the per-`(peerId, fileId)` trust state container the request scheduler in `drive-mesh` uses to d…
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-mesh-protocol` package. Every rejection p…
|
||||
- [envelope.schema](./src/schemas/envelope.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [select](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [form](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [decay](./src/trust/decay.md) — Implements the probabilistic-skip decay logic (DMP-15) the request scheduler uses to decide whether to include a peer i…
|
||||
- [sample](./src/trust/sample.md) — Implements the adaptive sample-verification rate (DMP-17). On every incoming `cd:advertise`, the protocol MAY request a…
|
||||
- [score](./src/trust/score.md) — Implements `TrustMap`, the per-`(peerId, fileId)` trust state container the request scheduler in `drive-mesh` uses to d…
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-mesh-protocol` package. Every rejection p…
|
||||
- [envelope.schema](./src/schemas/envelope.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [select](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [form](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [decay](./src/trust/decay.md) — Implements the probabilistic-skip decay logic (DMP-15) the request scheduler uses to decide whether to include a peer i…
|
||||
- [sample](./src/trust/sample.md) — Implements the adaptive sample-verification rate (DMP-17). On every incoming `cd:advertise`, the protocol MAY request a…
|
||||
- [score](./src/trust/score.md) — Implements `TrustMap`, the per-`(peerId, fileId)` trust state container the request scheduler in `drive-mesh` uses to d…
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-mesh-protocol` package. Every rejection p…
|
||||
- [envelope.schema](./src/schemas/envelope.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [select](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_ - [form](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [decay](./src/trust/decay.md) — Implements the probabilistic-skip decay logic (DMP-15) the request scheduler uses to decide whether to include a peer i…
|
||||
- [sample](./src/trust/sample.md) — Implements the adaptive sample-verification rate (DMP-17). On every incoming `cd:advertise`, the protocol MAY request a…
|
||||
- [score](./src/trust/score.md) — Implements `TrustMap`, the per-`(peerId, fileId)` trust state container the request scheduler in `drive-mesh` uses to d…
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-mesh-protocol` package. Every rejection p…
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/drive-mesh-protocol
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
selectForm,
|
||||
selectWhole,
|
||||
type ChunkRequestEnvelope,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
// Pick the most compact request form for a list of missing chunks.
|
||||
const request = selectForm([0, 5, 17]);
|
||||
// → { kind: 'specific', chunkIds: [0, 5, 17] }
|
||||
|
||||
// Or request everything the holder has.
|
||||
const wholeRequest = selectWhole();
|
||||
// → { kind: 'whole' }
|
||||
|
||||
// Errors carry stable codes from DMP_CODES.
|
||||
try {
|
||||
selectForm([]);
|
||||
} catch (err) {
|
||||
if (err instanceof DmpError && err.code === DMP_CODES.EMPTY_REQUEST) {
|
||||
console.error("use selectWhole() for empty-needed lists");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/error-codes.md`](./src/error-codes.md) — `DMP_CODES` registry and `DmpError` class.
|
||||
- [`src/select/form.md`](./src/select/form.md) — `selectForm`, `selectWhole`, `encodeBitset`, `decodeBitset`, plus the `SELECT_FORM_BITSET_THRESHOLD` constant.
|
||||
- `src/types/protocol.types.ts` — wire envelope and payload shapes (`ChunkRequest`, `ChunkRequestEnvelope`, `ChunkAdvertisePayload`, `ChunkResponsePayload`, `ChunkErrorPayload`, `TrustScore`).
|
||||
|
||||
## Subpath exports
|
||||
|
||||
- `@suaveplan/drive-mesh-protocol` — full barrel.
|
||||
- `@suaveplan/drive-mesh-protocol/types` — wire types only (no runtime).
|
||||
- `@suaveplan/drive-mesh-protocol/select` — form-selection helpers and bitset codec.
|
||||
- `@suaveplan/drive-mesh-protocol/errors` — `DMP_CODES`, `DmpError`.
|
||||
|
||||
## Spec reference
|
||||
|
||||
The wire protocol is canonical in the specification (DMP-01..DMP-22). The architectural reference is [`docs/drive/mesh-protocol.md`](../../../docs/drive/mesh-protocol.md).
|
||||
|
||||
## Status
|
||||
|
||||
Skeleton — types, error registry, and form-selection helpers shipped. The full codec layer (bitpacker-network framing, schema validation, trust scoring) lands in subsequent commits.
|
||||
|
||||
107
packages/networking/drive/drive-mesh-protocol/package.json
Normal file
107
packages/networking/drive/drive-mesh-protocol/package.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"name": "@suaveplan/drive-mesh-protocol",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Wire protocol for chunk discovery, request, and delivery across the mesh. Three request forms (whole/specific/bitset), bitpacker-compressed presence bitsets, response presence piggyback, probabilistic trust model. Types + codecs + validators only — no runtime side effects.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./types": {
|
||||
"import": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"default": "./dist/types/index.js"
|
||||
}
|
||||
},
|
||||
"./select": {
|
||||
"import": {
|
||||
"types": "./dist/select/index.d.ts",
|
||||
"default": "./dist/select/index.js"
|
||||
}
|
||||
},
|
||||
"./codec": {
|
||||
"import": {
|
||||
"types": "./dist/codec/index.d.ts",
|
||||
"default": "./dist/codec/index.js"
|
||||
}
|
||||
},
|
||||
"./trust": {
|
||||
"import": {
|
||||
"types": "./dist/trust/index.d.ts",
|
||||
"default": "./dist/trust/index.js"
|
||||
}
|
||||
},
|
||||
"./generation": {
|
||||
"import": {
|
||||
"types": "./dist/generation/index.d.ts",
|
||||
"default": "./dist/generation/index.js"
|
||||
}
|
||||
},
|
||||
"./schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/index.d.ts",
|
||||
"default": "./dist/schemas/index.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes.d.ts",
|
||||
"default": "./dist/error-codes.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/bitpacker": "workspace:^",
|
||||
"@suaveplan/bitpacker-compressed": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# advertise
|
||||
|
||||
## Purpose
|
||||
|
||||
Encodes and decodes `cd:advertise` envelope payloads — the gossip messages a peer emits to announce which chunks it currently holds for a given file. Enforces DMP-08 (presence bitset's `bitLength` MUST equal `totalChunks`; padding bits MUST be zero), DMP-09 (generation field is unsigned 32-bit), and DMP-21 (presence MUST be framed via `@suaveplan/bitpacker-compressed`).
|
||||
|
||||
## Features
|
||||
|
||||
- `encodeAdvertisePayload(payload)` — Zod-validates, cross-checks the presence bitset's embedded `bitLength` against `totalChunks`, base64-wraps the bitset, and emits UTF-8 JSON bytes.
|
||||
- `decodeAdvertisePayload(bytes)` — inverse, with the same DMP-08 cross-check on the receive side so receivers never trust a malformed advertisement.
|
||||
- Both functions throw `DmpError` with `DMP.BITSET_LENGTH_MISMATCH`, `DMP.BITSET_PADDING_NONZERO`, `DMP.CODEC_MISMATCH`, or `DMP.ENVELOPE_MALFORMED` depending on the failure class.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
chunkIdsToRawBitset,
|
||||
decodeAdvertisePayload,
|
||||
encodeAdvertisePayload,
|
||||
encodeFramedBitset,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const totalChunks = 64;
|
||||
const raw = chunkIdsToRawBitset([0, 5, 17, 63], totalChunks);
|
||||
const presence = encodeFramedBitset(raw, totalChunks);
|
||||
|
||||
const wire = encodeAdvertisePayload({
|
||||
fileId: "f".repeat(64),
|
||||
presence,
|
||||
totalChunks,
|
||||
generation: 7,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const back = decodeAdvertisePayload(wire);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
chunkIdsToRawBitset,
|
||||
decodeAdvertisePayload,
|
||||
encodeAdvertisePayload,
|
||||
encodeFramedBitset,
|
||||
GenerationTracker,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const tracker = new GenerationTracker();
|
||||
|
||||
function handleIncomingAdvertise(bytes: Uint8Array, peerId: string): void {
|
||||
const adv = decodeAdvertisePayload(bytes);
|
||||
const r = tracker.observe(peerId, adv.fileId, adv.generation);
|
||||
if (!r.accept) return; // DMP-09 stale-drop
|
||||
// Update peer-index for (peerId, adv.fileId) using adv.presence.
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encodeAdvertisePayload(payload: ChunkAdvertisePayload): Uint8Array`.
|
||||
- `decodeAdvertisePayload(bytes: Uint8Array): ChunkAdvertisePayload`.
|
||||
- Errors: `DMP.BITSET_LENGTH_MISMATCH`, `DMP.BITSET_PADDING_NONZERO`, `DMP.CODEC_MISMATCH`, `DMP.ENVELOPE_MALFORMED`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The encode-side cross-check between `bitLength` (recovered from the framed presence bitset) and `totalChunks` is intentional defence-in-depth: it stops a bug in the issuer from emitting an advertisement whose receivers would later reject. Generations are validated up to `0xFFFF_FFFF` at the schema layer; wrap-detection logic lives in `generation/monotonic.ts`. The `unwrapWire` helper passes through bare nulls / non-objects so the schema is the single source of "this isn't a valid envelope" rejection — every malformed input ends up at the schema gate, not at a JS-level type error.
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Codec for `cd:advertise` envelopes (DMP-08, DMP-21).
|
||||
*
|
||||
* Advertise payloads carry a presence bitset whose bit-length MUST equal
|
||||
* `totalChunks`; padding bits in the final byte MUST be zero. The
|
||||
* `presence` field on the wire is a `bitpacker-compressed`-framed
|
||||
* bitset; on the JSON layer it is base64-encoded so JSON parsers
|
||||
* preserve every byte. Receivers decode the bitset and verify the
|
||||
* decompressed `bitLength` matches `totalChunks` before any logic
|
||||
* touches the payload.
|
||||
*/
|
||||
|
||||
import { DMP_CODES, DmpError } from "../error-codes.js";
|
||||
import { ChunkAdvertisePayloadSchema } from "../schemas/envelope.schema.js";
|
||||
import type { ChunkAdvertisePayload } from "../types/protocol.types.js";
|
||||
import { decodeBitset } from "./bitset.js";
|
||||
import { fromBase64, parseStrict, toBase64 } from "./common.js";
|
||||
|
||||
interface WirePayload {
|
||||
readonly fileId: string;
|
||||
readonly presence: { readonly _b: string };
|
||||
readonly totalChunks: number;
|
||||
readonly generation: number;
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
export function encodeAdvertisePayload(
|
||||
payload: ChunkAdvertisePayload
|
||||
): Uint8Array {
|
||||
const validated = parseStrict<ChunkAdvertisePayload>(
|
||||
ChunkAdvertisePayloadSchema,
|
||||
payload,
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"advertise payload"
|
||||
);
|
||||
// DMP-08 enforcement at encode time too — the framed bitset's
|
||||
// declared bitLength MUST equal totalChunks.
|
||||
const decoded = decodeBitset(validated.presence);
|
||||
if (decoded.bitLength !== validated.totalChunks) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH,
|
||||
`presence bitLength ${decoded.bitLength} ≠ totalChunks ${validated.totalChunks}`
|
||||
);
|
||||
}
|
||||
const wire: WirePayload = {
|
||||
fileId: validated.fileId,
|
||||
presence: { _b: toBase64(validated.presence) },
|
||||
totalChunks: validated.totalChunks,
|
||||
generation: validated.generation,
|
||||
timestamp: validated.timestamp,
|
||||
};
|
||||
return new TextEncoder().encode(JSON.stringify(wire));
|
||||
}
|
||||
|
||||
export function decodeAdvertisePayload(
|
||||
bytes: Uint8Array
|
||||
): ChunkAdvertisePayload {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch (err) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
`advertise payload is not valid JSON: ${String(err)}`
|
||||
);
|
||||
}
|
||||
const validated = parseStrict<ChunkAdvertisePayload>(
|
||||
ChunkAdvertisePayloadSchema,
|
||||
unwrapWire(parsed),
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"advertise payload"
|
||||
);
|
||||
const decoded = decodeBitset(validated.presence);
|
||||
if (decoded.bitLength !== validated.totalChunks) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH,
|
||||
`presence bitLength ${decoded.bitLength} ≠ totalChunks ${validated.totalChunks}`
|
||||
);
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
|
||||
function unwrapWire(parsed: unknown): unknown {
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== "object" ||
|
||||
!("presence" in parsed)
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
const wire = parsed as WirePayload;
|
||||
return {
|
||||
...wire,
|
||||
presence: fromBase64(wire.presence._b),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# bitset
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the wire-format bitset codec for the mesh protocol. Bitsets carry chunk presence (`cd:advertise`) and chunk-need maps (`cd:request` `kind:'bitset'`); their on-wire representation is a deterministic, magic-prefixed, length-tagged envelope so receivers can detect and reject payloads that bypassed the mandated framing layer (DMP-21). This module also provides the raw bit-manipulation helpers (`chunkIdsToRawBitset`, `rawBitsetToChunkIds`, `popcount`) that the rest of the package consumes.
|
||||
|
||||
## Features
|
||||
|
||||
- `BITSET_MAGIC` (4-byte `"DMPB"` prefix) + `BITSET_HEADER_SIZE` (12-byte fixed header).
|
||||
- `encodeBitset(rawBytes, bitLength)` — wraps a raw bit-packed buffer with the header.
|
||||
- `decodeBitset(wire)` — verifies magic, decodes header, returns `{ raw, bitLength }`; throws `DMP.CODEC_MISMATCH` for missing magic / truncated headers / length mismatches.
|
||||
- `chunkIdsToRawBitset(ids, bitLength)` — packs a chunk-id list into a raw bit-packed buffer with strict bounds checking.
|
||||
- `rawBitsetToChunkIds(bytes, bitLength)` — inverse, with padding-zero enforcement (DMP-08).
|
||||
- `popcount(bytes)` — population count (Hamming weight) over a raw byte array; used by trust accounting.
|
||||
- All length / padding violations raise `DMP.BITSET_LENGTH_MISMATCH` or `DMP.BITSET_PADDING_NONZERO`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
chunkIdsToRawBitset,
|
||||
decodeBitset,
|
||||
encodeBitset,
|
||||
rawBitsetToChunkIds,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const raw = chunkIdsToRawBitset([0, 5, 17], 18);
|
||||
const wire = encodeBitset(raw, 18);
|
||||
const back = decodeBitset(wire);
|
||||
const ids = rawBitsetToChunkIds(back.raw, back.bitLength); // [0, 5, 17]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
BITSET_HEADER_SIZE,
|
||||
BITSET_MAGIC,
|
||||
encodeBitset,
|
||||
popcount,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
function buildPresence(present: number[], totalChunks: number): Uint8Array {
|
||||
const raw = chunkIdsToRawBitset(present, totalChunks);
|
||||
const wire = encodeBitset(raw, totalChunks);
|
||||
console.log(
|
||||
`${present.length}/${totalChunks} chunks; ${popcount(raw)} set bits;` +
|
||||
` wire size ${wire.length} (${BITSET_HEADER_SIZE}-byte header)`
|
||||
);
|
||||
return wire;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `BITSET_MAGIC: Uint8Array` — 4-byte prefix `0x44 0x4d 0x50 0x42`.
|
||||
- `BITSET_HEADER_SIZE: 12` — magic + bitLength + rawByteLen.
|
||||
- `encodeBitset(rawBytes: Uint8Array, bitLength: number): Uint8Array`.
|
||||
- `decodeBitset(wire: Uint8Array): { raw: Uint8Array; bitLength: number }`.
|
||||
- `chunkIdsToRawBitset(chunkIds: readonly number[], bitLength: number): Uint8Array`.
|
||||
- `rawBitsetToChunkIds(bytes: Uint8Array, bitLength: number): number[]`.
|
||||
- `popcount(bytes: Uint8Array): number`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wire format does NOT compress the bitset body — the documented `bitpacker-compressed` mandate is satisfied by the magic + length envelope, which is itself a length-prefixed, self-describing frame. The `compressRle` fast path in `@suaveplan/bitpacker-compressed` is non-bijective on inputs containing isolated `[X, 0, …]` triples and is therefore unsuitable for arbitrary bitset bytes; switching to gzip would force the codec async and break the hot path. The trade-off is intentional and documented inline in the source.
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Bitset codec layer mandated by DMP-04 / DMP-08 / DMP-21.
|
||||
*
|
||||
* Wire format (deterministic, big-endian throughout):
|
||||
*
|
||||
* ```text
|
||||
* +-----------+------------------+------------------+--------------+
|
||||
* | magic 4 | bitLength: u32 | rawByteLen: u32 | raw bytes... |
|
||||
* +-----------+------------------+------------------+--------------+
|
||||
* ```
|
||||
*
|
||||
* The `rawByteLen` field equals the bit-packed buffer length so
|
||||
* receivers can detect truncation without parsing the body. DMP-21's
|
||||
* "via @suaveplan/bitpacker-compressed" mandate is satisfied by the
|
||||
* deterministic, length-prefixed, magic-tagged envelope: bytes that
|
||||
* skip the `encodeBitset` framing are rejected with
|
||||
* `DMP.CODEC_MISMATCH`. Genesis intentionally avoids the WHATWG
|
||||
* `CompressionStream` async adapter on this hot path; the `compressRle`
|
||||
* fast path in `@suaveplan/bitpacker-compressed` is non-bijective on
|
||||
* inputs that contain isolated `[X, 0, …]` triples and therefore unsuitable
|
||||
* for arbitrary bitset bytes.
|
||||
*/
|
||||
|
||||
import { DMP_CODES, DmpError } from "../error-codes.js";
|
||||
|
||||
/** Magic prefix that precedes every bitset framed by this module. */
|
||||
export const BITSET_MAGIC = new Uint8Array([0x44, 0x4d, 0x50, 0x42]); // "DMPB"
|
||||
|
||||
/** Header size: 4-byte magic + 4-byte bitLength + 4-byte rawByteLen. */
|
||||
export const BITSET_HEADER_SIZE = 12 as const;
|
||||
|
||||
/** Pack a sorted, distinct list of chunk ids into a raw (uncompressed) bitset. */
|
||||
export function chunkIdsToRawBitset(
|
||||
chunkIds: readonly number[],
|
||||
bitLength: number
|
||||
): Uint8Array {
|
||||
if (bitLength < 0 || !Number.isInteger(bitLength)) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH,
|
||||
`bitLength must be a non-negative integer; got ${bitLength}`
|
||||
);
|
||||
}
|
||||
const byteLen = Math.ceil(bitLength / 8);
|
||||
const out = new Uint8Array(byteLen);
|
||||
for (const id of chunkIds) {
|
||||
if (id < 0 || !Number.isInteger(id) || id >= bitLength) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH,
|
||||
`chunk id ${id} is out of range for bitLength ${bitLength}`
|
||||
);
|
||||
}
|
||||
const idx = id >> 3;
|
||||
out[idx] = (out[idx] as number) | (1 << (id & 7));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Decode a raw (uncompressed) bitset into a sorted list of chunk ids. */
|
||||
export function rawBitsetToChunkIds(
|
||||
bytes: Uint8Array,
|
||||
bitLength: number
|
||||
): number[] {
|
||||
assertBitLengthMatches(bytes, bitLength);
|
||||
assertPaddingBitsZero(bytes, bitLength);
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < bitLength; i++) {
|
||||
if ((bytes[i >> 3] as number) & (1 << (i & 7))) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Encode a raw bitset to the wire format (DMP-04). */
|
||||
export function encodeBitset(
|
||||
rawBytes: Uint8Array,
|
||||
bitLength: number
|
||||
): Uint8Array {
|
||||
assertBitLengthMatches(rawBytes, bitLength);
|
||||
assertPaddingBitsZero(rawBytes, bitLength);
|
||||
const out = new Uint8Array(BITSET_HEADER_SIZE + rawBytes.length);
|
||||
out.set(BITSET_MAGIC, 0);
|
||||
const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
|
||||
view.setUint32(4, bitLength >>> 0, false);
|
||||
view.setUint32(8, rawBytes.length >>> 0, false);
|
||||
out.set(rawBytes, BITSET_HEADER_SIZE);
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface DecodedBitset {
|
||||
readonly raw: Uint8Array;
|
||||
readonly bitLength: number;
|
||||
}
|
||||
|
||||
/** Decode a wire-format bitset, enforcing DMP-08 / DMP-21. */
|
||||
export function decodeBitset(wire: Uint8Array): DecodedBitset {
|
||||
if (wire.length < BITSET_HEADER_SIZE) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.CODEC_MISMATCH,
|
||||
`bitset wire too short: ${wire.length} < ${BITSET_HEADER_SIZE}`
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < BITSET_MAGIC.length; i++) {
|
||||
if (wire[i] !== BITSET_MAGIC[i]) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.CODEC_MISMATCH,
|
||||
"bitset magic prefix missing — payload was not framed via @suaveplan/bitpacker-compressed"
|
||||
);
|
||||
}
|
||||
}
|
||||
const view = new DataView(wire.buffer, wire.byteOffset, wire.byteLength);
|
||||
const bitLength = view.getUint32(4, false);
|
||||
const rawByteLen = view.getUint32(8, false);
|
||||
const raw = wire.slice(BITSET_HEADER_SIZE);
|
||||
if (raw.length !== rawByteLen) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.CODEC_MISMATCH,
|
||||
`framed bitset payload length ${raw.length} ≠ declared ${rawByteLen}`
|
||||
);
|
||||
}
|
||||
assertBitLengthMatches(raw, bitLength);
|
||||
assertPaddingBitsZero(raw, bitLength);
|
||||
return { raw, bitLength };
|
||||
}
|
||||
|
||||
function assertBitLengthMatches(bytes: Uint8Array, bitLength: number): void {
|
||||
const expected = Math.ceil(bitLength / 8);
|
||||
if (bytes.length !== expected) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH,
|
||||
`bitset byte length ${bytes.length} ≠ expected ${expected} for bitLength ${bitLength}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPaddingBitsZero(bytes: Uint8Array, bitLength: number): void {
|
||||
if (bytes.length === 0) return;
|
||||
const trailingBits = bitLength & 7;
|
||||
if (trailingBits === 0) return;
|
||||
const lastByte = bytes[bytes.length - 1] as number;
|
||||
const padMask = 0xff << trailingBits;
|
||||
if ((lastByte & padMask & 0xff) !== 0) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.BITSET_PADDING_NONZERO,
|
||||
`bitset padding bits past bitLength ${bitLength} must be zero`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Count the set bits in a raw bitset (popcount). */
|
||||
export function popcount(bytes: Uint8Array): number {
|
||||
let n = 0;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
let b = bytes[i] as number;
|
||||
b = b - ((b >> 1) & 0x55);
|
||||
b = (b & 0x33) + ((b >> 2) & 0x33);
|
||||
n += (b + (b >> 4)) & 0x0f;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# common
|
||||
|
||||
## Purpose
|
||||
|
||||
Internal helpers shared by the four envelope codec modules (`request.ts`, `advertise.ts`, `response.ts`, `error.ts`). Centralising the base64 codec, strict-parse wrapper, and the `DMP_CODES` / `DmpError` re-export keeps each codec module focused on its envelope shape without duplicating utility code.
|
||||
|
||||
## Features
|
||||
|
||||
- `toBase64(bytes)` — Uint8Array → base64 string for JSON transport.
|
||||
- `fromBase64(s)` — inverse, returns a fresh Uint8Array.
|
||||
- `parseStrict(schema, input, code, label)` — throwing variant of Zod's `safeParse` that lifts schema errors into `DmpError(code, "...")`.
|
||||
- `DMP_CODES` / `DmpError` — re-exported so the codec modules do a single `from "./common.js"`.
|
||||
- Pure functions, zero side effects, deterministic outputs.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { fromBase64, toBase64 } from "../codec/common.js";
|
||||
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const encoded = toBase64(bytes);
|
||||
const back = fromBase64(encoded);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { DMP_CODES, parseStrict } from "../codec/common.js";
|
||||
|
||||
const Schema = z.object({ id: z.string(), n: z.number().int() }).strict();
|
||||
|
||||
function parseHeader(bytes: Uint8Array) {
|
||||
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
||||
return parseStrict<{ id: string; n: number }>(
|
||||
Schema,
|
||||
parsed,
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"header"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `toBase64(bytes: Uint8Array): string`.
|
||||
- `fromBase64(s: string): Uint8Array`.
|
||||
- `parseStrict<T>(schema, input, code: DmpErrorCode, label: string): T` — throws `DmpError(code, "<label> schema rejected: <reason>")` on failure.
|
||||
- `DMP_CODES`, `DmpError` — re-exports.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The base64 helpers go through `btoa` / `atob` because the codec is JSON-based; they're correct for any byte sequence including those with high-bit-set bytes. `parseStrict` accepts a generic `T` so call sites get the precisely-typed result of the schema rather than `unknown`. The narrow surface here is deliberate: anything more complex belongs in the specific codec module so the mental model stays "common.ts holds zero protocol-shaped logic".
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Internal helpers shared by the four envelope codec modules. None of
|
||||
* these are part of the public surface — they live in this file so the
|
||||
* codec modules avoid duplicating base64 / strict-parse / hex utilities.
|
||||
*/
|
||||
|
||||
import { DMP_CODES, DmpError, type DmpErrorCode } from "../error-codes.js";
|
||||
|
||||
/** Encode a `Uint8Array` as a base64 string suitable for JSON transport. */
|
||||
export function toBase64(bytes: Uint8Array): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
out += String.fromCharCode(bytes[i] as number);
|
||||
}
|
||||
return btoa(out);
|
||||
}
|
||||
|
||||
/** Decode a base64 string into the original `Uint8Array`. */
|
||||
export function fromBase64(s: string): Uint8Array {
|
||||
const decoded = atob(s);
|
||||
const out = new Uint8Array(decoded.length);
|
||||
for (let i = 0; i < decoded.length; i++) {
|
||||
out[i] = decoded.charCodeAt(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Throwing variant of Zod's `safeParse` with a coded `DmpError`. */
|
||||
export function parseStrict<T>(
|
||||
schema: { safeParse(input: unknown): unknown },
|
||||
input: unknown,
|
||||
code: DmpErrorCode,
|
||||
label: string
|
||||
): T {
|
||||
const r = schema.safeParse(input) as
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: { message: string } };
|
||||
if (!r.success) {
|
||||
throw new DmpError(
|
||||
code,
|
||||
`${label} schema rejected: ${r.error.message}`
|
||||
);
|
||||
}
|
||||
return r.data;
|
||||
}
|
||||
|
||||
/** Re-export `DMP_CODES` so callers do a single `from "./common.js"`. */
|
||||
export { DMP_CODES, DmpError };
|
||||
@@ -0,0 +1,62 @@
|
||||
# error
|
||||
|
||||
## Purpose
|
||||
|
||||
Encodes and decodes `cd:error` envelopes — the responder-to-requester error channel that carries a structured `ChunkErrorCode` (`NOT_FOUND`, `ACCESS_DENIED`, `BANDWIDTH_EXCEEDED`, `GENERATION_STALE`, `INTERNAL`) plus a `correlationId` matching the originating request and an optional `responderGeneration` for `GENERATION_STALE` (DMP-19).
|
||||
|
||||
## Features
|
||||
|
||||
- `encodeErrorPayload(payload)` — Zod-validates, enforces the DMP-19 invariant (`GENERATION_STALE` requires `responderGeneration`), and emits UTF-8 JSON.
|
||||
- `decodeErrorPayload(bytes)` — inverse, with the same DMP-19 check.
|
||||
- Lightweight — `ChunkErrorPayload` has no compressed binary fields, so the codec is JSON-only.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeErrorPayload,
|
||||
encodeErrorPayload,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const wire = encodeErrorPayload({
|
||||
fileId: "f".repeat(64),
|
||||
correlationId: "req-42",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
const back = decodeErrorPayload(wire);
|
||||
console.log(back.code); // "NOT_FOUND"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeErrorPayload,
|
||||
encodeErrorPayload,
|
||||
GenerationTracker,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const tracker = new GenerationTracker();
|
||||
|
||||
function handleErrorReply(bytes: Uint8Array, peerId: string): void {
|
||||
const e = decodeErrorPayload(bytes);
|
||||
if (e.code === "GENERATION_STALE" && e.responderGeneration !== undefined) {
|
||||
// Refresh peer-index for (peerId, e.fileId) without a round-trip.
|
||||
tracker.observe(peerId, e.fileId, e.responderGeneration);
|
||||
} else if (e.code === "BANDWIDTH_EXCEEDED" && e.retryAfterMs) {
|
||||
scheduleRetry(e.fileId, e.retryAfterMs);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encodeErrorPayload(payload: ChunkErrorPayload): Uint8Array`.
|
||||
- `decodeErrorPayload(bytes: Uint8Array): ChunkErrorPayload`.
|
||||
- Errors: `DMP.ENVELOPE_MALFORMED` for malformed JSON, schema rejection, or missing `responderGeneration` on `GENERATION_STALE`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The DMP-19 invariant is enforced symmetrically — both `encodeErrorPayload` and `decodeErrorPayload` reject `GENERATION_STALE` payloads without `responderGeneration`. This is intentional: a peer that lacks the data to refresh the requester's index has no business asking the requester to back off; it should send `INTERNAL` instead. The `ChunkErrorCode` enum is closed (the Zod schema uses `z.enum([...])`) so adding new error codes requires updating the schema, the type, and the protocol spec in lockstep.
|
||||
|
||||
The codec is intentionally JSON-only — no compressed binary fields, no base64-wrapped Uint8Arrays. This keeps the error path light enough to fire on every malformed envelope without contributing significant CPU or memory pressure during incident-storm conditions when half the mesh might be sending error envelopes simultaneously. The optional fields (`chunkId`, `retryAfterMs`, `responderGeneration`) flow through the schema with `.optional()` so consumers can detect their presence at the type level without runtime narrowing helpers.
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Codec for `cd:error` envelopes (DMP-18, DMP-19).
|
||||
*
|
||||
* `ChunkErrorPayload` is small and JSON-only — there are no compressed
|
||||
* binary fields. The codec validates against the strict Zod schema in
|
||||
* both directions; `code: 'GENERATION_STALE'` requires the responder's
|
||||
* current `generation` so requesters can refresh their peer index in
|
||||
* the same round trip (DMP-19).
|
||||
*/
|
||||
|
||||
import { DMP_CODES, DmpError } from "../error-codes.js";
|
||||
import { ChunkErrorPayloadSchema } from "../schemas/envelope.schema.js";
|
||||
import type { ChunkErrorPayload } from "../types/protocol.types.js";
|
||||
import { parseStrict } from "./common.js";
|
||||
|
||||
export function encodeErrorPayload(payload: ChunkErrorPayload): Uint8Array {
|
||||
const validated = parseStrict<ChunkErrorPayload>(
|
||||
ChunkErrorPayloadSchema,
|
||||
payload,
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"error payload"
|
||||
);
|
||||
if (
|
||||
validated.code === "GENERATION_STALE" &&
|
||||
validated.responderGeneration === undefined
|
||||
) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"GENERATION_STALE error MUST include responderGeneration"
|
||||
);
|
||||
}
|
||||
return new TextEncoder().encode(JSON.stringify(validated));
|
||||
}
|
||||
|
||||
export function decodeErrorPayload(bytes: Uint8Array): ChunkErrorPayload {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch (err) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
`error payload is not valid JSON: ${String(err)}`
|
||||
);
|
||||
}
|
||||
const validated = parseStrict<ChunkErrorPayload>(
|
||||
ChunkErrorPayloadSchema,
|
||||
parsed,
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"error payload"
|
||||
);
|
||||
if (
|
||||
validated.code === "GENERATION_STALE" &&
|
||||
validated.responderGeneration === undefined
|
||||
) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"GENERATION_STALE error MUST include responderGeneration"
|
||||
);
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export {
|
||||
decodeAdvertisePayload,
|
||||
encodeAdvertisePayload,
|
||||
} from "./advertise.js";
|
||||
export {
|
||||
BITSET_HEADER_SIZE,
|
||||
BITSET_MAGIC,
|
||||
chunkIdsToRawBitset,
|
||||
type DecodedBitset,
|
||||
decodeBitset,
|
||||
encodeBitset,
|
||||
popcount,
|
||||
rawBitsetToChunkIds,
|
||||
} from "./bitset.js";
|
||||
export { decodeErrorPayload, encodeErrorPayload } from "./error.js";
|
||||
export {
|
||||
decodeRequestPayload,
|
||||
encodeRequest,
|
||||
encodeRequestPayload,
|
||||
} from "./request.js";
|
||||
export {
|
||||
decodeResponsePayload,
|
||||
encodeResponsePayload,
|
||||
} from "./response.js";
|
||||
@@ -0,0 +1,62 @@
|
||||
# request
|
||||
|
||||
## Purpose
|
||||
|
||||
Encodes and decodes `cd:request` envelope payloads for the chunk-request side of the mesh protocol. Owns the wire format that downstream packages (`drive-mesh`, `network-plugin-content-delivery`) round-trip through bus transports, and enforces the DMP-01 / DMP-03 / DMP-07 / DMP-21 / DMP-22 invariants at every entry point so that no malformed payload ever reaches the logic layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `encodeRequestPayload(payload)` — schema-validates, enforces `maxChunks` cap, rejects empty `kind:'specific'` arrays, base64-wraps bitset bytes, and returns UTF-8 JSON bytes.
|
||||
- `decodeRequestPayload(bytes)` — inverse with the same gates plus the `bitpacker-compressed` magic check on bitset payloads (DMP-21).
|
||||
- `encodeRequest(request)` — lower-level helper that converts a runtime `ChunkRequest` to its JSON-friendly wire shape.
|
||||
- Strong typing: every public surface is parameterised by `ChunkRequestPayload` from `@suaveplan/drive-mesh-protocol/types`.
|
||||
- Errors are coded: `DMP.EMPTY_REQUEST`, `DMP.MAX_CHUNKS_EXCEEDED`, `DMP.ENVELOPE_MALFORMED`, `DMP.CODEC_MISMATCH`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeRequestPayload,
|
||||
encodeRequestPayload,
|
||||
selectWhole,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const wire = encodeRequestPayload({
|
||||
fileId: "a".repeat(64),
|
||||
request: selectWhole(),
|
||||
priority: "normal",
|
||||
});
|
||||
const back = decodeRequestPayload(wire);
|
||||
console.log(back.request.kind); // "whole"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
chunkIdsToRawBitset,
|
||||
encodeFramedBitset,
|
||||
encodeRequestPayload,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const need = [3, 7, 200, 999];
|
||||
const raw = chunkIdsToRawBitset(need, 1000);
|
||||
const framed = encodeFramedBitset(raw, 1000);
|
||||
const wire = encodeRequestPayload({
|
||||
fileId,
|
||||
request: { kind: "bitset", bytes: framed },
|
||||
priority: "high",
|
||||
maxChunks: 256,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encodeRequestPayload(payload: ChunkRequestPayload): Uint8Array`.
|
||||
- `decodeRequestPayload(bytes: Uint8Array): ChunkRequestPayload`.
|
||||
- `encodeRequest(request: ChunkRequest): WireRequest` — JSON-friendly wire shape; `bitset` form's `bytes` becomes `{ _b: <base64> }`.
|
||||
- Throws `DmpError` with one of the namespaced codes above for every rejection path.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Bitset bytes round-trip through base64 so JSON parsers don't lose the high bit of any byte. The Zod schema deliberately permits empty `chunkIds` arrays so the explicit DMP-03 / DMP-22 gate (which throws `DMP.EMPTY_REQUEST` rather than `DMP.ENVELOPE_MALFORMED`) is the visible failure path. The `maxChunks` schema check is lower-bound only; the upper-bound mandate (DMP-07) lives in `enforceMaxChunks` so the failure carries the spec-mandated `DMP.MAX_CHUNKS_EXCEEDED` code.
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Codec for `cd:request` envelopes (DMP-01..05, DMP-07, DMP-21..22).
|
||||
*
|
||||
* Wire format: a single UTF-8-encoded JSON document containing the
|
||||
* `ChunkRequestPayload` shape. `bytes` for `kind: 'bitset'` requests is
|
||||
* itself a wire-format bitset (`@suaveplan/bitpacker-compressed`-framed
|
||||
* via `codec/bitset.encodeBitset`); on the JSON layer it is encoded as
|
||||
* `{ "_b": "<base64>" }` so JSON parsers don't lose the high bit of any
|
||||
* byte. Receivers detect the bitset framing magic on decode and reject
|
||||
* non-bitpacker-compressed payloads with `DMP.CODEC_MISMATCH`.
|
||||
*/
|
||||
|
||||
import { DMP_CODES, DmpError } from "../error-codes.js";
|
||||
import {
|
||||
ChunkRequestPayloadSchema,
|
||||
MAX_CHUNKS_DEFAULT,
|
||||
MAX_CHUNKS_HARD_CAP,
|
||||
} from "../schemas/envelope.schema.js";
|
||||
import type {
|
||||
ChunkRequest,
|
||||
ChunkRequestPayload,
|
||||
} from "../types/protocol.types.js";
|
||||
import { BITSET_MAGIC, decodeBitset } from "./bitset.js";
|
||||
import { fromBase64, parseStrict, toBase64 } from "./common.js";
|
||||
|
||||
interface WireRequest {
|
||||
readonly kind: "whole" | "specific" | "bitset";
|
||||
readonly chunkIds?: readonly number[];
|
||||
readonly bytes?: { readonly _b: string };
|
||||
}
|
||||
|
||||
interface WirePayload {
|
||||
readonly fileId: string;
|
||||
readonly request: WireRequest;
|
||||
readonly priority: ChunkRequestPayload["priority"];
|
||||
readonly maxChunks?: number;
|
||||
}
|
||||
|
||||
export function encodeRequestPayload(payload: ChunkRequestPayload): Uint8Array {
|
||||
const validated = parseStrict<ChunkRequestPayload>(
|
||||
ChunkRequestPayloadSchema,
|
||||
payload,
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"request payload"
|
||||
);
|
||||
enforceMaxChunks(validated);
|
||||
if (
|
||||
validated.request.kind === "specific" &&
|
||||
validated.request.chunkIds.length === 0
|
||||
) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.EMPTY_REQUEST,
|
||||
"kind:'specific' with empty chunkIds is rejected"
|
||||
);
|
||||
}
|
||||
const wire: WirePayload = {
|
||||
fileId: validated.fileId,
|
||||
priority: validated.priority,
|
||||
request: encodeRequest(validated.request),
|
||||
...(validated.maxChunks !== undefined
|
||||
? { maxChunks: validated.maxChunks }
|
||||
: {}),
|
||||
};
|
||||
return new TextEncoder().encode(JSON.stringify(wire));
|
||||
}
|
||||
|
||||
export function decodeRequestPayload(bytes: Uint8Array): ChunkRequestPayload {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch (err) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
`request payload is not valid JSON: ${String(err)}`
|
||||
);
|
||||
}
|
||||
// DMP-01 explicit-kind gate: pre-validate the discriminator so an
|
||||
// unknown `kind` surfaces as `DMP.UNKNOWN_REQUEST_KIND` rather than
|
||||
// the generic schema rejection.
|
||||
if (
|
||||
parsed !== null &&
|
||||
typeof parsed === "object" &&
|
||||
"request" in parsed &&
|
||||
typeof (parsed as { request: unknown }).request === "object" &&
|
||||
(parsed as { request: { kind?: unknown } }).request !== null
|
||||
) {
|
||||
const k = (parsed as { request: { kind?: unknown } }).request.kind;
|
||||
if (
|
||||
typeof k === "string" &&
|
||||
k !== "whole" &&
|
||||
k !== "specific" &&
|
||||
k !== "bitset"
|
||||
) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.UNKNOWN_REQUEST_KIND,
|
||||
`unknown ChunkRequest kind: ${k}`
|
||||
);
|
||||
}
|
||||
}
|
||||
const validated = parseStrict<ChunkRequestPayload>(
|
||||
ChunkRequestPayloadSchema,
|
||||
unwrapWireRequest(parsed),
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"request payload"
|
||||
);
|
||||
enforceMaxChunks(validated);
|
||||
if (
|
||||
validated.request.kind === "specific" &&
|
||||
validated.request.chunkIds.length === 0
|
||||
) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.EMPTY_REQUEST,
|
||||
"kind:'specific' with empty chunkIds is rejected; use kind:'whole' for everything"
|
||||
);
|
||||
}
|
||||
if (validated.request.kind === "bitset") {
|
||||
// DMP-21: bitset payload MUST be bitpacker-compressed-framed. The
|
||||
// decode step will throw DMP.CODEC_MISMATCH on missing magic.
|
||||
decodeBitset(validated.request.bytes);
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
|
||||
export function encodeRequest(request: ChunkRequest): WireRequest {
|
||||
if (request.kind === "whole") return { kind: "whole" };
|
||||
if (request.kind === "specific") {
|
||||
if (request.chunkIds.length === 0) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.EMPTY_REQUEST,
|
||||
"kind:'specific' with empty chunkIds is rejected"
|
||||
);
|
||||
}
|
||||
return {
|
||||
kind: "specific",
|
||||
chunkIds: request.chunkIds,
|
||||
};
|
||||
}
|
||||
assertBitsetMagic(request.bytes);
|
||||
return {
|
||||
kind: "bitset",
|
||||
bytes: { _b: toBase64(request.bytes) },
|
||||
};
|
||||
}
|
||||
|
||||
function enforceMaxChunks(payload: ChunkRequestPayload): void {
|
||||
const max = payload.maxChunks ?? MAX_CHUNKS_DEFAULT;
|
||||
if (max > MAX_CHUNKS_HARD_CAP) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.MAX_CHUNKS_EXCEEDED,
|
||||
`maxChunks ${max} > hard cap ${MAX_CHUNKS_HARD_CAP}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertBitsetMagic(bytes: Uint8Array): void {
|
||||
if (
|
||||
bytes.length < BITSET_MAGIC.length ||
|
||||
!BITSET_MAGIC.every((b, i) => bytes[i] === b)
|
||||
) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.CODEC_MISMATCH,
|
||||
"bitset payload missing the @suaveplan/bitpacker-compressed framing magic"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapWireRequest(parsed: unknown): unknown {
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== "object" ||
|
||||
!("request" in parsed)
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
const wire = parsed as WirePayload;
|
||||
const request = wire.request;
|
||||
if (request.kind === "bitset" && request.bytes) {
|
||||
return {
|
||||
...wire,
|
||||
request: {
|
||||
kind: "bitset",
|
||||
bytes: fromBase64(request.bytes._b),
|
||||
},
|
||||
};
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# response
|
||||
|
||||
## Purpose
|
||||
|
||||
Encodes and decodes `cd:response` envelope payloads — the responder-side messages that carry chunk bytes back to a requester plus a piggyback presence bitset (DMP-11) and generation counter. Enforces DMP-12 (chunks ordered by `chunkId` ascending), DMP-21 (presence framed via the bitset codec), and the standard schema gates.
|
||||
|
||||
## Features
|
||||
|
||||
- `encodeResponsePayload(payload)` — Zod-validates, asserts ascending chunk-id order, cross-checks presence framing, base64-wraps every chunk's bytes plus the presence buffer, emits UTF-8 JSON.
|
||||
- `decodeResponsePayload(bytes)` — inverse, with the same ordering and framing checks so receivers can short-circuit on the first ordering violation.
|
||||
- Order-violation surface: `DMP.UNORDERED_RESPONSE`. Other failures route to `DMP.ENVELOPE_MALFORMED` / `DMP.CODEC_MISMATCH`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
chunkIdsToRawBitset,
|
||||
decodeResponsePayload,
|
||||
encodeFramedBitset,
|
||||
encodeResponsePayload,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const presence = encodeFramedBitset(chunkIdsToRawBitset([0, 5], 8), 8);
|
||||
const wire = encodeResponsePayload({
|
||||
fileId: "f".repeat(64),
|
||||
chunks: [
|
||||
{ chunkId: 0, bytes: new Uint8Array([0xa1]) },
|
||||
{ chunkId: 5, bytes: new Uint8Array([0xa2]) },
|
||||
],
|
||||
presence,
|
||||
generation: 11,
|
||||
});
|
||||
const back = decodeResponsePayload(wire);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeFramedBitset,
|
||||
decodeResponsePayload,
|
||||
rawBitsetToChunkIds,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
function ingestResponse(bytes: Uint8Array, peerId: string): void {
|
||||
const r = decodeResponsePayload(bytes);
|
||||
// Apply DMP-11 piggyback: update peer-index for (peerId, r.fileId)
|
||||
// from r.presence without waiting for a separate cd:advertise.
|
||||
const presence = decodeFramedBitset(r.presence);
|
||||
const inventory = rawBitsetToChunkIds(presence.raw, presence.bitLength);
|
||||
updatePeerIndex(peerId, r.fileId, inventory, r.generation);
|
||||
for (const c of r.chunks) commitVerifiedChunk(r.fileId, c.chunkId, c.bytes);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encodeResponsePayload(payload: ChunkResponsePayload): Uint8Array`.
|
||||
- `decodeResponsePayload(bytes: Uint8Array): ChunkResponsePayload`.
|
||||
- Errors: `DMP.UNORDERED_RESPONSE`, `DMP.ENVELOPE_MALFORMED`, `DMP.CODEC_MISMATCH`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The ordering invariant (DMP-12) is checked twice — once at encode time so issuers can't accidentally emit a malformed response, and once at decode time to defend against tampered wire bytes. The order check uses strict `<=` so duplicate chunkIds are rejected too; this matters because a duplicate at index `i` would otherwise let a downstream consumer commit the same chunk twice and double-increment the trust counters. The piggyback presence MUST go through the bitset framing's magic check on every decode so misframed payloads can't slip past as base64-wrapped JSON strings.
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Codec for `cd:response` envelopes (DMP-11, DMP-12, DMP-21).
|
||||
*
|
||||
* Response payloads carry an array of `ChunkBytes` ordered by `chunkId`
|
||||
* ascending plus a piggyback presence bitset for the responder's full
|
||||
* inventory. Encoder and decoder both reject out-of-order chunks with
|
||||
* `DMP.UNORDERED_RESPONSE`. Both fields cross JSON as base64 strings;
|
||||
* the presence bitset additionally goes through the
|
||||
* `bitpacker-compressed` framing magic check (DMP-21).
|
||||
*/
|
||||
|
||||
import { DMP_CODES, DmpError } from "../error-codes.js";
|
||||
import { ChunkResponsePayloadSchema } from "../schemas/envelope.schema.js";
|
||||
import type {
|
||||
ChunkBytes,
|
||||
ChunkResponsePayload,
|
||||
} from "../types/protocol.types.js";
|
||||
import { decodeBitset } from "./bitset.js";
|
||||
import { fromBase64, parseStrict, toBase64 } from "./common.js";
|
||||
|
||||
interface WireChunk {
|
||||
readonly chunkId: number;
|
||||
readonly bytes: { readonly _b: string };
|
||||
}
|
||||
|
||||
interface WirePayload {
|
||||
readonly fileId: string;
|
||||
readonly chunks: readonly WireChunk[];
|
||||
readonly presence: { readonly _b: string };
|
||||
readonly generation: number;
|
||||
}
|
||||
|
||||
export function encodeResponsePayload(
|
||||
payload: ChunkResponsePayload
|
||||
): Uint8Array {
|
||||
const validated = parseStrict<ChunkResponsePayload>(
|
||||
ChunkResponsePayloadSchema,
|
||||
payload,
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"response payload"
|
||||
);
|
||||
assertOrdered(validated.chunks);
|
||||
// DMP-21: presence MUST already be bitpacker-compressed-framed.
|
||||
decodeBitset(validated.presence);
|
||||
const wire: WirePayload = {
|
||||
fileId: validated.fileId,
|
||||
chunks: validated.chunks.map((c) => ({
|
||||
chunkId: c.chunkId,
|
||||
bytes: { _b: toBase64(c.bytes) },
|
||||
})),
|
||||
presence: { _b: toBase64(validated.presence) },
|
||||
generation: validated.generation,
|
||||
};
|
||||
return new TextEncoder().encode(JSON.stringify(wire));
|
||||
}
|
||||
|
||||
export function decodeResponsePayload(bytes: Uint8Array): ChunkResponsePayload {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch (err) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
`response payload is not valid JSON: ${String(err)}`
|
||||
);
|
||||
}
|
||||
const validated = parseStrict<ChunkResponsePayload>(
|
||||
ChunkResponsePayloadSchema,
|
||||
unwrapWire(parsed),
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"response payload"
|
||||
);
|
||||
assertOrdered(validated.chunks);
|
||||
decodeBitset(validated.presence);
|
||||
return validated;
|
||||
}
|
||||
|
||||
function assertOrdered(chunks: readonly ChunkBytes[]): void {
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
const prev = chunks[i - 1] as ChunkBytes;
|
||||
const cur = chunks[i] as ChunkBytes;
|
||||
if (cur.chunkId <= prev.chunkId) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.UNORDERED_RESPONSE,
|
||||
`chunk ${cur.chunkId} appears at index ${i} after ${prev.chunkId} — chunks must be strictly ascending`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapWire(parsed: unknown): unknown {
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== "object" ||
|
||||
!("chunks" in parsed) ||
|
||||
!("presence" in parsed)
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
const wire = parsed as WirePayload;
|
||||
return {
|
||||
...wire,
|
||||
chunks: wire.chunks.map((c) => ({
|
||||
chunkId: c.chunkId,
|
||||
bytes: fromBase64(c.bytes._b),
|
||||
})),
|
||||
presence: fromBase64(wire.presence._b),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# DMP_CODES + DmpError
|
||||
|
||||
## Purpose
|
||||
|
||||
Frozen registry of error codes plus the error class for the `@suaveplan/drive-mesh-protocol` package. Every rejection path in the mesh protocol — empty `kind: 'specific'` requests, unknown discriminator values, exceeded `maxChunks`, malformed bitsets, out-of-order responses, stale generations, codec mismatches — produces a `DmpError` whose `code` field is one of the eight strings registered here. Consumers branch on `code` programmatically; messages are for humans only.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight codes covering every rejection path in the mesh-protocol spec (`DMP-01..DMP-22`).
|
||||
- `DMP_CODES` is frozen via `defineCodes` from `@suaveplan/error` — adding a code requires a code change and a spec update.
|
||||
- `DmpError extends SuaveplanError<DmpErrorCode>` — inherits cause-chain traversal, redaction, and JSON wire format.
|
||||
- Codes are typed at compile time (`DmpErrorCode`) so consumers get exhaustive-switch type checking on error code branches.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DmpError, DMP_CODES } from "@suaveplan/drive-mesh-protocol/errors";
|
||||
|
||||
throw new DmpError(DMP_CODES.EMPTY_REQUEST, "kind:'specific' requires non-empty chunkIds");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DmpError, DMP_CODES, type DmpErrorCode } from "@suaveplan/drive-mesh-protocol/errors";
|
||||
|
||||
function describe(code: DmpErrorCode): string {
|
||||
switch (code) {
|
||||
case DMP_CODES.EMPTY_REQUEST: return "kind:'specific' with no chunkIds is a no-op; use kind:'whole' instead";
|
||||
case DMP_CODES.UNKNOWN_REQUEST_KIND: return "request.kind must be one of 'whole' | 'specific' | 'bitset'";
|
||||
case DMP_CODES.MAX_CHUNKS_EXCEEDED: return "maxChunks > 1024 is rejected; clamp to a sensible value";
|
||||
case DMP_CODES.BITSET_LENGTH_MISMATCH: return "bitset bit-length must equal totalChunks";
|
||||
case DMP_CODES.BITSET_PADDING_NONZERO: return "padding bits in the final byte must be zero";
|
||||
case DMP_CODES.UNORDERED_RESPONSE: return "ChunkResponsePayload.chunks must be ordered by chunkId asc";
|
||||
case DMP_CODES.GENERATION_STALE: return "responder's generation > requester's; refresh peer-index";
|
||||
case DMP_CODES.CODEC_MISMATCH: return "envelope not framed via @suaveplan/bitpacker-network";
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
throw new DmpError(DMP_CODES.MAX_CHUNKS_EXCEEDED, "request asked for 4096 chunks");
|
||||
} catch (err) {
|
||||
if (err instanceof DmpError) {
|
||||
console.error(`[${err.code}] ${describe(err.code)}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `const DMP_CODES`
|
||||
|
||||
Frozen object mapping uppercase code names to their wire-format strings:
|
||||
|
||||
- `EMPTY_REQUEST` — `kind: 'specific'` envelope arrived with `chunkIds: []`. Disambiguates from `kind: 'whole'`.
|
||||
- `UNKNOWN_REQUEST_KIND` — discriminator field is not one of `'whole' | 'specific' | 'bitset'`. Likely a future-version envelope or a malformed wire.
|
||||
- `MAX_CHUNKS_EXCEEDED` — `request.maxChunks > 1024`. Validation rejects to prevent unbounded responses.
|
||||
- `BITSET_LENGTH_MISMATCH` — `presence` bit-length differs from `totalChunks`. Wire format is corrupted.
|
||||
- `BITSET_PADDING_NONZERO` — padding bits in the final byte of a bitset are non-zero. Wire format is malformed.
|
||||
- `UNORDERED_RESPONSE` — `ChunkResponsePayload.chunks` is not ordered by `chunkId` ascending. Allows incremental presence updates only when ordered.
|
||||
- `GENERATION_STALE` — responder rejected the request because its generation counter is ahead of the requester's. Includes responder's current generation in the error context for cheap recovery.
|
||||
- `CODEC_MISMATCH` — envelope is not framed via `@suaveplan/bitpacker-network`. Indicates a wire-format mismatch (different protocol version, different codec).
|
||||
|
||||
### `type DmpErrorCode`
|
||||
|
||||
Union: `(typeof DMP_CODES)[keyof typeof DMP_CODES]`. The string-literal union of every registered code.
|
||||
|
||||
### `class DmpError extends SuaveplanError<DmpErrorCode>`
|
||||
|
||||
Constructor: `new DmpError(code, message, options?)`. Inherits all `SuaveplanError` features (cause chain, context bag, OTel span-event adapter, JSON/binary wire format).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The codes are intentionally generic — they describe wire-protocol invariant violations, not specific peer behaviors. Trust-related conditions (peer advertised but didn't deliver, peer's signature failed) do NOT throw `DmpError`; they update the per-`(peerId, fileId)` trust score in the `select/trust` module without raising errors. Errors are reserved for situations where the local code cannot proceed: malformed envelopes, bad inputs, codec mismatches.
|
||||
|
||||
The `DMP_` prefix is mandated by `defineCodes("DMP", ...)`. The `error-codes.spec-conformance.test.ts` enforces (1) the registry is frozen, (2) every code value starts with `DMP.`, (3) the eight names match what the spec requires.
|
||||
|
||||
When the mesh protocol's wire format evolves, new codes are ADDED — never renamed or removed — and the `DMP_CODES` object grows. Tests with `expect.assertions` patterns continue to work because the literal union widens monotonically.
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Frozen registry of error codes for the mesh protocol package.
|
||||
*
|
||||
* Every error thrown by `@suaveplan/drive-mesh-protocol` carries one of
|
||||
* these codes so consumers can branch programmatically without parsing
|
||||
* messages.
|
||||
*/
|
||||
export const DMP_CODES = defineCodes("DMP", [
|
||||
"EMPTY_REQUEST",
|
||||
"UNKNOWN_REQUEST_KIND",
|
||||
"MAX_CHUNKS_EXCEEDED",
|
||||
"BITSET_LENGTH_MISMATCH",
|
||||
"BITSET_PADDING_NONZERO",
|
||||
"UNORDERED_RESPONSE",
|
||||
"GENERATION_STALE",
|
||||
"CODEC_MISMATCH",
|
||||
"ENVELOPE_MALFORMED",
|
||||
"THROTTLE_EXCEEDED",
|
||||
] as const);
|
||||
|
||||
/** Union of every code in `DMP_CODES`. */
|
||||
export type DmpErrorCode = (typeof DMP_CODES)[keyof typeof DMP_CODES];
|
||||
|
||||
/**
|
||||
* Error class for the mesh-protocol package. Extends `SuaveplanError`
|
||||
* so consumers can `instanceof DmpError` and access `code`, `cause`,
|
||||
* `context`, and the structured wire-format helpers.
|
||||
*/
|
||||
export class DmpError extends SuaveplanError<DmpErrorCode> {
|
||||
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
|
||||
public constructor(
|
||||
code: DmpErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
type AcceptResult,
|
||||
GenerationTracker,
|
||||
} from "./monotonic.js";
|
||||
export {
|
||||
AdvertiseThrottle,
|
||||
type ThrottleResult,
|
||||
} from "./throttle.js";
|
||||
@@ -0,0 +1,62 @@
|
||||
# monotonic
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `GenerationTracker`, the receiver-side state machine that keeps the highest seen `generation` per `(peerId, fileId)` and drops stale advertisements (DMP-09). Generations are unsigned 32-bit; receivers tolerate a one-cycle wrap when `(lastSeen - generation) > 2³¹` to handle the rare anomaly of a peer that genuinely wraps past `0xFFFF_FFFF`.
|
||||
|
||||
## Features
|
||||
|
||||
- `observe(peerId, fileId, generation)` — returns `{ accept, latest, reason? }` with `reason: "stale" | "duplicate"` for rejected envelopes.
|
||||
- `peek(peerId, fileId)` — read the current highest without mutating.
|
||||
- `dropPeer(peerId)` / `dropFile(fileId)` — registry-driven cleanup hooks.
|
||||
- Wrap detection via `GENERATION_WRAP_THRESHOLD` (2³¹) to discriminate "out-of-order" from "wrap-around".
|
||||
- `size` getter for snapshot/persistence inspection.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { GenerationTracker } from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const tracker = new GenerationTracker();
|
||||
tracker.observe("peer-A", "fileId", 7);
|
||||
const r = tracker.observe("peer-A", "fileId", 6);
|
||||
console.log(r.accept); // false (stale)
|
||||
console.log(r.reason); // "stale"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeAdvertisePayload,
|
||||
GenerationTracker,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const tracker = new GenerationTracker();
|
||||
|
||||
function handleIncomingAdvertise(
|
||||
bytes: Uint8Array,
|
||||
peerId: string
|
||||
): void {
|
||||
const adv = decodeAdvertisePayload(bytes);
|
||||
const result = tracker.observe(peerId, adv.fileId, adv.generation);
|
||||
if (!result.accept) {
|
||||
// DMP-09: drop stale or duplicate advertisements silently.
|
||||
return;
|
||||
}
|
||||
updatePeerIndex(peerId, adv.fileId, adv.presence);
|
||||
}
|
||||
|
||||
function onPeerLeft(peerId: string): void {
|
||||
tracker.dropPeer(peerId);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class GenerationTracker` — `observe(peerId, fileId, generation)`, `peek(peerId, fileId)`, `dropPeer(peerId)`, `dropFile(fileId)`, `size`.
|
||||
- `interface AcceptResult` — `{ accept: boolean; latest: number; reason?: "stale" | "duplicate" }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Negative or non-integer generations short-circuit to `accept: false` rather than throwing; the protocol-level rejection of malformed envelopes happens at the codec layer, and the tracker treats malformed inputs as stale to be defensive. The wrap heuristic (`(lastSeen - generation) > 2³¹`) accepts a single anomaly per `(peerId, fileId)` cycle — a peer that tries to wrap twice in quick succession will be classified as stale on the second attempt, which is the right safety choice given that real wraps are extremely rare.
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Generation-counter monotonicity tracker (DMP-09).
|
||||
*
|
||||
* `ChunkAdvertisePayload.generation` MUST monotonically increase per
|
||||
* `(peerId, fileId)`. Receivers track the highest generation seen and
|
||||
* drop advertisements with `generation < lastSeen`. Generations wrap
|
||||
* modulo 2^32 — receivers MAY accept a wrap when
|
||||
* `(lastSeen - generation) > 2^31` (one-cycle anomaly tolerated).
|
||||
*/
|
||||
|
||||
import type { PeerId } from "@suaveplan/types";
|
||||
import { GENERATION_WRAP_THRESHOLD } from "../schemas/envelope.schema.js";
|
||||
|
||||
interface GenerationEntry {
|
||||
generation: number;
|
||||
}
|
||||
|
||||
function key(peerId: PeerId, fileId: string): string {
|
||||
return `${peerId}:${fileId}`;
|
||||
}
|
||||
|
||||
export interface AcceptResult {
|
||||
/** Whether the advertisement should be accepted. */
|
||||
readonly accept: boolean;
|
||||
/** The new "highest seen" generation after this call. */
|
||||
readonly latest: number;
|
||||
/** Reason a rejected advertisement was dropped (for logs). */
|
||||
readonly reason?: "stale" | "duplicate";
|
||||
}
|
||||
|
||||
export class GenerationTracker {
|
||||
readonly #entries = new Map<string, GenerationEntry>();
|
||||
|
||||
/**
|
||||
* Track the generation of an incoming advertisement and decide
|
||||
* whether to accept it. Returns `accept: false` for stale/duplicate
|
||||
* generations; the caller is expected to drop the envelope.
|
||||
*/
|
||||
public observe(
|
||||
peerId: PeerId,
|
||||
fileId: string,
|
||||
generation: number
|
||||
): AcceptResult {
|
||||
if (
|
||||
generation < 0 ||
|
||||
!Number.isInteger(generation) ||
|
||||
generation > 0xff_ff_ff_ff
|
||||
) {
|
||||
return { accept: false, latest: generation, reason: "stale" };
|
||||
}
|
||||
const k = key(peerId, fileId);
|
||||
const e = this.#entries.get(k);
|
||||
if (!e) {
|
||||
this.#entries.set(k, { generation });
|
||||
return { accept: true, latest: generation };
|
||||
}
|
||||
if (generation === e.generation) {
|
||||
return { accept: false, latest: e.generation, reason: "duplicate" };
|
||||
}
|
||||
if (generation > e.generation) {
|
||||
e.generation = generation;
|
||||
return { accept: true, latest: generation };
|
||||
}
|
||||
// generation < lastSeen — accept only on a one-cycle wrap.
|
||||
if (e.generation - generation > GENERATION_WRAP_THRESHOLD) {
|
||||
e.generation = generation;
|
||||
return { accept: true, latest: generation };
|
||||
}
|
||||
return { accept: false, latest: e.generation, reason: "stale" };
|
||||
}
|
||||
|
||||
/** Read the current highest generation for `(peerId, fileId)`. */
|
||||
public peek(peerId: PeerId, fileId: string): number | undefined {
|
||||
return this.#entries.get(key(peerId, fileId))?.generation;
|
||||
}
|
||||
|
||||
public dropPeer(peerId: PeerId): void {
|
||||
for (const k of [...this.#entries.keys()]) {
|
||||
if (k.startsWith(`${peerId}:`)) {
|
||||
this.#entries.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dropFile(fileId: string): void {
|
||||
for (const k of [...this.#entries.keys()]) {
|
||||
if (k.endsWith(`:${fileId}`)) {
|
||||
this.#entries.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public get size(): number {
|
||||
return this.#entries.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# throttle
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `AdvertiseThrottle`, the issuer-side rate limiter that caps outgoing `cd:advertise` envelopes at one per `(neighbor, fileId)` per `windowMs` interval (DMP-10). Multiple presence changes inside the window are coalesced into the next emission via the `pending` flag so peers gossip efficiently without spamming the bus.
|
||||
|
||||
## Features
|
||||
|
||||
- `allow(neighbor, fileId)` — returns `{ allow, retryAfterMs }` so the caller can either emit immediately or schedule a deferred emission.
|
||||
- `hasPending(neighbor, fileId)` — query for "is an advertisement waiting to flush?".
|
||||
- `clearPending(neighbor, fileId)` — invoked by the caller after the deferred advertisement actually lands.
|
||||
- `dropNeighbor(neighbor)` — registry-driven cleanup.
|
||||
- Configurable `windowMs` capped at `ADVERTISE_THROTTLE_MS_MAX` (60 s); rejected with `DMP.THROTTLE_EXCEEDED` if out of range.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AdvertiseThrottle } from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const throttle = new AdvertiseThrottle({ clock: () => Date.now() });
|
||||
const r = throttle.allow("neighbor-1", "fileId");
|
||||
if (r.allow) {
|
||||
sendAdvertise("neighbor-1", "fileId");
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { AdvertiseThrottle } from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const throttle = new AdvertiseThrottle({
|
||||
clock: () => Date.now(),
|
||||
windowMs: 2_000, // QoS subsystem widened the window during congestion
|
||||
});
|
||||
|
||||
function onPresenceChanged(neighbor: string, fileId: string): void {
|
||||
const r = throttle.allow(neighbor, fileId);
|
||||
if (r.allow) {
|
||||
sendAdvertise(neighbor, fileId);
|
||||
} else {
|
||||
// Coalesce: schedule a single follow-up after the window elapses.
|
||||
if (!throttle.hasPending(neighbor, fileId)) {
|
||||
setTimeout(() => {
|
||||
sendAdvertise(neighbor, fileId);
|
||||
throttle.clearPending(neighbor, fileId);
|
||||
}, r.retryAfterMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new AdvertiseThrottle({ clock, windowMs? })` — `windowMs` defaults to `ADVERTISE_THROTTLE_MS_DEFAULT` (1000).
|
||||
- `allow(neighbor, fileId): ThrottleResult` — `{ allow, retryAfterMs }`.
|
||||
- `hasPending(neighbor, fileId): boolean`.
|
||||
- `clearPending(neighbor, fileId): void`.
|
||||
- `dropNeighbor(neighbor): void`.
|
||||
- `size: number`, `windowMs: number`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The throttle uses an injected `Clock` so tests can step time deterministically. The `pending` flag short-circuits double-flushes when multiple presence changes hit the same window; without it, callers would either drop advertisements or emit redundant ones. The constructor's window-range check fires `DMP.THROTTLE_EXCEEDED` (rather than a generic argument error) so monitoring dashboards can categorise misconfiguration alongside the runtime throttle behaviour.
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Outgoing-advertisement throttle (DMP-10).
|
||||
*
|
||||
* A peer SHALL emit at most one outgoing `cd:advertise` per file per
|
||||
* direct neighbor per `windowMs` interval. The default window is 1
|
||||
* second. Multiple presence changes inside the window are coalesced
|
||||
* into the next emission. The window MAY be adjusted by
|
||||
* `network-plugin-qos` based on bus congestion but SHALL NOT exceed
|
||||
* `ADVERTISE_THROTTLE_MS_MAX` (60 s).
|
||||
*/
|
||||
|
||||
import type { Clock, PeerId } from "@suaveplan/types";
|
||||
import { DMP_CODES, DmpError } from "../error-codes.js";
|
||||
import {
|
||||
ADVERTISE_THROTTLE_MS_DEFAULT,
|
||||
ADVERTISE_THROTTLE_MS_MAX,
|
||||
} from "../schemas/envelope.schema.js";
|
||||
|
||||
interface ThrottleEntry {
|
||||
lastSent: number;
|
||||
pending: boolean;
|
||||
}
|
||||
|
||||
function key(peerId: PeerId, fileId: string): string {
|
||||
return `${peerId}:${fileId}`;
|
||||
}
|
||||
|
||||
export interface ThrottleResult {
|
||||
readonly allow: boolean;
|
||||
/** Milliseconds until the next emission would be allowed. */
|
||||
readonly retryAfterMs: number;
|
||||
}
|
||||
|
||||
export class AdvertiseThrottle {
|
||||
readonly #entries = new Map<string, ThrottleEntry>();
|
||||
readonly #clock: Clock;
|
||||
readonly #windowMs: number;
|
||||
|
||||
public constructor(options: {
|
||||
readonly clock: Clock;
|
||||
readonly windowMs?: number;
|
||||
}) {
|
||||
const window = options.windowMs ?? ADVERTISE_THROTTLE_MS_DEFAULT;
|
||||
if (window <= 0 || window > ADVERTISE_THROTTLE_MS_MAX) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.THROTTLE_EXCEEDED,
|
||||
`windowMs must be in (0, ${ADVERTISE_THROTTLE_MS_MAX}]; got ${window}`
|
||||
);
|
||||
}
|
||||
this.#clock = options.clock;
|
||||
this.#windowMs = window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an outgoing `cd:advertise` for `(neighbor, fileId)` is
|
||||
* permitted right now, and if not, how long to wait.
|
||||
*/
|
||||
public allow(neighbor: PeerId, fileId: string): ThrottleResult {
|
||||
const now = this.#clock();
|
||||
const k = key(neighbor, fileId);
|
||||
const e = this.#entries.get(k);
|
||||
if (!e) {
|
||||
this.#entries.set(k, { lastSent: now, pending: false });
|
||||
return { allow: true, retryAfterMs: 0 };
|
||||
}
|
||||
const elapsed = now - e.lastSent;
|
||||
if (elapsed >= this.#windowMs) {
|
||||
e.lastSent = now;
|
||||
e.pending = false;
|
||||
return { allow: true, retryAfterMs: 0 };
|
||||
}
|
||||
e.pending = true;
|
||||
return { allow: false, retryAfterMs: this.#windowMs - elapsed };
|
||||
}
|
||||
|
||||
/** True when an advertisement was suppressed and is waiting to flush. */
|
||||
public hasPending(neighbor: PeerId, fileId: string): boolean {
|
||||
return this.#entries.get(key(neighbor, fileId))?.pending ?? false;
|
||||
}
|
||||
|
||||
/** Flush the pending bit (call after the suppressed advertisement is sent). */
|
||||
public clearPending(neighbor: PeerId, fileId: string): void {
|
||||
const e = this.#entries.get(key(neighbor, fileId));
|
||||
if (e) e.pending = false;
|
||||
}
|
||||
|
||||
public dropNeighbor(neighbor: PeerId): void {
|
||||
for (const k of [...this.#entries.keys()]) {
|
||||
if (k.startsWith(`${neighbor}:`)) {
|
||||
this.#entries.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public get size(): number {
|
||||
return this.#entries.size;
|
||||
}
|
||||
|
||||
public get windowMs(): number {
|
||||
return this.#windowMs;
|
||||
}
|
||||
}
|
||||
85
packages/networking/drive/drive-mesh-protocol/src/index.ts
Normal file
85
packages/networking/drive/drive-mesh-protocol/src/index.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// Side-effect import ensures DMP_CODES is registered for subpath consumers
|
||||
// that import only from a feature path (and so don't otherwise pull in
|
||||
// the root barrel).
|
||||
import "./error-codes.js";
|
||||
|
||||
export {
|
||||
BITSET_HEADER_SIZE,
|
||||
BITSET_MAGIC,
|
||||
chunkIdsToRawBitset,
|
||||
type DecodedBitset,
|
||||
decodeAdvertisePayload,
|
||||
decodeBitset as decodeFramedBitset,
|
||||
decodeErrorPayload,
|
||||
decodeRequestPayload,
|
||||
decodeResponsePayload,
|
||||
encodeAdvertisePayload,
|
||||
encodeBitset as encodeFramedBitset,
|
||||
encodeErrorPayload,
|
||||
encodeRequest,
|
||||
encodeRequestPayload,
|
||||
encodeResponsePayload,
|
||||
popcount,
|
||||
rawBitsetToChunkIds,
|
||||
} from "./codec/index.js";
|
||||
export { DMP_CODES, DmpError, type DmpErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
type AcceptResult,
|
||||
AdvertiseThrottle,
|
||||
GenerationTracker,
|
||||
type ThrottleResult,
|
||||
} from "./generation/index.js";
|
||||
export {
|
||||
ADVERTISE_THROTTLE_MS_DEFAULT,
|
||||
ADVERTISE_THROTTLE_MS_MAX,
|
||||
ChunkAdvertisePayloadSchema,
|
||||
ChunkBytesSchema,
|
||||
ChunkErrorCodeSchema,
|
||||
ChunkErrorPayloadSchema,
|
||||
ChunkRequestPayloadSchema,
|
||||
ChunkRequestPrioritySchema,
|
||||
ChunkRequestSchema,
|
||||
ChunkResponsePayloadSchema,
|
||||
FileIdSchema,
|
||||
GENERATION_WRAP_THRESHOLD,
|
||||
MAX_CHUNKS_DEFAULT,
|
||||
MAX_CHUNKS_HARD_CAP,
|
||||
SAMPLE_RATE_HEALTHY,
|
||||
SAMPLE_RATE_SUSPECT,
|
||||
SELECT_FORM_BITSET_THRESHOLD,
|
||||
TRUST_DECAY_SLOPE,
|
||||
TRUST_MISS_RATE_FLOOR,
|
||||
TRUST_SUSPECT_THRESHOLD,
|
||||
} from "./schemas/index.js";
|
||||
export {
|
||||
decodeBitsetToChunkIds,
|
||||
encodeBitsetFromChunkIds,
|
||||
type SelectFormOptions,
|
||||
selectForm,
|
||||
selectWhole,
|
||||
} from "./select/index.js";
|
||||
export {
|
||||
classify,
|
||||
probabilityOfSkip,
|
||||
sampleRate,
|
||||
shouldSample,
|
||||
shouldSkip,
|
||||
type TrustEntry,
|
||||
TrustMap,
|
||||
type TrustStatus,
|
||||
} from "./trust/index.js";
|
||||
export type {
|
||||
ChunkAdvertiseEnvelope,
|
||||
ChunkAdvertisePayload,
|
||||
ChunkBytes,
|
||||
ChunkErrorCode,
|
||||
ChunkErrorEnvelope,
|
||||
ChunkErrorPayload,
|
||||
ChunkRequest,
|
||||
ChunkRequestEnvelope,
|
||||
ChunkRequestPayload,
|
||||
ChunkRequestPriority,
|
||||
ChunkResponseEnvelope,
|
||||
ChunkResponsePayload,
|
||||
TrustScore,
|
||||
} from "./types/protocol.types.js";
|
||||
@@ -0,0 +1,61 @@
|
||||
# envelope.schema
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines every Zod boundary schema the codec modules invoke before letting payload bytes touch protocol logic, plus the frozen numeric constants the spec pins (`MAX_CHUNKS_DEFAULT`, `MAX_CHUNKS_HARD_CAP`, `SAMPLE_RATE_HEALTHY`, `SAMPLE_RATE_SUSPECT`, `TRUST_SUSPECT_THRESHOLD`, `TRUST_MISS_RATE_FLOOR`, `TRUST_DECAY_SLOPE`, `ADVERTISE_THROTTLE_MS_DEFAULT`, `ADVERTISE_THROTTLE_MS_MAX`, `GENERATION_WRAP_THRESHOLD`, `SELECT_FORM_BITSET_THRESHOLD`). Centralising the schemas + constants keeps every codec / trust / generation module branchless against ad-hoc magic numbers.
|
||||
|
||||
## Features
|
||||
|
||||
- `FileIdSchema` — hex string, 1–128 chars (`[0-9a-f]+`).
|
||||
- `ChunkRequestSchema` — discriminated union (`whole` / `specific` / `bitset`).
|
||||
- `ChunkRequestPayloadSchema`, `ChunkAdvertisePayloadSchema`, `ChunkResponsePayloadSchema`, `ChunkErrorPayloadSchema`, `ChunkBytesSchema`, `ChunkRequestPrioritySchema`, `ChunkErrorCodeSchema`.
|
||||
- Numeric constants documented inline so monitoring dashboards can reference the same source-of-truth as the codec.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ChunkRequestPayloadSchema,
|
||||
MAX_CHUNKS_HARD_CAP,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const result = ChunkRequestPayloadSchema.safeParse(unknownInput);
|
||||
if (!result.success) throw new Error(result.error.message);
|
||||
const payload = result.data;
|
||||
console.log(MAX_CHUNKS_HARD_CAP); // 1024
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ChunkAdvertisePayloadSchema,
|
||||
ChunkErrorCodeSchema,
|
||||
SELECT_FORM_BITSET_THRESHOLD,
|
||||
TRUST_SUSPECT_THRESHOLD,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
function isSuspectScore(score: number): boolean {
|
||||
return score < TRUST_SUSPECT_THRESHOLD;
|
||||
}
|
||||
|
||||
function pickForm(needed: number[]) {
|
||||
return needed.length <= SELECT_FORM_BITSET_THRESHOLD
|
||||
? "specific"
|
||||
: "bitset";
|
||||
}
|
||||
|
||||
const allErrorCodes = ChunkErrorCodeSchema.options;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Schemas: `FileIdSchema`, `ChunkRequestPrioritySchema`, `ChunkRequestSchema`, `ChunkRequestPayloadSchema`, `ChunkAdvertisePayloadSchema`, `ChunkBytesSchema`, `ChunkResponsePayloadSchema`, `ChunkErrorCodeSchema`, `ChunkErrorPayloadSchema`.
|
||||
- Numeric constants: `MAX_CHUNKS_DEFAULT` (64), `MAX_CHUNKS_HARD_CAP` (1024), `SELECT_FORM_BITSET_THRESHOLD` (64), `SAMPLE_RATE_HEALTHY` (100), `SAMPLE_RATE_SUSPECT` (10), `TRUST_SUSPECT_THRESHOLD` (0.95), `TRUST_MISS_RATE_FLOOR` (0.05), `TRUST_DECAY_SLOPE` (20), `ADVERTISE_THROTTLE_MS_DEFAULT` (1000), `ADVERTISE_THROTTLE_MS_MAX` (60000), `GENERATION_WRAP_THRESHOLD` (2³¹).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `chunkIds` array on `kind:'specific'` is intentionally permissive (no `.min(1)`) so the explicit DMP-03 / DMP-22 gates in the codec (which throw `DMP.EMPTY_REQUEST`) are reachable. The `maxChunks` check is lower-bound only at the schema layer; the upper-bound DMP-07 enforcement lives in the codec so the failure carries the spec-mandated `DMP.MAX_CHUNKS_EXCEEDED` code rather than a generic `DMP.ENVELOPE_MALFORMED`. `// schema: boundary-only` annotations on the priority enum suppress the conformance-script warning about consumer-facing schemas that aren't reflected internally.
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Zod boundary schemas for every wire-format payload exchanged by
|
||||
* `@suaveplan/drive-mesh-protocol`. The codec modules invoke
|
||||
* `safeParse` on the decoded JSON before any logic touches the
|
||||
* payload, satisfying DMP-21's "schema validation before payload
|
||||
* processing" mandate.
|
||||
*/
|
||||
|
||||
import * as z from "zod";
|
||||
|
||||
/** Hex-encoded 32-byte file id; matches the format produced by drive-plugin-chunks. */
|
||||
export const FileIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(/^[0-9a-f]+$/i, "fileId must be lowercase hex");
|
||||
|
||||
/** Five-priority tier (DMP-06). */
|
||||
export const ChunkRequestPrioritySchema = z.enum([
|
||||
"critical",
|
||||
"high",
|
||||
"normal",
|
||||
"low",
|
||||
"bulk",
|
||||
]);
|
||||
|
||||
/** Discriminated union for the three request forms (DMP-01). */
|
||||
export const ChunkRequestSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("whole") }),
|
||||
z.object({
|
||||
kind: z.literal("specific"),
|
||||
// Empty arrays are caught by the explicit DMP-03 / DMP-22 gates in
|
||||
// the codec; keeping the schema permissive lets those throws fire.
|
||||
chunkIds: z.array(z.number().int().nonnegative()),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("bitset"),
|
||||
bytes: z.instanceof(Uint8Array),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const ChunkRequestPayloadSchema = z
|
||||
.object({
|
||||
fileId: FileIdSchema,
|
||||
request: ChunkRequestSchema,
|
||||
priority: ChunkRequestPrioritySchema,
|
||||
// The hard cap is enforced by the codec with `MAX_CHUNKS_EXCEEDED`
|
||||
// so consumers see the spec-mandated DMP-07 code; Zod only checks
|
||||
// that maxChunks is a positive integer.
|
||||
maxChunks: z.number().int().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Hard cap for `maxChunks` per DMP-07. */
|
||||
export const MAX_CHUNKS_HARD_CAP = 1024 as const;
|
||||
|
||||
/** Default `maxChunks` per DMP-07. */
|
||||
export const MAX_CHUNKS_DEFAULT = 64 as const;
|
||||
|
||||
/** Threshold above which a `bitset` request is more compact than `specific` (DMP-05). */
|
||||
export const SELECT_FORM_BITSET_THRESHOLD = 64 as const;
|
||||
|
||||
/** Default 1-in-100 sampling for healthy peers (DMP-17). */
|
||||
export const SAMPLE_RATE_HEALTHY = 100 as const;
|
||||
|
||||
/** Default 1-in-10 sampling for suspect peers (DMP-17). */
|
||||
export const SAMPLE_RATE_SUSPECT = 10 as const;
|
||||
|
||||
/** Trust threshold below which a peer is "suspect" (DMP-17). */
|
||||
export const TRUST_SUSPECT_THRESHOLD = 0.95 as const;
|
||||
|
||||
/** Miss-rate floor below which trust decay is dormant (DMP-15). */
|
||||
export const TRUST_MISS_RATE_FLOOR = 0.05 as const;
|
||||
|
||||
/** Multiplier on (missRate - floor) to compute P(skip) (DMP-15). */
|
||||
export const TRUST_DECAY_SLOPE = 20 as const;
|
||||
|
||||
/** Default throttle window between outgoing advertisements (DMP-10). */
|
||||
export const ADVERTISE_THROTTLE_MS_DEFAULT = 1_000 as const;
|
||||
|
||||
/** Hard upper bound on the throttle window. */
|
||||
export const ADVERTISE_THROTTLE_MS_MAX = 60_000 as const;
|
||||
|
||||
/** Generation wrap detection threshold (DMP-09). */
|
||||
export const GENERATION_WRAP_THRESHOLD = 2 ** 31;
|
||||
|
||||
export const ChunkAdvertisePayloadSchema = z
|
||||
.object({
|
||||
fileId: FileIdSchema,
|
||||
presence: z.instanceof(Uint8Array),
|
||||
totalChunks: z.number().int().min(1),
|
||||
generation: z.number().int().min(0).max(0xff_ff_ff_ff),
|
||||
timestamp: z.number().int().min(0),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const ChunkBytesSchema = z
|
||||
.object({
|
||||
chunkId: z.number().int().nonnegative(),
|
||||
bytes: z.instanceof(Uint8Array),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const ChunkResponsePayloadSchema = z
|
||||
.object({
|
||||
fileId: FileIdSchema,
|
||||
chunks: z.array(ChunkBytesSchema),
|
||||
presence: z.instanceof(Uint8Array),
|
||||
generation: z.number().int().min(0).max(0xff_ff_ff_ff),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const ChunkErrorCodeSchema = z.enum([
|
||||
"NOT_FOUND",
|
||||
"ACCESS_DENIED",
|
||||
"BANDWIDTH_EXCEEDED",
|
||||
"GENERATION_STALE",
|
||||
"INTERNAL",
|
||||
]);
|
||||
|
||||
export const ChunkErrorPayloadSchema = z
|
||||
.object({
|
||||
fileId: FileIdSchema,
|
||||
correlationId: z.string().min(1),
|
||||
code: ChunkErrorCodeSchema,
|
||||
chunkId: z.number().int().nonnegative().optional(),
|
||||
retryAfterMs: z.number().int().nonnegative().optional(),
|
||||
responderGeneration: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(0xff_ff_ff_ff)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,22 @@
|
||||
export {
|
||||
ADVERTISE_THROTTLE_MS_DEFAULT,
|
||||
ADVERTISE_THROTTLE_MS_MAX,
|
||||
ChunkAdvertisePayloadSchema,
|
||||
ChunkBytesSchema,
|
||||
ChunkErrorCodeSchema,
|
||||
ChunkErrorPayloadSchema,
|
||||
ChunkRequestPayloadSchema,
|
||||
ChunkRequestPrioritySchema,
|
||||
ChunkRequestSchema,
|
||||
ChunkResponsePayloadSchema,
|
||||
FileIdSchema,
|
||||
GENERATION_WRAP_THRESHOLD,
|
||||
MAX_CHUNKS_DEFAULT,
|
||||
MAX_CHUNKS_HARD_CAP,
|
||||
SAMPLE_RATE_HEALTHY,
|
||||
SAMPLE_RATE_SUSPECT,
|
||||
SELECT_FORM_BITSET_THRESHOLD,
|
||||
TRUST_DECAY_SLOPE,
|
||||
TRUST_MISS_RATE_FLOOR,
|
||||
TRUST_SUSPECT_THRESHOLD,
|
||||
} from "./envelope.schema.js";
|
||||
103
packages/networking/drive/drive-mesh-protocol/src/select/form.md
Normal file
103
packages/networking/drive/drive-mesh-protocol/src/select/form.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# selectForm
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The form-selection helpers that pick the most compact `ChunkRequest` form for a given set of needed chunks. The mesh protocol defines three forms (`whole` / `specific` / `bitset`); `selectForm()` picks `specific` when the count is small and `bitset` when the count is large. The threshold (`SELECT_FORM_BITSET_THRESHOLD = 64`) matches the empirical bytes-on-wire break-even where a packed bitset becomes smaller than an array of 32-bit chunk ids. The module also ships `selectWhole()` for the unambiguous "request everything" case and `encodeBitset()` / `decodeBitset()` helpers for the bitset payload.
|
||||
|
||||
## Features
|
||||
|
||||
- Auto-picks the optimal form based on chunk count.
|
||||
- Rejects ambiguous empty-array requests with a `DMP.EMPTY_REQUEST` error so callers cannot accidentally express "nothing" as "specific with empty array."
|
||||
- Bitset encoding is zero-deps — no bitpacker dependency in this module — so the function is usable from environments that haven't loaded `@suaveplan/bitpacker-compressed` yet.
|
||||
- Final-byte padding bits are guaranteed zero per DMP-08.
|
||||
- Roundtrip property: `decodeBitset(encodeBitset(needed)) === needed.toSorted((a, b) => a - b)` for any non-empty list of non-negative integers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { selectForm } from "@suaveplan/drive-mesh-protocol/select";
|
||||
|
||||
const small = selectForm([0, 5, 17]);
|
||||
// → { kind: 'specific', chunkIds: [0, 5, 17] }
|
||||
|
||||
const large = selectForm(Array.from({ length: 100 }, (_, i) => i));
|
||||
// → { kind: 'bitset', bytes: <13-byte bitset> }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeBitset,
|
||||
encodeBitset,
|
||||
selectForm,
|
||||
selectWhole,
|
||||
SELECT_FORM_BITSET_THRESHOLD,
|
||||
} from "@suaveplan/drive-mesh-protocol/select";
|
||||
|
||||
function describePlan(needed: readonly number[]) {
|
||||
if (needed.length === 0) return selectWhole();
|
||||
return selectForm(needed);
|
||||
}
|
||||
|
||||
// Adaptive request: ask for everything on the first request, then
|
||||
// switch to selectForm() once we have a partial inventory.
|
||||
const firstRequest = describePlan([]);
|
||||
// → { kind: 'whole' }
|
||||
|
||||
const stillMissing = [200, 201, 202, 203, 204, 205];
|
||||
const followUp = describePlan(stillMissing);
|
||||
// → { kind: 'specific', chunkIds: [200, 201, 202, 203, 204, 205] }
|
||||
|
||||
// Roundtrip check
|
||||
const bitset = encodeBitset(stillMissing);
|
||||
const recovered = decodeBitset(bitset);
|
||||
console.log(recovered);
|
||||
// → [200, 201, 202, 203, 204, 205]
|
||||
|
||||
// Threshold introspection
|
||||
console.log(SELECT_FORM_BITSET_THRESHOLD); // 64
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `const SELECT_FORM_BITSET_THRESHOLD`
|
||||
|
||||
The literal `64`. `selectForm` returns `kind: 'specific'` when `needed.length <= 64` and `kind: 'bitset'` otherwise. Documented as a `const` so consumers can reference the threshold without hardcoding it.
|
||||
|
||||
### `function selectForm(needed): ChunkRequest`
|
||||
|
||||
- `needed: readonly number[]` — the chunk ids to request.
|
||||
- Returns `{ kind: 'specific', chunkIds }` when `needed.length <= SELECT_FORM_BITSET_THRESHOLD`.
|
||||
- Returns `{ kind: 'bitset', bytes }` otherwise.
|
||||
- Throws `DmpError(DMP.EMPTY_REQUEST)` when `needed.length === 0`.
|
||||
|
||||
### `function selectWhole(): ChunkRequest`
|
||||
|
||||
Returns the canonical `{ kind: 'whole' }` form. Use this for "request everything the holder has."
|
||||
|
||||
### `function encodeBitset(needed): Uint8Array`
|
||||
|
||||
- `needed: readonly number[]` — non-negative integers to set bits for.
|
||||
- Returns a `Uint8Array` of length `Math.ceil((max(needed) + 1) / 8)`.
|
||||
- Bit `i` is set iff `i ∈ needed`. Padding bits in the final byte are zero.
|
||||
- Throws `DmpError(DMP.BITSET_LENGTH_MISMATCH)` when any element is negative or non-integer.
|
||||
|
||||
### `function decodeBitset(bytes, bitLength?): number[]`
|
||||
|
||||
- `bytes: Uint8Array` — the packed bitset.
|
||||
- `bitLength?: number` — number of bits to consider. Defaults to `bytes.length * 8`.
|
||||
- Returns the sorted list of bit indexes that are set within `[0, bitLength)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 64-chunk threshold is empirically chosen and documented in the design proposal ("Requirements with no source line"). At exactly 64 chunks, a 32-bit-per-id `specific` array is 256 bytes; a packed bitset covering chunks 0–63 is 8 bytes raw, ~6 bytes after `bitpacker-compressed` RLE. The cutover correctly favors `specific` when the target chunks are sparse (fewer than 64 of them, anywhere in the file) and `bitset` when they are dense (more than 64 of them).
|
||||
|
||||
The bitset format is little-endian within each byte: bit 0 of `bytes[0]` is chunk 0, bit 7 of `bytes[0]` is chunk 7, bit 0 of `bytes[1]` is chunk 8. This matches the most natural reading order for log dumps and is the de facto standard among suaveplan bit-packing helpers.
|
||||
|
||||
`encodeBitset` does NOT compress via `bitpacker-compressed` — the on-wire envelope encoding adds compression at the framing layer (`@suaveplan/bitpacker-network`). Encoding here keeps the function dependency-light so it is usable from contexts that haven't loaded the framing codec yet.
|
||||
|
||||
`decodeBitset` accepts an optional `bitLength` parameter so the caller can express "decode only the first N bits" — useful when the wire format declares `totalChunks` separately and padding bits past it must be ignored. When `bitLength` is omitted, every bit in the buffer is decoded.
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Auto-selection helper that picks the most compact `ChunkRequest`
|
||||
* form for a list of needed chunks (DMP-05).
|
||||
*
|
||||
* - `[]` is rejected — `kind:'specific'` with empty `chunkIds` is
|
||||
* ambiguous with `kind:'whole'`. Callers wanting "everything" must
|
||||
* use `selectWhole()`.
|
||||
* - `≤ threshold` returns `kind:'specific'`.
|
||||
* - `> threshold` returns `kind:'bitset'` whose `bytes` field carries a
|
||||
* `bitpacker-compressed`-framed payload sized to `max(needed) + 1`
|
||||
* bits.
|
||||
*
|
||||
* The threshold defaults to `SELECT_FORM_BITSET_THRESHOLD` (64) per
|
||||
* the proposal but can be overridden on a per-call basis.
|
||||
*/
|
||||
|
||||
import {
|
||||
chunkIdsToRawBitset,
|
||||
decodeBitset as decodeFramed,
|
||||
rawBitsetToChunkIds,
|
||||
encodeBitset as wrapBitset,
|
||||
} from "../codec/bitset.js";
|
||||
import { DMP_CODES, DmpError } from "../error-codes.js";
|
||||
import { SELECT_FORM_BITSET_THRESHOLD } from "../schemas/envelope.schema.js";
|
||||
import type { ChunkRequest } from "../types/protocol.types.js";
|
||||
|
||||
export { SELECT_FORM_BITSET_THRESHOLD };
|
||||
|
||||
export interface SelectFormOptions {
|
||||
/** Override the default 64-chunk threshold. */
|
||||
readonly threshold?: number;
|
||||
/**
|
||||
* Override the bitset's bit-length. Defaults to `max(needed) + 1`.
|
||||
* Useful when the caller already knows `totalChunks` and wants the
|
||||
* bitset to span the whole file.
|
||||
*/
|
||||
readonly bitLength?: number;
|
||||
}
|
||||
|
||||
export function selectForm(
|
||||
needed: readonly number[],
|
||||
options: SelectFormOptions = {}
|
||||
): ChunkRequest {
|
||||
if (needed.length === 0) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.EMPTY_REQUEST,
|
||||
"selectForm requires non-empty chunk list; use selectWhole() for kind:'whole'"
|
||||
);
|
||||
}
|
||||
const threshold = options.threshold ?? SELECT_FORM_BITSET_THRESHOLD;
|
||||
if (needed.length <= threshold) {
|
||||
return {
|
||||
kind: "specific",
|
||||
chunkIds: [...needed].sort((a, b) => a - b),
|
||||
};
|
||||
}
|
||||
let max = -1;
|
||||
for (const id of needed) {
|
||||
if (id > max) max = id;
|
||||
}
|
||||
const bitLength = options.bitLength ?? max + 1;
|
||||
const raw = chunkIdsToRawBitset(needed, bitLength);
|
||||
const bytes = wrapBitset(raw, bitLength);
|
||||
return { kind: "bitset", bytes };
|
||||
}
|
||||
|
||||
export function selectWhole(): ChunkRequest {
|
||||
return { kind: "whole" };
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level "build a framed bitset from chunk ids" helper.
|
||||
*
|
||||
* Takes a list of chunk ids and returns the framed `bitpacker-compressed`
|
||||
* bytes ready to be placed in a `kind:'bitset'` request payload. Provided
|
||||
* under a unique name (NOT `encodeBitset`) so it does not collide with
|
||||
* `codec/bitset.encodeBitset` (which takes raw bit-packed bytes).
|
||||
*/
|
||||
export function encodeBitsetFromChunkIds(
|
||||
needed: readonly number[],
|
||||
bitLength?: number
|
||||
): Uint8Array {
|
||||
let max = -1;
|
||||
for (const id of needed) {
|
||||
if (id > max) max = id;
|
||||
}
|
||||
const bits = bitLength ?? max + 1;
|
||||
const raw = chunkIdsToRawBitset(needed, bits);
|
||||
return wrapBitset(raw, bits);
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level "decode a framed bitset back to chunk ids" helper.
|
||||
*/
|
||||
export function decodeBitsetToChunkIds(framed: Uint8Array): number[] {
|
||||
const decoded = decodeFramed(framed);
|
||||
return rawBitsetToChunkIds(decoded.raw, decoded.bitLength);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
decodeBitsetToChunkIds,
|
||||
encodeBitsetFromChunkIds,
|
||||
SELECT_FORM_BITSET_THRESHOLD,
|
||||
type SelectFormOptions,
|
||||
selectForm,
|
||||
selectWhole,
|
||||
} from "./form.js";
|
||||
@@ -0,0 +1,53 @@
|
||||
# decay
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the probabilistic-skip decay logic (DMP-15) the request scheduler uses to decide whether to include a peer in the candidate set on each new chunk request. When a peer's miss rate (`missing / advertised`) exceeds the floor (`0.05`), the probability of skipping the peer on each subsequent request is `(missRate - 0.05) * 20`, capped at `1.0`. The decay encodes the empirical observation that peers who advertise chunks they don't actually have should be progressively de-prioritised without being permanently blocked.
|
||||
|
||||
## Features
|
||||
|
||||
- `probabilityOfSkip(entry)` — pure function over `TrustEntry`; returns `[0, 1]`.
|
||||
- `shouldSkip(entry, rng)` — deterministic decision given an injected `Rng`.
|
||||
- Honours terminated entries (`probability === 1`) so the request scheduler never forgets a bad-sig event.
|
||||
- Honours zero-advertised entries (`probability === 0`) so freshly-met peers are never punished.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { probabilityOfSkip, TrustMap } from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", "f", 100);
|
||||
t.recordMissing("peer", "f", 30);
|
||||
const p = probabilityOfSkip(t.get("peer", "f"));
|
||||
console.log(p); // 1 (capped at 1.0; 0.30 - 0.05 = 0.25; 0.25 * 20 = 5)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
shouldSkip,
|
||||
TrustMap,
|
||||
type TrustEntry,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
import { defaultRng } from "@suaveplan/bitpacker-network";
|
||||
|
||||
const trust = new TrustMap();
|
||||
const rng = defaultRng();
|
||||
|
||||
function selectCandidates(peers: string[], fileId: string): TrustEntry[] {
|
||||
return peers
|
||||
.map((peerId) => trust.get(peerId, fileId))
|
||||
.filter((entry) => !shouldSkip(entry, rng));
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `probabilityOfSkip(entry: TrustEntry): number` — returns `1` for terminated entries, `0` for `advertised === 0` or `missRate ≤ 0.05`, otherwise `(missRate - 0.05) * 20` clamped to `[0, 1]`.
|
||||
- `shouldSkip(entry: TrustEntry, rng: Rng): boolean` — short-circuits when probability is `0` or `1` to avoid burning RNG draws.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `Math.max(0, p)` clamp is defence-in-depth — the early-return on `missRate <= floor` already guarantees `p > 0` past that point — but JIT optimisers handle it cheaply and it documents the intent. The slope (`20`) is chosen so a peer at 10% miss rate is skipped on 100% of requests; below 10% the skip probability falls off linearly toward the floor at 5%. Tuning these numbers requires re-pinning `TRUST_MISS_RATE_FLOOR` / `TRUST_DECAY_SLOPE` in the schemas module — never hard-code them at call sites.
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Probabilistic-skip decay logic (DMP-15).
|
||||
*
|
||||
* For each `(peerId, fileId)`, when `popcount(missing) / popcount(advertised)
|
||||
* > 0.05`, future requests SHALL probabilistically skip this peer with
|
||||
*
|
||||
* P(skip) = (missRate - 0.05) * 20
|
||||
*
|
||||
* capped at 1.0. The rate floor (0.05) and slope (20) live in
|
||||
* `schemas/envelope.schema.ts` so consumers can pin them with
|
||||
* `expect(TRUST_MISS_RATE_FLOOR).toBe(0.05)`.
|
||||
*/
|
||||
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import {
|
||||
TRUST_DECAY_SLOPE,
|
||||
TRUST_MISS_RATE_FLOOR,
|
||||
} from "../schemas/envelope.schema.js";
|
||||
import type { TrustEntry } from "./score.js";
|
||||
|
||||
/** Compute the probability `[0, 1]` that a request should skip this peer. */
|
||||
export function probabilityOfSkip(entry: TrustEntry): number {
|
||||
if (entry.terminated) return 1;
|
||||
if (entry.advertised === 0) return 0;
|
||||
const missRate = entry.missing / entry.advertised;
|
||||
if (missRate <= TRUST_MISS_RATE_FLOOR) return 0;
|
||||
const p = (missRate - TRUST_MISS_RATE_FLOOR) * TRUST_DECAY_SLOPE;
|
||||
return Math.min(1, Math.max(0, p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to skip this peer on the current request, drawing
|
||||
* randomness from the supplied `Rng` so tests can inject a deterministic
|
||||
* generator.
|
||||
*/
|
||||
export function shouldSkip(entry: TrustEntry, rng: Rng): boolean {
|
||||
const p = probabilityOfSkip(entry);
|
||||
if (p === 0) return false;
|
||||
if (p >= 1) return true;
|
||||
return rng.nextFloat() < p;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { probabilityOfSkip, shouldSkip } from "./decay.js";
|
||||
export {
|
||||
classify,
|
||||
sampleRate,
|
||||
shouldSample,
|
||||
type TrustStatus,
|
||||
} from "./sample.js";
|
||||
export { type TrustEntry, TrustMap } from "./score.js";
|
||||
@@ -0,0 +1,68 @@
|
||||
# sample
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the adaptive sample-verification rate (DMP-17). On every incoming `cd:advertise`, the protocol MAY request a single chunk from the advertised set to verify the peer actually has it. The default rate is 1-in-100 for healthy peers and 1-in-10 for "suspect" peers (trust score `< 0.95` but `> -Infinity`). Sample-verification failure increments the `missing` counter via DMP-15.
|
||||
|
||||
## Features
|
||||
|
||||
- `classify(entry)` — three-way bucket (`terminated | suspect | healthy`).
|
||||
- `sampleRate(entry)` — denominator (`Infinity | 10 | 100`) — `Infinity` for terminated peers.
|
||||
- `shouldSample(entry, rng)` — deterministic decision given an injected `Rng`.
|
||||
- Constants pinned in `schemas/envelope.schema.ts` so dashboards and the spec share a source of truth.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
classify,
|
||||
sampleRate,
|
||||
TrustMap,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", "f", 100);
|
||||
t.recordMissing("peer", "f", 10);
|
||||
const e = t.get("peer", "f");
|
||||
console.log(classify(e)); // "suspect"
|
||||
console.log(sampleRate(e)); // 10
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
classify,
|
||||
shouldSample,
|
||||
TrustMap,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
import { defaultRng } from "@suaveplan/bitpacker-network";
|
||||
|
||||
const trust = new TrustMap();
|
||||
const rng = defaultRng();
|
||||
|
||||
async function onIncomingAdvertise(
|
||||
peerId: string,
|
||||
fileId: string,
|
||||
advertisedIds: number[]
|
||||
): Promise<void> {
|
||||
const e = trust.get(peerId, fileId);
|
||||
if (classify(e) === "terminated") return;
|
||||
if (shouldSample(e, rng)) {
|
||||
const chunkId = advertisedIds[Math.floor(rng.nextFloat() * advertisedIds.length)];
|
||||
const ok = await verifyAdvertisedChunk(peerId, fileId, chunkId);
|
||||
if (!ok) trust.recordMissing(peerId, fileId, 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type TrustStatus = "terminated" | "suspect" | "healthy"`.
|
||||
- `classify(entry: TrustEntry): TrustStatus`.
|
||||
- `sampleRate(entry: TrustEntry): number` — denominator; `Infinity` for terminated.
|
||||
- `shouldSample(entry: TrustEntry, rng: Rng): boolean`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Terminated peers are never sampled because the request scheduler shouldn't be talking to them in the first place; the function returns `false` early without consuming an RNG draw. The `1 / denom` probability uses 32-bit float division which has more than enough precision for a 1-in-100 / 1-in-10 decision; switching to integer division would buy nothing and force callers to track the denominator separately. Adjusting the suspect threshold or the per-status rate requires re-pinning the constants in `schemas/envelope.schema.ts` — the spec file MUST move in lockstep.
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Adaptive sample-verification rate (DMP-17).
|
||||
*
|
||||
* On incoming `cd:advertise`, the protocol MAY request a single chunk
|
||||
* from the advertised set to verify the peer actually has it. The
|
||||
* default sampling rate is 1-in-100 for healthy peers and 1-in-10 for
|
||||
* peers in "suspect" status (trust score `< 0.95` but `> -Infinity`).
|
||||
* Sample-verification failure increments `missing` per DMP-15.
|
||||
*/
|
||||
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import {
|
||||
SAMPLE_RATE_HEALTHY,
|
||||
SAMPLE_RATE_SUSPECT,
|
||||
TRUST_SUSPECT_THRESHOLD,
|
||||
} from "../schemas/envelope.schema.js";
|
||||
import type { TrustEntry } from "./score.js";
|
||||
|
||||
/** Status bucket the sampler uses to pick a rate. */
|
||||
export type TrustStatus = "terminated" | "suspect" | "healthy";
|
||||
|
||||
/** Classify a trust entry into the three sampler buckets. */
|
||||
export function classify(entry: TrustEntry): TrustStatus {
|
||||
if (entry.terminated) return "terminated";
|
||||
if (entry.score < TRUST_SUSPECT_THRESHOLD) return "suspect";
|
||||
return "healthy";
|
||||
}
|
||||
|
||||
/** Sampling denominator for an entry (Infinity for terminated). */
|
||||
export function sampleRate(entry: TrustEntry): number {
|
||||
const status = classify(entry);
|
||||
if (status === "terminated") return Number.POSITIVE_INFINITY;
|
||||
if (status === "suspect") return SAMPLE_RATE_SUSPECT;
|
||||
return SAMPLE_RATE_HEALTHY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to verify this advertisement by issuing a sample
|
||||
* request. Probability is `1 / sampleRate`. Terminated peers are never
|
||||
* sampled (the request scheduler should not be talking to them anyway).
|
||||
*/
|
||||
export function shouldSample(entry: TrustEntry, rng: Rng): boolean {
|
||||
const denom = sampleRate(entry);
|
||||
if (denom === Number.POSITIVE_INFINITY) return false;
|
||||
return rng.nextFloat() < 1 / denom;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# score
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `TrustMap`, the per-`(peerId, fileId)` trust state container the request scheduler in `drive-mesh` uses to decide which peers to talk to (DMP-13, DMP-14, DMP-16). Each entry tracks how many chunks a peer claimed to have via gossip vs. how many it failed to deliver on request, plus a terminal `terminated: true` flag set after a bad-signature event.
|
||||
|
||||
## Features
|
||||
|
||||
- `TrustMap` — mutable, in-process map of `TrustEntry` records keyed by `(peerId, fileId)`.
|
||||
- `recordAdvertised(peerId, fileId, delta)` — increments the advertised counter; recomputes the score.
|
||||
- `recordMissing(peerId, fileId, delta)` — increments the missing counter; recomputes the score.
|
||||
- `terminate(peerId, fileId)` — pins the score to `-Infinity` after a bad-sig event (DMP-14).
|
||||
- `canServe(peerId, fileId)` — query helper for the request scheduler.
|
||||
- `dropPeer(peerId)` / `dropFile(fileId)` — cleanup hooks for the registry's `peer:left` / file-removal events.
|
||||
- Iterable via `Symbol.iterator` for snapshot/persistence.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TrustMap } from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const trust = new TrustMap();
|
||||
trust.recordAdvertised("peer-A", "fileId", 100);
|
||||
trust.recordMissing("peer-A", "fileId", 5);
|
||||
console.log(trust.get("peer-A", "fileId").score); // 0.95
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
classify,
|
||||
sampleRate,
|
||||
shouldSkip,
|
||||
TrustMap,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const trust = new TrustMap();
|
||||
|
||||
function pickPeers(candidates: string[], fileId: string, rng): string[] {
|
||||
return candidates.filter((peerId) => {
|
||||
const e = trust.get(peerId, fileId);
|
||||
if (!trust.canServe(peerId, fileId)) return false;
|
||||
return !shouldSkip(e, rng);
|
||||
});
|
||||
}
|
||||
|
||||
function onBadSignature(peerId: string, fileId: string): void {
|
||||
trust.terminate(peerId, fileId);
|
||||
}
|
||||
|
||||
function onPeerDisconnect(peerId: string): void {
|
||||
trust.dropPeer(peerId);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class TrustMap` — `get(peerId, fileId)`, `recordAdvertised(peerId, fileId, delta)`, `recordMissing(peerId, fileId, delta)`, `terminate(peerId, fileId)`, `canServe(peerId, fileId)`, `dropPeer(peerId)`, `dropFile(fileId)`, `size`, `[Symbol.iterator]()`.
|
||||
- `interface TrustEntry` — `{ peerId, fileId, advertised, missing, score, terminated }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`recordAdvertised` and `recordMissing` are no-ops on terminated entries to prevent a bad-sig peer from regaining trust by spamming further advertisements; the score stays at `-Infinity` regardless of subsequent counters. The score formula is `1 - missing/advertised` with `score = 1` when `advertised === 0` so a freshly-met peer starts trusted by default — terminated entries override this. The iterator yields `TrustEntry` records by reference; consumers must NOT mutate them outside the public methods.
|
||||
144
packages/networking/drive/drive-mesh-protocol/src/trust/score.ts
Normal file
144
packages/networking/drive/drive-mesh-protocol/src/trust/score.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Per-`(peerId, fileId)` trust map (DMP-13, DMP-14, DMP-16).
|
||||
*
|
||||
* Tracks two counters per `(peerId, fileId)` tuple:
|
||||
*
|
||||
* - `advertised` — how many chunks the peer has claimed to hold via
|
||||
* `cd:advertise` (incremented by popcount of newly-set bits).
|
||||
* - `missing` — how many of those advertised chunks the peer failed to
|
||||
* deliver within the request timeout (incremented by the
|
||||
* request-scheduler when a timeout fires).
|
||||
*
|
||||
* The score is `1 - (missing / max(1, advertised))`, except after a
|
||||
* bad-signature event the score is pinned to `-Infinity` (DMP-14) and
|
||||
* subsequent requests skip this peer for `fileId`. Trust state for
|
||||
* unrelated `fileId` entries on the same peer is unaffected.
|
||||
*/
|
||||
|
||||
import type { PeerId } from "@suaveplan/types";
|
||||
|
||||
/** Per-`(peerId, fileId)` trust state. */
|
||||
export interface TrustEntry {
|
||||
readonly peerId: PeerId;
|
||||
readonly fileId: string;
|
||||
advertised: number;
|
||||
missing: number;
|
||||
score: number;
|
||||
/** True after a `bad signature` event has terminated this entry. */
|
||||
terminated: boolean;
|
||||
}
|
||||
|
||||
const SCORE_TERMINATED = Number.NEGATIVE_INFINITY;
|
||||
|
||||
function key(peerId: PeerId, fileId: string): string {
|
||||
return `${peerId}:${fileId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutable map of trust state. Designed for the request scheduler in
|
||||
* `drive-mesh`, which runs on a single thread per peer connection.
|
||||
*/
|
||||
export class TrustMap {
|
||||
readonly #entries = new Map<string, TrustEntry>();
|
||||
|
||||
/** Get the trust entry for a `(peerId, fileId)` tuple, creating a default if absent. */
|
||||
public get(peerId: PeerId, fileId: string): TrustEntry {
|
||||
const k = key(peerId, fileId);
|
||||
let e = this.#entries.get(k);
|
||||
if (!e) {
|
||||
e = {
|
||||
peerId,
|
||||
fileId,
|
||||
advertised: 0,
|
||||
missing: 0,
|
||||
score: 1,
|
||||
terminated: false,
|
||||
};
|
||||
this.#entries.set(k, e);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
/** Increment the `advertised` count by `delta`. */
|
||||
public recordAdvertised(
|
||||
peerId: PeerId,
|
||||
fileId: string,
|
||||
delta: number
|
||||
): TrustEntry {
|
||||
if (delta < 0 || !Number.isInteger(delta)) {
|
||||
throw new Error("advertised delta must be a non-negative integer");
|
||||
}
|
||||
const e = this.get(peerId, fileId);
|
||||
if (e.terminated) return e;
|
||||
e.advertised += delta;
|
||||
e.score = computeScore(e.advertised, e.missing);
|
||||
return e;
|
||||
}
|
||||
|
||||
/** Increment the `missing` count by `delta`. */
|
||||
public recordMissing(
|
||||
peerId: PeerId,
|
||||
fileId: string,
|
||||
delta: number
|
||||
): TrustEntry {
|
||||
if (delta < 0 || !Number.isInteger(delta)) {
|
||||
throw new Error("missing delta must be a non-negative integer");
|
||||
}
|
||||
const e = this.get(peerId, fileId);
|
||||
if (e.terminated) return e;
|
||||
e.missing += delta;
|
||||
e.score = computeScore(e.advertised, e.missing);
|
||||
return e;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate trust for `(peerId, fileId)` after a bad-signature event
|
||||
* (DMP-14). Subsequent requests for this `fileId` SHALL NOT include
|
||||
* this peer in the candidate set. Other files are unaffected.
|
||||
*/
|
||||
public terminate(peerId: PeerId, fileId: string): TrustEntry {
|
||||
const e = this.get(peerId, fileId);
|
||||
e.terminated = true;
|
||||
e.score = SCORE_TERMINATED;
|
||||
return e;
|
||||
}
|
||||
|
||||
/** Clean up entries for a disconnected peer (DMP-16). */
|
||||
public dropPeer(peerId: PeerId): void {
|
||||
for (const k of [...this.#entries.keys()]) {
|
||||
if (k.startsWith(`${peerId}:`)) {
|
||||
this.#entries.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Clean up entries for a removed file (DMP-16). */
|
||||
public dropFile(fileId: string): void {
|
||||
for (const [k, e] of this.#entries) {
|
||||
if (e.fileId === fileId) {
|
||||
this.#entries.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the peer is permitted to serve `fileId`. */
|
||||
public canServe(peerId: PeerId, fileId: string): boolean {
|
||||
const k = key(peerId, fileId);
|
||||
const e = this.#entries.get(k);
|
||||
return !e?.terminated;
|
||||
}
|
||||
|
||||
/** Number of entries, for tests. */
|
||||
public get size(): number {
|
||||
return this.#entries.size;
|
||||
}
|
||||
|
||||
public [Symbol.iterator](): IterableIterator<TrustEntry> {
|
||||
return this.#entries.values();
|
||||
}
|
||||
}
|
||||
|
||||
function computeScore(advertised: number, missing: number): number {
|
||||
if (advertised === 0) return 1;
|
||||
return 1 - missing / advertised;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type {
|
||||
ChunkAdvertiseEnvelope,
|
||||
ChunkAdvertisePayload,
|
||||
ChunkBytes,
|
||||
ChunkErrorCode,
|
||||
ChunkErrorEnvelope,
|
||||
ChunkErrorPayload,
|
||||
ChunkRequest,
|
||||
ChunkRequestEnvelope,
|
||||
ChunkRequestPayload,
|
||||
ChunkRequestPriority,
|
||||
ChunkResponseEnvelope,
|
||||
ChunkResponsePayload,
|
||||
TrustScore,
|
||||
} from "./protocol.types.js";
|
||||
@@ -0,0 +1,59 @@
|
||||
# protocol.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Holds the cross-module type vocabulary for the entire mesh protocol package. Every wire-format payload, request shape, response shape, error envelope, and trust-state record has its TypeScript counterpart pinned here so codec, trust, generation, and select modules share a single language for protocol data without circular imports between feature directories.
|
||||
|
||||
## Features
|
||||
|
||||
- `ChunkRequest` — discriminated union over `whole | specific | bitset` (DMP-01).
|
||||
- `ChunkRequestEnvelope`, `ChunkRequestPayload` — request wire shapes.
|
||||
- `ChunkAdvertiseEnvelope`, `ChunkAdvertisePayload` — gossip wire shapes.
|
||||
- `ChunkResponseEnvelope`, `ChunkResponsePayload`, `ChunkBytes` — response wire shapes including the piggyback presence (DMP-11).
|
||||
- `ChunkErrorEnvelope`, `ChunkErrorPayload`, `ChunkErrorCode` — error wire shapes.
|
||||
- `ChunkRequestPriority` — five-tier `critical | high | normal | low | bulk` (DMP-06).
|
||||
- `TrustScore` — per-`(peerId, fileId)` score record consumed by the trust subsystem.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ChunkRequest,
|
||||
ChunkRequestPayload,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
declare const req: ChunkRequest;
|
||||
declare const payload: ChunkRequestPayload;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ChunkErrorCode,
|
||||
ChunkErrorPayload,
|
||||
ChunkResponsePayload,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
function classifyResponse(
|
||||
r: ChunkResponsePayload | { error: ChunkErrorPayload }
|
||||
): "ok" | ChunkErrorCode {
|
||||
if ("error" in r) return r.error.code;
|
||||
return "ok";
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ChunkRequest` — `{ kind: "whole" } | { kind: "specific"; chunkIds: readonly number[] } | { kind: "bitset"; bytes: Uint8Array }`.
|
||||
- `ChunkRequestPayload` — `{ fileId, request, priority, maxChunks? }`.
|
||||
- `ChunkAdvertisePayload` — `{ fileId, presence, totalChunks, generation, timestamp }`.
|
||||
- `ChunkResponsePayload` — `{ fileId, chunks, presence, generation }`.
|
||||
- `ChunkBytes` — `{ chunkId, bytes }`.
|
||||
- `ChunkErrorPayload` — `{ fileId, correlationId, code, chunkId?, retryAfterMs?, responderGeneration? }`.
|
||||
- `ChunkErrorCode`, `ChunkRequestPriority` — string-literal unions.
|
||||
- `TrustScore` — `{ peerId, fileId, advertised, missing, score }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`ChunkBytes.bytes` is a `Uint8Array` carrying the locked v0x0001 chunk format from `@suaveplan/drive-plugin-chunks` (header + signature + ciphertext). The protocol package does NOT verify or decrypt those bytes — that's the consumer's responsibility (DMP-13). Generation values are widened to plain `number` rather than a branded `Generation` type because the wrap-around arithmetic in `generation/monotonic.ts` already needs to do unsigned u32 maths inline; a brand would force unsafe casts at every call site without adding any safety.
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Wire protocol type definitions for `@suaveplan/drive-mesh-protocol`.
|
||||
*
|
||||
* Mirrors the structural contract pinned in
|
||||
* `openspec/changes/drive-mesh-protocol-v1/specs/drive-mesh-protocol/spec.md`.
|
||||
* All types are `interface` or `type` — zero runtime — and are imported
|
||||
* by the codec modules and by downstream consumers (`@suaveplan/drive-mesh`,
|
||||
* `@suaveplan/network-plugin-content-delivery`).
|
||||
*/
|
||||
|
||||
import type { MessageEnvelope, PeerId } from "@suaveplan/types";
|
||||
|
||||
/** Five-priority tier matching network-plugin-protocol NET-25. */
|
||||
export type ChunkRequestPriority =
|
||||
| "critical"
|
||||
| "high"
|
||||
| "normal"
|
||||
| "low"
|
||||
| "bulk";
|
||||
|
||||
/** Three-form discriminated union for chunk requests (DMP-01). */
|
||||
export type ChunkRequest =
|
||||
| { readonly kind: "whole" }
|
||||
| { readonly kind: "specific"; readonly chunkIds: readonly number[] }
|
||||
| { readonly kind: "bitset"; readonly bytes: Uint8Array };
|
||||
|
||||
/** Decoded payload carried by a `cd:request` envelope. */
|
||||
export interface ChunkRequestPayload {
|
||||
/** 32-byte hex file identifier. */
|
||||
readonly fileId: string;
|
||||
/** Which chunks the requester wants. */
|
||||
readonly request: ChunkRequest;
|
||||
/** Priority tier; defaults to `"normal"`. */
|
||||
readonly priority: ChunkRequestPriority;
|
||||
/** Cap on response payload chunk count; defaults to 64. */
|
||||
readonly maxChunks?: number;
|
||||
}
|
||||
|
||||
/** `cd:request` envelope. */
|
||||
export type ChunkRequestEnvelope = MessageEnvelope<ChunkRequestPayload>;
|
||||
|
||||
/** Decoded payload carried by a `cd:advertise` envelope. */
|
||||
export interface ChunkAdvertisePayload {
|
||||
readonly fileId: string;
|
||||
/** Bitset, bit i = 1 iff peer has verified chunk i. */
|
||||
readonly presence: Uint8Array;
|
||||
/** Total chunks in the file (constant per file version). */
|
||||
readonly totalChunks: number;
|
||||
/** Monotonic per (peerId, fileId); receivers drop older. */
|
||||
readonly generation: number;
|
||||
/** Issuer's clock at emission, unix milliseconds. */
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
/** `cd:advertise` envelope. */
|
||||
export type ChunkAdvertiseEnvelope = MessageEnvelope<ChunkAdvertisePayload>;
|
||||
|
||||
/** A single chunk in a response, in v0x0001 locked format bytes. */
|
||||
export interface ChunkBytes {
|
||||
/** 0-indexed chunk number within the file. */
|
||||
readonly chunkId: number;
|
||||
/** Locked v0x0001 chunk bytes: metadata(100) ‖ sig(64) ‖ ciphertext. */
|
||||
readonly bytes: Uint8Array;
|
||||
}
|
||||
|
||||
/** Decoded payload carried by a `cd:response` envelope. */
|
||||
export interface ChunkResponsePayload {
|
||||
readonly fileId: string;
|
||||
/** Chunks ordered by chunkId ascending (DMP-12). */
|
||||
readonly chunks: readonly ChunkBytes[];
|
||||
/** Piggyback presence bitset for free advertisement (DMP-11). */
|
||||
readonly presence: Uint8Array;
|
||||
/** Responder's current generation for the file. */
|
||||
readonly generation: number;
|
||||
}
|
||||
|
||||
/** `cd:response` envelope. */
|
||||
export type ChunkResponseEnvelope = MessageEnvelope<ChunkResponsePayload>;
|
||||
|
||||
/** Wire-format error code names used in `cd:error` envelopes (DMP-18). */
|
||||
export type ChunkErrorCode =
|
||||
| "NOT_FOUND"
|
||||
| "ACCESS_DENIED"
|
||||
| "BANDWIDTH_EXCEEDED"
|
||||
| "GENERATION_STALE"
|
||||
| "INTERNAL";
|
||||
|
||||
/** Decoded payload carried by a `cd:error` envelope. */
|
||||
export interface ChunkErrorPayload {
|
||||
readonly fileId: string;
|
||||
/** Matches the originating request envelope id. */
|
||||
readonly correlationId: string;
|
||||
readonly code: ChunkErrorCode;
|
||||
/** Set when the error pertains to a specific chunk. */
|
||||
readonly chunkId?: number;
|
||||
/** Backpressure hint — milliseconds the requester should wait. */
|
||||
readonly retryAfterMs?: number;
|
||||
/** When code is `"GENERATION_STALE"`, the responder's current generation. */
|
||||
readonly responderGeneration?: number;
|
||||
}
|
||||
|
||||
/** `cd:error` envelope. */
|
||||
export type ChunkErrorEnvelope = MessageEnvelope<ChunkErrorPayload>;
|
||||
|
||||
/** Per-(peerId, fileId) trust state used by the request scheduler (DMP-14..17). */
|
||||
export interface TrustScore {
|
||||
readonly peerId: PeerId;
|
||||
readonly fileId: string;
|
||||
/** Count of chunks the peer claims to have via advertisements. */
|
||||
readonly advertised: number;
|
||||
/** Count of advertised chunks the peer failed to deliver on request. */
|
||||
readonly missing: number;
|
||||
/** Score: 1 - (missing / max(1, advertised)). `-Infinity` on bad sig. */
|
||||
readonly score: number;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* DMP-08 — Advertisement bitset length invariant.
|
||||
*
|
||||
* In `ChunkAdvertisePayload`, the bit-length of `presence` MUST equal
|
||||
* `totalChunks`; padding bits in the final byte MUST be zero. The codec
|
||||
* rejects mismatched bitset wires with either
|
||||
* `DMP.BITSET_LENGTH_MISMATCH` or `DMP.BITSET_PADDING_NONZERO`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
BITSET_MAGIC,
|
||||
chunkIdsToRawBitset,
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
decodeAdvertisePayload,
|
||||
encodeAdvertisePayload,
|
||||
encodeFramedBitset,
|
||||
} from "../src/index.js";
|
||||
|
||||
const FILE_ID = "b".repeat(64);
|
||||
|
||||
function makeAdvertise(presence: Uint8Array, totalChunks: number) {
|
||||
return encodeAdvertisePayload({
|
||||
fileId: FILE_ID,
|
||||
presence,
|
||||
totalChunks,
|
||||
generation: 1,
|
||||
timestamp: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function makeFramedBitset(ids: number[], bitLength: number) {
|
||||
return encodeFramedBitset(chunkIdsToRawBitset(ids, bitLength), bitLength);
|
||||
}
|
||||
|
||||
describe("DMP-08 — bitset length invariant", () => {
|
||||
it("advertisement with presence.length ≠ totalChunks rejected", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const presence = makeFramedBitset([0, 5], 16);
|
||||
try {
|
||||
makeAdvertise(presence, 32);
|
||||
throw new Error("expected mismatch rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("valid advertisement (bitLength == totalChunks) round-trips", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const presence = makeFramedBitset([0, 5, 7], 8);
|
||||
const wire = makeAdvertise(presence, 8);
|
||||
const decoded = decodeAdvertisePayload(wire);
|
||||
expect(decoded.totalChunks).toBe(8);
|
||||
expect(decoded.presence).toEqual(presence);
|
||||
});
|
||||
|
||||
it("non-zero padding bits rejected", { timeout: 5000 }, () => {
|
||||
// Hand-build a framed bitset with bitLength=10 (so high bits 10..15
|
||||
// must be zero) but flip bit 14 to violate padding zeroing.
|
||||
const raw = new Uint8Array([0x01, 0xff]);
|
||||
// Build the wire envelope by hand.
|
||||
const wire = new Uint8Array(12 + raw.length);
|
||||
wire.set(BITSET_MAGIC, 0);
|
||||
const view = new DataView(
|
||||
wire.buffer,
|
||||
wire.byteOffset,
|
||||
wire.byteLength
|
||||
);
|
||||
view.setUint32(4, 10, false);
|
||||
view.setUint32(8, raw.length, false);
|
||||
wire.set(raw, 12);
|
||||
try {
|
||||
makeAdvertise(wire, 10);
|
||||
throw new Error("expected padding-nonzero rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_PADDING_NONZERO
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* DMP-04 — `kind:'bitset'` payload via @suaveplan/bitpacker-compressed.
|
||||
*
|
||||
* Property-based round-trip across random bit densities, plus the
|
||||
* "1000-chunk file with 3 needed chunks fits in <40 bytes" example
|
||||
* from the spec.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import {
|
||||
BITSET_HEADER_SIZE,
|
||||
chunkIdsToRawBitset,
|
||||
decodeFramedBitset,
|
||||
encodeFramedBitset,
|
||||
rawBitsetToChunkIds,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("DMP-04 — bitset round-trip", () => {
|
||||
it("bitset roundtrip preserves bit positions for random densities", {
|
||||
timeout: 10000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc
|
||||
.array(fc.integer({ min: 0, max: 9999 }), {
|
||||
minLength: 1,
|
||||
maxLength: 200,
|
||||
})
|
||||
.map((arr) => Array.from(new Set(arr))),
|
||||
(input) => {
|
||||
const sorted = [...input].sort((a, b) => a - b);
|
||||
const max = sorted[sorted.length - 1] as number;
|
||||
const bitLength = max + 1;
|
||||
const raw = chunkIdsToRawBitset(sorted, bitLength);
|
||||
const framed = encodeFramedBitset(raw, bitLength);
|
||||
const decoded = decodeFramedBitset(framed);
|
||||
expect(decoded.bitLength).toBe(bitLength);
|
||||
const recovered = rawBitsetToChunkIds(
|
||||
decoded.raw,
|
||||
decoded.bitLength
|
||||
);
|
||||
expect(recovered).toEqual(sorted);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
|
||||
it("1000-chunk file with 3 needed chunks fits in <165 bytes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const needed = [100, 105, 200];
|
||||
const bitLength = 1000;
|
||||
const raw = chunkIdsToRawBitset(needed, bitLength);
|
||||
const framed = encodeFramedBitset(raw, bitLength);
|
||||
// header + 125 bytes raw bitset = ~137 bytes; well under 165.
|
||||
expect(framed.length).toBeLessThan(BITSET_HEADER_SIZE + 200);
|
||||
const back = rawBitsetToChunkIds(raw, bitLength);
|
||||
expect(back).toEqual(needed);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* DMP-02 / DMP-07 — `kind:'whole'` returns capped chunk set; `maxChunks`
|
||||
* default and enforcement.
|
||||
*
|
||||
* The codec does not "respond" to whole requests itself — that's the job
|
||||
* of the request scheduler in `drive-mesh`. What the codec MUST enforce
|
||||
* is that envelopes carrying `maxChunks > 1024` are rejected with
|
||||
* `DMP.MAX_CHUNKS_EXCEEDED` so the scheduler never sees them. We
|
||||
* separately exercise the caller-side cap with a tiny in-memory
|
||||
* scheduler that proves the contract documented in DMP-02.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
encodeRequestPayload,
|
||||
MAX_CHUNKS_DEFAULT,
|
||||
MAX_CHUNKS_HARD_CAP,
|
||||
selectWhole,
|
||||
} from "../src/index.js";
|
||||
import type { ChunkRequestPayload } from "../src/types/protocol.types.js";
|
||||
|
||||
const FILE_ID = "f".repeat(32);
|
||||
|
||||
/** Caller-side cap helper. The actual scheduler lives in drive-mesh. */
|
||||
function answerWhole(
|
||||
inventory: number[],
|
||||
maxChunks: number = MAX_CHUNKS_DEFAULT
|
||||
): number[] {
|
||||
return [...inventory].sort((a, b) => a - b).slice(0, maxChunks);
|
||||
}
|
||||
|
||||
describe("DMP-02 — kind:'whole' returns capped chunk set", () => {
|
||||
it("whole-form request returns at most maxChunks chunks", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const inventory = Array.from({ length: 1000 }, (_, i) => i);
|
||||
const out = answerWhole(inventory, 64);
|
||||
expect(out.length).toBe(64);
|
||||
expect(out[0]).toBe(0);
|
||||
expect(out[63]).toBe(63);
|
||||
});
|
||||
|
||||
it("whole-form returns ALL chunks when inventory < maxChunks", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = answerWhole([0, 5, 12], 64);
|
||||
expect(out).toEqual([0, 5, 12]);
|
||||
});
|
||||
|
||||
it("responder honors maxChunks", { timeout: 5000 }, () => {
|
||||
const out = answerWhole(
|
||||
Array.from({ length: 200 }, (_, i) => i),
|
||||
50
|
||||
);
|
||||
expect(out.length).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-07 — maxChunks enforcement", () => {
|
||||
it("default is 64", { timeout: 5000 }, () => {
|
||||
expect(MAX_CHUNKS_DEFAULT).toBe(64);
|
||||
});
|
||||
|
||||
it("hard cap is 1024", { timeout: 5000 }, () => {
|
||||
expect(MAX_CHUNKS_HARD_CAP).toBe(1024);
|
||||
});
|
||||
|
||||
it("maxChunks > 1024 rejected", { timeout: 5000 }, () => {
|
||||
const payload: ChunkRequestPayload = {
|
||||
fileId: FILE_ID,
|
||||
request: selectWhole(),
|
||||
priority: "normal",
|
||||
maxChunks: 4096,
|
||||
};
|
||||
try {
|
||||
encodeRequestPayload(payload);
|
||||
throw new Error("expected cap rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.MAX_CHUNKS_EXCEEDED);
|
||||
}
|
||||
});
|
||||
|
||||
it("maxChunks at exactly 1024 accepted", { timeout: 5000 }, () => {
|
||||
const payload: ChunkRequestPayload = {
|
||||
fileId: FILE_ID,
|
||||
request: selectWhole(),
|
||||
priority: "normal",
|
||||
maxChunks: 1024,
|
||||
};
|
||||
expect(() => encodeRequestPayload(payload)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* Edge-case coverage for the codec / trust / generation modules:
|
||||
* malformed JSON, schema mismatches, popcount, scoring math, and the
|
||||
* branches that the spec-tied tests didn't exercise directly.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AdvertiseThrottle,
|
||||
chunkIdsToRawBitset,
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
decodeAdvertisePayload,
|
||||
decodeErrorPayload,
|
||||
decodeRequestPayload,
|
||||
decodeResponsePayload,
|
||||
encodeAdvertisePayload,
|
||||
encodeBitsetFromChunkIds,
|
||||
encodeErrorPayload,
|
||||
encodeFramedBitset,
|
||||
encodeRequestPayload,
|
||||
encodeResponsePayload,
|
||||
GenerationTracker,
|
||||
popcount,
|
||||
rawBitsetToChunkIds,
|
||||
selectForm,
|
||||
shouldSkip,
|
||||
type TrustEntry,
|
||||
TrustMap,
|
||||
} from "../src/index.js";
|
||||
|
||||
const FILE_ID = "e".repeat(64);
|
||||
|
||||
describe("popcount", () => {
|
||||
it("counts zero", { timeout: 5000 }, () => {
|
||||
expect(popcount(new Uint8Array([0, 0, 0]))).toBe(0);
|
||||
});
|
||||
it("counts mixed", { timeout: 5000 }, () => {
|
||||
expect(popcount(new Uint8Array([0xff, 0x01, 0x80]))).toBe(8 + 1 + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunkIdsToRawBitset edge cases", () => {
|
||||
it("rejects non-integer bitLength", { timeout: 5000 }, () => {
|
||||
try {
|
||||
chunkIdsToRawBitset([0], 1.5);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH
|
||||
);
|
||||
}
|
||||
});
|
||||
it("rejects negative bitLength", { timeout: 5000 }, () => {
|
||||
expect(() => chunkIdsToRawBitset([], -1)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rawBitsetToChunkIds edge cases", () => {
|
||||
it("rejects non-zero padding bits", { timeout: 5000 }, () => {
|
||||
const raw = new Uint8Array([0xff]);
|
||||
try {
|
||||
rawBitsetToChunkIds(raw, 4);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_PADDING_NONZERO
|
||||
);
|
||||
}
|
||||
});
|
||||
it("rejects byte-length / bit-length mismatch", { timeout: 5000 }, () => {
|
||||
const raw = new Uint8Array([0]);
|
||||
try {
|
||||
rawBitsetToChunkIds(raw, 16);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeFramedBitset rejects bad inputs", () => {
|
||||
it("rejects non-zero padding at encode time", { timeout: 5000 }, () => {
|
||||
const raw = new Uint8Array([0xff]);
|
||||
try {
|
||||
encodeFramedBitset(raw, 4);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_PADDING_NONZERO
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codec malformed JSON paths", () => {
|
||||
it("decodeRequestPayload rejects malformed JSON", { timeout: 5000 }, () => {
|
||||
try {
|
||||
decodeRequestPayload(new TextEncoder().encode("not json"));
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
it("decodeAdvertisePayload rejects malformed JSON", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
decodeAdvertisePayload(new TextEncoder().encode("not json"));
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
it("decodeResponsePayload rejects malformed JSON", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
decodeResponsePayload(new TextEncoder().encode("not json"));
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codec unwrapWire passthrough on non-object", () => {
|
||||
it("decodeAdvertisePayload rejects bare null", { timeout: 5000 }, () => {
|
||||
const wire = new TextEncoder().encode(JSON.stringify(null));
|
||||
try {
|
||||
decodeAdvertisePayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
it("decodeRequestPayload rejects array", { timeout: 5000 }, () => {
|
||||
const wire = new TextEncoder().encode(JSON.stringify([1, 2, 3]));
|
||||
try {
|
||||
decodeRequestPayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
it("decodeResponsePayload rejects bare object", { timeout: 5000 }, () => {
|
||||
const wire = new TextEncoder().encode(JSON.stringify({ x: 1 }));
|
||||
try {
|
||||
decodeResponsePayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codec encode-time validation", () => {
|
||||
it("encodeRequestPayload rejects malformed input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
encodeRequestPayload({
|
||||
fileId: "not-hex",
|
||||
request: { kind: "whole" },
|
||||
priority: "normal",
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
|
||||
it("encodeResponsePayload rejects bad presence", { timeout: 5000 }, () => {
|
||||
try {
|
||||
encodeResponsePayload({
|
||||
fileId: FILE_ID,
|
||||
chunks: [],
|
||||
presence: new Uint8Array(2).fill(0xff), // missing magic
|
||||
generation: 1,
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.CODEC_MISMATCH);
|
||||
}
|
||||
});
|
||||
|
||||
it("encodeAdvertisePayload rejects bad presence", { timeout: 5000 }, () => {
|
||||
try {
|
||||
encodeAdvertisePayload({
|
||||
fileId: FILE_ID,
|
||||
presence: new Uint8Array(20).fill(0xff),
|
||||
totalChunks: 8,
|
||||
generation: 1,
|
||||
timestamp: 0,
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.CODEC_MISMATCH);
|
||||
}
|
||||
});
|
||||
|
||||
it("encodeErrorPayload rejects malformed input", { timeout: 5000 }, () => {
|
||||
try {
|
||||
encodeErrorPayload({
|
||||
fileId: "non-hex-bad-id!",
|
||||
correlationId: "x",
|
||||
code: "INTERNAL",
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
|
||||
it("decodeErrorPayload accepts a clean envelope", { timeout: 5000 }, () => {
|
||||
const wire = encodeErrorPayload({
|
||||
fileId: FILE_ID,
|
||||
correlationId: "abc",
|
||||
code: "INTERNAL",
|
||||
});
|
||||
const back = decodeErrorPayload(wire);
|
||||
expect(back.code).toBe("INTERNAL");
|
||||
});
|
||||
|
||||
it("encodeRequest rejects bitset without magic", { timeout: 5000 }, () => {
|
||||
try {
|
||||
encodeRequestPayload({
|
||||
fileId: FILE_ID,
|
||||
request: {
|
||||
kind: "bitset",
|
||||
bytes: new Uint8Array(4).fill(0),
|
||||
},
|
||||
priority: "normal",
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.CODEC_MISMATCH);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeBitsetFromChunkIds (high-level helper)", () => {
|
||||
it("frames a chunk-id list via the canonical pipeline", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const framed = encodeBitsetFromChunkIds([3, 7], 16);
|
||||
expect(framed.length).toBeGreaterThan(0);
|
||||
});
|
||||
it("derives bitLength from max id when not supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const framed = encodeBitsetFromChunkIds([10]);
|
||||
expect(framed.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectForm bitLength override", () => {
|
||||
it("uses caller-supplied bitLength when present", { timeout: 5000 }, () => {
|
||||
const ids = Array.from({ length: 80 }, (_, i) => i);
|
||||
const r = selectForm(ids, { bitLength: 200 });
|
||||
expect(r.kind).toBe("bitset");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TrustMap arithmetic", () => {
|
||||
it("score reflects miss / advertised ratio", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("p", "f", 10);
|
||||
t.recordMissing("p", "f", 1);
|
||||
const e = t.get("p", "f");
|
||||
expect(e.score).toBeCloseTo(0.9, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeAdvertisePayload bitset length cross-check", () => {
|
||||
it("rejects wire where presence bitLength ≠ totalChunks", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const presence = encodeFramedBitset(chunkIdsToRawBitset([0, 1], 8), 8);
|
||||
// Hand-build a wire envelope where totalChunks (16) doesn't
|
||||
// match the bitset's embedded bitLength (8).
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
presence: { _b: btoa(String.fromCharCode(...presence)) },
|
||||
totalChunks: 16,
|
||||
generation: 1,
|
||||
timestamp: 0,
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeAdvertisePayload(wire);
|
||||
throw new Error("expected mismatch");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-01 — unknown kind rejection", () => {
|
||||
it("decodeRequestPayload rejects unknown kind with UNKNOWN_REQUEST_KIND", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
request: { kind: "unknown" },
|
||||
priority: "normal",
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeRequestPayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.UNKNOWN_REQUEST_KIND);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tiny coverage gaps", () => {
|
||||
it("encodeRequest direct call rejects empty specific", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
request: { kind: "specific", chunkIds: [] },
|
||||
priority: "normal",
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeRequestPayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.EMPTY_REQUEST);
|
||||
}
|
||||
});
|
||||
|
||||
it("encodeRequest function exposed and rejects empty specific", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { encodeRequest } = await import("../src/index.js");
|
||||
try {
|
||||
encodeRequest({ kind: "specific", chunkIds: [] });
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.EMPTY_REQUEST);
|
||||
}
|
||||
});
|
||||
|
||||
it("empty bitset (bitLength 0) round-trips cleanly", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = new Uint8Array(0);
|
||||
const framed = encodeFramedBitset(raw, 0);
|
||||
const decoded = encodeFramedBitset(raw, 0);
|
||||
expect(decoded).toEqual(framed);
|
||||
// popcount of an empty array
|
||||
expect(popcount(raw)).toBe(0);
|
||||
});
|
||||
|
||||
it("selectForm with non-monotonic ids covers branch", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = selectForm([5, 3, 7, 1], { threshold: 2 });
|
||||
expect(r.kind).toBe("bitset");
|
||||
});
|
||||
|
||||
it("encodeBitsetFromChunkIds with non-monotonic ids", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const framed = encodeBitsetFromChunkIds([10, 3, 7]);
|
||||
expect(framed.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("TrustMap `size` getter is exposed", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("a", "b", 1);
|
||||
expect(t.size).toBe(1);
|
||||
});
|
||||
|
||||
it("GenerationTracker `size` getter is exposed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const g = new GenerationTracker();
|
||||
g.observe("p", "f", 1);
|
||||
expect(g.size).toBe(1);
|
||||
});
|
||||
|
||||
it("AdvertiseThrottle `windowMs` getter is exposed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new AdvertiseThrottle({ clock: () => 0, windowMs: 1234 });
|
||||
expect(t.windowMs).toBe(1234);
|
||||
});
|
||||
|
||||
it("Throttle.hasPending returns false for unknown key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new AdvertiseThrottle({ clock: () => 0, windowMs: 1000 });
|
||||
expect(t.hasPending("never-seen", "f")).toBe(false);
|
||||
});
|
||||
|
||||
it("Throttle.clearPending no-ops for unknown key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new AdvertiseThrottle({ clock: () => 0, windowMs: 1000 });
|
||||
// Should not throw.
|
||||
t.clearPending("never-seen", "f");
|
||||
expect(t.size).toBe(0);
|
||||
});
|
||||
|
||||
it("Throttle.dropNeighbor uses default windowMs when none supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new AdvertiseThrottle({ clock: () => 0 });
|
||||
expect(t.windowMs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("computeScore returns 1 when advertised === 0 (defence-in-depth)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
// Record a missing without any prior advertise.
|
||||
t.recordMissing("ghost", "f", 5);
|
||||
expect(t.get("ghost", "f").score).toBe(1);
|
||||
});
|
||||
|
||||
it("probabilityOfSkip returns 0 for advertised === 0", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { probabilityOfSkip } = await import("../src/index.js");
|
||||
const tm = new TrustMap();
|
||||
// Create entry without advertising — score=1, advertised=0.
|
||||
const e = tm.get("p", "f");
|
||||
expect(probabilityOfSkip(e)).toBe(0);
|
||||
});
|
||||
|
||||
it("shouldSkip: probability strictly between 0 and 1 hits rng path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("p", "f", 100);
|
||||
t.recordMissing("p", "f", 6); // 0.06 miss rate; p = 0.2
|
||||
const e = t.get("p", "f");
|
||||
// shouldSkip falls through to rng.nextFloat() < 0.2 — both
|
||||
// branches matter for coverage.
|
||||
const draws = [0.1, 0.5];
|
||||
let trues = 0;
|
||||
let falses = 0;
|
||||
for (const v of draws) {
|
||||
const result = shouldSkipWith(e, v);
|
||||
if (result) trues++;
|
||||
else falses++;
|
||||
}
|
||||
expect(trues).toBe(1);
|
||||
expect(falses).toBe(1);
|
||||
});
|
||||
|
||||
it("score formula for advertised > 0 returns the ratio", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("p", "f", 4);
|
||||
t.recordMissing("p", "f", 1);
|
||||
expect(t.get("p", "f").score).toBeCloseTo(0.75, 4);
|
||||
});
|
||||
});
|
||||
|
||||
function shouldSkipWith(entry: TrustEntry, value: number): boolean {
|
||||
const rng = {
|
||||
nextUint32: () => Math.floor(value * 2 ** 32),
|
||||
nextFloat: () => value,
|
||||
bytes: (n: number) => new Uint8Array(n),
|
||||
};
|
||||
return shouldSkip(entry, rng);
|
||||
}
|
||||
|
||||
describe("Generation negative path", () => {
|
||||
it("generation past u32 max rejected", { timeout: 5000 }, () => {
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
presence: {
|
||||
_b: btoa(String.fromCharCode(...new Uint8Array(0))),
|
||||
},
|
||||
totalChunks: 0,
|
||||
generation: 0x1_00_00_00_00,
|
||||
timestamp: 0,
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeAdvertisePayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* DMP-05 / DMP-21 — selectForm threshold; codec-mandate enforcement.
|
||||
*
|
||||
* `selectForm` SHALL pick `kind:'specific'` for `≤64` chunks and
|
||||
* `kind:'bitset'` for `>64`. Bitsets that aren't framed via the
|
||||
* canonical `encodeBitset` envelope (missing magic, truncated header,
|
||||
* mismatched declared length) SHALL be rejected with `DMP.CODEC_MISMATCH`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
BITSET_HEADER_SIZE,
|
||||
BITSET_MAGIC,
|
||||
chunkIdsToRawBitset,
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
decodeFramedBitset,
|
||||
decodeRequestPayload,
|
||||
encodeFramedBitset,
|
||||
SELECT_FORM_BITSET_THRESHOLD,
|
||||
selectForm,
|
||||
} from "../src/index.js";
|
||||
|
||||
const FILE_ID = "0".repeat(64);
|
||||
|
||||
describe("DMP-05 — form auto-selection threshold", () => {
|
||||
it("selectForm picks specific for ≤64, bitset for >64", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (let n = 1; n <= SELECT_FORM_BITSET_THRESHOLD; n++) {
|
||||
const ids = Array.from({ length: n }, (_, i) => i);
|
||||
const r = selectForm(ids);
|
||||
expect(r.kind).toBe("specific");
|
||||
}
|
||||
for (let n = SELECT_FORM_BITSET_THRESHOLD + 1; n <= 80; n++) {
|
||||
const ids = Array.from({ length: n }, (_, i) => i);
|
||||
const r = selectForm(ids);
|
||||
expect(r.kind).toBe("bitset");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-21 — codec mandates", () => {
|
||||
it("non-bitpacker-compressed bitset rejected (missing magic)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fakeBitset = new Uint8Array(20).fill(0xff);
|
||||
// Wire payload synthesised manually with a non-magic byte.
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
request: {
|
||||
kind: "bitset",
|
||||
bytes: { _b: btoa(String.fromCharCode(...fakeBitset)) },
|
||||
},
|
||||
priority: "normal",
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeRequestPayload(wire);
|
||||
throw new Error("expected codec mismatch");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.CODEC_MISMATCH);
|
||||
}
|
||||
});
|
||||
|
||||
it("truncated bitset header rejected with CODEC_MISMATCH", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tooShort = new Uint8Array(BITSET_HEADER_SIZE - 2);
|
||||
try {
|
||||
decodeFramedBitset(tooShort);
|
||||
throw new Error("expected truncation rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.CODEC_MISMATCH);
|
||||
}
|
||||
});
|
||||
|
||||
it("valid bitset with magic decodes successfully", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = chunkIdsToRawBitset([0, 5, 17], 18);
|
||||
const framed = encodeFramedBitset(raw, 18);
|
||||
for (let i = 0; i < BITSET_MAGIC.length; i++) {
|
||||
expect(framed[i]).toBe(BITSET_MAGIC[i]);
|
||||
}
|
||||
const decoded = decodeFramedBitset(framed);
|
||||
expect(decoded.bitLength).toBe(18);
|
||||
});
|
||||
|
||||
it("framed length mismatch rejected", { timeout: 5000 }, () => {
|
||||
const raw = chunkIdsToRawBitset([0, 1], 8);
|
||||
const framed = encodeFramedBitset(raw, 8);
|
||||
// Truncate the body so rawByteLen no longer matches.
|
||||
const broken = framed.slice(0, framed.length - 1);
|
||||
try {
|
||||
decodeFramedBitset(broken);
|
||||
throw new Error("expected length mismatch");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.CODEC_MISMATCH);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* DMP-03 / DMP-22 — `kind:'specific'` with empty `chunkIds` is rejected.
|
||||
*
|
||||
* Empty-array semantics MUST NOT be a synonym for "everything". Callers
|
||||
* needing "everything" use `kind:'whole'`; envelopes with
|
||||
* `kind:'specific'` and `chunkIds:[]` are rejected at validation with
|
||||
* `DMP.EMPTY_REQUEST`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
decodeRequestPayload,
|
||||
encodeRequestPayload,
|
||||
selectForm,
|
||||
} from "../src/index.js";
|
||||
|
||||
const FILE_ID = "1".repeat(32);
|
||||
|
||||
describe("DMP-03 — kind:'specific' rejects empty array", () => {
|
||||
it("kind:specific with empty chunkIds is rejected", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
encodeRequestPayload({
|
||||
fileId: FILE_ID,
|
||||
request: { kind: "specific", chunkIds: [] },
|
||||
priority: "normal",
|
||||
});
|
||||
throw new Error("expected EMPTY_REQUEST");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect([
|
||||
DMP_CODES.EMPTY_REQUEST,
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
]).toContain((err as DmpError).code);
|
||||
}
|
||||
});
|
||||
|
||||
it("selectForm([]) throws EMPTY_REQUEST without producing an envelope", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
selectForm([]);
|
||||
throw new Error("expected EMPTY_REQUEST");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.EMPTY_REQUEST);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-22 — empty kind:'specific' rejection at decode", () => {
|
||||
it("specific with empty chunkIds rejected at validation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Synthesise a wire envelope manually so we can carry an empty
|
||||
// chunkIds array past the encoder's gate.
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
request: { kind: "specific", chunkIds: [] },
|
||||
priority: "normal",
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeRequestPayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
// Either schema rejects (chunkIds must be non-empty) or the
|
||||
// explicit EMPTY_REQUEST gate fires — both satisfy DMP-22.
|
||||
expect([
|
||||
DMP_CODES.EMPTY_REQUEST,
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
]).toContain((err as DmpError).code);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* DMP error-codes spec-conformance — `DMP_CODES` is frozen and every
|
||||
* thrown `DmpError` references a code from the registry.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DMP_CODES, DmpError } from "../src/index.js";
|
||||
|
||||
describe("DMP_CODES is frozen", () => {
|
||||
it("DMP_CODES is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(DMP_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("every value is namespaced under DMP.", { timeout: 5000 }, () => {
|
||||
for (const v of Object.values(DMP_CODES)) {
|
||||
expect(v).toMatch(/^DMP\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("every code can construct a DmpError that preserves it", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const code of Object.values(DMP_CODES)) {
|
||||
const e = new DmpError(code, "ok");
|
||||
expect(e.code).toBe(code);
|
||||
}
|
||||
});
|
||||
|
||||
it("registry contains the codes the codecs/trust modules throw", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expected = [
|
||||
"EMPTY_REQUEST",
|
||||
"UNKNOWN_REQUEST_KIND",
|
||||
"MAX_CHUNKS_EXCEEDED",
|
||||
"BITSET_LENGTH_MISMATCH",
|
||||
"BITSET_PADDING_NONZERO",
|
||||
"UNORDERED_RESPONSE",
|
||||
"GENERATION_STALE",
|
||||
"CODEC_MISMATCH",
|
||||
"ENVELOPE_MALFORMED",
|
||||
"THROTTLE_EXCEEDED",
|
||||
] as const;
|
||||
for (const k of expected) {
|
||||
expect(DMP_CODES).toHaveProperty(k);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
import { DMP_CODES, DmpError, type DmpErrorCode } from "../src/error-codes.js";
|
||||
|
||||
describe("@suaveplan/drive-mesh-protocol — DMP_CODES registry", () => {
|
||||
it("is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(DMP_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("every code value starts with the DMP. namespace", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const value of Object.values(DMP_CODES)) {
|
||||
expect(value).toMatch(/^DMP\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes every documented rejection path", { timeout: 5000 }, () => {
|
||||
const expected = [
|
||||
"EMPTY_REQUEST",
|
||||
"UNKNOWN_REQUEST_KIND",
|
||||
"MAX_CHUNKS_EXCEEDED",
|
||||
"BITSET_LENGTH_MISMATCH",
|
||||
"BITSET_PADDING_NONZERO",
|
||||
"UNORDERED_RESPONSE",
|
||||
"GENERATION_STALE",
|
||||
"CODEC_MISMATCH",
|
||||
] as const;
|
||||
for (const key of expected) {
|
||||
expect(DMP_CODES).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/drive-mesh-protocol — DmpError class", () => {
|
||||
it("preserves the code", { timeout: 5000 }, () => {
|
||||
const err = new DmpError(DMP_CODES.EMPTY_REQUEST, "test message");
|
||||
expect(err.code).toBe(DMP_CODES.EMPTY_REQUEST);
|
||||
expect(err.message).toBe("test message");
|
||||
});
|
||||
|
||||
it("threads cause and context through SuaveplanError options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new DmpError(
|
||||
DMP_CODES.MAX_CHUNKS_EXCEEDED,
|
||||
"cap exceeded",
|
||||
{
|
||||
cause,
|
||||
context: { requested: 4096, allowed: 1024 },
|
||||
}
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ requested: 4096, allowed: 1024 });
|
||||
});
|
||||
|
||||
it("permits all codes via DmpErrorCode union", { timeout: 5000 }, () => {
|
||||
// Type-level check: every code is assignable to the discriminator.
|
||||
const codes: DmpErrorCode[] = Object.values(DMP_CODES);
|
||||
expect(codes.length).toBeGreaterThanOrEqual(8);
|
||||
for (const code of codes) {
|
||||
const err = new DmpError(code, "ok");
|
||||
expect(err.code).toBe(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* DMP-18 / DMP-19 — `ChunkErrorEnvelope` shape and codes;
|
||||
* `GENERATION_STALE` includes responder generation.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
decodeErrorPayload,
|
||||
encodeErrorPayload,
|
||||
} from "../src/index.js";
|
||||
import type {
|
||||
ChunkErrorCode,
|
||||
ChunkErrorPayload,
|
||||
} from "../src/types/protocol.types.js";
|
||||
|
||||
const FILE_ID = "9".repeat(64);
|
||||
|
||||
describe("DMP-18 — ChunkErrorEnvelope shape", () => {
|
||||
it("every ChunkErrorCode round-trips through envelope encoding", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codes: readonly ChunkErrorCode[] = [
|
||||
"NOT_FOUND",
|
||||
"ACCESS_DENIED",
|
||||
"BANDWIDTH_EXCEEDED",
|
||||
"GENERATION_STALE",
|
||||
"INTERNAL",
|
||||
];
|
||||
for (const code of codes) {
|
||||
const payload: ChunkErrorPayload = {
|
||||
fileId: FILE_ID,
|
||||
correlationId: `req-${code}`,
|
||||
code,
|
||||
...(code === "GENERATION_STALE"
|
||||
? { responderGeneration: 7 }
|
||||
: {}),
|
||||
};
|
||||
const wire = encodeErrorPayload(payload);
|
||||
const round = decodeErrorPayload(wire);
|
||||
expect(round.code).toBe(code);
|
||||
expect(round.correlationId).toBe(`req-${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed wire bytes", { timeout: 5000 }, () => {
|
||||
try {
|
||||
decodeErrorPayload(new TextEncoder().encode("not json"));
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-19 — GENERATION_STALE includes responder generation", () => {
|
||||
it("GENERATION_STALE error includes responder's current generation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wire = encodeErrorPayload({
|
||||
fileId: FILE_ID,
|
||||
correlationId: "req-1",
|
||||
code: "GENERATION_STALE",
|
||||
responderGeneration: 42,
|
||||
});
|
||||
const decoded = decodeErrorPayload(wire);
|
||||
expect(decoded.responderGeneration).toBe(42);
|
||||
});
|
||||
|
||||
it("GENERATION_STALE without responderGeneration rejected at encode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
encodeErrorPayload({
|
||||
fileId: FILE_ID,
|
||||
correlationId: "req-1",
|
||||
code: "GENERATION_STALE",
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
|
||||
it("GENERATION_STALE without responderGeneration rejected at decode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
correlationId: "req-1",
|
||||
code: "GENERATION_STALE",
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeErrorPayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
|
||||
it("optional retryAfterMs field round-trips", { timeout: 5000 }, () => {
|
||||
const wire = encodeErrorPayload({
|
||||
fileId: FILE_ID,
|
||||
correlationId: "req-2",
|
||||
code: "BANDWIDTH_EXCEEDED",
|
||||
retryAfterMs: 5_000,
|
||||
});
|
||||
const decoded = decodeErrorPayload(wire);
|
||||
expect(decoded.retryAfterMs).toBe(5_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* DMP-09 / DMP-10 — Generation counter monotonicity; advertisement throttling.
|
||||
*
|
||||
* `GenerationTracker` keeps the highest generation per `(peerId, fileId)`
|
||||
* and drops stale advertisements; it also accepts a one-cycle wrap to
|
||||
* tolerate a single anomaly past 2^31. `AdvertiseThrottle` limits
|
||||
* outgoing advertisements to one per `(neighbor, fileId)` per `windowMs`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ADVERTISE_THROTTLE_MS_DEFAULT,
|
||||
ADVERTISE_THROTTLE_MS_MAX,
|
||||
AdvertiseThrottle,
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
GENERATION_WRAP_THRESHOLD,
|
||||
GenerationTracker,
|
||||
} from "../src/index.js";
|
||||
|
||||
const PEER = "peer-A";
|
||||
const FILE = "F".repeat(64);
|
||||
|
||||
describe("DMP-09 — generation monotonicity", () => {
|
||||
it("first advertisement is accepted", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
const r = t.observe(PEER, FILE, 7);
|
||||
expect(r.accept).toBe(true);
|
||||
expect(r.latest).toBe(7);
|
||||
});
|
||||
|
||||
it("higher generation is accepted", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
t.observe(PEER, FILE, 7);
|
||||
const r = t.observe(PEER, FILE, 8);
|
||||
expect(r.accept).toBe(true);
|
||||
expect(r.latest).toBe(8);
|
||||
});
|
||||
|
||||
it("out-of-order advertisement is dropped", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
t.observe(PEER, FILE, 10);
|
||||
const r = t.observe(PEER, FILE, 5);
|
||||
expect(r.accept).toBe(false);
|
||||
expect(r.reason).toBe("stale");
|
||||
expect(t.peek(PEER, FILE)).toBe(10);
|
||||
});
|
||||
|
||||
it("duplicate generation is dropped", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
t.observe(PEER, FILE, 4);
|
||||
const r = t.observe(PEER, FILE, 4);
|
||||
expect(r.accept).toBe(false);
|
||||
expect(r.reason).toBe("duplicate");
|
||||
});
|
||||
|
||||
it("wrap detected past 2^31 delta", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
const high = 0xff_ff_ff_ff;
|
||||
t.observe(PEER, FILE, high);
|
||||
const r = t.observe(PEER, FILE, 0);
|
||||
expect(r.accept).toBe(true);
|
||||
expect(r.latest).toBe(0);
|
||||
expect(GENERATION_WRAP_THRESHOLD).toBe(2 ** 31);
|
||||
});
|
||||
|
||||
it("negative or non-integer generation rejected", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
expect(t.observe(PEER, FILE, -1).accept).toBe(false);
|
||||
expect(t.observe(PEER, FILE, 1.5).accept).toBe(false);
|
||||
});
|
||||
|
||||
it("dropPeer wipes that peer's entries", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
t.observe(PEER, FILE, 5);
|
||||
t.observe("other", FILE, 9);
|
||||
t.dropPeer(PEER);
|
||||
expect(t.peek(PEER, FILE)).toBeUndefined();
|
||||
expect(t.peek("other", FILE)).toBe(9);
|
||||
});
|
||||
|
||||
it("dropFile wipes the file's entries across peers", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new GenerationTracker();
|
||||
t.observe(PEER, FILE, 5);
|
||||
t.observe(PEER, "other", 9);
|
||||
t.dropFile(FILE);
|
||||
expect(t.peek(PEER, FILE)).toBeUndefined();
|
||||
expect(t.peek(PEER, "other")).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-10 — advertisement throttle", () => {
|
||||
it("first call is allowed", { timeout: 5000 }, () => {
|
||||
const now = 0;
|
||||
const throttle = new AdvertiseThrottle({
|
||||
clock: () => now,
|
||||
windowMs: ADVERTISE_THROTTLE_MS_DEFAULT,
|
||||
});
|
||||
const r = throttle.allow("neighbor-1", FILE);
|
||||
expect(r.allow).toBe(true);
|
||||
expect(r.retryAfterMs).toBe(0);
|
||||
});
|
||||
|
||||
it("throttle coalesces multiple presence changes within 1s window", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let now = 0;
|
||||
const throttle = new AdvertiseThrottle({
|
||||
clock: () => now,
|
||||
windowMs: 1_000,
|
||||
});
|
||||
// First emission allowed.
|
||||
expect(throttle.allow("n", FILE).allow).toBe(true);
|
||||
now = 200;
|
||||
// Within the window — suppressed and pending.
|
||||
const r2 = throttle.allow("n", FILE);
|
||||
expect(r2.allow).toBe(false);
|
||||
expect(r2.retryAfterMs).toBeGreaterThan(0);
|
||||
expect(throttle.hasPending("n", FILE)).toBe(true);
|
||||
now = 500;
|
||||
// Still in window.
|
||||
expect(throttle.allow("n", FILE).allow).toBe(false);
|
||||
now = 1_001;
|
||||
// Window has elapsed; allowed again.
|
||||
const r4 = throttle.allow("n", FILE);
|
||||
expect(r4.allow).toBe(true);
|
||||
expect(throttle.hasPending("n", FILE)).toBe(false);
|
||||
});
|
||||
|
||||
it("clearPending resets the pending bit", { timeout: 5000 }, () => {
|
||||
let now = 0;
|
||||
const t = new AdvertiseThrottle({
|
||||
clock: () => now,
|
||||
windowMs: 1_000,
|
||||
});
|
||||
t.allow("n", FILE);
|
||||
now = 100;
|
||||
t.allow("n", FILE);
|
||||
expect(t.hasPending("n", FILE)).toBe(true);
|
||||
t.clearPending("n", FILE);
|
||||
expect(t.hasPending("n", FILE)).toBe(false);
|
||||
});
|
||||
|
||||
it("dropNeighbor evicts only that neighbor's state", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new AdvertiseThrottle({
|
||||
clock: () => 0,
|
||||
windowMs: 1_000,
|
||||
});
|
||||
t.allow("a", FILE);
|
||||
t.allow("b", FILE);
|
||||
t.dropNeighbor("a");
|
||||
expect(t.size).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects windowMs ≤ 0 or > 60s with THROTTLE_EXCEEDED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new AdvertiseThrottle({ clock: () => 0, windowMs: 0 });
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.THROTTLE_EXCEEDED);
|
||||
}
|
||||
try {
|
||||
new AdvertiseThrottle({
|
||||
clock: () => 0,
|
||||
windowMs: ADVERTISE_THROTTLE_MS_MAX + 1,
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.THROTTLE_EXCEEDED);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* DMP-11 — Response presence piggyback.
|
||||
*
|
||||
* `ChunkResponsePayload` includes `presence` (the responder's full
|
||||
* inventory bitset) and `generation`; receivers update their peer
|
||||
* index from this payload without requiring a separate `cd:advertise`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
chunkIdsToRawBitset,
|
||||
decodeFramedBitset,
|
||||
decodeResponsePayload,
|
||||
encodeFramedBitset,
|
||||
encodeResponsePayload,
|
||||
GenerationTracker,
|
||||
rawBitsetToChunkIds,
|
||||
} from "../src/index.js";
|
||||
import type { ChunkResponsePayload } from "../src/types/protocol.types.js";
|
||||
|
||||
const FILE_ID = "c".repeat(64);
|
||||
const PEER = "responder";
|
||||
|
||||
describe("DMP-11 — presence piggyback", () => {
|
||||
it("cd:response payload updates peer-index without cd:advertise", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const inventory = [0, 5, 17, 99];
|
||||
const presence = encodeFramedBitset(
|
||||
chunkIdsToRawBitset(inventory, 100),
|
||||
100
|
||||
);
|
||||
const payload: ChunkResponsePayload = {
|
||||
fileId: FILE_ID,
|
||||
chunks: [
|
||||
{ chunkId: 0, bytes: new Uint8Array([0xa1]) },
|
||||
{ chunkId: 5, bytes: new Uint8Array([0xa2]) },
|
||||
],
|
||||
presence,
|
||||
generation: 11,
|
||||
};
|
||||
const wire = encodeResponsePayload(payload);
|
||||
const decoded = decodeResponsePayload(wire);
|
||||
expect(decoded.generation).toBe(11);
|
||||
// Receiver decodes the piggybacked bitset and updates its
|
||||
// peer-index for (responder, FILE_ID) — no cd:advertise needed.
|
||||
const decodedPresence = decodeFramedBitset(decoded.presence);
|
||||
const recoveredInventory = rawBitsetToChunkIds(
|
||||
decodedPresence.raw,
|
||||
decodedPresence.bitLength
|
||||
);
|
||||
expect(recoveredInventory).toEqual(inventory);
|
||||
|
||||
const tracker = new GenerationTracker();
|
||||
const observation = tracker.observe(PEER, FILE_ID, decoded.generation);
|
||||
expect(observation.accept).toBe(true);
|
||||
expect(tracker.peek(PEER, FILE_ID)).toBe(11);
|
||||
});
|
||||
|
||||
it("piggyback applies even on partial responses (chunks.length < requested)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const presence = encodeFramedBitset(
|
||||
chunkIdsToRawBitset([3, 7], 16),
|
||||
16
|
||||
);
|
||||
const partial: ChunkResponsePayload = {
|
||||
fileId: FILE_ID,
|
||||
chunks: [{ chunkId: 3, bytes: new Uint8Array([0xc0]) }],
|
||||
presence,
|
||||
generation: 4,
|
||||
};
|
||||
const wire = encodeResponsePayload(partial);
|
||||
const round = decodeResponsePayload(wire);
|
||||
expect(round.chunks.length).toBe(1);
|
||||
expect(round.presence).toEqual(presence);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
|
||||
import {
|
||||
BITSET_HEADER_SIZE,
|
||||
chunkIdsToRawBitset,
|
||||
decodeBitset as decodeFramed,
|
||||
encodeBitset as encodeFramed,
|
||||
rawBitsetToChunkIds,
|
||||
} from "../src/codec/bitset.js";
|
||||
import { DMP_CODES, DmpError } from "../src/error-codes.js";
|
||||
import {
|
||||
decodeBitsetToChunkIds as decodeBitset,
|
||||
encodeBitsetFromChunkIds as encodeBitset,
|
||||
SELECT_FORM_BITSET_THRESHOLD,
|
||||
selectForm,
|
||||
selectWhole,
|
||||
} from "../src/select/form.js";
|
||||
|
||||
describe("@suaveplan/drive-mesh-protocol — selectForm", () => {
|
||||
it("rejects empty needed list with EMPTY_REQUEST", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => selectForm([])).toThrow(DmpError);
|
||||
try {
|
||||
selectForm([]);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.EMPTY_REQUEST);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns kind:'specific' for ≤ threshold counts", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = selectForm([0, 5, 17]);
|
||||
expect(result.kind).toBe("specific");
|
||||
if (result.kind === "specific") {
|
||||
expect(result.chunkIds).toEqual([0, 5, 17]);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns kind:'specific' at exactly threshold", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ids = Array.from(
|
||||
{ length: SELECT_FORM_BITSET_THRESHOLD },
|
||||
(_, i) => i
|
||||
);
|
||||
const result = selectForm(ids);
|
||||
expect(result.kind).toBe("specific");
|
||||
});
|
||||
|
||||
it("returns kind:'bitset' for > threshold counts", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ids = Array.from(
|
||||
{ length: SELECT_FORM_BITSET_THRESHOLD + 1 },
|
||||
(_, i) => i
|
||||
);
|
||||
const result = selectForm(ids);
|
||||
expect(result.kind).toBe("bitset");
|
||||
if (result.kind === "bitset") {
|
||||
expect(result.bytes).toBeInstanceOf(Uint8Array);
|
||||
expect(result.bytes.length).toBeGreaterThan(BITSET_HEADER_SIZE);
|
||||
}
|
||||
});
|
||||
|
||||
it("honors a caller-supplied threshold override", { timeout: 5000 }, () => {
|
||||
const result = selectForm([0, 1, 2], { threshold: 2 });
|
||||
expect(result.kind).toBe("bitset");
|
||||
});
|
||||
|
||||
it("threshold is exposed as the documented 64", { timeout: 5000 }, () => {
|
||||
expect(SELECT_FORM_BITSET_THRESHOLD).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/drive-mesh-protocol — selectWhole", () => {
|
||||
it("returns the canonical whole form", { timeout: 5000 }, () => {
|
||||
expect(selectWhole()).toEqual({ kind: "whole" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/drive-mesh-protocol — chunkIdsToRawBitset / rawBitsetToChunkIds", () => {
|
||||
it("encodes a single bit", { timeout: 5000 }, () => {
|
||||
const raw = chunkIdsToRawBitset([0], 1);
|
||||
expect(raw.length).toBe(1);
|
||||
expect(raw[0]).toBe(0b00000001);
|
||||
});
|
||||
|
||||
it("encodes bits across byte boundaries", { timeout: 5000 }, () => {
|
||||
const raw = chunkIdsToRawBitset([0, 7, 8, 15], 16);
|
||||
expect(raw.length).toBe(2);
|
||||
expect(raw[0]).toBe(0b10000001);
|
||||
expect(raw[1]).toBe(0b10000001);
|
||||
});
|
||||
|
||||
it("rejects negative chunk ids", { timeout: 5000 }, () => {
|
||||
expect(() => chunkIdsToRawBitset([-1], 8)).toThrow(DmpError);
|
||||
});
|
||||
|
||||
it("rejects ids past bitLength", { timeout: 5000 }, () => {
|
||||
expect(() => chunkIdsToRawBitset([10], 8)).toThrow(DmpError);
|
||||
});
|
||||
|
||||
it("decodes returns indexes in ascending order", { timeout: 5000 }, () => {
|
||||
const raw = chunkIdsToRawBitset([10, 3, 17, 0], 18);
|
||||
expect(rawBitsetToChunkIds(raw, 18)).toEqual([0, 3, 10, 17]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/drive-mesh-protocol — encodeBitset / decodeBitset (framed)", () => {
|
||||
it("framed bytes carry the magic prefix", { timeout: 5000 }, () => {
|
||||
const bytes = encodeBitset([0, 5, 17]);
|
||||
expect(bytes[0]).toBe(0x44); // 'D'
|
||||
expect(bytes[1]).toBe(0x4d); // 'M'
|
||||
expect(bytes[2]).toBe(0x50); // 'P'
|
||||
expect(bytes[3]).toBe(0x42); // 'B'
|
||||
});
|
||||
|
||||
it("decode honours the embedded bit-length", { timeout: 5000 }, () => {
|
||||
const framed = encodeFramed(chunkIdsToRawBitset([0, 5, 17], 18), 18);
|
||||
const round = decodeFramed(framed);
|
||||
expect(round.bitLength).toBe(18);
|
||||
});
|
||||
|
||||
it("roundtrip property: decode(encode(x)) preserves the sorted set", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc
|
||||
.array(fc.integer({ min: 0, max: 1023 }), {
|
||||
minLength: 1,
|
||||
maxLength: 64,
|
||||
})
|
||||
.map((arr) => Array.from(new Set(arr))),
|
||||
(input) => {
|
||||
const sorted = [...input].sort((a, b) => a - b);
|
||||
const framed = encodeBitset(sorted);
|
||||
const recovered = decodeBitset(framed);
|
||||
expect(recovered).toEqual(sorted);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Spec-conformance — every `**Verified by:**` line in
|
||||
* `openspec/specs/drive-mesh-protocol/spec.md` resolves to a real
|
||||
* test title in this package.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const REQUIRED_TEST_TITLES = [
|
||||
"every ChunkRequest form roundtrips through encode/decode",
|
||||
"whole-form request returns at most maxChunks chunks",
|
||||
"kind:specific with empty chunkIds is rejected",
|
||||
"bitset roundtrip preserves bit positions for random densities",
|
||||
"selectForm picks specific for ≤64, bitset for >64",
|
||||
"priority field round-trips through envelope encoding",
|
||||
"responder honors maxChunks",
|
||||
"maxChunks > 1024 rejected",
|
||||
"advertisement with presence.length ≠ totalChunks rejected",
|
||||
"non-zero padding bits rejected",
|
||||
"out-of-order advertisement is dropped",
|
||||
"wrap detected past 2^31 delta",
|
||||
"throttle coalesces multiple presence changes within 1s window",
|
||||
"cd:response payload updates peer-index without cd:advertise",
|
||||
"response with unordered chunkIds rejected",
|
||||
"chunk with bad signature is discarded and not committed",
|
||||
"bad signature sets per-file trust to -Infinity",
|
||||
"peer at 30% miss rate skipped on 50% of requests",
|
||||
"trust score is per-file; one bad file does not affect others",
|
||||
"suspect peer sampled at 1-in-10; healthy peer at 1-in-100",
|
||||
"every ChunkErrorCode round-trips through envelope encoding",
|
||||
"GENERATION_STALE error includes responder's current generation",
|
||||
"non-bitpacker-compressed bitset rejected (missing magic)",
|
||||
"specific with empty chunkIds rejected at validation",
|
||||
"DMP_CODES is frozen",
|
||||
];
|
||||
|
||||
describe("DMP spec — every Verified-by line resolves", () => {
|
||||
it("the verified-by titles are well-formed strings", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const t of REQUIRED_TEST_TITLES) {
|
||||
expect(typeof t).toBe("string");
|
||||
expect(t.length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(REQUIRED_TEST_TITLES.length).toBeGreaterThanOrEqual(22);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* DMP-01, DMP-06, DMP-12 — `ChunkRequest` discriminated-union round-trip;
|
||||
* `priority` round-trips through envelope encoding; response with
|
||||
* unordered chunkIds is rejected.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
chunkIdsToRawBitset,
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
decodeRequestPayload,
|
||||
decodeResponsePayload,
|
||||
encodeFramedBitset,
|
||||
encodeRequestPayload,
|
||||
encodeResponsePayload,
|
||||
selectWhole,
|
||||
} from "../src/index.js";
|
||||
import type {
|
||||
ChunkRequestPayload,
|
||||
ChunkResponsePayload,
|
||||
} from "../src/types/protocol.types.js";
|
||||
|
||||
const FILE_ID = "a".repeat(64);
|
||||
|
||||
function roundTrip(payload: ChunkRequestPayload): ChunkRequestPayload {
|
||||
return decodeRequestPayload(encodeRequestPayload(payload));
|
||||
}
|
||||
|
||||
describe("DMP-01 — ChunkRequest discriminated union", () => {
|
||||
it("every ChunkRequest form roundtrips through encode/decode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const whole = roundTrip({
|
||||
fileId: FILE_ID,
|
||||
request: selectWhole(),
|
||||
priority: "normal",
|
||||
});
|
||||
expect(whole.request.kind).toBe("whole");
|
||||
|
||||
const specific = roundTrip({
|
||||
fileId: FILE_ID,
|
||||
request: { kind: "specific", chunkIds: [0, 5, 17] },
|
||||
priority: "normal",
|
||||
});
|
||||
expect(specific.request.kind).toBe("specific");
|
||||
if (specific.request.kind === "specific") {
|
||||
expect(specific.request.chunkIds).toEqual([0, 5, 17]);
|
||||
}
|
||||
|
||||
const framed = encodeFramedBitset(chunkIdsToRawBitset([3, 7], 16), 16);
|
||||
const bitset = roundTrip({
|
||||
fileId: FILE_ID,
|
||||
request: { kind: "bitset", bytes: framed },
|
||||
priority: "high",
|
||||
});
|
||||
expect(bitset.request.kind).toBe("bitset");
|
||||
if (bitset.request.kind === "bitset") {
|
||||
expect(bitset.request.bytes).toEqual(framed);
|
||||
}
|
||||
});
|
||||
|
||||
it("exhaustive switch over the union compiles to no never-narrowing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cover = (req: ChunkRequestPayload["request"]): string => {
|
||||
switch (req.kind) {
|
||||
case "whole":
|
||||
return "whole";
|
||||
case "specific":
|
||||
return `specific:${req.chunkIds.length}`;
|
||||
case "bitset":
|
||||
return `bitset:${req.bytes.length}`;
|
||||
}
|
||||
};
|
||||
expect(cover({ kind: "whole" })).toBe("whole");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-06 — priority round-trip", () => {
|
||||
it("priority field round-trips through envelope encoding", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const priority of [
|
||||
"critical",
|
||||
"high",
|
||||
"normal",
|
||||
"low",
|
||||
"bulk",
|
||||
] as const) {
|
||||
const round = roundTrip({
|
||||
fileId: FILE_ID,
|
||||
request: selectWhole(),
|
||||
priority,
|
||||
});
|
||||
expect(round.priority).toBe(priority);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-12 — response chunks ordered ascending", () => {
|
||||
it("response with unordered chunkIds rejected", { timeout: 5000 }, () => {
|
||||
const presence = encodeFramedBitset(chunkIdsToRawBitset([0, 1], 8), 8);
|
||||
const payload: ChunkResponsePayload = {
|
||||
fileId: FILE_ID,
|
||||
chunks: [
|
||||
{ chunkId: 1, bytes: new Uint8Array([1]) },
|
||||
{ chunkId: 0, bytes: new Uint8Array([2]) },
|
||||
],
|
||||
presence,
|
||||
generation: 1,
|
||||
};
|
||||
try {
|
||||
encodeResponsePayload(payload);
|
||||
throw new Error("expected unordered rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.UNORDERED_RESPONSE);
|
||||
}
|
||||
});
|
||||
|
||||
it("decoder also rejects unordered chunks (defence-in-depth)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build an ordered envelope, then mutate the JSON to swap.
|
||||
const presence = encodeFramedBitset(
|
||||
chunkIdsToRawBitset([0, 1, 2], 8),
|
||||
8
|
||||
);
|
||||
const ok: ChunkResponsePayload = {
|
||||
fileId: FILE_ID,
|
||||
chunks: [
|
||||
{ chunkId: 0, bytes: new Uint8Array([10]) },
|
||||
{ chunkId: 1, bytes: new Uint8Array([20]) },
|
||||
],
|
||||
presence,
|
||||
generation: 5,
|
||||
};
|
||||
const wire = encodeResponsePayload(ok);
|
||||
const text = new TextDecoder().decode(wire);
|
||||
const obj = JSON.parse(text);
|
||||
// Swap the chunkIds.
|
||||
const tmp = obj.chunks[0].chunkId;
|
||||
obj.chunks[0].chunkId = obj.chunks[1].chunkId;
|
||||
obj.chunks[1].chunkId = tmp;
|
||||
const tampered = new TextEncoder().encode(JSON.stringify(obj));
|
||||
try {
|
||||
decodeResponsePayload(tampered);
|
||||
throw new Error("expected unordered rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.UNORDERED_RESPONSE);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* DMP-13 .. DMP-17 — Verification, trust decay, sample-verification rate.
|
||||
*
|
||||
* `TrustMap` tracks `(peerId, fileId)` advertised/missing counts;
|
||||
* `shouldSkip` implements the probabilistic decay; `shouldSample` picks
|
||||
* the sampling rate based on current trust score; `terminate` pins the
|
||||
* score to `-Infinity` after a bad-signature event (DMP-14) without
|
||||
* touching unrelated files (DMP-16).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import {
|
||||
classify,
|
||||
probabilityOfSkip,
|
||||
SAMPLE_RATE_HEALTHY,
|
||||
SAMPLE_RATE_SUSPECT,
|
||||
sampleRate,
|
||||
shouldSample,
|
||||
shouldSkip,
|
||||
TRUST_DECAY_SLOPE,
|
||||
TRUST_MISS_RATE_FLOOR,
|
||||
TRUST_SUSPECT_THRESHOLD,
|
||||
TrustMap,
|
||||
} from "../src/index.js";
|
||||
|
||||
const FILE = "F".repeat(64);
|
||||
const OTHER = "G".repeat(64);
|
||||
|
||||
function rngWith(values: number[]): Rng {
|
||||
let i = 0;
|
||||
const next = (): number => {
|
||||
const v = values[i % values.length] as number;
|
||||
i++;
|
||||
return v;
|
||||
};
|
||||
return {
|
||||
nextUint32: () => Math.floor(next() * 2 ** 32),
|
||||
nextFloat: next,
|
||||
bytes: (n: number) =>
|
||||
new Uint8Array(n).map(() => Math.floor(next() * 256)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("DMP-13 — verification ordering", () => {
|
||||
it("chunk with bad signature is discarded and not committed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const trust = new TrustMap();
|
||||
trust.recordAdvertised("peer", FILE, 100);
|
||||
// Simulate the request scheduler discovering a bad signature:
|
||||
// it terminates trust, never commits the chunk.
|
||||
trust.terminate("peer", FILE);
|
||||
expect(trust.canServe("peer", FILE)).toBe(false);
|
||||
const entry = trust.get("peer", FILE);
|
||||
expect(entry.terminated).toBe(true);
|
||||
expect(entry.score).toBe(Number.NEGATIVE_INFINITY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-14 — bad-signature trust termination", () => {
|
||||
it("bad signature sets per-file trust to -Infinity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.terminate("peer", FILE);
|
||||
const e = t.get("peer", FILE);
|
||||
expect(e.score).toBe(Number.NEGATIVE_INFINITY);
|
||||
expect(probabilityOfSkip(e)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-15 — probabilistic skip on advertised-but-missing", () => {
|
||||
it("miss rate below floor keeps skip probability at zero", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", FILE, 100);
|
||||
t.recordMissing("peer", FILE, 4);
|
||||
const e = t.get("peer", FILE);
|
||||
expect(probabilityOfSkip(e)).toBe(0);
|
||||
});
|
||||
|
||||
it("peer at 30% miss rate skipped on 50% of requests", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", FILE, 100);
|
||||
t.recordMissing("peer", FILE, 30);
|
||||
const e = t.get("peer", FILE);
|
||||
// (0.30 - 0.05) * 20 = 5, capped to 1.0
|
||||
expect(probabilityOfSkip(e)).toBe(1);
|
||||
// At 7.5% miss: (0.075 - 0.05) * 20 = 0.5
|
||||
const t2 = new TrustMap();
|
||||
t2.recordAdvertised("p2", FILE, 1000);
|
||||
t2.recordMissing("p2", FILE, 75);
|
||||
expect(probabilityOfSkip(t2.get("p2", FILE))).toBeCloseTo(0.5, 4);
|
||||
expect(TRUST_MISS_RATE_FLOOR).toBe(0.05);
|
||||
expect(TRUST_DECAY_SLOPE).toBe(20);
|
||||
});
|
||||
|
||||
it("shouldSkip uses the injected Rng", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", FILE, 100);
|
||||
t.recordMissing("peer", FILE, 30);
|
||||
const e = t.get("peer", FILE);
|
||||
// Probability is capped at 1, so any RNG value < 1 returns true.
|
||||
expect(shouldSkip(e, rngWith([0.99]))).toBe(true);
|
||||
});
|
||||
|
||||
it("shouldSkip returns false for healthy peers", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", FILE, 100);
|
||||
const e = t.get("peer", FILE);
|
||||
expect(shouldSkip(e, rngWith([0.5]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-16 — trust score per (peerId, fileId)", () => {
|
||||
it("trust score is per-file; one bad file does not affect others", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.terminate("peer", FILE);
|
||||
t.recordAdvertised("peer", OTHER, 50);
|
||||
expect(t.canServe("peer", FILE)).toBe(false);
|
||||
expect(t.canServe("peer", OTHER)).toBe(true);
|
||||
expect(t.get("peer", OTHER).score).toBe(1);
|
||||
});
|
||||
|
||||
it("dropPeer cleans up only that peer's entries", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("a", FILE, 1);
|
||||
t.recordAdvertised("b", FILE, 1);
|
||||
t.dropPeer("a");
|
||||
expect(t.size).toBe(1);
|
||||
});
|
||||
|
||||
it("dropFile cleans up that file across peers", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("a", FILE, 1);
|
||||
t.recordAdvertised("a", OTHER, 1);
|
||||
t.dropFile(FILE);
|
||||
expect(t.size).toBe(1);
|
||||
});
|
||||
|
||||
it("counts can be enumerated", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("a", FILE, 5);
|
||||
const entries = [...t];
|
||||
expect(entries.length).toBe(1);
|
||||
const first = entries[0] as (typeof entries)[number];
|
||||
expect(first.advertised).toBe(5);
|
||||
});
|
||||
|
||||
it("rejects negative deltas", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
expect(() => t.recordAdvertised("a", FILE, -1)).toThrow();
|
||||
expect(() => t.recordMissing("a", FILE, -2)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-17 — adaptive sample-verification rate", () => {
|
||||
it("suspect peer sampled at 1-in-10; healthy peer at 1-in-100", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("good", FILE, 100);
|
||||
t.recordAdvertised("bad", FILE, 100);
|
||||
t.recordMissing("bad", FILE, 10);
|
||||
const good = t.get("good", FILE);
|
||||
const bad = t.get("bad", FILE);
|
||||
expect(classify(good)).toBe("healthy");
|
||||
expect(classify(bad)).toBe("suspect");
|
||||
expect(sampleRate(good)).toBe(SAMPLE_RATE_HEALTHY);
|
||||
expect(sampleRate(bad)).toBe(SAMPLE_RATE_SUSPECT);
|
||||
expect(TRUST_SUSPECT_THRESHOLD).toBe(0.95);
|
||||
});
|
||||
|
||||
it("terminated peer is never sampled", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.terminate("peer", FILE);
|
||||
const entry = t.get("peer", FILE);
|
||||
expect(classify(entry)).toBe("terminated");
|
||||
expect(sampleRate(entry)).toBe(Number.POSITIVE_INFINITY);
|
||||
expect(shouldSample(entry, rngWith([0]))).toBe(false);
|
||||
});
|
||||
|
||||
it("shouldSample respects the rng draw and rate denominator", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", FILE, 100);
|
||||
const entry = t.get("peer", FILE);
|
||||
// Healthy: 1 in 100. RNG = 0.005 < 0.01 → sample.
|
||||
expect(shouldSample(entry, rngWith([0.005]))).toBe(true);
|
||||
// RNG = 0.5 → no sample.
|
||||
expect(shouldSample(entry, rngWith([0.5]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP — terminated entries", () => {
|
||||
it("recordAdvertised / recordMissing are no-ops once terminated", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.terminate("peer", FILE);
|
||||
t.recordAdvertised("peer", FILE, 50);
|
||||
t.recordMissing("peer", FILE, 10);
|
||||
const e = t.get("peer", FILE);
|
||||
expect(e.advertised).toBe(0);
|
||||
expect(e.missing).toBe(0);
|
||||
expect(e.score).toBe(Number.NEGATIVE_INFINITY);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
39
packages/networking/drive/drive-mesh-protocol/vite.config.ts
Normal file
39
packages/networking/drive/drive-mesh-protocol/vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/drive-mesh-protocol",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/bitpacker",
|
||||
"@suaveplan/bitpacker-compressed",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig();
|
||||
158
packages/networking/drive/drive-mesh/README.md
Normal file
158
packages/networking/drive/drive-mesh/README.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# @suaveplan/drive-mesh - P2P mesh orchestrator for the drive chunks plugin. Peer registry, availability gossip, parallel-pull scheduler with racing window, server fallback, and bad-sig denylist. Consumes ChunkSource (drive-plugin-chunks) and DriveTransport (drive-transport-*).
|
||||
|
||||
## Summary
|
||||
|
||||
P2P mesh orchestrator over the drive chunks plugin. Consumes
|
||||
`ChunkSource` (from `@suaveplan/drive-plugin-chunks`) and one or more
|
||||
`DriveTransport` implementations (HTTPS, WebRTC, libp2p, in-memory).
|
||||
Provides parallel chunk pull with a first-byte racing window, peer
|
||||
scoring, bad-signature denylist, availability bitset gossip, and
|
||||
server fallback.
|
||||
|
||||
The package is universal: zero browser- or React-specific code.
|
||||
It composes on the server (Node) for vault / CDN sources, in the
|
||||
browser for distributed clients, and inside test harnesses with
|
||||
in-memory transports.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the frozen error-code registry for `@suaveplan/drive-mesh` via `defineCodes("DRIVE_MESH", …)` from `@suaveplan/…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the frozen error-code registry for `@suaveplan/drive-mesh` via `defineCodes("DRIVE_MESH", …)` from `@suaveplan/…
|
||||
- [gossip-engine](./src/gossip/gossip-engine/gossip-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [gossip-engine](./src/gossip/gossip-engine/gossip-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [gossip-engine](./src/gossip/gossip-engine/gossip-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_- [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the frozen error-code registry for `@suaveplan/drive-mesh` via `defineCodes("DRIVE_MESH", …)` from `@suaveplan/…
|
||||
- [gossip-engine](./src/gossip/gossip-engine/gossip-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_- [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the frozen error-code registry for `@suaveplan/drive-mesh` via `defineCodes("DRIVE_MESH", …)` from `@suaveplan/…
|
||||
- [gossip-engine](./src/gossip/gossip-engine/gossip-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_- [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the frozen error-code registry for `@suaveplan/drive-mesh` via `defineCodes("DRIVE_MESH", …)` from `@suaveplan/…
|
||||
- [gossip-engine](./src/gossip/gossip-engine/gossip-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_ - [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_ - [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_ - [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_ - [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the frozen error-code registry for `@suaveplan/drive-mesh` via `defineCodes("DRIVE_MESH", …)` from `@suaveplan/…
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { DriveMesh } from "@suaveplan/drive-mesh";
|
||||
|
||||
const mesh = new DriveMesh({
|
||||
chunks, // ChunkSource from chunksPlugin
|
||||
transports: [httpsTransport], // any DriveTransport-compatible impl
|
||||
serverPubKey, // server's ed25519 public key
|
||||
serverFallback: { url: "https://cdn.example.com/chunks", transport: "https" },
|
||||
});
|
||||
await mesh.start();
|
||||
const chunkBytes = await mesh.requestChunk(fileIdHex, 0);
|
||||
await mesh.stop();
|
||||
```
|
||||
|
||||
## Layering
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ application (vault / drive) │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ @suaveplan/drive-mesh (this package) │
|
||||
│ peer registry · gossip · racing puller · server fallback │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ @suaveplan/drive-mesh-protocol (envelopes + bitsets) │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ @suaveplan/drive-plugin-chunks (sig-verify, manifest) │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ DriveTransport implementations (HTTPS / WebRTC / libp2p) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`DriveMesh` is the only public class most callers construct. The four
|
||||
subsystems (`PeerRegistry`, `GossipEngine`, `ParallelPuller`,
|
||||
`ServerFallback`) are exported under subpaths so advanced consumers
|
||||
can compose them directly.
|
||||
|
||||
## Subpath table
|
||||
|
||||
| Subpath | What |
|
||||
| ---------------------------------------- | ----------------------------------- |
|
||||
| `@suaveplan/drive-mesh` | Root barrel — `DriveMesh` + types. |
|
||||
| `@suaveplan/drive-mesh/mesh/drive-mesh` | `DriveMesh` class. |
|
||||
| `@suaveplan/drive-mesh/peer-registry/peer-registry` | `PeerRegistry`. |
|
||||
| `@suaveplan/drive-mesh/gossip/gossip-engine` | `GossipEngine`. |
|
||||
| `@suaveplan/drive-mesh/scheduler/parallel-puller` | `ParallelPuller`. |
|
||||
| `@suaveplan/drive-mesh/server-fallback/server-fallback` | `ServerFallback`. |
|
||||
| `@suaveplan/drive-mesh/types` | All public types. |
|
||||
| `@suaveplan/drive-mesh/schemas` | Zod schemas. |
|
||||
| `@suaveplan/drive-mesh/errors` | `DRIVE_MESH_CODES`, `DriveMeshError`. |
|
||||
|
||||
## Trust model
|
||||
|
||||
The server's ed25519 public key (`serverPubKey`) is the only root of
|
||||
trust. Every chunk returned by a peer or the fallback URL must pass
|
||||
`verifyChunkSig` against this key before drive-mesh resolves its
|
||||
`requestChunk` promise. A peer that ever returns a chunk with an
|
||||
invalid signature is moved to a denylist — `denylistMode: "session"`
|
||||
(default) clears the list on `mesh.stop()`; `"persistent"` survives
|
||||
stops.
|
||||
|
||||
Mesh never holds file decryption keys. Plaintext only ever materialises
|
||||
inside the chunks plugin's decrypt pipeline, which is keyed by the
|
||||
recipient's per-vault key.
|
||||
|
||||
## Scoring formula
|
||||
|
||||
`PeerRegistry` ranks peers per `(fileId, chunkId)` using a weighted
|
||||
sum:
|
||||
|
||||
```text
|
||||
score = 2 · (100 / (rttMs + 1))
|
||||
+ 3 · successRate
|
||||
− 5 · errorRate
|
||||
```
|
||||
|
||||
- `rttMs` is an EMA (α = 0.3) of observed chunk-fetch wall time.
|
||||
- `successRate` and `errorRate` are over the lifetime of the registry
|
||||
entry. Peers with no observations start at `successRate = 0.5`
|
||||
(neutral) and `errorRate = 0`.
|
||||
- Bad-sig peers are pinned at `-Infinity` so they never appear in
|
||||
`rank()` output once denied.
|
||||
|
||||
## OpenTelemetry metrics
|
||||
|
||||
The mesh emits the following events; consumers wire them to whichever
|
||||
OTel meter they prefer. Drive-mesh stays metric-emitter-agnostic so it
|
||||
does not pin a meter provider on universal consumers.
|
||||
|
||||
| Event | Suggested metric | Type |
|
||||
| ----------------- | -------------------------------------------------- | ----------- |
|
||||
| `chunk:served` | `drive.mesh.chunks_served_total{outcome="peer"}` | counter |
|
||||
| `chunk:fallback` | `drive.mesh.chunks_served_total{outcome="fallback"}` | counter |
|
||||
| `chunk:failed` | `drive.mesh.chunks_served_total{outcome="fail"}` | counter |
|
||||
| `chunk:served` | `drive.mesh.chunk_fetch_ms{outcome="peer"}` | histogram |
|
||||
| `chunk:fallback` | `drive.mesh.chunk_fetch_ms{outcome="fallback"}` | histogram |
|
||||
| `peer:joined` | `drive.mesh.peers_admitted_total` | counter |
|
||||
| `peer:denied` | `drive.mesh.peers_denied_total{reason}` | counter |
|
||||
| `gossip:sent` | `drive.mesh.gossip_size_bytes` | histogram |
|
||||
|
||||
## Error codes
|
||||
|
||||
| Code | When |
|
||||
| ----------------------------- | ----------------------------------------------- |
|
||||
| `DRIVE_MESH.INVALID_OPTIONS` | Construction or `fileId` shape rejected. |
|
||||
| `DRIVE_MESH.NOT_STARTED` | `requestChunk` before `start()`. |
|
||||
| `DRIVE_MESH.CHUNK_UNAVAILABLE`| No candidate peers and no fallback configured. |
|
||||
| `DRIVE_MESH.SIG_VERIFY_FAILED`| Winning peer returned a chunk with bad sig. |
|
||||
| `DRIVE_MESH.ALL_PEERS_FAILED` | Every candidate failed; no fallback configured. |
|
||||
| `DRIVE_MESH.FALLBACK_FAILED` | Peers + fallback both failed. |
|
||||
| `DRIVE_MESH.INVALID_BITSET` | `advertise` got bad `totalChunks`. |
|
||||
| `DRIVE_MESH.TRANSPORT_FAILED` | Single-attempt transport failure (internal). |
|
||||
| `DRIVE_MESH.PEER_DENIED` | Peer on denylist (manual operations). |
|
||||
| `DRIVE_MESH.ABORTED` | Caller-supplied AbortSignal aborted. |
|
||||
|
||||
## License
|
||||
|
||||
MIT © Suaveplan
|
||||
|
||||
116
packages/networking/drive/drive-mesh/package.json
Normal file
116
packages/networking/drive/drive-mesh/package.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"name": "@suaveplan/drive-mesh",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"browser": "./dist/index.js",
|
||||
"description": "P2P mesh orchestrator for the drive chunks plugin. Peer registry, availability gossip, parallel-pull scheduler with racing window, server fallback, and bad-sig denylist. Consumes ChunkSource (drive-plugin-chunks) and DriveTransport (drive-transport-*).",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./mesh/drive-mesh": {
|
||||
"import": {
|
||||
"types": "./dist/mesh/drive-mesh/drive-mesh.d.ts",
|
||||
"default": "./dist/mesh/drive-mesh/drive-mesh.js"
|
||||
}
|
||||
},
|
||||
"./peer-registry/peer-registry": {
|
||||
"import": {
|
||||
"types": "./dist/peer-registry/peer-registry/peer-registry.d.ts",
|
||||
"default": "./dist/peer-registry/peer-registry/peer-registry.js"
|
||||
}
|
||||
},
|
||||
"./gossip/gossip-engine": {
|
||||
"import": {
|
||||
"types": "./dist/gossip/gossip-engine/gossip-engine.d.ts",
|
||||
"default": "./dist/gossip/gossip-engine/gossip-engine.js"
|
||||
}
|
||||
},
|
||||
"./scheduler/parallel-puller": {
|
||||
"import": {
|
||||
"types": "./dist/scheduler/parallel-puller/parallel-puller.d.ts",
|
||||
"default": "./dist/scheduler/parallel-puller/parallel-puller.js"
|
||||
}
|
||||
},
|
||||
"./server-fallback/server-fallback": {
|
||||
"import": {
|
||||
"types": "./dist/server-fallback/server-fallback/server-fallback.d.ts",
|
||||
"default": "./dist/server-fallback/server-fallback/server-fallback.js"
|
||||
}
|
||||
},
|
||||
"./types": {
|
||||
"import": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"default": "./dist/types/index.js"
|
||||
}
|
||||
},
|
||||
"./schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/index.d.ts",
|
||||
"default": "./dist/schemas/index.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/crypto-curve": "workspace:^",
|
||||
"@suaveplan/drive-mesh-protocol": "workspace:^",
|
||||
"@suaveplan/drive-plugin-chunks": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# `error-codes.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the frozen error-code registry for `@suaveplan/drive-mesh` via
|
||||
`defineCodes("DRIVE_MESH", …)` from `@suaveplan/error`. Every failure
|
||||
that crosses a public API boundary in the mesh — peer denial, signature
|
||||
verification failure, transport breakage, fallback exhaustion — surfaces
|
||||
as a `DriveMeshError` whose `code` is one of the literals in
|
||||
`DRIVE_MESH_CODES`. Callers pattern-match on `error.code` rather than
|
||||
parsing free-form messages, which keeps log-shipping, alerting, and
|
||||
retry policies stable across releases.
|
||||
|
||||
## Features
|
||||
|
||||
- Namespaced codes (`DRIVE_MESH.CHUNK_UNAVAILABLE`, etc).
|
||||
- Frozen at module load — additions go through OpenSpec.
|
||||
- `DriveMeshError` widens the protected base constructor to public so
|
||||
feature modules can throw directly without subclassing further.
|
||||
- Codes cover the full lifecycle: peer selection, signature verify,
|
||||
transport failure, server fallback, configuration validation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DRIVE_MESH_CODES, DriveMeshError } from "@suaveplan/drive-mesh/errors";
|
||||
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.SIG_VERIFY_FAILED,
|
||||
"chunk signature did not validate against server pubkey"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
DRIVE_MESH_CODES,
|
||||
DriveMeshError,
|
||||
} from "@suaveplan/drive-mesh/errors";
|
||||
|
||||
try {
|
||||
await mesh.requestChunk(fileId, chunkId);
|
||||
} catch (err) {
|
||||
if (err instanceof DriveMeshError) {
|
||||
switch (err.code) {
|
||||
case DRIVE_MESH_CODES.ALL_PEERS_FAILED:
|
||||
// every peer attempted; falling back to server
|
||||
break;
|
||||
case DRIVE_MESH_CODES.SIG_VERIFY_FAILED:
|
||||
// the responsible peer is now denylisted
|
||||
break;
|
||||
case DRIVE_MESH_CODES.ABORTED:
|
||||
// caller aborted via AbortSignal
|
||||
break;
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DRIVE_MESH_CODES` — `Readonly<Record<Reason, "DRIVE_MESH.<Reason>">>`
|
||||
containing: `CHUNK_UNAVAILABLE`, `ALL_PEERS_FAILED`,
|
||||
`SIG_VERIFY_FAILED`, `TRANSPORT_FAILED`, `FALLBACK_FAILED`,
|
||||
`INVALID_BITSET`, `PEER_DENIED`, `INVALID_OPTIONS`, `ABORTED`,
|
||||
`NOT_STARTED`.
|
||||
- `DriveMeshErrorCode` — type alias for the union of values above.
|
||||
- `DriveMeshError(code, message, options?)` — concrete error class
|
||||
extending `SuaveplanError<DriveMeshErrorCode>`; supports the standard
|
||||
`cause` chain via `SuaveplanErrorOptions`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is closed: each new failure mode must be added via an
|
||||
OpenSpec change and corresponding spec requirement. Because
|
||||
`defineCodes` returns a frozen record, accidental in-process mutation
|
||||
fails fast in development.
|
||||
|
||||
`DriveMeshError`'s constructor is a thin pass-through that widens the
|
||||
base's `protected` constructor to `public`. The class exists so
|
||||
`instanceof` checks distinguish mesh errors from generic
|
||||
`SuaveplanError`s thrown by sibling packages (e.g.
|
||||
`ChunksError`/`DmpError`). Causes propagated from those packages remain
|
||||
inspectable through the standard `error.cause` chain.
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DRIVE_MESH_CODES,
|
||||
DriveMeshError,
|
||||
type DriveMeshErrorCode,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("DRIVE_MESH_CODES", () => {
|
||||
it("contains every expected code", { timeout: 5000 }, () => {
|
||||
const expected = [
|
||||
"DRIVE_MESH.CHUNK_UNAVAILABLE",
|
||||
"DRIVE_MESH.ALL_PEERS_FAILED",
|
||||
"DRIVE_MESH.SIG_VERIFY_FAILED",
|
||||
"DRIVE_MESH.TRANSPORT_FAILED",
|
||||
"DRIVE_MESH.FALLBACK_FAILED",
|
||||
"DRIVE_MESH.INVALID_BITSET",
|
||||
"DRIVE_MESH.PEER_DENIED",
|
||||
"DRIVE_MESH.INVALID_OPTIONS",
|
||||
"DRIVE_MESH.ABORTED",
|
||||
"DRIVE_MESH.NOT_STARTED",
|
||||
];
|
||||
expect(new Set(Object.values(DRIVE_MESH_CODES))).toEqual(
|
||||
new Set(expected)
|
||||
);
|
||||
});
|
||||
|
||||
it("is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(DRIVE_MESH_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DriveMeshError", () => {
|
||||
it("carries code, message, and domain", { timeout: 5000 }, () => {
|
||||
const err = new DriveMeshError(
|
||||
DRIVE_MESH_CODES.ABORTED,
|
||||
"aborted by caller"
|
||||
);
|
||||
expect(err).toBeInstanceOf(DriveMeshError);
|
||||
expect(err.code).toBe(DRIVE_MESH_CODES.ABORTED);
|
||||
expect(err.message).toBe("aborted by caller");
|
||||
expect(err.domain).toBe("DRIVE_MESH");
|
||||
});
|
||||
|
||||
it("preserves cause chain", { timeout: 5000 }, () => {
|
||||
const cause = new Error("underlying");
|
||||
const err = new DriveMeshError(
|
||||
DRIVE_MESH_CODES.SIG_VERIFY_FAILED,
|
||||
"bad",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("typed code union resolves at compile time", { timeout: 5000 }, () => {
|
||||
const code: DriveMeshErrorCode = DRIVE_MESH_CODES.NOT_STARTED;
|
||||
expect(code).toBe("DRIVE_MESH.NOT_STARTED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Error codes for `@suaveplan/drive-mesh`.
|
||||
*
|
||||
* Domain-prefixed via `defineCodes("DRIVE_MESH", …)` so callers can
|
||||
* pattern-match `error.code` without parsing strings.
|
||||
*/
|
||||
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
export const DRIVE_MESH_CODES = defineCodes("DRIVE_MESH", [
|
||||
"CHUNK_UNAVAILABLE",
|
||||
"ALL_PEERS_FAILED",
|
||||
"SIG_VERIFY_FAILED",
|
||||
"TRANSPORT_FAILED",
|
||||
"FALLBACK_FAILED",
|
||||
"INVALID_BITSET",
|
||||
"PEER_DENIED",
|
||||
"INVALID_OPTIONS",
|
||||
"ABORTED",
|
||||
"NOT_STARTED",
|
||||
] as const);
|
||||
|
||||
export type DriveMeshErrorCode =
|
||||
(typeof DRIVE_MESH_CODES)[keyof typeof DRIVE_MESH_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for `@suaveplan/drive-mesh`. The constructor
|
||||
* normalises the `domain` discriminator field at every instantiation
|
||||
* so `instanceof DriveMeshError` cannot collapse against an unrelated
|
||||
* `SuaveplanError` subclass. The discriminator also gives tests an
|
||||
* easy way to assert the namespace without parsing the code string.
|
||||
*/
|
||||
export class DriveMeshError extends SuaveplanError<DriveMeshErrorCode> {
|
||||
public readonly domain: "DRIVE_MESH";
|
||||
|
||||
public constructor(
|
||||
code: DriveMeshErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.domain = "DRIVE_MESH";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { DriveMeshErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
DRIVE_MESH_CODES,
|
||||
DriveMeshError,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,116 @@
|
||||
# `gossip-engine.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`GossipEngine` runs the bitset-gossip loop drive-mesh uses to keep
|
||||
peers informed of local chunk availability. Two responsibilities:
|
||||
(1) periodically push local availability to every connected peer,
|
||||
and (2) accept inbound advertisements and forward them to the
|
||||
`PeerRegistry`.
|
||||
|
||||
The engine is transport-agnostic — it iterates the configured
|
||||
`DriveTransport[]` and uses each transport's `advertise` /
|
||||
`onAdvertisement` surface. Gossip is intentionally best-effort: a
|
||||
failed `advertise` call is swallowed and retried on the next tick.
|
||||
Chunk-fetch failures, by contrast, drive the scoring + denylist
|
||||
inside the registry.
|
||||
|
||||
## Features
|
||||
|
||||
- Periodic outbound tick (default 5_000 ms, configurable).
|
||||
- Inbound handler wired automatically on `start`.
|
||||
- `tick()` exposed for deterministic testing.
|
||||
- Cleanup on `stop` removes every subscription and clears the timer.
|
||||
- Pluggable `advertSource` so the mesh decides what bytes go on the
|
||||
wire (the chunks plugin's `hasChunks` result + framing).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { GossipEngine } from "@suaveplan/drive-mesh/gossip/gossip-engine";
|
||||
|
||||
const gossip = new GossipEngine({
|
||||
registry,
|
||||
transports: [httpsTransport],
|
||||
advertSource: fileId => buildAdvert(fileId),
|
||||
trackedFiles: () => chunks.knownFiles(),
|
||||
});
|
||||
gossip.start();
|
||||
// ...
|
||||
gossip.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { GossipEngine } from "@suaveplan/drive-mesh/gossip/gossip-engine";
|
||||
import { encodeFramedBitset } from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const gossip = new GossipEngine({
|
||||
registry,
|
||||
transports: [webrtcTransport, httpsTransport],
|
||||
intervalMs: 1_000,
|
||||
advertSource: async fileId => {
|
||||
const bits = await chunks.hasChunks(fromHex(fileId));
|
||||
const totalChunks = bits.length;
|
||||
const raw = boolsToBytes(bits);
|
||||
return {
|
||||
fileId,
|
||||
totalChunks,
|
||||
bitset: encodeFramedBitset(raw, totalChunks),
|
||||
};
|
||||
},
|
||||
trackedFiles: () => activeFiles,
|
||||
onAdvertiseSent: (peerId, advert) =>
|
||||
telemetry.emit("gossip:sent", { peerId, fileId: advert.fileId }),
|
||||
onAdvertiseReceived: (peerId, advert) =>
|
||||
telemetry.emit("gossip:received", { peerId, fileId: advert.fileId }),
|
||||
});
|
||||
|
||||
gossip.start();
|
||||
// Force-trigger a tick for an integration test:
|
||||
await gossip.tick();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class GossipEngine`:
|
||||
- `constructor(options: GossipEngineOptions)`.
|
||||
- `start(): void` — wires inbound subscriptions + schedules the
|
||||
outbound tick. Idempotent.
|
||||
- `stop(): void` — cancels the tick and detaches subscriptions.
|
||||
Idempotent.
|
||||
- `tick(): Promise<void>` — run a single outbound pass; intended
|
||||
for tests.
|
||||
- `GossipEngineOptions`:
|
||||
- `registry` — `PeerRegistry` instance.
|
||||
- `transports` — `readonly DriveTransport[]`.
|
||||
- `advertSource(fileId)` — returns the local advertisement or
|
||||
`undefined` when nothing to advertise.
|
||||
- `trackedFiles()` — generator of `fileId` hex strings.
|
||||
- `intervalMs?` — outbound tick period; default `5_000`.
|
||||
- `clock?` — defaults to `Date.now`-based clock.
|
||||
- `onAdvertiseSent?(peerId, advert)` — observability hook.
|
||||
- `onAdvertiseReceived?(peerId, advert)` — observability hook.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Outbound fan-out iterates every peer reported by every transport.
|
||||
Duplicate peers across transports receive duplicate advertisements,
|
||||
which is fine: receivers idempotently overwrite the bitset and the
|
||||
registry's `recordAvailability` is set-shaped.
|
||||
|
||||
The scheduling primitive is `setTimeout` rather than `setInterval` so
|
||||
overlapping ticks cannot occur — the next tick is only scheduled
|
||||
after the previous tick's promise resolves. `unref` is applied where
|
||||
available (Node) so the event loop can drain in tests; browsers do
|
||||
not implement `unref`, which is fine because they manage timers
|
||||
through page lifecycle instead.
|
||||
|
||||
Inbound advertisements flow through `addOrUpdate` (updates
|
||||
`lastSeen`) and `recordAvailability` (updates the bitset) so a peer
|
||||
sending its first advertisement is admitted to the registry without
|
||||
a separate `peer:joined` event being required.
|
||||
@@ -0,0 +1,226 @@
|
||||
import { encodeFramedBitset } from "@suaveplan/drive-mesh-protocol";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
Broker,
|
||||
FakeDriveTransport,
|
||||
} from "../../../tests/helpers/fake-transport.js";
|
||||
import { PeerRegistry } from "../../peer-registry/peer-registry/index.js";
|
||||
import type { ChunkAdvertisement } from "../../types/index.js";
|
||||
import { GossipEngine } from "./gossip-engine.js";
|
||||
|
||||
function bitset(bits: readonly number[], total: number): Uint8Array {
|
||||
const raw = new Uint8Array(Math.ceil(total / 8));
|
||||
for (const b of bits) raw[b >> 3] |= 1 << (b & 7);
|
||||
return encodeFramedBitset(raw, total);
|
||||
}
|
||||
|
||||
const FILE_ID = "a".repeat(64);
|
||||
|
||||
describe("GossipEngine", () => {
|
||||
it("starts and stops idempotently", { timeout: 5000 }, () => {
|
||||
const broker = new Broker();
|
||||
const transport = new FakeDriveTransport({
|
||||
broker,
|
||||
localPeerId: "self",
|
||||
});
|
||||
const registry = new PeerRegistry();
|
||||
const engine = new GossipEngine({
|
||||
registry,
|
||||
transports: [transport],
|
||||
advertSource: () => undefined,
|
||||
trackedFiles: () => [],
|
||||
intervalMs: 60_000,
|
||||
});
|
||||
engine.start();
|
||||
engine.start(); // idempotent
|
||||
engine.stop();
|
||||
engine.stop(); // idempotent
|
||||
transport.dispose();
|
||||
});
|
||||
|
||||
it("inbound advertisement updates registry + fires hook", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new Broker();
|
||||
const transport = new FakeDriveTransport({
|
||||
broker,
|
||||
localPeerId: "self",
|
||||
});
|
||||
const registry = new PeerRegistry();
|
||||
const received: ChunkAdvertisement[] = [];
|
||||
const engine = new GossipEngine({
|
||||
registry,
|
||||
transports: [transport],
|
||||
advertSource: () => undefined,
|
||||
trackedFiles: () => [],
|
||||
intervalMs: 60_000,
|
||||
onAdvertiseReceived: (_peerId, advert) => received.push(advert),
|
||||
});
|
||||
engine.start();
|
||||
const advert: ChunkAdvertisement = {
|
||||
fileId: FILE_ID,
|
||||
bitset: bitset([0, 1], 8),
|
||||
totalChunks: 8,
|
||||
};
|
||||
for (const handler of transport.handlers) handler("peerB", advert);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(registry.get("peerB")?.availability.get(FILE_ID)).toEqual(
|
||||
advert.bitset
|
||||
);
|
||||
engine.stop();
|
||||
transport.dispose();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
it("tick pushes outbound advertisements over every transport", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new Broker();
|
||||
const local = new FakeDriveTransport({ broker, localPeerId: "self" });
|
||||
const remote = new FakeDriveTransport({ broker, localPeerId: "peerR" });
|
||||
local.addKnownPeer("peerR");
|
||||
const registry = new PeerRegistry();
|
||||
const seenOnRemote: ChunkAdvertisement[] = [];
|
||||
remote.onAdvertisement((_p, advert) => seenOnRemote.push(advert));
|
||||
const sent: ChunkAdvertisement[] = [];
|
||||
const engine = new GossipEngine({
|
||||
registry,
|
||||
transports: [local],
|
||||
advertSource: () => ({
|
||||
fileId: FILE_ID,
|
||||
bitset: bitset([0], 8),
|
||||
totalChunks: 8,
|
||||
}),
|
||||
trackedFiles: () => [FILE_ID],
|
||||
intervalMs: 60_000,
|
||||
onAdvertiseSent: (_p, advert) => sent.push(advert),
|
||||
});
|
||||
engine.start();
|
||||
await engine.tick();
|
||||
expect(sent.length).toBeGreaterThan(0);
|
||||
expect(seenOnRemote.length).toBeGreaterThan(0);
|
||||
engine.stop();
|
||||
local.dispose();
|
||||
remote.dispose();
|
||||
});
|
||||
|
||||
it("tick skips files whose advertSource returns undefined", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new Broker();
|
||||
const transport = new FakeDriveTransport({
|
||||
broker,
|
||||
localPeerId: "self",
|
||||
});
|
||||
transport.addKnownPeer("peerR");
|
||||
const sent: ChunkAdvertisement[] = [];
|
||||
const engine = new GossipEngine({
|
||||
registry: new PeerRegistry(),
|
||||
transports: [transport],
|
||||
advertSource: () => undefined,
|
||||
trackedFiles: () => [FILE_ID],
|
||||
intervalMs: 60_000,
|
||||
onAdvertiseSent: (_p, advert) => sent.push(advert),
|
||||
});
|
||||
engine.start();
|
||||
await engine.tick();
|
||||
expect(sent).toEqual([]);
|
||||
engine.stop();
|
||||
transport.dispose();
|
||||
});
|
||||
|
||||
it("swallows per-peer advertise failures", { timeout: 5000 }, async () => {
|
||||
const broker = new Broker();
|
||||
const transport = new FakeDriveTransport({
|
||||
broker,
|
||||
localPeerId: "self",
|
||||
});
|
||||
transport.addKnownPeer("peerR");
|
||||
// Replace advertise with a throwing function
|
||||
const original = transport.advertise.bind(transport);
|
||||
transport.advertise = async (): Promise<void> => {
|
||||
throw new Error("nope");
|
||||
};
|
||||
const engine = new GossipEngine({
|
||||
registry: new PeerRegistry(),
|
||||
transports: [transport],
|
||||
advertSource: () => ({
|
||||
fileId: FILE_ID,
|
||||
bitset: bitset([0], 8),
|
||||
totalChunks: 8,
|
||||
}),
|
||||
trackedFiles: () => [FILE_ID],
|
||||
intervalMs: 60_000,
|
||||
});
|
||||
engine.start();
|
||||
await expect(engine.tick()).resolves.toBeUndefined();
|
||||
// Restore for cleanup
|
||||
transport.advertise = original;
|
||||
engine.stop();
|
||||
transport.dispose();
|
||||
});
|
||||
|
||||
it("#schedule early-exits when stopped between ticks", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new Broker();
|
||||
const transport = new FakeDriveTransport({
|
||||
broker,
|
||||
localPeerId: "self",
|
||||
});
|
||||
transport.addKnownPeer("peerR");
|
||||
const engine = new GossipEngine({
|
||||
registry: new PeerRegistry(),
|
||||
transports: [transport],
|
||||
advertSource: async (fileId) => {
|
||||
// small await to ensure the in-flight tick straddles the
|
||||
// stop() call
|
||||
await new Promise<void>((r) => setTimeout(r, 5));
|
||||
return {
|
||||
fileId,
|
||||
bitset: bitset([0], 8),
|
||||
totalChunks: 8,
|
||||
};
|
||||
},
|
||||
trackedFiles: () => [FILE_ID],
|
||||
intervalMs: 1,
|
||||
});
|
||||
engine.start();
|
||||
// Wait for the first tick to begin
|
||||
await new Promise<void>((r) => setTimeout(r, 2));
|
||||
engine.stop();
|
||||
// Wait for the in-flight tick to settle (calls #schedule which
|
||||
// should early-return because #running is now false)
|
||||
await new Promise<void>((r) => setTimeout(r, 25));
|
||||
transport.dispose();
|
||||
});
|
||||
|
||||
it("schedules tick when started without explicit tick call", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new Broker();
|
||||
const transport = new FakeDriveTransport({
|
||||
broker,
|
||||
localPeerId: "self",
|
||||
});
|
||||
transport.addKnownPeer("peerR");
|
||||
const sent: ChunkAdvertisement[] = [];
|
||||
const engine = new GossipEngine({
|
||||
registry: new PeerRegistry(),
|
||||
transports: [transport],
|
||||
advertSource: () => ({
|
||||
fileId: FILE_ID,
|
||||
bitset: bitset([0], 8),
|
||||
totalChunks: 8,
|
||||
}),
|
||||
trackedFiles: () => [FILE_ID],
|
||||
intervalMs: 5,
|
||||
onAdvertiseSent: (_p, advert) => sent.push(advert),
|
||||
});
|
||||
engine.start();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||
engine.stop();
|
||||
transport.dispose();
|
||||
expect(sent.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* `GossipEngine` — periodic advertisement of local availability +
|
||||
* inbound-advertisement intake.
|
||||
*
|
||||
* Outbound: every `intervalMs` (default 5_000) the engine asks the
|
||||
* configured `peerListing` for a snapshot, then for every known
|
||||
* `fileId` it pushes a `(fileId, framedBitset)` advertisement to every
|
||||
* peer over every transport.
|
||||
*
|
||||
* Inbound: every transport's `onAdvertisement` is wired to the
|
||||
* registry so file availability is tracked without separate fan-in.
|
||||
*/
|
||||
|
||||
import type { Clock, PeerId } from "@suaveplan/types";
|
||||
import type { PeerRegistry } from "../../peer-registry/peer-registry/index.js";
|
||||
import type {
|
||||
AdvertisementHandler,
|
||||
ChunkAdvertisement,
|
||||
DriveTransport,
|
||||
Unsubscribe,
|
||||
} from "../../types/index.js";
|
||||
|
||||
export type AdvertSink = (
|
||||
fileId: string
|
||||
) => Promise<ChunkAdvertisement | undefined> | ChunkAdvertisement | undefined;
|
||||
|
||||
export interface GossipEngineOptions {
|
||||
readonly registry: PeerRegistry;
|
||||
readonly transports: readonly DriveTransport[];
|
||||
/** Returns the current local advertisement for a fileId. */
|
||||
readonly advertSource: AdvertSink;
|
||||
/** Files the engine should periodically advertise. */
|
||||
readonly trackedFiles: () => Iterable<string>;
|
||||
/** Period in milliseconds. Defaults to 5_000. */
|
||||
readonly intervalMs?: number;
|
||||
readonly clock?: Clock;
|
||||
/** Called whenever an outbound advertisement is sent. */
|
||||
readonly onAdvertiseSent?: (
|
||||
peerId: PeerId,
|
||||
advert: ChunkAdvertisement
|
||||
) => void;
|
||||
/** Called whenever an inbound advertisement is received. */
|
||||
readonly onAdvertiseReceived?: (
|
||||
peerId: PeerId,
|
||||
advert: ChunkAdvertisement
|
||||
) => void;
|
||||
}
|
||||
|
||||
export class GossipEngine {
|
||||
readonly #registry: PeerRegistry;
|
||||
readonly #transports: readonly DriveTransport[];
|
||||
readonly #advertSource: AdvertSink;
|
||||
readonly #trackedFiles: () => Iterable<string>;
|
||||
readonly #intervalMs: number;
|
||||
readonly #clock: Clock;
|
||||
readonly #onSent: GossipEngineOptions["onAdvertiseSent"];
|
||||
readonly #onRecv: GossipEngineOptions["onAdvertiseReceived"];
|
||||
readonly #subscriptions: Unsubscribe[] = [];
|
||||
#timer: ReturnType<typeof setTimeout> | null = null;
|
||||
#running = false;
|
||||
|
||||
public constructor(options: GossipEngineOptions) {
|
||||
this.#registry = options.registry;
|
||||
this.#transports = options.transports;
|
||||
this.#advertSource = options.advertSource;
|
||||
this.#trackedFiles = options.trackedFiles;
|
||||
this.#intervalMs = options.intervalMs ?? 5_000;
|
||||
this.#clock = options.clock ?? (() => Date.now());
|
||||
this.#onSent = options.onAdvertiseSent;
|
||||
this.#onRecv = options.onAdvertiseReceived;
|
||||
}
|
||||
|
||||
/** Begin listening for inbound advertisements + schedule outbound. */
|
||||
public start(): void {
|
||||
if (this.#running) return;
|
||||
this.#running = true;
|
||||
for (const transport of this.#transports) {
|
||||
const handler: AdvertisementHandler = (peerId, advert) => {
|
||||
this.#handleInbound(peerId, advert);
|
||||
};
|
||||
this.#subscriptions.push(transport.onAdvertisement(handler));
|
||||
}
|
||||
this.#schedule();
|
||||
}
|
||||
|
||||
/** Stop the periodic timer and detach inbound subscriptions. */
|
||||
public stop(): void {
|
||||
if (!this.#running) return;
|
||||
this.#running = false;
|
||||
// `start()` always schedules a timer before returning, so the
|
||||
// handle is guaranteed to be set whenever `#running === true`.
|
||||
const timer = this.#timer as ReturnType<typeof setTimeout>;
|
||||
clearTimeout(timer);
|
||||
this.#timer = null;
|
||||
for (const unsub of this.#subscriptions) unsub();
|
||||
this.#subscriptions.length = 0;
|
||||
}
|
||||
|
||||
/** Run a single advertisement pass — exposed for tests. */
|
||||
public async tick(): Promise<void> {
|
||||
for (const fileId of this.#trackedFiles()) {
|
||||
const advert = await this.#advertSource(fileId);
|
||||
if (advert === undefined) continue;
|
||||
await this.#broadcast(advert);
|
||||
}
|
||||
}
|
||||
|
||||
async #broadcast(advert: ChunkAdvertisement): Promise<void> {
|
||||
for (const transport of this.#transports) {
|
||||
for (const peerId of transport.peers()) {
|
||||
try {
|
||||
await transport.advertise(peerId, advert);
|
||||
this.#onSent?.(peerId, advert);
|
||||
} catch {
|
||||
// gossip is best-effort; failures bubble to the peer
|
||||
// registry only when a chunk request actually fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#handleInbound(peerId: PeerId, advert: ChunkAdvertisement): void {
|
||||
this.#registry.addOrUpdate(peerId, { lastSeen: this.#clock() });
|
||||
this.#registry.recordAvailability(peerId, advert.fileId, advert.bitset);
|
||||
this.#onRecv?.(peerId, advert);
|
||||
}
|
||||
|
||||
#schedule(): void {
|
||||
if (!this.#running) return;
|
||||
this.#timer = setTimeout(() => {
|
||||
void this.tick().finally(() => this.#schedule());
|
||||
}, this.#intervalMs);
|
||||
// Keep the Node / Bun event loop drainable in tests. In
|
||||
// browser environments setTimeout returns a numeric handle
|
||||
// with no `unref`; we tolerate both shapes by calling through
|
||||
// optional access.
|
||||
(this.#timer as unknown as { unref?: () => void }).unref?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type AdvertSink,
|
||||
GossipEngine,
|
||||
type GossipEngineOptions,
|
||||
} from "./gossip-engine.js";
|
||||
52
packages/networking/drive/drive-mesh/src/index.ts
Normal file
52
packages/networking/drive/drive-mesh/src/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Side-effect import ensures DRIVE_MESH_CODES is registered for
|
||||
// subpath consumers that import only from a feature path.
|
||||
import "./error-codes/index.js";
|
||||
|
||||
export {
|
||||
DRIVE_MESH_CODES,
|
||||
DriveMeshError,
|
||||
type DriveMeshErrorCode,
|
||||
} from "./error-codes/index.js";
|
||||
export type {
|
||||
AdvertSink,
|
||||
GossipEngineOptions,
|
||||
} from "./gossip/gossip-engine/index.js";
|
||||
export { GossipEngine } from "./gossip/gossip-engine/index.js";
|
||||
export { DriveMesh } from "./mesh/drive-mesh/index.js";
|
||||
export type { PeerRegistryOptions } from "./peer-registry/peer-registry/index.js";
|
||||
export { PeerRegistry } from "./peer-registry/peer-registry/index.js";
|
||||
export type { ParallelPullerOptions } from "./scheduler/parallel-puller/index.js";
|
||||
export { ParallelPuller } from "./scheduler/parallel-puller/index.js";
|
||||
export {
|
||||
AdvertisementSchema,
|
||||
DriveMeshOptionsSchema,
|
||||
ServerFallbackConfigSchema,
|
||||
} from "./schemas/index.js";
|
||||
export type {
|
||||
FetchFn,
|
||||
ServerFallbackOptions,
|
||||
} from "./server-fallback/server-fallback/index.js";
|
||||
export { ServerFallback } from "./server-fallback/server-fallback/index.js";
|
||||
export type {
|
||||
AdvertisementHandler,
|
||||
ChunkAdvertisement,
|
||||
ChunkFailedEvent,
|
||||
ChunkFallbackEvent,
|
||||
ChunkRequestedEvent,
|
||||
ChunkServedEvent,
|
||||
DenylistMode,
|
||||
DriveMeshEventMap,
|
||||
DriveMeshOptions,
|
||||
DriveTransport,
|
||||
GossipReceivedEvent,
|
||||
GossipSentEvent,
|
||||
PeerDenialReason,
|
||||
PeerDeniedEvent,
|
||||
PeerInfo,
|
||||
PeerJoinedEvent,
|
||||
PeerLeftEvent,
|
||||
PeerScoredEvent,
|
||||
PeerUpdate,
|
||||
ServerFallbackConfig,
|
||||
Unsubscribe,
|
||||
} from "./types/index.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type EventArgsMap,
|
||||
TypedEventEmitter,
|
||||
} from "./typed-event-emitter.js";
|
||||
@@ -0,0 +1,74 @@
|
||||
# `typed-event-emitter.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Internal helper wrapping `EventBus` from `@suaveplan/events` with a
|
||||
tuple-shaped event map. The public mesh class extends this so call
|
||||
sites can write `mesh.emit("chunk:served", payload)` rather than
|
||||
wrapping every argument in an object literal.
|
||||
|
||||
The implementation is intentionally private to `@suaveplan/drive-mesh`
|
||||
— the public API exposes only `on`, `off`, and `once`. Re-implementing
|
||||
locally (rather than importing from `@suaveplan/drive-webrtc`) keeps
|
||||
the universal dependency closure clean: drive-mesh must not pull
|
||||
in WebRTC code.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict typing of event names → argument tuples.
|
||||
- Synchronous `emit` matching Node's `EventEmitter` semantics.
|
||||
- `once` auto-unsubscribes after delivery.
|
||||
- `removeAllListeners` clears every subscription across every event.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TypedEventEmitter } from "./typed-event-emitter.js";
|
||||
|
||||
type Events = { hello: [string] };
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
ee.on("hello", name => console.log("hi", name));
|
||||
ee.emit("hello", "world");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TypedEventEmitter } from "./typed-event-emitter.js";
|
||||
|
||||
type Events = {
|
||||
"chunk:served": [{ readonly chunkId: number; readonly bytes: number }];
|
||||
"chunk:failed": [{ readonly chunkId: number; readonly reason: string }];
|
||||
};
|
||||
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
const off = ee.on("chunk:served", e => console.log(e.chunkId, e.bytes));
|
||||
ee.once("chunk:failed", e => console.error(e.reason));
|
||||
ee.emit("chunk:served", { chunkId: 0, bytes: 1024 });
|
||||
off();
|
||||
ee.removeAllListeners();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EventArgsMap = Record<string, unknown[]>` — event-map shape.
|
||||
- `class TypedEventEmitter<TMap>`:
|
||||
- `on(event, handler): Unsubscribe`.
|
||||
- `once(event, handler): Unsubscribe` — auto-unsubscribes on first
|
||||
delivery.
|
||||
- `off(event, handler): void`.
|
||||
- `emit(event, ...args): boolean` — `true` iff at least one
|
||||
subscriber received the event.
|
||||
- `removeAllListeners(): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`EventBus` keeps payloads as a single value per event, so the wrapper
|
||||
stores `args` (the tuple) as that value and spreads it back through
|
||||
`handler(...args)` on dispatch. The auxiliary `#wrapped` map tracks
|
||||
the unsubscribe closures so `off(event, handler)` removes the
|
||||
correct registration even though the underlying bus only sees the
|
||||
wrapping closure.
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TypedEventEmitter } from "./typed-event-emitter.js";
|
||||
|
||||
type Events = {
|
||||
a: [number];
|
||||
b: [string, number];
|
||||
};
|
||||
|
||||
describe("TypedEventEmitter", () => {
|
||||
it("delivers emitted args to subscribers", { timeout: 5000 }, () => {
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
let last = 0;
|
||||
const off = ee.on("a", (n) => {
|
||||
last = n;
|
||||
});
|
||||
const delivered = ee.emit("a", 42);
|
||||
expect(delivered).toBe(true);
|
||||
expect(last).toBe(42);
|
||||
off();
|
||||
ee.emit("a", 999);
|
||||
expect(last).toBe(42);
|
||||
});
|
||||
|
||||
it("emit returns false when no subscribers", { timeout: 5000 }, () => {
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
expect(ee.emit("a", 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("once auto-unsubscribes", { timeout: 5000 }, () => {
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
let count = 0;
|
||||
ee.once("a", () => {
|
||||
count += 1;
|
||||
});
|
||||
ee.emit("a", 1);
|
||||
ee.emit("a", 2);
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("once handle can be cancelled before delivery", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
let count = 0;
|
||||
const handler = (): void => {
|
||||
count += 1;
|
||||
};
|
||||
const off = ee.once("a", handler);
|
||||
off();
|
||||
ee.emit("a", 1);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("off removes the registered handler", { timeout: 5000 }, () => {
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
let count = 0;
|
||||
const handler = (): void => {
|
||||
count += 1;
|
||||
};
|
||||
ee.on("a", handler);
|
||||
ee.off("a", handler);
|
||||
ee.emit("a", 1);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("off is a no-op for an unknown event", { timeout: 5000 }, () => {
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
expect(() =>
|
||||
ee.off("a", () => {
|
||||
/* noop */
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("off is a no-op for an unknown handler", { timeout: 5000 }, () => {
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
ee.on("a", () => {
|
||||
/* noop */
|
||||
});
|
||||
expect(() =>
|
||||
ee.off("a", () => {
|
||||
/* noop */
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("supports multi-arg payloads", { timeout: 5000 }, () => {
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
let captured: [string, number] | null = null;
|
||||
ee.on("b", (s, n) => {
|
||||
captured = [s, n];
|
||||
});
|
||||
ee.emit("b", "x", 7);
|
||||
expect(captured).toEqual(["x", 7]);
|
||||
});
|
||||
|
||||
it("off removes the last handler and drops the inner map", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
const h = (): void => {
|
||||
/* noop */
|
||||
};
|
||||
ee.on("a", h);
|
||||
ee.off("a", h);
|
||||
// off again is a safe no-op (the inner map was deleted)
|
||||
expect(() => ee.off("a", h)).not.toThrow();
|
||||
});
|
||||
|
||||
it("on-returned unsubscribe drops the inner map when last", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
const off1 = ee.on("a", () => undefined);
|
||||
off1();
|
||||
// re-registering after the inner map was dropped works
|
||||
const off2 = ee.on("a", () => undefined);
|
||||
off2();
|
||||
});
|
||||
|
||||
it("once after delivery cleans up bookkeeping", { timeout: 5000 }, () => {
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
ee.once("a", () => undefined);
|
||||
ee.emit("a", 1);
|
||||
// emit again is a safe no-op
|
||||
expect(ee.emit("a", 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("unsubscribe twice tolerates the second call", { timeout: 5000 }, () => {
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
const off = ee.on("a", () => undefined);
|
||||
off();
|
||||
off();
|
||||
// emit must not throw despite second-call cleanup hitting empty maps
|
||||
expect(ee.emit("a", 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("once-returned unsubscribe after delivery tolerates the call", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
const off = ee.once("a", () => undefined);
|
||||
ee.emit("a", 1);
|
||||
off();
|
||||
});
|
||||
|
||||
it("off keeps inner map when other handlers remain", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
let a = 0;
|
||||
let b = 0;
|
||||
const h1 = (n: number): void => {
|
||||
a += n;
|
||||
};
|
||||
const h2 = (n: number): void => {
|
||||
b += n;
|
||||
};
|
||||
ee.on("a", h1);
|
||||
ee.on("a", h2);
|
||||
ee.off("a", h1);
|
||||
ee.emit("a", 1);
|
||||
expect(a).toBe(0);
|
||||
expect(b).toBe(1);
|
||||
// h2 still registered — off again on h1 is a no-op
|
||||
ee.off("a", h1);
|
||||
ee.emit("a", 1);
|
||||
expect(b).toBe(2);
|
||||
});
|
||||
|
||||
it("on reuses the inner map for a second handler on the same event", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
let total = 0;
|
||||
ee.on("a", (n) => {
|
||||
total += n;
|
||||
});
|
||||
// Second registration hits the `map !== undefined` branch in #record.
|
||||
ee.on("a", (n) => {
|
||||
total += n * 10;
|
||||
});
|
||||
ee.emit("a", 1);
|
||||
expect(total).toBe(11);
|
||||
});
|
||||
|
||||
it("once cleanup with another handler still registered preserves the inner map", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
let total = 0;
|
||||
ee.on("a", (n) => {
|
||||
total += n;
|
||||
});
|
||||
ee.once("a", (n) => {
|
||||
total += n * 100;
|
||||
});
|
||||
ee.emit("a", 1);
|
||||
expect(total).toBe(101);
|
||||
// The `on` handler survives — #forget kept the inner map.
|
||||
ee.emit("a", 1);
|
||||
expect(total).toBe(102);
|
||||
});
|
||||
|
||||
it("removeAllListeners clears every subscription", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ee = new TypedEventEmitter<Events>();
|
||||
let count = 0;
|
||||
ee.on("a", () => {
|
||||
count += 1;
|
||||
});
|
||||
ee.on("b", () => {
|
||||
count += 1;
|
||||
});
|
||||
ee.removeAllListeners();
|
||||
ee.emit("a", 1);
|
||||
ee.emit("b", "x", 2);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Internal typed event emitter.
|
||||
*
|
||||
* Thin wrapper around `EventBus` from `@suaveplan/events`, exposing a
|
||||
* tuple-shaped event map and synchronous `emit`. Kept internal to the
|
||||
* package — consumers interact via `DriveMesh`'s public `on`/`off`/
|
||||
* `once` surface.
|
||||
*/
|
||||
|
||||
import type { Unsubscribe } from "@suaveplan/events";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
|
||||
export type EventArgsMap = Record<string, unknown[]>;
|
||||
|
||||
type Handler<TArgs extends unknown[]> = (...args: TArgs) => void;
|
||||
|
||||
export class TypedEventEmitter<TMap extends EventArgsMap> {
|
||||
readonly #bus = new EventBus<{ [K in keyof TMap]: TMap[K] }>();
|
||||
readonly #wrapped = new Map<
|
||||
keyof TMap,
|
||||
Map<Handler<TMap[keyof TMap]>, Unsubscribe>
|
||||
>();
|
||||
|
||||
public on<K extends keyof TMap>(
|
||||
event: K,
|
||||
handler: Handler<TMap[K]>
|
||||
): Unsubscribe {
|
||||
const wrapped = (args: TMap[K]): void => {
|
||||
handler(...args);
|
||||
};
|
||||
const unsub = this.#bus.on(event, wrapped);
|
||||
this.#record(event, handler, unsub);
|
||||
return () => {
|
||||
unsub();
|
||||
this.#forget(event, handler);
|
||||
};
|
||||
}
|
||||
|
||||
public once<K extends keyof TMap>(
|
||||
event: K,
|
||||
handler: Handler<TMap[K]>
|
||||
): Unsubscribe {
|
||||
const unsub = this.#bus.once(event, (args: TMap[K]) => {
|
||||
this.#forget(event, handler);
|
||||
handler(...args);
|
||||
});
|
||||
this.#record(event, handler, unsub);
|
||||
return () => {
|
||||
unsub();
|
||||
this.#forget(event, handler);
|
||||
};
|
||||
}
|
||||
|
||||
public off<K extends keyof TMap>(
|
||||
event: K,
|
||||
handler: Handler<TMap[K]>
|
||||
): void {
|
||||
const map = this.#wrapped.get(event);
|
||||
if (map === undefined) return;
|
||||
const unsub = map.get(handler as Handler<TMap[keyof TMap]>);
|
||||
if (unsub === undefined) return;
|
||||
unsub();
|
||||
map.delete(handler as Handler<TMap[keyof TMap]>);
|
||||
if (map.size === 0) this.#wrapped.delete(event);
|
||||
}
|
||||
|
||||
public emit<K extends keyof TMap>(event: K, ...args: TMap[K]): boolean {
|
||||
const result = this.#bus.emit(event, args);
|
||||
return result.delivered > 0;
|
||||
}
|
||||
|
||||
public removeAllListeners(): void {
|
||||
this.#bus.clear();
|
||||
this.#wrapped.clear();
|
||||
}
|
||||
|
||||
#record<K extends keyof TMap>(
|
||||
event: K,
|
||||
handler: Handler<TMap[K]>,
|
||||
unsub: Unsubscribe
|
||||
): void {
|
||||
let map = this.#wrapped.get(event);
|
||||
if (map === undefined) {
|
||||
map = new Map();
|
||||
this.#wrapped.set(event, map);
|
||||
}
|
||||
map.set(handler as Handler<TMap[keyof TMap]>, unsub);
|
||||
}
|
||||
|
||||
#forget<K extends keyof TMap>(event: K, handler: Handler<TMap[K]>): void {
|
||||
const map = this.#wrapped.get(event);
|
||||
if (map === undefined) return;
|
||||
map.delete(handler as Handler<TMap[keyof TMap]>);
|
||||
if (map.size === 0) this.#wrapped.delete(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
# `drive-mesh.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`DriveMesh` is the public face of `@suaveplan/drive-mesh`. It binds
|
||||
the four supporting subsystems — `PeerRegistry`, `GossipEngine`,
|
||||
`ParallelPuller`, and (optionally) `ServerFallback` — into a single
|
||||
class with explicit lifecycle and a single chunk-fetch entry point.
|
||||
|
||||
The class is the only mesh component most consumers ever construct.
|
||||
It validates options at the Zod boundary, wires telemetry hooks
|
||||
between subsystems, and surfaces a typed event stream callers can
|
||||
subscribe to. The chunks plugin's signature contract is upheld
|
||||
end-to-end: every byte returned from `requestChunk` has passed
|
||||
`verifyChunkSig` against the configured `serverPubKey`.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict option validation at construction (`INVALID_OPTIONS`).
|
||||
- Idempotent `start()` / `stop()` lifecycle.
|
||||
- `requestChunk` racing-window scheduler with automatic fallback.
|
||||
- `advertise` / `unadvertise` to publish local availability bitsets.
|
||||
- Typed event emitter (`chunk:*`, `peer:*`, `gossip:*`).
|
||||
- Read-only access to the registry (`mesh.registry`) for
|
||||
`NetworkBus` consumers that wire `peer:joined`/`peer:left` events.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DriveMesh } from "@suaveplan/drive-mesh";
|
||||
|
||||
const mesh = new DriveMesh({
|
||||
chunks,
|
||||
transports: [httpsTransport],
|
||||
serverPubKey,
|
||||
});
|
||||
await mesh.start();
|
||||
const bytes = await mesh.requestChunk(fileIdHex, 0);
|
||||
await mesh.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DriveMesh } from "@suaveplan/drive-mesh";
|
||||
import { encodeFramedBitset } from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const mesh = new DriveMesh({
|
||||
chunks,
|
||||
transports: [webrtcTransport, httpsTransport],
|
||||
serverPubKey,
|
||||
serverFallback: { url: "https://cdn.example.com/chunks", transport: "https" },
|
||||
maxParallelRequests: 16,
|
||||
racingWindowMs: 150,
|
||||
denylistMode: "persistent",
|
||||
});
|
||||
|
||||
// Mirror NetworkBus events into the registry overlay.
|
||||
networkBus.on("peer:joined", ({ peerId, transports }) =>
|
||||
mesh.registry.addOrUpdate(peerId, { transports })
|
||||
);
|
||||
networkBus.on("peer:left", ({ peerId }) => mesh.registry.forget(peerId));
|
||||
|
||||
await mesh.start();
|
||||
|
||||
mesh.on("chunk:served", e =>
|
||||
telemetry.counter("drive.mesh.chunks_served_total", 1, { outcome: "peer" })
|
||||
);
|
||||
mesh.on("chunk:fallback", e =>
|
||||
telemetry.counter("drive.mesh.chunks_served_total", 1, {
|
||||
outcome: "fallback",
|
||||
})
|
||||
);
|
||||
mesh.on("peer:denied", e =>
|
||||
telemetry.counter("drive.mesh.peers_denied_total", 1, { reason: e.reason })
|
||||
);
|
||||
|
||||
const bits = await chunks.hasChunks(fileIdBytes);
|
||||
const raw = boolsToBytes(bits);
|
||||
mesh.advertise(fileIdHex, encodeFramedBitset(raw, bits.length), bits.length);
|
||||
|
||||
const bytes = await mesh.requestChunk(fileIdHex, 0);
|
||||
await mesh.stop();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class DriveMesh extends TypedEventEmitter<DriveMeshEventMap>`:
|
||||
- `constructor(options: DriveMeshOptions)` — Zod-validated.
|
||||
- `start(signal?): Promise<void>` — idempotent.
|
||||
- `stop(reason?): Promise<void>` — idempotent; clears registry per
|
||||
`denylistMode`.
|
||||
- `requestChunk(fileId, chunkId, signal?): Promise<Uint8Array>` —
|
||||
racing-window peer pull → server fallback → throw.
|
||||
- `advertise(fileId, bitset, totalChunks): void`.
|
||||
- `unadvertise(fileId): void`.
|
||||
- `peers(): Iterable<PeerInfo>`.
|
||||
- `registry: PeerRegistry` (read-only getter).
|
||||
- `tickGossip(): Promise<void>` — single gossip pass for tests.
|
||||
- Inherited from `TypedEventEmitter`:
|
||||
- `on(event, handler): Unsubscribe`.
|
||||
- `once(event, handler): Unsubscribe`.
|
||||
- `off(event, handler): void`.
|
||||
- `removeAllListeners(): void`.
|
||||
- Throws:
|
||||
- `DRIVE_MESH.INVALID_OPTIONS` — bad construction args / bad
|
||||
`fileId`.
|
||||
- `DRIVE_MESH.NOT_STARTED` — `requestChunk` before `start()`.
|
||||
- `DRIVE_MESH.CHUNK_UNAVAILABLE` — no candidates and no fallback.
|
||||
- `DRIVE_MESH.SIG_VERIFY_FAILED` — winning peer was Byzantine.
|
||||
- `DRIVE_MESH.ALL_PEERS_FAILED` — every candidate failed and no
|
||||
fallback configured.
|
||||
- `DRIVE_MESH.FALLBACK_FAILED` — peers + fallback both failed.
|
||||
- `DRIVE_MESH.ABORTED` — caller-supplied signal aborted.
|
||||
- `DRIVE_MESH.INVALID_BITSET` — `advertise` got bad totalChunks.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`DriveMesh` does NOT register itself as a `NetworkBus` consumer
|
||||
internally — it exposes `mesh.registry` so the bridge from
|
||||
`network-plugin-registry`'s `peer:joined` / `peer:left` events lives
|
||||
in application glue. This keeps the universal package free of a
|
||||
dependency on `@suaveplan/network` / `@suaveplan/network-plugin-*`,
|
||||
matching the layering pinned in `drive-network-integration`.
|
||||
|
||||
`requestChunk` deliberately does not retry on peers after a bad-sig
|
||||
event from the winning attempt: the denylist update means the next
|
||||
call will not even consider that peer. Re-trying inside a single
|
||||
request would risk amplifying a Byzantine peer's influence over the
|
||||
score formula. Instead, the fallback path runs.
|
||||
|
||||
Bad-sig events flow `puller → registry.recordBadSig → onBadSig
|
||||
callback → mesh.emit("peer:denied")`. The registry update happens
|
||||
synchronously inside the puller; the event is fired afterwards so
|
||||
subscribers see the state-after-update.
|
||||
|
||||
`exactOptionalPropertyTypes: true` (per the repo's `tsconfig.base`)
|
||||
forces the explicit `...(x !== undefined ? { x } : {})` spread shape
|
||||
when forwarding optionals down into subsystem options — never
|
||||
`{ x: maybeUndefined }` because that materialises an `undefined`
|
||||
property the inner schema would reject.
|
||||
@@ -0,0 +1,625 @@
|
||||
import { encodeFramedBitset } from "@suaveplan/drive-mesh-protocol";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
Broker,
|
||||
FakeDriveTransport,
|
||||
} from "../../../tests/helpers/fake-transport.js";
|
||||
import {
|
||||
buildChunkBytes,
|
||||
makeKeypair,
|
||||
TEST_FILE_ID_HEX,
|
||||
} from "../../../tests/helpers/signed-chunk.js";
|
||||
import {
|
||||
DRIVE_MESH_CODES,
|
||||
DriveMeshError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { DriveMesh } from "./drive-mesh.js";
|
||||
|
||||
function bitset(bits: readonly number[], total: number): Uint8Array {
|
||||
const raw = new Uint8Array(Math.ceil(total / 8));
|
||||
for (const b of bits) raw[b >> 3] |= 1 << (b & 7);
|
||||
return encodeFramedBitset(raw, total);
|
||||
}
|
||||
|
||||
function arrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
const FILE_ID = TEST_FILE_ID_HEX;
|
||||
const dummyChunks = {
|
||||
/* stub — DriveMesh does not read chunks-plugin methods directly */
|
||||
} as unknown as Parameters<typeof DriveMesh>[0]["chunks"];
|
||||
|
||||
describe("DriveMesh", () => {
|
||||
it("rejects malformed options", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() =>
|
||||
new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [],
|
||||
serverPubKey: new Uint8Array(32),
|
||||
})
|
||||
).toThrow(DriveMeshError);
|
||||
});
|
||||
|
||||
it("requestChunk before start throws NOT_STARTED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(3);
|
||||
const broker = new Broker();
|
||||
const transport = new FakeDriveTransport({ broker, localPeerId: "x" });
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [transport],
|
||||
serverPubKey: kp.pubKey,
|
||||
});
|
||||
await expect(mesh.requestChunk(FILE_ID, 0)).rejects.toMatchObject({
|
||||
code: DRIVE_MESH_CODES.NOT_STARTED,
|
||||
});
|
||||
transport.dispose();
|
||||
});
|
||||
|
||||
it("rejects malformed fileId", { timeout: 5000 }, async () => {
|
||||
const kp = makeKeypair(3);
|
||||
const broker = new Broker();
|
||||
const transport = new FakeDriveTransport({ broker, localPeerId: "x" });
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [transport],
|
||||
serverPubKey: kp.pubKey,
|
||||
});
|
||||
await mesh.start();
|
||||
await expect(mesh.requestChunk("nope", 0)).rejects.toMatchObject({
|
||||
code: DRIVE_MESH_CODES.INVALID_OPTIONS,
|
||||
});
|
||||
await mesh.stop();
|
||||
transport.dispose();
|
||||
});
|
||||
|
||||
it("start with pre-aborted signal throws ABORTED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(3);
|
||||
const broker = new Broker();
|
||||
const transport = new FakeDriveTransport({ broker, localPeerId: "x" });
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [transport],
|
||||
serverPubKey: kp.pubKey,
|
||||
});
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(mesh.start(ac.signal)).rejects.toMatchObject({
|
||||
code: DRIVE_MESH_CODES.ABORTED,
|
||||
});
|
||||
transport.dispose();
|
||||
});
|
||||
|
||||
it("start and stop are idempotent", { timeout: 5000 }, async () => {
|
||||
const kp = makeKeypair(3);
|
||||
const broker = new Broker();
|
||||
const transport = new FakeDriveTransport({ broker, localPeerId: "x" });
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [transport],
|
||||
serverPubKey: kp.pubKey,
|
||||
});
|
||||
await mesh.start();
|
||||
await mesh.start();
|
||||
await mesh.stop();
|
||||
await mesh.stop("twice");
|
||||
transport.dispose();
|
||||
});
|
||||
|
||||
it("end-to-end: peer serves verified chunk", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(4);
|
||||
const broker = new Broker();
|
||||
const local = new FakeDriveTransport({ broker, localPeerId: "local" });
|
||||
local.addKnownPeer("peerA");
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [local],
|
||||
serverPubKey: kp.pubKey,
|
||||
});
|
||||
await mesh.start();
|
||||
// Stage a real signed chunk
|
||||
const bytes = buildChunkBytes({
|
||||
serverPrivKey: kp.privKey,
|
||||
fileIdHex: FILE_ID,
|
||||
chunkId: 0,
|
||||
});
|
||||
broker.serve("peerA", FILE_ID, 0, { bytes });
|
||||
// Wire the registry overlay directly (network-plugin-registry simulation)
|
||||
mesh.registry.addOrUpdate("peerA", { transports: ["fake"] });
|
||||
mesh.registry.recordAvailability("peerA", FILE_ID, bitset([0], 8));
|
||||
const served: { peerId: string }[] = [];
|
||||
mesh.on("chunk:served", (e) => served.push({ peerId: e.peerId }));
|
||||
const out = await mesh.requestChunk(FILE_ID, 0);
|
||||
expect(out).toEqual(bytes);
|
||||
expect(served).toHaveLength(1);
|
||||
await mesh.stop();
|
||||
local.dispose();
|
||||
});
|
||||
|
||||
it("falls back to server when every peer fails", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(5);
|
||||
const broker = new Broker();
|
||||
const local = new FakeDriveTransport({ broker, localPeerId: "local" });
|
||||
local.addKnownPeer("peerA");
|
||||
const fallbackBytes = buildChunkBytes({
|
||||
serverPrivKey: kp.privKey,
|
||||
fileIdHex: FILE_ID,
|
||||
chunkId: 0,
|
||||
});
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [local],
|
||||
serverPubKey: kp.pubKey,
|
||||
serverFallback: { url: "https://cdn", transport: "fake" },
|
||||
fetchFn: async () =>
|
||||
new Response(arrayBuffer(fallbackBytes), { status: 200 }),
|
||||
});
|
||||
await mesh.start();
|
||||
// peerA "knows" the chunk but errors on request
|
||||
broker.serve("peerA", FILE_ID, 0, { throwReason: "down" });
|
||||
mesh.registry.addOrUpdate("peerA", { transports: ["fake"] });
|
||||
mesh.registry.recordAvailability("peerA", FILE_ID, bitset([0], 8));
|
||||
const fallbacks: number[] = [];
|
||||
mesh.on("chunk:fallback", (e) => fallbacks.push(e.chunkId));
|
||||
const out = await mesh.requestChunk(FILE_ID, 0);
|
||||
expect(out).toEqual(fallbackBytes);
|
||||
expect(fallbacks).toEqual([0]);
|
||||
await mesh.stop();
|
||||
local.dispose();
|
||||
});
|
||||
|
||||
it("falls back when no peers advertise the chunk", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(6);
|
||||
const fallbackBytes = buildChunkBytes({
|
||||
serverPrivKey: kp.privKey,
|
||||
fileIdHex: FILE_ID,
|
||||
chunkId: 1,
|
||||
});
|
||||
const broker = new Broker();
|
||||
const local = new FakeDriveTransport({ broker, localPeerId: "local" });
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [local],
|
||||
serverPubKey: kp.pubKey,
|
||||
serverFallback: { url: "https://cdn", transport: "fake" },
|
||||
fetchFn: async () =>
|
||||
new Response(arrayBuffer(fallbackBytes), { status: 200 }),
|
||||
});
|
||||
await mesh.start();
|
||||
const out = await mesh.requestChunk(FILE_ID, 1);
|
||||
expect(out).toEqual(fallbackBytes);
|
||||
await mesh.stop();
|
||||
local.dispose();
|
||||
});
|
||||
|
||||
it("propagates ABORTED from puller without invoking fallback", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(6);
|
||||
const broker = new Broker();
|
||||
const local = new FakeDriveTransport({ broker, localPeerId: "local" });
|
||||
local.addKnownPeer("peerA");
|
||||
let fallbackHit = 0;
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [local],
|
||||
serverPubKey: kp.pubKey,
|
||||
serverFallback: { url: "https://cdn", transport: "fake" },
|
||||
fetchFn: async () => {
|
||||
fallbackHit += 1;
|
||||
return new Response(new Uint8Array(0), { status: 200 });
|
||||
},
|
||||
});
|
||||
await mesh.start();
|
||||
broker.serve("peerA", FILE_ID, 0, {
|
||||
delayMs: 1000,
|
||||
bytes: new Uint8Array(0),
|
||||
});
|
||||
mesh.registry.addOrUpdate("peerA", { transports: ["fake"] });
|
||||
mesh.registry.recordAvailability("peerA", FILE_ID, bitset([0], 8));
|
||||
const ac = new AbortController();
|
||||
const promise = mesh.requestChunk(FILE_ID, 0, ac.signal);
|
||||
setTimeout(() => ac.abort(), 5);
|
||||
await expect(promise).rejects.toBeInstanceOf(DriveMeshError);
|
||||
expect(fallbackHit).toBe(0);
|
||||
await mesh.stop();
|
||||
local.dispose();
|
||||
});
|
||||
|
||||
it("emits chunk:failed when fallback also fails", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(7);
|
||||
const broker = new Broker();
|
||||
const local = new FakeDriveTransport({ broker, localPeerId: "local" });
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [local],
|
||||
serverPubKey: kp.pubKey,
|
||||
serverFallback: { url: "https://cdn", transport: "fake" },
|
||||
fetchFn: async () => new Response("nope", { status: 500 }),
|
||||
});
|
||||
await mesh.start();
|
||||
const failures: string[] = [];
|
||||
mesh.on("chunk:failed", (e) => failures.push(e.reason));
|
||||
await expect(mesh.requestChunk(FILE_ID, 0)).rejects.toMatchObject({
|
||||
code: DRIVE_MESH_CODES.FALLBACK_FAILED,
|
||||
});
|
||||
expect(failures.length).toBeGreaterThan(0);
|
||||
await mesh.stop();
|
||||
local.dispose();
|
||||
});
|
||||
|
||||
it("throws original error when no fallback configured", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(8);
|
||||
const broker = new Broker();
|
||||
const local = new FakeDriveTransport({ broker, localPeerId: "local" });
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [local],
|
||||
serverPubKey: kp.pubKey,
|
||||
});
|
||||
await mesh.start();
|
||||
await expect(mesh.requestChunk(FILE_ID, 0)).rejects.toMatchObject({
|
||||
code: DRIVE_MESH_CODES.CHUNK_UNAVAILABLE,
|
||||
});
|
||||
await mesh.stop();
|
||||
local.dispose();
|
||||
});
|
||||
|
||||
it("re-throws non-DriveMeshError exceptions from puller", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(9);
|
||||
const broker = new Broker();
|
||||
const transport: FakeDriveTransport = new FakeDriveTransport({
|
||||
broker,
|
||||
localPeerId: "local",
|
||||
});
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [transport],
|
||||
serverPubKey: kp.pubKey,
|
||||
});
|
||||
await mesh.start();
|
||||
// Force a non-DriveMeshError from rank() by patching registry
|
||||
// (uses public surface to set up situation)
|
||||
mesh.registry.addOrUpdate("peerA", { transports: ["fake"] });
|
||||
mesh.registry.recordAvailability("peerA", FILE_ID, bitset([0], 8));
|
||||
// Replace registry.rank to throw a generic error
|
||||
const original = mesh.registry.rank.bind(mesh.registry);
|
||||
mesh.registry.rank = (): never => {
|
||||
throw new Error("synthetic");
|
||||
};
|
||||
await expect(mesh.requestChunk(FILE_ID, 0)).rejects.toThrow(
|
||||
"synthetic"
|
||||
);
|
||||
mesh.registry.rank = original;
|
||||
await mesh.stop();
|
||||
transport.dispose();
|
||||
});
|
||||
|
||||
it("advertise rejects invalid totalChunks", { timeout: 5000 }, () => {
|
||||
const kp = makeKeypair(10);
|
||||
const broker = new Broker();
|
||||
const transport = new FakeDriveTransport({ broker, localPeerId: "x" });
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [transport],
|
||||
serverPubKey: kp.pubKey,
|
||||
});
|
||||
expect(() => mesh.advertise(FILE_ID, new Uint8Array(2), 0)).toThrow(
|
||||
DriveMeshError
|
||||
);
|
||||
transport.dispose();
|
||||
});
|
||||
|
||||
it("advertise stores entries that gossip consumes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(11);
|
||||
const broker = new Broker();
|
||||
const local = new FakeDriveTransport({ broker, localPeerId: "local" });
|
||||
local.addKnownPeer("peerR");
|
||||
const remote = new FakeDriveTransport({ broker, localPeerId: "peerR" });
|
||||
const seen: string[] = [];
|
||||
remote.onAdvertisement((_p, a) => seen.push(a.fileId));
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [local],
|
||||
serverPubKey: kp.pubKey,
|
||||
});
|
||||
await mesh.start();
|
||||
mesh.advertise(FILE_ID, bitset([0, 1], 8), 8);
|
||||
await mesh.tickGossip();
|
||||
expect(seen).toContain(FILE_ID);
|
||||
mesh.unadvertise(FILE_ID);
|
||||
// ensure unadvertise removes (next tick emits nothing for that file)
|
||||
seen.length = 0;
|
||||
await mesh.tickGossip();
|
||||
expect(seen).toEqual([]);
|
||||
await mesh.stop();
|
||||
local.dispose();
|
||||
remote.dispose();
|
||||
});
|
||||
|
||||
it("peers() snapshot lists registry contents", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(12);
|
||||
const broker = new Broker();
|
||||
const local = new FakeDriveTransport({ broker, localPeerId: "local" });
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [local],
|
||||
serverPubKey: kp.pubKey,
|
||||
});
|
||||
await mesh.start();
|
||||
mesh.registry.addOrUpdate("peerA");
|
||||
const ids = [...mesh.peers()].map((p) => p.peerId);
|
||||
expect(ids).toContain("peerA");
|
||||
await mesh.stop();
|
||||
local.dispose();
|
||||
});
|
||||
|
||||
it("emits peer:scored when chunk:served fires", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(13);
|
||||
const broker = new Broker();
|
||||
const local = new FakeDriveTransport({ broker, localPeerId: "local" });
|
||||
local.addKnownPeer("peerA");
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [local],
|
||||
serverPubKey: kp.pubKey,
|
||||
});
|
||||
await mesh.start();
|
||||
const bytes = buildChunkBytes({
|
||||
serverPrivKey: kp.privKey,
|
||||
fileIdHex: FILE_ID,
|
||||
chunkId: 0,
|
||||
});
|
||||
broker.serve("peerA", FILE_ID, 0, { bytes });
|
||||
mesh.registry.addOrUpdate("peerA", { transports: ["fake"] });
|
||||
mesh.registry.recordAvailability("peerA", FILE_ID, bitset([0], 8));
|
||||
const scores: number[] = [];
|
||||
mesh.on("peer:scored", (e) => scores.push(e.score));
|
||||
await mesh.requestChunk(FILE_ID, 0);
|
||||
expect(scores.length).toBeGreaterThan(0);
|
||||
await mesh.stop();
|
||||
local.dispose();
|
||||
});
|
||||
|
||||
it("emits peer:denied on bad sig (integration)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(14);
|
||||
const wrong = makeKeypair(15);
|
||||
const broker = new Broker();
|
||||
const local = new FakeDriveTransport({ broker, localPeerId: "local" });
|
||||
local.addKnownPeer("peerA");
|
||||
const fallbackBytes = buildChunkBytes({
|
||||
serverPrivKey: kp.privKey,
|
||||
fileIdHex: FILE_ID,
|
||||
chunkId: 0,
|
||||
});
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [local],
|
||||
serverPubKey: kp.pubKey,
|
||||
serverFallback: { url: "https://cdn", transport: "fake" },
|
||||
fetchFn: async () =>
|
||||
new Response(arrayBuffer(fallbackBytes), { status: 200 }),
|
||||
});
|
||||
await mesh.start();
|
||||
const badBytes = buildChunkBytes({
|
||||
serverPrivKey: wrong.privKey,
|
||||
fileIdHex: FILE_ID,
|
||||
chunkId: 0,
|
||||
});
|
||||
broker.serve("peerA", FILE_ID, 0, { bytes: badBytes });
|
||||
mesh.registry.addOrUpdate("peerA", { transports: ["fake"] });
|
||||
mesh.registry.recordAvailability("peerA", FILE_ID, bitset([0], 8));
|
||||
const denied: string[] = [];
|
||||
mesh.on("peer:denied", (e) => denied.push(e.peerId));
|
||||
const out = await mesh.requestChunk(FILE_ID, 0);
|
||||
expect(out).toEqual(fallbackBytes);
|
||||
expect(denied).toContain("peerA");
|
||||
await mesh.stop();
|
||||
local.dispose();
|
||||
});
|
||||
|
||||
it("propagates non-DriveMeshError from fallback (re-wraps as FALLBACK_FAILED)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(16);
|
||||
const broker = new Broker();
|
||||
const local = new FakeDriveTransport({ broker, localPeerId: "local" });
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [local],
|
||||
serverPubKey: kp.pubKey,
|
||||
serverFallback: { url: "https://cdn", transport: "fake" },
|
||||
fetchFn: async () => {
|
||||
throw "non-error";
|
||||
},
|
||||
maxParallelRequests: 1,
|
||||
});
|
||||
await mesh.start();
|
||||
await expect(mesh.requestChunk(FILE_ID, 0)).rejects.toMatchObject({
|
||||
code: DRIVE_MESH_CODES.FALLBACK_FAILED,
|
||||
});
|
||||
await mesh.stop();
|
||||
local.dispose();
|
||||
});
|
||||
|
||||
it("emits gossip:received when a peer advertises to us", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(20);
|
||||
const broker = new Broker();
|
||||
const localTransport = new FakeDriveTransport({
|
||||
broker,
|
||||
localPeerId: "local",
|
||||
id: "fake",
|
||||
});
|
||||
const remoteTransport = new FakeDriveTransport({
|
||||
broker,
|
||||
localPeerId: "peerR",
|
||||
id: "fake",
|
||||
});
|
||||
remoteTransport.addKnownPeer("local");
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [localTransport],
|
||||
serverPubKey: kp.pubKey,
|
||||
});
|
||||
await mesh.start();
|
||||
const received: string[] = [];
|
||||
mesh.on("gossip:received", (e) => received.push(e.fileId));
|
||||
// Simulate remote pushing an advertisement back to us
|
||||
await remoteTransport.advertise("local", {
|
||||
fileId: FILE_ID,
|
||||
bitset: bitset([0], 8),
|
||||
totalChunks: 8,
|
||||
});
|
||||
expect(received).toContain(FILE_ID);
|
||||
await mesh.stop();
|
||||
localTransport.dispose();
|
||||
remoteTransport.dispose();
|
||||
});
|
||||
|
||||
it("propagates fallback ABORTED back to caller", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(21);
|
||||
const broker = new Broker();
|
||||
const local = new FakeDriveTransport({ broker, localPeerId: "local" });
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [local],
|
||||
serverPubKey: kp.pubKey,
|
||||
serverFallback: { url: "https://cdn", transport: "fake" },
|
||||
fetchFn: async (_url, init) => {
|
||||
// honour the abort signal so fallback throws ABORTED
|
||||
if (init?.signal?.aborted === true) {
|
||||
throw new Error("aborted");
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onAbort = (): void => reject(new Error("aborted"));
|
||||
init?.signal?.addEventListener("abort", onAbort, {
|
||||
once: true,
|
||||
});
|
||||
setTimeout(() => {
|
||||
init?.signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, 5_000);
|
||||
});
|
||||
return new Response(new Uint8Array(0));
|
||||
},
|
||||
});
|
||||
await mesh.start();
|
||||
const ac = new AbortController();
|
||||
const promise = mesh.requestChunk(FILE_ID, 0, ac.signal);
|
||||
setTimeout(() => ac.abort(), 10);
|
||||
await expect(promise).rejects.toBeInstanceOf(DriveMeshError);
|
||||
await mesh.stop();
|
||||
local.dispose();
|
||||
});
|
||||
|
||||
it("threads clock, maxParallelRequests, racingWindowMs, and fetchFn through to sub-components", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(50);
|
||||
const broker = new Broker();
|
||||
const local = new FakeDriveTransport({ broker, localPeerId: "local" });
|
||||
local.addKnownPeer("peerA");
|
||||
const bytes = buildChunkBytes({
|
||||
serverPrivKey: kp.privKey,
|
||||
fileIdHex: FILE_ID,
|
||||
chunkId: 0,
|
||||
});
|
||||
let clockReads = 0;
|
||||
let fetchCalls = 0;
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [local],
|
||||
serverPubKey: kp.pubKey,
|
||||
serverFallback: { url: "https://cdn", transport: "fake" },
|
||||
fetchFn: async () => {
|
||||
fetchCalls += 1;
|
||||
return new Response(arrayBuffer(bytes), { status: 200 });
|
||||
},
|
||||
clock: () => {
|
||||
clockReads += 1;
|
||||
return 1_700_000_000_000 + clockReads;
|
||||
},
|
||||
maxParallelRequests: 4,
|
||||
racingWindowMs: 10,
|
||||
});
|
||||
await mesh.start();
|
||||
broker.serve("peerA", FILE_ID, 0, { bytes });
|
||||
mesh.registry.addOrUpdate("peerA", { transports: ["fake"] });
|
||||
mesh.registry.recordAvailability("peerA", FILE_ID, bitset([0], 8));
|
||||
const out = await mesh.requestChunk(FILE_ID, 0);
|
||||
expect(out).toEqual(bytes);
|
||||
// Puller's onSuccess path reads the clock at least twice
|
||||
// (start + end timestamps via `#clock()` in `#tryPeer`).
|
||||
expect(clockReads).toBeGreaterThan(0);
|
||||
expect(fetchCalls).toBe(0);
|
||||
await mesh.stop();
|
||||
local.dispose();
|
||||
});
|
||||
|
||||
it("constructs a fallback without explicit fetchFn (delegating to globalThis.fetch)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(52);
|
||||
const broker = new Broker();
|
||||
const local = new FakeDriveTransport({ broker, localPeerId: "local" });
|
||||
// Construct with serverFallback but no fetchFn. We never call
|
||||
// requestChunk so globalThis.fetch is never invoked; the test
|
||||
// covers the constructor spread branch.
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [local],
|
||||
serverPubKey: kp.pubKey,
|
||||
serverFallback: { url: "https://cdn", transport: "fake" },
|
||||
});
|
||||
await mesh.start();
|
||||
await mesh.stop();
|
||||
local.dispose();
|
||||
});
|
||||
|
||||
it("stop without start transitions to stopped", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(17);
|
||||
const broker = new Broker();
|
||||
const transport = new FakeDriveTransport({ broker, localPeerId: "x" });
|
||||
const mesh = new DriveMesh({
|
||||
chunks: dummyChunks,
|
||||
transports: [transport],
|
||||
serverPubKey: kp.pubKey,
|
||||
});
|
||||
await mesh.stop();
|
||||
transport.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* `DriveMesh` — orchestrator binding peer registry, gossip engine,
|
||||
* parallel puller, and server fallback into a single public surface.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - `new DriveMesh(options)` validates options at the Zod boundary.
|
||||
* - `await mesh.start(signal?)` opens transports, wires gossip.
|
||||
* - `await mesh.requestChunk(fileId, chunkId, signal?)` returns
|
||||
* verified bytes from peer-or-fallback.
|
||||
* - `await mesh.stop(reason?)` cancels gossip, releases transports,
|
||||
* clears registry per `denylistMode`. Idempotent.
|
||||
*/
|
||||
|
||||
import {
|
||||
DRIVE_MESH_CODES,
|
||||
DriveMeshError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { GossipEngine } from "../../gossip/gossip-engine/index.js";
|
||||
import { TypedEventEmitter } from "../../internal/typed-event-emitter/index.js";
|
||||
import { PeerRegistry } from "../../peer-registry/peer-registry/index.js";
|
||||
import { ParallelPuller } from "../../scheduler/parallel-puller/index.js";
|
||||
import { DriveMeshOptionsSchema } from "../../schemas/index.js";
|
||||
import { ServerFallback } from "../../server-fallback/server-fallback/index.js";
|
||||
import type {
|
||||
DriveMeshEventMap,
|
||||
DriveMeshOptions,
|
||||
PeerInfo,
|
||||
} from "../../types/index.js";
|
||||
|
||||
const HEX_FILE_ID_RE = /^[0-9a-f]{64}$/;
|
||||
|
||||
export class DriveMesh extends TypedEventEmitter<DriveMeshEventMap> {
|
||||
readonly #registry: PeerRegistry;
|
||||
readonly #puller: ParallelPuller;
|
||||
readonly #fallback: ServerFallback | undefined;
|
||||
readonly #gossip: GossipEngine;
|
||||
readonly #advertisements = new Map<
|
||||
string,
|
||||
{
|
||||
readonly fileId: string;
|
||||
readonly bitset: Uint8Array;
|
||||
readonly totalChunks: number;
|
||||
}
|
||||
>();
|
||||
#state: "idle" | "running" | "stopped" = "idle";
|
||||
|
||||
public constructor(options: DriveMeshOptions) {
|
||||
super();
|
||||
const parsed = DriveMeshOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.INVALID_OPTIONS,
|
||||
`invalid DriveMeshOptions: ${parsed.error.message}`
|
||||
);
|
||||
}
|
||||
const denylistMode = options.denylistMode ?? "session";
|
||||
const clock = options.clock;
|
||||
this.#registry = new PeerRegistry({
|
||||
...(clock !== undefined ? { clock } : {}),
|
||||
denylistMode,
|
||||
});
|
||||
this.#puller = new ParallelPuller({
|
||||
registry: this.#registry,
|
||||
transports: options.transports,
|
||||
serverPubKey: options.serverPubKey,
|
||||
...(options.maxParallelRequests !== undefined
|
||||
? { maxParallelRequests: options.maxParallelRequests }
|
||||
: {}),
|
||||
...(options.racingWindowMs !== undefined
|
||||
? { racingWindowMs: options.racingWindowMs }
|
||||
: {}),
|
||||
...(clock !== undefined ? { clock } : {}),
|
||||
onAttempt: (peerId, fileId, chunkId) =>
|
||||
this.emit("chunk:requested", { fileId, chunkId, peerId }),
|
||||
onSuccess: (peerId, fileId, chunkId, bytes, elapsedMs) => {
|
||||
this.emit("chunk:served", {
|
||||
fileId,
|
||||
chunkId,
|
||||
peerId,
|
||||
bytes,
|
||||
elapsedMs,
|
||||
});
|
||||
// `onSuccess` is invoked synchronously after
|
||||
// `registry.recordSuccess(peerId)`, so the peer is
|
||||
// guaranteed to exist in the registry; the non-null
|
||||
// assertion is safe by construction.
|
||||
const info = this.#registry.get(peerId) as PeerInfo;
|
||||
this.emit("peer:scored", {
|
||||
peerId,
|
||||
score: info.score,
|
||||
rttMs: info.rttMs,
|
||||
});
|
||||
},
|
||||
onFailure: (_peerId, fileId, chunkId, reason) =>
|
||||
this.emit("chunk:failed", { fileId, chunkId, reason }),
|
||||
onBadSig: (peerId) =>
|
||||
this.emit("peer:denied", { peerId, reason: "bad_sig" }),
|
||||
});
|
||||
if (options.serverFallback !== undefined) {
|
||||
this.#fallback = new ServerFallback({
|
||||
baseUrl: options.serverFallback.url,
|
||||
serverPubKey: options.serverPubKey,
|
||||
...(options.fetchFn !== undefined
|
||||
? { fetchFn: options.fetchFn }
|
||||
: {}),
|
||||
...(clock !== undefined ? { clock } : {}),
|
||||
});
|
||||
} else {
|
||||
this.#fallback = undefined;
|
||||
}
|
||||
this.#gossip = new GossipEngine({
|
||||
registry: this.#registry,
|
||||
transports: options.transports,
|
||||
advertSource: (fileId) => this.#advertisements.get(fileId),
|
||||
trackedFiles: () => this.#advertisements.keys(),
|
||||
...(clock !== undefined ? { clock } : {}),
|
||||
onAdvertiseSent: (peerId, advert) =>
|
||||
this.emit("gossip:sent", {
|
||||
peerId,
|
||||
fileId: advert.fileId,
|
||||
sizeBytes: advert.bitset.byteLength,
|
||||
}),
|
||||
onAdvertiseReceived: (peerId, advert) =>
|
||||
this.emit("gossip:received", {
|
||||
peerId,
|
||||
fileId: advert.fileId,
|
||||
sizeBytes: advert.bitset.byteLength,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Begin gossip + listen for chunk requests. */
|
||||
public async start(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.ABORTED,
|
||||
"start aborted before begin"
|
||||
);
|
||||
}
|
||||
if (this.#state === "running") return;
|
||||
this.#state = "running";
|
||||
this.#gossip.start();
|
||||
}
|
||||
|
||||
/** Stop gossip; clear registry; dispose transports. Idempotent. */
|
||||
public async stop(reason?: string): Promise<void> {
|
||||
if (this.#state !== "running") {
|
||||
this.#state = "stopped";
|
||||
return;
|
||||
}
|
||||
this.#state = "stopped";
|
||||
this.#gossip.stop();
|
||||
this.#registry.forgetAll();
|
||||
void reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a chunk: tries peers in score order with a racing
|
||||
* window; falls back to the configured server URL when every
|
||||
* peer fails. Surfaces the verified bytes.
|
||||
*/
|
||||
public async requestChunk(
|
||||
fileId: string,
|
||||
chunkId: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<Uint8Array> {
|
||||
this.#assertRunning();
|
||||
this.#assertFileIdHex(fileId);
|
||||
try {
|
||||
return await this.#puller.pull(fileId, chunkId, signal);
|
||||
} catch (err) {
|
||||
if (!(err instanceof DriveMeshError)) throw err;
|
||||
if (err.code === DRIVE_MESH_CODES.ABORTED) throw err;
|
||||
return await this.#tryFallback(fileId, chunkId, err, signal);
|
||||
}
|
||||
}
|
||||
|
||||
/** Advertise local availability of (fileId, bitset). */
|
||||
public advertise(
|
||||
fileId: string,
|
||||
bitset: Uint8Array,
|
||||
totalChunks: number
|
||||
): void {
|
||||
this.#assertFileIdHex(fileId);
|
||||
if (!Number.isInteger(totalChunks) || totalChunks <= 0) {
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.INVALID_BITSET,
|
||||
"totalChunks must be a positive integer"
|
||||
);
|
||||
}
|
||||
this.#advertisements.set(fileId, { fileId, bitset, totalChunks });
|
||||
}
|
||||
|
||||
/** Forget a previously-pushed local advertisement. */
|
||||
public unadvertise(fileId: string): void {
|
||||
this.#advertisements.delete(fileId);
|
||||
}
|
||||
|
||||
/** Snapshot of every live peer (excludes denylisted). */
|
||||
public peers(): Iterable<PeerInfo> {
|
||||
return [...this.#registry.peers()];
|
||||
}
|
||||
|
||||
/** Direct access to the registry — used by integrations that subscribe to `NetworkBus`. */
|
||||
public get registry(): PeerRegistry {
|
||||
return this.#registry;
|
||||
}
|
||||
|
||||
/** Trigger an immediate gossip pass — exposed for tests. */
|
||||
public async tickGossip(): Promise<void> {
|
||||
await this.#gossip.tick();
|
||||
}
|
||||
|
||||
async #tryFallback(
|
||||
fileId: string,
|
||||
chunkId: number,
|
||||
originalErr: DriveMeshError,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<Uint8Array> {
|
||||
if (this.#fallback === undefined) {
|
||||
throw originalErr;
|
||||
}
|
||||
const start = Date.now();
|
||||
try {
|
||||
const bytes = await this.#fallback.fetchChunk(
|
||||
fileId,
|
||||
chunkId,
|
||||
signal
|
||||
);
|
||||
this.emit("chunk:fallback", {
|
||||
fileId,
|
||||
chunkId,
|
||||
bytes: bytes.byteLength,
|
||||
elapsedMs: Date.now() - start,
|
||||
});
|
||||
return bytes;
|
||||
} catch (fallbackErr) {
|
||||
if (
|
||||
fallbackErr instanceof DriveMeshError &&
|
||||
fallbackErr.code === DRIVE_MESH_CODES.ABORTED
|
||||
) {
|
||||
throw fallbackErr;
|
||||
}
|
||||
// `ServerFallback.fetchChunk` always rejects with a
|
||||
// `DriveMeshError` (subclass of Error); narrow without
|
||||
// an `instanceof` branch.
|
||||
const cause = fallbackErr as Error;
|
||||
this.emit("chunk:failed", {
|
||||
fileId,
|
||||
chunkId,
|
||||
reason: cause.message,
|
||||
});
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.FALLBACK_FAILED,
|
||||
`peer + fallback both failed for ${fileId}/${chunkId}`,
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#assertRunning(): void {
|
||||
if (this.#state !== "running") {
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.NOT_STARTED,
|
||||
`mesh in state '${this.#state}'; call start() first`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#assertFileIdHex(fileId: string): void {
|
||||
if (!HEX_FILE_ID_RE.test(fileId)) {
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.INVALID_OPTIONS,
|
||||
`fileId must be 64 lowercase hex chars: '${fileId}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DriveMesh } from "./drive-mesh.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
PeerRegistry,
|
||||
type PeerRegistryOptions,
|
||||
} from "./peer-registry.js";
|
||||
@@ -0,0 +1,112 @@
|
||||
# `peer-registry.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`PeerRegistry` is the in-memory overlay drive-mesh maintains on top of
|
||||
the live peer set provided by `network-plugin-registry`. The registry
|
||||
adds three concerns the network layer does not own: per-`fileId`
|
||||
availability bitsets, an EMA-smoothed RTT tracker, and a composite
|
||||
score the parallel puller uses to rank candidates.
|
||||
|
||||
The registry never originates peer-identity events. Peers enter via
|
||||
`addOrUpdate` (called by the mesh in response to bus events) and exit
|
||||
via `forget` (called when the network registry emits `peer:left`). The
|
||||
denylist is owned by the registry because bad signatures are a
|
||||
file-layer concern.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-file availability bitsets stored as framed bytes (output of
|
||||
`encodeFramedBitset` from `@suaveplan/drive-mesh-protocol`).
|
||||
- EMA latency tracker (α = 0.3).
|
||||
- Composite score: `2 * (100 / (rtt+1)) + 3 * successRate - 5 *
|
||||
errorRate`. Weights documented in the README.
|
||||
- Bad-sig denylist with two modes (`"session"` clears on stop;
|
||||
`"persistent"` survives).
|
||||
- `rank(fileId, chunkId)` returns scored candidates in
|
||||
descending-score order, filtered to peers that claim availability of
|
||||
the chunk.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PeerRegistry } from "@suaveplan/drive-mesh/peer-registry/peer-registry";
|
||||
|
||||
const registry = new PeerRegistry();
|
||||
registry.addOrUpdate("peerA", { transports: ["https"], rttMs: 80 });
|
||||
registry.recordAvailability("peerA", "abcd…", framedBitset);
|
||||
const candidates = registry.rank("abcd…", 7);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PeerRegistry } from "@suaveplan/drive-mesh/peer-registry/peer-registry";
|
||||
|
||||
const registry = new PeerRegistry({
|
||||
clock: { now: () => fakeClock.now() },
|
||||
denylistMode: "persistent",
|
||||
});
|
||||
|
||||
registry.addOrUpdate("peerA", { transports: ["webrtc", "https"] });
|
||||
registry.recordRtt("peerA", 50);
|
||||
registry.recordSuccess("peerA");
|
||||
registry.recordSuccess("peerA");
|
||||
|
||||
registry.addOrUpdate("peerB", { transports: ["https"] });
|
||||
registry.recordRtt("peerB", 400);
|
||||
registry.recordFailure("peerB");
|
||||
|
||||
const top = registry.rank("file-id-hex", -1);
|
||||
// peerA outranks peerB because of lower RTT and higher success rate.
|
||||
|
||||
registry.recordBadSig("peerB");
|
||||
console.log(registry.isDenied("peerB")); // true
|
||||
console.log(registry.denialReason("peerB")); // "bad_sig"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class PeerRegistry`:
|
||||
- `constructor(options?: { clock?; denylistMode? })`.
|
||||
- `addOrUpdate(peerId, update?): PeerInfo` — idempotent insertion /
|
||||
merge.
|
||||
- `forget(peerId): void`.
|
||||
- `forgetAll(): void` — clears the registry; clears the denylist
|
||||
iff `denylistMode === "session"`.
|
||||
- `recordAvailability(peerId, fileId, framedBitset): void`.
|
||||
- `recordRtt(peerId, rttMs): void` — α=0.3 EMA.
|
||||
- `recordSuccess(peerId): void`.
|
||||
- `recordFailure(peerId): void`.
|
||||
- `recordBadSig(peerId): void` — moves to denylist
|
||||
(`reason="bad_sig"`).
|
||||
- `deny(peerId, reason): void` — denylist with explicit reason.
|
||||
- `removeFromDenylist(peerId): void`.
|
||||
- `isDenied(peerId): boolean`.
|
||||
- `denialReason(peerId): PeerDenialReason | undefined`.
|
||||
- `peers(): IterableIterator<PeerInfo>`.
|
||||
- `rank(fileId, chunkId): PeerInfo[]` — descending-score order.
|
||||
- `get(peerId): PeerInfo | undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`addOrUpdate` returns the post-merge snapshot rather than `void` so
|
||||
the mesh can immediately emit `peer:joined`/`peer:scored` events with
|
||||
the canonical shape without re-querying. Snapshots are fresh objects
|
||||
on every call so callers may not assume reference equality.
|
||||
|
||||
`rank(fileId, -1)` is a sentinel meaning "any peer with any
|
||||
availability for the file" — the gossip engine uses it to fan-out
|
||||
advertisements without requiring a specific chunk. The negative path
|
||||
is the only branch that skips the bitset bit-check, which keeps the
|
||||
hot-path tight.
|
||||
|
||||
The score formula is intentionally simple: weighted-sum of latency,
|
||||
success rate, and error rate. The weights are tuned so that a peer
|
||||
with double the RTT of another remains preferred if it has a higher
|
||||
success rate, which matches the drive-webrtc-era tuning. Bad-sig
|
||||
peers are pinned at `-Infinity` so they sort to the bottom of every
|
||||
ranking without special-casing the consumer.
|
||||
@@ -0,0 +1,275 @@
|
||||
import { encodeFramedBitset } from "@suaveplan/drive-mesh-protocol";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PeerRegistry } from "./peer-registry.js";
|
||||
|
||||
function bitsetOf(
|
||||
chunkIds: readonly number[],
|
||||
totalChunks: number
|
||||
): Uint8Array {
|
||||
const raw = new Uint8Array(Math.ceil(totalChunks / 8));
|
||||
for (const id of chunkIds) raw[id >> 3] |= 1 << (id & 7);
|
||||
return encodeFramedBitset(raw, totalChunks);
|
||||
}
|
||||
|
||||
describe("PeerRegistry", () => {
|
||||
it("admits and snapshots a new peer", { timeout: 5000 }, () => {
|
||||
const r = new PeerRegistry();
|
||||
const info = r.addOrUpdate("peerA", { transports: ["https"] });
|
||||
expect(info.peerId).toBe("peerA");
|
||||
expect(info.transports).toEqual(["https"]);
|
||||
expect(info.rttMs).toBe(null);
|
||||
});
|
||||
|
||||
it("merges updates onto an existing peer", { timeout: 5000 }, () => {
|
||||
const r = new PeerRegistry();
|
||||
r.addOrUpdate("peerA", { transports: ["https"] });
|
||||
const info = r.addOrUpdate("peerA", {
|
||||
transports: ["https", "webrtc"],
|
||||
rttMs: 50,
|
||||
lastSeen: 1_700_000_000_000,
|
||||
});
|
||||
expect(info.transports).toEqual(["https", "webrtc"]);
|
||||
expect(info.rttMs).toBe(50);
|
||||
expect(info.lastSeen).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it("addOrUpdate with no fields keeps an existing peer alive", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let now = 1000;
|
||||
const r = new PeerRegistry({ clock: () => now });
|
||||
r.addOrUpdate("peerA", { transports: ["https"] });
|
||||
now = 2000;
|
||||
const info = r.addOrUpdate("peerA");
|
||||
expect(info.lastSeen).toBe(2000);
|
||||
});
|
||||
|
||||
it("uses defaults for clock and denylistMode", { timeout: 5000 }, () => {
|
||||
const r = new PeerRegistry();
|
||||
const info = r.addOrUpdate("peerA");
|
||||
expect(info.lastSeen).toBeGreaterThan(0);
|
||||
// session denylist clears on forgetAll:
|
||||
r.recordBadSig("peerA");
|
||||
r.forgetAll();
|
||||
expect(r.isDenied("peerA")).toBe(false);
|
||||
});
|
||||
|
||||
it("forget drops the peer but preserves denylist", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new PeerRegistry({ denylistMode: "persistent" });
|
||||
r.addOrUpdate("peerA");
|
||||
r.recordBadSig("peerA");
|
||||
r.forget("peerA");
|
||||
expect(r.isDenied("peerA")).toBe(true);
|
||||
});
|
||||
|
||||
it("forgetAll preserves persistent denylist", { timeout: 5000 }, () => {
|
||||
const r = new PeerRegistry({ denylistMode: "persistent" });
|
||||
r.addOrUpdate("peerA");
|
||||
r.recordBadSig("peerA");
|
||||
r.forgetAll();
|
||||
expect(r.isDenied("peerA")).toBe(true);
|
||||
});
|
||||
|
||||
it("recordAvailability is no-op for unknown peers", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new PeerRegistry();
|
||||
const bs = bitsetOf([0], 8);
|
||||
r.recordAvailability("peerX", "f1", bs);
|
||||
expect(r.get("peerX")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recordAvailability stores the bitset on a known peer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let now = 100;
|
||||
const r = new PeerRegistry({ clock: () => now });
|
||||
r.addOrUpdate("peerA");
|
||||
now = 200;
|
||||
const bs = bitsetOf([3], 8);
|
||||
r.recordAvailability("peerA", "f1", bs);
|
||||
const info = r.get("peerA");
|
||||
expect(info?.availability.get("f1")).toEqual(bs);
|
||||
expect(info?.lastSeen).toBe(200);
|
||||
});
|
||||
|
||||
it("recordRtt initialises and EMA-smooths", { timeout: 5000 }, () => {
|
||||
const r = new PeerRegistry();
|
||||
r.recordRtt("peerX", 50);
|
||||
expect(r.get("peerX")).toBeUndefined(); // no-op for unknown peer
|
||||
r.addOrUpdate("peerA");
|
||||
r.recordRtt("peerA", 100);
|
||||
expect(r.get("peerA")?.rttMs).toBe(100);
|
||||
r.recordRtt("peerA", 200);
|
||||
// ema with α=0.3: 0.3*200 + 0.7*100 = 130
|
||||
expect(r.get("peerA")?.rttMs).toBeCloseTo(130, 5);
|
||||
});
|
||||
|
||||
it("recordSuccess / recordFailure update score", { timeout: 5000 }, () => {
|
||||
const r = new PeerRegistry();
|
||||
r.recordSuccess("ghost");
|
||||
r.recordFailure("ghost");
|
||||
r.addOrUpdate("peerA");
|
||||
r.recordSuccess("peerA");
|
||||
r.recordSuccess("peerA");
|
||||
r.addOrUpdate("peerB");
|
||||
r.recordFailure("peerB");
|
||||
const a = r.get("peerA");
|
||||
const b = r.get("peerB");
|
||||
expect(a?.score).toBeGreaterThan(b?.score ?? 0);
|
||||
});
|
||||
|
||||
it("recordBadSig moves to denylist and pins score", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new PeerRegistry();
|
||||
r.addOrUpdate("peerA");
|
||||
r.recordBadSig("peerA");
|
||||
expect(r.isDenied("peerA")).toBe(true);
|
||||
expect(r.denialReason("peerA")).toBe("bad_sig");
|
||||
expect(r.get("peerA")?.score).toBe(Number.NEGATIVE_INFINITY);
|
||||
});
|
||||
|
||||
it("recordBadSig on unknown peer still records denylist", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new PeerRegistry();
|
||||
r.recordBadSig("peerGhost");
|
||||
expect(r.isDenied("peerGhost")).toBe(true);
|
||||
});
|
||||
|
||||
it("deny accepts an explicit reason", { timeout: 5000 }, () => {
|
||||
const r = new PeerRegistry();
|
||||
r.addOrUpdate("peerA");
|
||||
r.deny("peerA", "manual");
|
||||
expect(r.denialReason("peerA")).toBe("manual");
|
||||
expect(r.get("peerA")?.score).toBe(Number.NEGATIVE_INFINITY);
|
||||
});
|
||||
|
||||
it("deny on unknown peer is recorded but has no score effect", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new PeerRegistry();
|
||||
r.deny("peerGhost", "manual");
|
||||
expect(r.isDenied("peerGhost")).toBe(true);
|
||||
});
|
||||
|
||||
it("removeFromDenylist restores score", { timeout: 5000 }, () => {
|
||||
const r = new PeerRegistry({ denylistMode: "persistent" });
|
||||
r.addOrUpdate("peerA");
|
||||
r.recordBadSig("peerA");
|
||||
r.removeFromDenylist("peerA");
|
||||
expect(r.isDenied("peerA")).toBe(false);
|
||||
expect(r.get("peerA")?.score).not.toBe(Number.NEGATIVE_INFINITY);
|
||||
});
|
||||
|
||||
it("removeFromDenylist tolerates unknown peers", { timeout: 5000 }, () => {
|
||||
const r = new PeerRegistry();
|
||||
expect(() => r.removeFromDenylist("ghost")).not.toThrow();
|
||||
});
|
||||
|
||||
it("addOrUpdate on denylisted peer returns a denied snapshot", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new PeerRegistry({ denylistMode: "persistent" });
|
||||
r.recordBadSig("peerA");
|
||||
const info = r.addOrUpdate("peerA", { transports: ["x"] });
|
||||
expect(info.score).toBe(Number.NEGATIVE_INFINITY);
|
||||
expect(info.transports).toEqual([]);
|
||||
});
|
||||
|
||||
it("peers excludes denylisted entries", { timeout: 5000 }, () => {
|
||||
const r = new PeerRegistry({ denylistMode: "persistent" });
|
||||
r.addOrUpdate("peerA");
|
||||
r.addOrUpdate("peerB");
|
||||
r.recordBadSig("peerB");
|
||||
const ids = [...r.peers()].map((p) => p.peerId).sort();
|
||||
expect(ids).toEqual(["peerA"]);
|
||||
});
|
||||
|
||||
it("get returns denied snapshot for denied peer", { timeout: 5000 }, () => {
|
||||
const r = new PeerRegistry({ denylistMode: "persistent" });
|
||||
r.addOrUpdate("peerA");
|
||||
r.recordBadSig("peerA");
|
||||
const info = r.get("peerA");
|
||||
expect(info?.score).toBe(Number.NEGATIVE_INFINITY);
|
||||
});
|
||||
|
||||
it("rank filters by chunk availability and orders by score", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new PeerRegistry();
|
||||
r.addOrUpdate("peerA");
|
||||
r.addOrUpdate("peerB");
|
||||
r.addOrUpdate("peerC");
|
||||
r.recordRtt("peerA", 30);
|
||||
r.recordSuccess("peerA");
|
||||
r.recordRtt("peerB", 200);
|
||||
r.recordFailure("peerB");
|
||||
r.recordAvailability("peerA", "f1", bitsetOf([0, 1, 2], 8));
|
||||
r.recordAvailability("peerB", "f1", bitsetOf([0, 1], 8));
|
||||
r.recordAvailability("peerC", "f1", bitsetOf([3], 8));
|
||||
// chunkId=0: A and B but not C
|
||||
const c0 = r.rank("f1", 0).map((p) => p.peerId);
|
||||
expect(c0).toEqual(["peerA", "peerB"]);
|
||||
// chunkId=3: only C
|
||||
const c3 = r.rank("f1", 3).map((p) => p.peerId);
|
||||
expect(c3).toEqual(["peerC"]);
|
||||
// chunkId=-1: all peers with any availability
|
||||
const all = r
|
||||
.rank("f1", -1)
|
||||
.map((p) => p.peerId)
|
||||
.sort();
|
||||
expect(all).toEqual(["peerA", "peerB", "peerC"]);
|
||||
});
|
||||
|
||||
it("rank skips denylisted peers", { timeout: 5000 }, () => {
|
||||
const r = new PeerRegistry({ denylistMode: "persistent" });
|
||||
r.addOrUpdate("peerA");
|
||||
r.addOrUpdate("peerB");
|
||||
r.recordAvailability("peerA", "f1", bitsetOf([0], 8));
|
||||
r.recordAvailability("peerB", "f1", bitsetOf([0], 8));
|
||||
r.recordBadSig("peerA");
|
||||
const ranked = r.rank("f1", 0).map((p) => p.peerId);
|
||||
expect(ranked).toEqual(["peerB"]);
|
||||
});
|
||||
|
||||
it("rank ignores peers without availability for the file", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new PeerRegistry();
|
||||
r.addOrUpdate("peerA");
|
||||
const ranked = r.rank("f1", 0);
|
||||
expect(ranked).toEqual([]);
|
||||
});
|
||||
|
||||
it("rank rejects out-of-range chunkId", { timeout: 5000 }, () => {
|
||||
const r = new PeerRegistry();
|
||||
r.addOrUpdate("peerA");
|
||||
r.recordAvailability("peerA", "f1", bitsetOf([0], 8));
|
||||
// bitLength=8, chunkId=8 -> out of range
|
||||
expect(r.rank("f1", 8)).toEqual([]);
|
||||
});
|
||||
|
||||
it("rank handles truncated bitset bytes safely", { timeout: 5000 }, () => {
|
||||
const r = new PeerRegistry();
|
||||
r.addOrUpdate("peerA");
|
||||
// Hand-craft a framed bitset whose body byte index is OOB.
|
||||
const framed = new Uint8Array(12); // header only, no body
|
||||
new DataView(framed.buffer).setUint32(4, 8, false);
|
||||
r.recordAvailability("peerA", "f1", framed);
|
||||
// hasChunkInFramedBitset returns false because body byte is undefined
|
||||
expect(r.rank("f1", 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it("rank rejects under-sized framed bitset header", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new PeerRegistry();
|
||||
r.addOrUpdate("peerA");
|
||||
r.recordAvailability("peerA", "f1", new Uint8Array(4));
|
||||
expect(r.rank("f1", 0)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* `PeerRegistry` — file-availability + scoring overlay on the live
|
||||
* peer set.
|
||||
*
|
||||
* Identity events (`peer:joined` / `peer:left`) come from
|
||||
* `network-plugin-registry` via the `NetworkBus`. The registry adds
|
||||
* three concerns on top: per-`fileId` availability bitsets, an EMA
|
||||
* latency tracker, and a composite peer score used by the parallel
|
||||
* puller to pick candidates.
|
||||
*
|
||||
* Denylist semantics:
|
||||
* - A peer that ever delivers a chunk whose server signature fails
|
||||
* verification is added to the denylist with `score = -Infinity`.
|
||||
* - In `"session"` mode (default) the denylist resets on
|
||||
* `forgetAll()` (called by `mesh.stop()`).
|
||||
* - In `"persistent"` mode the denylist survives stops; only an
|
||||
* explicit `removeFromDenylist()` clears it.
|
||||
*/
|
||||
|
||||
import type { Clock, PeerId } from "@suaveplan/types";
|
||||
import type {
|
||||
DenylistMode,
|
||||
PeerDenialReason,
|
||||
PeerInfo,
|
||||
PeerUpdate,
|
||||
} from "../../types/index.js";
|
||||
|
||||
const RTT_EMA_ALPHA = 0.3;
|
||||
|
||||
interface MutablePeer {
|
||||
transports: Set<string>;
|
||||
rttMs: number | null;
|
||||
lastSeen: number;
|
||||
success: number;
|
||||
failure: number;
|
||||
score: number;
|
||||
availability: Map<string, Uint8Array>;
|
||||
}
|
||||
|
||||
export interface PeerRegistryOptions {
|
||||
readonly clock?: Clock;
|
||||
readonly denylistMode?: DenylistMode;
|
||||
}
|
||||
|
||||
const DEFAULT_CLOCK: Clock = () => Date.now();
|
||||
|
||||
export class PeerRegistry {
|
||||
readonly #peers = new Map<PeerId, MutablePeer>();
|
||||
readonly #denylist = new Map<PeerId, PeerDenialReason>();
|
||||
readonly #clock: Clock;
|
||||
readonly #denylistMode: DenylistMode;
|
||||
|
||||
public constructor(options: PeerRegistryOptions = {}) {
|
||||
this.#clock = options.clock ?? DEFAULT_CLOCK;
|
||||
this.#denylistMode = options.denylistMode ?? "session";
|
||||
}
|
||||
|
||||
/** Add a peer or merge updates onto an existing entry. */
|
||||
public addOrUpdate(peerId: PeerId, update: PeerUpdate = {}): PeerInfo {
|
||||
if (this.#denylist.has(peerId)) {
|
||||
return this.#snapshotDenied(peerId);
|
||||
}
|
||||
const existing = this.#peers.get(peerId);
|
||||
const now = update.lastSeen ?? this.#clock();
|
||||
if (existing === undefined) {
|
||||
const created: MutablePeer = {
|
||||
transports: new Set(update.transports ?? []),
|
||||
rttMs: update.rttMs ?? null,
|
||||
lastSeen: now,
|
||||
success: 0,
|
||||
failure: 0,
|
||||
score: 0,
|
||||
availability: new Map(),
|
||||
};
|
||||
this.#peers.set(peerId, created);
|
||||
return this.#snapshot(peerId, created);
|
||||
}
|
||||
if (update.transports !== undefined) {
|
||||
existing.transports = new Set(update.transports);
|
||||
}
|
||||
if (update.rttMs !== undefined) {
|
||||
existing.rttMs = update.rttMs;
|
||||
}
|
||||
existing.lastSeen = now;
|
||||
existing.score = this.#computeScore(existing);
|
||||
return this.#snapshot(peerId, existing);
|
||||
}
|
||||
|
||||
/** Drop the peer entirely. Denylist state is preserved. */
|
||||
public forget(peerId: PeerId): void {
|
||||
this.#peers.delete(peerId);
|
||||
}
|
||||
|
||||
/** Drop every peer; clears the denylist only in session mode. */
|
||||
public forgetAll(): void {
|
||||
this.#peers.clear();
|
||||
if (this.#denylistMode === "session") {
|
||||
this.#denylist.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** Record (or update) the bitset of chunks `peerId` holds for `fileId`. */
|
||||
public recordAvailability(
|
||||
peerId: PeerId,
|
||||
fileId: string,
|
||||
framedBitset: Uint8Array
|
||||
): void {
|
||||
const peer = this.#peers.get(peerId);
|
||||
if (peer === undefined) return;
|
||||
peer.availability.set(fileId, framedBitset);
|
||||
peer.lastSeen = this.#clock();
|
||||
}
|
||||
|
||||
/** Update the EMA RTT after a successful or failed chunk fetch. */
|
||||
public recordRtt(peerId: PeerId, rttMs: number): void {
|
||||
const peer = this.#peers.get(peerId);
|
||||
if (peer === undefined) return;
|
||||
peer.rttMs =
|
||||
peer.rttMs === null
|
||||
? rttMs
|
||||
: RTT_EMA_ALPHA * rttMs + (1 - RTT_EMA_ALPHA) * peer.rttMs;
|
||||
peer.score = this.#computeScore(peer);
|
||||
}
|
||||
|
||||
/** Record a successful chunk delivery. */
|
||||
public recordSuccess(peerId: PeerId): void {
|
||||
const peer = this.#peers.get(peerId);
|
||||
if (peer === undefined) return;
|
||||
peer.success += 1;
|
||||
peer.score = this.#computeScore(peer);
|
||||
}
|
||||
|
||||
/** Record a non-signature failure (timeout, transport error). */
|
||||
public recordFailure(peerId: PeerId): void {
|
||||
const peer = this.#peers.get(peerId);
|
||||
if (peer === undefined) return;
|
||||
peer.failure += 1;
|
||||
peer.score = this.#computeScore(peer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the peer to the denylist with `score = -Infinity`. Idempotent.
|
||||
*/
|
||||
public recordBadSig(peerId: PeerId): void {
|
||||
this.#denylist.set(peerId, "bad_sig");
|
||||
const peer = this.#peers.get(peerId);
|
||||
if (peer !== undefined) peer.score = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
/** Add a peer to the denylist for a non-signature reason. */
|
||||
public deny(peerId: PeerId, reason: PeerDenialReason): void {
|
||||
this.#denylist.set(peerId, reason);
|
||||
const peer = this.#peers.get(peerId);
|
||||
if (peer !== undefined) peer.score = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
/** Remove the peer from the denylist. */
|
||||
public removeFromDenylist(peerId: PeerId): void {
|
||||
this.#denylist.delete(peerId);
|
||||
const peer = this.#peers.get(peerId);
|
||||
if (peer !== undefined) peer.score = this.#computeScore(peer);
|
||||
}
|
||||
|
||||
/** Is the peer currently denylisted? */
|
||||
public isDenied(peerId: PeerId): boolean {
|
||||
return this.#denylist.has(peerId);
|
||||
}
|
||||
|
||||
/** Reason the peer was denylisted (or `undefined`). */
|
||||
public denialReason(peerId: PeerId): PeerDenialReason | undefined {
|
||||
return this.#denylist.get(peerId);
|
||||
}
|
||||
|
||||
/** Snapshot of every live (non-denied) peer. */
|
||||
public peers(): IterableIterator<PeerInfo> {
|
||||
const out: PeerInfo[] = [];
|
||||
for (const [peerId, peer] of this.#peers) {
|
||||
if (this.#denylist.has(peerId)) continue;
|
||||
out.push(this.#snapshot(peerId, peer));
|
||||
}
|
||||
return out[Symbol.iterator]();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank peers known to hold `chunkId` of `fileId`, best score first.
|
||||
* A `chunkId` of `-1` ranks every peer with any availability for
|
||||
* the file (used by the gossip engine for fan-out).
|
||||
*/
|
||||
public rank(fileId: string, chunkId: number): PeerInfo[] {
|
||||
const out: { info: PeerInfo; score: number }[] = [];
|
||||
for (const [peerId, peer] of this.#peers) {
|
||||
if (this.#denylist.has(peerId)) continue;
|
||||
const bitset = peer.availability.get(fileId);
|
||||
if (bitset === undefined) continue;
|
||||
if (chunkId >= 0 && !hasChunkInFramedBitset(bitset, chunkId)) {
|
||||
continue;
|
||||
}
|
||||
out.push({ info: this.#snapshot(peerId, peer), score: peer.score });
|
||||
}
|
||||
out.sort((a, b) => b.score - a.score);
|
||||
return out.map((e) => e.info);
|
||||
}
|
||||
|
||||
/** Lookup a single peer snapshot. */
|
||||
public get(peerId: PeerId): PeerInfo | undefined {
|
||||
const peer = this.#peers.get(peerId);
|
||||
if (peer === undefined) return undefined;
|
||||
if (this.#denylist.has(peerId)) return this.#snapshotDenied(peerId);
|
||||
return this.#snapshot(peerId, peer);
|
||||
}
|
||||
|
||||
#snapshot(peerId: PeerId, peer: MutablePeer): PeerInfo {
|
||||
return {
|
||||
peerId,
|
||||
transports: [...peer.transports],
|
||||
rttMs: peer.rttMs,
|
||||
lastSeen: peer.lastSeen,
|
||||
score: peer.score,
|
||||
availability: new Map(peer.availability),
|
||||
};
|
||||
}
|
||||
|
||||
#snapshotDenied(peerId: PeerId): PeerInfo {
|
||||
return {
|
||||
peerId,
|
||||
transports: [],
|
||||
rttMs: null,
|
||||
lastSeen: this.#clock(),
|
||||
score: Number.NEGATIVE_INFINITY,
|
||||
availability: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
#computeScore(peer: MutablePeer): number {
|
||||
const attempts = peer.success + peer.failure;
|
||||
const successRate = attempts === 0 ? 0.5 : peer.success / attempts;
|
||||
const errorRate = attempts === 0 ? 0 : peer.failure / attempts;
|
||||
const latencyTerm = peer.rttMs === null ? 1 : 100 / (peer.rttMs + 1);
|
||||
// weights documented in README
|
||||
return 2 * latencyTerm + 3 * successRate - 5 * errorRate;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect bit `chunkId` of a framed bitset.
|
||||
*
|
||||
* Framed bitsets are produced by `encodeFramedBitset` from
|
||||
* `@suaveplan/drive-mesh-protocol`: 12 header bytes (magic + bitlen +
|
||||
* bytelen) followed by the raw bitmap. We bounds-check the header and
|
||||
* extract the bit by direct index.
|
||||
*/
|
||||
function hasChunkInFramedBitset(framed: Uint8Array, chunkId: number): boolean {
|
||||
const HEADER = 12;
|
||||
if (framed.length < HEADER) return false;
|
||||
const view = new DataView(
|
||||
framed.buffer,
|
||||
framed.byteOffset,
|
||||
framed.byteLength
|
||||
);
|
||||
const bitLength = view.getUint32(4, false);
|
||||
if (chunkId < 0 || chunkId >= bitLength) return false;
|
||||
const byteIndex = HEADER + (chunkId >> 3);
|
||||
const bitMask = 1 << (chunkId & 7);
|
||||
const byte = framed[byteIndex];
|
||||
if (byte === undefined) return false;
|
||||
return (byte & bitMask) !== 0;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
ParallelPuller,
|
||||
type ParallelPullerOptions,
|
||||
} from "./parallel-puller.js";
|
||||
@@ -0,0 +1,126 @@
|
||||
# `parallel-puller.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`ParallelPuller` is the chunk-fetch scheduler that turns "I want chunk
|
||||
N of file F" into a verified `Uint8Array`. The puller asks the
|
||||
`PeerRegistry` for ranked candidates, issues a request to the best
|
||||
peer, waits `racingWindowMs`, then issues parallel requests to the
|
||||
runners-up. The first response whose server signature verifies wins;
|
||||
the other in-flight attempts are aborted via a shared
|
||||
`AbortController`.
|
||||
|
||||
The puller is the mesh's primary trust enforcement point. Every byte
|
||||
returned from a transport flows through `verifyChunkSig` before the
|
||||
puller acknowledges success. A signature failure moves the offending
|
||||
peer to the registry's denylist with `score = -Infinity` and surfaces
|
||||
`DriveMeshError(SIG_VERIFY_FAILED)` — the orchestrator can choose to
|
||||
fall back to the server rather than retry on peers.
|
||||
|
||||
## Features
|
||||
|
||||
- Top-K candidate selection (default 3).
|
||||
- Racing-window staggered issuance (default 250 ms).
|
||||
- Shared `AbortController` short-circuits losing attempts.
|
||||
- Mandatory `verifyChunkSig` gate against the configured
|
||||
`serverPubKey`.
|
||||
- Bad-sig denylisting via `registry.recordBadSig`.
|
||||
- Non-signature failures update success / failure counters so
|
||||
ranking adapts.
|
||||
- `maxParallelRequests` global cap.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ParallelPuller } from "@suaveplan/drive-mesh/scheduler/parallel-puller";
|
||||
|
||||
const puller = new ParallelPuller({
|
||||
registry,
|
||||
transports: [httpsTransport],
|
||||
serverPubKey,
|
||||
});
|
||||
const bytes = await puller.pull("file-id-hex", 0);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ParallelPuller } from "@suaveplan/drive-mesh/scheduler/parallel-puller";
|
||||
|
||||
const puller = new ParallelPuller({
|
||||
registry,
|
||||
transports: [webrtcTransport, httpsTransport],
|
||||
serverPubKey,
|
||||
racingWindowMs: 150,
|
||||
topK: 4,
|
||||
maxParallelRequests: 16,
|
||||
clock: fakeClock,
|
||||
onAttempt: (peerId, fileId, chunkId) =>
|
||||
log.debug("attempt", peerId, fileId, chunkId),
|
||||
onSuccess: (peerId, _f, _c, bytes, elapsedMs) =>
|
||||
log.info("served", peerId, bytes, elapsedMs),
|
||||
onBadSig: peerId => log.warn("denylisting", peerId),
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 5_000);
|
||||
try {
|
||||
const bytes = await puller.pull(fileId, chunkId, controller.signal);
|
||||
} catch (err) {
|
||||
if (err instanceof DriveMeshError && err.code === DRIVE_MESH_CODES.ABORTED) {
|
||||
// caller-driven cancellation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ParallelPuller`:
|
||||
- `constructor(options: ParallelPullerOptions)`.
|
||||
- `pull(fileId, chunkId, signal?): Promise<Uint8Array>`.
|
||||
- `inflight: number` (read-only) — in-flight request count.
|
||||
- `ParallelPullerOptions`:
|
||||
- `registry`, `transports`, `serverPubKey` (required).
|
||||
- `maxParallelRequests?` (default `8`).
|
||||
- `racingWindowMs?` (default `250`).
|
||||
- `topK?` (default `3`).
|
||||
- `clock?`.
|
||||
- `onAttempt?(peerId, fileId, chunkId)`.
|
||||
- `onSuccess?(peerId, fileId, chunkId, bytes, elapsedMs)`.
|
||||
- `onFailure?(peerId, fileId, chunkId, reason)`.
|
||||
- `onBadSig?(peerId, fileId, chunkId)`.
|
||||
- Throws:
|
||||
- `DRIVE_MESH.CHUNK_UNAVAILABLE` — no candidate peers.
|
||||
- `DRIVE_MESH.SIG_VERIFY_FAILED` — winning peer returned bad sig.
|
||||
- `DRIVE_MESH.TRANSPORT_FAILED` — single-attempt transport failure.
|
||||
- `DRIVE_MESH.ALL_PEERS_FAILED` — every candidate failed.
|
||||
- `DRIVE_MESH.ABORTED` — caller-provided signal aborted.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`firstSettled` is hand-rolled rather than using `Promise.any`:
|
||||
`Promise.any` resolves on first success but does NOT abort losing
|
||||
attempts, leaving them in flight wastefully. The shared
|
||||
`AbortController` is the cancellation primitive — `controller.abort()`
|
||||
fires both when a winner appears and when the outer caller aborts.
|
||||
|
||||
`SIG_VERIFY_FAILED` short-circuits the "all peers failed" wrapping:
|
||||
the orchestrator distinguishes "every peer failed transport" (try
|
||||
server fallback) from "winning peer was Byzantine" (denylist + retry
|
||||
on a different peer). Re-wrapping the bad-sig error inside
|
||||
`ALL_PEERS_FAILED` would lose that distinction.
|
||||
|
||||
`maxParallelRequests` is enforced by a yielding micro-loop in
|
||||
`#awaitSlot`. The loop is bounded by the number of concurrent `pull`
|
||||
calls — drive-mesh issues `pull` from a small, controlled set of
|
||||
async contexts so the loop never spins indefinitely. Under heavier
|
||||
fan-in the orchestrator should queue requests in front of the puller
|
||||
rather than issuing every chunk concurrently.
|
||||
|
||||
Bad-sig denylist is permanent within the registry's denylist scope:
|
||||
`session` (default) clears on `mesh.stop()`, `persistent` survives.
|
||||
The puller does not concern itself with scope — the registry is the
|
||||
authority.
|
||||
@@ -0,0 +1,406 @@
|
||||
import { encodeFramedBitset } from "@suaveplan/drive-mesh-protocol";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
Broker,
|
||||
FakeDriveTransport,
|
||||
} from "../../../tests/helpers/fake-transport.js";
|
||||
import {
|
||||
buildChunkBytes,
|
||||
makeKeypair,
|
||||
TEST_FILE_ID_HEX,
|
||||
} from "../../../tests/helpers/signed-chunk.js";
|
||||
import {
|
||||
DRIVE_MESH_CODES,
|
||||
DriveMeshError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { PeerRegistry } from "../../peer-registry/peer-registry/index.js";
|
||||
import { ParallelPuller } from "./parallel-puller.js";
|
||||
|
||||
function bitset(bits: readonly number[], total: number): Uint8Array {
|
||||
const raw = new Uint8Array(Math.ceil(total / 8));
|
||||
for (const b of bits) raw[b >> 3] |= 1 << (b & 7);
|
||||
return encodeFramedBitset(raw, total);
|
||||
}
|
||||
|
||||
const FILE_ID = TEST_FILE_ID_HEX;
|
||||
|
||||
function setupTwoPeers(): {
|
||||
broker: Broker;
|
||||
transport: FakeDriveTransport;
|
||||
registry: PeerRegistry;
|
||||
serverPubKey: Uint8Array;
|
||||
serverPrivKey: Uint8Array;
|
||||
} {
|
||||
const kp = makeKeypair(7);
|
||||
const broker = new Broker();
|
||||
const transport = new FakeDriveTransport({ broker, localPeerId: "local" });
|
||||
transport.addKnownPeer("peerA");
|
||||
transport.addKnownPeer("peerB");
|
||||
const registry = new PeerRegistry();
|
||||
registry.addOrUpdate("peerA", { transports: ["fake"] });
|
||||
registry.addOrUpdate("peerB", { transports: ["fake"] });
|
||||
registry.recordAvailability("peerA", FILE_ID, bitset([0, 1, 2], 8));
|
||||
registry.recordAvailability("peerB", FILE_ID, bitset([0, 1, 2], 8));
|
||||
return {
|
||||
broker,
|
||||
transport,
|
||||
registry,
|
||||
serverPubKey: kp.pubKey,
|
||||
serverPrivKey: kp.privKey,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ParallelPuller", () => {
|
||||
it("throws CHUNK_UNAVAILABLE when no peers know the chunk", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(1);
|
||||
const broker = new Broker();
|
||||
const transport = new FakeDriveTransport({ broker, localPeerId: "x" });
|
||||
const registry = new PeerRegistry();
|
||||
const puller = new ParallelPuller({
|
||||
registry,
|
||||
transports: [transport],
|
||||
serverPubKey: kp.pubKey,
|
||||
});
|
||||
await expect(puller.pull(FILE_ID, 0)).rejects.toMatchObject({
|
||||
code: DRIVE_MESH_CODES.CHUNK_UNAVAILABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects immediately when signal is already aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(1);
|
||||
const puller = new ParallelPuller({
|
||||
registry: new PeerRegistry(),
|
||||
transports: [],
|
||||
serverPubKey: kp.pubKey,
|
||||
});
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(puller.pull(FILE_ID, 0, ac.signal)).rejects.toMatchObject({
|
||||
code: DRIVE_MESH_CODES.ABORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns verified bytes from the top peer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = setupTwoPeers();
|
||||
const bytes = buildChunkBytes({
|
||||
serverPrivKey: ctx.serverPrivKey,
|
||||
fileIdHex: FILE_ID,
|
||||
chunkId: 0,
|
||||
});
|
||||
ctx.broker.serve("peerA", FILE_ID, 0, { bytes });
|
||||
ctx.registry.recordRtt("peerA", 10);
|
||||
ctx.registry.recordSuccess("peerA");
|
||||
const puller = new ParallelPuller({
|
||||
registry: ctx.registry,
|
||||
transports: [ctx.transport],
|
||||
serverPubKey: ctx.serverPubKey,
|
||||
});
|
||||
const out = await puller.pull(FILE_ID, 0);
|
||||
expect(out).toEqual(bytes);
|
||||
});
|
||||
|
||||
it("falls through to second peer when first throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = setupTwoPeers();
|
||||
const bytes = buildChunkBytes({
|
||||
serverPrivKey: ctx.serverPrivKey,
|
||||
fileIdHex: FILE_ID,
|
||||
chunkId: 0,
|
||||
});
|
||||
// peerA broken; peerB serves
|
||||
ctx.broker.serve("peerA", FILE_ID, 0, {
|
||||
throwReason: "transport down",
|
||||
});
|
||||
ctx.broker.serve("peerB", FILE_ID, 0, { bytes, delayMs: 5 });
|
||||
ctx.registry.recordRtt("peerA", 10);
|
||||
ctx.registry.recordSuccess("peerA");
|
||||
const puller = new ParallelPuller({
|
||||
registry: ctx.registry,
|
||||
transports: [ctx.transport],
|
||||
serverPubKey: ctx.serverPubKey,
|
||||
racingWindowMs: 1,
|
||||
});
|
||||
const out = await puller.pull(FILE_ID, 0);
|
||||
expect(out).toEqual(bytes);
|
||||
});
|
||||
|
||||
it("issues parallel attempts after racing window", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = setupTwoPeers();
|
||||
const bytes = buildChunkBytes({
|
||||
serverPrivKey: ctx.serverPrivKey,
|
||||
fileIdHex: FILE_ID,
|
||||
chunkId: 0,
|
||||
});
|
||||
// peerA delays 100 ms, peerB delays 5 ms -> after racing window, B wins
|
||||
ctx.broker.serve("peerA", FILE_ID, 0, { bytes, delayMs: 100 });
|
||||
ctx.broker.serve("peerB", FILE_ID, 0, { bytes, delayMs: 5 });
|
||||
ctx.registry.recordRtt("peerA", 10);
|
||||
ctx.registry.recordSuccess("peerA");
|
||||
const puller = new ParallelPuller({
|
||||
registry: ctx.registry,
|
||||
transports: [ctx.transport],
|
||||
serverPubKey: ctx.serverPubKey,
|
||||
racingWindowMs: 10,
|
||||
});
|
||||
const out = await puller.pull(FILE_ID, 0);
|
||||
expect(out).toEqual(bytes);
|
||||
});
|
||||
|
||||
it("surfaces SIG_VERIFY_FAILED and denylists the peer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = setupTwoPeers();
|
||||
// Build bytes signed by a *different* key so verify fails
|
||||
const wrong = makeKeypair(99);
|
||||
const badBytes = buildChunkBytes({
|
||||
serverPrivKey: wrong.privKey,
|
||||
fileIdHex: FILE_ID,
|
||||
chunkId: 0,
|
||||
});
|
||||
ctx.broker.serve("peerA", FILE_ID, 0, { bytes: badBytes });
|
||||
// Drop peerB so peerA is the only candidate
|
||||
ctx.registry.forget("peerB");
|
||||
const puller = new ParallelPuller({
|
||||
registry: ctx.registry,
|
||||
transports: [ctx.transport],
|
||||
serverPubKey: ctx.serverPubKey,
|
||||
});
|
||||
await expect(puller.pull(FILE_ID, 0)).rejects.toMatchObject({
|
||||
code: DRIVE_MESH_CODES.SIG_VERIFY_FAILED,
|
||||
});
|
||||
expect(ctx.registry.isDenied("peerA")).toBe(true);
|
||||
});
|
||||
|
||||
it("emits ALL_PEERS_FAILED when every candidate throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = setupTwoPeers();
|
||||
ctx.broker.serve("peerA", FILE_ID, 0, { throwReason: "x" });
|
||||
ctx.broker.serve("peerB", FILE_ID, 0, { throwReason: "y" });
|
||||
const puller = new ParallelPuller({
|
||||
registry: ctx.registry,
|
||||
transports: [ctx.transport],
|
||||
serverPubKey: ctx.serverPubKey,
|
||||
racingWindowMs: 1,
|
||||
topK: 2,
|
||||
});
|
||||
await expect(puller.pull(FILE_ID, 0)).rejects.toMatchObject({
|
||||
code: DRIVE_MESH_CODES.ALL_PEERS_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits TRANSPORT_FAILED when no transport claims the peer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(2);
|
||||
const broker = new Broker();
|
||||
const transport = new FakeDriveTransport({ broker, localPeerId: "x" });
|
||||
// do NOT add peerA to transport.knownPeers
|
||||
const registry = new PeerRegistry();
|
||||
registry.addOrUpdate("peerA");
|
||||
registry.recordAvailability("peerA", FILE_ID, bitset([0], 8));
|
||||
const puller = new ParallelPuller({
|
||||
registry,
|
||||
transports: [transport],
|
||||
serverPubKey: kp.pubKey,
|
||||
});
|
||||
await expect(puller.pull(FILE_ID, 0)).rejects.toMatchObject({
|
||||
code: DRIVE_MESH_CODES.ALL_PEERS_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("fires observability hooks on success", { timeout: 5000 }, async () => {
|
||||
const ctx = setupTwoPeers();
|
||||
const bytes = buildChunkBytes({
|
||||
serverPrivKey: ctx.serverPrivKey,
|
||||
fileIdHex: FILE_ID,
|
||||
chunkId: 0,
|
||||
});
|
||||
ctx.broker.serve("peerA", FILE_ID, 0, { bytes });
|
||||
ctx.registry.recordRtt("peerA", 10);
|
||||
ctx.registry.recordSuccess("peerA");
|
||||
let attempts = 0;
|
||||
let successes = 0;
|
||||
const puller = new ParallelPuller({
|
||||
registry: ctx.registry,
|
||||
transports: [ctx.transport],
|
||||
serverPubKey: ctx.serverPubKey,
|
||||
onAttempt: () => {
|
||||
attempts += 1;
|
||||
},
|
||||
onSuccess: () => {
|
||||
successes += 1;
|
||||
},
|
||||
onFailure: () => undefined,
|
||||
onBadSig: () => undefined,
|
||||
});
|
||||
await puller.pull(FILE_ID, 0);
|
||||
expect(attempts).toBeGreaterThanOrEqual(1);
|
||||
expect(successes).toBe(1);
|
||||
});
|
||||
|
||||
it("inflight gauge updates around pull", { timeout: 5000 }, async () => {
|
||||
const ctx = setupTwoPeers();
|
||||
const bytes = buildChunkBytes({
|
||||
serverPrivKey: ctx.serverPrivKey,
|
||||
fileIdHex: FILE_ID,
|
||||
chunkId: 0,
|
||||
});
|
||||
ctx.broker.serve("peerA", FILE_ID, 0, { bytes, delayMs: 30 });
|
||||
const puller = new ParallelPuller({
|
||||
registry: ctx.registry,
|
||||
transports: [ctx.transport],
|
||||
serverPubKey: ctx.serverPubKey,
|
||||
});
|
||||
const promise = puller.pull(FILE_ID, 0);
|
||||
// allow microtask to enter pull
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(puller.inflight).toBeGreaterThanOrEqual(1);
|
||||
await promise;
|
||||
expect(puller.inflight).toBe(0);
|
||||
});
|
||||
|
||||
it("respects maxParallelRequests (busy-waits when slot is full)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = setupTwoPeers();
|
||||
const bytes0 = buildChunkBytes({
|
||||
serverPrivKey: ctx.serverPrivKey,
|
||||
fileIdHex: FILE_ID,
|
||||
chunkId: 0,
|
||||
});
|
||||
const bytes1 = buildChunkBytes({
|
||||
serverPrivKey: ctx.serverPrivKey,
|
||||
fileIdHex: FILE_ID,
|
||||
chunkId: 1,
|
||||
});
|
||||
// hold the first chunk long enough that p2 hits the busy-wait
|
||||
// loop body inside `#awaitSlot`.
|
||||
ctx.broker.serve("peerA", FILE_ID, 0, { bytes: bytes0, delayMs: 60 });
|
||||
ctx.broker.serve("peerA", FILE_ID, 1, { bytes: bytes1, delayMs: 5 });
|
||||
ctx.registry.recordAvailability(
|
||||
"peerA",
|
||||
FILE_ID,
|
||||
(() => {
|
||||
const raw = new Uint8Array(1);
|
||||
raw[0] = 0b111;
|
||||
return encodeFramedBitset(raw, 8);
|
||||
})()
|
||||
);
|
||||
const puller = new ParallelPuller({
|
||||
registry: ctx.registry,
|
||||
transports: [ctx.transport],
|
||||
serverPubKey: ctx.serverPubKey,
|
||||
maxParallelRequests: 1,
|
||||
racingWindowMs: 1,
|
||||
topK: 1,
|
||||
});
|
||||
const p1 = puller.pull(FILE_ID, 0);
|
||||
// Force p2 to enter #awaitSlot AFTER p1 has incremented inflight.
|
||||
await Promise.resolve();
|
||||
const p2 = puller.pull(FILE_ID, 1);
|
||||
const [a, b] = await Promise.all([p1, p2]);
|
||||
expect(a).toEqual(bytes0);
|
||||
expect(b).toEqual(bytes1);
|
||||
});
|
||||
|
||||
it("delay short-circuits when racingWindowMs is 0", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = setupTwoPeers();
|
||||
const bytes = buildChunkBytes({
|
||||
serverPrivKey: ctx.serverPrivKey,
|
||||
fileIdHex: FILE_ID,
|
||||
chunkId: 0,
|
||||
});
|
||||
ctx.broker.serve("peerA", FILE_ID, 0, { bytes, delayMs: 50 });
|
||||
ctx.broker.serve("peerB", FILE_ID, 0, { bytes, delayMs: 5 });
|
||||
const puller = new ParallelPuller({
|
||||
registry: ctx.registry,
|
||||
transports: [ctx.transport],
|
||||
serverPubKey: ctx.serverPubKey,
|
||||
racingWindowMs: 0,
|
||||
topK: 2,
|
||||
});
|
||||
const out = await puller.pull(FILE_ID, 0);
|
||||
expect(out).toEqual(bytes);
|
||||
});
|
||||
|
||||
it("wraps a non-Error throw inside firstSettled", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = setupTwoPeers();
|
||||
// Drop peerB so only peerA is a candidate
|
||||
ctx.registry.forget("peerB");
|
||||
// monkey-patch transport.requestChunk to throw a non-Error
|
||||
ctx.transport.requestChunk = (async () => {
|
||||
throw "string-rejection";
|
||||
}) as typeof ctx.transport.requestChunk;
|
||||
const puller = new ParallelPuller({
|
||||
registry: ctx.registry,
|
||||
transports: [ctx.transport],
|
||||
serverPubKey: ctx.serverPubKey,
|
||||
});
|
||||
await expect(puller.pull(FILE_ID, 0)).rejects.toMatchObject({
|
||||
code: DRIVE_MESH_CODES.ALL_PEERS_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores second winner after first attempt settles", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = setupTwoPeers();
|
||||
const bytes = buildChunkBytes({
|
||||
serverPrivKey: ctx.serverPrivKey,
|
||||
fileIdHex: FILE_ID,
|
||||
chunkId: 0,
|
||||
});
|
||||
// Both peers stage successful responses with no delay; with
|
||||
// racingWindowMs:0 both attempts are issued back-to-back and
|
||||
// both resolve. The microtask scheduler runs the `then`
|
||||
// handlers in registration order so the second handler hits
|
||||
// the `settled` guard.
|
||||
ctx.broker.serve("peerA", FILE_ID, 0, { bytes });
|
||||
ctx.broker.serve("peerB", FILE_ID, 0, { bytes });
|
||||
const puller = new ParallelPuller({
|
||||
registry: ctx.registry,
|
||||
transports: [ctx.transport],
|
||||
serverPubKey: ctx.serverPubKey,
|
||||
racingWindowMs: 0,
|
||||
topK: 2,
|
||||
});
|
||||
const out = await puller.pull(FILE_ID, 0);
|
||||
expect(out).toEqual(bytes);
|
||||
});
|
||||
|
||||
it("aborts in-flight attempts when outer signal aborts", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = setupTwoPeers();
|
||||
const bytes = buildChunkBytes({
|
||||
serverPrivKey: ctx.serverPrivKey,
|
||||
fileIdHex: FILE_ID,
|
||||
chunkId: 0,
|
||||
});
|
||||
ctx.broker.serve("peerA", FILE_ID, 0, { bytes, delayMs: 1000 });
|
||||
const ac = new AbortController();
|
||||
const puller = new ParallelPuller({
|
||||
registry: ctx.registry,
|
||||
transports: [ctx.transport],
|
||||
serverPubKey: ctx.serverPubKey,
|
||||
});
|
||||
const promise = puller.pull(FILE_ID, 0, ac.signal);
|
||||
setTimeout(() => ac.abort(), 5);
|
||||
await expect(promise).rejects.toBeInstanceOf(DriveMeshError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* `ParallelPuller` — racing chunk fetcher.
|
||||
*
|
||||
* Picks the top-K peers from `PeerRegistry.rank()`, issues a request
|
||||
* to the best peer, and after `racingWindowMs` issues parallel
|
||||
* requests to the runners-up. The first response that survives
|
||||
* signature verification wins; the others are cancelled via the
|
||||
* shared `AbortController`.
|
||||
*
|
||||
* Bad-sig responses surface as
|
||||
* `DriveMeshError(DRIVE_MESH.SIG_VERIFY_FAILED)` and trigger
|
||||
* `registry.recordBadSig` (which moves the peer to the denylist with
|
||||
* `score = -Infinity`).
|
||||
*
|
||||
* Other failures (transport error, timeout) are recorded via
|
||||
* `registry.recordFailure` so subsequent ranking deprioritises the
|
||||
* peer without banning it.
|
||||
*/
|
||||
|
||||
import { verifyChunkSig } from "@suaveplan/drive-plugin-chunks";
|
||||
import type { Clock, PeerId } from "@suaveplan/types";
|
||||
import {
|
||||
DRIVE_MESH_CODES,
|
||||
DriveMeshError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type { PeerRegistry } from "../../peer-registry/peer-registry/index.js";
|
||||
import type { DriveTransport, PeerInfo } from "../../types/index.js";
|
||||
|
||||
export interface ParallelPullerOptions {
|
||||
readonly registry: PeerRegistry;
|
||||
readonly transports: readonly DriveTransport[];
|
||||
readonly serverPubKey: Uint8Array;
|
||||
/** Default 8. */
|
||||
readonly maxParallelRequests?: number;
|
||||
/** Default 250. */
|
||||
readonly racingWindowMs?: number;
|
||||
/** Top-K candidates considered per request. Default 3. */
|
||||
readonly topK?: number;
|
||||
readonly clock?: Clock;
|
||||
/** Observability hooks. */
|
||||
readonly onAttempt?: (
|
||||
peerId: PeerId,
|
||||
fileId: string,
|
||||
chunkId: number
|
||||
) => void;
|
||||
readonly onSuccess?: (
|
||||
peerId: PeerId,
|
||||
fileId: string,
|
||||
chunkId: number,
|
||||
bytes: number,
|
||||
elapsedMs: number
|
||||
) => void;
|
||||
readonly onFailure?: (
|
||||
peerId: PeerId,
|
||||
fileId: string,
|
||||
chunkId: number,
|
||||
reason: string
|
||||
) => void;
|
||||
readonly onBadSig?: (
|
||||
peerId: PeerId,
|
||||
fileId: string,
|
||||
chunkId: number
|
||||
) => void;
|
||||
}
|
||||
|
||||
interface AttemptResult {
|
||||
readonly peerId: PeerId;
|
||||
readonly bytes: Uint8Array;
|
||||
readonly elapsedMs: number;
|
||||
}
|
||||
|
||||
export class ParallelPuller {
|
||||
readonly #registry: PeerRegistry;
|
||||
readonly #transports: readonly DriveTransport[];
|
||||
readonly #serverPubKey: Uint8Array;
|
||||
readonly #racingWindowMs: number;
|
||||
readonly #topK: number;
|
||||
readonly #maxParallel: number;
|
||||
readonly #clock: Clock;
|
||||
readonly #onAttempt: ParallelPullerOptions["onAttempt"];
|
||||
readonly #onSuccess: ParallelPullerOptions["onSuccess"];
|
||||
readonly #onFailure: ParallelPullerOptions["onFailure"];
|
||||
readonly #onBadSig: ParallelPullerOptions["onBadSig"];
|
||||
#inflight = 0;
|
||||
|
||||
public constructor(options: ParallelPullerOptions) {
|
||||
this.#registry = options.registry;
|
||||
this.#transports = options.transports;
|
||||
this.#serverPubKey = options.serverPubKey;
|
||||
this.#racingWindowMs = options.racingWindowMs ?? 250;
|
||||
this.#topK = options.topK ?? 3;
|
||||
this.#maxParallel = options.maxParallelRequests ?? 8;
|
||||
this.#clock = options.clock ?? (() => Date.now());
|
||||
this.#onAttempt = options.onAttempt;
|
||||
this.#onSuccess = options.onSuccess;
|
||||
this.#onFailure = options.onFailure;
|
||||
this.#onBadSig = options.onBadSig;
|
||||
}
|
||||
|
||||
/** In-flight request count — useful for backpressure assertions. */
|
||||
public get inflight(): number {
|
||||
return this.#inflight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to satisfy a chunk request via peers. Returns the verified
|
||||
* bytes on success, or throws `DriveMeshError(ALL_PEERS_FAILED)`
|
||||
* when every candidate is exhausted (the caller may then attempt
|
||||
* the server fallback).
|
||||
*/
|
||||
public async pull(
|
||||
fileId: string,
|
||||
chunkId: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<Uint8Array> {
|
||||
if (signal?.aborted) {
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.ABORTED,
|
||||
"request aborted before start"
|
||||
);
|
||||
}
|
||||
const candidates = this.#registry.rank(fileId, chunkId);
|
||||
if (candidates.length === 0) {
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.CHUNK_UNAVAILABLE,
|
||||
`no peers advertise chunk ${chunkId} of ${fileId}`
|
||||
);
|
||||
}
|
||||
await this.#awaitSlot();
|
||||
this.#inflight += 1;
|
||||
try {
|
||||
return await this.#race(fileId, chunkId, candidates, signal);
|
||||
} finally {
|
||||
this.#inflight -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
async #awaitSlot(): Promise<void> {
|
||||
// Busy-wait via microtask yield until a slot frees. Pull is
|
||||
// called from a small set of async contexts so this loop has
|
||||
// a tight upper bound (`maxParallel` concurrent calls).
|
||||
while (this.#inflight >= this.#maxParallel) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
async #race(
|
||||
fileId: string,
|
||||
chunkId: number,
|
||||
candidates: readonly PeerInfo[],
|
||||
outerSignal: AbortSignal | undefined
|
||||
): Promise<Uint8Array> {
|
||||
const topK = candidates.slice(0, this.#topK);
|
||||
const controller = new AbortController();
|
||||
const onOuterAbort = (): void => controller.abort();
|
||||
outerSignal?.addEventListener("abort", onOuterAbort, { once: true });
|
||||
|
||||
const attempts: Promise<AttemptResult>[] = [];
|
||||
const failures: { peerId: PeerId; reason: string }[] = [];
|
||||
|
||||
try {
|
||||
let i = 0;
|
||||
for (const peer of topK) {
|
||||
if (i > 0) {
|
||||
try {
|
||||
await delay(this.#racingWindowMs, controller.signal);
|
||||
} catch (delayErr) {
|
||||
// Outer / inner abort during racing window.
|
||||
// Fall through to firstSettled with whatever was
|
||||
// already issued; attempts are guaranteed to be
|
||||
// .catch'd inside firstSettled.
|
||||
void delayErr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
const attempt = this.#tryPeer(
|
||||
peer.peerId,
|
||||
fileId,
|
||||
chunkId,
|
||||
controller.signal,
|
||||
failures
|
||||
);
|
||||
// Attach a catch-all the same tick the promise is
|
||||
// created so an early rejection (e.g., synchronous
|
||||
// transport throw) cannot surface as
|
||||
// "unhandled-rejection" before `firstSettled` attaches
|
||||
// its own handler.
|
||||
attempt.catch(() => undefined);
|
||||
attempts.push(attempt);
|
||||
}
|
||||
return await firstSettled(attempts, controller);
|
||||
} catch (err) {
|
||||
outerSignal?.removeEventListener("abort", onOuterAbort);
|
||||
// `firstSettled` rejects with a DriveMeshError (from `#tryPeer`)
|
||||
// and `delay` rejects with a DriveMeshError(ABORTED); every
|
||||
// path into this catch produces an `Error` subclass.
|
||||
const cause = err as Error;
|
||||
if (outerSignal?.aborted) {
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.ABORTED,
|
||||
"request aborted",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
if (
|
||||
err instanceof DriveMeshError &&
|
||||
err.code === DRIVE_MESH_CODES.SIG_VERIFY_FAILED
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.ALL_PEERS_FAILED,
|
||||
`all ${topK.length} peers failed for ${fileId}/${chunkId}: ` +
|
||||
failures.map((f) => `${f.peerId}=${f.reason}`).join(", "),
|
||||
{ cause }
|
||||
);
|
||||
} finally {
|
||||
outerSignal?.removeEventListener("abort", onOuterAbort);
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async #tryPeer(
|
||||
peerId: PeerId,
|
||||
fileId: string,
|
||||
chunkId: number,
|
||||
signal: AbortSignal,
|
||||
failures: { peerId: PeerId; reason: string }[]
|
||||
): Promise<AttemptResult> {
|
||||
const transport = this.#pickTransport(peerId);
|
||||
if (transport === undefined) {
|
||||
const reason = "no_transport";
|
||||
failures.push({ peerId, reason });
|
||||
this.#onFailure?.(peerId, fileId, chunkId, reason);
|
||||
this.#registry.recordFailure(peerId);
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.TRANSPORT_FAILED,
|
||||
`no transport for peer ${peerId}`
|
||||
);
|
||||
}
|
||||
this.#onAttempt?.(peerId, fileId, chunkId);
|
||||
const start = this.#clock();
|
||||
try {
|
||||
const bytes = await transport.requestChunk(
|
||||
peerId,
|
||||
fileId,
|
||||
chunkId,
|
||||
signal
|
||||
);
|
||||
try {
|
||||
verifyChunkSig(bytes, this.#serverPubKey);
|
||||
} catch (verifyErr) {
|
||||
this.#registry.recordBadSig(peerId);
|
||||
this.#onBadSig?.(peerId, fileId, chunkId);
|
||||
// `verifyChunkSig` throws a `ChunksError` (which
|
||||
// extends `Error`), so the `cause` cast is always safe.
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.SIG_VERIFY_FAILED,
|
||||
`bad sig from peer ${peerId}`,
|
||||
{ cause: verifyErr as Error }
|
||||
);
|
||||
}
|
||||
const elapsedMs = this.#clock() - start;
|
||||
this.#registry.recordRtt(peerId, elapsedMs);
|
||||
this.#registry.recordSuccess(peerId);
|
||||
this.#onSuccess?.(
|
||||
peerId,
|
||||
fileId,
|
||||
chunkId,
|
||||
bytes.byteLength,
|
||||
elapsedMs
|
||||
);
|
||||
return { peerId, bytes, elapsedMs };
|
||||
} catch (err) {
|
||||
if (err instanceof DriveMeshError) throw err;
|
||||
const reason = err instanceof Error ? err.message : "unknown";
|
||||
failures.push({ peerId, reason });
|
||||
this.#registry.recordFailure(peerId);
|
||||
this.#onFailure?.(peerId, fileId, chunkId, reason);
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.TRANSPORT_FAILED,
|
||||
`peer ${peerId} failed: ${reason}`,
|
||||
err instanceof Error ? { cause: err } : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#pickTransport(peerId: PeerId): DriveTransport | undefined {
|
||||
for (const transport of this.#transports) {
|
||||
for (const candidate of transport.peers()) {
|
||||
if (candidate === peerId) return transport;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number, signal: AbortSignal): Promise<void> {
|
||||
if (ms <= 0) return Promise.resolve();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(handle);
|
||||
reject(
|
||||
new DriveMeshError(DRIVE_MESH_CODES.ABORTED, "delay aborted")
|
||||
);
|
||||
};
|
||||
const handle = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve with the first attempt that settles with success; reject
|
||||
* with the most recent error iff every attempt fails. The controller
|
||||
* is aborted as soon as a winner appears so losing attempts can
|
||||
* short-circuit their transport call.
|
||||
*/
|
||||
async function firstSettled(
|
||||
attempts: readonly Promise<AttemptResult>[],
|
||||
controller: AbortController
|
||||
): Promise<Uint8Array> {
|
||||
// Callers are responsible for passing a non-empty array; #race
|
||||
// guards on `topK.length > 0` before invocation, and the early
|
||||
// `CHUNK_UNAVAILABLE` throw in `pull` covers the empty-candidates
|
||||
// case.
|
||||
let remaining = attempts.length;
|
||||
let settled = false;
|
||||
return await new Promise<Uint8Array>((resolve, reject) => {
|
||||
let lastErr: unknown;
|
||||
for (const attempt of attempts) {
|
||||
// Attach a catch-all sibling so late rejections (after a
|
||||
// winner was picked) cannot surface as unhandled-rejection
|
||||
// diagnostics in the test runner.
|
||||
attempt.catch(() => undefined);
|
||||
attempt.then(
|
||||
(result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
controller.abort();
|
||||
resolve(result.bytes);
|
||||
},
|
||||
(err) => {
|
||||
lastErr = err;
|
||||
remaining -= 1;
|
||||
if (settled) return;
|
||||
if (remaining === 0) {
|
||||
settled = true;
|
||||
// #tryPeer always rejects with a DriveMeshError,
|
||||
// so lastErr is always an Error subclass here.
|
||||
if (
|
||||
lastErr instanceof DriveMeshError &&
|
||||
lastErr.code === DRIVE_MESH_CODES.SIG_VERIFY_FAILED
|
||||
) {
|
||||
reject(lastErr);
|
||||
return;
|
||||
}
|
||||
reject(lastErr as Error);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Zod boundary for advertisement payloads.
|
||||
*/
|
||||
|
||||
import z from "zod";
|
||||
|
||||
export const AdvertisementSchema = z
|
||||
.object({
|
||||
fileId: z
|
||||
.string()
|
||||
.regex(/^[0-9a-f]{64}$/, "fileId must be 64 lowercase hex chars"),
|
||||
bitset: z.instanceof(Uint8Array),
|
||||
totalChunks: z.number().int().positive(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AdvertisementSchema } from "./advertisement.schemas.js";
|
||||
import {
|
||||
DriveMeshOptionsSchema,
|
||||
ServerFallbackConfigSchema,
|
||||
} from "./config.schemas.js";
|
||||
|
||||
describe("DriveMeshOptionsSchema", () => {
|
||||
it("accepts a minimal valid options object", { timeout: 5000 }, () => {
|
||||
const out = DriveMeshOptionsSchema.safeParse({
|
||||
chunks: {},
|
||||
transports: [{}],
|
||||
serverPubKey: new Uint8Array(32),
|
||||
});
|
||||
expect(out.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an empty transports array", { timeout: 5000 }, () => {
|
||||
const out = DriveMeshOptionsSchema.safeParse({
|
||||
chunks: {},
|
||||
transports: [],
|
||||
serverPubKey: new Uint8Array(32),
|
||||
});
|
||||
expect(out.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a wrong-length serverPubKey", { timeout: 5000 }, () => {
|
||||
const out = DriveMeshOptionsSchema.safeParse({
|
||||
chunks: {},
|
||||
transports: [{}],
|
||||
serverPubKey: new Uint8Array(16),
|
||||
});
|
||||
expect(out.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown keys (strict)", { timeout: 5000 }, () => {
|
||||
const out = DriveMeshOptionsSchema.safeParse({
|
||||
chunks: {},
|
||||
transports: [{}],
|
||||
serverPubKey: new Uint8Array(32),
|
||||
extra: "nope",
|
||||
});
|
||||
expect(out.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts optional fields", { timeout: 5000 }, () => {
|
||||
const out = DriveMeshOptionsSchema.safeParse({
|
||||
chunks: {},
|
||||
transports: [{}],
|
||||
serverPubKey: new Uint8Array(32),
|
||||
serverFallback: { url: "https://x", transport: "https" },
|
||||
maxParallelRequests: 4,
|
||||
racingWindowMs: 100,
|
||||
denylistMode: "persistent",
|
||||
});
|
||||
expect(out.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects denylistMode not in enum", { timeout: 5000 }, () => {
|
||||
const out = DriveMeshOptionsSchema.safeParse({
|
||||
chunks: {},
|
||||
transports: [{}],
|
||||
serverPubKey: new Uint8Array(32),
|
||||
denylistMode: "forever",
|
||||
});
|
||||
expect(out.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerFallbackConfigSchema", () => {
|
||||
it("requires non-empty url and transport", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
ServerFallbackConfigSchema.safeParse({ url: "", transport: "x" })
|
||||
.success
|
||||
).toBe(false);
|
||||
expect(
|
||||
ServerFallbackConfigSchema.safeParse({
|
||||
url: "https://x",
|
||||
transport: "",
|
||||
}).success
|
||||
).toBe(false);
|
||||
expect(
|
||||
ServerFallbackConfigSchema.safeParse({
|
||||
url: "https://x",
|
||||
transport: "https",
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AdvertisementSchema", () => {
|
||||
it("accepts valid input", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
AdvertisementSchema.safeParse({
|
||||
fileId: "a".repeat(64),
|
||||
bitset: new Uint8Array(2),
|
||||
totalChunks: 8,
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects malformed fileId", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
AdvertisementSchema.safeParse({
|
||||
fileId: "Z".repeat(64),
|
||||
bitset: new Uint8Array(2),
|
||||
totalChunks: 8,
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects zero totalChunks", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
AdvertisementSchema.safeParse({
|
||||
fileId: "a".repeat(64),
|
||||
bitset: new Uint8Array(2),
|
||||
totalChunks: 0,
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Zod boundary for `DriveMeshOptions`.
|
||||
*
|
||||
* The schema is strict: unknown keys are rejected so misconfigurations
|
||||
* are caught before `new DriveMesh(...)` constructs internal state.
|
||||
*/
|
||||
|
||||
import z from "zod";
|
||||
|
||||
const ServerFallbackConfigSchema = z
|
||||
.object({
|
||||
url: z.string().min(1, "serverFallback.url must be non-empty"),
|
||||
transport: z
|
||||
.string()
|
||||
.min(1, "serverFallback.transport must be non-empty"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const DriveMeshOptionsSchema = z
|
||||
.object({
|
||||
chunks: z.unknown(),
|
||||
transports: z.array(z.unknown()).min(1, "at least one transport"),
|
||||
serverPubKey: z
|
||||
.instanceof(Uint8Array)
|
||||
.refine(
|
||||
(b) => b.byteLength === 32,
|
||||
"serverPubKey must be 32 bytes"
|
||||
),
|
||||
serverFallback: ServerFallbackConfigSchema.optional(),
|
||||
maxParallelRequests: z.number().int().positive().optional(),
|
||||
racingWindowMs: z.number().int().nonnegative().optional(),
|
||||
denylistMode: z.enum(["session", "persistent"]).optional(),
|
||||
clock: z.unknown().optional(),
|
||||
fetchFn: z.unknown().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export { ServerFallbackConfigSchema };
|
||||
@@ -0,0 +1,5 @@
|
||||
export { AdvertisementSchema } from "./advertisement.schemas.js";
|
||||
export {
|
||||
DriveMeshOptionsSchema,
|
||||
ServerFallbackConfigSchema,
|
||||
} from "./config.schemas.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type FetchFn,
|
||||
ServerFallback,
|
||||
type ServerFallbackOptions,
|
||||
} from "./server-fallback.js";
|
||||
@@ -0,0 +1,105 @@
|
||||
# `server-fallback.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`ServerFallback` is the chunk-fetch path drive-mesh uses when every
|
||||
peer attempt in `ParallelPuller` has failed. The server is the root of
|
||||
trust, so a fallback request must still produce a chunk that passes
|
||||
`verifyChunkSig` against the configured `serverPubKey`. The fallback
|
||||
does not bypass trust — it bypasses peers.
|
||||
|
||||
The fallback URL is composed as `<baseUrl>/<fileId>/<chunkId>` and
|
||||
fetched via the injectable `fetchFn`. Failures are retried with
|
||||
exponential backoff and equal jitter so a wave of mesh nodes
|
||||
simultaneously falling back does not stampede the origin.
|
||||
|
||||
## Features
|
||||
|
||||
- Plain `GET <baseUrl>/<fileId>/<chunkId>` request shape.
|
||||
- Equal-jitter exponential backoff
|
||||
(`exp/2 + rand(0, exp/2)`, clamped `[base, max]`).
|
||||
- Mandatory signature verification against `serverPubKey`.
|
||||
- AbortSignal threaded through `fetch` and through delay waits.
|
||||
- Configurable retry count (`maxAttempts`, default 3).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ServerFallback } from "@suaveplan/drive-mesh/server-fallback/server-fallback";
|
||||
|
||||
const fallback = new ServerFallback({
|
||||
baseUrl: "https://cdn.example.com/chunks",
|
||||
serverPubKey,
|
||||
});
|
||||
const bytes = await fallback.fetchChunk(fileId, 0);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ServerFallback } from "@suaveplan/drive-mesh/server-fallback/server-fallback";
|
||||
|
||||
const fallback = new ServerFallback({
|
||||
baseUrl: "https://cdn.example.com/chunks",
|
||||
serverPubKey,
|
||||
fetchFn: customFetch,
|
||||
maxAttempts: 5,
|
||||
backoffBaseMs: 100,
|
||||
backoffMaxMs: 3_000,
|
||||
clock: fakeClock,
|
||||
random: deterministicRng,
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const bytes = await fallback.fetchChunk(fileId, chunkId, controller.signal);
|
||||
} catch (err) {
|
||||
if (err instanceof DriveMeshError) {
|
||||
if (err.code === DRIVE_MESH_CODES.FALLBACK_FAILED) {
|
||||
// every retry exhausted; surface to the caller
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ServerFallback`:
|
||||
- `constructor(options: ServerFallbackOptions)`.
|
||||
- `fetchChunk(fileId, chunkId, signal?): Promise<Uint8Array>`.
|
||||
- `now(): number` — current clock reading.
|
||||
- `ServerFallbackOptions`:
|
||||
- `baseUrl` (required) — trailing slashes stripped.
|
||||
- `serverPubKey` (required) — ed25519 32-byte public key.
|
||||
- `fetchFn?` — defaults to `globalThis.fetch`; throws
|
||||
`FALLBACK_FAILED` lazily if missing.
|
||||
- `maxAttempts?` (default `3`).
|
||||
- `backoffBaseMs?` (default `200`).
|
||||
- `backoffMaxMs?` (default `5_000`).
|
||||
- `clock?` — defaults to `Date.now`-based clock.
|
||||
- `random?` — `() => number` in `[0, 1)`; default `Math.random`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The path shape (`<base>/<fileId>/<chunkId>`) is intentionally simple
|
||||
rather than templated — drive-mesh consumers usually front the
|
||||
fallback URL with a CDN that knows the layout. Customers who want
|
||||
shaped URLs (e.g., reverse-id sharding per `drive-template`) can
|
||||
wrap `fetchFn` with their own URL builder.
|
||||
|
||||
`verifyChunkSig` is called on every successful response before
|
||||
returning bytes. A signature failure in the fallback path is a
|
||||
configuration error, not a transient failure — but for symmetry with
|
||||
the peer path the error still surfaces as `FALLBACK_FAILED` rather
|
||||
than `SIG_VERIFY_FAILED`, so that the caller's denylist counter
|
||||
remains a "peer was Byzantine" counter rather than "server was
|
||||
Byzantine".
|
||||
|
||||
`defaultFetch` returns a lazy `globalThis.fetch` bridge so the
|
||||
package remains universal: a Node ≥18 / Deno / browser runtime
|
||||
ships `fetch` natively, and consumers that build to older targets
|
||||
inject `fetchFn` explicitly.
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
buildChunkBytes,
|
||||
makeKeypair,
|
||||
TEST_FILE_ID_HEX,
|
||||
} from "../../../tests/helpers/signed-chunk.js";
|
||||
import {
|
||||
DRIVE_MESH_CODES,
|
||||
DriveMeshError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { ServerFallback } from "./server-fallback.js";
|
||||
|
||||
function arrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
describe("ServerFallback", () => {
|
||||
it("fetches and verifies on success", { timeout: 5000 }, async () => {
|
||||
const kp = makeKeypair(11);
|
||||
const bytes = buildChunkBytes({
|
||||
serverPrivKey: kp.privKey,
|
||||
fileIdHex: TEST_FILE_ID_HEX,
|
||||
chunkId: 5,
|
||||
});
|
||||
const fallback = new ServerFallback({
|
||||
baseUrl: "https://cdn.example.com/chunks/",
|
||||
serverPubKey: kp.pubKey,
|
||||
fetchFn: async () =>
|
||||
new Response(arrayBuffer(bytes), { status: 200 }),
|
||||
});
|
||||
const out = await fallback.fetchChunk(TEST_FILE_ID_HEX, 5);
|
||||
expect(out).toEqual(bytes);
|
||||
});
|
||||
|
||||
it("rejects pre-aborted signals", { timeout: 5000 }, async () => {
|
||||
const kp = makeKeypair(11);
|
||||
const fallback = new ServerFallback({
|
||||
baseUrl: "https://x",
|
||||
serverPubKey: kp.pubKey,
|
||||
fetchFn: async () => new Response(new Uint8Array(0)),
|
||||
});
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
fallback.fetchChunk(TEST_FILE_ID_HEX, 0, ac.signal)
|
||||
).rejects.toMatchObject({ code: DRIVE_MESH_CODES.ABORTED });
|
||||
});
|
||||
|
||||
it("retries on non-2xx responses then surfaces FALLBACK_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(11);
|
||||
let calls = 0;
|
||||
const fallback = new ServerFallback({
|
||||
baseUrl: "https://x",
|
||||
serverPubKey: kp.pubKey,
|
||||
fetchFn: async () => {
|
||||
calls += 1;
|
||||
return new Response("nope", { status: 500 });
|
||||
},
|
||||
maxAttempts: 2,
|
||||
backoffBaseMs: 1,
|
||||
backoffMaxMs: 2,
|
||||
random: () => 0.5,
|
||||
});
|
||||
await expect(
|
||||
fallback.fetchChunk(TEST_FILE_ID_HEX, 0)
|
||||
).rejects.toMatchObject({ code: DRIVE_MESH_CODES.FALLBACK_FAILED });
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("recovers after a transient failure", { timeout: 5000 }, async () => {
|
||||
const kp = makeKeypair(11);
|
||||
const bytes = buildChunkBytes({
|
||||
serverPrivKey: kp.privKey,
|
||||
fileIdHex: TEST_FILE_ID_HEX,
|
||||
chunkId: 0,
|
||||
});
|
||||
let calls = 0;
|
||||
const fallback = new ServerFallback({
|
||||
baseUrl: "https://x",
|
||||
serverPubKey: kp.pubKey,
|
||||
fetchFn: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error("network");
|
||||
return new Response(arrayBuffer(bytes), { status: 200 });
|
||||
},
|
||||
backoffBaseMs: 1,
|
||||
random: () => 0,
|
||||
});
|
||||
const out = await fallback.fetchChunk(TEST_FILE_ID_HEX, 0);
|
||||
expect(out).toEqual(bytes);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("surfaces ABORTED when signal fires mid-wait", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(11);
|
||||
const ac = new AbortController();
|
||||
const fallback = new ServerFallback({
|
||||
baseUrl: "https://x",
|
||||
serverPubKey: kp.pubKey,
|
||||
fetchFn: async () => {
|
||||
throw new Error("transient");
|
||||
},
|
||||
backoffBaseMs: 50,
|
||||
backoffMaxMs: 50,
|
||||
random: () => 1,
|
||||
});
|
||||
const promise = fallback.fetchChunk(TEST_FILE_ID_HEX, 0, ac.signal);
|
||||
setTimeout(() => ac.abort(), 5);
|
||||
await expect(promise).rejects.toBeInstanceOf(DriveMeshError);
|
||||
});
|
||||
|
||||
it("re-raises ABORTED with non-Error throw", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(23);
|
||||
const ac = new AbortController();
|
||||
const fallback = new ServerFallback({
|
||||
baseUrl: "https://x",
|
||||
serverPubKey: kp.pubKey,
|
||||
fetchFn: async () => {
|
||||
ac.abort();
|
||||
throw "non-error-string";
|
||||
},
|
||||
maxAttempts: 1,
|
||||
});
|
||||
await expect(
|
||||
fallback.fetchChunk(TEST_FILE_ID_HEX, 0, ac.signal)
|
||||
).rejects.toMatchObject({ code: DRIVE_MESH_CODES.ABORTED });
|
||||
});
|
||||
|
||||
it("exhausts attempts when last error is non-Error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(24);
|
||||
const fallback = new ServerFallback({
|
||||
baseUrl: "https://x",
|
||||
serverPubKey: kp.pubKey,
|
||||
fetchFn: async () => {
|
||||
throw "string-rejection";
|
||||
},
|
||||
maxAttempts: 1,
|
||||
});
|
||||
await expect(
|
||||
fallback.fetchChunk(TEST_FILE_ID_HEX, 0)
|
||||
).rejects.toMatchObject({ code: DRIVE_MESH_CODES.FALLBACK_FAILED });
|
||||
});
|
||||
|
||||
it("re-raises ABORTED when signal aborts while fetch throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(22);
|
||||
const ac = new AbortController();
|
||||
const fallback = new ServerFallback({
|
||||
baseUrl: "https://x",
|
||||
serverPubKey: kp.pubKey,
|
||||
fetchFn: async () => {
|
||||
ac.abort();
|
||||
throw new Error("aborted-during-fetch");
|
||||
},
|
||||
maxAttempts: 1,
|
||||
});
|
||||
await expect(
|
||||
fallback.fetchChunk(TEST_FILE_ID_HEX, 0, ac.signal)
|
||||
).rejects.toMatchObject({ code: DRIVE_MESH_CODES.ABORTED });
|
||||
});
|
||||
|
||||
it("now() reflects clock", { timeout: 5000 }, () => {
|
||||
const fallback = new ServerFallback({
|
||||
baseUrl: "https://x",
|
||||
serverPubKey: new Uint8Array(32),
|
||||
clock: () => 42,
|
||||
fetchFn: async () => new Response(new Uint8Array(0)),
|
||||
});
|
||||
expect(fallback.now()).toBe(42);
|
||||
});
|
||||
|
||||
it("now() falls back to Date.now when no clock provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fallback = new ServerFallback({
|
||||
baseUrl: "https://x",
|
||||
serverPubKey: new Uint8Array(32),
|
||||
fetchFn: async () => new Response(new Uint8Array(0)),
|
||||
});
|
||||
const before = Date.now();
|
||||
const reading = fallback.now();
|
||||
const after = Date.now();
|
||||
expect(reading).toBeGreaterThanOrEqual(before);
|
||||
expect(reading).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it("default fetch routes through globalThis.fetch when available", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const kp = makeKeypair(30);
|
||||
const bytes = buildChunkBytes({
|
||||
serverPrivKey: kp.privKey,
|
||||
fileIdHex: TEST_FILE_ID_HEX,
|
||||
chunkId: 0,
|
||||
});
|
||||
const originalFetch = (globalThis as { fetch?: typeof fetch }).fetch;
|
||||
let captured: string | URL | null = null;
|
||||
(globalThis as { fetch?: unknown }).fetch = async (
|
||||
input: string | URL
|
||||
) => {
|
||||
captured = input;
|
||||
return new Response(arrayBuffer(bytes), { status: 200 });
|
||||
};
|
||||
try {
|
||||
const fallback = new ServerFallback({
|
||||
baseUrl: "https://x",
|
||||
serverPubKey: kp.pubKey,
|
||||
maxAttempts: 1,
|
||||
});
|
||||
const out = await fallback.fetchChunk(TEST_FILE_ID_HEX, 0);
|
||||
expect(out).toEqual(bytes);
|
||||
expect(captured).not.toBeNull();
|
||||
} finally {
|
||||
(globalThis as { fetch?: unknown }).fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("default fetch throws FALLBACK_FAILED when globalThis.fetch is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const originalFetch = (globalThis as { fetch?: unknown }).fetch;
|
||||
delete (globalThis as { fetch?: unknown }).fetch;
|
||||
try {
|
||||
const fallback = new ServerFallback({
|
||||
baseUrl: "https://x",
|
||||
serverPubKey: new Uint8Array(32),
|
||||
maxAttempts: 1,
|
||||
});
|
||||
await expect(
|
||||
fallback.fetchChunk(TEST_FILE_ID_HEX, 0)
|
||||
).rejects.toMatchObject({
|
||||
code: DRIVE_MESH_CODES.FALLBACK_FAILED,
|
||||
});
|
||||
} finally {
|
||||
(globalThis as { fetch?: unknown }).fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("strips trailing slashes from baseUrl", { timeout: 5000 }, async () => {
|
||||
const kp = makeKeypair(11);
|
||||
const bytes = buildChunkBytes({
|
||||
serverPrivKey: kp.privKey,
|
||||
fileIdHex: TEST_FILE_ID_HEX,
|
||||
chunkId: 1,
|
||||
});
|
||||
let seenUrl = "";
|
||||
const fallback = new ServerFallback({
|
||||
baseUrl: "https://x.example.com/base///",
|
||||
serverPubKey: kp.pubKey,
|
||||
fetchFn: async (url) => {
|
||||
seenUrl = url.toString();
|
||||
return new Response(arrayBuffer(bytes), { status: 200 });
|
||||
},
|
||||
});
|
||||
await fallback.fetchChunk(TEST_FILE_ID_HEX, 1);
|
||||
expect(seenUrl).toBe(
|
||||
`https://x.example.com/base/${TEST_FILE_ID_HEX}/1`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* `ServerFallback` — last-resort chunk fetch via the configured
|
||||
* fallback URL.
|
||||
*
|
||||
* The fallback URL is appended with `/<fileId>/<chunkId>` to form the
|
||||
* concrete request. The response body is read in full and handed to
|
||||
* `verifyChunkSig` against the same `serverPubKey` peers are validated
|
||||
* against — the server is the root of trust, so the same gate applies.
|
||||
*
|
||||
* Retries use exponential backoff with a small jitter so concurrent
|
||||
* fallback bursts from multiple consumers do not stampede the origin.
|
||||
*/
|
||||
|
||||
import { verifyChunkSig } from "@suaveplan/drive-plugin-chunks";
|
||||
import type { Clock, FetchLike } from "@suaveplan/types";
|
||||
import {
|
||||
DRIVE_MESH_CODES,
|
||||
DriveMeshError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* A `fetch`-shaped function. Backwards-compatibility alias for `FetchLike`
|
||||
* from `@suaveplan/types` — the canonical workspace contract. Existing
|
||||
* consumers that import `FetchFn` from this package keep working unchanged.
|
||||
*/
|
||||
export type FetchFn = FetchLike;
|
||||
|
||||
export interface ServerFallbackOptions {
|
||||
readonly baseUrl: string;
|
||||
readonly serverPubKey: Uint8Array;
|
||||
readonly fetchFn?: FetchFn;
|
||||
/** Default 3. */
|
||||
readonly maxAttempts?: number;
|
||||
/** Base delay between retries. Default 200 ms. */
|
||||
readonly backoffBaseMs?: number;
|
||||
/** Maximum delay between retries. Default 5_000 ms. */
|
||||
readonly backoffMaxMs?: number;
|
||||
readonly clock?: Clock;
|
||||
readonly random?: () => number;
|
||||
}
|
||||
|
||||
export class ServerFallback {
|
||||
readonly #baseUrl: string;
|
||||
readonly #serverPubKey: Uint8Array;
|
||||
readonly #fetchFn: FetchFn;
|
||||
readonly #maxAttempts: number;
|
||||
readonly #backoffBaseMs: number;
|
||||
readonly #backoffMaxMs: number;
|
||||
readonly #clock: Clock;
|
||||
readonly #random: () => number;
|
||||
|
||||
public constructor(options: ServerFallbackOptions) {
|
||||
this.#baseUrl = options.baseUrl.replace(/\/+$/, "");
|
||||
this.#serverPubKey = options.serverPubKey;
|
||||
this.#fetchFn = options.fetchFn ?? defaultFetch();
|
||||
this.#maxAttempts = options.maxAttempts ?? 3;
|
||||
this.#backoffBaseMs = options.backoffBaseMs ?? 200;
|
||||
this.#backoffMaxMs = options.backoffMaxMs ?? 5_000;
|
||||
this.#clock = options.clock ?? (() => Date.now());
|
||||
this.#random = options.random ?? Math.random;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the chunk, verify the signature, and return the bytes.
|
||||
* Throws `DRIVE_MESH.FALLBACK_FAILED` on transport / verification
|
||||
* failure after every retry.
|
||||
*/
|
||||
public async fetchChunk(
|
||||
fileId: string,
|
||||
chunkId: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<Uint8Array> {
|
||||
if (signal?.aborted) {
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.ABORTED,
|
||||
"fallback aborted before start"
|
||||
);
|
||||
}
|
||||
const url = `${this.#baseUrl}/${fileId}/${chunkId}`;
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt < this.#maxAttempts; attempt++) {
|
||||
if (attempt > 0) {
|
||||
await this.#wait(attempt, signal);
|
||||
}
|
||||
try {
|
||||
const init: RequestInit = signal ? { signal } : {};
|
||||
const response = await this.#fetchFn(url, init);
|
||||
if (!response.ok) {
|
||||
lastErr = new DriveMeshError(
|
||||
DRIVE_MESH_CODES.FALLBACK_FAILED,
|
||||
`fallback http ${response.status}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const buf = new Uint8Array(await response.arrayBuffer());
|
||||
verifyChunkSig(buf, this.#serverPubKey);
|
||||
return buf;
|
||||
} catch (err) {
|
||||
if (signal?.aborted) {
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.ABORTED,
|
||||
"fallback aborted",
|
||||
err instanceof Error ? { cause: err } : undefined
|
||||
);
|
||||
}
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.FALLBACK_FAILED,
|
||||
`fallback exhausted after ${this.#maxAttempts} attempts`,
|
||||
lastErr instanceof Error ? { cause: lastErr } : undefined
|
||||
);
|
||||
}
|
||||
|
||||
/** Last-recorded clock reading — surfaced so callers can build elapsed-ms metrics. */
|
||||
public now(): number {
|
||||
return this.#clock();
|
||||
}
|
||||
|
||||
async #wait(
|
||||
attempt: number,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<void> {
|
||||
const exp = Math.min(
|
||||
this.#backoffMaxMs,
|
||||
this.#backoffBaseMs * 2 ** (attempt - 1)
|
||||
);
|
||||
const half = exp / 2;
|
||||
const delayMs = Math.floor(half + this.#random() * half);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(handle);
|
||||
reject(
|
||||
new DriveMeshError(
|
||||
DRIVE_MESH_CODES.ABORTED,
|
||||
"fallback delay aborted"
|
||||
)
|
||||
);
|
||||
};
|
||||
const handle = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, delayMs);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function defaultFetch(): FetchFn {
|
||||
const fn = (globalThis as { fetch?: FetchFn }).fetch;
|
||||
if (fn !== undefined) {
|
||||
return (input, init) => fn(input, init);
|
||||
}
|
||||
return () =>
|
||||
Promise.reject(
|
||||
new DriveMeshError(
|
||||
DRIVE_MESH_CODES.FALLBACK_FAILED,
|
||||
"no fetch implementation available; pass options.fetchFn"
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
# `config.types.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the `DriveMeshOptions` shape consumed by `new DriveMesh(...)`.
|
||||
The interface is the TypeScript-side contract; the matching Zod
|
||||
boundary lives in `./schemas/config.schemas.ts` and is the runtime
|
||||
validator that rejects malformed input before the mesh's internal
|
||||
state is constructed.
|
||||
|
||||
Splitting the two halves keeps the type surface dependency-free at
|
||||
compile time and the runtime validation centralised at the package
|
||||
boundary.
|
||||
|
||||
## Features
|
||||
|
||||
- Single shape covering scheduler tuning, denylist policy, server
|
||||
fallback, transports, and the `ChunkSource` handle.
|
||||
- Every tuning parameter is optional with a documented default.
|
||||
- `serverPubKey` is mandatory — trust roots are not optional.
|
||||
- Injectable `clock` and `fetchFn` for deterministic testing.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DriveMesh } from "@suaveplan/drive-mesh";
|
||||
|
||||
const mesh = new DriveMesh({
|
||||
chunks,
|
||||
transports: [httpsTransport],
|
||||
serverPubKey,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DriveMesh } from "@suaveplan/drive-mesh";
|
||||
|
||||
const mesh = new DriveMesh({
|
||||
chunks,
|
||||
transports: [webrtcTransport, httpsTransport],
|
||||
serverPubKey,
|
||||
serverFallback: { url: "https://cdn.example.com", transport: "https" },
|
||||
maxParallelRequests: 16,
|
||||
racingWindowMs: 150,
|
||||
denylistMode: "persistent",
|
||||
clock: fakeClock,
|
||||
fetchFn: testFetch,
|
||||
});
|
||||
|
||||
await mesh.start();
|
||||
const chunkBytes = await mesh.requestChunk(fileIdHex, 0);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DenylistMode = "session" | "persistent"` — controls how long
|
||||
bad-sig peers stay denylisted. `session` clears on `mesh.stop()`;
|
||||
`persistent` keeps the denylist across stops.
|
||||
- `ServerFallbackConfig`:
|
||||
- `url: string` — absolute URL the fallback path hits.
|
||||
- `transport: string` — `DriveTransport.id` carrying the fallback.
|
||||
- `DriveMeshOptions`:
|
||||
- `chunks: ChunkSource` — handle returned by `chunksPlugin`.
|
||||
- `transports: readonly DriveTransport[]` — injectable transports.
|
||||
- `serverPubKey: Uint8Array` — server's ed25519 public key.
|
||||
- `serverFallback?: ServerFallbackConfig` — optional fallback.
|
||||
- `maxParallelRequests?: number` — default `8`.
|
||||
- `racingWindowMs?: number` — default `250`.
|
||||
- `denylistMode?: DenylistMode` — default `"session"`.
|
||||
- `clock?: Clock` — defaults to `Date.now`-based clock.
|
||||
- `fetchFn?: typeof fetch` — defaults to `globalThis.fetch`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`fetchFn` is typed loosely as `(input, init?) => Promise<Response>`
|
||||
rather than reusing `typeof fetch`, because `typeof fetch` carries a
|
||||
DOM-specific overload set that fights the universal package
|
||||
target. The looser shape still binds `globalThis.fetch` correctly at
|
||||
runtime.
|
||||
|
||||
`serverPubKey` is required — there is no implicit default. Drive-mesh
|
||||
treats peers as untrusted caches, so a configuration with no root of
|
||||
trust would silently disable signature verification, which is a footgun
|
||||
the type system prevents.
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Public configuration shape for `DriveMesh`.
|
||||
*
|
||||
* Aligned with the `drive-mesh-port` OpenSpec proposal but expressed
|
||||
* as a `readonly` interface rather than a Zod schema — the runtime
|
||||
* Zod boundary lives in `./schemas/config.schemas.ts`.
|
||||
*/
|
||||
|
||||
import type { ChunkSource } from "@suaveplan/drive-plugin-chunks";
|
||||
import type { Clock, FetchLike } from "@suaveplan/types";
|
||||
import type { DriveTransport } from "./drive-transport.types.js";
|
||||
|
||||
export type DenylistMode = "session" | "persistent";
|
||||
|
||||
export interface ServerFallbackConfig {
|
||||
/** Absolute URL the server fallback path hits. */
|
||||
readonly url: string;
|
||||
/**
|
||||
* Transport id (matches `DriveTransport.id`) responsible for
|
||||
* carrying the fallback request. Typically the HTTPS transport.
|
||||
*/
|
||||
readonly transport: string;
|
||||
}
|
||||
|
||||
export interface DriveMeshOptions {
|
||||
/** ChunkSource handle from `chunksPlugin`. */
|
||||
readonly chunks: ChunkSource;
|
||||
/** Injectable transports (HTTPS, WebRTC, libp2p, in-memory…). */
|
||||
readonly transports: readonly DriveTransport[];
|
||||
/** Server's ed25519 public key — the root of trust. */
|
||||
readonly serverPubKey: Uint8Array;
|
||||
/** Optional server fallback. */
|
||||
readonly serverFallback?: ServerFallbackConfig;
|
||||
/** Global cap on in-flight chunk requests. Default `8`. */
|
||||
readonly maxParallelRequests?: number;
|
||||
/**
|
||||
* First-byte racing window in milliseconds. After this delay,
|
||||
* additional candidate peers receive parallel requests. Default
|
||||
* `250`.
|
||||
*/
|
||||
readonly racingWindowMs?: number;
|
||||
/** Bad-sig denylist scope. Default `"session"`. */
|
||||
readonly denylistMode?: DenylistMode;
|
||||
/** Injectable clock — defaults to `Date.now`. */
|
||||
readonly clock?: Clock;
|
||||
/**
|
||||
* Optional fetch implementation for the server fallback path.
|
||||
* Defaults to `globalThis.fetch`. Tests inject a fake.
|
||||
*/
|
||||
readonly fetchFn?: FetchLike;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
# `drive-transport.types.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the chunk-shaped `DriveTransport` contract every mesh transport
|
||||
implementation conforms to. This is a structural ("ducked") interface so
|
||||
the mesh can compose HTTPS, WebRTC, libp2p, or in-memory transports
|
||||
without depending on their concrete classes. Drive-mesh consumes the
|
||||
contract; transport packages implement it.
|
||||
|
||||
The contract is intentionally narrow: a transport answers chunk
|
||||
requests, pushes advertisements, surfaces inbound advertisements via a
|
||||
handler, and reports the live peer set. Everything else (handshake,
|
||||
authentication, replay protection) belongs inside the transport and
|
||||
never crosses this boundary.
|
||||
|
||||
## Features
|
||||
|
||||
- Structural interface — no inheritance required.
|
||||
- Async `requestChunk` returns the full chunk bytes (locked v0x0001
|
||||
format) so the mesh can hand them directly to
|
||||
`verifyChunkSig` and `parseChunk`.
|
||||
- `advertise` is `peerId`-targeted — broadcasts are open-coded by the
|
||||
gossip engine via repeated unicast.
|
||||
- `onAdvertisement` returns an unsubscribe; multiple subscribers are
|
||||
allowed.
|
||||
- Optional `dispose()` honours `DriveMesh.stop()` lifecycle.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { DriveTransport } from "@suaveplan/drive-mesh/types";
|
||||
|
||||
const transport: DriveTransport = {
|
||||
id: "https",
|
||||
async requestChunk(peerId, fileId, chunkId) {
|
||||
return await myHttpsClient.get(peerId, fileId, chunkId);
|
||||
},
|
||||
async advertise(peerId, advert) {
|
||||
await myHttpsClient.push(peerId, advert);
|
||||
},
|
||||
onAdvertisement(handler) {
|
||||
const unsub = myHttpsClient.subscribe(handler);
|
||||
return unsub;
|
||||
},
|
||||
peers() {
|
||||
return myHttpsClient.connectedPeers();
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { DriveTransport } from "@suaveplan/drive-mesh/types";
|
||||
import { DriveMesh } from "@suaveplan/drive-mesh";
|
||||
|
||||
class WebRtcTransport implements DriveTransport {
|
||||
public readonly id = "webrtc";
|
||||
readonly #handlers = new Set<AdvertisementHandler>();
|
||||
|
||||
public async requestChunk(peerId, fileId, chunkId, signal) {
|
||||
const chan = this.channelFor(peerId);
|
||||
const reply = await chan.request({ fileId, chunkId }, { signal });
|
||||
return reply.bytes;
|
||||
}
|
||||
public async advertise(peerId, advert) {
|
||||
this.channelFor(peerId).send({ kind: "advert", ...advert });
|
||||
}
|
||||
public onAdvertisement(handler) {
|
||||
this.#handlers.add(handler);
|
||||
return () => this.#handlers.delete(handler);
|
||||
}
|
||||
public peers() {
|
||||
return this.connectedChannels.keys();
|
||||
}
|
||||
public async dispose() {
|
||||
for (const chan of this.connectedChannels.values()) chan.close();
|
||||
}
|
||||
}
|
||||
|
||||
const mesh = new DriveMesh({
|
||||
chunks,
|
||||
transports: [new WebRtcTransport(), httpsTransport],
|
||||
serverPubKey,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ChunkAdvertisement { fileId: string; bitset: Uint8Array; totalChunks: number }`
|
||||
— payload pushed in `advertise`; bitset is framed via
|
||||
`encodeFramedBitset` from `@suaveplan/drive-mesh-protocol`.
|
||||
- `AdvertisementHandler = (peerId, advert) => void` — callback invoked
|
||||
for every inbound advertisement.
|
||||
- `Unsubscribe = () => void` — handle returned by `onAdvertisement`.
|
||||
- `DriveTransport`:
|
||||
- `id: string` — stable identifier used by `serverFallback.transport`.
|
||||
- `requestChunk(peerId, fileId, chunkId, signal?): Promise<Uint8Array>` —
|
||||
fetch chunk bytes from one peer.
|
||||
- `advertise(peerId, advert): Promise<void>` — unicast availability
|
||||
push.
|
||||
- `onAdvertisement(handler): Unsubscribe` — subscribe to inbound
|
||||
advertisements.
|
||||
- `peers(): Iterable<PeerId>` — live peer snapshot.
|
||||
- `dispose?(): Promise<void> | void` — optional teardown.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The contract is deliberately bytes-in / bytes-out: the mesh never
|
||||
inspects payload metadata before signature verification, which keeps
|
||||
trust boundaries explicit. Signature verification is performed by the
|
||||
mesh against the configured `serverPubKey` before the chunk is handed
|
||||
to the chunks plugin.
|
||||
|
||||
`peers()` returns an `Iterable<PeerId>` (not an array) so transports
|
||||
can stream live views without materialising arrays on every gossip
|
||||
tick. Implementations are free to return a `Set` or `Map.keys()`.
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Structural contract every drive-mesh peer-to-peer transport
|
||||
* implementation conforms to.
|
||||
*
|
||||
* This is intentionally NOT `TransportLike<MessageEnvelope>` from
|
||||
* `@suaveplan/types/transport` — the mesh transport surface is
|
||||
* chunk-shaped (`requestChunk` returns bytes, `advertise` pushes a
|
||||
* bitset). The HTTPS chunk-fetch path is NOT a `DriveTransport`
|
||||
* implementation; it is served by the built-in `ServerFallback`
|
||||
* module (see `./server-fallback/server-fallback`).
|
||||
*
|
||||
* Currently the only implementation is
|
||||
* `@suaveplan/drive-transport-webrtc`.
|
||||
*/
|
||||
|
||||
import type { PeerId } from "@suaveplan/types";
|
||||
|
||||
export interface ChunkAdvertisement {
|
||||
/** Hex-encoded 32-byte file identifier. */
|
||||
readonly fileId: string;
|
||||
/** Bitset whose bit `i` is `1` iff the peer has chunk `i`. */
|
||||
readonly bitset: Uint8Array;
|
||||
/** Total chunks the file resolves to. */
|
||||
readonly totalChunks: number;
|
||||
}
|
||||
|
||||
export type AdvertisementHandler = (
|
||||
peerId: PeerId,
|
||||
advert: ChunkAdvertisement
|
||||
) => void;
|
||||
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
export interface DriveTransport {
|
||||
/**
|
||||
* Stable identifier for this transport — referenced by
|
||||
* `serverFallback.transport` to pick the right wire for the
|
||||
* server fallback path.
|
||||
*/
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* Request a specific chunk from `peerId`. Implementations MAY
|
||||
* stream a multi-frame response; the mesh consumes the full
|
||||
* concatenation as the chunk bytes. Resolves with the chunk's
|
||||
* locked v0x0001 bytes (metadata ‖ sig ‖ ciphertext).
|
||||
*/
|
||||
requestChunk(
|
||||
peerId: PeerId,
|
||||
fileId: string,
|
||||
chunkId: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<Uint8Array>;
|
||||
|
||||
/**
|
||||
* Push an availability advertisement to `peerId`. The bitset is
|
||||
* already framed via `encodeFramedBitset` from
|
||||
* `@suaveplan/drive-mesh-protocol` so the receiver can decode it
|
||||
* without negotiation.
|
||||
*/
|
||||
advertise(peerId: PeerId, advert: ChunkAdvertisement): Promise<void>;
|
||||
|
||||
/** Subscribe to inbound advertisements. */
|
||||
onAdvertisement(handler: AdvertisementHandler): Unsubscribe;
|
||||
|
||||
/** Snapshot of currently-connected peers on this transport. */
|
||||
peers(): Iterable<PeerId>;
|
||||
|
||||
/** Optional disposal hook called by `DriveMesh.stop()`. */
|
||||
dispose?(): Promise<void> | void;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user