Updates
This commit is contained in:
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
+23
@@ -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
|
||||||
|
}
|
||||||
+23
@@ -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
|
||||||
|
}
|
||||||
+23
@@ -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
|
||||||
|
}
|
||||||
+21
@@ -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
|
||||||
|
}
|
||||||
+21
@@ -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
|
||||||
|
}
|
||||||
+21
@@ -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
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"scores": {},
|
||||||
|
"history": {},
|
||||||
|
"lastPersist": 1779329383700
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"scores": {},
|
||||||
|
"history": {},
|
||||||
|
"lastPersist": 1779330024028
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"scores": {},
|
||||||
|
"history": {},
|
||||||
|
"lastPersist": 1779330409237
|
||||||
|
}
|
||||||
+21
@@ -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
|
||||||
|
}
|
||||||
+21
@@ -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
|
||||||
|
}
|
||||||
+21
@@ -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
|
||||||
|
}
|
||||||
+35
@@ -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
|
||||||
|
}
|
||||||
+35
@@ -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
|
||||||
|
}
|
||||||
+35
@@ -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
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"scores": {},
|
||||||
|
"history": {},
|
||||||
|
"lastPersist": 1779329383772
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"scores": {},
|
||||||
|
"history": {},
|
||||||
|
"lastPersist": 1779330024093
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"scores": {},
|
||||||
|
"history": {},
|
||||||
|
"lastPersist": 1779330409302
|
||||||
|
}
|
||||||
+37
@@ -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
|
||||||
|
}
|
||||||
+37
@@ -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
|
||||||
|
}
|
||||||
+37
@@ -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
|
||||||
|
}
|
||||||
+37
@@ -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
|
||||||
|
}
|
||||||
+37
@@ -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
|
||||||
|
}
|
||||||
+37
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user