Initial Commit
This commit is contained in:
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;
|
||||
}
|
||||
105
packages/networking/drive/drive-mesh/src/types/events.types.md
Normal file
105
packages/networking/drive/drive-mesh/src/types/events.types.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# `events.types.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the tuple-shaped event map emitted by `DriveMesh`. The map
|
||||
follows the same `Record<string, unknown[]>` convention as
|
||||
`@suaveplan/drive-webrtc`'s `TypedEventEmitter`, so call sites read
|
||||
`mesh.emit("chunk:served", payload)` rather than wrapping arguments in
|
||||
an object literal on every emission.
|
||||
|
||||
Events are organised in three families: chunk lifecycle (`chunk:*`),
|
||||
peer lifecycle (`peer:*`), and gossip telemetry (`gossip:*`). Each
|
||||
event payload is fully typed so downstream subscribers get
|
||||
compile-time validation of the property names they touch.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict typing — `mesh.on("chunk:served", e => …)` infers
|
||||
`ChunkServedEvent`.
|
||||
- Stable shape: every event carries the contextual identifiers
|
||||
(`fileId`, `chunkId`, `peerId` where applicable) needed to correlate
|
||||
it with the request that triggered it.
|
||||
- Latency observability via `elapsedMs` on chunk events.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DriveMesh } from "@suaveplan/drive-mesh";
|
||||
|
||||
mesh.on("chunk:served", ({ fileId, chunkId, peerId, elapsedMs }) => {
|
||||
console.log("hit", fileId, chunkId, peerId, elapsedMs);
|
||||
});
|
||||
mesh.on("chunk:fallback", ({ fileId, chunkId }) => {
|
||||
console.log("fallback", fileId, chunkId);
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DriveMesh } from "@suaveplan/drive-mesh";
|
||||
import type { DriveMeshEventMap } from "@suaveplan/drive-mesh/types";
|
||||
|
||||
function wire(mesh: DriveMesh, sink: TelemetrySink) {
|
||||
const map: { [K in keyof DriveMeshEventMap]: () => void } = {
|
||||
"chunk:requested": mesh.on("chunk:requested", e =>
|
||||
sink.counter("drive.mesh.chunk_requested_total", 1, e)
|
||||
),
|
||||
"chunk:served": mesh.on("chunk:served", e => {
|
||||
sink.counter("drive.mesh.chunk_served_total", 1, { outcome: "peer" });
|
||||
sink.histogram("drive.mesh.chunk_fetch_ms", e.elapsedMs, e);
|
||||
}),
|
||||
"chunk:fallback": mesh.on("chunk:fallback", e =>
|
||||
sink.counter("drive.mesh.chunk_served_total", 1, { outcome: "fallback" })
|
||||
),
|
||||
"chunk:failed": mesh.on("chunk:failed", e =>
|
||||
sink.counter("drive.mesh.chunk_served_total", 1, { outcome: "fail" })
|
||||
),
|
||||
"peer:joined": mesh.on("peer:joined", () =>
|
||||
sink.counter("drive.mesh.peers_admitted_total", 1)
|
||||
),
|
||||
"peer:left": mesh.on("peer:left", () => {}),
|
||||
"peer:denied": mesh.on("peer:denied", e =>
|
||||
sink.counter("drive.mesh.peers_denied_total", 1, { reason: e.reason })
|
||||
),
|
||||
"peer:scored": mesh.on("peer:scored", () => {}),
|
||||
"gossip:sent": mesh.on("gossip:sent", e =>
|
||||
sink.histogram("drive.mesh.gossip_size_bytes", e.sizeBytes)
|
||||
),
|
||||
"gossip:received": mesh.on("gossip:received", () => {}),
|
||||
};
|
||||
return () => Object.values(map).forEach(off => off());
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DriveMeshEventMap` — `Record<EventName, [PayloadType]>` consumed by
|
||||
`TypedEventEmitter`.
|
||||
- Chunk lifecycle:
|
||||
- `ChunkRequestedEvent { fileId; chunkId; peerId }`.
|
||||
- `ChunkServedEvent { fileId; chunkId; peerId; bytes; elapsedMs }`.
|
||||
- `ChunkFallbackEvent { fileId; chunkId; bytes; elapsedMs }`.
|
||||
- `ChunkFailedEvent { fileId; chunkId; reason }`.
|
||||
- Peer lifecycle:
|
||||
- `PeerJoinedEvent { peerId; transports }`.
|
||||
- `PeerLeftEvent { peerId }`.
|
||||
- `PeerDeniedEvent { peerId; reason }`.
|
||||
- `PeerScoredEvent { peerId; score; rttMs }`.
|
||||
- Gossip telemetry:
|
||||
- `GossipSentEvent { peerId; fileId; sizeBytes }`.
|
||||
- `GossipReceivedEvent { peerId; fileId; sizeBytes }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every payload is a single object rather than a positional tuple of
|
||||
primitives. This is intentional: object payloads survive future
|
||||
schema growth without changing the call signature at every subscriber
|
||||
site.
|
||||
|
||||
`elapsedMs` is measured against the injectable `clock` so tests
|
||||
running under a `FakeClock` get deterministic latency numbers.
|
||||
`bytes` is always the size of the verified ciphertext, not the wire
|
||||
payload, so consumers can compare against the file size in the
|
||||
manifest without double-counting headers.
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Tuple-shaped event map for `DriveMesh`. Mirrors the convention used
|
||||
* by `@suaveplan/drive-webrtc` so `mesh.emit("name", payload)` reads
|
||||
* naturally at call sites.
|
||||
*/
|
||||
|
||||
import type { PeerId } from "@suaveplan/types";
|
||||
import type { PeerDenialReason } from "./peer.types.js";
|
||||
|
||||
export interface ChunkRequestedEvent {
|
||||
readonly fileId: string;
|
||||
readonly chunkId: number;
|
||||
readonly peerId: PeerId;
|
||||
}
|
||||
|
||||
export interface ChunkServedEvent {
|
||||
readonly fileId: string;
|
||||
readonly chunkId: number;
|
||||
readonly peerId: PeerId;
|
||||
readonly bytes: number;
|
||||
readonly elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface ChunkFallbackEvent {
|
||||
readonly fileId: string;
|
||||
readonly chunkId: number;
|
||||
readonly bytes: number;
|
||||
readonly elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface ChunkFailedEvent {
|
||||
readonly fileId: string;
|
||||
readonly chunkId: number;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
export interface PeerJoinedEvent {
|
||||
readonly peerId: PeerId;
|
||||
readonly transports: readonly string[];
|
||||
}
|
||||
|
||||
export interface PeerLeftEvent {
|
||||
readonly peerId: PeerId;
|
||||
}
|
||||
|
||||
export interface PeerDeniedEvent {
|
||||
readonly peerId: PeerId;
|
||||
readonly reason: PeerDenialReason;
|
||||
}
|
||||
|
||||
export interface PeerScoredEvent {
|
||||
readonly peerId: PeerId;
|
||||
readonly score: number;
|
||||
readonly rttMs: number | null;
|
||||
}
|
||||
|
||||
export interface GossipSentEvent {
|
||||
readonly peerId: PeerId;
|
||||
readonly fileId: string;
|
||||
readonly sizeBytes: number;
|
||||
}
|
||||
|
||||
export interface GossipReceivedEvent {
|
||||
readonly peerId: PeerId;
|
||||
readonly fileId: string;
|
||||
readonly sizeBytes: number;
|
||||
}
|
||||
|
||||
export type DriveMeshEventMap = {
|
||||
"chunk:requested": [ChunkRequestedEvent];
|
||||
"chunk:served": [ChunkServedEvent];
|
||||
"chunk:fallback": [ChunkFallbackEvent];
|
||||
"chunk:failed": [ChunkFailedEvent];
|
||||
"peer:joined": [PeerJoinedEvent];
|
||||
"peer:left": [PeerLeftEvent];
|
||||
"peer:denied": [PeerDeniedEvent];
|
||||
"peer:scored": [PeerScoredEvent];
|
||||
"gossip:sent": [GossipSentEvent];
|
||||
"gossip:received": [GossipReceivedEvent];
|
||||
};
|
||||
25
packages/networking/drive/drive-mesh/src/types/index.ts
Normal file
25
packages/networking/drive/drive-mesh/src/types/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type {
|
||||
DenylistMode,
|
||||
DriveMeshOptions,
|
||||
ServerFallbackConfig,
|
||||
} from "./config.types.js";
|
||||
export type {
|
||||
AdvertisementHandler,
|
||||
ChunkAdvertisement,
|
||||
DriveTransport,
|
||||
Unsubscribe,
|
||||
} from "./drive-transport.types.js";
|
||||
export type {
|
||||
ChunkFailedEvent,
|
||||
ChunkFallbackEvent,
|
||||
ChunkRequestedEvent,
|
||||
ChunkServedEvent,
|
||||
DriveMeshEventMap,
|
||||
GossipReceivedEvent,
|
||||
GossipSentEvent,
|
||||
PeerDeniedEvent,
|
||||
PeerJoinedEvent,
|
||||
PeerLeftEvent,
|
||||
PeerScoredEvent,
|
||||
} from "./events.types.js";
|
||||
export type { PeerDenialReason, PeerInfo, PeerUpdate } from "./peer.types.js";
|
||||
108
packages/networking/drive/drive-mesh/src/types/peer.types.md
Normal file
108
packages/networking/drive/drive-mesh/src/types/peer.types.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# `peer.types.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the `PeerInfo` overlay tracked by the drive-mesh peer registry.
|
||||
Drive-mesh does not own peer identity — that comes from
|
||||
`network-plugin-registry` via the `NetworkBus` (see
|
||||
`drive-network-integration` spec). What drive-mesh adds on top is
|
||||
file-specific state: which chunks of which files a peer claims to have,
|
||||
and a composite score derived from observed latency / success / error
|
||||
rates.
|
||||
|
||||
The `PeerInfo` shape is intentionally read-only at every nested level
|
||||
so consumers can hand snapshots to telemetry / dashboards without
|
||||
worrying about external mutation.
|
||||
|
||||
## Features
|
||||
|
||||
- File-keyed availability bitsets (one per `fileId`).
|
||||
- EMA latency tracker surfaced as `rttMs`.
|
||||
- Composite `score: number`; `-Infinity` is the denylist sentinel.
|
||||
- Multi-transport tracking — same `peerId` may be reachable via
|
||||
multiple wires.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { PeerInfo } from "@suaveplan/drive-mesh/types";
|
||||
|
||||
function logTop(peers: Iterable<PeerInfo>) {
|
||||
const ranked = [...peers].sort((a, b) => b.score - a.score);
|
||||
for (const peer of ranked.slice(0, 5)) {
|
||||
console.log(peer.peerId, peer.score, peer.rttMs);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { PeerInfo, PeerDenialReason } from "@suaveplan/drive-mesh/types";
|
||||
import { DRIVE_MESH_CODES, DriveMeshError } from "@suaveplan/drive-mesh/errors";
|
||||
|
||||
function ensureReachable(peer: PeerInfo): void {
|
||||
if (peer.score === Number.NEGATIVE_INFINITY) {
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.PEER_DENIED,
|
||||
`${peer.peerId} denylisted`
|
||||
);
|
||||
}
|
||||
if (peer.transports.length === 0) {
|
||||
throw new DriveMeshError(
|
||||
DRIVE_MESH_CODES.TRANSPORT_FAILED,
|
||||
`${peer.peerId} has no reachable transport`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function fileCoverage(peer: PeerInfo, fileId: string): number {
|
||||
const bits = peer.availability.get(fileId);
|
||||
if (bits === undefined) return 0;
|
||||
let count = 0;
|
||||
for (const byte of bits) count += popcount(byte);
|
||||
return count;
|
||||
}
|
||||
|
||||
function popcount(byte: number): number {
|
||||
let n = byte & 0xff;
|
||||
let c = 0;
|
||||
while (n) {
|
||||
c += n & 1;
|
||||
n >>>= 1;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PeerDenialReason = "bad_sig" | "manual"` — taxonomy surfaced on
|
||||
denylist counters.
|
||||
- `PeerInfo`:
|
||||
- `peerId: PeerId` — wire identity (hex pubkey).
|
||||
- `transports: readonly string[]` — transport ids the peer is
|
||||
reachable on; empty iff every transport has dropped it.
|
||||
- `rttMs: number | null` — EMA-smoothed RTT in ms; `null` until the
|
||||
first measurement.
|
||||
- `lastSeen: number` — unix ms of the most recent observation.
|
||||
- `score: number` — composite score; `-Infinity` is the denylist
|
||||
sentinel.
|
||||
- `availability: ReadonlyMap<string, Uint8Array>` — `fileId` →
|
||||
framed bitset of held chunks.
|
||||
- `PeerUpdate { transports?; rttMs?; lastSeen? }` — partial mutation
|
||||
accepted by `PeerRegistry.addOrUpdate`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`score` is computed by the peer registry from observations rather than
|
||||
stored mutably from the outside — the registry is the authority. The
|
||||
field is exposed read-only on `PeerInfo` so consumers (scheduler,
|
||||
gossip engine, telemetry) can read a stable snapshot. Mutations happen
|
||||
inside the registry's `record*` methods.
|
||||
|
||||
`availability` is keyed by hex `fileId` rather than raw `Uint8Array`
|
||||
because `Map`'s equality is reference-based — comparing two byte
|
||||
arrays with the same content would always miss. The framed bitset is
|
||||
the value rather than a parsed `BitSet` so the gossip engine can
|
||||
forward bytes without re-encoding.
|
||||
44
packages/networking/drive/drive-mesh/src/types/peer.types.ts
Normal file
44
packages/networking/drive/drive-mesh/src/types/peer.types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Public type shapes for the peer-registry overlay.
|
||||
*
|
||||
* Identity events come from `network-plugin-registry` via the
|
||||
* `NetworkBus`; drive-mesh maintains the file-specific availability
|
||||
* overlay on top.
|
||||
*/
|
||||
|
||||
import type { PeerId } from "@suaveplan/types";
|
||||
|
||||
/** Reason a peer is on the denylist. */
|
||||
export type PeerDenialReason = "bad_sig" | "manual";
|
||||
|
||||
export interface PeerInfo {
|
||||
/** Wire identity of the peer. */
|
||||
readonly peerId: PeerId;
|
||||
/**
|
||||
* Transports through which the peer is reachable. Empty when the
|
||||
* peer has been forgotten by every transport — the registry retains
|
||||
* it briefly for late-arriving advertisements.
|
||||
*/
|
||||
readonly transports: readonly string[];
|
||||
/** Last EMA-smoothed round-trip time in milliseconds. */
|
||||
readonly rttMs: number | null;
|
||||
/** Unix milliseconds the peer was last observed. */
|
||||
readonly lastSeen: number;
|
||||
/**
|
||||
* Composite score combining latency, success rate, and error rate.
|
||||
* Higher is better. `-Infinity` indicates denylisted.
|
||||
*/
|
||||
readonly score: number;
|
||||
/**
|
||||
* File-keyed availability bitsets. Keys are hex `fileId`. Values
|
||||
* are framed bitsets (output of `encodeFramedBitset`).
|
||||
*/
|
||||
readonly availability: ReadonlyMap<string, Uint8Array>;
|
||||
}
|
||||
|
||||
/** Partial update accepted by `PeerRegistry.addOrUpdate`. */
|
||||
export interface PeerUpdate {
|
||||
readonly transports?: readonly string[];
|
||||
readonly rttMs?: number;
|
||||
readonly lastSeen?: number;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* In-memory `DriveTransport` for tests.
|
||||
*
|
||||
* Modelled on the FakeWebSocketBroker pattern from drive-webrtc: a
|
||||
* single `Broker` object knows about every transport instance, so
|
||||
* an `advertise` call on transport A reaches every subscriber on
|
||||
* transport B that is registered for the same broker.
|
||||
*
|
||||
* `requestChunk` resolves with the bytes the broker has stored under
|
||||
* `(peerId, fileId, chunkId)`. Tests stage bytes via `broker.serve(...)`.
|
||||
*/
|
||||
|
||||
import type { PeerId } from "@suaveplan/types";
|
||||
import type {
|
||||
AdvertisementHandler,
|
||||
ChunkAdvertisement,
|
||||
DriveTransport,
|
||||
Unsubscribe,
|
||||
} from "../../src/types/index.js";
|
||||
|
||||
export interface ChunkBytesSource {
|
||||
/** Bytes to return, OR a thrown error / promise rejection. */
|
||||
readonly bytes?: Uint8Array;
|
||||
readonly throwReason?: string;
|
||||
/** Optional delay in ms before resolving. */
|
||||
readonly delayMs?: number;
|
||||
}
|
||||
|
||||
export class Broker {
|
||||
public readonly transports = new Set<FakeDriveTransport>();
|
||||
public readonly chunkStore = new Map<string, ChunkBytesSource>();
|
||||
|
||||
public serve(
|
||||
peerId: PeerId,
|
||||
fileId: string,
|
||||
chunkId: number,
|
||||
source: ChunkBytesSource
|
||||
): void {
|
||||
this.chunkStore.set(`${peerId}:${fileId}:${chunkId}`, source);
|
||||
}
|
||||
|
||||
public deliverAdvertisement(
|
||||
senderId: PeerId,
|
||||
advert: ChunkAdvertisement
|
||||
): void {
|
||||
for (const transport of this.transports) {
|
||||
for (const handler of transport.handlers) {
|
||||
if (transport.localPeerId === senderId) continue;
|
||||
handler(senderId, advert);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeDriveTransport implements DriveTransport {
|
||||
public readonly id: string;
|
||||
public readonly localPeerId: PeerId;
|
||||
public readonly handlers = new Set<AdvertisementHandler>();
|
||||
public readonly knownPeers = new Set<PeerId>();
|
||||
readonly #broker: Broker;
|
||||
|
||||
public constructor(args: {
|
||||
readonly broker: Broker;
|
||||
readonly localPeerId: PeerId;
|
||||
readonly id?: string;
|
||||
}) {
|
||||
this.id = args.id ?? "fake";
|
||||
this.localPeerId = args.localPeerId;
|
||||
this.#broker = args.broker;
|
||||
this.#broker.transports.add(this);
|
||||
}
|
||||
|
||||
public addKnownPeer(peerId: PeerId): void {
|
||||
this.knownPeers.add(peerId);
|
||||
}
|
||||
|
||||
public async requestChunk(
|
||||
peerId: PeerId,
|
||||
fileId: string,
|
||||
chunkId: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<Uint8Array> {
|
||||
const key = `${peerId}:${fileId}:${chunkId}`;
|
||||
const source = this.#broker.chunkStore.get(key);
|
||||
if (source === undefined) {
|
||||
throw new Error(`no chunk staged at ${key}`);
|
||||
}
|
||||
if (source.delayMs !== undefined && source.delayMs > 0) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const handle = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, source.delayMs);
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(handle);
|
||||
reject(new Error("aborted"));
|
||||
};
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
if (source.throwReason !== undefined) {
|
||||
throw new Error(source.throwReason);
|
||||
}
|
||||
if (source.bytes === undefined) {
|
||||
throw new Error(`no bytes staged at ${key}`);
|
||||
}
|
||||
return source.bytes;
|
||||
}
|
||||
|
||||
public async advertise(
|
||||
peerId: PeerId,
|
||||
advert: ChunkAdvertisement
|
||||
): Promise<void> {
|
||||
// Deliver to every other transport's subscribers via the broker.
|
||||
for (const transport of this.#broker.transports) {
|
||||
if (transport === this) continue;
|
||||
if (transport.localPeerId !== peerId) continue;
|
||||
for (const handler of transport.handlers) {
|
||||
handler(this.localPeerId, advert);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public onAdvertisement(handler: AdvertisementHandler): Unsubscribe {
|
||||
this.handlers.add(handler);
|
||||
return () => {
|
||||
this.handlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
public peers(): Iterable<PeerId> {
|
||||
return this.knownPeers;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.#broker.transports.delete(this);
|
||||
this.handlers.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Test helper: build a real, signature-valid chunk byte string.
|
||||
*
|
||||
* Uses `@suaveplan/drive-plugin-chunks`' `buildSignedChunk` over a
|
||||
* synthetic but well-formed `Chunk` so `verifyChunkSig` accepts the
|
||||
* result. Fixtures stay deterministic — no random bytes anywhere.
|
||||
*/
|
||||
|
||||
import {
|
||||
getPublicKey,
|
||||
randomPrivateKey,
|
||||
} from "@suaveplan/crypto-curve/ed25519";
|
||||
import { buildSignedChunk } from "@suaveplan/drive-plugin-chunks";
|
||||
|
||||
export interface Keypair {
|
||||
readonly privKey: Uint8Array;
|
||||
readonly pubKey: Uint8Array;
|
||||
}
|
||||
|
||||
export function makeKeypair(seed = 1): Keypair {
|
||||
// Deterministic seed via a 32-byte buffer filled with `seed`. Noble
|
||||
// does not strictly require uniformly-random keys for verifiability,
|
||||
// only that the corresponding public key is derived from the same
|
||||
// private key.
|
||||
const priv =
|
||||
seed === 0 ? randomPrivateKey() : new Uint8Array(32).fill(seed & 0xff);
|
||||
const pub = getPublicKey(priv);
|
||||
return { privKey: priv, pubKey: pub };
|
||||
}
|
||||
|
||||
export function fileIdBytes(hex: string): Uint8Array {
|
||||
if (hex.length !== 64) {
|
||||
throw new Error(`fileId hex must be 64 chars, got ${hex.length}`);
|
||||
}
|
||||
const out = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const byteHex = hex.slice(i * 2, i * 2 + 2);
|
||||
out[i] = Number.parseInt(byteHex, 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildChunkBytes(args: {
|
||||
readonly serverPrivKey: Uint8Array;
|
||||
readonly fileIdHex: string;
|
||||
readonly chunkId: number;
|
||||
readonly totalChunks?: number;
|
||||
readonly payload?: Uint8Array;
|
||||
}): Uint8Array {
|
||||
const totalChunks = args.totalChunks ?? 8;
|
||||
const payload =
|
||||
args.payload ?? new Uint8Array([args.chunkId & 0xff, 0x42, 0x13, 0x37]);
|
||||
const chunkSize = Math.max(payload.byteLength, 4);
|
||||
const chunk = {
|
||||
metadata: {
|
||||
version: 0x0001,
|
||||
fileId: fileIdBytes(args.fileIdHex),
|
||||
chunkId: args.chunkId,
|
||||
totalChunks,
|
||||
fileSize: BigInt(chunkSize * totalChunks),
|
||||
chunkSize,
|
||||
payloadLen: payload.byteLength,
|
||||
flags: 0,
|
||||
nonce: new Uint8Array(24),
|
||||
aeadTag: new Uint8Array(16),
|
||||
},
|
||||
sig: new Uint8Array(64), // overwritten by buildSignedChunk
|
||||
ciphertext: payload,
|
||||
};
|
||||
return buildSignedChunk(chunk, args.serverPrivKey);
|
||||
}
|
||||
|
||||
export const TEST_FILE_ID_HEX = "a".repeat(64);
|
||||
12
packages/networking/drive/drive-mesh/tsconfig.lib.json
Normal file
12
packages/networking/drive/drive-mesh/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
41
packages/networking/drive/drive-mesh/vite.config.ts
Normal file
41
packages/networking/drive/drive-mesh/vite.config.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/drive-mesh",
|
||||
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/crypto-curve",
|
||||
"@suaveplan/drive-mesh-protocol",
|
||||
"@suaveplan/drive-plugin-chunks",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
11
packages/networking/drive/drive-mesh/vitest.config.ts
Normal file
11
packages/networking/drive/drive-mesh/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({
|
||||
testTimeout: 30_000,
|
||||
coverageThresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user