hrpc-inspector 0.1.0 → 0.1.2
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/dist/detect.d.ts +10 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +22 -6
- package/dist/observe.d.ts +128 -0
- package/dist/observe.js +22 -6
- package/dist/reporters.d.ts +25 -0
- package/dist/wrap-client.d.ts +27 -0
- package/package.json +14 -10
- package/scripts/rewrite-dts-specifiers.mjs +32 -0
- package/src/externals.d.ts +16 -0
- package/src/observe.ts +25 -2
- package/src/wrap-client.ts +28 -8
package/dist/detect.d.ts
ADDED
|
@@ -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;
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
@@ -117,10 +117,21 @@ function monitorStream(report, endpoint, corrId, stream, t0, preview, tag) {
|
|
|
117
117
|
report({ type: "request.end", method: endpoint, corrId, status, count: n, dur: Date.now() - t0, t: Date.now(), ...tag, ...extra || {} });
|
|
118
118
|
};
|
|
119
119
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
stream.
|
|
123
|
-
|
|
120
|
+
const original = stream.emit;
|
|
121
|
+
if (typeof original !== "function") return;
|
|
122
|
+
stream.emit = function patched(name, ...args) {
|
|
123
|
+
try {
|
|
124
|
+
if (name === "data") {
|
|
125
|
+
report({ type: "stream.data", method: endpoint, corrId, seq: ++n, item: preview(args[0]), t: Date.now(), ...tag });
|
|
126
|
+
} else if (name === "error") {
|
|
127
|
+
settle("error", { error: String(args[0]) });
|
|
128
|
+
} else if (name === "end" || name === "close") {
|
|
129
|
+
settle("closed");
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
}
|
|
133
|
+
return original.apply(this, [name, ...args]);
|
|
134
|
+
};
|
|
124
135
|
} catch (e) {
|
|
125
136
|
report({ type: "request.end", method: endpoint, corrId, status: "error", error: "monitor-failed: " + String(e), dur: 0, t: Date.now(), ...tag });
|
|
126
137
|
}
|
|
@@ -247,7 +258,8 @@ async function autoSwarm(topic, onConnection) {
|
|
|
247
258
|
const Hyperswarm = (await import("hyperswarm")).default;
|
|
248
259
|
const crypto = (await import("hypercore-crypto")).default;
|
|
249
260
|
const swarm = new Hyperswarm();
|
|
250
|
-
const
|
|
261
|
+
const enc = new TextEncoder();
|
|
262
|
+
const topicKey = crypto.hash(enc.encode(topic));
|
|
251
263
|
swarm.on("connection", (conn) => onConnection(conn));
|
|
252
264
|
await swarm.join(topicKey, { client: true, server: false }).flushed();
|
|
253
265
|
}
|
|
@@ -256,6 +268,8 @@ function observe(opts = {}) {
|
|
|
256
268
|
const source = resolveSource(opts.source, info.runtime);
|
|
257
269
|
const redactOn = opts.redact ?? (opts.websocket ? false : true);
|
|
258
270
|
const redactor = opts.redactor ?? (redactOn ? new Redactor({ bodyAllowlist: opts.bodyAllowlist }) : null);
|
|
271
|
+
const redactMethods = new Set(opts.redactMethods ?? []);
|
|
272
|
+
const methodRedactor = !redactor && redactMethods.size > 0 ? new Redactor() : null;
|
|
259
273
|
const wireSource = redactor ? redactSource(source, redactor) : source;
|
|
260
274
|
const invokeEnabled = opts.allowInvoke === true && !!opts.websocket;
|
|
261
275
|
const replayLogMax = opts.replayLogMax ?? 2e3;
|
|
@@ -295,7 +309,9 @@ function observe(opts = {}) {
|
|
|
295
309
|
const flusher = new BatchFlusher({
|
|
296
310
|
intervalMs: opts.intervalMs ?? 200,
|
|
297
311
|
onFlush: (batch) => {
|
|
298
|
-
const out = redactor ? redactor.redactBatch(batch) : batch
|
|
312
|
+
const out = redactor ? redactor.redactBatch(batch) : methodRedactor ? batch.map(
|
|
313
|
+
(e) => redactMethods.has(String(e.method)) ? methodRedactor.redact(e) : e
|
|
314
|
+
) : batch;
|
|
299
315
|
exporter.export(out.map((e) => ({ ...e, src: wireSource.id })));
|
|
300
316
|
}
|
|
301
317
|
});
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
/**
|
|
46
|
+
* Per-`method` FORCED redaction, for when global `redact` is off (the local viewer default).
|
|
47
|
+
* A listed method still appears in the GUI — you see it fired, when, how long it took, whether
|
|
48
|
+
* it errored, and the SHAPE of its payload (`{phrase: 'string'}`) — but its values are replaced
|
|
49
|
+
* by a content summary. Use it for methods that carry secrets you never want rendered, e.g.
|
|
50
|
+
* a recovery phrase. This is a denylist: a newly added secret-bearing method must be listed.
|
|
51
|
+
* Ignored when `redact` is on, because everything is redacted already.
|
|
52
|
+
*/
|
|
53
|
+
redactMethods?: Iterable<string>;
|
|
54
|
+
/** Supply a shared Redactor (e.g. to reuse its local peer reverse-map). Default: a fresh one. */
|
|
55
|
+
redactor?: Redactor;
|
|
56
|
+
/** Per-`method` opt-in allowlist for full payload bodies (default: none — bodies summarised). */
|
|
57
|
+
bodyAllowlist?: Iterable<string>;
|
|
58
|
+
/**
|
|
59
|
+
* Identity of THIS device/app, for the GUI's device & app selectors. A string label, or an
|
|
60
|
+
* object `{ id?, label?, deviceId?, appId?, app?, device?, variant? }`.
|
|
61
|
+
* • deviceId — physical device (client-supplied; e.g. the app's own options.deviceId) → select by device
|
|
62
|
+
* • appId — app/install instance → distinguishes MULTIPLE apps sharing one package
|
|
63
|
+
* Omit → auto: browser reuses localStorage for a stable id (clears on delete-data); other
|
|
64
|
+
* runtimes get a session id (pass deviceId/appId for stability). Only the client knows its
|
|
65
|
+
* app name / device / variant, so it supplies those.
|
|
66
|
+
*/
|
|
67
|
+
source?: string | Record<string, unknown>;
|
|
68
|
+
/**
|
|
69
|
+
* Allow the GUI to REPLAY a logged call back into this client (re-run a call the app already
|
|
70
|
+
* made). Boundary: the GUI references a call by corrId and the METHOD is resolved from this
|
|
71
|
+
* client's own call log — it never crosses the wire, so a replay cannot be redirected to a
|
|
72
|
+
* method the app never called. The ARGS may be supplied by the GUI (edit-and-replay) and are
|
|
73
|
+
* NOT validated here beyond "must be a JSON array"; use `canReplay` to check them.
|
|
74
|
+
*
|
|
75
|
+
* Fails CLOSED: must be set to `true` EXPLICITLY. There is no dev-detection default — omit it
|
|
76
|
+
* and replay is off, so a shipped build never installs the inbound handler. Only meaningful
|
|
77
|
+
* together with `websocket`.
|
|
78
|
+
*/
|
|
79
|
+
allowInvoke?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Optional gate consulted before a replay runs — return false to block (e.g. to refuse
|
|
82
|
+
* re-running a mutating method like a send). Default: allow. Receives the logged method and the
|
|
83
|
+
* args that will actually be used — which may be UNVALIDATED, GUI-supplied args. This is the
|
|
84
|
+
* only argument check in the system, so an app whose methods mutate state should implement it.
|
|
85
|
+
*/
|
|
86
|
+
canReplay?: (method: string, args: unknown[]) => boolean;
|
|
87
|
+
/** Max logged calls retained for replay (corrId→method+args). Default 500. */
|
|
88
|
+
replayLogMax?: number;
|
|
89
|
+
}
|
|
90
|
+
export interface ObserveHandle {
|
|
91
|
+
/** Wire your L1 adapters into this: `instrumentDataChannel(dc, peerId, obs.sink)`. */
|
|
92
|
+
sink: EventSink;
|
|
93
|
+
flusher: BatchFlusher<L2Event>;
|
|
94
|
+
runtime: Runtime;
|
|
95
|
+
mode: 'websocket' | 'stream' | 'custom' | 'buffering';
|
|
96
|
+
/** The redactor applied on export (null if redact:false). Use `.resolvePeer()` for a local names view. */
|
|
97
|
+
redactor: Redactor | null;
|
|
98
|
+
/** This device/app's resolved identity — stamped on every event as `src` and announced to the GUI. */
|
|
99
|
+
source: SourceInfo;
|
|
100
|
+
/** Report one event directly to the sink (this is what wrapClient uses). */
|
|
101
|
+
report: Report;
|
|
102
|
+
/** Wrap an RPC/service client so its calls + subscription streams are observed. */
|
|
103
|
+
wrapClient<T extends Record<string, any>>(client: T, opts?: WrapClientOptions): T;
|
|
104
|
+
/**
|
|
105
|
+
* Snapshot of request/response calls currently in flight — started (request.start) but not yet
|
|
106
|
+
* settled (request.end). Each `{ method, corrId, t }`. Subscription streams are not included (see
|
|
107
|
+
* observe.ts). Wire this to a thread-occupancy probe's `getInflight` so a stall names the call
|
|
108
|
+
* that was running (e.g. hrpc-inspector-threads).
|
|
109
|
+
*/
|
|
110
|
+
inflight(): Array<{
|
|
111
|
+
method: string;
|
|
112
|
+
corrId: string;
|
|
113
|
+
t: number;
|
|
114
|
+
}>;
|
|
115
|
+
/**
|
|
116
|
+
* Bridge a hypertrace tracer into the SAME event stream: pass hypertrace's `setTraceFunction`
|
|
117
|
+
* and every `trace()` from instrumented classes (e.g. a WebRTC/call layer) becomes a
|
|
118
|
+
* `type:'trace'` event on a distinct "trace" plane in the GUI. Install this BEFORE the traced
|
|
119
|
+
* objects are constructed (hypertrace only traces objects created after the function is set).
|
|
120
|
+
* Dependency-free: the app supplies `setTraceFunction`, so the package never imports hypertrace.
|
|
121
|
+
* Returns a disposer that stops forwarding.
|
|
122
|
+
*/
|
|
123
|
+
bridgeTraces(setTraceFunction: (fn: (trace: any) => void) => void): () => void;
|
|
124
|
+
/** Attach a hub connection later (auto-swarm calls this on 'connection'). */
|
|
125
|
+
attach(stream: WritableStreamLike): void;
|
|
126
|
+
stop(): void;
|
|
127
|
+
}
|
|
128
|
+
export declare function observe(opts?: ObserveOptions): ObserveHandle;
|
package/dist/observe.js
CHANGED
|
@@ -117,10 +117,21 @@ function monitorStream(report, endpoint, corrId, stream, t0, preview, tag) {
|
|
|
117
117
|
report({ type: "request.end", method: endpoint, corrId, status, count: n, dur: Date.now() - t0, t: Date.now(), ...tag, ...extra || {} });
|
|
118
118
|
};
|
|
119
119
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
stream.
|
|
123
|
-
|
|
120
|
+
const original = stream.emit;
|
|
121
|
+
if (typeof original !== "function") return;
|
|
122
|
+
stream.emit = function patched(name, ...args) {
|
|
123
|
+
try {
|
|
124
|
+
if (name === "data") {
|
|
125
|
+
report({ type: "stream.data", method: endpoint, corrId, seq: ++n, item: preview(args[0]), t: Date.now(), ...tag });
|
|
126
|
+
} else if (name === "error") {
|
|
127
|
+
settle("error", { error: String(args[0]) });
|
|
128
|
+
} else if (name === "end" || name === "close") {
|
|
129
|
+
settle("closed");
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
}
|
|
133
|
+
return original.apply(this, [name, ...args]);
|
|
134
|
+
};
|
|
124
135
|
} catch (e) {
|
|
125
136
|
report({ type: "request.end", method: endpoint, corrId, status: "error", error: "monitor-failed: " + String(e), dur: 0, t: Date.now(), ...tag });
|
|
126
137
|
}
|
|
@@ -247,7 +258,8 @@ async function autoSwarm(topic, onConnection) {
|
|
|
247
258
|
const Hyperswarm = (await import("hyperswarm")).default;
|
|
248
259
|
const crypto = (await import("hypercore-crypto")).default;
|
|
249
260
|
const swarm = new Hyperswarm();
|
|
250
|
-
const
|
|
261
|
+
const enc = new TextEncoder();
|
|
262
|
+
const topicKey = crypto.hash(enc.encode(topic));
|
|
251
263
|
swarm.on("connection", (conn) => onConnection(conn));
|
|
252
264
|
await swarm.join(topicKey, { client: true, server: false }).flushed();
|
|
253
265
|
}
|
|
@@ -256,6 +268,8 @@ function observe(opts = {}) {
|
|
|
256
268
|
const source = resolveSource(opts.source, info.runtime);
|
|
257
269
|
const redactOn = opts.redact ?? (opts.websocket ? false : true);
|
|
258
270
|
const redactor = opts.redactor ?? (redactOn ? new Redactor({ bodyAllowlist: opts.bodyAllowlist }) : null);
|
|
271
|
+
const redactMethods = new Set(opts.redactMethods ?? []);
|
|
272
|
+
const methodRedactor = !redactor && redactMethods.size > 0 ? new Redactor() : null;
|
|
259
273
|
const wireSource = redactor ? redactSource(source, redactor) : source;
|
|
260
274
|
const invokeEnabled = opts.allowInvoke === true && !!opts.websocket;
|
|
261
275
|
const replayLogMax = opts.replayLogMax ?? 2e3;
|
|
@@ -295,7 +309,9 @@ function observe(opts = {}) {
|
|
|
295
309
|
const flusher = new BatchFlusher({
|
|
296
310
|
intervalMs: opts.intervalMs ?? 200,
|
|
297
311
|
onFlush: (batch) => {
|
|
298
|
-
const out = redactor ? redactor.redactBatch(batch) : batch
|
|
312
|
+
const out = redactor ? redactor.redactBatch(batch) : methodRedactor ? batch.map(
|
|
313
|
+
(e) => redactMethods.has(String(e.method)) ? methodRedactor.redact(e) : e
|
|
314
|
+
) : batch;
|
|
299
315
|
exporter.export(out.map((e) => ({ ...e, src: wireSource.id })));
|
|
300
316
|
}
|
|
301
317
|
});
|
|
@@ -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.
|
|
3
|
+
"version": "0.1.2",
|
|
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": "./
|
|
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": "./
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
14
|
"default": "./dist/index.js"
|
|
15
15
|
},
|
|
16
16
|
"./detect": {
|
|
17
|
-
"types": "./
|
|
17
|
+
"types": "./dist/detect.d.ts",
|
|
18
18
|
"default": "./dist/detect.js"
|
|
19
19
|
},
|
|
20
20
|
"./observe": {
|
|
21
|
-
"types": "./
|
|
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.
|
|
46
|
+
"hrpc-inspector-probe": "^0.1.1"
|
|
45
47
|
},
|
|
46
48
|
"peerDependencies": {
|
|
47
49
|
"hypercore-crypto": "^3.0.0",
|
|
@@ -60,6 +62,8 @@
|
|
|
60
62
|
}
|
|
61
63
|
},
|
|
62
64
|
"devDependencies": {
|
|
63
|
-
"esbuild": "^0.28.2"
|
|
65
|
+
"esbuild": "^0.28.2",
|
|
66
|
+
"streamx": "^2.28.1",
|
|
67
|
+
"typescript": "^7.0.2"
|
|
64
68
|
}
|
|
65
69
|
}
|
|
@@ -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
|
@@ -128,6 +128,15 @@ export interface ObserveOptions {
|
|
|
128
128
|
* Set false ONLY for a purely-local view that never leaves the machine (F3/F4/F9).
|
|
129
129
|
*/
|
|
130
130
|
redact?: boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Per-`method` FORCED redaction, for when global `redact` is off (the local viewer default).
|
|
133
|
+
* A listed method still appears in the GUI — you see it fired, when, how long it took, whether
|
|
134
|
+
* it errored, and the SHAPE of its payload (`{phrase: 'string'}`) — but its values are replaced
|
|
135
|
+
* by a content summary. Use it for methods that carry secrets you never want rendered, e.g.
|
|
136
|
+
* a recovery phrase. This is a denylist: a newly added secret-bearing method must be listed.
|
|
137
|
+
* Ignored when `redact` is on, because everything is redacted already.
|
|
138
|
+
*/
|
|
139
|
+
redactMethods?: Iterable<string>;
|
|
131
140
|
/** Supply a shared Redactor (e.g. to reuse its local peer reverse-map). Default: a fresh one. */
|
|
132
141
|
redactor?: Redactor;
|
|
133
142
|
/** Per-`method` opt-in allowlist for full payload bodies (default: none — bodies summarised). */
|
|
@@ -232,7 +241,9 @@ async function autoSwarm(topic: string, onConnection: (stream: WritableStreamLik
|
|
|
232
241
|
const Hyperswarm = (await import('hyperswarm')).default;
|
|
233
242
|
const crypto = (await import('hypercore-crypto')).default;
|
|
234
243
|
const swarm = new Hyperswarm();
|
|
235
|
-
|
|
244
|
+
// Buffer is a Bare/Node global and absent in browsers; this path only runs under Bare.
|
|
245
|
+
const enc = new TextEncoder();
|
|
246
|
+
const topicKey = crypto.hash(enc.encode(topic));
|
|
236
247
|
swarm.on('connection', (conn: WritableStreamLike) => onConnection(conn));
|
|
237
248
|
await swarm.join(topicKey, { client: true, server: false }).flushed();
|
|
238
249
|
}
|
|
@@ -247,6 +258,10 @@ export function observe(opts: ObserveOptions = {}): ObserveHandle {
|
|
|
247
258
|
const redactOn = opts.redact ?? (opts.websocket ? false : true);
|
|
248
259
|
const redactor: Redactor | null =
|
|
249
260
|
opts.redactor ?? (redactOn ? new Redactor({ bodyAllowlist: opts.bodyAllowlist }) : null);
|
|
261
|
+
// Only meaningful while global redaction is off; when it is on these methods are covered anyway.
|
|
262
|
+
const redactMethods = new Set(opts.redactMethods ?? []);
|
|
263
|
+
const methodRedactor: Redactor | null =
|
|
264
|
+
!redactor && redactMethods.size > 0 ? new Redactor() : null;
|
|
250
265
|
// Identity ON THE WIRE — hashed ids / no human labels when redaction is on. `handle.source`
|
|
251
266
|
// keeps the original for the app's own use.
|
|
252
267
|
const wireSource = redactor ? redactSource(source, redactor) : source;
|
|
@@ -304,7 +319,15 @@ export function observe(opts: ObserveOptions = {}): ObserveHandle {
|
|
|
304
319
|
const flusher = new BatchFlusher<L2Event>({
|
|
305
320
|
intervalMs: opts.intervalMs ?? 200,
|
|
306
321
|
onFlush: (batch) => {
|
|
307
|
-
const out = redactor
|
|
322
|
+
const out = redactor
|
|
323
|
+
? redactor.redactBatch(batch)
|
|
324
|
+
: methodRedactor
|
|
325
|
+
? batch.map((e) =>
|
|
326
|
+
redactMethods.has(String((e as Record<string, unknown>).method))
|
|
327
|
+
? (methodRedactor.redact(e as Record<string, unknown>) as L2Event)
|
|
328
|
+
: e,
|
|
329
|
+
)
|
|
330
|
+
: batch;
|
|
308
331
|
// Stamp the (wire) source id so the GUI can group/select by device.
|
|
309
332
|
exporter.export(out.map((e) => ({ ...(e as object), src: wireSource.id })));
|
|
310
333
|
},
|
package/src/wrap-client.ts
CHANGED
|
@@ -57,10 +57,16 @@ function defaultPreview(v: unknown): unknown {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
60
|
+
// Observe a subscription stream WITHOUT changing its behaviour.
|
|
61
|
+
//
|
|
62
|
+
// This wraps `emit` instead of calling `.on('data', ...)`. Registering a data listener is not
|
|
63
|
+
// passive: it puts the stream into flowing mode immediately, so anything emitted before the app
|
|
64
|
+
// attaches its own listener is delivered to us and LOST to the app. That is a debug-only data
|
|
65
|
+
// loss — the app silently misses items, with no error — and it is real: keet's RPC subscriptions
|
|
66
|
+
// return their stream synchronously, while the consumer attaches a tick later.
|
|
67
|
+
//
|
|
68
|
+
// Wrapping `emit` cannot cause that: we only see events the stream was already delivering. If
|
|
69
|
+
// nothing consumes the stream, nothing is emitted and we report nothing, which is the truth.
|
|
64
70
|
function monitorStream(report: Report, endpoint: string, corrId: string, stream: any, t0: number, preview: (v: unknown) => unknown, tag: Record<string, unknown>): void {
|
|
65
71
|
report({ type: 'stream.open', method: endpoint, corrId, t: Date.now(), ...tag });
|
|
66
72
|
let n = 0;
|
|
@@ -71,10 +77,24 @@ function monitorStream(report: Report, endpoint: string, corrId: string, stream:
|
|
|
71
77
|
report({ type: 'request.end', method: endpoint, corrId, status, count: n, dur: Date.now() - t0, t: Date.now(), ...tag, ...(extra || {}) });
|
|
72
78
|
};
|
|
73
79
|
try {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
stream.
|
|
77
|
-
|
|
80
|
+
const original = stream.emit;
|
|
81
|
+
if (typeof original !== 'function') return;
|
|
82
|
+
stream.emit = function patched(this: unknown, name: string, ...args: unknown[]): unknown {
|
|
83
|
+
// Observation must never affect delivery: record, then hand off unchanged. A throw in the
|
|
84
|
+
// reporting path would surface as a stream error in the app, so it is swallowed.
|
|
85
|
+
try {
|
|
86
|
+
if (name === 'data') {
|
|
87
|
+
report({ type: 'stream.data', method: endpoint, corrId, seq: ++n, item: preview(args[0]), t: Date.now(), ...tag });
|
|
88
|
+
} else if (name === 'error') {
|
|
89
|
+
settle('error', { error: String(args[0]) });
|
|
90
|
+
} else if (name === 'end' || name === 'close') {
|
|
91
|
+
settle('closed');
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
/* never break the stream */
|
|
95
|
+
}
|
|
96
|
+
return original.apply(this, [name, ...args] as never);
|
|
97
|
+
};
|
|
78
98
|
} catch (e) {
|
|
79
99
|
report({ type: 'request.end', method: endpoint, corrId, status: 'error', error: 'monitor-failed: ' + String(e), dur: 0, t: Date.now(), ...tag });
|
|
80
100
|
}
|