hrpc-inspector 0.0.0 → 0.1.0

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/src/observe.ts ADDED
@@ -0,0 +1,430 @@
1
+ // observe() — the zero-config entry point. One call wires:
2
+ //
3
+ // your adapter → obs.sink → BatchFlusher(100-250ms) → exporter → hub
4
+ //
5
+ // and auto-selects the exporter for the detected runtime:
6
+ // • explicit stream given → write frames to it
7
+ // • Bare/Pear (or RN bare-kit worklet) → auto-dial the hub topic over Hyperswarm,
8
+ // buffering events until a peer (the hub) connects
9
+ // • anything else → buffer safely and let the caller attach()
10
+ //
11
+ // The core is transport- and host-agnostic; this file is the only place that knows
12
+ // how to reach a hub in each environment.
13
+
14
+ import { BatchFlusher } from 'hrpc-inspector-probe/flush';
15
+ import { createHyperswarmExporter } from 'hrpc-inspector-probe/exporters/hyperswarm';
16
+ import type { WritableStreamLike, HyperswarmExporter } from 'hrpc-inspector-probe/exporters/hyperswarm';
17
+ import { Redactor } from 'hrpc-inspector-probe/redactor';
18
+ import type { EventSink, L2Event } from 'hrpc-inspector-probe/sink';
19
+ import { detectRuntime } from './detect.ts';
20
+ import type { Runtime } from './detect.ts';
21
+ import { createWebSocketReporter } from './reporters.ts';
22
+ import type { Reporter } from './reporters.ts';
23
+ import { wrapClient, setInvokeContext } from './wrap-client.ts';
24
+ import type { Report, WrapClientOptions } from './wrap-client.ts';
25
+
26
+ // NOTE: there is deliberately NO ambient "am I in dev?" signal here. The replay/invoke capability
27
+ // used to default to a sniffed dev flag, which returned true whenever neither globalThis.__DEV__ nor
28
+ // globalThis.process?.env existed — i.e. in a production browser bundle and in every Bare/Pear build
29
+ // (`bare -e 'typeof process'` → undefined). A shipped app therefore installed the inbound invoke
30
+ // handler. The capability now fails CLOSED: it requires an explicit `allowInvoke: true`.
31
+
32
+ export const DEFAULT_TOPIC = 'p2p-observability-hub:v0';
33
+
34
+ export interface SourceInfo {
35
+ /** Stable id used to group/select this source in the GUI. */
36
+ id: string;
37
+ /** Human label shown in the selector. */
38
+ label: string;
39
+ /** Detected runtime (react-native | pear | bare | electron | node | browser). */
40
+ runtime: string;
41
+ /** Physical device id (client-supplied; e.g. an app's own options.deviceId). Lets the GUI select by device. */
42
+ deviceId?: string;
43
+ /** App / install instance id — distinguishes MULTIPLE apps sharing one package. */
44
+ appId?: string;
45
+ [extra: string]: unknown;
46
+ }
47
+
48
+ // Reuse the runtime's local storage for a STABLE id (survives restart, clears on delete-data).
49
+ // Only the browser has a synchronous, universal store (localStorage); RN/Bare/Node clients pass
50
+ // their own persistent deviceId (they own AsyncStorage / a file / a platform id).
51
+ function persistentId(runtime: string): string {
52
+ try {
53
+ const ls = (globalThis as any).localStorage;
54
+ if (runtime === 'browser' && ls) {
55
+ const KEY = 'hrpc-inspector:deviceId';
56
+ let v = ls.getItem(KEY);
57
+ if (!v) { v = Math.random().toString(36).slice(2, 10); ls.setItem(KEY, v); }
58
+ return v;
59
+ }
60
+ } catch {
61
+ /* storage blocked (private mode etc.) — fall through to a session id */
62
+ }
63
+ return Math.random().toString(36).slice(2, 8);
64
+ }
65
+
66
+ /**
67
+ * Auto-fill a source identity; the client overrides any field. The unique `id` is TIER-TAGGED
68
+ * and component-ESCAPED so it is injective — a device id and an app id can never collide, and a
69
+ * value containing the separator can't forge another source (protocol-architect review):
70
+ * explicit id → `d=<dev>;a=<app>` → `d=<dev>` → `a=<app>` → `r=<runtime>-<persistentOrSession>`.
71
+ */
72
+ function resolveSource(s: string | Record<string, unknown> | undefined, runtime: string): SourceInfo {
73
+ if (typeof s === 'string' && s) return { id: s, label: s, runtime };
74
+ const p = (s ?? {}) as Record<string, unknown>;
75
+ const deviceId = (p.deviceId as string | undefined) || undefined;
76
+ const appId = ((p.appId as string | undefined) ?? (p.app as string | undefined)) || undefined;
77
+ const enc = (v: string) => encodeURIComponent(v);
78
+ const explicitId = typeof p.id === 'string' && p.id ? p.id : undefined; // ignore empty-string id
79
+ const id =
80
+ explicitId ??
81
+ (deviceId && appId ? `d=${enc(deviceId)};a=${enc(appId)}`
82
+ : deviceId ? `d=${enc(deviceId)}`
83
+ : appId ? `a=${enc(appId)}`
84
+ : `r=${runtime}-${persistentId(runtime)}`);
85
+ const parts = [appId, (p.device as string) ?? deviceId, p.variant].filter(Boolean);
86
+ const label = (typeof p.label === 'string' && p.label ? p.label : undefined) ?? (parts.length ? parts.join(' · ') : id);
87
+ return { ...p, id, label, runtime, deviceId, appId } as SourceInfo;
88
+ }
89
+
90
+ /**
91
+ * Redact a source for the EXPORT path (hub / .p2plog leave the device). Client-chosen labels
92
+ * ("Ari's iPhone") and device ids are fingerprints, so hash the ids and drop free-text; keep
93
+ * only non-personal structural fields. WHITELIST — arbitrary client `[extra]` fields are dropped.
94
+ */
95
+ function redactSource(src: SourceInfo, redactor: Redactor): SourceInfo {
96
+ const id = redactor.hashPeer(src.id);
97
+ return {
98
+ id,
99
+ label: id, // no human name on the wire
100
+ runtime: src.runtime,
101
+ ...(src.deviceId ? { deviceId: redactor.hashPeer(src.deviceId) } : {}),
102
+ ...(src.appId ? { appId: src.appId } : {}), // a bundle id is not personal
103
+ ...(src.variant ? { variant: src.variant } : {}), // e.g. "nightly"
104
+ };
105
+ }
106
+
107
+ export interface ObserveOptions {
108
+ /** Flush cadence; must be in the collector's verified 100-250ms window. Default 200. */
109
+ intervalMs?: number;
110
+ /** Explicit hub connection (a NoiseSecretStream / socket). Skips auto-swarm. */
111
+ stream?: WritableStreamLike;
112
+ /** Fully custom exporter (e.g. Rozenite bridge, OTLP). Skips everything else. */
113
+ exporter?: HyperswarmExporter;
114
+ /** Hub topic string; must match the hub's. Default DEFAULT_TOPIC. */
115
+ topic?: string;
116
+ /** Set false to disable Bare/Pear auto-dial (then attach() a stream yourself). */
117
+ autoSwarm?: boolean;
118
+ /**
119
+ * Send events to a local GUI over WebSocket instead of the Hyperswarm hub — the reliable
120
+ * dev-viewer path for EVERY runtime (RN / Pear / desktop / browser),
121
+ * e.g. 'ws://127.0.0.1:9420/ws'. When set, redaction defaults OFF (local viewing);
122
+ * pass redact:true to force it. Takes precedence over stream/auto-swarm.
123
+ */
124
+ websocket?: string;
125
+ /**
126
+ * Redact every event on the way OUT (peer ids hashed, URLs host-only, bodies off)
127
+ * — ON by default, because the export path (hub / .p2plog) leaves the device.
128
+ * Set false ONLY for a purely-local view that never leaves the machine (F3/F4/F9).
129
+ */
130
+ redact?: boolean;
131
+ /** Supply a shared Redactor (e.g. to reuse its local peer reverse-map). Default: a fresh one. */
132
+ redactor?: Redactor;
133
+ /** Per-`method` opt-in allowlist for full payload bodies (default: none — bodies summarised). */
134
+ bodyAllowlist?: Iterable<string>;
135
+ /**
136
+ * Identity of THIS device/app, for the GUI's device & app selectors. A string label, or an
137
+ * object `{ id?, label?, deviceId?, appId?, app?, device?, variant? }`.
138
+ * • deviceId — physical device (client-supplied; e.g. the app's own options.deviceId) → select by device
139
+ * • appId — app/install instance → distinguishes MULTIPLE apps sharing one package
140
+ * Omit → auto: browser reuses localStorage for a stable id (clears on delete-data); other
141
+ * runtimes get a session id (pass deviceId/appId for stability). Only the client knows its
142
+ * app name / device / variant, so it supplies those.
143
+ */
144
+ source?: string | Record<string, unknown>;
145
+ /**
146
+ * Allow the GUI to REPLAY a logged call back into this client (re-run a call the app already
147
+ * made). Boundary: the GUI references a call by corrId and the METHOD is resolved from this
148
+ * client's own call log — it never crosses the wire, so a replay cannot be redirected to a
149
+ * method the app never called. The ARGS may be supplied by the GUI (edit-and-replay) and are
150
+ * NOT validated here beyond "must be a JSON array"; use `canReplay` to check them.
151
+ *
152
+ * Fails CLOSED: must be set to `true` EXPLICITLY. There is no dev-detection default — omit it
153
+ * and replay is off, so a shipped build never installs the inbound handler. Only meaningful
154
+ * together with `websocket`.
155
+ */
156
+ allowInvoke?: boolean;
157
+ /**
158
+ * Optional gate consulted before a replay runs — return false to block (e.g. to refuse
159
+ * re-running a mutating method like a send). Default: allow. Receives the logged method and the
160
+ * args that will actually be used — which may be UNVALIDATED, GUI-supplied args. This is the
161
+ * only argument check in the system, so an app whose methods mutate state should implement it.
162
+ */
163
+ canReplay?: (method: string, args: unknown[]) => boolean;
164
+ /** Max logged calls retained for replay (corrId→method+args). Default 500. */
165
+ replayLogMax?: number;
166
+ }
167
+
168
+ export interface ObserveHandle {
169
+ /** Wire your L1 adapters into this: `instrumentDataChannel(dc, peerId, obs.sink)`. */
170
+ sink: EventSink;
171
+ flusher: BatchFlusher<L2Event>;
172
+ runtime: Runtime;
173
+ mode: 'websocket' | 'stream' | 'custom' | 'buffering';
174
+ /** The redactor applied on export (null if redact:false). Use `.resolvePeer()` for a local names view. */
175
+ redactor: Redactor | null;
176
+ /** This device/app's resolved identity — stamped on every event as `src` and announced to the GUI. */
177
+ source: SourceInfo;
178
+ /** Report one event directly to the sink (this is what wrapClient uses). */
179
+ report: Report;
180
+ /** Wrap an RPC/service client so its calls + subscription streams are observed. */
181
+ wrapClient<T extends Record<string, any>>(client: T, opts?: WrapClientOptions): T;
182
+ /**
183
+ * Snapshot of request/response calls currently in flight — started (request.start) but not yet
184
+ * settled (request.end). Each `{ method, corrId, t }`. Subscription streams are not included (see
185
+ * observe.ts). Wire this to a thread-occupancy probe's `getInflight` so a stall names the call
186
+ * that was running (e.g. hrpc-inspector-threads).
187
+ */
188
+ inflight(): Array<{ method: string; corrId: string; t: number }>;
189
+ /**
190
+ * Bridge a hypertrace tracer into the SAME event stream: pass hypertrace's `setTraceFunction`
191
+ * and every `trace()` from instrumented classes (e.g. a WebRTC/call layer) becomes a
192
+ * `type:'trace'` event on a distinct "trace" plane in the GUI. Install this BEFORE the traced
193
+ * objects are constructed (hypertrace only traces objects created after the function is set).
194
+ * Dependency-free: the app supplies `setTraceFunction`, so the package never imports hypertrace.
195
+ * Returns a disposer that stops forwarding.
196
+ */
197
+ bridgeTraces(setTraceFunction: (fn: (trace: any) => void) => void): () => void;
198
+ /** Attach a hub connection later (auto-swarm calls this on 'connection'). */
199
+ attach(stream: WritableStreamLike): void;
200
+ stop(): void;
201
+ }
202
+
203
+ /** An exporter that buffers batches until a real stream is attached, then drains + goes live. */
204
+ function createDeferredExporter(): HyperswarmExporter & {
205
+ attach(stream: WritableStreamLike): void;
206
+ readonly pendingBatches: number;
207
+ } {
208
+ let live: HyperswarmExporter | null = null;
209
+ const buffered: unknown[][] = [];
210
+ return {
211
+ export(batch: unknown[]): void {
212
+ if (live) live.export(batch);
213
+ else buffered.push(batch);
214
+ },
215
+ attach(stream: WritableStreamLike): void {
216
+ live = createHyperswarmExporter(stream);
217
+ for (const b of buffered) live.export(b);
218
+ buffered.length = 0;
219
+ },
220
+ get pendingBatches(): number {
221
+ return buffered.length;
222
+ },
223
+ };
224
+ }
225
+
226
+ // Best-effort Hyperswarm auto-dial. Guarded dynamic import so this file is safe to load
227
+ // under Node/RN where hyperswarm isn't present.
228
+ // COVERAGE (honest): only the buffering→attach() HALF is tested (observe.test.mjs). This
229
+ // auto-dial trigger itself — detect Bare → import hyperswarm → join → on('connection') →
230
+ // attach — is UNVERIFIED here: no hyperswarm installed, no second peer/network (C14/G4).
231
+ async function autoSwarm(topic: string, onConnection: (stream: WritableStreamLike) => void): Promise<void> {
232
+ const Hyperswarm = (await import('hyperswarm')).default;
233
+ const crypto = (await import('hypercore-crypto')).default;
234
+ const swarm = new Hyperswarm();
235
+ const topicKey = crypto.hash(Buffer.from(topic));
236
+ swarm.on('connection', (conn: WritableStreamLike) => onConnection(conn));
237
+ await swarm.join(topicKey, { client: true, server: false }).flushed();
238
+ }
239
+
240
+ export function observe(opts: ObserveOptions = {}): ObserveHandle {
241
+ const info = detectRuntime();
242
+ const source = resolveSource(opts.source, info.runtime);
243
+
244
+ // Redaction is the choke point before anything leaves the device — computed FIRST so the
245
+ // source announce + `src` stamp can also be redacted. Default ON for export paths
246
+ // (hub / .p2plog); default OFF for the local WebSocket viewer (localhost, dev). `redact` wins.
247
+ const redactOn = opts.redact ?? (opts.websocket ? false : true);
248
+ const redactor: Redactor | null =
249
+ opts.redactor ?? (redactOn ? new Redactor({ bodyAllowlist: opts.bodyAllowlist }) : null);
250
+ // Identity ON THE WIRE — hashed ids / no human labels when redaction is on. `handle.source`
251
+ // keeps the original for the app's own use.
252
+ const wireSource = redactor ? redactSource(source, redactor) : source;
253
+
254
+ // --- Replay capability (opt-in, websocket-only). The GUI can re-run a call the app already made,
255
+ // referenced BY corrId: the METHOD is looked up in this client's own call log and never comes off
256
+ // the wire, so a replay cannot be redirected to a method the app never called. The ARGS may be
257
+ // overridden by the GUI and are not validated here beyond being a JSON array — opts.canReplay is
258
+ // the only argument check (see handleInvoke).
259
+ // Fails CLOSED: `allowInvoke: true` must be passed EXPLICITLY — no dev sniffing, so a production
260
+ // build that forgot to strip observe() still installs no inbound handler and advertises no caps.
261
+ const invokeEnabled = opts.allowInvoke === true && !!opts.websocket;
262
+ // Replay window: how many recent calls stay replayable. The GUI can show up to ~5000 rows, so a
263
+ // small window meant an older-but-still-visible row failed with 'unknown-corrId'. 2000 covers
264
+ // realistic "replay a call you just saw" on a chatty app while bounding memory (raw
265
+ // args are retained for replay fidelity — dev-only, never leaves the device). Tune via replayLogMax.
266
+ const replayLogMax = opts.replayLogMax ?? 2000;
267
+ const recentCalls = new Map<string, { method: string; args: unknown[] }>(); // corrId → its own call
268
+ const recordCall = invokeEnabled
269
+ ? (corrId: string, method: string, args: unknown[]) => {
270
+ recentCalls.set(corrId, { method, args });
271
+ if (recentCalls.size > replayLogMax) { const oldest = recentCalls.keys().next().value; if (oldest !== undefined) recentCalls.delete(oldest); }
272
+ }
273
+ : undefined;
274
+ let wrapped: Record<string, any> | null = null; // set when the app calls handle.wrapClient
275
+
276
+ let exporter: { export(batch: unknown[]): void };
277
+ let reporter: Reporter | null = null;
278
+ let deferred: ReturnType<typeof createDeferredExporter> | null = null;
279
+ let mode: ObserveHandle['mode'];
280
+
281
+ if (opts.exporter) {
282
+ exporter = opts.exporter;
283
+ mode = 'custom';
284
+ } else if (opts.websocket) {
285
+ // Reliable local-dev viewer for every runtime: WebSocket → GUI server. Announce our
286
+ // (possibly redacted) source identity on connect so the GUI can offer a device selector.
287
+ reporter = createWebSocketReporter(opts.websocket, {
288
+ // Advertise the replay capability so the GUI shows Replay only for opted-in sources.
289
+ announce: invokeEnabled ? { ...(wireSource as object), caps: { invoke: true } } : wireSource,
290
+ // Inbound handler ONLY when replay is enabled — otherwise the reporter stays send-only.
291
+ onMessage: invokeEnabled ? handleInvoke : undefined,
292
+ });
293
+ exporter = reporter;
294
+ mode = 'websocket';
295
+ } else if (opts.stream) {
296
+ exporter = createHyperswarmExporter(opts.stream);
297
+ mode = 'stream';
298
+ } else {
299
+ deferred = createDeferredExporter();
300
+ exporter = deferred;
301
+ mode = 'buffering';
302
+ }
303
+
304
+ const flusher = new BatchFlusher<L2Event>({
305
+ intervalMs: opts.intervalMs ?? 200,
306
+ onFlush: (batch) => {
307
+ const out = redactor ? redactor.redactBatch(batch) : batch;
308
+ // Stamp the (wire) source id so the GUI can group/select by device.
309
+ exporter.export(out.map((e) => ({ ...(e as object), src: wireSource.id })));
310
+ },
311
+ });
312
+ flusher.start();
313
+
314
+ const sink: EventSink = {
315
+ emit(event: L2Event): void {
316
+ // Never throw into the app: the flusher just stages.
317
+ try {
318
+ flusher.add(event);
319
+ } catch {
320
+ /* monitoring must not crash the app it observes */
321
+ }
322
+ },
323
+ };
324
+ // In-flight tracker — request/response calls that have started (request.start) but not yet
325
+ // settled (request.end). Maintained purely by watching the event stream, so it needs no change
326
+ // to wrapClient. Scope is deliberate: subscription streams announce via stream.open (not
327
+ // request.start), so a long-lived subscription is NOT counted as in-flight — otherwise every
328
+ // stall would be blamed on every open stream. Exposed as handle.inflight() so a thread-occupancy
329
+ // probe can BLAME a stall on the promise-returning call that was actually running.
330
+ // Bounded so a call that never settles can't leak memory (oldest evicted past the cap).
331
+ const INFLIGHT_MAX = 1000;
332
+ const inflightCalls = new Map<string, { method: string; corrId: string; t: number }>();
333
+ const report: Report = (event) => {
334
+ const e = event as any;
335
+ const corrId = e && typeof e.corrId === 'string' ? e.corrId : null;
336
+ if (corrId) {
337
+ if (e.type === 'request.start') {
338
+ inflightCalls.set(corrId, { method: String(e.method ?? ''), corrId, t: typeof e.t === 'number' ? e.t : Date.now() });
339
+ if (inflightCalls.size > INFLIGHT_MAX) { const oldest = inflightCalls.keys().next().value; if (oldest !== undefined) inflightCalls.delete(oldest); }
340
+ } else if (e.type === 'stream.open' || e.type === 'request.end') {
341
+ // request.end = the call settled. stream.open = this corrId is a subscription, not a
342
+ // request/response call (wrapFn emits request.start unconditionally, THEN stream.open) —
343
+ // drop it so long-lived subscriptions don't pollute every stall's blame list.
344
+ inflightCalls.delete(corrId);
345
+ }
346
+ }
347
+ sink.emit(event as L2Event);
348
+ };
349
+
350
+ // Surface an invoke failure back to the GUI over the SAME outbound event path (no side channel).
351
+ function emitInvokeError(invokeId: string, corrId: string, error: string): void {
352
+ report({ type: 'invoke.error', invokeId, replayOf: corrId, origin: 'gui', error, t: Date.now() });
353
+ }
354
+ // Dispatch a GUI replay request. `{__invoke:{corrId, invokeId}}` re-runs the app's OWN logged
355
+ // call for that corrId — an unknown corrId is rejected, so only real, already-made calls replay.
356
+ function handleInvoke(msg: any): void {
357
+ if (!invokeEnabled || !msg || !msg.__invoke) return;
358
+ const corrId = String(msg.__invoke.corrId ?? '');
359
+ const invokeId = String(msg.__invoke.invokeId ?? '');
360
+ if (!wrapped) return emitInvokeError(invokeId, corrId, 'client-not-wrapped');
361
+ const rec = recentCalls.get(corrId);
362
+ if (!rec) return emitInvokeError(invokeId, corrId, 'unknown-corrId'); // enforced: log-bound
363
+ // "Replay with edit": args MAY be overridden by the GUI, but the METHOD stays locked to the
364
+ // logged call (rec.method) — you can only re-send a call that actually happened with tweaked
365
+ // args, never invoke a method the app never made. Edited args must be a JSON array.
366
+ const edited = msg.__invoke.args;
367
+ if (edited !== undefined && !Array.isArray(edited)) return emitInvokeError(invokeId, corrId, 'bad-args');
368
+ const args = edited !== undefined ? edited : rec.args;
369
+ if (opts.canReplay && !opts.canReplay(rec.method, args)) return emitInvokeError(invokeId, corrId, 'blocked-by-canReplay');
370
+ const [ns, m] = rec.method.split('.', 2);
371
+ const fn = m ? wrapped?.[ns]?.[m] : (wrapped as any)?.[ns];
372
+ if (typeof fn !== 'function') return emitInvokeError(invokeId, corrId, 'method-unavailable');
373
+ try {
374
+ setInvokeContext({ invokeId, replayOf: corrId, method: rec.method }); // tags the re-run's events origin:'gui'
375
+ (m ? wrapped![ns][m] : (wrapped as any)[ns])(...args);
376
+ } catch (e) {
377
+ emitInvokeError(invokeId, corrId, 'threw: ' + String(e));
378
+ } finally {
379
+ setInvokeContext(null); // never let the tag leak onto a later organic call
380
+ }
381
+ }
382
+
383
+ const attach = (stream: WritableStreamLike): void => {
384
+ if (deferred) deferred.attach(stream);
385
+ };
386
+
387
+ // Auto-dial the hub when we're inside Bare/Pear (or an RN bare-kit worklet) and buffering.
388
+ if (mode === 'buffering' && info.isBareRuntime && opts.autoSwarm !== false) {
389
+ autoSwarm(opts.topic ?? DEFAULT_TOPIC, attach).catch(() => {
390
+ /* stay buffering; caller may attach() manually */
391
+ });
392
+ }
393
+
394
+ return {
395
+ sink,
396
+ flusher,
397
+ runtime: info.runtime,
398
+ mode,
399
+ redactor,
400
+ source,
401
+ report,
402
+ inflight: () => [...inflightCalls.values()],
403
+ wrapClient: (client, o) => { wrapped = wrapClient(client, report, { ...(o || {}), onCall: recordCall }); return wrapped as typeof client; },
404
+ bridgeTraces: (setTraceFunction) => {
405
+ let on = true;
406
+ try {
407
+ setTraceFunction((trace: any) => {
408
+ if (!on) return;
409
+ try {
410
+ const obj = (trace && trace.object) || {};
411
+ const caller = (trace && trace.caller) || {};
412
+ const data = caller.props; // the trace({...}) args
413
+ const event = (data && data.event) || caller.functionName || 'trace';
414
+ report({
415
+ type: 'trace', plane: 'trace',
416
+ className: obj.className, objId: obj.id, objProps: obj.props,
417
+ method: (obj.className ? obj.className + '.' : '') + event, // GUI groups/labels by this
418
+ event, data,
419
+ caller: caller.functionName ? caller.functionName + ':' + caller.line : undefined,
420
+ t: Date.now(),
421
+ });
422
+ } catch { /* a trace must never break the app it observes */ }
423
+ });
424
+ } catch { /* setTraceFunction unavailable — no-op */ }
425
+ return () => { on = false; };
426
+ },
427
+ attach,
428
+ stop: () => { flusher.stop(); reporter?.close(); },
429
+ };
430
+ }
@@ -0,0 +1,84 @@
1
+ // Reporters = where observed events go. The WebSocket reporter is the reliable local-dev
2
+ // path for EVERY runtime (RN, Pear/Bare, Node/Electron, browser): a plain WebSocket to the
3
+ // GUI server on your machine. Unlike the Hyperswarm hub (peer↔peer, DHT holepunched), a
4
+ // WebSocket to your dev host Just Works — it's how RN DevTools/Metro talk to your laptop.
5
+ //
6
+ // A reporter is `{ export(batch), close() }` — the same shape observe()'s flusher drives.
7
+
8
+ export interface Reporter {
9
+ export(batch: unknown[]): void;
10
+ close(): void;
11
+ }
12
+
13
+ export interface WebSocketReporterOptions {
14
+ /** WebSocket implementation. Default globalThis.WebSocket (RN/browser/Bare/Node ≥22 have it). */
15
+ WebSocketImpl?: any;
16
+ /** Max events buffered while disconnected (oldest dropped past this). Default 1000. */
17
+ maxQueue?: number;
18
+ /** Reconnect delay ms. Default 3000. */
19
+ reconnectMs?: number;
20
+ /** Source identity sent once per connection as `{ __source }` so the GUI can build a device selector. */
21
+ announce?: unknown;
22
+ /**
23
+ * Inbound frame handler. When set, the reporter becomes BIDIRECTIONAL — it installs
24
+ * `ws.onmessage` and forwards parsed frames here. observe() passes this ONLY in dev (for the
25
+ * replay/invoke path); leaving it undefined keeps the reporter strictly send-only, as before.
26
+ */
27
+ onMessage?: (msg: any) => void;
28
+ }
29
+
30
+ /**
31
+ * Send each event as one JSON WebSocket message to `url` (e.g. ws://127.0.0.1:9420/ws).
32
+ * Auto-connects, queues while down, flushes on open, reconnects on close. Never throws.
33
+ */
34
+ export function createWebSocketReporter(url: string, opts: WebSocketReporterOptions = {}): Reporter {
35
+ const WS = opts.WebSocketImpl ?? (globalThis as any).WebSocket;
36
+ const maxQueue = opts.maxQueue ?? 1000;
37
+ const reconnectMs = opts.reconnectMs ?? 3000;
38
+ const OPEN = 1;
39
+
40
+ let ws: any = null;
41
+ let queue: unknown[] = [];
42
+ let closed = false;
43
+
44
+ function enqueue(ev: unknown) {
45
+ queue.push(ev);
46
+ if (queue.length > maxQueue) queue.shift();
47
+ }
48
+ function rawSend(ev: unknown) {
49
+ try { ws.send(JSON.stringify(ev)); } catch { enqueue(ev); }
50
+ }
51
+ function ensure() {
52
+ if (closed || !WS) return;
53
+ if (ws && (ws.readyState === 0 || ws.readyState === 1)) return;
54
+ try {
55
+ ws = new WS(url);
56
+ ws.onopen = () => {
57
+ if (opts.announce !== undefined) rawSend({ __source: opts.announce }); // identify this device first
58
+ const q = queue; queue = []; for (const e of q) rawSend(e);
59
+ };
60
+ ws.onclose = () => { ws = null; if (!closed) setTimeout(ensure, reconnectMs); };
61
+ ws.onerror = () => {};
62
+ // Bidirectional only when a handler was supplied (dev/replay path). Never throws inward.
63
+ if (opts.onMessage) ws.onmessage = (m: any) => { try { opts.onMessage!(JSON.parse(m.data)); } catch { /* ignore malformed inbound */ } };
64
+ } catch {
65
+ ws = null;
66
+ }
67
+ }
68
+
69
+ return {
70
+ export(batch: unknown[]) {
71
+ if (closed) return;
72
+ ensure();
73
+ for (const ev of batch) {
74
+ if (ws && ws.readyState === OPEN) rawSend(ev);
75
+ else enqueue(ev);
76
+ }
77
+ },
78
+ close() {
79
+ closed = true;
80
+ try { ws?.close(); } catch {}
81
+ ws = null;
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,140 @@
1
+ // Generic RPC/service-client tap — the most useful and portable way to observe an app.
2
+ //
3
+ // Works for ANY object of namespaced methods that return a Promise (request/response) OR a
4
+ // stream (subscription): an app-core RPC client, a libp2p service, your own service object.
5
+ // Runtime-agnostic (RN, Pear/Bare, Node, Electron, browser) — it only touches JS objects.
6
+ //
7
+ // Emits (via `report`) the same event vocabulary the collector/GUI understand:
8
+ // request.start {method,args,corrId} → a call began
9
+ // request.end {method,status,response|error,dur,corrId} → promise settled / stream closed
10
+ // stream.open {method,corrId} → a subscription stream began
11
+ // stream.data {method,seq,item,corrId} → one item pushed by a subscription
12
+
13
+ export type Report = (event: Record<string, unknown>) => void;
14
+
15
+ export interface WrapClientOptions {
16
+ /** Summarise args/response/stream items before reporting. Default: bounded JSON. */
17
+ preview?: (v: unknown) => unknown;
18
+ /** Monitor subscription streams' data/end/error. Default true. */
19
+ monitorStreams?: boolean;
20
+ /**
21
+ * Called with the RAW (un-previewed) args at each call site, keyed by the minted corrId.
22
+ * observe() uses this to keep a bounded call-log so a GUI can REPLAY a logged call by corrId.
23
+ * The METHOD comes from this log, never from the wire, so a replay cannot be redirected to a
24
+ * method the app never called. ARGS, however, may be supplied by the GUI (edit-and-replay) and
25
+ * are not validated by this library — the app's `canReplay(method, args)` hook is the only
26
+ * argument check. Wired only when `allowInvoke: true` is passed explicitly.
27
+ */
28
+ onCall?: (corrId: string, method: string, args: unknown[]) => void;
29
+ }
30
+
31
+ let seq = 0;
32
+
33
+ // Consume-once context for tagging a REPLAYED call's events as GUI-originated. The dispatcher
34
+ // (observe.invoke) sets this immediately before invoking the wrapped fn; wrapFn reads it on its
35
+ // synchronous first line and clears it. The `method` guard means a stray organic call that somehow
36
+ // runs in between cannot steal the tag — it only applies to the exact method being replayed.
37
+ let invokeCtx: { invokeId: string; replayOf: string; method: string } | null = null;
38
+ export function setInvokeContext(ctx: { invokeId: string; replayOf: string; method: string } | null): void {
39
+ invokeCtx = ctx;
40
+ }
41
+
42
+ // Keep realistic RPC bodies WHOLE so the GUI can render them as a foldable tree — the old 4000B
43
+ // cap collapsed anything larger (e.g. a message list) into a truncated STRING, which the inspector
44
+ // then showed as a string, not JSON. 64KB covers normal responses; the local viewer is localhost +
45
+ // redaction-off, and export paths redact bodies regardless of this preview. Above the cap we keep a
46
+ // STRUCTURED marker (still tree-able), never a raw truncated string.
47
+ const PREVIEW_MAX_BYTES = 64_000;
48
+ function defaultPreview(v: unknown): unknown {
49
+ try {
50
+ const s = JSON.stringify(v);
51
+ if (s === undefined) return String(v);
52
+ if (s.length <= PREVIEW_MAX_BYTES) return v; // small enough: keep structure
53
+ if (typeof v === 'string') return v.slice(0, PREVIEW_MAX_BYTES) + `…[truncated ${s.length}B]`;
54
+ return { __truncated: true, bytes: s.length, preview: s.slice(0, 2000) + '…' }; // object stays tree-able
55
+ } catch {
56
+ return { __unserializable: true };
57
+ }
58
+ }
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.
64
+ function monitorStream(report: Report, endpoint: string, corrId: string, stream: any, t0: number, preview: (v: unknown) => unknown, tag: Record<string, unknown>): void {
65
+ report({ type: 'stream.open', method: endpoint, corrId, t: Date.now(), ...tag });
66
+ let n = 0;
67
+ let ended = false;
68
+ const settle = (status: string, extra?: Record<string, unknown>) => {
69
+ if (ended) return;
70
+ ended = true;
71
+ report({ type: 'request.end', method: endpoint, corrId, status, count: n, dur: Date.now() - t0, t: Date.now(), ...tag, ...(extra || {}) });
72
+ };
73
+ 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'));
78
+ } catch (e) {
79
+ report({ type: 'request.end', method: endpoint, corrId, status: 'error', error: 'monitor-failed: ' + String(e), dur: 0, t: Date.now(), ...tag });
80
+ }
81
+ }
82
+
83
+ function wrapFn(report: Report, endpoint: string, fn: any, self: any, preview: (v: unknown) => unknown, monitorStreams: boolean, onCall?: (corrId: string, method: string, args: unknown[]) => void): any {
84
+ return function observed(this: unknown, ...args: unknown[]) {
85
+ const corrId = 'rpc-' + ++seq;
86
+ // Consume the invoke tag ONCE, and only for the exact method being replayed.
87
+ let tag: Record<string, unknown> = {};
88
+ if (invokeCtx && invokeCtx.method === endpoint) {
89
+ tag = { origin: 'gui', invokeId: invokeCtx.invokeId, replayOf: invokeCtx.replayOf };
90
+ invokeCtx = null;
91
+ }
92
+ if (onCall) { try { onCall(corrId, endpoint, args); } catch { /* logging must not break the call */ } }
93
+ const t = Date.now();
94
+ report({ type: 'request.start', method: endpoint, corrId, args: preview(args), t, ...tag });
95
+ let result: any;
96
+ try {
97
+ result = fn.apply(self, args);
98
+ } catch (err) {
99
+ report({ type: 'request.end', method: endpoint, corrId, status: 'error', error: String(err), dur: Date.now() - t, t: Date.now(), ...tag });
100
+ throw err;
101
+ }
102
+ if (result && typeof result.then === 'function') {
103
+ result.then(
104
+ (res: unknown) => report({ type: 'request.end', method: endpoint, corrId, status: 'ok', response: preview(res), dur: Date.now() - t, t: Date.now(), ...tag }),
105
+ (err: unknown) => report({ type: 'request.end', method: endpoint, corrId, status: 'error', error: String(err), dur: Date.now() - t, t: Date.now(), ...tag })
106
+ );
107
+ } else if (monitorStreams && result && typeof result.on === 'function') {
108
+ monitorStream(report, endpoint, corrId, result, t, preview, tag);
109
+ } else {
110
+ report({ type: 'request.end', method: endpoint, corrId, status: 'ok', response: preview(result), dur: Date.now() - t, t: Date.now(), ...tag });
111
+ }
112
+ return result;
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Wrap a client object of namespaced methods (`client.namespace.method(...)`) so every call
118
+ * reports endpoint + request + response/stream. Mutates and returns the same client; method
119
+ * return values and `this` binding are preserved (a wrapped call behaves identically).
120
+ */
121
+ export function wrapClient<T extends Record<string, any>>(client: T, report: Report, opts: WrapClientOptions = {}): T {
122
+ if (!client || typeof client !== 'object') return client;
123
+ const preview = opts.preview ?? defaultPreview;
124
+ const monitorStreams = opts.monitorStreams !== false;
125
+ try {
126
+ for (const ns of Object.keys(client)) {
127
+ const group = (client as any)[ns];
128
+ if (group && typeof group === 'object') {
129
+ for (const m of Object.keys(group)) {
130
+ if (typeof group[m] === 'function') group[m] = wrapFn(report, `${ns}.${m}`, group[m], group, preview, monitorStreams, opts.onCall);
131
+ }
132
+ } else if (typeof group === 'function') {
133
+ (client as any)[ns] = wrapFn(report, ns, group, client, preview, monitorStreams, opts.onCall);
134
+ }
135
+ }
136
+ } catch {
137
+ /* never break the client */
138
+ }
139
+ return client;
140
+ }