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.
@@ -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
@@ -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
- stream.on("data", (item) => report({ type: "stream.data", method: endpoint, corrId, seq: ++n, item: preview(item), t: Date.now(), ...tag }));
121
- stream.on("error", (err) => settle("error", { error: String(err) }));
122
- stream.on("end", () => settle("closed"));
123
- stream.on("close", () => settle("closed"));
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 topicKey = crypto.hash(Buffer.from(topic));
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
- stream.on("data", (item) => report({ type: "stream.data", method: endpoint, corrId, seq: ++n, item: preview(item), t: Date.now(), ...tag }));
121
- stream.on("error", (err) => settle("error", { error: String(err) }));
122
- stream.on("end", () => settle("closed"));
123
- stream.on("close", () => settle("closed"));
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 topicKey = crypto.hash(Buffer.from(topic));
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.0",
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": "./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,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
- const topicKey = crypto.hash(Buffer.from(topic));
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 ? redactor.redactBatch(batch) : batch;
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
  },
@@ -57,10 +57,16 @@ function defaultPreview(v: unknown): unknown {
57
57
  }
58
58
  }
59
59
 
60
- // Attach PASSIVE listeners to a subscription stream. Non-destructive when the app also uses
61
- // `.on('data')` (every listener receives each item). If a consumer instead uses `.read()` /
62
- // async-iteration, adding a data listener flips it to flowing mode — opt out with
63
- // monitorStreams:false for those.
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
- stream.on('data', (item: unknown) => report({ type: 'stream.data', method: endpoint, corrId, seq: ++n, item: preview(item), t: Date.now(), ...tag }));
75
- stream.on('error', (err: unknown) => settle('error', { error: String(err) }));
76
- stream.on('end', () => settle('closed'));
77
- stream.on('close', () => settle('closed'));
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
  }