hrpc-inspector 0.1.0 → 0.1.1

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.
@@ -0,0 +1,10 @@
1
+ export type Runtime = 'pear' | 'bare' | 'react-native' | 'electron' | 'node' | 'browser' | 'unknown';
2
+ export interface RuntimeInfo {
3
+ runtime: Runtime;
4
+ /** Bare is available (Pear, plain Bare, or an RN bare-kit worklet) → hyperswarm can run in-process. */
5
+ isBareRuntime: boolean;
6
+ /** A browser/RN JS context where we must export over a bridge, not a socket. */
7
+ isBridgeOnly: boolean;
8
+ }
9
+ /** Pure and injectable (pass a fake global in tests). Order matters: most specific first. */
10
+ export declare function detectRuntime(env?: any): RuntimeInfo;
@@ -0,0 +1,16 @@
1
+ export { observe, DEFAULT_TOPIC } from './observe.js';
2
+ export type { ObserveOptions, ObserveHandle } from './observe.js';
3
+ export { detectRuntime } from './detect.js';
4
+ export type { Runtime, RuntimeInfo } from './detect.js';
5
+ export { wrapClient } from './wrap-client.js';
6
+ export type { Report, WrapClientOptions } from './wrap-client.js';
7
+ export { createWebSocketReporter } from './reporters.js';
8
+ export type { Reporter, WebSocketReporterOptions } from './reporters.js';
9
+ export { instrumentDataChannel, instrumentPeerConnection, } from 'hrpc-inspector-probe/adapters/webrtc';
10
+ export { instrumentWebSocket } from 'hrpc-inspector-probe/adapters/websocket';
11
+ export { instrumentHyperswarmStream } from 'hrpc-inspector-probe/adapters/hyperswarm';
12
+ export { CollectorSink } from 'hrpc-inspector-probe/core/sink';
13
+ export { BatchFlusher } from 'hrpc-inspector-probe/flush';
14
+ export { createHyperswarmExporter } from 'hrpc-inspector-probe/exporters/hyperswarm';
15
+ export { StreamFramer, encodeFrame } from 'hrpc-inspector-probe/transport/framing';
16
+ export type { EventSink, L2Event, L2EventType } from 'hrpc-inspector-probe/sink';
package/dist/index.js CHANGED
@@ -247,7 +247,8 @@ async function autoSwarm(topic, onConnection) {
247
247
  const Hyperswarm = (await import("hyperswarm")).default;
248
248
  const crypto = (await import("hypercore-crypto")).default;
249
249
  const swarm = new Hyperswarm();
250
- const topicKey = crypto.hash(Buffer.from(topic));
250
+ const enc = new TextEncoder();
251
+ const topicKey = crypto.hash(enc.encode(topic));
251
252
  swarm.on("connection", (conn) => onConnection(conn));
252
253
  await swarm.join(topicKey, { client: true, server: false }).flushed();
253
254
  }
@@ -0,0 +1,119 @@
1
+ import { BatchFlusher } from 'hrpc-inspector-probe/flush';
2
+ import type { WritableStreamLike, HyperswarmExporter } from 'hrpc-inspector-probe/exporters/hyperswarm';
3
+ import { Redactor } from 'hrpc-inspector-probe/redactor';
4
+ import type { EventSink, L2Event } from 'hrpc-inspector-probe/sink';
5
+ import type { Runtime } from './detect.js';
6
+ import type { Report, WrapClientOptions } from './wrap-client.js';
7
+ export declare const DEFAULT_TOPIC = "p2p-observability-hub:v0";
8
+ export interface SourceInfo {
9
+ /** Stable id used to group/select this source in the GUI. */
10
+ id: string;
11
+ /** Human label shown in the selector. */
12
+ label: string;
13
+ /** Detected runtime (react-native | pear | bare | electron | node | browser). */
14
+ runtime: string;
15
+ /** Physical device id (client-supplied; e.g. an app's own options.deviceId). Lets the GUI select by device. */
16
+ deviceId?: string;
17
+ /** App / install instance id — distinguishes MULTIPLE apps sharing one package. */
18
+ appId?: string;
19
+ [extra: string]: unknown;
20
+ }
21
+ export interface ObserveOptions {
22
+ /** Flush cadence; must be in the collector's verified 100-250ms window. Default 200. */
23
+ intervalMs?: number;
24
+ /** Explicit hub connection (a NoiseSecretStream / socket). Skips auto-swarm. */
25
+ stream?: WritableStreamLike;
26
+ /** Fully custom exporter (e.g. Rozenite bridge, OTLP). Skips everything else. */
27
+ exporter?: HyperswarmExporter;
28
+ /** Hub topic string; must match the hub's. Default DEFAULT_TOPIC. */
29
+ topic?: string;
30
+ /** Set false to disable Bare/Pear auto-dial (then attach() a stream yourself). */
31
+ autoSwarm?: boolean;
32
+ /**
33
+ * Send events to a local GUI over WebSocket instead of the Hyperswarm hub — the reliable
34
+ * dev-viewer path for EVERY runtime (RN / Pear / desktop / browser),
35
+ * e.g. 'ws://127.0.0.1:9420/ws'. When set, redaction defaults OFF (local viewing);
36
+ * pass redact:true to force it. Takes precedence over stream/auto-swarm.
37
+ */
38
+ websocket?: string;
39
+ /**
40
+ * Redact every event on the way OUT (peer ids hashed, URLs host-only, bodies off)
41
+ * — ON by default, because the export path (hub / .p2plog) leaves the device.
42
+ * Set false ONLY for a purely-local view that never leaves the machine (F3/F4/F9).
43
+ */
44
+ redact?: boolean;
45
+ /** Supply a shared Redactor (e.g. to reuse its local peer reverse-map). Default: a fresh one. */
46
+ redactor?: Redactor;
47
+ /** Per-`method` opt-in allowlist for full payload bodies (default: none — bodies summarised). */
48
+ bodyAllowlist?: Iterable<string>;
49
+ /**
50
+ * Identity of THIS device/app, for the GUI's device & app selectors. A string label, or an
51
+ * object `{ id?, label?, deviceId?, appId?, app?, device?, variant? }`.
52
+ * • deviceId — physical device (client-supplied; e.g. the app's own options.deviceId) → select by device
53
+ * • appId — app/install instance → distinguishes MULTIPLE apps sharing one package
54
+ * Omit → auto: browser reuses localStorage for a stable id (clears on delete-data); other
55
+ * runtimes get a session id (pass deviceId/appId for stability). Only the client knows its
56
+ * app name / device / variant, so it supplies those.
57
+ */
58
+ source?: string | Record<string, unknown>;
59
+ /**
60
+ * Allow the GUI to REPLAY a logged call back into this client (re-run a call the app already
61
+ * made). Boundary: the GUI references a call by corrId and the METHOD is resolved from this
62
+ * client's own call log — it never crosses the wire, so a replay cannot be redirected to a
63
+ * method the app never called. The ARGS may be supplied by the GUI (edit-and-replay) and are
64
+ * NOT validated here beyond "must be a JSON array"; use `canReplay` to check them.
65
+ *
66
+ * Fails CLOSED: must be set to `true` EXPLICITLY. There is no dev-detection default — omit it
67
+ * and replay is off, so a shipped build never installs the inbound handler. Only meaningful
68
+ * together with `websocket`.
69
+ */
70
+ allowInvoke?: boolean;
71
+ /**
72
+ * Optional gate consulted before a replay runs — return false to block (e.g. to refuse
73
+ * re-running a mutating method like a send). Default: allow. Receives the logged method and the
74
+ * args that will actually be used — which may be UNVALIDATED, GUI-supplied args. This is the
75
+ * only argument check in the system, so an app whose methods mutate state should implement it.
76
+ */
77
+ canReplay?: (method: string, args: unknown[]) => boolean;
78
+ /** Max logged calls retained for replay (corrId→method+args). Default 500. */
79
+ replayLogMax?: number;
80
+ }
81
+ export interface ObserveHandle {
82
+ /** Wire your L1 adapters into this: `instrumentDataChannel(dc, peerId, obs.sink)`. */
83
+ sink: EventSink;
84
+ flusher: BatchFlusher<L2Event>;
85
+ runtime: Runtime;
86
+ mode: 'websocket' | 'stream' | 'custom' | 'buffering';
87
+ /** The redactor applied on export (null if redact:false). Use `.resolvePeer()` for a local names view. */
88
+ redactor: Redactor | null;
89
+ /** This device/app's resolved identity — stamped on every event as `src` and announced to the GUI. */
90
+ source: SourceInfo;
91
+ /** Report one event directly to the sink (this is what wrapClient uses). */
92
+ report: Report;
93
+ /** Wrap an RPC/service client so its calls + subscription streams are observed. */
94
+ wrapClient<T extends Record<string, any>>(client: T, opts?: WrapClientOptions): T;
95
+ /**
96
+ * Snapshot of request/response calls currently in flight — started (request.start) but not yet
97
+ * settled (request.end). Each `{ method, corrId, t }`. Subscription streams are not included (see
98
+ * observe.ts). Wire this to a thread-occupancy probe's `getInflight` so a stall names the call
99
+ * that was running (e.g. hrpc-inspector-threads).
100
+ */
101
+ inflight(): Array<{
102
+ method: string;
103
+ corrId: string;
104
+ t: number;
105
+ }>;
106
+ /**
107
+ * Bridge a hypertrace tracer into the SAME event stream: pass hypertrace's `setTraceFunction`
108
+ * and every `trace()` from instrumented classes (e.g. a WebRTC/call layer) becomes a
109
+ * `type:'trace'` event on a distinct "trace" plane in the GUI. Install this BEFORE the traced
110
+ * objects are constructed (hypertrace only traces objects created after the function is set).
111
+ * Dependency-free: the app supplies `setTraceFunction`, so the package never imports hypertrace.
112
+ * Returns a disposer that stops forwarding.
113
+ */
114
+ bridgeTraces(setTraceFunction: (fn: (trace: any) => void) => void): () => void;
115
+ /** Attach a hub connection later (auto-swarm calls this on 'connection'). */
116
+ attach(stream: WritableStreamLike): void;
117
+ stop(): void;
118
+ }
119
+ export declare function observe(opts?: ObserveOptions): ObserveHandle;
package/dist/observe.js CHANGED
@@ -247,7 +247,8 @@ async function autoSwarm(topic, onConnection) {
247
247
  const Hyperswarm = (await import("hyperswarm")).default;
248
248
  const crypto = (await import("hypercore-crypto")).default;
249
249
  const swarm = new Hyperswarm();
250
- const topicKey = crypto.hash(Buffer.from(topic));
250
+ const enc = new TextEncoder();
251
+ const topicKey = crypto.hash(enc.encode(topic));
251
252
  swarm.on("connection", (conn) => onConnection(conn));
252
253
  await swarm.join(topicKey, { client: true, server: false }).flushed();
253
254
  }
@@ -0,0 +1,25 @@
1
+ export interface Reporter {
2
+ export(batch: unknown[]): void;
3
+ close(): void;
4
+ }
5
+ export interface WebSocketReporterOptions {
6
+ /** WebSocket implementation. Default globalThis.WebSocket (RN/browser/Bare/Node ≥22 have it). */
7
+ WebSocketImpl?: any;
8
+ /** Max events buffered while disconnected (oldest dropped past this). Default 1000. */
9
+ maxQueue?: number;
10
+ /** Reconnect delay ms. Default 3000. */
11
+ reconnectMs?: number;
12
+ /** Source identity sent once per connection as `{ __source }` so the GUI can build a device selector. */
13
+ announce?: unknown;
14
+ /**
15
+ * Inbound frame handler. When set, the reporter becomes BIDIRECTIONAL — it installs
16
+ * `ws.onmessage` and forwards parsed frames here. observe() passes this ONLY in dev (for the
17
+ * replay/invoke path); leaving it undefined keeps the reporter strictly send-only, as before.
18
+ */
19
+ onMessage?: (msg: any) => void;
20
+ }
21
+ /**
22
+ * Send each event as one JSON WebSocket message to `url` (e.g. ws://127.0.0.1:9420/ws).
23
+ * Auto-connects, queues while down, flushes on open, reconnects on close. Never throws.
24
+ */
25
+ export declare function createWebSocketReporter(url: string, opts?: WebSocketReporterOptions): Reporter;
@@ -0,0 +1,27 @@
1
+ export type Report = (event: Record<string, unknown>) => void;
2
+ export interface WrapClientOptions {
3
+ /** Summarise args/response/stream items before reporting. Default: bounded JSON. */
4
+ preview?: (v: unknown) => unknown;
5
+ /** Monitor subscription streams' data/end/error. Default true. */
6
+ monitorStreams?: boolean;
7
+ /**
8
+ * Called with the RAW (un-previewed) args at each call site, keyed by the minted corrId.
9
+ * observe() uses this to keep a bounded call-log so a GUI can REPLAY a logged call by corrId.
10
+ * The METHOD comes from this log, never from the wire, so a replay cannot be redirected to a
11
+ * method the app never called. ARGS, however, may be supplied by the GUI (edit-and-replay) and
12
+ * are not validated by this library — the app's `canReplay(method, args)` hook is the only
13
+ * argument check. Wired only when `allowInvoke: true` is passed explicitly.
14
+ */
15
+ onCall?: (corrId: string, method: string, args: unknown[]) => void;
16
+ }
17
+ export declare function setInvokeContext(ctx: {
18
+ invokeId: string;
19
+ replayOf: string;
20
+ method: string;
21
+ } | null): void;
22
+ /**
23
+ * Wrap a client object of namespaced methods (`client.namespace.method(...)`) so every call
24
+ * reports endpoint + request + response/stream. Mutates and returns the same client; method
25
+ * return values and `this` binding are preserved (a wrapped call behaves identically).
26
+ */
27
+ export declare function wrapClient<T extends Record<string, any>>(client: T, report: Report, opts?: WrapClientOptions): T;
package/package.json CHANGED
@@ -1,24 +1,24 @@
1
1
  {
2
2
  "name": "hrpc-inspector",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "hrpc based debugging and observability for Bare. Tap an RPC client or a raw transport and read endpoint, request, response, latency and live subscription streams in a local GUI, or merge multi-peer timelines over a Hyperswarm hub.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
- "types": "./src/index.ts",
7
+ "types": "./dist/index.d.ts",
8
8
  "bin": {
9
9
  "hrpc-inspector": "bin/hrpc-inspector.mjs"
10
10
  },
11
11
  "exports": {
12
12
  ".": {
13
- "types": "./src/index.ts",
13
+ "types": "./dist/index.d.ts",
14
14
  "default": "./dist/index.js"
15
15
  },
16
16
  "./detect": {
17
- "types": "./src/detect.ts",
17
+ "types": "./dist/detect.d.ts",
18
18
  "default": "./dist/detect.js"
19
19
  },
20
20
  "./observe": {
21
- "types": "./src/observe.ts",
21
+ "types": "./dist/observe.d.ts",
22
22
  "default": "./dist/observe.js"
23
23
  }
24
24
  },
@@ -30,18 +30,20 @@
30
30
  "LICENSE",
31
31
  "NOTICE",
32
32
  "README.md",
33
- "USAGE.md"
33
+ "USAGE.md",
34
+ "scripts"
34
35
  ],
35
36
  "scripts": {
36
- "build": "rm -rf dist && esbuild src/index.ts src/detect.ts src/observe.ts --bundle --format=esm --platform=neutral --external:hrpc-inspector-probe --external:hrpc-inspector-protocol --external:hyperswarm --external:hypercore-crypto --outdir=dist",
37
+ "build": "rm -rf dist && esbuild src/index.ts src/detect.ts src/observe.ts --bundle --format=esm --platform=neutral --external:hrpc-inspector-probe --external:hrpc-inspector-protocol --external:hyperswarm --external:hypercore-crypto --outdir=dist && npm run build:types",
37
38
  "prepare": "npm run build",
38
- "test": "bash test/run-all.sh"
39
+ "test": "bash test/run-all.sh",
40
+ "build:types": "tsc -p tsconfig.build.json && node scripts/rewrite-dts-specifiers.mjs dist"
39
41
  },
40
42
  "repository": "git+https://github.com/holepunchto/hrpc-inspector.git",
41
43
  "license": "Apache-2.0",
42
44
  "sideEffects": false,
43
45
  "dependencies": {
44
- "hrpc-inspector-probe": "^0.1.0"
46
+ "hrpc-inspector-probe": "^0.1.1"
45
47
  },
46
48
  "peerDependencies": {
47
49
  "hypercore-crypto": "^3.0.0",
@@ -60,6 +62,7 @@
60
62
  }
61
63
  },
62
64
  "devDependencies": {
63
- "esbuild": "^0.28.2"
65
+ "esbuild": "^0.28.2",
66
+ "typescript": "^7.0.2"
64
67
  }
65
68
  }
@@ -0,0 +1,32 @@
1
+ // tsc emits declarations that keep the source's explicit `.ts` specifiers, and a consumer
2
+ // type-checking those hits TS5097 unless they enable allowImportingTsExtensions — which no
3
+ // consumer should. rewriteRelativeImportExtensions only rewrites emitted JS, not .d.ts, so
4
+ // rewrite relative `./x.ts` -> `./x.js` here. Resolution then finds ./x.d.ts beside it.
5
+ import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+
8
+ function walk(dir) {
9
+ return readdirSync(dir).flatMap((entry) => {
10
+ const full = join(dir, entry)
11
+ return statSync(full).isDirectory() ? walk(full) : [full]
12
+ })
13
+ }
14
+
15
+ const dir = process.argv[2] ?? 'dist'
16
+ const SPECIFIER = /(from\s+|import\s*\(\s*)(['"])(\.\.?\/[^'"]+)\.ts\2/g
17
+
18
+ const declarations = walk(dir).filter((f) => f.endsWith('.d.ts'))
19
+
20
+ let changed = 0
21
+ for (const path of declarations) {
22
+ const before = readFileSync(path, 'utf8')
23
+ const after = before.replace(SPECIFIER, (_m, kw, q, spec) => `${kw}${q}${spec}.js${q}`)
24
+ if (after !== before) { writeFileSync(path, after); changed++ }
25
+ }
26
+
27
+ const leftover = declarations.filter((f) => SPECIFIER.test(readFileSync(f, 'utf8')))
28
+ if (leftover.length) {
29
+ console.error(`rewrite-dts-specifiers: .ts specifiers survive in ${leftover.join(', ')}`)
30
+ process.exit(1)
31
+ }
32
+ console.log(`rewrite-dts-specifiers: ${changed} file(s) rewritten in ${dir}`)
@@ -0,0 +1,16 @@
1
+ // hyperswarm / hypercore-crypto are optional peers pulled in by a guarded dynamic import in the
2
+ // Bare/Pear auto-dial path, and marked --external in the build. Neither ships types, so declare
3
+ // the shape this package actually uses rather than depending on the packages being installed.
4
+ declare module 'hyperswarm' {
5
+ interface SwarmDiscovery { flushed(): Promise<void> }
6
+ export default class Hyperswarm {
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the stream shape is the consumer's to name
8
+ on(event: 'connection', listener: (conn: any) => void): this
9
+ join(topic: Uint8Array, opts?: { client?: boolean; server?: boolean }): SwarmDiscovery
10
+ }
11
+ }
12
+
13
+ declare module 'hypercore-crypto' {
14
+ const crypto: { hash(data: Uint8Array): Uint8Array }
15
+ export default crypto
16
+ }
package/src/observe.ts CHANGED
@@ -232,7 +232,9 @@ async function autoSwarm(topic: string, onConnection: (stream: WritableStreamLik
232
232
  const Hyperswarm = (await import('hyperswarm')).default;
233
233
  const crypto = (await import('hypercore-crypto')).default;
234
234
  const swarm = new Hyperswarm();
235
- const topicKey = crypto.hash(Buffer.from(topic));
235
+ // Buffer is a Bare/Node global and absent in browsers; this path only runs under Bare.
236
+ const enc = new TextEncoder();
237
+ const topicKey = crypto.hash(enc.encode(topic));
236
238
  swarm.on('connection', (conn: WritableStreamLike) => onConnection(conn));
237
239
  await swarm.join(topicKey, { client: true, server: false }).flushed();
238
240
  }