@cero-base/tools 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -0
- package/bin/cero-tools.js +33 -0
- package/package.json +47 -0
- package/src/connect.js +106 -0
- package/src/format.js +71 -0
- package/src/index.js +6 -0
- package/src/protocol.js +62 -0
- package/src/redact.js +44 -0
- package/src/server.js +153 -0
- package/src/stats.js +45 -0
- package/src/tap.js +105 -0
- package/src/transport.js +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @cero-base/tools
|
|
2
|
+
|
|
3
|
+
Passive devtools for [cero](https://www.npmjs.com/package/@cero-base/cero) — observe a running instance's **data**, its **event log**, and its **p2p/replication stats** from another process, without ever touching the swarm it measures.
|
|
4
|
+
|
|
5
|
+
A `cero.use(devtools())` extension taps the instance over a **local out-of-band pipe** (never the app's hyperswarm), so connection counts, core lengths, and replication numbers stay exactly as they'd be with no devtools attached. Read-only by construction; identity secrets are structurally unreachable.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @cero-base/tools
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Agent side — attach the tap
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { cero } from '@cero-base/cero'
|
|
17
|
+
import { devtools, loopback } from '@cero-base/tools'
|
|
18
|
+
|
|
19
|
+
cero.use(devtools({ transport: loopback({ port: 9111 }) }))
|
|
20
|
+
// ...then create your instance as usual; the tap follows it and every child handle.
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`devtools(opts)`:
|
|
24
|
+
|
|
25
|
+
| opt | default | meaning |
|
|
26
|
+
| ---------------- | ------- | ----------------------------------------------------------------------------------- |
|
|
27
|
+
| `transport` | — | a `{ accept(handler) }` source of consumer connections (use `loopback({ port })`) |
|
|
28
|
+
| `bufferSize` | `1000` | event ring buffer size (drop-oldest) |
|
|
29
|
+
| `sampleInterval` | `1000` | stats sampling interval (ms) |
|
|
30
|
+
| `redact` | — | a `(ref, row) => row` masker, or a config `{ fields, match, deny }` (see Redaction) |
|
|
31
|
+
|
|
32
|
+
Zero footprint when not registered; all cleanup is bound to the root handle's `close`.
|
|
33
|
+
|
|
34
|
+
## Consumer side — connect
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import { connect, dial } from '@cero-base/tools'
|
|
38
|
+
|
|
39
|
+
const session = await connect(dial({ port: 9111 }))
|
|
40
|
+
|
|
41
|
+
await session.handles() // [{ id, type }, ...] — root + open children
|
|
42
|
+
await session.get('messages') // { data, total, size } — reuses cero's read operators
|
|
43
|
+
await session.count('members') // number
|
|
44
|
+
session.watch('messages') // Readable of live snapshots
|
|
45
|
+
session.events() // Readable of { op, name, row, writerKey, seq } — local AND remote
|
|
46
|
+
session.stats() // Readable of { network, bee, cores }
|
|
47
|
+
session.close()
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## CLI
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
cero-tools <port> [refs...]
|
|
54
|
+
# prints the handle tree + state for each ref, then tails events + stats until Ctrl-C
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Redaction
|
|
58
|
+
|
|
59
|
+
App rows can hold anything, so `devtools({ redact })` masks matched fields in both snapshots and events — content hidden, shape kept (`‹redacted:bytes(32)›`):
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
devtools({ redact: {} }) // default denylist: seed/secret/token/private/...
|
|
63
|
+
devtools({ redact: { fields: ['profile.apiKey'] } })
|
|
64
|
+
devtools({ redact: { match: (key) => key.endsWith('Secret') } })
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Identity credentials (the seed and keypairs) need no redaction: they're `local` builtins and the inspection surface only serves declared `meta.refs`, so they're never reachable.
|
|
68
|
+
|
|
69
|
+
## API
|
|
70
|
+
|
|
71
|
+
`devtools(opts)` · `serve(stream, me, opts)` · `connect(stream)` · `stats(handle)` · `redact(config)` · `loopback(opts)` · `dial(opts)`
|
|
72
|
+
|
|
73
|
+
## Status
|
|
74
|
+
|
|
75
|
+
MVP: local-pipe transport, `get`/`watch`/`count` + events + sampled stats, redaction, a CLI. Deferred to later phases: a whitelisted swarm transport, a read-only replication / time-travel plane, and an MCP/UI consumer.
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
Apache-2.0
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { dial } from '../src/transport.js'
|
|
3
|
+
import { connect } from '../src/connect.js'
|
|
4
|
+
import { formatHandles, formatState, formatEvent, formatStats } from '../src/format.js'
|
|
5
|
+
|
|
6
|
+
const arg = process.argv[2]
|
|
7
|
+
if (!arg) {
|
|
8
|
+
console.log('usage: cero-tools [host:]<port> [refs...]')
|
|
9
|
+
process.exit(1)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const [hostPart, portPart] = arg.includes(':') ? arg.split(':') : [undefined, arg]
|
|
13
|
+
const host = hostPart
|
|
14
|
+
const port = Number(portPart)
|
|
15
|
+
|
|
16
|
+
const socket = dial({ host, port })
|
|
17
|
+
const session = await connect(socket)
|
|
18
|
+
|
|
19
|
+
console.log(formatHandles(await session.handles()))
|
|
20
|
+
|
|
21
|
+
for (const ref of process.argv.slice(3)) {
|
|
22
|
+
console.log(formatState(ref, await session.get(ref)))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const events = session.events()
|
|
26
|
+
const live = session.stats()
|
|
27
|
+
events.on('data', (e) => console.log(formatEvent(e)))
|
|
28
|
+
live.on('data', (s) => console.log(formatStats(s)))
|
|
29
|
+
|
|
30
|
+
process.on('SIGINT', () => {
|
|
31
|
+
session.close()
|
|
32
|
+
process.exit(0)
|
|
33
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cero-base/tools",
|
|
3
|
+
"version": "0.8.4",
|
|
4
|
+
"description": "Passive devtools for cero — observe data, events, and p2p/replication stats over a local out-of-band pipe.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"cero-tools": "./bin/cero-tools.js"
|
|
9
|
+
},
|
|
10
|
+
"imports": {
|
|
11
|
+
"net": {
|
|
12
|
+
"bare": "bare-net",
|
|
13
|
+
"default": "net"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"bin",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./src/index.js",
|
|
27
|
+
"./connect": "./src/connect.js",
|
|
28
|
+
"./stats": "./src/stats.js"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "ls test/*.test.js | xargs -P1 -n1 brittle-node"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@cero-base/cero": "^0.8.4",
|
|
35
|
+
"@cero-base/core": "^0.8.4",
|
|
36
|
+
"b4a": "^1.8.1",
|
|
37
|
+
"bare-net": "^2.3.1",
|
|
38
|
+
"compact-encoding": "^3.1.0",
|
|
39
|
+
"streamx": "^2.22.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@hyperswarm/testnet": "^3.1.4",
|
|
43
|
+
"brittle": "^4.0.0",
|
|
44
|
+
"typescript": "^5.7.0"
|
|
45
|
+
},
|
|
46
|
+
"license": "Apache-2.0"
|
|
47
|
+
}
|
package/src/connect.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Readable } from 'streamx'
|
|
2
|
+
|
|
3
|
+
import { Framed } from './protocol.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Consumer SDK for a tap server reached over a local Duplex `stream`. Returns
|
|
7
|
+
* a session exposing read methods (`handles`/`get`/`count`) and stream methods
|
|
8
|
+
* (`watch`/`events`/`stats`). Stream methods return a streamx Readable that
|
|
9
|
+
* cancels its server-side source when destroyed.
|
|
10
|
+
*
|
|
11
|
+
* @param {any} stream A streamx Duplex connected to a tap server.
|
|
12
|
+
* @returns {Promise<{ handles(): Promise<any>, get(ref: string, query?: any, handleId?: string): Promise<any>, count(ref: string, query?: any, handleId?: string): Promise<any>, watch(ref: string, query?: any, handleId?: string): import('streamx').Readable, events(): import('streamx').Readable, stats(): import('streamx').Readable, close(): void }>}
|
|
13
|
+
*/
|
|
14
|
+
export async function connect(stream) {
|
|
15
|
+
return new Session(stream)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class Session {
|
|
19
|
+
constructor(stream) {
|
|
20
|
+
this.stream = stream
|
|
21
|
+
this.pending = new Map()
|
|
22
|
+
this.streams = new Map()
|
|
23
|
+
this.seq = 0
|
|
24
|
+
this.wire = new Framed(stream, (msg) => this._onMessage(msg))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_onMessage(msg) {
|
|
28
|
+
if ('frame' in msg || msg.end) {
|
|
29
|
+
const r = this.streams.get(msg.id)
|
|
30
|
+
if (!r) return
|
|
31
|
+
if (msg.end) {
|
|
32
|
+
this.streams.delete(msg.id)
|
|
33
|
+
r.push(null)
|
|
34
|
+
} else {
|
|
35
|
+
r.push(msg.frame)
|
|
36
|
+
}
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
const p = this.pending.get(msg.id)
|
|
40
|
+
if (!p) return
|
|
41
|
+
this.pending.delete(msg.id)
|
|
42
|
+
if (msg.ok === false) p.reject(new TapError(msg.error, msg.code))
|
|
43
|
+
else p.resolve(msg.data)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_newId() {
|
|
47
|
+
return ++this.seq
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_request(method, fields) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const id = this._newId()
|
|
53
|
+
this.pending.set(id, { resolve, reject })
|
|
54
|
+
this.wire.send({ id, method, ...fields })
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_stream(method, fields) {
|
|
59
|
+
const id = this._newId()
|
|
60
|
+
const r = new Readable({
|
|
61
|
+
destroy: (cb) => {
|
|
62
|
+
if (this.streams.delete(id))
|
|
63
|
+
this.wire.send({ id: this._newId(), method: 'cancel', cancelId: id })
|
|
64
|
+
cb(null)
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
this.streams.set(id, r)
|
|
68
|
+
this.wire.send({ id, method, ...fields })
|
|
69
|
+
return r
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
handles() {
|
|
73
|
+
return this._request('handles', {})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get(ref, query, handleId) {
|
|
77
|
+
return this._request('get', { ref, query, handleId })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
count(ref, query, handleId) {
|
|
81
|
+
return this._request('count', { ref, query, handleId })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
watch(ref, query, handleId) {
|
|
85
|
+
return this._stream('watch', { ref, query, handleId })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
events() {
|
|
89
|
+
return this._stream('events', {})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
stats() {
|
|
93
|
+
return this._stream('stats', {})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
close() {
|
|
97
|
+
this.stream.end()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
class TapError extends Error {
|
|
102
|
+
constructor(message, code) {
|
|
103
|
+
super(message)
|
|
104
|
+
this.code = code
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/format.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import b4a from 'b4a'
|
|
2
|
+
|
|
3
|
+
const ROW_MAX = 80
|
|
4
|
+
|
|
5
|
+
function shortRow(row) {
|
|
6
|
+
if (row == null) return ''
|
|
7
|
+
const s = JSON.stringify(row)
|
|
8
|
+
return s.length > ROW_MAX ? s.slice(0, ROW_MAX - 1) + '…' : s
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function shortKey(key) {
|
|
12
|
+
if (key == null) return ''
|
|
13
|
+
if (b4a.isBuffer(key) || key instanceof Uint8Array) return b4a.toString(key, 'hex').slice(0, 8)
|
|
14
|
+
return String(key).slice(0, 8)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Render a handle tree (`[{ id, type }]`) as a short list.
|
|
19
|
+
*
|
|
20
|
+
* @param {Array<{ id: string, type: string | null }>} tree
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function formatHandles(tree) {
|
|
24
|
+
if (!tree || !tree.length) return 'handles: (none)'
|
|
25
|
+
const lines = tree.map((h) => ` ${h.type || 'root'} ${h.id}`)
|
|
26
|
+
return `handles (${tree.length}):\n${lines.join('\n')}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Render a `get()` reply — `{ data, total, size }` for collections or
|
|
31
|
+
* `{ data }` for single refs — as a compact summary.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} ref
|
|
34
|
+
* @param {{ data: any, total?: number, size?: number }} result
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function formatState(ref, result) {
|
|
38
|
+
const data = result?.data
|
|
39
|
+
if (Array.isArray(data)) {
|
|
40
|
+
const total = result.total ?? data.length
|
|
41
|
+
const head = data.slice(0, 3).map(shortRow)
|
|
42
|
+
return `${ref} (${total}):\n${head.map((r) => ' ' + r).join('\n')}`
|
|
43
|
+
}
|
|
44
|
+
return `${ref}: ${shortRow(data)}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Render one applied-op event as a single line.
|
|
49
|
+
*
|
|
50
|
+
* @param {{ op: string, name: string, row: any, writerKey?: any, seq?: number }} e
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function formatEvent(e) {
|
|
54
|
+
const writer = shortKey(e.writerKey)
|
|
55
|
+
const tail = writer ? ` (${writer})` : ''
|
|
56
|
+
return `#${e.seq ?? '?'} ${e.op} ${e.name} ${shortRow(e.row)}${tail}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Render a stats snapshot as a single line.
|
|
61
|
+
*
|
|
62
|
+
* @param {{ network?: { connections: number, peers: number }, bee?: { local: number }, cores?: any[] }} s
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function formatStats(s) {
|
|
66
|
+
const conns = s?.network?.connections ?? 0
|
|
67
|
+
const peers = s?.network?.peers ?? 0
|
|
68
|
+
const local = s?.bee?.local ?? 0
|
|
69
|
+
const cores = s?.cores?.length ?? 0
|
|
70
|
+
return `net ${conns}c/${peers}p · bee ${local} · cores ${cores}`
|
|
71
|
+
}
|
package/src/index.js
ADDED
package/src/protocol.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import b4a from 'b4a'
|
|
2
|
+
|
|
3
|
+
const BYTES = '$b'
|
|
4
|
+
|
|
5
|
+
function toWire(v) {
|
|
6
|
+
if (b4a.isBuffer(v) || v instanceof Uint8Array) return { [BYTES]: b4a.toString(v, 'hex') }
|
|
7
|
+
if (Array.isArray(v)) return v.map(toWire)
|
|
8
|
+
if (v && typeof v === 'object') {
|
|
9
|
+
const o = {}
|
|
10
|
+
for (const k of Object.keys(v)) o[k] = toWire(v[k])
|
|
11
|
+
return o
|
|
12
|
+
}
|
|
13
|
+
return v
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fromWire(v) {
|
|
17
|
+
if (Array.isArray(v)) return v.map(fromWire)
|
|
18
|
+
if (v && typeof v === 'object') {
|
|
19
|
+
if (typeof v[BYTES] === 'string') return b4a.from(v[BYTES], 'hex')
|
|
20
|
+
const o = {}
|
|
21
|
+
for (const k of Object.keys(v)) o[k] = fromWire(v[k])
|
|
22
|
+
return o
|
|
23
|
+
}
|
|
24
|
+
return v
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Length-prefixed JSON framing over a Duplex `stream`. Buffers incoming chunks
|
|
29
|
+
* and delivers each decoded frame to `onMessage`; `send` writes a framed frame.
|
|
30
|
+
*/
|
|
31
|
+
export class Framed {
|
|
32
|
+
constructor(stream, onMessage) {
|
|
33
|
+
this.stream = stream
|
|
34
|
+
this.onMessage = onMessage
|
|
35
|
+
this.buf = b4a.alloc(0)
|
|
36
|
+
stream.on('data', (chunk) => this._onData(chunk))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_onData(chunk) {
|
|
40
|
+
this.buf = b4a.concat([this.buf, chunk])
|
|
41
|
+
while (this.buf.length >= 4) {
|
|
42
|
+
const len = new DataView(this.buf.buffer, this.buf.byteOffset, 4).getUint32(0, true)
|
|
43
|
+
if (this.buf.length < 4 + len) break
|
|
44
|
+
const body = b4a.toString(this.buf.subarray(4, 4 + len))
|
|
45
|
+
this.buf = this.buf.subarray(4 + len)
|
|
46
|
+
let msg
|
|
47
|
+
try {
|
|
48
|
+
msg = fromWire(JSON.parse(body))
|
|
49
|
+
} catch {
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
this.onMessage(msg)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
send(obj) {
|
|
57
|
+
const body = b4a.from(JSON.stringify(toWire(obj)))
|
|
58
|
+
const head = b4a.alloc(4)
|
|
59
|
+
new DataView(head.buffer, head.byteOffset, 4).setUint32(0, body.length, true)
|
|
60
|
+
this.stream.write(b4a.concat([head, body]))
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/redact.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import b4a from 'b4a'
|
|
2
|
+
|
|
3
|
+
// Obvious secret-ish field names, masked by default once redaction is enabled.
|
|
4
|
+
const DEFAULT_DENY = /^(seed|secret|secretkey|password|passphrase|token|private|privatekey)$/i
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build a row masker for devtools. A field is masked when its dotted path is in
|
|
8
|
+
* `fields`, its name matches `deny` (default denylist above), or `match(key,
|
|
9
|
+
* value, path)` returns true. Masked values become a typed placeholder —
|
|
10
|
+
* `‹redacted:bytes(32)›` / `‹redacted:string›` — so the shape stays visible but
|
|
11
|
+
* the content is hidden. Everything else passes through untouched.
|
|
12
|
+
*
|
|
13
|
+
* @param {{ fields?: string[], deny?: RegExp | false, match?: (key: string, value: any, path: string) => boolean }} [config]
|
|
14
|
+
* @returns {(ref: string, row: any) => any}
|
|
15
|
+
*/
|
|
16
|
+
export function redact(config = {}) {
|
|
17
|
+
const fields = new Set(config.fields || [])
|
|
18
|
+
const deny = config.deny === false ? null : config.deny || DEFAULT_DENY
|
|
19
|
+
const match = typeof config.match === 'function' ? config.match : null
|
|
20
|
+
|
|
21
|
+
const isBytes = (v) => b4a.isBuffer(v) || v instanceof Uint8Array
|
|
22
|
+
const mask = (v) =>
|
|
23
|
+
isBytes(v) ? `‹redacted:bytes(${v.length})›` : `‹redacted:${v === null ? 'null' : typeof v}›`
|
|
24
|
+
|
|
25
|
+
const walk = (value, path) => {
|
|
26
|
+
if (isBytes(value)) return value
|
|
27
|
+
if (Array.isArray(value)) return value.map((v) => walk(v, path))
|
|
28
|
+
if (value && typeof value === 'object') {
|
|
29
|
+
const out = {}
|
|
30
|
+
for (const k of Object.keys(value)) {
|
|
31
|
+
const p = path ? `${path}.${k}` : k
|
|
32
|
+
const v = value[k]
|
|
33
|
+
out[k] =
|
|
34
|
+
fields.has(p) || (deny && deny.test(k)) || (match && match(k, v, p))
|
|
35
|
+
? mask(v)
|
|
36
|
+
: walk(v, p)
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
return value
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (ref, row) => (row && typeof row === 'object' && !isBytes(row) ? walk(row, '') : row)
|
|
44
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { get, count, watch } from '@cero-base/cero'
|
|
2
|
+
|
|
3
|
+
import { Framed } from './protocol.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Read-only inspection server over a local Duplex `stream`. Serves the live
|
|
7
|
+
* handle tree rooted at `me`, exposing only MAIN refs declared in each
|
|
8
|
+
* handle's `spec.meta.refs` — local secret refs (master/keypair/
|
|
9
|
+
* handle-keypairs) are never reachable, since they live in `meta.local.refs`.
|
|
10
|
+
*
|
|
11
|
+
* @param {any} stream A streamx Duplex carrying length-prefixed JSON frames.
|
|
12
|
+
* @param {any} me The live root cero handle.
|
|
13
|
+
* @param {{ events?: { snapshot(): any, subscribe(fn: (e: any) => void): () => void }, stats?: { snapshot(): any, subscribe(fn: (s: any) => void): () => void }, redact?: (ref: string, row: any) => any }} [opts]
|
|
14
|
+
* @returns {{ close(): void }}
|
|
15
|
+
*/
|
|
16
|
+
export function serve(stream, me, opts) {
|
|
17
|
+
return new TapServer(stream, me, opts)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class TapServer {
|
|
21
|
+
constructor(stream, me, opts = {}) {
|
|
22
|
+
this.stream = stream
|
|
23
|
+
this.me = me
|
|
24
|
+
this.redact = opts.redact || ((ref, row) => row)
|
|
25
|
+
this.opts = opts
|
|
26
|
+
this.tracked = new Map()
|
|
27
|
+
this.wire = new Framed(stream, (req) => this.onRequest(req))
|
|
28
|
+
this._cleanup = () => this._teardown()
|
|
29
|
+
stream.on('close', this._cleanup)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async onRequest(req) {
|
|
33
|
+
const handler = this[req.method]
|
|
34
|
+
if (!handler || !METHODS.has(req.method)) {
|
|
35
|
+
this._send({
|
|
36
|
+
id: req.id,
|
|
37
|
+
ok: false,
|
|
38
|
+
error: `unknown method '${req.method}'`,
|
|
39
|
+
code: 'UNKNOWN'
|
|
40
|
+
})
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
await handler.call(this, req)
|
|
45
|
+
} catch (err) {
|
|
46
|
+
this._send({ id: req.id, ok: false, error: String(err.message || err), code: err.code })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
handles(req) {
|
|
51
|
+
const me = this.me
|
|
52
|
+
const data = [
|
|
53
|
+
{ id: me.id, type: 'root' },
|
|
54
|
+
...[...(me.children || [])].map((c) => ({ id: c.id, type: c.type || null }))
|
|
55
|
+
]
|
|
56
|
+
this._send({ id: req.id, ok: true, data })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async get(req) {
|
|
60
|
+
const ref = this._resolveRef(req)
|
|
61
|
+
const r = await get(ref, req.query)
|
|
62
|
+
const data = Array.isArray(r.data)
|
|
63
|
+
? r.data.map((row) => this.redact(req.ref, row))
|
|
64
|
+
: r.data && this.redact(req.ref, r.data)
|
|
65
|
+
this._send({ id: req.id, ok: true, data: { ...r, data } })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async count(req) {
|
|
69
|
+
const ref = this._resolveRef(req)
|
|
70
|
+
this._send({ id: req.id, ok: true, data: (await count(ref, req.query)).data })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
watch(req) {
|
|
74
|
+
const ref = this._resolveRef(req)
|
|
75
|
+
const s = watch(ref, req.query)
|
|
76
|
+
this._track(req.id, () => s.destroy())
|
|
77
|
+
s.on('data', (d) => {
|
|
78
|
+
const data = Array.isArray(d.data) ? d.data.map((row) => this.redact(req.ref, row)) : d.data
|
|
79
|
+
this._send({ id: req.id, frame: { ...d, data } })
|
|
80
|
+
})
|
|
81
|
+
s.on('end', () => this._send({ id: req.id, end: true }))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
events(req) {
|
|
85
|
+
this._source(req, this.opts.events)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
stats(req) {
|
|
89
|
+
this._source(req, this.opts.stats)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
cancel(req) {
|
|
93
|
+
this._drop(req.cancelId)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_resolveRef(req) {
|
|
97
|
+
const me = this.me
|
|
98
|
+
const handle =
|
|
99
|
+
req.handleId === me.id || !req.handleId
|
|
100
|
+
? me
|
|
101
|
+
: [...(me.children || [])].find((c) => c.id === req.handleId)
|
|
102
|
+
const info = handle?.spec?.meta?.refs?.[req.ref]
|
|
103
|
+
if (!handle || !info) throw new CodeError('UNKNOWN', `unknown ref '${req.ref}'`)
|
|
104
|
+
return handle[req.ref]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
_source(req, src) {
|
|
108
|
+
if (!src) {
|
|
109
|
+
this._send({ id: req.id, end: true })
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
const snap = src.snapshot()
|
|
113
|
+
const items = Array.isArray(snap) ? snap : snap == null ? [] : [snap]
|
|
114
|
+
for (const item of items) this._send({ id: req.id, frame: item })
|
|
115
|
+
const off = src.subscribe((e) => this._send({ id: req.id, frame: e }))
|
|
116
|
+
this._track(req.id, off)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_track(id, off) {
|
|
120
|
+
this.tracked.set(id, off)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
_drop(id) {
|
|
124
|
+
const off = this.tracked.get(id)
|
|
125
|
+
if (off) {
|
|
126
|
+
this.tracked.delete(id)
|
|
127
|
+
off()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_send(obj) {
|
|
132
|
+
this.wire.send(obj)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
_teardown() {
|
|
136
|
+
for (const off of this.tracked.values()) off()
|
|
137
|
+
this.tracked.clear()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
close() {
|
|
141
|
+
this._teardown()
|
|
142
|
+
this.stream.destroy()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const METHODS = new Set(['handles', 'get', 'count', 'watch', 'events', 'stats', 'cancel'])
|
|
147
|
+
|
|
148
|
+
class CodeError extends Error {
|
|
149
|
+
constructor(code, message) {
|
|
150
|
+
super(message)
|
|
151
|
+
this.code = code
|
|
152
|
+
}
|
|
153
|
+
}
|
package/src/stats.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only snapshot of a handle's existing p2p/storage counters. Pure: no
|
|
3
|
+
* listeners, no mutation, never reads a secret. `dht.stats` and the corestore
|
|
4
|
+
* `cores` map are undocumented internals, so they are guarded — a fresh handle
|
|
5
|
+
* (pre-ready) yields zeros rather than throwing.
|
|
6
|
+
*
|
|
7
|
+
* @param {any} handle A cero root or child handle.
|
|
8
|
+
* @param {number} [at] Sample timestamp, stamped by the caller.
|
|
9
|
+
* @returns {{ handleId: string, network: { connections: number, peers: number, dht: any }, bee: { local: number }, cores: Array<{ length: number, byteLength: number, peers: number }>, at: number }}
|
|
10
|
+
*/
|
|
11
|
+
export function stats(handle, at = 0) {
|
|
12
|
+
const net = handle.network
|
|
13
|
+
const bee = handle.store?.bee
|
|
14
|
+
|
|
15
|
+
const cores = []
|
|
16
|
+
try {
|
|
17
|
+
const map = handle.store?.store?.cores
|
|
18
|
+
if (map) {
|
|
19
|
+
for (const core of map.values()) {
|
|
20
|
+
cores.push({
|
|
21
|
+
length: core.length ?? 0,
|
|
22
|
+
byteLength: core.byteLength ?? 0,
|
|
23
|
+
peers: core.peers?.length ?? 0
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
} catch {}
|
|
28
|
+
|
|
29
|
+
let dht = null
|
|
30
|
+
try {
|
|
31
|
+
dht = net?.swarm?.dht?.stats ?? null
|
|
32
|
+
} catch {}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
handleId: handle.id,
|
|
36
|
+
network: {
|
|
37
|
+
connections: net?.connections?.size ?? 0,
|
|
38
|
+
peers: net?.peers?.size ?? 0,
|
|
39
|
+
dht
|
|
40
|
+
},
|
|
41
|
+
bee: { local: bee?.local?.length ?? 0 },
|
|
42
|
+
cores,
|
|
43
|
+
at
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/tap.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { stats } from './stats.js'
|
|
2
|
+
import { serve } from './server.js'
|
|
3
|
+
import { redact } from './redact.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `cero.use(devtools())` tap extension. Bound to the root handle's lifecycle,
|
|
7
|
+
* it feeds every applied op into a bounded ring buffer, samples `stats(me)` on
|
|
8
|
+
* an interval, and — if given a `transport` — serves a read-only inspection
|
|
9
|
+
* surface to out-of-band consumers. Nothing here touches the app swarm.
|
|
10
|
+
*
|
|
11
|
+
* @param {{ bufferSize?: number, sampleInterval?: number, redact?: ((ref: string, row: any) => any) | { fields?: string[], match?: Function, deny?: RegExp | false }, transport?: { accept(handler: (stream: any) => void): void } }} [opts]
|
|
12
|
+
* @returns {{ setup(me: any): () => void }}
|
|
13
|
+
*/
|
|
14
|
+
export function devtools(opts = {}) {
|
|
15
|
+
return {
|
|
16
|
+
setup(me) {
|
|
17
|
+
const events = new Ring(opts.bufferSize ?? 1000)
|
|
18
|
+
const sampler = new Sampler(me, opts.sampleInterval ?? 1000)
|
|
19
|
+
const redactor = makeRedactor(opts.redact)
|
|
20
|
+
const offs = []
|
|
21
|
+
const servers = new Set()
|
|
22
|
+
|
|
23
|
+
const follow = (handle) => {
|
|
24
|
+
const off = handle.store.onApply((e) =>
|
|
25
|
+
events.push(redactor ? { ...e, row: redactor(e.name, e.row) } : e)
|
|
26
|
+
)
|
|
27
|
+
offs.push(off)
|
|
28
|
+
if (handle !== me) handle.once('close', off)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
follow(me)
|
|
32
|
+
for (const child of me.children) follow(child)
|
|
33
|
+
me.on('handle', (child) => follow(child), { signal: me.signal })
|
|
34
|
+
|
|
35
|
+
if (opts.transport) {
|
|
36
|
+
opts.transport.accept((stream) => {
|
|
37
|
+
const server = serve(stream, me, { events, stats: sampler, redact: redactor })
|
|
38
|
+
servers.add(server)
|
|
39
|
+
stream.on('close', () => servers.delete(server))
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return () => {
|
|
44
|
+
sampler.stop()
|
|
45
|
+
for (const off of offs) off()
|
|
46
|
+
for (const server of servers) server.close()
|
|
47
|
+
servers.clear()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeRedactor(opt) {
|
|
54
|
+
if (!opt) return null
|
|
55
|
+
return typeof opt === 'function' ? opt : redact(opt)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Bounded ring buffer with snapshot + live subscription. */
|
|
59
|
+
class Ring {
|
|
60
|
+
constructor(max) {
|
|
61
|
+
this.max = max
|
|
62
|
+
this.items = []
|
|
63
|
+
this.subs = new Set()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
push(e) {
|
|
67
|
+
this.items.push(e)
|
|
68
|
+
if (this.items.length > this.max) this.items.shift()
|
|
69
|
+
for (const fn of this.subs) fn(e)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
snapshot() {
|
|
73
|
+
return this.items.slice()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
subscribe(fn) {
|
|
77
|
+
this.subs.add(fn)
|
|
78
|
+
return () => this.subs.delete(fn)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Periodic `stats(me)` sampler with snapshot + live subscription. */
|
|
83
|
+
class Sampler {
|
|
84
|
+
constructor(me, interval) {
|
|
85
|
+
this.subs = new Set()
|
|
86
|
+
this.latest = stats(me, Date.now())
|
|
87
|
+
this.timer = setInterval(() => {
|
|
88
|
+
this.latest = stats(me, Date.now())
|
|
89
|
+
for (const fn of this.subs) fn(this.latest)
|
|
90
|
+
}, interval)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
snapshot() {
|
|
94
|
+
return this.latest
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
subscribe(fn) {
|
|
98
|
+
this.subs.add(fn)
|
|
99
|
+
return () => this.subs.delete(fn)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
stop() {
|
|
103
|
+
clearInterval(this.timer)
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/transport.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import net from 'net'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Loopback TCP transport implementing the tap `{ accept(handler) }` contract
|
|
5
|
+
* plus lifecycle. Each inbound connection's socket is a streamx-compatible
|
|
6
|
+
* Duplex, ready to pass straight to `serve()`.
|
|
7
|
+
*
|
|
8
|
+
* @param {{ port?: number, host?: string }} [opts]
|
|
9
|
+
* @returns {{ accept(handler: (socket: any) => void): void, ready(): Promise<{ port: number }>, port: number | null, close(): void }}
|
|
10
|
+
*/
|
|
11
|
+
export function loopback(opts = {}) {
|
|
12
|
+
const host = opts.host ?? '127.0.0.1'
|
|
13
|
+
let server = null
|
|
14
|
+
return {
|
|
15
|
+
accept(handler) {
|
|
16
|
+
server = net.createServer((socket) => handler(socket))
|
|
17
|
+
server.listen(opts.port ?? 0, host)
|
|
18
|
+
},
|
|
19
|
+
ready() {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
if (!server) return reject(new Error('accept() not called'))
|
|
22
|
+
if (server.listening) return resolve({ port: server.address().port })
|
|
23
|
+
server.once('listening', () => resolve({ port: server.address().port }))
|
|
24
|
+
server.once('error', reject)
|
|
25
|
+
})
|
|
26
|
+
},
|
|
27
|
+
get port() {
|
|
28
|
+
return server?.listening ? server.address().port : null
|
|
29
|
+
},
|
|
30
|
+
close() {
|
|
31
|
+
server?.close()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Dial a loopback tap server and return the connected socket, ready for
|
|
38
|
+
* `connect()`.
|
|
39
|
+
*
|
|
40
|
+
* @param {{ port: number, host?: string }} [opts]
|
|
41
|
+
* @returns {any} A streamx-compatible Duplex socket.
|
|
42
|
+
*/
|
|
43
|
+
export function dial(opts = {}) {
|
|
44
|
+
return net.connect(opts.port, opts.host ?? '127.0.0.1')
|
|
45
|
+
}
|