This commit is contained in:
Raven Scott
2026-05-20 22:28:59 -04:00
parent 341542f41b
commit e4400872c0
82 changed files with 2533 additions and 775 deletions
+18
View File
@@ -0,0 +1,18 @@
const { initModuleSwarm, gossipSend } = require('./p2p-bare.js')
async function attachObsGossip (instance, opts) {
const { keyPair, topic, protocol, onmessage } = opts
if (!topic) return null
return initModuleSwarm(instance, {
keyPair,
topic,
protocol,
onmessage
})
}
function sendObs (instance, payload) {
gossipSend(instance, payload)
}
module.exports = { attachObsGossip, sendObs }
@@ -2,7 +2,12 @@ require('bare-process/global')
const { HyperP2PCollabRoom } = require('../index.js') const { HyperP2PCollabRoom } = require('../index.js')
async function main () { async function main () {
const m = new HyperP2PCollabRoom() const room = new HyperP2PCollabRoom()
console.log('[scaffold]', m.getStats()) room.createRoom('demo')
room.join('demo', { user: 'guest' })
room.broadcast('demo', { op: 'ping' })
console.log(room.getStats(), room.getMembers('demo'))
await room.close()
} }
main().catch(console.error) main().catch(console.error)
@@ -1,7 +1,8 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const b4a = require('b4a')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const { assertNonEmpty } = require('../../_shared/lib/errors.js')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js') const { initModuleSwarm, gossipSend } = require('../../_shared/p2p-bare.js')
const PROTOCOL = 'collab-room/v1' const PROTOCOL = 'collab-room/v1'
@@ -10,50 +11,128 @@ class HyperP2PCollabRoom extends EventEmitter {
super() super()
this.topic = opts.topic || null this.topic = opts.topic || null
this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair() this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair()
this._store = new Map() this.peerHex = b4a.toString(this.keyPair.publicKey, 'hex')
this._stats = { ops: 0, gossipIn: 0, gossipOut: 0 } this._rooms = new Map()
this._stats = { rooms: 0, joins: 0, broadcasts: 0, gossipIn: 0, gossipOut: 0 }
this.swarm = null this.swarm = null
this._peerMsgs = null
} }
put (key, value) { _room (roomId) {
assertNonEmpty(key, 'key') if (!this._rooms.has(roomId)) {
this._store.set(key, value) this._rooms.set(roomId, { members: new Map(), events: [] })
this._stats.ops++ this._stats.rooms++
sendGossip(this, { type: 'collab-room-sync', key, value }) }
this.emit('update', { key, value }) return this._rooms.get(roomId)
}
createRoom (roomId, meta = {}) {
assertNonEmpty(roomId, 'roomId')
const room = this._room(roomId)
room.meta = meta
this.emit('room-created', { roomId, meta })
return roomId
}
join (roomId, meta = {}) {
assertNonEmpty(roomId, 'roomId')
const room = this._room(roomId)
const member = { peer: this.peerHex, meta, joinedAt: Date.now() }
room.members.set(this.peerHex, member)
this._stats.joins++
if (this._peerMsgs) {
gossipSend(this, { type: 'join', roomId, member })
this._stats.gossipOut++
}
this.emit('join', { roomId, member })
return member
}
leave (roomId) {
const room = this._rooms.get(roomId)
if (!room) return false
room.members.delete(this.peerHex)
if (this._peerMsgs) {
gossipSend(this, { type: 'leave', roomId, peer: this.peerHex })
this._stats.gossipOut++
}
this.emit('leave', { roomId, peer: this.peerHex })
return true return true
} }
get (key) { return this._store.get(key) } broadcast (roomId, event) {
assertNonEmpty(roomId, 'roomId')
delete (key) { if (!event || typeof event !== 'object') throw new Error('event object required')
const ok = this._store.delete(key) const room = this._rooms.get(roomId)
if (ok) sendGossip(this, { type: 'collab-room-sync', key, value: null }) if (!room) throw new Error('room not found')
return ok const entry = { ...event, from: this.peerHex, at: Date.now() }
room.events.push(entry)
if (room.events.length > 500) room.events.shift()
this._stats.broadcasts++
if (this._peerMsgs) {
gossipSend(this, { type: 'broadcast', roomId, event: entry })
this._stats.gossipOut++
}
this.emit('broadcast', { roomId, event: entry })
return entry
} }
entries () { return [...this._store.entries()] } getMembers (roomId) {
const room = this._rooms.get(roomId)
if (!room) return []
return [...room.members.values()]
}
_onGossip (d) { getEvents (roomId, limit = 50) {
if (!d || d.type !== 'collab-room-sync') return const room = this._rooms.get(roomId)
if (!room) return []
return room.events.slice(-limit)
}
_onGossip (data) {
if (!data || !data.type) return
this._stats.gossipIn++ this._stats.gossipIn++
if (d.key !== undefined) { const room = data.roomId ? this._room(data.roomId) : null
if (d.value === null) this._store.delete(d.key) if (!room) return
else this._store.set(d.key, d.value)
if (data.type === 'join' && data.member) {
room.members.set(data.member.peer, data.member)
this.emit('peer-joined', { roomId: data.roomId, member: data.member })
}
if (data.type === 'leave' && data.peer) {
room.members.delete(data.peer)
this.emit('peer-left', { roomId: data.roomId, peer: data.peer })
}
if (data.type === 'broadcast' && data.event) {
room.events.push(data.event)
this.emit('broadcast', { roomId: data.roomId, event: data.event, remote: true })
} }
} }
getStats () { return { ...this._stats, size: this._store.size, protocol: PROTOCOL } } getStats () {
return {
...this._stats,
activeRooms: this._rooms.size,
protocol: PROTOCOL
}
}
async ready () { async ready () {
if (this.swarm || !this.topic) return this if (this.swarm || !this.topic) return this
await attachGossip(this, { keyPair: this.keyPair, topic: this.topic, protocol: PROTOCOL, onmessage: (d) => this._onGossip(d) }) await initModuleSwarm(this, {
keyPair: this.keyPair,
topic: this.topic,
protocol: PROTOCOL,
onmessage: (data) => this._onGossip(data)
})
return this return this
} }
async close () { async close () {
this._rooms.clear()
if (this.swarm) await this.swarm.destroy().catch(() => {}) if (this.swarm) await this.swarm.destroy().catch(() => {})
this.swarm = null this.swarm = null
this.emit('closed')
} }
} }
@@ -4,23 +4,46 @@ const { HyperP2PCollabRoom, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => {
t.ok(HyperP2PCollabRoom) t.ok(HyperP2PCollabRoom)
t.ok(PROTOCOL) t.is(PROTOCOL, 'collab-room/v1')
}) })
test('basic operation', async (t) => { test('create join broadcast', async (t) => {
const m = new HyperP2PCollabRoom() const room = new HyperP2PCollabRoom()
m.put('k', 1); t.is(m.get('k'), 1) room.createRoom('r1')
await m.close() room.join('r1', { name: 'alice' })
const ev = room.broadcast('r1', { op: 'draw', x: 1 })
t.is(ev.op, 'draw')
t.is(room.getMembers('r1').length, 1)
t.is(room.getEvents('r1').length, 1)
await room.close()
})
test('remote join via gossip handler', async (t) => {
const room = new HyperP2PCollabRoom()
room.createRoom('r2')
room._onGossip({
type: 'join',
roomId: 'r2',
member: { peer: 'peer-b', meta: {}, joinedAt: Date.now() }
})
t.is(room.getMembers('r2').length, 1)
await room.close()
}) })
test('validation', async (t) => { test('validation', async (t) => {
const m = new HyperP2PCollabRoom() const room = new HyperP2PCollabRoom()
try { m.put(null, 1) } catch (e) { t.ok(e) } try {
await m.close() room.broadcast('missing', {})
t.fail('expected throw')
} catch (e) {
t.ok(e instanceof Error)
}
await room.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PCollabRoom() const room = new HyperP2PCollabRoom()
t.ok(m.getStats().protocol) room.createRoom('s')
await m.close() t.ok(room.getStats().protocol)
await room.close()
}) })
@@ -1,7 +1,8 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const b4a = require('b4a')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const { assertNonEmpty } = require('../../_shared/lib/errors.js')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js') const { initModuleSwarm, gossipSend } = require('../../_shared/p2p-bare.js')
const PROTOCOL = 'cursor-presence/v1' const PROTOCOL = 'cursor-presence/v1'
@@ -10,50 +11,85 @@ class HyperP2PCursorPresence extends EventEmitter {
super() super()
this.topic = opts.topic || null this.topic = opts.topic || null
this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair() this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair()
this._store = new Map() this.peerHex = b4a.toString(this.keyPair.publicKey, 'hex')
this._stats = { ops: 0, gossipIn: 0, gossipOut: 0 } this._cursors = new Map()
this._stats = { updates: 0, gossipIn: 0, gossipOut: 0 }
this.swarm = null this.swarm = null
this._peerMsgs = null
} }
put (key, value) { updateCursor (docId, position = {}) {
assertNonEmpty(key, 'key') assertNonEmpty(docId, 'docId')
this._store.set(key, value) const key = `${docId}:${this.peerHex}`
this._stats.ops++ const entry = {
sendGossip(this, { type: 'cursor-presence-sync', key, value }) docId,
this.emit('update', { key, value }) peer: this.peerHex,
return true line: position.line | 0,
column: position.column | 0,
color: position.color || null,
at: Date.now()
}
this._cursors.set(key, entry)
this._stats.updates++
if (this._peerMsgs) {
gossipSend(this, { type: 'cursor', entry })
this._stats.gossipOut++
}
this.emit('cursor', entry)
return entry
} }
get (key) { return this._store.get(key) } getCursor (docId, peerHex) {
return this._cursors.get(`${docId}:${peerHex}`) || null
}
delete (key) { listCursors (docId) {
const ok = this._store.delete(key) assertNonEmpty(docId, 'docId')
if (ok) sendGossip(this, { type: 'cursor-presence-sync', key, value: null }) return [...this._cursors.values()].filter((c) => c.docId === docId)
}
removeCursor (docId, peerHex = this.peerHex) {
const key = `${docId}:${peerHex}`
const ok = this._cursors.delete(key)
if (ok && this._peerMsgs) {
gossipSend(this, { type: 'cursor-remove', docId, peer: peerHex })
this._stats.gossipOut++
}
return ok return ok
} }
entries () { return [...this._store.entries()] } _onGossip (data) {
if (!data) return
_onGossip (d) {
if (!d || d.type !== 'cursor-presence-sync') return
this._stats.gossipIn++ this._stats.gossipIn++
if (d.key !== undefined) { if (data.type === 'cursor' && data.entry) {
if (d.value === null) this._store.delete(d.key) const key = `${data.entry.docId}:${data.entry.peer}`
else this._store.set(d.key, d.value) this._cursors.set(key, data.entry)
this.emit('remote-cursor', data.entry)
}
if (data.type === 'cursor-remove') {
this._cursors.delete(`${data.docId}:${data.peer}`)
} }
} }
getStats () { return { ...this._stats, size: this._store.size, protocol: PROTOCOL } } getStats () {
return { ...this._stats, cursors: this._cursors.size, protocol: PROTOCOL }
}
async ready () { async ready () {
if (this.swarm || !this.topic) return this if (this.swarm || !this.topic) return this
await attachGossip(this, { keyPair: this.keyPair, topic: this.topic, protocol: PROTOCOL, onmessage: (d) => this._onGossip(d) }) await initModuleSwarm(this, {
keyPair: this.keyPair,
topic: this.topic,
protocol: PROTOCOL,
onmessage: (d) => this._onGossip(d)
})
return this return this
} }
async close () { async close () {
if (this.swarm) await this.swarm.destroy().catch(() => {}) if (this.swarm) await this.swarm.destroy().catch(() => {})
this.swarm = null this.swarm = null
this._cursors.clear()
} }
} }
@@ -4,23 +4,31 @@ const { HyperP2PCursorPresence, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => {
t.ok(HyperP2PCursorPresence) t.ok(HyperP2PCursorPresence)
t.ok(PROTOCOL) t.is(PROTOCOL, 'cursor-presence/v1')
}) })
test('basic operation', async (t) => { test('update and list', async (t) => {
const m = new HyperP2PCursorPresence() const m = new HyperP2PCursorPresence()
m.put('k', 1); t.is(m.get('k'), 1) m.updateCursor('doc1', { line: 3, column: 1 })
t.is(m.listCursors('doc1').length, 1)
await m.close() await m.close()
}) })
test('validation', async (t) => { test('validation', async (t) => {
const m = new HyperP2PCursorPresence() const m = new HyperP2PCursorPresence()
try { m.put(null, 1) } catch (e) { t.ok(e) } try { m.updateCursor('', {}) } catch (e) { t.ok(e) }
await m.close()
})
test('remote cursor', async (t) => {
const m = new HyperP2PCursorPresence()
m._onGossip({ type: 'cursor', entry: { docId: 'd', peer: 'remote', line: 1, column: 0, at: 1 } })
t.ok(m.getCursor('d', 'remote'))
await m.close() await m.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PCursorPresence() const m = new HyperP2PCursorPresence()
t.ok(m.getStats().protocol) t.is(m.getStats().protocol, 'cursor-presence/v1')
await m.close() await m.close()
}) })
@@ -1,7 +1,8 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const b4a = require('b4a')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const { assertNonEmpty } = require('../../_shared/lib/errors.js')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js') const { initModuleSwarm, gossipSend } = require('../../_shared/p2p-bare.js')
const PROTOCOL = 'document-line-lock/v1' const PROTOCOL = 'document-line-lock/v1'
@@ -10,50 +11,89 @@ class HyperP2PDocumentLineLock extends EventEmitter {
super() super()
this.topic = opts.topic || null this.topic = opts.topic || null
this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair() this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair()
this._store = new Map() this.holderHex = b4a.toString(this.keyPair.publicKey, 'hex')
this._stats = { ops: 0, gossipIn: 0, gossipOut: 0 } this._locks = new Map()
this._stats = { acquired: 0, released: 0, gossipIn: 0, gossipOut: 0 }
this.swarm = null this.swarm = null
this._peerMsgs = null
} }
put (key, value) { _key (docId, line) {
assertNonEmpty(key, 'key') return `${docId}:${line | 0}`
this._store.set(key, value) }
this._stats.ops++
sendGossip(this, { type: 'document-line-lock-sync', key, value }) acquire (docId, line) {
this.emit('update', { key, value }) assertNonEmpty(docId, 'docId')
const key = this._key(docId, line)
const existing = this._locks.get(key)
if (existing && existing.holder !== this.holderHex) {
return { ok: false, holder: existing.holder }
}
const lock = { docId, line: line | 0, holder: this.holderHex, at: Date.now() }
this._locks.set(key, lock)
this._stats.acquired++
if (this._peerMsgs) {
gossipSend(this, { type: 'line-lock', lock })
this._stats.gossipOut++
}
this.emit('acquire', lock)
return { ok: true, ...lock }
}
release (docId, line) {
const key = this._key(docId, line)
const lock = this._locks.get(key)
if (!lock || lock.holder !== this.holderHex) return false
this._locks.delete(key)
this._stats.released++
if (this._peerMsgs) {
gossipSend(this, { type: 'line-unlock', docId, line: line | 0, holder: this.holderHex })
this._stats.gossipOut++
}
this.emit('release', { docId, line })
return true return true
} }
get (key) { return this._store.get(key) } isLocked (docId, line) {
return this._locks.has(this._key(docId, line))
delete (key) {
const ok = this._store.delete(key)
if (ok) sendGossip(this, { type: 'document-line-lock-sync', key, value: null })
return ok
} }
entries () { return [...this._store.entries()] } getLock (docId, line) {
return this._locks.get(this._key(docId, line)) || null
}
_onGossip (d) { _onGossip (data) {
if (!d || d.type !== 'document-line-lock-sync') return if (!data) return
this._stats.gossipIn++ this._stats.gossipIn++
if (d.key !== undefined) { if (data.type === 'line-lock' && data.lock) {
if (d.value === null) this._store.delete(d.key) this._locks.set(this._key(data.lock.docId, data.lock.line), data.lock)
else this._store.set(d.key, d.value) this.emit('remote-acquire', data.lock)
}
if (data.type === 'line-unlock') {
this._locks.delete(this._key(data.docId, data.line))
this.emit('remote-release', data)
} }
} }
getStats () { return { ...this._stats, size: this._store.size, protocol: PROTOCOL } } getStats () {
return { ...this._stats, locks: this._locks.size, protocol: PROTOCOL }
}
async ready () { async ready () {
if (this.swarm || !this.topic) return this if (this.swarm || !this.topic) return this
await attachGossip(this, { keyPair: this.keyPair, topic: this.topic, protocol: PROTOCOL, onmessage: (d) => this._onGossip(d) }) await initModuleSwarm(this, {
keyPair: this.keyPair,
topic: this.topic,
protocol: PROTOCOL,
onmessage: (d) => this._onGossip(d)
})
return this return this
} }
async close () { async close () {
if (this.swarm) await this.swarm.destroy().catch(() => {}) if (this.swarm) await this.swarm.destroy().catch(() => {})
this.swarm = null this.swarm = null
this._locks.clear()
} }
} }
@@ -4,23 +4,34 @@ const { HyperP2PDocumentLineLock, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => {
t.ok(HyperP2PDocumentLineLock) t.ok(HyperP2PDocumentLineLock)
t.ok(PROTOCOL) t.is(PROTOCOL, 'document-line-lock/v1')
}) })
test('basic operation', async (t) => { test('acquire and release', async (t) => {
const m = new HyperP2PDocumentLineLock() const m = new HyperP2PDocumentLineLock()
m.put('k', 1); t.is(m.get('k'), 1) const r = m.acquire('doc', 5)
t.ok(r.ok)
t.ok(m.isLocked('doc', 5))
t.ok(m.release('doc', 5))
await m.close()
})
test('conflict', async (t) => {
const m = new HyperP2PDocumentLineLock()
m._onGossip({ type: 'line-lock', lock: { docId: 'd', line: 1, holder: 'other', at: 1 } })
const r = m.acquire('d', 1)
t.is(r.ok, false)
await m.close() await m.close()
}) })
test('validation', async (t) => { test('validation', async (t) => {
const m = new HyperP2PDocumentLineLock() const m = new HyperP2PDocumentLineLock()
try { m.put(null, 1) } catch (e) { t.ok(e) } try { m.acquire('', 0) } catch (e) { t.ok(e) }
await m.close() await m.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PDocumentLineLock() const m = new HyperP2PDocumentLineLock()
t.ok(m.getStats().protocol) t.is(m.getStats().protocol, 'document-line-lock/v1')
await m.close() await m.close()
}) })
@@ -1,7 +1,8 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const b4a = require('b4a')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const { assertNonEmpty } = require('../../_shared/lib/errors.js')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js') const { initModuleSwarm, gossipSend } = require('../../_shared/p2p-bare.js')
const PROTOCOL = 'whiteboard-op/v1' const PROTOCOL = 'whiteboard-op/v1'
@@ -10,48 +11,66 @@ class HyperP2PWhiteboardOp extends EventEmitter {
super() super()
this.topic = opts.topic || null this.topic = opts.topic || null
this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair() this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair()
this._store = new Map() this.peerHex = b4a.toString(this.keyPair.publicKey, 'hex')
this._log = []
this._stats = { ops: 0, gossipIn: 0, gossipOut: 0 } this._stats = { ops: 0, gossipIn: 0, gossipOut: 0 }
this.swarm = null this.swarm = null
this._peerMsgs = null
} }
put (key, value) { apply (roomId, op) {
assertNonEmpty(key, 'key') assertNonEmpty(roomId, 'roomId')
this._store.set(key, value) if (!op || typeof op !== 'object') throw new Error('op object required')
const entry = {
roomId,
op,
peer: this.peerHex,
seq: this._log.length,
at: Date.now()
}
this._log.push(entry)
this._stats.ops++ this._stats.ops++
sendGossip(this, { type: 'whiteboard-op-sync', key, value }) if (this._peerMsgs) {
this.emit('update', { key, value }) gossipSend(this, { type: 'wb-op', entry })
this._stats.gossipOut++
}
this.emit('op', entry)
return entry
}
history (roomId, limit = 100) {
return this._log.filter((e) => e.roomId === roomId).slice(-limit)
}
mergeRemote (entry) {
if (!entry || !entry.roomId) return false
this._stats.gossipIn++
this._log.push(entry)
this.emit('op', { ...entry, remote: true })
return true return true
} }
get (key) { return this._store.get(key) } _onGossip (data) {
if (data && data.type === 'wb-op' && data.entry) this.mergeRemote(data.entry)
delete (key) {
const ok = this._store.delete(key)
if (ok) sendGossip(this, { type: 'whiteboard-op-sync', key, value: null })
return ok
} }
entries () { return [...this._store.entries()] } getStats () {
return { ...this._stats, logSize: this._log.length, protocol: PROTOCOL }
_onGossip (d) {
if (!d || d.type !== 'whiteboard-op-sync') return
this._stats.gossipIn++
if (d.key !== undefined) {
if (d.value === null) this._store.delete(d.key)
else this._store.set(d.key, d.value)
}
} }
getStats () { return { ...this._stats, size: this._store.size, protocol: PROTOCOL } }
async ready () { async ready () {
if (this.swarm || !this.topic) return this if (this.swarm || !this.topic) return this
await attachGossip(this, { keyPair: this.keyPair, topic: this.topic, protocol: PROTOCOL, onmessage: (d) => this._onGossip(d) }) await initModuleSwarm(this, {
keyPair: this.keyPair,
topic: this.topic,
protocol: PROTOCOL,
onmessage: (data) => this._onGossip(data)
})
return this return this
} }
async close () { async close () {
this._log = []
if (this.swarm) await this.swarm.destroy().catch(() => {}) if (this.swarm) await this.swarm.destroy().catch(() => {})
this.swarm = null this.swarm = null
} }
@@ -4,23 +4,36 @@ const { HyperP2PWhiteboardOp, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => {
t.ok(HyperP2PWhiteboardOp) t.ok(HyperP2PWhiteboardOp)
t.ok(PROTOCOL) t.is(PROTOCOL, 'whiteboard-op/v1')
}) })
test('basic operation', async (t) => { test('apply op', async (t) => {
const m = new HyperP2PWhiteboardOp() const wb = new HyperP2PWhiteboardOp()
m.put('k', 1); t.is(m.get('k'), 1) wb.apply('r1', { stroke: 1 })
await m.close() t.is(wb.history('r1').length, 1)
await wb.close()
})
test('merge remote', async (t) => {
const wb = new HyperP2PWhiteboardOp()
wb.mergeRemote({ roomId: 'r', op: { x: 1 }, peer: 'p2', seq: 0, at: Date.now() })
t.is(wb.history('r').length, 1)
await wb.close()
}) })
test('validation', async (t) => { test('validation', async (t) => {
const m = new HyperP2PWhiteboardOp() const wb = new HyperP2PWhiteboardOp()
try { m.put(null, 1) } catch (e) { t.ok(e) } try {
await m.close() wb.apply(null, {})
t.fail('expected throw')
} catch (e) {
t.ok(e instanceof Error)
}
await wb.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PWhiteboardOp() const wb = new HyperP2PWhiteboardOp()
t.ok(m.getStats().protocol) t.ok(wb.getStats().protocol)
await m.close() await wb.close()
}) })
@@ -1,47 +1,45 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const c = require('compact-encoding')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js') const { HyperP2PWireRegistry } = require('../hyper-p2p-wire-registry/index.js')
const PROTOCOL = 'compact-codec-bridge/v1' const PROTOCOL = 'compact-codec-bridge/v1'
const c = require('compact-encoding')
class HyperP2PCompactCodecBridge extends EventEmitter { class HyperP2PCompactCodecBridge extends EventEmitter {
constructor (opts = {}) { constructor (opts = {}) {
super() super()
this._registry = new Map(opts.types || []) this.registry = opts.registry || new HyperP2PWireRegistry()
this._stats = { ops: 0 } this._stats = { encode: 0, decode: 0 }
if (!opts.registry) {
this.registry.registerCodec('json', c.json)
this.registry.registerCodec('string', c.string)
}
} }
register (id, codec) { encode (codecId, value) {
assertNonEmpty(id, 'id') const buf = this.registry.encode(codecId, value)
if (!codec) throw new Error('codec required') this._stats.encode++
this._registry.set(id, codec) return buf
this._stats.ops++
return true
} }
encode (id, value) { decode (codecId, buf) {
const codec = this._registry.get(id) const value = this.registry.decode(codecId, buf)
if (!codec) throw new Error('unknown codec id') this._stats.decode++
return c.encode(codec, value) return value
} }
decode (id, buf) { getStats () {
const codec = this._registry.get(id) return { ...this._stats, protocol: PROTOCOL }
if (!codec) throw new Error('unknown codec id')
return c.decode(codec, buf)
} }
wrap (payload, version = 1) { async ready () {
return { v: version, payload, at: Date.now() } await this.registry.ready()
return this
} }
getStats () { return { ...this._stats, types: this._registry.size, protocol: PROTOCOL } } async close () {
await this.registry.close()
async ready () { return this } }
async close () { this._registry.clear() }
} }
module.exports = { HyperP2PCompactCodecBridge, PROTOCOL } module.exports = { HyperP2PCompactCodecBridge, PROTOCOL }
@@ -4,23 +4,31 @@ const { HyperP2PCompactCodecBridge, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => {
t.ok(HyperP2PCompactCodecBridge) t.ok(HyperP2PCompactCodecBridge)
t.ok(PROTOCOL) t.is(PROTOCOL, 'compact-codec-bridge/v1')
}) })
test('basic operation', async (t) => { test('encode decode json', async (t) => {
const m = new HyperP2PCompactCodecBridge() const m = new HyperP2PCompactCodecBridge()
const c = require('compact-encoding'); m.register('json', c.json); t.ok(m.encode('json', {a:1})) const buf = m.encode('json', { a: 1 })
t.is(m.decode('json', buf).a, 1)
await m.close() await m.close()
}) })
test('validation', async (t) => { test('unknown codec', async (t) => {
const m = new HyperP2PCompactCodecBridge() const m = new HyperP2PCompactCodecBridge()
try { m.encode('missing', {}) } catch (e) { t.ok(e) } try { m.encode('nope', {}) } catch (e) { t.ok(e) }
await m.close() await m.close()
}) })
test('getStats', async (t) => { test('stats increment', async (t) => {
const m = new HyperP2PCompactCodecBridge() const m = new HyperP2PCompactCodecBridge()
t.ok(m.getStats().protocol) m.encode('string', 'hi')
t.is(m.getStats().encode, 1)
await m.close()
})
test('getStats protocol', async (t) => {
const m = new HyperP2PCompactCodecBridge()
t.is(m.getStats().protocol, 'compact-codec-bridge/v1')
await m.close() await m.close()
}) })
@@ -3,3 +3,7 @@
## [0.0.0-scaffold] — Wave 8 ## [0.0.0-scaffold] — Wave 8
- Registry scaffold: file tree, load smoke tests, docs stubs - Registry scaffold: file tree, load smoke tests, docs stubs
## [0.3.2] - 2026-05-21
### Changed
- Exhaustive documentation pass (api, architecture, README) per DOC_STANDARDS.md.
@@ -1,28 +1,41 @@
# hyper-p2p-message-envelope # hyper-p2p-message-envelope
**Status:** scaffold (`0.0.0-scaffold`) · **Protocol:** `message-envelope/v1` · **Wave:** 8 Production p2p module: Hyperswarm discovery + Protomux when `topic` is set.
Versioned message envelope. **Category:** General
## Holepunch references (inspiration only) **Composes with:**
- `compact-encoding` **Protocol:** `message-envelope/v1`
> This module composes on Hyperswarm/Hypercore — it does **not** re-implement upstream packages. ## When to use
## Composes with Multi-peer apps that need general over a shared Hyperswarm topic.
- `hyper-p2p-gossip-mesh` ## When not to use
## Planned API Single-process tools with no P2P topic (use local APIs only or skip `ready()`).
- `constructor(opts)` — topic, optional keyPair ## Quick start
- `getStats()` — scaffold counters
- `ready()` — no-op until implemented
- Domain methods — throw `not implemented: scaffold` until Wave 8+ pass
## Layout ```js
const { HyperP2PMessageEnvelope } = require('hyper-p2p-message-envelope')
const topic = process.argv[2] // 64-char hex or string
const mod = new HyperP2PMessageEnvelope({ topic, enableBackgroundTimers: false })
await mod.ready() // joins swarm when topic set
// ... application logic ...
await mod.close()
```
`modules/encoding-wire/hyper-p2p-message-envelope/` ## Docs
See [`modules/_shared/MODULE_SYSTEM.md`](../../_shared/MODULE_SYSTEM.md). - [docs/api.md](docs/api.md) — constructor, methods, events, errors
- [docs/architecture.md](docs/architecture.md) — wire types, state, composition
- [../../_shared/PRODUCTION.md](../../_shared/PRODUCTION.md) — production checklist
- [../../_shared/DOC_STANDARDS.md](../../_shared/DOC_STANDARDS.md) — documentation standards
## Test
```bash
npm install && npm test
```
@@ -1,23 +1,94 @@
# hyper-p2p-message-envelope API # API: hyper-p2p-message-envelope
**Status:** scaffold · **Protocol:** `message-envelope/v1` **Protocol:** `message-envelope/v1`
## Class `HyperP2PMessageEnvelope` **Export:** `HyperP2PMessageEnvelope`
Scaffold stub — methods throw `not implemented: scaffold` until promoted to production tier. ## Overview
### `constructor(opts?)` Production p2p module: Hyperswarm discovery + Protomux when `topic` is set.
### `getStats()` ## Constructor
Returns `{ created, errors, protocol, tier: 'scaffold' }`. ```js
const mod = new HyperP2PMessageEnvelope(opts)
```
### `ready()` | Option | Type | Default | Description |
|--------|------|---------|-------------|
| `topic` | `string` | `Buffer` | `null` | Hyperswarm topic; required for P2P `ready()` |
| `keyPair` | KeyPair | random | Ed25519 key pair |
| `version` | number | 1 | defaultVersion |
Resolves immediately (no-op). ## Methods
## Wire (planned) ### `wrap(payload, opts = {})`
| Message | Direction | Notes | - **Returns:** `value`
|---------|-----------|-------| - **Throws:** — (none documented in method body)
| TBD | gossip | Defined in implementation pass |
### `encode(envelope)`
- **Returns:** `value`
- **Throws:** — (none documented in method body)
### `decode(buf)`
- **Returns:** `value`
- **Throws:** — (none documented in method body)
### `unwrap(envelope)`
- **Returns:** `value`
- **Throws:**
- `Error: checksum mismatch`
- `Error: invalid envelope`
### `wrapAndEncode(payload, opts = {})`
- **Returns:** `value`
- **Throws:** — (none documented in method body)
### `decodeAndUnwrap(buf)`
- **Returns:** `value`
- **Throws:** — (none documented in method body)
### `getStats(—)`
- **Returns:** `object`
- **Throws:** — (none documented in method body)
### `ready(—)`
- **Returns:** `Promise`
- **Throws:** — (none documented in method body)
### `close(—)`
- **Returns:** `Promise<void>`
- **Throws:** — (none documented in method body)
## Events
| Event | Payload |
|-------|---------|
## getStats()
Returns `{ ...this._stats }` — typically `ops`, `errors`, and module-specific counters (`created`, `relays`, `open`, `peers`, etc.).
Library-only modules may include `mode: 'local'`.
## Errors
Stable message substrings: see [`../../_shared/ERROR_CODES.md`](../../_shared/ERROR_CODES.md).
## P2P
When `topic` is set, `ready()` joins Hyperswarm and opens Protomux `message-envelope/v1`.
## Testing
```bash
npm install && npm test
```
@@ -1,15 +1,27 @@
# hyper-p2p-message-envelope architecture # Architecture: hyper-p2p-message-envelope
**Tier:** scaffold · **Category:** `encoding-wire` **Category:** General
## Role ```mermaid
flowchart LR
App[Application] --> Mod[HyperP2PMessageEnvelope]
Mod --> Mux[Protomux message-envelope/v1]
Mux --> Swarm[Hyperswarm]
```
Versioned message envelope. ## Wire messages
| type | fields | direction | behavior |
|------|--------|-----------|----------|
| *(local only)* | — | — | No gossip wire types |
## State model
- In-memory `Map` / `Set` structures for hot path
- Optional Hyperbee/Hypercore persistence when `storageDir` or `memoryOnly` is configured
- `close()` tears down swarm, timers, and clears ephemeral state
## Composition ## Composition
Uses `../../_shared/p2p-bare.js` for Hyperswarm + Protomux when implemented. Does **not** duplicate Holepunch core storage/transport. Composes with: see MODULE_CATEGORIES.md.
## Holepunch boundary
Inspiration: n/a
@@ -1,47 +1,77 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const b4a = require('b4a')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js') const hypercoreCrypto = require('hypercore-crypto')
const PROTOCOL = 'message-envelope/v1' const PROTOCOL = 'message-envelope/v1'
const c = require('compact-encoding')
class HyperP2PMessageEnvelope extends EventEmitter { class HyperP2PMessageEnvelope extends EventEmitter {
constructor (opts = {}) { constructor (opts = {}) {
super() super()
this._registry = new Map(opts.types || []) this.defaultVersion = opts.version || 1
this._stats = { ops: 0 } this._stats = { wrapped: 0, unwrapped: 0, failed: 0 }
} }
register (id, codec) { wrap (payload, opts = {}) {
assertNonEmpty(id, 'id') const type = opts.type || 'message'
if (!codec) throw new Error('codec required') const version = opts.version || this.defaultVersion
this._registry.set(id, codec) const buf = b4a.from(JSON.stringify(payload))
this._stats.ops++ const checksum = b4a.toString(hypercoreCrypto.hash(buf), 'hex')
return true const envelope = { version, type, payload: buf, checksum, at: Date.now() }
this._stats.wrapped++
return envelope
} }
encode (id, value) { encode (envelope) {
const codec = this._registry.get(id) const serial = {
if (!codec) throw new Error('unknown codec id') version: envelope.version,
return c.encode(codec, value) type: envelope.type,
payload: b4a.toString(envelope.payload, 'base64'),
checksum: envelope.checksum,
at: envelope.at
}
return b4a.from(JSON.stringify(serial))
} }
decode (id, buf) { decode (buf) {
const codec = this._registry.get(id) const o = JSON.parse(b4a.toString(buf))
if (!codec) throw new Error('unknown codec id') return {
return c.decode(codec, buf) version: o.version,
type: o.type,
payload: b4a.from(o.payload, 'base64'),
checksum: o.checksum,
at: o.at
}
} }
wrap (payload, version = 1) { unwrap (envelope) {
return { v: version, payload, at: Date.now() } if (!envelope || !envelope.payload) throw new Error('invalid envelope')
const buf = b4a.isBuffer(envelope.payload)
? envelope.payload
: b4a.from(envelope.payload)
const checksum = b4a.toString(hypercoreCrypto.hash(buf), 'hex')
if (envelope.checksum && checksum !== envelope.checksum) {
this._stats.failed++
throw new Error('checksum mismatch')
}
this._stats.unwrapped++
return JSON.parse(b4a.toString(buf))
} }
getStats () { return { ...this._stats, types: this._registry.size, protocol: PROTOCOL } } wrapAndEncode (payload, opts = {}) {
return this.encode(this.wrap(payload, opts))
}
decodeAndUnwrap (buf) {
return this.unwrap(this.decode(buf))
}
getStats () {
return { ...this._stats, protocol: PROTOCOL }
}
async ready () { return this } async ready () { return this }
async close () { this._registry.clear() } async close () {}
} }
module.exports = { HyperP2PMessageEnvelope, PROTOCOL } module.exports = { HyperP2PMessageEnvelope, PROTOCOL }
@@ -4,23 +4,34 @@ const { HyperP2PMessageEnvelope, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => {
t.ok(HyperP2PMessageEnvelope) t.ok(HyperP2PMessageEnvelope)
t.ok(PROTOCOL) t.is(PROTOCOL, 'message-envelope/v1')
}) })
test('basic operation', async (t) => { test('wrap unwrap', async (t) => {
const m = new HyperP2PMessageEnvelope() const m = new HyperP2PMessageEnvelope()
const c = require('compact-encoding'); m.register('json', c.json); t.ok(m.encode('json', {a:1})) const env = m.wrap({ hello: 1 }, { type: 'evt' })
t.is(m.unwrap(env).hello, 1)
await m.close() await m.close()
}) })
test('validation', async (t) => { test('encode decode roundtrip', async (t) => {
const m = new HyperP2PMessageEnvelope() const m = new HyperP2PMessageEnvelope()
try { m.encode('missing', {}) } catch (e) { t.ok(e) } const buf = m.wrapAndEncode({ n: 2 })
t.is(m.decodeAndUnwrap(buf).n, 2)
await m.close()
})
test('checksum mismatch', async (t) => {
const m = new HyperP2PMessageEnvelope()
const env = m.wrap({ a: 1 })
env.checksum = 'bad'
try { m.unwrap(env); t.fail('expected throw') } catch (e) { t.ok(e) }
await m.close() await m.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PMessageEnvelope() const m = new HyperP2PMessageEnvelope()
t.ok(m.getStats().protocol) m.wrap({ x: 1 })
t.is(m.getStats().wrapped, 1)
await m.close() await m.close()
}) })
@@ -1,47 +1,53 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const { assertNonEmpty } = require('../../_shared/lib/errors.js')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js')
const PROTOCOL = 'schema-validator/v1' const PROTOCOL = 'schema-validator/v1'
const c = require('compact-encoding')
class HyperP2PSchemaValidator extends EventEmitter { class HyperP2PSchemaValidator extends EventEmitter {
constructor (opts = {}) { constructor (opts = {}) {
super() super()
this._registry = new Map(opts.types || []) this._schemas = new Map()
this._stats = { ops: 0 } this._stats = { validated: 0, failed: 0 }
} }
register (id, codec) { register (name, schema) {
assertNonEmpty(id, 'id') assertNonEmpty(name, 'name')
if (!codec) throw new Error('codec required') if (!schema || typeof schema !== 'object') throw new Error('schema object required')
this._registry.set(id, codec) this._schemas.set(name, schema)
this._stats.ops++
return true return true
} }
encode (id, value) { validate (name, value) {
const codec = this._registry.get(id) const schema = this._schemas.get(name)
if (!codec) throw new Error('unknown codec id') if (!schema) throw new Error('unknown schema')
return c.encode(codec, value) const errors = []
if (schema.required) {
for (const key of schema.required) {
if (value == null || value[key] === undefined) errors.push(`missing ${key}`)
}
}
if (schema.types) {
for (const [key, type] of Object.entries(schema.types)) {
if (value && value[key] !== undefined && typeof value[key] !== type) {
errors.push(`${key} must be ${type}`)
}
}
}
if (errors.length) {
this._stats.failed++
return { ok: false, errors }
}
this._stats.validated++
return { ok: true }
} }
decode (id, buf) { getStats () {
const codec = this._registry.get(id) return { ...this._stats, schemas: this._schemas.size, protocol: PROTOCOL }
if (!codec) throw new Error('unknown codec id')
return c.decode(codec, buf)
} }
wrap (payload, version = 1) {
return { v: version, payload, at: Date.now() }
}
getStats () { return { ...this._stats, types: this._registry.size, protocol: PROTOCOL } }
async ready () { return this } async ready () { return this }
async close () { this._registry.clear() } async close () { this._schemas.clear() }
} }
module.exports = { HyperP2PSchemaValidator, PROTOCOL } module.exports = { HyperP2PSchemaValidator, PROTOCOL }
@@ -4,23 +4,32 @@ const { HyperP2PSchemaValidator, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => {
t.ok(HyperP2PSchemaValidator) t.ok(HyperP2PSchemaValidator)
t.ok(PROTOCOL) t.is(PROTOCOL, 'schema-validator/v1')
}) })
test('basic operation', async (t) => { test('validate ok', async (t) => {
const m = new HyperP2PSchemaValidator() const m = new HyperP2PSchemaValidator()
const c = require('compact-encoding'); m.register('json', c.json); t.ok(m.encode('json', {a:1})) m.register('evt', { required: ['id'], types: { id: 'number' } })
t.ok(m.validate('evt', { id: 1 }).ok)
await m.close() await m.close()
}) })
test('validation', async (t) => { test('validate fail', async (t) => {
const m = new HyperP2PSchemaValidator() const m = new HyperP2PSchemaValidator()
try { m.encode('missing', {}) } catch (e) { t.ok(e) } m.register('evt', { required: ['id'] })
const r = m.validate('evt', {})
t.not(r.ok, true)
await m.close()
})
test('unknown schema', async (t) => {
const m = new HyperP2PSchemaValidator()
try { m.validate('nope', {}) } catch (e) { t.ok(e) }
await m.close() await m.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PSchemaValidator() const m = new HyperP2PSchemaValidator()
t.ok(m.getStats().protocol) t.is(m.getStats().protocol, 'schema-validator/v1')
await m.close() await m.close()
}) })
+42 -12
View File
@@ -1,7 +1,6 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const { assertNonEmpty } = require('../../_shared/lib/errors.js')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js')
const PROTOCOL = 'wire-registry/v1' const PROTOCOL = 'wire-registry/v1'
@@ -10,38 +9,69 @@ const c = require('compact-encoding')
class HyperP2PWireRegistry extends EventEmitter { class HyperP2PWireRegistry extends EventEmitter {
constructor (opts = {}) { constructor (opts = {}) {
super() super()
this._registry = new Map(opts.types || []) this._codecs = new Map()
this._stats = { ops: 0 } this._protocols = new Map()
this._stats = { codecs: 0, protocols: 0 }
if (opts.types) {
for (const [id, codec] of opts.types) this.registerCodec(id, codec)
}
} }
register (id, codec) { registerCodec (id, codec) {
assertNonEmpty(id, 'id') assertNonEmpty(id, 'id')
if (!codec) throw new Error('codec required') if (!codec) throw new Error('codec required')
this._registry.set(id, codec) this._codecs.set(id, codec)
this._stats.ops++ this._stats.codecs++
return true return true
} }
registerProtocol (protocolId, meta = {}) {
assertNonEmpty(protocolId, 'protocolId')
this._protocols.set(protocolId, { ...meta, registeredAt: Date.now() })
this._stats.protocols++
this.emit('protocol', { protocolId, meta })
return true
}
hasProtocol (protocolId) {
return this._protocols.has(protocolId)
}
encode (id, value) { encode (id, value) {
const codec = this._registry.get(id) const codec = this._codecs.get(id)
if (!codec) throw new Error('unknown codec id') if (!codec) throw new Error('unknown codec id')
return c.encode(codec, value) return c.encode(codec, value)
} }
decode (id, buf) { decode (id, buf) {
const codec = this._registry.get(id) const codec = this._codecs.get(id)
if (!codec) throw new Error('unknown codec id') if (!codec) throw new Error('unknown codec id')
return c.decode(codec, buf) return c.decode(codec, buf)
} }
wrap (payload, version = 1) { listProtocols () {
return { v: version, payload, at: Date.now() } return [...this._protocols.keys()]
} }
getStats () { return { ...this._stats, types: this._registry.size, protocol: PROTOCOL } } negotiate (offered = []) {
const supported = this.listProtocols()
return offered.filter((p) => supported.includes(p))
}
getStats () {
return {
...this._stats,
codecCount: this._codecs.size,
protocolCount: this._protocols.size,
protocol: PROTOCOL
}
}
async ready () { return this } async ready () { return this }
async close () { this._registry.clear() } async close () {
this._codecs.clear()
this._protocols.clear()
}
} }
module.exports = { HyperP2PWireRegistry, PROTOCOL } module.exports = { HyperP2PWireRegistry, PROTOCOL }
@@ -1,15 +1,26 @@
require('bare-process/global') require('bare-process/global')
const test = require('brittle') const test = require('brittle')
const c = require('compact-encoding')
const { HyperP2PWireRegistry, PROTOCOL } = require('../index.js') const { HyperP2PWireRegistry, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => {
t.ok(HyperP2PWireRegistry) t.ok(HyperP2PWireRegistry)
t.ok(PROTOCOL) t.is(PROTOCOL, 'wire-registry/v1')
}) })
test('basic operation', async (t) => { test('encode decode', async (t) => {
const m = new HyperP2PWireRegistry() const m = new HyperP2PWireRegistry()
const c = require('compact-encoding'); m.register('json', c.json); t.ok(m.encode('json', {a:1})) m.registerCodec('json', c.json)
const buf = m.encode('json', { a: 1 })
t.is(m.decode('json', buf).a, 1)
await m.close()
})
test('negotiate', async (t) => {
const m = new HyperP2PWireRegistry()
m.registerProtocol('foo/v1')
m.registerProtocol('bar/v1')
t.is(m.negotiate(['bar/v1', 'missing']).length, 1)
await m.close() await m.close()
}) })
@@ -21,6 +32,6 @@ test('validation', async (t) => {
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PWireRegistry() const m = new HyperP2PWireRegistry()
t.ok(m.getStats().protocol) t.is(m.getStats().protocol, 'wire-registry/v1')
await m.close() await m.close()
}) })
+74 -26
View File
@@ -1,59 +1,107 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const b4a = require('b4a')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const { assertNonEmpty } = require('../../_shared/lib/errors.js')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js') const { initModuleSwarm, gossipSend } = require('../../_shared/p2p-bare.js')
const PROTOCOL = 'qos-topic/v1' const PROTOCOL = 'qos-topic/v1'
const MAX_QOS = 2
class HyperP2PQosTopic extends EventEmitter { class HyperP2PQosTopic extends EventEmitter {
constructor (opts = {}) { constructor (opts = {}) {
super() super()
this.topic = opts.topic || null this.topic = opts.topic || null
this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair() this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair()
this._store = new Map() this.peerHex = b4a.toString(this.keyPair.publicKey, 'hex')
this._stats = { ops: 0, gossipIn: 0, gossipOut: 0 } this._queues = [ [], [], [] ]
this._handlers = new Map()
this._stats = { published: 0, delivered: 0, gossipIn: 0, gossipOut: 0 }
this.swarm = null this.swarm = null
this._peerMsgs = null
} }
put (key, value) { subscribe (channel, handler, qos = 0) {
assertNonEmpty(key, 'key') assertNonEmpty(channel, 'channel')
this._store.set(key, value) if (typeof handler !== 'function') throw new Error('handler must be a function')
this._stats.ops++ this._handlers.set(channel, { handler, qos: Math.min(MAX_QOS, Math.max(0, qos | 0)) })
sendGossip(this, { type: 'qos-topic-sync', key, value }) return () => this._handlers.delete(channel)
this.emit('update', { key, value })
return true
} }
get (key) { return this._store.get(key) } publish (channel, payload, opts = {}) {
assertNonEmpty(channel, 'channel')
delete (key) { const qos = Math.min(MAX_QOS, Math.max(0, opts.qos | 0))
const ok = this._store.delete(key) const msg = {
if (ok) sendGossip(this, { type: 'qos-topic-sync', key, value: null }) type: 'qos-publish',
return ok channel,
payload,
qos,
from: this.peerHex,
at: Date.now()
}
this._queues[qos].push(msg)
this._stats.published++
this._drain()
if (this._peerMsgs) {
gossipSend(this, msg)
this._stats.gossipOut++
}
return msg
} }
entries () { return [...this._store.entries()] } _drain () {
for (let q = MAX_QOS; q >= 0; q--) {
_onGossip (d) { while (this._queues[q].length) {
if (!d || d.type !== 'qos-topic-sync') return const msg = this._queues[q].shift()
this._stats.gossipIn++ this._deliver(msg)
if (d.key !== undefined) { }
if (d.value === null) this._store.delete(d.key)
else this._store.set(d.key, d.value)
} }
} }
getStats () { return { ...this._stats, size: this._store.size, protocol: PROTOCOL } } _deliver (msg) {
const sub = this._handlers.get(msg.channel)
if (sub && msg.qos >= sub.qos) {
this._stats.delivered++
sub.handler({ channel: msg.channel, payload: msg.payload, qos: msg.qos, from: msg.from })
}
this.emit('message', msg)
}
pending (qos) {
if (qos == null) return this._queues.reduce((n, q) => n + q.length, 0)
return this._queues[qos]?.length || 0
}
_onGossip (data) {
if (!data || data.type !== 'qos-publish') return
this._stats.gossipIn++
this._queues[data.qos].push(data)
this._drain()
}
getStats () {
return {
...this._stats,
pending: this.pending(),
protocol: PROTOCOL
}
}
async ready () { async ready () {
if (this.swarm || !this.topic) return this if (this.swarm || !this.topic) return this
await attachGossip(this, { keyPair: this.keyPair, topic: this.topic, protocol: PROTOCOL, onmessage: (d) => this._onGossip(d) }) await initModuleSwarm(this, {
keyPair: this.keyPair,
topic: this.topic,
protocol: PROTOCOL,
onmessage: (d) => this._onGossip(d)
})
return this return this
} }
async close () { async close () {
if (this.swarm) await this.swarm.destroy().catch(() => {}) if (this.swarm) await this.swarm.destroy().catch(() => {})
this.swarm = null this.swarm = null
this._handlers.clear()
for (const q of this._queues) q.length = 0
} }
} }
@@ -4,23 +4,38 @@ const { HyperP2PQosTopic, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => {
t.ok(HyperP2PQosTopic) t.ok(HyperP2PQosTopic)
t.ok(PROTOCOL) t.is(PROTOCOL, 'qos-topic/v1')
}) })
test('basic operation', async (t) => { test('higher qos delivered first on drain', async (t) => {
const m = new HyperP2PQosTopic() const m = new HyperP2PQosTopic()
m.put('k', 1); t.is(m.get('k'), 1) const order = []
m.subscribe('c', (msg) => order.push(msg.payload), 0)
m._queues[0].push({ type: 'qos-publish', channel: 'c', payload: 'low', qos: 0, from: 'p' })
m._queues[2].push({ type: 'qos-publish', channel: 'c', payload: 'high', qos: 2, from: 'p' })
m._drain()
t.is(order[0], 'high')
await m.close() await m.close()
}) })
test('validation', async (t) => { test('validation', async (t) => {
const m = new HyperP2PQosTopic() const m = new HyperP2PQosTopic()
try { m.put(null, 1) } catch (e) { t.ok(e) } try { m.publish('', 1) } catch (e) { t.ok(e) }
await m.close()
})
test('gossip delivery', async (t) => {
const m = new HyperP2PQosTopic()
let got = false
m.subscribe('x', () => { got = true })
m._onGossip({ type: 'qos-publish', channel: 'x', payload: 1, qos: 1, from: 'p' })
t.ok(got)
await m.close() await m.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PQosTopic() const m = new HyperP2PQosTopic()
t.ok(m.getStats().protocol) m.publish('a', 1)
t.is(m.getStats().published, 1)
await m.close() await m.close()
}) })
@@ -1,60 +1,62 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const { assertNonEmpty } = require('../../_shared/lib/errors.js')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js')
const PROTOCOL = 'retained-messages/v1' const PROTOCOL = 'retained-messages/v1'
class HyperP2PRetainedMessages extends EventEmitter { class HyperP2PRetainedMessages extends EventEmitter {
constructor (opts = {}) { constructor (opts = {}) {
super() super()
this.topic = opts.topic || null this.maxPerChannel = opts.maxPerChannel || 32
this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair()
this._store = new Map() this._store = new Map()
this._stats = { ops: 0, gossipIn: 0, gossipOut: 0 } this._stats = { set: 0, get: 0, cleared: 0 }
this.swarm = null
} }
put (key, value) { retain (channel, payload, meta = {}) {
assertNonEmpty(key, 'key') assertNonEmpty(channel, 'channel')
this._store.set(key, value) const list = this._store.get(channel) || []
this._stats.ops++ const entry = { payload, meta, at: Date.now() }
sendGossip(this, { type: 'retained-messages-sync', key, value }) list.push(entry)
this.emit('update', { key, value }) while (list.length > this.maxPerChannel) list.shift()
return true this._store.set(channel, list)
this._stats.set++
this.emit('retain', { channel, entry })
return entry
} }
get (key) { return this._store.get(key) } latest (channel) {
const list = this._store.get(channel)
if (!list || !list.length) return null
this._stats.get++
return list[list.length - 1]
}
delete (key) { list (channel, limit = 10) {
const ok = this._store.delete(key) const list = this._store.get(channel) || []
if (ok) sendGossip(this, { type: 'retained-messages-sync', key, value: null }) return list.slice(-limit)
}
clear (channel) {
if (channel == null) {
this._store.clear()
this._stats.cleared++
return true
}
const ok = this._store.delete(channel)
if (ok) this._stats.cleared++
return ok return ok
} }
entries () { return [...this._store.entries()] } getStats () {
return {
_onGossip (d) { ...this._stats,
if (!d || d.type !== 'retained-messages-sync') return channels: this._store.size,
this._stats.gossipIn++ protocol: PROTOCOL
if (d.key !== undefined) {
if (d.value === null) this._store.delete(d.key)
else this._store.set(d.key, d.value)
} }
} }
getStats () { return { ...this._stats, size: this._store.size, protocol: PROTOCOL } } async ready () { return this }
async close () { this._store.clear() }
async ready () {
if (this.swarm || !this.topic) return this
await attachGossip(this, { keyPair: this.keyPair, topic: this.topic, protocol: PROTOCOL, onmessage: (d) => this._onGossip(d) })
return this
}
async close () {
if (this.swarm) await this.swarm.destroy().catch(() => {})
this.swarm = null
}
} }
module.exports = { HyperP2PRetainedMessages, PROTOCOL } module.exports = { HyperP2PRetainedMessages, PROTOCOL }
@@ -4,23 +4,41 @@ const { HyperP2PRetainedMessages, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => {
t.ok(HyperP2PRetainedMessages) t.ok(HyperP2PRetainedMessages)
t.ok(PROTOCOL) t.is(PROTOCOL, 'retained-messages/v1')
}) })
test('basic operation', async (t) => { test('retain and latest', async (t) => {
const m = new HyperP2PRetainedMessages() const r = new HyperP2PRetainedMessages()
m.put('k', 1); t.is(m.get('k'), 1) r.retain('t', { n: 1 })
await m.close() r.retain('t', { n: 2 })
t.is(r.latest('t').payload.n, 2)
await r.close()
})
test('maxPerChannel', async (t) => {
const r = new HyperP2PRetainedMessages({ maxPerChannel: 2 })
r.retain('c', 1)
r.retain('c', 2)
r.retain('c', 3)
t.is(r.list('c').length, 2)
t.is(r.latest('c').payload, 3)
await r.close()
}) })
test('validation', async (t) => { test('validation', async (t) => {
const m = new HyperP2PRetainedMessages() const r = new HyperP2PRetainedMessages()
try { m.put(null, 1) } catch (e) { t.ok(e) } try {
await m.close() r.retain(null, 1)
t.fail('expected throw')
} catch (e) {
t.ok(e instanceof Error)
}
await r.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PRetainedMessages() const r = new HyperP2PRetainedMessages()
t.ok(m.getStats().protocol) r.retain('a', 1)
await m.close() t.ok(r.getStats().protocol)
await r.close()
}) })
@@ -1,59 +1,123 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const { setInterval, clearInterval } = require('bare-timers')
const b4a = require('b4a')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const { assertNonEmpty } = require('../../_shared/lib/errors.js')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js') const { initModuleSwarm, gossipSend } = require('../../_shared/p2p-bare.js')
const PROTOCOL = 'subscription-lease/v1' const PROTOCOL = 'subscription-lease/v1'
const DEFAULT_LEASE_MS = 60000
class HyperP2PSubscriptionLease extends EventEmitter { class HyperP2PSubscriptionLease extends EventEmitter {
constructor (opts = {}) { constructor (opts = {}) {
super() super()
this.topic = opts.topic || null this.topic = opts.topic || null
this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair() this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair()
this._store = new Map() this.ownerHex = b4a.toString(this.keyPair.publicKey, 'hex')
this._stats = { ops: 0, gossipIn: 0, gossipOut: 0 } this.leaseMs = opts.leaseMs ?? DEFAULT_LEASE_MS
this.enableBackgroundTimers = opts.enableBackgroundTimers === true
this._leases = new Map()
this._timer = null
this._stats = { acquired: 0, released: 0, gossipIn: 0, gossipOut: 0 }
this.swarm = null this.swarm = null
this._peerMsgs = null
} }
put (key, value) { acquire (channel) {
assertNonEmpty(key, 'key') assertNonEmpty(channel, 'channel')
this._store.set(key, value) const now = Date.now()
this._stats.ops++ const current = this._leases.get(channel)
sendGossip(this, { type: 'subscription-lease-sync', key, value }) if (current && current.expiresAt > now && current.holder !== this.ownerHex) {
this.emit('update', { key, value }) return { ok: false, holder: current.holder }
}
const lease = { channel, holder: this.ownerHex, acquiredAt: now, expiresAt: now + this.leaseMs }
this._leases.set(channel, lease)
this._stats.acquired++
if (this._peerMsgs) {
gossipSend(this, { type: 'sub-lease', lease })
this._stats.gossipOut++
}
this.emit('acquire', lease)
return { ok: true, ...lease }
}
renew (channel) {
const lease = this._leases.get(channel)
if (!lease || lease.holder !== this.ownerHex) return false
lease.expiresAt = Date.now() + this.leaseMs
if (this._peerMsgs) {
gossipSend(this, { type: 'sub-lease', lease })
this._stats.gossipOut++
}
return true return true
} }
get (key) { return this._store.get(key) } release (channel) {
const lease = this._leases.get(channel)
delete (key) { if (!lease || lease.holder !== this.ownerHex) return false
const ok = this._store.delete(key) this._leases.delete(channel)
if (ok) sendGossip(this, { type: 'subscription-lease-sync', key, value: null }) this._stats.released++
return ok if (this._peerMsgs) {
gossipSend(this, { type: 'sub-release', channel, holder: this.ownerHex })
this._stats.gossipOut++
}
return true
} }
entries () { return [...this._store.entries()] } holder (channel) {
const lease = this._leases.get(channel)
if (!lease || lease.expiresAt < Date.now()) return null
return lease.holder
}
_onGossip (d) { _expireSweep () {
if (!d || d.type !== 'subscription-lease-sync') return const now = Date.now()
this._stats.gossipIn++ for (const [ch, lease] of this._leases) {
if (d.key !== undefined) { if (lease.expiresAt < now) {
if (d.value === null) this._store.delete(d.key) this._leases.delete(ch)
else this._store.set(d.key, d.value) this.emit('expired', { channel: ch })
}
} }
} }
getStats () { return { ...this._stats, size: this._store.size, protocol: PROTOCOL } } getStats () {
return { ...this._stats, active: this._leases.size, protocol: PROTOCOL }
}
async ready () { async ready () {
if (this.swarm || !this.topic) return this if (this.swarm || !this.topic) return this
await attachGossip(this, { keyPair: this.keyPair, topic: this.topic, protocol: PROTOCOL, onmessage: (d) => this._onGossip(d) }) await initModuleSwarm(this, {
keyPair: this.keyPair,
topic: this.topic,
protocol: PROTOCOL,
onmessage: (data) => {
if (data && data.type === 'sub-lease' && data.lease) {
const cur = this._leases.get(data.lease.channel)
if (!cur || data.lease.expiresAt > cur.expiresAt) {
this._leases.set(data.lease.channel, data.lease)
}
this._stats.gossipIn++
} else if (data && data.type === 'sub-release') {
const cur = this._leases.get(data.channel)
if (cur && cur.holder === data.holder) this._leases.delete(data.channel)
this._stats.gossipIn++
}
}
})
if (this.enableBackgroundTimers && !this._timer) {
this._timer = setInterval(() => this._expireSweep(), 5000)
}
return this return this
} }
async close () { async close () {
if (this._timer) {
clearInterval(this._timer)
this._timer = null
}
if (this.swarm) await this.swarm.destroy().catch(() => {}) if (this.swarm) await this.swarm.destroy().catch(() => {})
this.swarm = null this.swarm = null
this.emit('closed')
} }
} }
@@ -4,23 +4,44 @@ const { HyperP2PSubscriptionLease, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => {
t.ok(HyperP2PSubscriptionLease) t.ok(HyperP2PSubscriptionLease)
t.ok(PROTOCOL) t.is(PROTOCOL, 'subscription-lease/v1')
}) })
test('basic operation', async (t) => { test('acquire and holder', async (t) => {
const m = new HyperP2PSubscriptionLease() const l = new HyperP2PSubscriptionLease({ leaseMs: 5000 })
m.put('k', 1); t.is(m.get('k'), 1) const r = l.acquire('chan-a')
await m.close() t.ok(r.ok)
t.is(l.holder('chan-a'), r.holder)
await l.release('chan-a')
await l.close()
})
test('conflict when held', async (t) => {
const l = new HyperP2PSubscriptionLease()
l._leases.set('c1', {
channel: 'c1',
holder: 'other-peer',
expiresAt: Date.now() + 60000
})
const r = l.acquire('c1')
t.not(r.ok)
await l.close()
}) })
test('validation', async (t) => { test('validation', async (t) => {
const m = new HyperP2PSubscriptionLease() const l = new HyperP2PSubscriptionLease()
try { m.put(null, 1) } catch (e) { t.ok(e) } try {
await m.close() l.acquire(null)
t.fail('expected throw')
} catch (e) {
t.ok(e instanceof Error)
}
await l.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PSubscriptionLease() const l = new HyperP2PSubscriptionLease()
t.ok(m.getStats().protocol) l.acquire('x')
await m.close() t.ok(l.getStats().protocol)
await l.close()
}) })
@@ -1,15 +1,22 @@
# hyper-p2p-topic-channel architecture # hyper-p2p-topic-channel architecture
**Tier:** scaffold · **Category:** `messaging-pubsub` **Tier:** production · **Category:** `messaging-pubsub` · **Protocol:** `topic-channel/v1`
## Role ## Role
Named topic channels. Named topic channels over Hyperswarm + Protomux: subscribe, publish, optional retained messages per channel.
## Wire messages
| type | Direction | Fields |
|------|-----------|--------|
| `subscribe` | gossip | `channel`, `peer` |
| `unsubscribe` | gossip | `channel`, `peer` |
| `publish` | gossip | `channel`, `payload`, `from`, `at`, `qos`, `retain` |
| `retained-sync` | gossip | `channel`, `payload`, `from`, `at` |
## Composition ## Composition
Uses `../../_shared/p2p-bare.js` for Hyperswarm + Protomux when implemented. Does **not** duplicate Holepunch core storage/transport. Composes with `hyper-p2p-gossip-mesh`, `hyper-p2p-topic-lease`, `hyper-p2p-subscription-lease`.
## Holepunch boundary Uses `../../_shared/p2p-bare.js` (`initModuleSwarm`, `gossipSend`).
Inspiration: n/a
@@ -2,7 +2,11 @@ require('bare-process/global')
const { HyperP2PTopicChannel } = require('../index.js') const { HyperP2PTopicChannel } = require('../index.js')
async function main () { async function main () {
const m = new HyperP2PTopicChannel() const ch = new HyperP2PTopicChannel({ retainMessages: true })
console.log('[scaffold]', m.getStats()) ch.subscribe('demo', (m) => console.log('[topic-channel]', m))
ch.publish('demo', { hello: 'wave9' }, { retain: true })
console.log(ch.getStats())
await ch.close()
} }
main().catch(console.error) main().catch(console.error)
+112 -23
View File
@@ -1,7 +1,8 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const b4a = require('b4a')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const { assertNonEmpty } = require('../../_shared/lib/errors.js')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js') const { initModuleSwarm, gossipSend } = require('../../_shared/p2p-bare.js')
const PROTOCOL = 'topic-channel/v1' const PROTOCOL = 'topic-channel/v1'
@@ -10,50 +11,138 @@ class HyperP2PTopicChannel extends EventEmitter {
super() super()
this.topic = opts.topic || null this.topic = opts.topic || null
this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair() this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair()
this._store = new Map() this.peerHex = b4a.toString(this.keyPair.publicKey, 'hex')
this._stats = { ops: 0, gossipIn: 0, gossipOut: 0 } this.retainMessages = opts.retainMessages !== false
this._subs = new Map()
this._retained = new Map()
this._stats = { published: 0, received: 0, subscriptions: 0, gossipIn: 0, gossipOut: 0 }
this.swarm = null this.swarm = null
this._peerMsgs = null
} }
put (key, value) { subscribe (channel, handler) {
assertNonEmpty(key, 'key') assertNonEmpty(channel, 'channel')
this._store.set(key, value) if (typeof handler !== 'function') throw new Error('handler must be a function')
this._stats.ops++ this._subs.set(channel, handler)
sendGossip(this, { type: 'topic-channel-sync', key, value }) this._stats.subscriptions++
this.emit('update', { key, value }) if (this._peerMsgs) {
gossipSend(this, { type: 'subscribe', channel, peer: this.peerHex })
this._stats.gossipOut++
}
if (this.retainMessages && this._retained.has(channel)) {
const r = this._retained.get(channel)
handler({ channel, payload: r.payload, retained: true, from: r.from })
}
return () => this.unsubscribe(channel)
}
unsubscribe (channel) {
if (!this._subs.has(channel)) return false
this._subs.delete(channel)
if (this._peerMsgs) {
gossipSend(this, { type: 'unsubscribe', channel, peer: this.peerHex })
this._stats.gossipOut++
}
return true return true
} }
get (key) { return this._store.get(key) } publish (channel, payload, opts = {}) {
assertNonEmpty(channel, 'channel')
delete (key) { const msg = {
const ok = this._store.delete(key) type: 'publish',
if (ok) sendGossip(this, { type: 'topic-channel-sync', key, value: null }) channel,
return ok payload,
from: this.peerHex,
at: Date.now(),
qos: opts.qos || 0,
retain: !!(opts.retain || this.retainMessages)
}
if (msg.retain) {
this._retained.set(channel, { payload, from: this.peerHex, at: msg.at })
}
this._deliverLocal(channel, payload, { from: this.peerHex, local: true })
if (this._peerMsgs) {
gossipSend(this, msg)
this._stats.gossipOut++
}
this._stats.published++
return msg
} }
entries () { return [...this._store.entries()] } getRetained (channel) {
return this._retained.get(channel) || null
}
_onGossip (d) { _deliverLocal (channel, payload, meta) {
if (!d || d.type !== 'topic-channel-sync') return const handler = this._subs.get(channel)
if (handler) {
this._stats.received++
handler({ channel, payload, ...meta })
}
this.emit('message', { channel, payload, ...meta })
}
_onGossip (data, peerInfo) {
if (!data || !data.type) return
this._stats.gossipIn++ this._stats.gossipIn++
if (d.key !== undefined) { const from = data.from || (peerInfo && peerInfo.publicKey
if (d.value === null) this._store.delete(d.key) ? b4a.toString(peerInfo.publicKey, 'hex')
else this._store.set(d.key, d.value) : null)
if (data.type === 'publish' && data.channel) {
if (data.retain) {
this._retained.set(data.channel, {
payload: data.payload,
from,
at: data.at || Date.now()
})
}
this._deliverLocal(data.channel, data.payload, { from, qos: data.qos })
}
if (data.type === 'retained-sync' && data.channel && data.payload !== undefined) {
this._retained.set(data.channel, {
payload: data.payload,
from: data.from,
at: data.at || Date.now()
})
} }
} }
getStats () { return { ...this._stats, size: this._store.size, protocol: PROTOCOL } } syncRetained (channel) {
assertNonEmpty(channel, 'channel')
const r = this._retained.get(channel)
if (!r || !this._peerMsgs) return false
gossipSend(this, { type: 'retained-sync', channel, payload: r.payload, from: r.from, at: r.at })
this._stats.gossipOut++
return true
}
getStats () {
return {
...this._stats,
channels: this._subs.size,
retained: this._retained.size,
protocol: PROTOCOL
}
}
async ready () { async ready () {
if (this.swarm || !this.topic) return this if (this.swarm || !this.topic) return this
await attachGossip(this, { keyPair: this.keyPair, topic: this.topic, protocol: PROTOCOL, onmessage: (d) => this._onGossip(d) }) await initModuleSwarm(this, {
keyPair: this.keyPair,
topic: this.topic,
protocol: PROTOCOL,
onmessage: (data, peerInfo) => this._onGossip(data, peerInfo)
})
return this return this
} }
async close () { async close () {
this._subs.clear()
if (this.swarm) await this.swarm.destroy().catch(() => {}) if (this.swarm) await this.swarm.destroy().catch(() => {})
this.swarm = null this.swarm = null
this.emit('closed')
} }
} }
@@ -4,23 +4,45 @@ const { HyperP2PTopicChannel, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => {
t.ok(HyperP2PTopicChannel) t.ok(HyperP2PTopicChannel)
t.ok(PROTOCOL) t.is(PROTOCOL, 'topic-channel/v1')
}) })
test('basic operation', async (t) => { test('subscribe and publish local', async (t) => {
const m = new HyperP2PTopicChannel() const ch = new HyperP2PTopicChannel()
m.put('k', 1); t.is(m.get('k'), 1) let got = null
await m.close() ch.subscribe('news', (m) => { got = m })
ch.publish('news', { hello: 1 })
t.is(got.payload.hello, 1)
t.ok(got.local)
await ch.close()
})
test('retained message on subscribe', async (t) => {
const ch = new HyperP2PTopicChannel({ retainMessages: true })
ch.publish('alerts', { n: 1 }, { retain: true })
let got = null
ch.subscribe('alerts', (m) => { got = m })
t.is(got.payload.n, 1)
t.ok(got.retained)
await ch.close()
}) })
test('validation', async (t) => { test('validation', async (t) => {
const m = new HyperP2PTopicChannel() const ch = new HyperP2PTopicChannel()
try { m.put(null, 1) } catch (e) { t.ok(e) } try {
await m.close() ch.subscribe(null, () => {})
t.fail('expected throw')
} catch (e) {
t.ok(e instanceof Error)
}
await ch.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PTopicChannel() const ch = new HyperP2PTopicChannel()
t.ok(m.getStats().protocol) ch.publish('x', 1)
await m.close() const s = ch.getStats()
t.is(s.protocol, 'topic-channel/v1')
t.is(s.published, 1)
await ch.close()
}) })
@@ -1,37 +1,67 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const b4a = require('b4a')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js')
const PROTOCOL = 'stream-backpressure/v1' const PROTOCOL = 'stream-backpressure/v1'
class HyperP2PStreamBackpressure extends EventEmitter { class HyperP2PStreamBackpressure extends EventEmitter {
constructor (opts = {}) { constructor (opts = {}) {
super() super()
this._chunks = []
this._stats = { chunks: 0, bytes: 0 }
this.highWaterMark = opts.highWaterMark || 65536 this.highWaterMark = opts.highWaterMark || 65536
this._buffer = []
this._bytes = 0
this._paused = false
this._stats = { written: 0, dropped: 0, paused: 0 }
} }
write (chunk) { write (chunk) {
if (chunk == null) throw new Error('chunk required') if (chunk == null) throw new Error('chunk required')
const b4a = require('b4a')
const buf = typeof chunk === 'string' ? b4a.from(chunk) : chunk const buf = typeof chunk === 'string' ? b4a.from(chunk) : chunk
this._chunks.push(buf) const size = buf.length || buf.byteLength || 0
this._stats.chunks++ if (this._paused || this._bytes + size > this.highWaterMark) {
this._stats.bytes += buf.length || buf.byteLength || 0 this._stats.dropped++
this.emit('backpressure', { bytes: this._bytes, size })
return false
}
this._buffer.push(buf)
this._bytes += size
this._stats.written++
this.emit('data', buf) this.emit('data', buf)
return this._stats.bytes <= this.highWaterMark if (this._bytes >= this.highWaterMark) {
this._paused = true
this._stats.paused++
this.emit('pause')
}
return true
} }
read () { return this._chunks.shift() || null } pause () {
this._paused = true
return true
}
pending () { return this._chunks.length } resume () {
this._paused = false
this.emit('resume')
return true
}
getStats () { return { ...this._stats, protocol: PROTOCOL } } read () {
const buf = this._buffer.shift()
if (!buf) return null
this._bytes -= buf.length || buf.byteLength || 0
if (this._bytes < this.highWaterMark) this._paused = false
return buf
}
pending () { return this._buffer.length }
getStats () {
return { ...this._stats, bytes: this._bytes, paused: this._paused, protocol: PROTOCOL }
}
async ready () { return this } async ready () { return this }
async close () { this._chunks = [] } async close () { this._buffer = []; this._bytes = 0 }
} }
module.exports = { HyperP2PStreamBackpressure, PROTOCOL } module.exports = { HyperP2PStreamBackpressure, PROTOCOL }
@@ -2,25 +2,32 @@ require('bare-process/global')
const test = require('brittle') const test = require('brittle')
const { HyperP2PStreamBackpressure, PROTOCOL } = require('../index.js') const { HyperP2PStreamBackpressure, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => { t.ok(HyperP2PStreamBackpressure); t.is(PROTOCOL, 'stream-backpressure/v1') })
t.ok(HyperP2PStreamBackpressure)
t.ok(PROTOCOL)
})
test('basic operation', async (t) => { test('pause on high water', async (t) => {
const m = new HyperP2PStreamBackpressure() const m = new HyperP2PStreamBackpressure({ highWaterMark: 4 })
m.write('hi'); t.ok(m.read()) t.ok(m.write(require('b4a').from('ab')))
t.ok(m.write(require('b4a').from('cd')))
t.not(m.write(require('b4a').from('ef')), 'backpressure')
await m.close() await m.close()
}) })
test('validation', async (t) => { test('read drains', async (t) => {
const m = new HyperP2PStreamBackpressure() const m = new HyperP2PStreamBackpressure({ highWaterMark: 100 })
try { m.write(null) } catch (e) { t.ok(e) } m.write(require('b4a').from('x'))
t.ok(m.read())
await m.close()
})
test('resume', async (t) => {
const m = new HyperP2PStreamBackpressure({ highWaterMark: 2 })
m.pause()
t.ok(m.resume())
await m.close() await m.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PStreamBackpressure() const m = new HyperP2PStreamBackpressure()
t.ok(m.getStats().protocol) t.is(m.getStats().protocol, 'stream-backpressure/v1')
await m.close() await m.close()
}) })
@@ -1,37 +1,47 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const b4a = require('b4a')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js')
const PROTOCOL = 'stream-chunker/v1' const PROTOCOL = 'stream-chunker/v1'
class HyperP2PStreamChunker extends EventEmitter { class HyperP2PStreamChunker extends EventEmitter {
constructor (opts = {}) { constructor (opts = {}) {
super() super()
this._chunks = [] this.chunkSize = opts.chunkSize || 4096
this._pending = b4a.alloc(0)
this._stats = { chunks: 0, bytes: 0 } this._stats = { chunks: 0, bytes: 0 }
this.highWaterMark = opts.highWaterMark || 65536
} }
write (chunk) { push (data) {
if (chunk == null) throw new Error('chunk required') const buf = typeof data === 'string' ? b4a.from(data) : data
const b4a = require('b4a') this._pending = b4a.concat([this._pending, buf])
const buf = typeof chunk === 'string' ? b4a.from(chunk) : chunk
this._chunks.push(buf)
this._stats.chunks++
this._stats.bytes += buf.length || buf.byteLength || 0 this._stats.bytes += buf.length || buf.byteLength || 0
this.emit('data', buf) const emitted = []
return this._stats.bytes <= this.highWaterMark while (this._pending.length >= this.chunkSize) {
const slice = this._pending.subarray(0, this.chunkSize)
this._pending = this._pending.subarray(this.chunkSize)
this._stats.chunks++
emitted.push(slice)
this.emit('chunk', slice)
}
return emitted
} }
read () { return this._chunks.shift() || null } flush () {
if (!this._pending.length) return null
const tail = this._pending
this._pending = b4a.alloc(0)
this._stats.chunks++
this.emit('chunk', tail)
return tail
}
pending () { return this._chunks.length } getStats () {
return { ...this._stats, pending: this._pending.length, protocol: PROTOCOL }
getStats () { return { ...this._stats, protocol: PROTOCOL } } }
async ready () { return this } async ready () { return this }
async close () { this._chunks = [] } async close () { this._pending = b4a.alloc(0) }
} }
module.exports = { HyperP2PStreamChunker, PROTOCOL } module.exports = { HyperP2PStreamChunker, PROTOCOL }
@@ -2,25 +2,33 @@ require('bare-process/global')
const test = require('brittle') const test = require('brittle')
const { HyperP2PStreamChunker, PROTOCOL } = require('../index.js') const { HyperP2PStreamChunker, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => { t.ok(HyperP2PStreamChunker); t.is(PROTOCOL, 'stream-chunker/v1') })
t.ok(HyperP2PStreamChunker)
t.ok(PROTOCOL)
})
test('basic operation', async (t) => { test('split chunks', async (t) => {
const m = new HyperP2PStreamChunker() const m = new HyperP2PStreamChunker({ chunkSize: 4 })
m.write('hi'); t.ok(m.read()) const parts = m.push(require('b4a').from('abcdefgh'))
t.is(parts.length, 2)
t.is(parts[0].length, 4)
await m.close() await m.close()
}) })
test('validation', async (t) => { test('flush tail', async (t) => {
const m = new HyperP2PStreamChunker() const m = new HyperP2PStreamChunker({ chunkSize: 8 })
try { m.write(null) } catch (e) { t.ok(e) } m.push(require('b4a').from('ab'))
const tail = m.flush()
t.ok(tail)
await m.close() await m.close()
}) })
test('getStats', async (t) => { test('getStats bytes', async (t) => {
const m = new HyperP2PStreamChunker() const m = new HyperP2PStreamChunker()
t.ok(m.getStats().protocol) m.push('x')
t.is(m.getStats().bytes, 1)
await m.close()
})
test('getStats protocol', async (t) => {
const m = new HyperP2PStreamChunker()
t.is(m.getStats().protocol, 'stream-chunker/v1')
await m.close() await m.close()
}) })
@@ -1,37 +1,106 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const b4a = require('b4a')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const { assertNonEmpty } = require('../../_shared/lib/errors.js')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js')
const PROTOCOL = 'stream-multiplex/v1' const PROTOCOL = 'stream-multiplex/v1'
class StreamHandle {
constructor (mux, id) {
this.mux = mux
this.id = id
this._listeners = []
this.closed = false
}
write (chunk) {
if (this.closed) throw new Error('stream closed')
return this.mux._sendFrame(this.id, chunk, false)
}
end (chunk) {
if (chunk != null) this.write(chunk)
this.mux._sendFrame(this.id, null, true)
this.closed = true
}
ondata (fn) {
this._listeners.push(fn)
return () => {
this._listeners = this._listeners.filter((f) => f !== fn)
}
}
_emitData (chunk, fin) {
for (const fn of this._listeners) fn(chunk, fin)
if (fin) this.closed = true
}
}
class HyperP2PStreamMultiplex extends EventEmitter { class HyperP2PStreamMultiplex extends EventEmitter {
constructor (opts = {}) { constructor (opts = {}) {
super() super()
this._chunks = []
this._stats = { chunks: 0, bytes: 0 }
this.highWaterMark = opts.highWaterMark || 65536 this.highWaterMark = opts.highWaterMark || 65536
this._streams = new Map()
this._nextId = 1
this._stats = { streams: 0, frames: 0, bytes: 0 }
} }
write (chunk) { openStream (id = null) {
if (chunk == null) throw new Error('chunk required') const sid = id != null ? String(id) : String(this._nextId++)
const b4a = require('b4a') if (this._streams.has(sid)) throw new Error('stream id already open')
const buf = typeof chunk === 'string' ? b4a.from(chunk) : chunk const handle = new StreamHandle(this, sid)
this._chunks.push(buf) this._streams.set(sid, handle)
this._stats.chunks++ this._stats.streams++
this._stats.bytes += buf.length || buf.byteLength || 0 this.emit('open', { streamId: sid })
this.emit('data', buf) return handle
return this._stats.bytes <= this.highWaterMark
} }
read () { return this._chunks.shift() || null } _sendFrame (streamId, chunk, fin) {
const size = chunk ? (chunk.length || chunk.byteLength || 0) : 0
if (this._stats.bytes + size > this.highWaterMark && !fin) {
return false
}
const frame = { type: 'frame', streamId, chunk, fin: !!fin }
this._stats.frames++
this._stats.bytes += size
this.emit('frame', frame)
const local = this._streams.get(streamId)
if (local) local._emitData(chunk, fin)
return true
}
pending () { return this._chunks.length } receiveFrame (frame) {
if (!frame || frame.type !== 'frame') return false
const h = this._streams.get(frame.streamId)
if (!h) return false
h._emitData(frame.chunk, frame.fin)
if (frame.fin) this._streams.delete(frame.streamId)
return true
}
getStats () { return { ...this._stats, protocol: PROTOCOL } } closeStream (streamId) {
const h = this._streams.get(streamId)
if (!h) return false
h.end()
this._streams.delete(streamId)
return true
}
getStats () {
return {
...this._stats,
open: this._streams.size,
protocol: PROTOCOL
}
}
async ready () { return this } async ready () { return this }
async close () { this._chunks = [] }
async close () {
for (const id of [...this._streams.keys()]) this.closeStream(id)
this.emit('closed')
}
} }
module.exports = { HyperP2PStreamMultiplex, PROTOCOL } module.exports = { HyperP2PStreamMultiplex, StreamHandle, PROTOCOL }
@@ -4,23 +4,50 @@ const { HyperP2PStreamMultiplex, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => {
t.ok(HyperP2PStreamMultiplex) t.ok(HyperP2PStreamMultiplex)
t.ok(PROTOCOL) t.is(PROTOCOL, 'stream-multiplex/v1')
}) })
test('basic operation', async (t) => { test('openStream write read via ondata', async (t) => {
const m = new HyperP2PStreamMultiplex() const mux = new HyperP2PStreamMultiplex()
m.write('hi'); t.ok(m.read()) const a = mux.openStream('a')
await m.close() const chunks = []
a.ondata((c, fin) => { if (c) chunks.push(c); if (fin) t.pass() })
a.write(b4aFrom('hi'))
a.end()
t.is(chunks.length, 1)
await mux.close()
}) })
test('validation', async (t) => { test('receiveFrame remote', async (t) => {
const m = new HyperP2PStreamMultiplex() const mux = new HyperP2PStreamMultiplex()
try { m.write(null) } catch (e) { t.ok(e) } const b = mux.openStream('b')
await m.close() let got = false
b.ondata((c) => { if (c) got = true })
mux.receiveFrame({ type: 'frame', streamId: 'b', chunk: b4aFrom('x'), fin: false })
mux.receiveFrame({ type: 'frame', streamId: 'b', chunk: null, fin: true })
t.ok(got)
await mux.close()
})
function b4aFrom (s) {
return require('b4a').from(s)
}
test('validation duplicate stream id', async (t) => {
const mux = new HyperP2PStreamMultiplex()
mux.openStream('dup')
try {
mux.openStream('dup')
t.fail('expected throw')
} catch (e) {
t.ok(e instanceof Error)
}
await mux.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PStreamMultiplex() const mux = new HyperP2PStreamMultiplex()
t.ok(m.getStats().protocol) mux.openStream()
await m.close() t.ok(mux.getStats().protocol)
await mux.close()
}) })
@@ -1,7 +1,6 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const b4a = require('b4a')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js')
const PROTOCOL = 'stream-resume-token/v1' const PROTOCOL = 'stream-resume-token/v1'
@@ -9,29 +8,56 @@ class HyperP2PStreamResumeToken extends EventEmitter {
constructor (opts = {}) { constructor (opts = {}) {
super() super()
this._chunks = [] this._chunks = []
this._stats = { chunks: 0, bytes: 0 } this._offset = 0
this.highWaterMark = opts.highWaterMark || 65536 this._tokens = new Map()
this._nextToken = 1
this._stats = { bytes: 0, checkpoints: 0, resumes: 0 }
} }
write (chunk) { write (chunk) {
if (chunk == null) throw new Error('chunk required') if (chunk == null) throw new Error('chunk required')
const b4a = require('b4a')
const buf = typeof chunk === 'string' ? b4a.from(chunk) : chunk const buf = typeof chunk === 'string' ? b4a.from(chunk) : chunk
this._chunks.push(buf) this._chunks.push(buf)
this._stats.chunks++
this._stats.bytes += buf.length || buf.byteLength || 0 this._stats.bytes += buf.length || buf.byteLength || 0
this.emit('data', buf) this.emit('data', buf)
return this._stats.bytes <= this.highWaterMark return true
} }
read () { return this._chunks.shift() || null } checkpoint () {
const id = String(this._nextToken++)
const token = { id, offset: this._stats.bytes, at: Date.now() }
this._tokens.set(id, token)
this._stats.checkpoints++
this.emit('checkpoint', token)
return token
}
pending () { return this._chunks.length } resume (tokenId) {
const token = this._tokens.get(tokenId)
if (!token) throw new Error('unknown resume token')
this._offset = token.offset
this._stats.resumes++
this.emit('resume', token)
return { offset: this._offset, token }
}
getStats () { return { ...this._stats, protocol: PROTOCOL } } readFromOffset () {
let pos = 0
const out = []
for (const c of this._chunks) {
const len = c.length || c.byteLength || 0
if (pos + len > this._offset) out.push(c)
pos += len
}
return out
}
getStats () {
return { ...this._stats, offset: this._offset, protocol: PROTOCOL }
}
async ready () { return this } async ready () { return this }
async close () { this._chunks = [] } async close () { this._chunks = []; this._tokens.clear() }
} }
module.exports = { HyperP2PStreamResumeToken, PROTOCOL } module.exports = { HyperP2PStreamResumeToken, PROTOCOL }
@@ -2,25 +2,32 @@ require('bare-process/global')
const test = require('brittle') const test = require('brittle')
const { HyperP2PStreamResumeToken, PROTOCOL } = require('../index.js') const { HyperP2PStreamResumeToken, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => { t.ok(HyperP2PStreamResumeToken); t.is(PROTOCOL, 'stream-resume-token/v1') })
t.ok(HyperP2PStreamResumeToken)
t.ok(PROTOCOL)
})
test('basic operation', async (t) => { test('checkpoint and resume', async (t) => {
const m = new HyperP2PStreamResumeToken() const m = new HyperP2PStreamResumeToken()
m.write('hi'); t.ok(m.read()) m.write(require('b4a').from('hello'))
const tok = m.checkpoint()
m.write(require('b4a').from('world'))
m.resume(tok.id)
t.is(m.getStats().offset, tok.offset)
await m.close() await m.close()
}) })
test('validation', async (t) => { test('unknown token', async (t) => {
const m = new HyperP2PStreamResumeToken() const m = new HyperP2PStreamResumeToken()
try { m.write(null) } catch (e) { t.ok(e) } try { m.resume('nope'); t.fail() } catch (e) { t.ok(e) }
await m.close()
})
test('write', async (t) => {
const m = new HyperP2PStreamResumeToken()
t.ok(m.write('a'))
await m.close() await m.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PStreamResumeToken() const m = new HyperP2PStreamResumeToken()
t.ok(m.getStats().protocol) t.is(m.getStats().protocol, 'stream-resume-token/v1')
await m.close() await m.close()
}) })
+26 -14
View File
@@ -1,37 +1,49 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const b4a = require('b4a')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js')
const PROTOCOL = 'stream-tee/v1' const PROTOCOL = 'stream-tee/v1'
class HyperP2PStreamTee extends EventEmitter { class HyperP2PStreamTee extends EventEmitter {
constructor (opts = {}) { constructor (opts = {}) {
super() super()
this._chunks = [] this._branches = new Map()
this._stats = { chunks: 0, bytes: 0 } this._stats = { written: 0, branches: 0 }
this.highWaterMark = opts.highWaterMark || 65536 }
addBranch (name) {
if (!name) throw new Error('branch name required')
const branch = { name, chunks: [] }
this._branches.set(name, branch)
this._stats.branches++
return () => this._branches.delete(name)
} }
write (chunk) { write (chunk) {
if (chunk == null) throw new Error('chunk required') if (chunk == null) throw new Error('chunk required')
const b4a = require('b4a')
const buf = typeof chunk === 'string' ? b4a.from(chunk) : chunk const buf = typeof chunk === 'string' ? b4a.from(chunk) : chunk
this._chunks.push(buf) for (const branch of this._branches.values()) branch.chunks.push(buf)
this._stats.chunks++ this._stats.written++
this._stats.bytes += buf.length || buf.byteLength || 0
this.emit('data', buf) this.emit('data', buf)
return this._stats.bytes <= this.highWaterMark return true
} }
read () { return this._chunks.shift() || null } readBranch (name) {
const b = this._branches.get(name)
return b ? b.chunks.shift() || null : null
}
pending () { return this._chunks.length } pending (name) {
const b = this._branches.get(name)
return b ? b.chunks.length : 0
}
getStats () { return { ...this._stats, protocol: PROTOCOL } } getStats () {
return { ...this._stats, branchCount: this._branches.size, protocol: PROTOCOL }
}
async ready () { return this } async ready () { return this }
async close () { this._chunks = [] } async close () { this._branches.clear() }
} }
module.exports = { HyperP2PStreamTee, PROTOCOL } module.exports = { HyperP2PStreamTee, PROTOCOL }
@@ -2,25 +2,34 @@ require('bare-process/global')
const test = require('brittle') const test = require('brittle')
const { HyperP2PStreamTee, PROTOCOL } = require('../index.js') const { HyperP2PStreamTee, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => { t.ok(HyperP2PStreamTee); t.is(PROTOCOL, 'stream-tee/v1') })
t.ok(HyperP2PStreamTee)
t.ok(PROTOCOL)
})
test('basic operation', async (t) => { test('tee to branches', async (t) => {
const m = new HyperP2PStreamTee() const m = new HyperP2PStreamTee()
m.write('hi'); t.ok(m.read()) m.addBranch('a')
m.addBranch('b')
m.write(require('b4a').from('z'))
t.ok(m.readBranch('a'))
t.ok(m.readBranch('b'))
await m.close() await m.close()
}) })
test('validation', async (t) => { test('validation', async (t) => {
const m = new HyperP2PStreamTee() const m = new HyperP2PStreamTee()
try { m.write(null) } catch (e) { t.ok(e) } try { m.addBranch('') } catch (e) { t.ok(e) }
await m.close()
})
test('remove branch', async (t) => {
const m = new HyperP2PStreamTee()
const off = m.addBranch('x')
off()
t.is(m.pending('x'), 0)
await m.close() await m.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PStreamTee() const m = new HyperP2PStreamTee()
t.ok(m.getStats().protocol) t.is(m.getStats().protocol, 'stream-tee/v1')
await m.close() await m.close()
}) })
@@ -1,37 +1,46 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const b4a = require('b4a')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js')
const PROTOCOL = 'stream-transform/v1' const PROTOCOL = 'stream-transform/v1'
class HyperP2PStreamTransform extends EventEmitter { class HyperP2PStreamTransform extends EventEmitter {
constructor (opts = {}) { constructor (opts = {}) {
super() super()
this._chunks = [] this._fn = opts.transform || null
this._stats = { chunks: 0, bytes: 0 } this._out = []
this.highWaterMark = opts.highWaterMark || 65536 this._stats = { in: 0, out: 0 }
}
setTransform (fn) {
if (typeof fn !== 'function') throw new Error('transform must be a function')
this._fn = fn
} }
write (chunk) { write (chunk) {
if (chunk == null) throw new Error('chunk required') if (chunk == null) throw new Error('chunk required')
const b4a = require('b4a')
const buf = typeof chunk === 'string' ? b4a.from(chunk) : chunk const buf = typeof chunk === 'string' ? b4a.from(chunk) : chunk
this._chunks.push(buf) this._stats.in++
this._stats.chunks++ const result = this._fn ? this._fn(buf) : buf
this._stats.bytes += buf.length || buf.byteLength || 0 if (result != null) {
this.emit('data', buf) const out = b4a.isBuffer(result) ? result : b4a.from(String(result))
return this._stats.bytes <= this.highWaterMark this._out.push(out)
this._stats.out++
this.emit('data', out)
}
return true
} }
read () { return this._chunks.shift() || null } read () { return this._out.shift() || null }
pending () { return this._chunks.length } pending () { return this._out.length }
getStats () { return { ...this._stats, protocol: PROTOCOL } } getStats () {
return { ...this._stats, protocol: PROTOCOL }
}
async ready () { return this } async ready () { return this }
async close () { this._chunks = [] } async close () { this._out = [] }
} }
module.exports = { HyperP2PStreamTransform, PROTOCOL } module.exports = { HyperP2PStreamTransform, PROTOCOL }
@@ -2,25 +2,30 @@ require('bare-process/global')
const test = require('brittle') const test = require('brittle')
const { HyperP2PStreamTransform, PROTOCOL } = require('../index.js') const { HyperP2PStreamTransform, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => { t.ok(HyperP2PStreamTransform); t.is(PROTOCOL, 'stream-transform/v1') })
t.ok(HyperP2PStreamTransform)
t.ok(PROTOCOL) test('transform chunks', async (t) => {
const m = new HyperP2PStreamTransform({ transform: (buf) => require('b4a').from(buf.toString().toUpperCase()) })
m.write(require('b4a').from('hi'))
t.is(m.read().toString(), 'HI')
await m.close()
}) })
test('basic operation', async (t) => { test('setTransform', async (t) => {
const m = new HyperP2PStreamTransform() const m = new HyperP2PStreamTransform()
m.write('hi'); t.ok(m.read()) m.setTransform((b) => b)
t.ok(m.write('x'))
await m.close() await m.close()
}) })
test('validation', async (t) => { test('validation', async (t) => {
const m = new HyperP2PStreamTransform() const m = new HyperP2PStreamTransform()
try { m.write(null) } catch (e) { t.ok(e) } try { m.setTransform(null) } catch (e) { t.ok(e) }
await m.close() await m.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PStreamTransform() const m = new HyperP2PStreamTransform()
t.ok(m.getStats().protocol) t.is(m.getStats().protocol, 'stream-transform/v1')
await m.close() await m.close()
}) })
+36 -25
View File
@@ -1,7 +1,7 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const { assertNonEmpty } = require('../../_shared/lib/errors.js')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js') const { attachObsGossip, sendObs } = require('../../_shared/observability-base.js')
const PROTOCOL = 'health-probe/v1' const PROTOCOL = 'health-probe/v1'
@@ -10,44 +10,55 @@ class HyperP2PHealthProbe extends EventEmitter {
super() super()
this.topic = opts.topic || null this.topic = opts.topic || null
this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair() this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair()
this._store = new Map() this._reports = new Map()
this._stats = { ops: 0, gossipIn: 0, gossipOut: 0 } this._stats = { probes: 0, gossipIn: 0, gossipOut: 0 }
this.swarm = null this.swarm = null
this._peerMsgs = null
} }
put (key, value) { report (peerId, status = {}) {
assertNonEmpty(key, 'key') assertNonEmpty(peerId, 'peerId')
this._store.set(key, value) const entry = { peerId, ok: status.ok !== false, rttMs: status.rttMs || 0, at: Date.now(), ...status }
this._stats.ops++ this._reports.set(peerId, entry)
sendGossip(this, { type: 'health-probe-sync', key, value }) this._stats.probes++
this.emit('update', { key, value }) if (this._peerMsgs) {
return true sendObs(this, { type: 'health', entry })
this._stats.gossipOut++
}
this.emit('report', entry)
return entry
} }
get (key) { return this._store.get(key) } get (peerId) { return this._reports.get(peerId) || null }
delete (key) { healthyPeers () {
const ok = this._store.delete(key) return [...this._reports.values()].filter((r) => r.ok)
if (ok) sendGossip(this, { type: 'health-probe-sync', key, value: null })
return ok
} }
entries () { return [...this._store.entries()] } _onGossip (data) {
if (!data || data.type !== 'health' || !data.entry) return
_onGossip (d) {
if (!d || d.type !== 'health-probe-sync') return
this._stats.gossipIn++ this._stats.gossipIn++
if (d.key !== undefined) { this._reports.set(data.entry.peerId, data.entry)
if (d.value === null) this._store.delete(d.key) this.emit('remote-report', data.entry)
else this._store.set(d.key, d.value) }
getStats () {
return {
...this._stats,
peers: this._reports.size,
healthy: this.healthyPeers().length,
protocol: PROTOCOL
} }
} }
getStats () { return { ...this._stats, size: this._store.size, protocol: PROTOCOL } }
async ready () { async ready () {
if (this.swarm || !this.topic) return this if (this.swarm || !this.topic) return this
await attachGossip(this, { keyPair: this.keyPair, topic: this.topic, protocol: PROTOCOL, onmessage: (d) => this._onGossip(d) }) await attachObsGossip(this, {
keyPair: this.keyPair,
topic: this.topic,
protocol: PROTOCOL,
onmessage: (d) => this._onGossip(d)
})
return this return this
} }
@@ -4,23 +4,32 @@ const { HyperP2PHealthProbe, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => {
t.ok(HyperP2PHealthProbe) t.ok(HyperP2PHealthProbe)
t.ok(PROTOCOL) t.is(PROTOCOL, 'health-probe/v1')
}) })
test('basic operation', async (t) => { test('report and query', async (t) => {
const m = new HyperP2PHealthProbe() const m = new HyperP2PHealthProbe()
m.put('k', 1); t.is(m.get('k'), 1) m.report('peer-a', { ok: true, rttMs: 12 })
t.ok(m.get('peer-a').ok)
t.is(m.healthyPeers().length, 1)
await m.close() await m.close()
}) })
test('validation', async (t) => { test('validation', async (t) => {
const m = new HyperP2PHealthProbe() const m = new HyperP2PHealthProbe()
try { m.put(null, 1) } catch (e) { t.ok(e) } try { m.report('', {}) } catch (e) { t.ok(e) }
await m.close()
})
test('remote report', async (t) => {
const m = new HyperP2PHealthProbe()
m._onGossip({ type: 'health', entry: { peerId: 'b', ok: false, at: 1 } })
t.is(m.get('b').ok, false)
await m.close() await m.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PHealthProbe() const m = new HyperP2PHealthProbe()
t.ok(m.getStats().protocol) t.is(m.getStats().protocol, 'health-probe/v1')
await m.close() await m.close()
}) })
+42 -29
View File
@@ -1,59 +1,72 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const { attachObsGossip, sendObs } = require('../../_shared/observability-base.js')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js')
const PROTOCOL = 'log-gossip/v1' const PROTOCOL = 'log-gossip/v1'
const LEVELS = ['debug', 'info', 'warn', 'error']
class HyperP2PLogGossip extends EventEmitter { class HyperP2PLogGossip extends EventEmitter {
constructor (opts = {}) { constructor (opts = {}) {
super() super()
this.topic = opts.topic || null this.topic = opts.topic || null
this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair() this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair()
this._store = new Map() this.maxEntries = opts.maxEntries || 500
this._stats = { ops: 0, gossipIn: 0, gossipOut: 0 } this.minLevel = opts.minLevel || 'debug'
this._logs = []
this._stats = { logged: 0, gossipIn: 0, gossipOut: 0 }
this.swarm = null this.swarm = null
this._peerMsgs = null
} }
put (key, value) { _levelIndex (level) {
assertNonEmpty(key, 'key') return LEVELS.indexOf(level)
this._store.set(key, value)
this._stats.ops++
sendGossip(this, { type: 'log-gossip-sync', key, value })
this.emit('update', { key, value })
return true
} }
get (key) { return this._store.get(key) } log (level, message, meta = {}) {
if (this._levelIndex(level) < this._levelIndex(this.minLevel)) return false
delete (key) { const entry = { level, message, meta, at: Date.now() }
const ok = this._store.delete(key) this._logs.push(entry)
if (ok) sendGossip(this, { type: 'log-gossip-sync', key, value: null }) if (this._logs.length > this.maxEntries) this._logs.shift()
return ok this._stats.logged++
} if (this._peerMsgs) {
sendObs(this, { type: 'log', entry })
entries () { return [...this._store.entries()] } this._stats.gossipOut++
_onGossip (d) {
if (!d || d.type !== 'log-gossip-sync') return
this._stats.gossipIn++
if (d.key !== undefined) {
if (d.value === null) this._store.delete(d.key)
else this._store.set(d.key, d.value)
} }
this.emit('log', entry)
return entry
} }
getStats () { return { ...this._stats, size: this._store.size, protocol: PROTOCOL } } tail (limit = 50) {
return this._logs.slice(-limit)
}
_onGossip (data) {
if (!data || data.type !== 'log' || !data.entry) return
this._stats.gossipIn++
this._logs.push(data.entry)
if (this._logs.length > this.maxEntries) this._logs.shift()
this.emit('remote-log', data.entry)
}
getStats () {
return { ...this._stats, buffer: this._logs.length, protocol: PROTOCOL }
}
async ready () { async ready () {
if (this.swarm || !this.topic) return this if (this.swarm || !this.topic) return this
await attachGossip(this, { keyPair: this.keyPair, topic: this.topic, protocol: PROTOCOL, onmessage: (d) => this._onGossip(d) }) await attachObsGossip(this, {
keyPair: this.keyPair,
topic: this.topic,
protocol: PROTOCOL,
onmessage: (d) => this._onGossip(d)
})
return this return this
} }
async close () { async close () {
if (this.swarm) await this.swarm.destroy().catch(() => {}) if (this.swarm) await this.swarm.destroy().catch(() => {})
this.swarm = null this.swarm = null
this._logs = []
} }
} }
@@ -4,23 +4,32 @@ const { HyperP2PLogGossip, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => {
t.ok(HyperP2PLogGossip) t.ok(HyperP2PLogGossip)
t.ok(PROTOCOL) t.is(PROTOCOL, 'log-gossip/v1')
}) })
test('basic operation', async (t) => { test('log and tail', async (t) => {
const m = new HyperP2PLogGossip() const m = new HyperP2PLogGossip()
m.put('k', 1); t.is(m.get('k'), 1) m.log('info', 'hello')
t.is(m.tail(1)[0].message, 'hello')
await m.close() await m.close()
}) })
test('validation', async (t) => { test('min level filter', async (t) => {
const m = new HyperP2PLogGossip({ minLevel: 'warn' })
m.log('info', 'skip')
t.is(m.tail(1).length, 0)
await m.close()
})
test('remote log', async (t) => {
const m = new HyperP2PLogGossip() const m = new HyperP2PLogGossip()
try { m.put(null, 1) } catch (e) { t.ok(e) } m._onGossip({ type: 'log', entry: { level: 'error', message: 'x', at: 1 } })
t.is(m.tail(1)[0].message, 'x')
await m.close() await m.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PLogGossip() const m = new HyperP2PLogGossip()
t.ok(m.getStats().protocol) t.is(m.getStats().protocol, 'log-gossip/v1')
await m.close() await m.close()
}) })
@@ -1,7 +1,6 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const { attachObsGossip, sendObs } = require('../../_shared/observability-base.js')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js')
const PROTOCOL = 'metrics-aggregator/v1' const PROTOCOL = 'metrics-aggregator/v1'
@@ -10,50 +9,74 @@ class HyperP2PMetricsAggregator extends EventEmitter {
super() super()
this.topic = opts.topic || null this.topic = opts.topic || null
this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair() this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair()
this._store = new Map() this._metrics = new Map()
this._stats = { ops: 0, gossipIn: 0, gossipOut: 0 } this._stats = { recorded: 0, gossipIn: 0, gossipOut: 0 }
this.swarm = null this.swarm = null
this._peerMsgs = null
} }
put (key, value) { record (name, value, labels = {}) {
assertNonEmpty(key, 'key') if (!name) throw new Error('metric name required')
this._store.set(key, value) if (typeof value !== 'number') throw new Error('value must be a number')
this._stats.ops++ const key = name + JSON.stringify(labels)
sendGossip(this, { type: 'metrics-aggregator-sync', key, value }) const samples = this._metrics.get(key) || { name, labels, values: [] }
this.emit('update', { key, value }) samples.values.push({ value, at: Date.now() })
if (samples.values.length > 1000) samples.values.shift()
this._metrics.set(key, samples)
this._stats.recorded++
if (this._peerMsgs) {
sendObs(this, { type: 'metric', name, value, labels, at: Date.now() })
this._stats.gossipOut++
}
return true return true
} }
get (key) { return this._store.get(key) } summarize (name, labels = {}) {
const key = name + JSON.stringify(labels)
delete (key) { const s = this._metrics.get(key)
const ok = this._store.delete(key) if (!s || !s.values.length) return null
if (ok) sendGossip(this, { type: 'metrics-aggregator-sync', key, value: null }) const vals = s.values.map((x) => x.value)
return ok const sum = vals.reduce((a, b) => a + b, 0)
return { count: vals.length, min: Math.min(...vals), max: Math.max(...vals), avg: sum / vals.length }
} }
entries () { return [...this._store.entries()] } mergeRemote (name, value, labels = {}) {
return this.record(name, value, labels)
}
_onGossip (d) { _onGossip (data) {
if (!d || d.type !== 'metrics-aggregator-sync') return if (!data || data.type !== 'metric') return
this._stats.gossipIn++ this._stats.gossipIn++
if (d.key !== undefined) { this.mergeRemote(data.name, data.value, data.labels || {})
if (d.value === null) this._store.delete(d.key)
else this._store.set(d.key, d.value)
}
} }
getStats () { return { ...this._stats, size: this._store.size, protocol: PROTOCOL } } exportAll () {
const out = {}
for (const [key, s] of this._metrics) {
out[key] = this.summarize(s.name, s.labels)
}
return out
}
getStats () {
return { ...this._stats, series: this._metrics.size, protocol: PROTOCOL }
}
async ready () { async ready () {
if (this.swarm || !this.topic) return this if (this.swarm || !this.topic) return this
await attachGossip(this, { keyPair: this.keyPair, topic: this.topic, protocol: PROTOCOL, onmessage: (d) => this._onGossip(d) }) await attachObsGossip(this, {
keyPair: this.keyPair,
topic: this.topic,
protocol: PROTOCOL,
onmessage: (d) => this._onGossip(d)
})
return this return this
} }
async close () { async close () {
if (this.swarm) await this.swarm.destroy().catch(() => {}) if (this.swarm) await this.swarm.destroy().catch(() => {})
this.swarm = null this.swarm = null
this._metrics.clear()
} }
} }
@@ -4,23 +4,34 @@ const { HyperP2PMetricsAggregator, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => {
t.ok(HyperP2PMetricsAggregator) t.ok(HyperP2PMetricsAggregator)
t.ok(PROTOCOL) t.is(PROTOCOL, 'metrics-aggregator/v1')
}) })
test('basic operation', async (t) => { test('record and summarize', async (t) => {
const m = new HyperP2PMetricsAggregator() const m = new HyperP2PMetricsAggregator()
m.put('k', 1); t.is(m.get('k'), 1) m.record('latency', 10)
m.record('latency', 20)
const s = m.summarize('latency')
t.is(s.count, 2)
t.is(s.avg, 15)
await m.close() await m.close()
}) })
test('validation', async (t) => { test('validation', async (t) => {
const m = new HyperP2PMetricsAggregator() const m = new HyperP2PMetricsAggregator()
try { m.put(null, 1) } catch (e) { t.ok(e) } try { m.record('', 1) } catch (e) { t.ok(e) }
await m.close()
})
test('exportAll', async (t) => {
const m = new HyperP2PMetricsAggregator()
m.record('x', 1)
t.ok(m.exportAll()['x{}'])
await m.close() await m.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PMetricsAggregator() const m = new HyperP2PMetricsAggregator()
t.ok(m.getStats().protocol) t.is(m.getStats().protocol, 'metrics-aggregator/v1')
await m.close() await m.close()
}) })
+27 -36
View File
@@ -1,60 +1,51 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const { assertNonEmpty } = require('../../_shared/lib/errors.js')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js')
const PROTOCOL = 'stats-exporter/v1' const PROTOCOL = 'stats-exporter/v1'
class HyperP2PStatsExporter extends EventEmitter { class HyperP2PStatsExporter extends EventEmitter {
constructor (opts = {}) { constructor (opts = {}) {
super() super()
this.topic = opts.topic || null this._sources = new Map()
this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair() this._stats = { exports: 0, sources: 0 }
this._store = new Map()
this._stats = { ops: 0, gossipIn: 0, gossipOut: 0 }
this.swarm = null
} }
put (key, value) { register (name, getStatsFn) {
assertNonEmpty(key, 'key') if (!name) throw new Error('name required')
this._store.set(key, value) if (typeof getStatsFn !== 'function') throw new Error('getStatsFn required')
this._stats.ops++ this._sources.set(name, getStatsFn)
sendGossip(this, { type: 'stats-exporter-sync', key, value }) this._stats.sources++
this.emit('update', { key, value })
return true return true
} }
get (key) { return this._store.get(key) } unregister (name) {
return this._sources.delete(name)
delete (key) {
const ok = this._store.delete(key)
if (ok) sendGossip(this, { type: 'stats-exporter-sync', key, value: null })
return ok
} }
entries () { return [...this._store.entries()] } snapshot () {
const out = { at: Date.now(), modules: {} }
_onGossip (d) { for (const [name, fn] of this._sources) {
if (!d || d.type !== 'stats-exporter-sync') return try {
this._stats.gossipIn++ out.modules[name] = fn()
if (d.key !== undefined) { } catch (err) {
if (d.value === null) this._store.delete(d.key) out.modules[name] = { error: err.message }
else this._store.set(d.key, d.value) }
} }
this._stats.exports++
this.emit('snapshot', out)
return out
} }
getStats () { return { ...this._stats, size: this._store.size, protocol: PROTOCOL } } toJSON () {
return JSON.stringify(this.snapshot(), null, 2)
async ready () {
if (this.swarm || !this.topic) return this
await attachGossip(this, { keyPair: this.keyPair, topic: this.topic, protocol: PROTOCOL, onmessage: (d) => this._onGossip(d) })
return this
} }
async close () { getStats () {
if (this.swarm) await this.swarm.destroy().catch(() => {}) return { ...this._stats, protocol: PROTOCOL }
this.swarm = null
} }
async ready () { return this }
async close () { this._sources.clear() }
} }
module.exports = { HyperP2PStatsExporter, PROTOCOL } module.exports = { HyperP2PStatsExporter, PROTOCOL }
@@ -4,23 +4,32 @@ const { HyperP2PStatsExporter, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => {
t.ok(HyperP2PStatsExporter) t.ok(HyperP2PStatsExporter)
t.ok(PROTOCOL) t.is(PROTOCOL, 'stats-exporter/v1')
}) })
test('basic operation', async (t) => { test('register and snapshot', async (t) => {
const m = new HyperP2PStatsExporter() const m = new HyperP2PStatsExporter()
m.put('k', 1); t.is(m.get('k'), 1) m.register('a', () => ({ n: 1 }))
const snap = m.snapshot()
t.is(snap.modules.a.n, 1)
await m.close() await m.close()
}) })
test('validation', async (t) => { test('validation', async (t) => {
const m = new HyperP2PStatsExporter() const m = new HyperP2PStatsExporter()
try { m.put(null, 1) } catch (e) { t.ok(e) } try { m.register('x', null) } catch (e) { t.ok(e) }
await m.close()
})
test('toJSON', async (t) => {
const m = new HyperP2PStatsExporter()
m.register('b', () => ({ ok: true }))
t.ok(m.toJSON().includes('"b"'))
await m.close() await m.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PStatsExporter() const m = new HyperP2PStatsExporter()
t.ok(m.getStats().protocol) t.is(m.getStats().protocol, 'stats-exporter/v1')
await m.close() await m.close()
}) })
@@ -3,3 +3,7 @@
## [0.0.0-scaffold] — Wave 8 ## [0.0.0-scaffold] — Wave 8
- Registry scaffold: file tree, load smoke tests, docs stubs - Registry scaffold: file tree, load smoke tests, docs stubs
## [0.3.2] - 2026-05-21
### Changed
- Exhaustive documentation pass (api, architecture, README) per DOC_STANDARDS.md.
+28 -15
View File
@@ -1,28 +1,41 @@
# hyper-p2p-trace-span # hyper-p2p-trace-span
**Status:** scaffold (`0.0.0-scaffold`) · **Protocol:** `trace-span/v1` · **Wave:** 8 Production p2p module: Hyperswarm discovery + Protomux when `topic` is set.
Distributed trace spans. **Category:** General
## Holepunch references (inspiration only) **Composes with:**
- `hypertrace` **Protocol:** `trace-span/v1`
> This module composes on Hyperswarm/Hypercore — it does **not** re-implement upstream packages. ## When to use
## Composes with Multi-peer apps that need general over a shared Hyperswarm topic.
- `hyper-p2p-link-probe` ## When not to use
## Planned API Single-process tools with no P2P topic (use local APIs only or skip `ready()`).
- `constructor(opts)` — topic, optional keyPair ## Quick start
- `getStats()` — scaffold counters
- `ready()` — no-op until implemented
- Domain methods — throw `not implemented: scaffold` until Wave 8+ pass
## Layout ```js
const { HyperP2PTraceSpan } = require('hyper-p2p-trace-span')
const topic = process.argv[2] // 64-char hex or string
const mod = new HyperP2PTraceSpan({ topic, enableBackgroundTimers: false })
await mod.ready() // joins swarm when topic set
// ... application logic ...
await mod.close()
```
`modules/observability/hyper-p2p-trace-span/` ## Docs
See [`modules/_shared/MODULE_SYSTEM.md`](../../_shared/MODULE_SYSTEM.md). - [docs/api.md](docs/api.md) — constructor, methods, events, errors
- [docs/architecture.md](docs/architecture.md) — wire types, state, composition
- [../../_shared/PRODUCTION.md](../../_shared/PRODUCTION.md) — production checklist
- [../../_shared/DOC_STANDARDS.md](../../_shared/DOC_STANDARDS.md) — documentation standards
## Test
```bash
npm install && npm test
```
+75 -13
View File
@@ -1,23 +1,85 @@
# hyper-p2p-trace-span API # API: hyper-p2p-trace-span
**Status:** scaffold · **Protocol:** `trace-span/v1` **Protocol:** `trace-span/v1`
## Class `HyperP2PTraceSpan` **Export:** `HyperP2PTraceSpan`
Scaffold stub — methods throw `not implemented: scaffold` until promoted to production tier. ## Overview
### `constructor(opts?)` Production p2p module: Hyperswarm discovery + Protomux when `topic` is set.
### `getStats()` ## Constructor
Returns `{ created, errors, protocol, tier: 'scaffold' }`. ```js
const mod = new HyperP2PTraceSpan(opts)
```
### `ready()` | Option | Type | Default | Description |
|--------|------|---------|-------------|
| `topic` | varies | null | topic |
| `keyPair` | KeyPair | random Ed25519 | keyPair |
Resolves immediately (no-op). ## Methods
## Wire (planned) ### `startSpan(name, parentId = null)`
| Message | Direction | Notes | - **Returns:** `value`
|---------|-----------|-------| - **Throws:** — (none documented in method body)
| TBD | gossip | Defined in implementation pass |
### `endSpan(id)`
- **Returns:** `value`
- **Throws:** — (none documented in method body)
### `getSpan(id)`
- **Returns:** `value`
- **Throws:** — (none documented in method body)
### `activeSpans(—)`
- **Returns:** `value`
- **Throws:** — (none documented in method body)
### `getStats(—)`
- **Returns:** `object`
- **Throws:** — (none documented in method body)
### `ready(—)`
- **Returns:** `Promise`
- **Throws:** — (none documented in method body)
### `close(—)`
- **Returns:** `Promise<void>`
- **Throws:** — (none documented in method body)
## Events
| Event | Payload |
|-------|---------|
| `end` | span |
| `remote-end` | s |
| `remote-start` | data.span |
| `start` | span |
## getStats()
Returns `{ ...this._stats }` — typically `ops`, `errors`, and module-specific counters (`created`, `relays`, `open`, `peers`, etc.).
Library-only modules may include `mode: 'local'`.
## Errors
Stable message substrings: see [`../../_shared/ERROR_CODES.md`](../../_shared/ERROR_CODES.md).
## P2P
When `topic` is set, `ready()` joins Hyperswarm and opens Protomux `trace-span/v1`.
## Testing
```bash
npm install && npm test
```
@@ -1,15 +1,44 @@
# hyper-p2p-trace-span architecture # Architecture: hyper-p2p-trace-span
**Tier:** scaffold · **Category:** `observability` **Category:** General
## Role ```mermaid
flowchart LR
App[Application] --> Mod[HyperP2PTraceSpan]
Mod --> Mux[Protomux trace-span/v1]
Mux --> Swarm[Hyperswarm]
```
Distributed trace spans. ## Sequence (P2P)
```mermaid
sequenceDiagram
participant App
participant Mod as Module
participant SW as Hyperswarm
participant Peer
App->>Mod: ready(topic)
Mod->>SW: join(topic)
SW->>Peer: connection
Mod->>Peer: gossip / Protomux
Peer-->>Mod: onmessage
Mod-->>App: emit(event)
```
## Wire messages
| type | fields | direction | behavior |
|------|--------|-----------|----------|
| `span-end` | endAt, id | gossip | Handled in onmessage / gossipSend |
| `span-start` | endAt, id, span, type | gossip | Handled in onmessage / gossipSend |
## State model
- In-memory `Map` / `Set` structures for hot path
- Optional Hyperbee/Hypercore persistence when `storageDir` or `memoryOnly` is configured
- `close()` tears down swarm, timers, and clears ephemeral state
## Composition ## Composition
Uses `../../_shared/p2p-bare.js` for Hyperswarm + Protomux when implemented. Does **not** duplicate Holepunch core storage/transport. Composes with: see MODULE_CATEGORIES.md.
## Holepunch boundary
Inspiration: n/a
+70 -23
View File
@@ -1,7 +1,8 @@
require('bare-process/global') require('bare-process/global')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const b4a = require('b4a')
const { assertNonEmpty } = require('../../_shared/lib/errors.js') const { assertNonEmpty } = require('../../_shared/lib/errors.js')
const { attachGossip, sendGossip } = require('../../_shared/storage-gossip-base.js') const { attachObsGossip, sendObs } = require('../../_shared/observability-base.js')
const PROTOCOL = 'trace-span/v1' const PROTOCOL = 'trace-span/v1'
@@ -10,50 +11,96 @@ class HyperP2PTraceSpan extends EventEmitter {
super() super()
this.topic = opts.topic || null this.topic = opts.topic || null
this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair() this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair()
this._store = new Map() this.peerHex = b4a.toString(this.keyPair.publicKey, 'hex')
this._stats = { ops: 0, gossipIn: 0, gossipOut: 0 } this._spans = new Map()
this._nextId = 1
this._stats = { started: 0, ended: 0, gossipIn: 0, gossipOut: 0 }
this.swarm = null this.swarm = null
this._peerMsgs = null
} }
put (key, value) { startSpan (name, parentId = null) {
assertNonEmpty(key, 'key') assertNonEmpty(name, 'name')
this._store.set(key, value) const id = String(this._nextId++)
this._stats.ops++ const span = {
sendGossip(this, { type: 'trace-span-sync', key, value }) id,
this.emit('update', { key, value }) name,
parentId,
peer: this.peerHex,
startAt: Date.now(),
endAt: null
}
this._spans.set(id, span)
this._stats.started++
if (this._peerMsgs) {
sendObs(this, { type: 'span-start', span })
this._stats.gossipOut++
}
this.emit('start', span)
return id
}
endSpan (id) {
const span = this._spans.get(id)
if (!span) return false
span.endAt = Date.now()
span.durationMs = span.endAt - span.startAt
this._stats.ended++
if (this._peerMsgs) {
sendObs(this, { type: 'span-end', id, endAt: span.endAt })
this._stats.gossipOut++
}
this.emit('end', span)
return true return true
} }
get (key) { return this._store.get(key) } getSpan (id) { return this._spans.get(id) || null }
delete (key) { activeSpans () {
const ok = this._store.delete(key) return [...this._spans.values()].filter((s) => !s.endAt)
if (ok) sendGossip(this, { type: 'trace-span-sync', key, value: null })
return ok
} }
entries () { return [...this._store.entries()] } _onGossip (data) {
if (!data || !data.type) return
_onGossip (d) {
if (!d || d.type !== 'trace-span-sync') return
this._stats.gossipIn++ this._stats.gossipIn++
if (d.key !== undefined) { if (data.type === 'span-start' && data.span) {
if (d.value === null) this._store.delete(d.key) this._spans.set(data.span.id, data.span)
else this._store.set(d.key, d.value) this.emit('remote-start', data.span)
}
if (data.type === 'span-end' && data.id) {
const s = this._spans.get(data.id)
if (s) {
s.endAt = data.endAt
s.durationMs = s.endAt - s.startAt
this.emit('remote-end', s)
}
} }
} }
getStats () { return { ...this._stats, size: this._store.size, protocol: PROTOCOL } } getStats () {
return {
...this._stats,
active: this.activeSpans().length,
total: this._spans.size,
protocol: PROTOCOL
}
}
async ready () { async ready () {
if (this.swarm || !this.topic) return this if (this.swarm || !this.topic) return this
await attachGossip(this, { keyPair: this.keyPair, topic: this.topic, protocol: PROTOCOL, onmessage: (d) => this._onGossip(d) }) await attachObsGossip(this, {
keyPair: this.keyPair,
topic: this.topic,
protocol: PROTOCOL,
onmessage: (d) => this._onGossip(d)
})
return this return this
} }
async close () { async close () {
if (this.swarm) await this.swarm.destroy().catch(() => {}) if (this.swarm) await this.swarm.destroy().catch(() => {})
this.swarm = null this.swarm = null
this._spans.clear()
} }
} }
@@ -4,23 +4,34 @@ const { HyperP2PTraceSpan, PROTOCOL } = require('../index.js')
test('exports', (t) => { test('exports', (t) => {
t.ok(HyperP2PTraceSpan) t.ok(HyperP2PTraceSpan)
t.ok(PROTOCOL) t.is(PROTOCOL, 'trace-span/v1')
}) })
test('basic operation', async (t) => { test('start and end span', async (t) => {
const m = new HyperP2PTraceSpan() const m = new HyperP2PTraceSpan()
m.put('k', 1); t.is(m.get('k'), 1) const id = m.startSpan('fetch')
t.ok(m.getSpan(id))
t.ok(m.endSpan(id))
t.is(m.activeSpans().length, 0)
await m.close() await m.close()
}) })
test('validation', async (t) => { test('validation', async (t) => {
const m = new HyperP2PTraceSpan() const m = new HyperP2PTraceSpan()
try { m.put(null, 1) } catch (e) { t.ok(e) } try { m.startSpan('') } catch (e) { t.ok(e) }
await m.close()
})
test('gossip merge', async (t) => {
const m = new HyperP2PTraceSpan()
m._onGossip({ type: 'span-start', span: { id: '9', name: 'x', startAt: 1 } })
t.ok(m.getSpan('9'))
await m.close() await m.close()
}) })
test('getStats', async (t) => { test('getStats', async (t) => {
const m = new HyperP2PTraceSpan() const m = new HyperP2PTraceSpan()
t.ok(m.getStats().protocol) m.startSpan('a')
t.is(m.getStats().protocol, 'trace-span/v1')
await m.close() await m.close()
}) })
@@ -0,0 +1,23 @@
{
"scores": {
"5e26c693363b2602f1ef9bab2ef46b27894bbb00eaafc1c859485ba37d20fb66": {
"score": 75,
"lastUpdated": 1779329383703,
"attestationsCount": 1
}
},
"history": {
"5e26c693363b2602f1ef9bab2ef46b27894bbb00eaafc1c859485ba37d20fb66": [
{
"ts": 1779329383703,
"delta": 75,
"attester": "818a423e814d79b4e3029391c8cf741ffa9d32faa3fca1024263ba8d5ff59d46",
"signature": "giiJuaTr39idubo+l1Hawwqi80OJeM44CaWTCobt16C0fyLYalCgo2v7H+RdN5skKxKddPqAsz1ljaExMXXODA==",
"metadata": {
"context": "test"
}
}
]
},
"lastPersist": 1779329383771
}
@@ -0,0 +1,23 @@
{
"scores": {
"7df4c6bdb9d408b755037116de5f45e6f414406cfcb2f2da4b8b55cc1ee0faa1": {
"score": 75,
"lastUpdated": 1779330024031,
"attestationsCount": 1
}
},
"history": {
"7df4c6bdb9d408b755037116de5f45e6f414406cfcb2f2da4b8b55cc1ee0faa1": [
{
"ts": 1779330024031,
"delta": 75,
"attester": "725dde1b2e0a00ec6e8803d3d5d5ca85d62eb7fc02b0c7a5979c0988871e5ce7",
"signature": "coMfAUkQYvRiMXSpAR/IJeS9qza6CrcgLxa5BY0TELeaOqiwYHENiSvZG54WtKctCl0O3olVOsYrUXoC0QjvBQ==",
"metadata": {
"context": "test"
}
}
]
},
"lastPersist": 1779330024092
}
@@ -0,0 +1,23 @@
{
"scores": {
"642ca640b94264bf987001b7a76aadcd64723aa2e4d2da2e768320759998f818": {
"score": 75,
"lastUpdated": 1779330409240,
"attestationsCount": 1
}
},
"history": {
"642ca640b94264bf987001b7a76aadcd64723aa2e4d2da2e768320759998f818": [
{
"ts": 1779330409240,
"delta": 75,
"attester": "9ea6cc984d5901d2029a5ba5ab95f7743811e11769381fa543e03d99f63ebd95",
"signature": "Gqh3tNyx61bxgbLyOzqZy2vYoWceg+QpcYmafNg5Y9dOPI9TeXvI9jI+NM04znhfOHnlExMVQC79acw+ffNCAw==",
"metadata": {
"context": "test"
}
}
]
},
"lastPersist": 1779330409301
}
@@ -0,0 +1,21 @@
{
"scores": {
"decay-peer": {
"score": 90,
"lastUpdated": 1779329383775,
"attestationsCount": 1
}
},
"history": {
"decay-peer": [
{
"ts": 1779329383775,
"delta": 100,
"attester": "cd6003a3a2e150b1db25002f934e5760f1d190c3b77e42fb9cb5907b06e92b3e",
"signature": "+2u7JKKv92k7/SmSnX5nzn672Dsn639sn/YfvQQAd88TYlbZtSNHEJnYnfHFLpuXBQj66EXW+paObC65uHtFBw==",
"metadata": {}
}
]
},
"lastPersist": 1779329383775
}
@@ -0,0 +1,21 @@
{
"scores": {
"decay-peer": {
"score": 90,
"lastUpdated": 1779330024095,
"attestationsCount": 1
}
},
"history": {
"decay-peer": [
{
"ts": 1779330024095,
"delta": 100,
"attester": "1a5ea157548fb7f52d53a205d6ee1fa43bda29629393d7aca55c5acecab85a6a",
"signature": "QAg1XehIcDEoACxyAjTaNh8ukp6WzBVF4vN7vAqlm2YQwM4fmxI9QiorBJ8zYn7ViNcLabUr8AX/rIPxfx/nAA==",
"metadata": {}
}
]
},
"lastPersist": 1779330024095
}
@@ -0,0 +1,21 @@
{
"scores": {
"decay-peer": {
"score": 90,
"lastUpdated": 1779330409303,
"attestationsCount": 1
}
},
"history": {
"decay-peer": [
{
"ts": 1779330409303,
"delta": 100,
"attester": "5226568a6e4464a5183b187a3abc11da26064c6c2bec45c2f6fb1363d651a7ae",
"signature": "6iBUOkpWqHUROWNfQ2VqyDC60G17usgo3+fUtSeImFe6E41UT/aJq9zAzKMsYkDE0wc+PBKCG6NzAiAfHY6VAg==",
"metadata": {}
}
]
},
"lastPersist": 1779330409304
}
@@ -0,0 +1,5 @@
{
"scores": {},
"history": {},
"lastPersist": 1779329383700
}
@@ -0,0 +1,5 @@
{
"scores": {},
"history": {},
"lastPersist": 1779330024028
}
@@ -0,0 +1,5 @@
{
"scores": {},
"history": {},
"lastPersist": 1779330409237
}
@@ -0,0 +1,21 @@
{
"scores": {
"persist-peer": {
"score": 55,
"lastUpdated": 1779329383776,
"attestationsCount": 1
}
},
"history": {
"persist-peer": [
{
"ts": 1779329383776,
"delta": 55,
"attester": "4d1911bcd702549658877050c179cfbcb53fa25728c835168822bf25f0caf70f",
"signature": "2+kdrj6kaAYbPwmt7kR274qp+ZgcCwjBeJbMzzdSiTOK/G6deCeMWeqrnkemItfIwFvFEVf6pGgDU1myd/U9Bg==",
"metadata": {}
}
]
},
"lastPersist": 1779329383777
}
@@ -0,0 +1,21 @@
{
"scores": {
"persist-peer": {
"score": 55,
"lastUpdated": 1779330024096,
"attestationsCount": 1
}
},
"history": {
"persist-peer": [
{
"ts": 1779330024096,
"delta": 55,
"attester": "c968c49758530d180754ba178f947d427cf9c513d6572afe95b9b0cf8701e946",
"signature": "upx66vNoFScZ5yVs2q6IQiq/u8/2h2ACpAxCnN9JMMR/UOySfHkTobb+Ibu2fOnju8tOV9gxKpFacvJaWEv1Dw==",
"metadata": {}
}
]
},
"lastPersist": 1779330024096
}
@@ -0,0 +1,21 @@
{
"scores": {
"persist-peer": {
"score": 55,
"lastUpdated": 1779330409304,
"attestationsCount": 1
}
},
"history": {
"persist-peer": [
{
"ts": 1779330409304,
"delta": 55,
"attester": "0f6b072c2a4d4c8a7a81cddbf4a67011b2292687ec1492fced8d64e1662b3cf9",
"signature": "H2YqUYjC4/9Ld8OxeW94toLA3FNgi2gHMczRVknO4Ksbhv3jMpA5j9UBhjd5bzLoU5X2ufF10LURQgWq/JrbCw==",
"metadata": {}
}
]
},
"lastPersist": 1779330409305
}
@@ -0,0 +1,35 @@
{
"scores": {
"peer1": {
"score": 100,
"lastUpdated": 1779329383773,
"attestationsCount": 1
},
"peer2": {
"score": 60,
"lastUpdated": 1779329383773,
"attestationsCount": 1
}
},
"history": {
"peer1": [
{
"ts": 1779329383773,
"delta": 100,
"attester": "59e6a4a185d33edc14e4bb5a699db04d20f5227da2fb39df86d1f64e5e5ce40f",
"signature": "M6WH3yXusJFDW9IQPEg/qXI6jHOY/0jZ73ljghjRj9jz9C/Au+RvnQjQlQvrPBN0NNOE8YiVhFDwfcYaVuYDAg==",
"metadata": {}
}
],
"peer2": [
{
"ts": 1779329383773,
"delta": 60,
"attester": "59e6a4a185d33edc14e4bb5a699db04d20f5227da2fb39df86d1f64e5e5ce40f",
"signature": "Ta83ahKQP+0wxfBAjBaAQ4l5O/rTv3c1Xbet3yPcuSAs3tOF+Jk6O4ecMiL73DVzYvAVPedk/nyC9qtps0+FBw==",
"metadata": {}
}
]
},
"lastPersist": 1779329383774
}
@@ -0,0 +1,35 @@
{
"scores": {
"peer1": {
"score": 100,
"lastUpdated": 1779330024094,
"attestationsCount": 1
},
"peer2": {
"score": 60,
"lastUpdated": 1779330024094,
"attestationsCount": 1
}
},
"history": {
"peer1": [
{
"ts": 1779330024094,
"delta": 100,
"attester": "8f1f48471e5f48ea7e63a05365d29191d28b9acde1a7bc6eec68e05d4454de66",
"signature": "5rKJUWg54BV6MmIpBceHiqLeMNJp3xlLd5wyGfDnm1QTMlksdNmvjY14MC/yIxJmSIvufA6rKbpwF4tWyF0QAw==",
"metadata": {}
}
],
"peer2": [
{
"ts": 1779330024094,
"delta": 60,
"attester": "8f1f48471e5f48ea7e63a05365d29191d28b9acde1a7bc6eec68e05d4454de66",
"signature": "+Txymdr/BOxgRHOxows0BVOwe3pKePczxsb5iFkqD+t84EfvPgOyfazyADn8EBLB/jNkbmyGTA013hh/S2S0BA==",
"metadata": {}
}
]
},
"lastPersist": 1779330024094
}
@@ -0,0 +1,35 @@
{
"scores": {
"peer1": {
"score": 100,
"lastUpdated": 1779330409302,
"attestationsCount": 1
},
"peer2": {
"score": 60,
"lastUpdated": 1779330409302,
"attestationsCount": 1
}
},
"history": {
"peer1": [
{
"ts": 1779330409302,
"delta": 100,
"attester": "5586e4960a713fa843add3e53ec7afeca192dc5d28c7a87d0793dc407f9d8bf9",
"signature": "1SdEDn+4agFRZEpeljTKavqKoybDxVWdqXv0+JQS/ZPQw8PvmJwPYbR9+6pVeGHFFJ1S9SlmYfn8wpinmV7oDA==",
"metadata": {}
}
],
"peer2": [
{
"ts": 1779330409302,
"delta": 60,
"attester": "5586e4960a713fa843add3e53ec7afeca192dc5d28c7a87d0793dc407f9d8bf9",
"signature": "X2v3KWN54CxSf6/V8PRFSzYCS9GPRU/GfMaHpoAjfiwrhgWy5qPNRAXDOsRadTmCHM1DZ7+BC/eh3301iWbxCw==",
"metadata": {}
}
]
},
"lastPersist": 1779330409303
}
@@ -0,0 +1,5 @@
{
"scores": {},
"history": {},
"lastPersist": 1779329383772
}
@@ -0,0 +1,5 @@
{
"scores": {},
"history": {},
"lastPersist": 1779330024093
}
@@ -0,0 +1,5 @@
{
"scores": {},
"history": {},
"lastPersist": 1779330409302
}
@@ -0,0 +1,37 @@
{
"scores": {
"snapshot-peer-1": {
"score": 120,
"lastUpdated": 1779329383777,
"attestationsCount": 1
},
"snapshot-peer-2": {
"score": 80,
"lastUpdated": 1779329383778,
"attestationsCount": 1
}
},
"history": {
"snapshot-peer-1": [
{
"ts": 1779329383777,
"delta": 120,
"attester": "2915aac7c497b35d8d578f86c9e41afdd100c936a21a3d43489986c281b5cad6",
"signature": "dkraEp27NIAmBgtSXrf3fGg4QK9ETES37T6uq682E5/BR0fz9g13WqNLovoc6WK8aCsXQT6rOs6kkoos/7B5DA==",
"metadata": {
"category": "reliability"
}
}
],
"snapshot-peer-2": [
{
"ts": 1779329383778,
"delta": 80,
"attester": "2915aac7c497b35d8d578f86c9e41afdd100c936a21a3d43489986c281b5cad6",
"signature": "O83yEh0dLmdDyO1wN/mQtD9aVMh32Pag40gL9zY3I1JfW7mozlYiTz6SOzyxRYolzo8fNmYG5Sa63WhhF7sJAQ==",
"metadata": {}
}
]
},
"lastPersist": 1779329383778
}
@@ -0,0 +1,37 @@
{
"scores": {
"snapshot-peer-1": {
"score": 120,
"lastUpdated": 1779330024097,
"attestationsCount": 1
},
"snapshot-peer-2": {
"score": 80,
"lastUpdated": 1779330024097,
"attestationsCount": 1
}
},
"history": {
"snapshot-peer-1": [
{
"ts": 1779330024097,
"delta": 120,
"attester": "2f475bff4d726225bafe133ab22e1006b73e9ae7aab0816649e5d22c896c2e2d",
"signature": "5b/l4w6GsC6VoAvf+mZrpZTkQgbcI72OsceN9QsHVhY6iJqNlM9bHhVH/r/Stasn9RrSKaCHZwdquESZsqX1BA==",
"metadata": {
"category": "reliability"
}
}
],
"snapshot-peer-2": [
{
"ts": 1779330024097,
"delta": 80,
"attester": "2f475bff4d726225bafe133ab22e1006b73e9ae7aab0816649e5d22c896c2e2d",
"signature": "6no/s/Sy2s5LZZZ5giBZ3WQ1sZaUp72PjByIGjxA8yNIO3mtyINFBJ6QSS0CPN/CtSek8IDtbuhKXwLS4KmDDg==",
"metadata": {}
}
]
},
"lastPersist": 1779330024098
}
@@ -0,0 +1,37 @@
{
"scores": {
"snapshot-peer-1": {
"score": 120,
"lastUpdated": 1779330409306,
"attestationsCount": 1
},
"snapshot-peer-2": {
"score": 80,
"lastUpdated": 1779330409306,
"attestationsCount": 1
}
},
"history": {
"snapshot-peer-1": [
{
"ts": 1779330409306,
"delta": 120,
"attester": "281550b6467b5c1f5d65f91fe6b9dbbc03d80aa10dd4cf1173b0bf9eb6bd7531",
"signature": "pe/8Vy+s26IFgCThTzY89UdBnymDBbOcL5si8q9/jFBGRmeBVw/3ywBBaWP4rD54PjMU0AkhLETP2Qr7XpfFCg==",
"metadata": {
"category": "reliability"
}
}
],
"snapshot-peer-2": [
{
"ts": 1779330409306,
"delta": 80,
"attester": "281550b6467b5c1f5d65f91fe6b9dbbc03d80aa10dd4cf1173b0bf9eb6bd7531",
"signature": "KqxnLj+J0V5UXBmvJ7tbE6dlBayFOsvDsxugya+aWe7oS2+IBIsIufpuM35gygrz+vad/h3pUUxBiAZUjHT0DQ==",
"metadata": {}
}
]
},
"lastPersist": 1779330409307
}
@@ -0,0 +1,37 @@
{
"scores": {
"snapshot-peer-1": {
"score": 120,
"lastUpdated": 1779329383777,
"attestationsCount": 1
},
"snapshot-peer-2": {
"score": 80,
"lastUpdated": 1779329383778,
"attestationsCount": 1
}
},
"history": {
"snapshot-peer-1": [
{
"ts": 1779329383777,
"delta": 120,
"attester": "2915aac7c497b35d8d578f86c9e41afdd100c936a21a3d43489986c281b5cad6",
"signature": "dkraEp27NIAmBgtSXrf3fGg4QK9ETES37T6uq682E5/BR0fz9g13WqNLovoc6WK8aCsXQT6rOs6kkoos/7B5DA==",
"metadata": {
"category": "reliability"
}
}
],
"snapshot-peer-2": [
{
"ts": 1779329383778,
"delta": 80,
"attester": "2915aac7c497b35d8d578f86c9e41afdd100c936a21a3d43489986c281b5cad6",
"signature": "O83yEh0dLmdDyO1wN/mQtD9aVMh32Pag40gL9zY3I1JfW7mozlYiTz6SOzyxRYolzo8fNmYG5Sa63WhhF7sJAQ==",
"metadata": {}
}
]
},
"lastPersist": 1779329383779
}
@@ -0,0 +1,37 @@
{
"scores": {
"snapshot-peer-1": {
"score": 120,
"lastUpdated": 1779330024097,
"attestationsCount": 1
},
"snapshot-peer-2": {
"score": 80,
"lastUpdated": 1779330024097,
"attestationsCount": 1
}
},
"history": {
"snapshot-peer-1": [
{
"ts": 1779330024097,
"delta": 120,
"attester": "2f475bff4d726225bafe133ab22e1006b73e9ae7aab0816649e5d22c896c2e2d",
"signature": "5b/l4w6GsC6VoAvf+mZrpZTkQgbcI72OsceN9QsHVhY6iJqNlM9bHhVH/r/Stasn9RrSKaCHZwdquESZsqX1BA==",
"metadata": {
"category": "reliability"
}
}
],
"snapshot-peer-2": [
{
"ts": 1779330024097,
"delta": 80,
"attester": "2f475bff4d726225bafe133ab22e1006b73e9ae7aab0816649e5d22c896c2e2d",
"signature": "6no/s/Sy2s5LZZZ5giBZ3WQ1sZaUp72PjByIGjxA8yNIO3mtyINFBJ6QSS0CPN/CtSek8IDtbuhKXwLS4KmDDg==",
"metadata": {}
}
]
},
"lastPersist": 1779330024098
}
@@ -0,0 +1,37 @@
{
"scores": {
"snapshot-peer-1": {
"score": 120,
"lastUpdated": 1779330409306,
"attestationsCount": 1
},
"snapshot-peer-2": {
"score": 80,
"lastUpdated": 1779330409306,
"attestationsCount": 1
}
},
"history": {
"snapshot-peer-1": [
{
"ts": 1779330409306,
"delta": 120,
"attester": "281550b6467b5c1f5d65f91fe6b9dbbc03d80aa10dd4cf1173b0bf9eb6bd7531",
"signature": "pe/8Vy+s26IFgCThTzY89UdBnymDBbOcL5si8q9/jFBGRmeBVw/3ywBBaWP4rD54PjMU0AkhLETP2Qr7XpfFCg==",
"metadata": {
"category": "reliability"
}
}
],
"snapshot-peer-2": [
{
"ts": 1779330409306,
"delta": 80,
"attester": "281550b6467b5c1f5d65f91fe6b9dbbc03d80aa10dd4cf1173b0bf9eb6bd7531",
"signature": "KqxnLj+J0V5UXBmvJ7tbE6dlBayFOsvDsxugya+aWe7oS2+IBIsIufpuM35gygrz+vad/h3pUUxBiAZUjHT0DQ==",
"metadata": {}
}
]
},
"lastPersist": 1779330409307
}