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/LICENSE +202 -0
- package/NOTICE +16 -0
- package/README.md +65 -0
- package/USAGE.md +180 -0
- package/bin/hrpc-inspector.mjs +79 -0
- package/dist/detect.js +21 -0
- package/dist/index.js +441 -0
- package/dist/observe.js +417 -0
- package/gui/index.html +996 -0
- package/gui/server.mjs +240 -0
- package/package.json +65 -1
- package/src/detect.ts +49 -0
- package/src/index.ts +37 -0
- package/src/init.mjs +89 -0
- package/src/observe.ts +430 -0
- package/src/reporters.ts +84 -0
- package/src/wrap-client.ts +140 -0
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
|
+
}
|
package/src/reporters.ts
ADDED
|
@@ -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
|
+
}
|