@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 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
@@ -0,0 +1,6 @@
1
+ export { stats } from './stats.js'
2
+ export { redact } from './redact.js'
3
+ export { devtools } from './tap.js'
4
+ export { serve } from './server.js'
5
+ export { connect } from './connect.js'
6
+ export { loopback, dial } from './transport.js'
@@ -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
+ }
@@ -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
+ }