This commit is contained in:
Raven Scott
2026-05-20 21:02:45 -04:00
parent 1f3f4b24a2
commit 14d0980b4f
734 changed files with 682 additions and 746 deletions
@@ -0,0 +1,155 @@
# API: hyper-p2p-presence
**Protocol:** `hyper-p2p-presence/v1.1`
**Export:** `HyperP2PPresence` (also `PRESENCE_PROTOCOL`, `PROTOCOL`)
## Overview
`HyperP2PPresence` is a production-grade P2P presence and liveness manager for Bare/Pear. Peers join a shared Hyperswarm topic, exchange signed presence records over a dedicated Protomux channel, keep an in-memory peer map, and persist records in a local Hyperbee database backed by Hypercore. Optional background timers periodically rebroadcast local presence and mark stale peers offline.
The module extends `bare-events` `EventEmitter`. Call `ready()` before relying on swarm or storage; call `close()` for teardown.
## Constructor
```js
const presence = new HyperP2PPresence(opts)
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `keyPair` | `KeyPair` | `hypercore-crypto.keyPair()` | Ed25519 key pair for Hypercore, signing, and swarm identity |
| `topic` | `string` \| `Buffer` | `null` | Hyperswarm topic (64-char hex or string hashed via `topicToBuffer`); **required** for `ready()` swarm join |
| `storageDir` | `string` | `path.join(process.cwd(), 'hyper-p2p-presence-storage')` | Root directory; Hypercore lives at `{storageDir}/presence` |
| `announceInterval` | `number` | `30000` | Milliseconds between periodic self-announce when background timers are enabled (stored as `announceIntervalMs`) |
| `expiry` | `number` | `120000` | Milliseconds until a peer record is considered stale if not refreshed (stored as `expiryMs`) |
| `metadata` | `object` | `{}` | Arbitrary JSON-serializable metadata attached to local presence |
| `enableBackgroundTimers` | `boolean` | `false` | When `true`, starts announce and cleanup `setInterval` loops after `ready()` |
## Methods
### `ready()`
Initializes Hyperbee storage, joins the Hyperswarm (when `topic` is set), registers self in `peers`, and optionally starts background timers.
- **Returns:** `Promise<void>`
- **Throws:**
- `Error: topic is required for presence swarm` — when `topic` is null/undefined during swarm init
- Filesystem errors from `fs.mkdir` except `err.code === 'EEXIST'`
Idempotent: if already joined (`_joined === true`), resolves immediately without re-emitting `ready`.
Emits `ready` on first successful join.
### `close()`
Clears announce and cleanup timers, destroys the swarm, closes Hyperbee, sets `_joined` to `false`, emits `close`.
- **Returns:** `Promise<void>`
- **Throws:** — (errors from `swarm.destroy()` / `bee.close()` are caught and ignored)
### `updateMetadata(newMetadata)`
Shallow-merges `newMetadata` into `this.metadata`, updates the local peer entry, persists to Hyperbee, emits `self-presence`.
- **Parameters:** `newMetadata``object` merged with `{ ...this.metadata, ...newMetadata }`
- **Returns:** `Promise<void>`
- **Throws:** — (Hyperbee `put` failures propagate)
Does not immediately send on the wire unless background announce runs or a peer connection triggers `_sendPresenceUpdate`.
### `getPeers(filter = {})`
Returns a snapshot array of presence records from the in-memory `peers` map.
- **Parameters:**
- `filter.online``boolean` | `undefined`; when set, only peers matching that online flag
- `filter.metadata``object`; every key must match `p.metadata[k]` exactly (shallow equality)
- **Returns:** `Array<PresenceRecord>`
- **Throws:** —
### `getSelf()`
- **Returns:** `PresenceRecord | null` — local peer entry keyed by hex public key, or `null` if missing
- **Throws:** —
### `getStats()`
- **Returns:** `{ ops: number, errors: number }` — shallow copy of internal counters (initialized to `0`; not incremented by current implementation paths)
- **Throws:** —
## Presence record shape
Objects in `peers`, events, and Hyperbee values share this structure:
| Field | Type | Description |
|-------|------|-------------|
| `publicKey` | `string` | Hex-encoded Ed25519 public key |
| `metadata` | `object` | Application metadata |
| `lastSeen` | `number` | Unix ms timestamp of last update |
| `online` | `boolean` | `true` while within expiry window |
| `expiresAt` | `number` | Unix ms when record should go offline without refresh |
| `signature` | `string \| null` | Base64 Ed25519 signature on wire updates (optional on stored records) |
| `verified` | `boolean` | Whether incoming signature verified against `peerInfo.publicKey` (remote updates only) |
## Events
| Event | Payload | When |
|-------|---------|------|
| `ready` | — | First successful `ready()` |
| `close` | — | After `close()` |
| `peer-connected` | `{ publicKey: string \| null }` | Protomux channel `onopen` for a remote peer |
| `peer-disconnected` | `{ publicKey: string \| null }` | Channel `onclose` |
| `peer-joined` | `PresenceRecord` | Remote presence first seen or transitions to online |
| `peer-updated` | `PresenceRecord` | Remote presence refresh while already online |
| `peer-left` | `PresenceRecord` | Cleanup timer marks peer offline (`expiresAt < now`) |
| `presence-changed` | `PresenceRecord[]` | After one or more peers left in a cleanup tick |
| `self-presence` | `PresenceRecord` | Background announce tick or `updateMetadata()` |
## getStats()
| Field | Type | Meaning |
|-------|------|---------|
| `ops` | `number` | Reserved operation counter (default `0`) |
| `errors` | `number` | Reserved error counter (default `0`) |
## Errors
Stable message substrings for tests and logging: see [`../_shared/ERROR_CODES.md`](../../_shared/ERROR_CODES.md).
Module-specific throws:
| Message | Source |
|---------|--------|
| `topic is required for presence swarm` | `_initSwarm()` when `topic` is missing |
Shared helper (`createSwarm` in `p2p-bare.js`) may throw `topic is required for createSwarm` if invoked without a topic (not reached when `topic` is set on the instance).
## P2P
1. `ready()` creates `{storageDir}/presence` Hypercore + Hyperbee (`keyEncoding: 'utf-8'`, `valueEncoding: 'json'`), loads persisted peers (marked `online: false` until a live announce).
2. `createSwarm({ keyPair, topic })` joins Hyperswarm; `wireConnection` opens Protomux per socket.
3. `protocolChannel(mux, { protocol: 'hyper-p2p-presence/v1.1', ... })` — compact-encoding JSON messages.
4. On channel open, local node sends a signed `presence` envelope; `onmessage` handles remote `type === 'presence'`.
5. Topic strings that match `/^[0-9a-f]{64}$/i` are used as raw 32-byte topics; other strings are hashed with `hypercore-crypto.hash`.
Signing uses Ed25519 via `hypercore-crypto.sign` / `verify` over a canonical JSON payload including `nonce` (16 random bytes, hex) for replay resistance. Wire `version` is `'1.1'`.
Enable `enableBackgroundTimers: true` in production so `announceInterval` broadcasts and `expiry`-driven cleanup run automatically.
## Testing
```bash
cd modules/core-infrastructure/hyper-p2p-presence
npm install && npm test
```
Tests cover lifecycle, metadata update, peer filters, signing fields, and close without topic. Use a unique `storageDir` per test run (see `test/test.js`).
Example:
```bash
bare examples/basic.js
```
For two-node integration, run paired processes with the same `topic` and distinct `storageDir` / `keyPair` values.
@@ -0,0 +1,136 @@
# Architecture: hyper-p2p-presence
**Category:** Core infrastructure
**Protocol:** `hyper-p2p-presence/v1.1`
## Layer diagram
```mermaid
flowchart TB
App[Application] --> HP[HyperP2PPresence]
HP --> EE[EventEmitter events]
HP --> Map[peers Map in-memory]
HP --> HB[Hyperbee on Hypercore]
HB --> FS["{storageDir}/presence"]
HP --> SW[Hyperswarm via createSwarm]
SW --> MUX[Protomux per connection]
MUX --> CH["Channel hyper-p2p-presence/v1.1"]
CH --> Wire[JSON presence envelopes]
```
## Sequence: ready → peer connect → announce
```mermaid
sequenceDiagram
participant App
participant HP as HyperP2PPresence
participant HB as Hyperbee
participant SW as Hyperswarm
participant Remote as Remote peer
App->>HP: ready()
HP->>HB: mkdir storage, core.ready(), load stream
HP->>SW: createSwarm(topic)
HP->>HP: _ensureSelfRegistered()
opt enableBackgroundTimers
HP->>HP: _startAnnounceTimer()
HP->>HP: _startCleanupTimer()
end
HP-->>App: emit ready
SW-->>HP: connection(socket, peerInfo, mux)
HP->>HP: protocolChannel + _peerChannels.set
HP-->>App: peer-connected
HP->>Remote: send presence envelope
Remote->>HP: presence envelope
HP->>HP: verify signature, peers.set
HP->>HB: bee.put(pubKeyHex, record)
HP-->>App: peer-joined or peer-updated
loop every announceIntervalMs
HP->>Remote: _broadcastPresence via _peerChannels
HP->>HB: bee.put(self)
HP-->>App: self-presence
end
loop every min(expiryMs/2, 30000)
HP->>HP: mark expired online=false
HP-->>App: peer-left, presence-changed
end
```
## Wire messages
All messages use Protomux `compact-encoding` JSON on protocol `hyper-p2p-presence/v1.1`.
| Envelope `type` | Fields | Direction | Behavior |
|-----------------|--------|-----------|----------|
| `presence` | See `data` below | bidirectional | Only `type === 'presence'` is handled in `onmessage`; other types ignored |
### `presence` → `data` object
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `publicKey` | `string` | yes | Hex public key of the announcing peer |
| `metadata` | `object` | no | Defaults to `{}` |
| `timestamp` | `number` | no | Unix ms; defaults to receive time if omitted |
| `nonce` | `string` | no | 32-char hex replay nonce; generated on send (16 random bytes) |
| `version` | `string` | no | `'1.1'` on outbound records |
| `signature` | `string` | no | Base64 Ed25519 signature over canonical JSON |
**Signature payload** (UTF-8 JSON stringified, then signed):
| Field | Value on send |
|-------|----------------|
| `publicKey` | Local hex public key |
| `metadata` | `this.metadata` |
| `timestamp` | Send-time ms |
| `nonce` | Fresh random hex |
| `version` | `'1.1'` |
Verification uses `peerInfo.publicKey` from Hyperswarm; sets `verified: true/false` on the stored record. Invalid or missing signatures do not drop the update—they store `verified: false`.
Full wire envelope:
```json
{
"type": "presence",
"data": { "publicKey": "...", "metadata": {}, "timestamp": 0, "nonce": "...", "version": "1.1", "signature": "..." }
}
```
## State model
| Structure | Key | Value / role |
|-----------|-----|----------------|
| `peers` | `publicKey` hex | `PresenceRecord` — authoritative in-memory view |
| `_peerChannels` | `publicKey` hex | `{ msg }` — Protomux message handle for `_broadcastPresence` |
| `bee` (Hyperbee) | `pubKeyHex` | Same record JSON persisted across restarts |
| `_joined` | — | `boolean``ready()` completed |
| `announceTimer` | — | `setInterval` every `announceIntervalMs` (only if `enableBackgroundTimers`) |
| `cleanupTimer` | — | `setInterval` every `min(expiryMs / 2, 30000)` (only if `enableBackgroundTimers`) |
**Persistence path:** `{storageDir}/presence/` — Hypercore directory name constant `PRESENCE_DB_NAME = 'presence'`.
**Expiry logic:** On cleanup tick, if `presence.expiresAt < now` and `presence.online`, set `online: false`, emit `peer-left`. Refreshed remote announces reset `expiresAt` to `now + expiryMs`.
**Load behavior:** `_loadPersistedPeers()` hydrates `peers` from Hyperbee with `online: false` until a live wire update.
## Composition
| Peer module | Relationship |
|-------------|--------------|
| `hyper-p2p-rpc` | App RPC over established SecretStream / swarm sockets; presence supplies who is online and metadata |
| `hyper-p2p-capabilities` | Tokens and delegation layered on identified peers |
| `hyper-p2p-link-probe` | RTT matrix; pairs with presence for anycast (see Wave 6 stack) |
Typical stack: Hyperswarm underlay → **presence** (who is here) → `hyper-p2p-protocol-handshake` / **rpc** (what they speak).
See [`../_shared/WAVE6_NETWORK_STACK.md`](../../_shared/WAVE6_NETWORK_STACK.md) for Wave 6 layering (`link-probe` ↔ presence, connection-pool, overlay-topology).
## Operational notes
- Background timers are **off** by default (`enableBackgroundTimers: false`); tests and minimal examples omit them. Production multi-peer apps should enable them.
- `_sendPresenceUpdate` runs on channel open and on announce ticks; metadata-only changes via `updateMetadata` update local state and Hyperbee but do not fan out until the next broadcast or new connection.
- Swarm and storage failures during `close()` are swallowed to ensure shutdown completes.