hrpc-inspector 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -28,9 +28,12 @@ function createWebSocketReporter(url, opts = {}) {
28
28
  const maxQueue = opts.maxQueue ?? 1e3;
29
29
  const reconnectMs = opts.reconnectMs ?? 3e3;
30
30
  const OPEN = 1;
31
+ const maxReconnectMs = opts.maxReconnectMs ?? 3e4;
31
32
  let ws = null;
32
33
  let queue = [];
33
34
  let closed = false;
35
+ let retryTimer = null;
36
+ let backoffMs = reconnectMs;
34
37
  function enqueue(ev) {
35
38
  queue.push(ev);
36
39
  if (queue.length > maxQueue) queue.shift();
@@ -42,12 +45,23 @@ function createWebSocketReporter(url, opts = {}) {
42
45
  enqueue(ev);
43
46
  }
44
47
  }
48
+ function scheduleRetry() {
49
+ if (closed || retryTimer) return;
50
+ retryTimer = setTimeout(() => {
51
+ retryTimer = null;
52
+ ensure();
53
+ }, backoffMs);
54
+ backoffMs = Math.min(backoffMs * 2, maxReconnectMs);
55
+ if (typeof retryTimer?.unref === "function") retryTimer.unref();
56
+ }
45
57
  function ensure() {
46
58
  if (closed || !WS) return;
47
59
  if (ws && (ws.readyState === 0 || ws.readyState === 1)) return;
60
+ if (retryTimer) return;
48
61
  try {
49
62
  ws = new WS(url);
50
63
  ws.onopen = () => {
64
+ backoffMs = reconnectMs;
51
65
  if (opts.announce !== void 0) rawSend({ __source: opts.announce });
52
66
  const q = queue;
53
67
  queue = [];
@@ -55,7 +69,7 @@ function createWebSocketReporter(url, opts = {}) {
55
69
  };
56
70
  ws.onclose = () => {
57
71
  ws = null;
58
- if (!closed) setTimeout(ensure, reconnectMs);
72
+ scheduleRetry();
59
73
  };
60
74
  ws.onerror = () => {
61
75
  };
@@ -67,6 +81,7 @@ function createWebSocketReporter(url, opts = {}) {
67
81
  };
68
82
  } catch {
69
83
  ws = null;
84
+ scheduleRetry();
70
85
  }
71
86
  }
72
87
  return {
@@ -80,6 +95,10 @@ function createWebSocketReporter(url, opts = {}) {
80
95
  },
81
96
  close() {
82
97
  closed = true;
98
+ if (retryTimer) {
99
+ clearTimeout(retryTimer);
100
+ retryTimer = null;
101
+ }
83
102
  try {
84
103
  ws?.close();
85
104
  } catch {
@@ -117,16 +136,27 @@ function monitorStream(report, endpoint, corrId, stream, t0, preview, tag) {
117
136
  report({ type: "request.end", method: endpoint, corrId, status, count: n, dur: Date.now() - t0, t: Date.now(), ...tag, ...extra || {} });
118
137
  };
119
138
  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"));
139
+ const original = stream.emit;
140
+ if (typeof original !== "function") return;
141
+ stream.emit = function patched(name, ...args) {
142
+ try {
143
+ if (name === "data") {
144
+ report({ type: "stream.data", method: endpoint, corrId, seq: ++n, item: preview(args[0]), t: Date.now(), ...tag });
145
+ } else if (name === "error") {
146
+ settle("error", { error: String(args[0]) });
147
+ } else if (name === "end" || name === "close") {
148
+ settle("closed");
149
+ }
150
+ } catch {
151
+ }
152
+ return original.apply(this, [name, ...args]);
153
+ };
124
154
  } catch (e) {
125
155
  report({ type: "request.end", method: endpoint, corrId, status: "error", error: "monitor-failed: " + String(e), dur: 0, t: Date.now(), ...tag });
126
156
  }
127
157
  }
128
158
  function wrapFn(report, endpoint, fn, self, preview, monitorStreams, onCall) {
129
- return function observed(...args) {
159
+ const wrapped = function observed(...args) {
130
160
  const corrId = "rpc-" + ++seq;
131
161
  let tag = {};
132
162
  if (invokeCtx && invokeCtx.method === endpoint) {
@@ -160,6 +190,12 @@ function wrapFn(report, endpoint, fn, self, preview, monitorStreams, onCall) {
160
190
  }
161
191
  return result;
162
192
  };
193
+ try {
194
+ Object.defineProperty(wrapped, "name", { value: fn.name, configurable: true });
195
+ Object.defineProperty(wrapped, "length", { value: fn.length, configurable: true });
196
+ } catch {
197
+ }
198
+ return wrapped;
163
199
  }
164
200
  function wrapClient(client, report, opts = {}) {
165
201
  if (!client || typeof client !== "object") return client;
@@ -257,6 +293,8 @@ function observe(opts = {}) {
257
293
  const source = resolveSource(opts.source, info.runtime);
258
294
  const redactOn = opts.redact ?? (opts.websocket ? false : true);
259
295
  const redactor = opts.redactor ?? (redactOn ? new Redactor({ bodyAllowlist: opts.bodyAllowlist }) : null);
296
+ const redactMethods = new Set(opts.redactMethods ?? []);
297
+ const methodRedactor = !redactor && redactMethods.size > 0 ? new Redactor() : null;
260
298
  const wireSource = redactor ? redactSource(source, redactor) : source;
261
299
  const invokeEnabled = opts.allowInvoke === true && !!opts.websocket;
262
300
  const replayLogMax = opts.replayLogMax ?? 2e3;
@@ -296,7 +334,9 @@ function observe(opts = {}) {
296
334
  const flusher = new BatchFlusher({
297
335
  intervalMs: opts.intervalMs ?? 200,
298
336
  onFlush: (batch) => {
299
- const out = redactor ? redactor.redactBatch(batch) : batch;
337
+ const out = redactor ? redactor.redactBatch(batch) : methodRedactor ? batch.map(
338
+ (e) => redactMethods.has(String(e.method)) ? methodRedactor.redact(e) : e
339
+ ) : batch;
300
340
  exporter.export(out.map((e) => ({ ...e, src: wireSource.id })));
301
341
  }
302
342
  });
package/dist/observe.d.ts CHANGED
@@ -42,6 +42,15 @@ export interface ObserveOptions {
42
42
  * Set false ONLY for a purely-local view that never leaves the machine (F3/F4/F9).
43
43
  */
44
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>;
45
54
  /** Supply a shared Redactor (e.g. to reuse its local peer reverse-map). Default: a fresh one. */
46
55
  redactor?: Redactor;
47
56
  /** Per-`method` opt-in allowlist for full payload bodies (default: none — bodies summarised). */
package/dist/observe.js CHANGED
@@ -28,9 +28,12 @@ function createWebSocketReporter(url, opts = {}) {
28
28
  const maxQueue = opts.maxQueue ?? 1e3;
29
29
  const reconnectMs = opts.reconnectMs ?? 3e3;
30
30
  const OPEN = 1;
31
+ const maxReconnectMs = opts.maxReconnectMs ?? 3e4;
31
32
  let ws = null;
32
33
  let queue = [];
33
34
  let closed = false;
35
+ let retryTimer = null;
36
+ let backoffMs = reconnectMs;
34
37
  function enqueue(ev) {
35
38
  queue.push(ev);
36
39
  if (queue.length > maxQueue) queue.shift();
@@ -42,12 +45,23 @@ function createWebSocketReporter(url, opts = {}) {
42
45
  enqueue(ev);
43
46
  }
44
47
  }
48
+ function scheduleRetry() {
49
+ if (closed || retryTimer) return;
50
+ retryTimer = setTimeout(() => {
51
+ retryTimer = null;
52
+ ensure();
53
+ }, backoffMs);
54
+ backoffMs = Math.min(backoffMs * 2, maxReconnectMs);
55
+ if (typeof retryTimer?.unref === "function") retryTimer.unref();
56
+ }
45
57
  function ensure() {
46
58
  if (closed || !WS) return;
47
59
  if (ws && (ws.readyState === 0 || ws.readyState === 1)) return;
60
+ if (retryTimer) return;
48
61
  try {
49
62
  ws = new WS(url);
50
63
  ws.onopen = () => {
64
+ backoffMs = reconnectMs;
51
65
  if (opts.announce !== void 0) rawSend({ __source: opts.announce });
52
66
  const q = queue;
53
67
  queue = [];
@@ -55,7 +69,7 @@ function createWebSocketReporter(url, opts = {}) {
55
69
  };
56
70
  ws.onclose = () => {
57
71
  ws = null;
58
- if (!closed) setTimeout(ensure, reconnectMs);
72
+ scheduleRetry();
59
73
  };
60
74
  ws.onerror = () => {
61
75
  };
@@ -67,6 +81,7 @@ function createWebSocketReporter(url, opts = {}) {
67
81
  };
68
82
  } catch {
69
83
  ws = null;
84
+ scheduleRetry();
70
85
  }
71
86
  }
72
87
  return {
@@ -80,6 +95,10 @@ function createWebSocketReporter(url, opts = {}) {
80
95
  },
81
96
  close() {
82
97
  closed = true;
98
+ if (retryTimer) {
99
+ clearTimeout(retryTimer);
100
+ retryTimer = null;
101
+ }
83
102
  try {
84
103
  ws?.close();
85
104
  } catch {
@@ -117,16 +136,27 @@ function monitorStream(report, endpoint, corrId, stream, t0, preview, tag) {
117
136
  report({ type: "request.end", method: endpoint, corrId, status, count: n, dur: Date.now() - t0, t: Date.now(), ...tag, ...extra || {} });
118
137
  };
119
138
  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"));
139
+ const original = stream.emit;
140
+ if (typeof original !== "function") return;
141
+ stream.emit = function patched(name, ...args) {
142
+ try {
143
+ if (name === "data") {
144
+ report({ type: "stream.data", method: endpoint, corrId, seq: ++n, item: preview(args[0]), t: Date.now(), ...tag });
145
+ } else if (name === "error") {
146
+ settle("error", { error: String(args[0]) });
147
+ } else if (name === "end" || name === "close") {
148
+ settle("closed");
149
+ }
150
+ } catch {
151
+ }
152
+ return original.apply(this, [name, ...args]);
153
+ };
124
154
  } catch (e) {
125
155
  report({ type: "request.end", method: endpoint, corrId, status: "error", error: "monitor-failed: " + String(e), dur: 0, t: Date.now(), ...tag });
126
156
  }
127
157
  }
128
158
  function wrapFn(report, endpoint, fn, self, preview, monitorStreams, onCall) {
129
- return function observed(...args) {
159
+ const wrapped = function observed(...args) {
130
160
  const corrId = "rpc-" + ++seq;
131
161
  let tag = {};
132
162
  if (invokeCtx && invokeCtx.method === endpoint) {
@@ -160,6 +190,12 @@ function wrapFn(report, endpoint, fn, self, preview, monitorStreams, onCall) {
160
190
  }
161
191
  return result;
162
192
  };
193
+ try {
194
+ Object.defineProperty(wrapped, "name", { value: fn.name, configurable: true });
195
+ Object.defineProperty(wrapped, "length", { value: fn.length, configurable: true });
196
+ } catch {
197
+ }
198
+ return wrapped;
163
199
  }
164
200
  function wrapClient(client, report, opts = {}) {
165
201
  if (!client || typeof client !== "object") return client;
@@ -257,6 +293,8 @@ function observe(opts = {}) {
257
293
  const source = resolveSource(opts.source, info.runtime);
258
294
  const redactOn = opts.redact ?? (opts.websocket ? false : true);
259
295
  const redactor = opts.redactor ?? (redactOn ? new Redactor({ bodyAllowlist: opts.bodyAllowlist }) : null);
296
+ const redactMethods = new Set(opts.redactMethods ?? []);
297
+ const methodRedactor = !redactor && redactMethods.size > 0 ? new Redactor() : null;
260
298
  const wireSource = redactor ? redactSource(source, redactor) : source;
261
299
  const invokeEnabled = opts.allowInvoke === true && !!opts.websocket;
262
300
  const replayLogMax = opts.replayLogMax ?? 2e3;
@@ -296,7 +334,9 @@ function observe(opts = {}) {
296
334
  const flusher = new BatchFlusher({
297
335
  intervalMs: opts.intervalMs ?? 200,
298
336
  onFlush: (batch) => {
299
- const out = redactor ? redactor.redactBatch(batch) : batch;
337
+ const out = redactor ? redactor.redactBatch(batch) : methodRedactor ? batch.map(
338
+ (e) => redactMethods.has(String(e.method)) ? methodRedactor.redact(e) : e
339
+ ) : batch;
300
340
  exporter.export(out.map((e) => ({ ...e, src: wireSource.id })));
301
341
  }
302
342
  });
@@ -17,6 +17,8 @@ export interface WebSocketReporterOptions {
17
17
  * replay/invoke path); leaving it undefined keeps the reporter strictly send-only, as before.
18
18
  */
19
19
  onMessage?: (msg: any) => void;
20
+ /** Ceiling for the reconnect backoff, in ms. Default 30000. */
21
+ maxReconnectMs?: number;
20
22
  }
21
23
  /**
22
24
  * Send each event as one JSON WebSocket message to `url` (e.g. ws://127.0.0.1:9420/ws).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hrpc-inspector",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",
@@ -63,6 +63,7 @@
63
63
  },
64
64
  "devDependencies": {
65
65
  "esbuild": "^0.28.2",
66
+ "streamx": "^2.28.1",
66
67
  "typescript": "^7.0.2"
67
68
  }
68
69
  }
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). */
@@ -249,6 +258,10 @@ export function observe(opts: ObserveOptions = {}): ObserveHandle {
249
258
  const redactOn = opts.redact ?? (opts.websocket ? false : true);
250
259
  const redactor: Redactor | null =
251
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;
252
265
  // Identity ON THE WIRE — hashed ids / no human labels when redaction is on. `handle.source`
253
266
  // keeps the original for the app's own use.
254
267
  const wireSource = redactor ? redactSource(source, redactor) : source;
@@ -306,7 +319,15 @@ export function observe(opts: ObserveOptions = {}): ObserveHandle {
306
319
  const flusher = new BatchFlusher<L2Event>({
307
320
  intervalMs: opts.intervalMs ?? 200,
308
321
  onFlush: (batch) => {
309
- 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;
310
331
  // Stamp the (wire) source id so the GUI can group/select by device.
311
332
  exporter.export(out.map((e) => ({ ...(e as object), src: wireSource.id })));
312
333
  },
package/src/reporters.ts CHANGED
@@ -25,6 +25,8 @@ export interface WebSocketReporterOptions {
25
25
  * replay/invoke path); leaving it undefined keeps the reporter strictly send-only, as before.
26
26
  */
27
27
  onMessage?: (msg: any) => void;
28
+ /** Ceiling for the reconnect backoff, in ms. Default 30000. */
29
+ maxReconnectMs?: number;
28
30
  }
29
31
 
30
32
  /**
@@ -37,9 +39,17 @@ export function createWebSocketReporter(url: string, opts: WebSocketReporterOpti
37
39
  const reconnectMs = opts.reconnectMs ?? 3000;
38
40
  const OPEN = 1;
39
41
 
42
+ const maxReconnectMs = opts.maxReconnectMs ?? 30000;
43
+
40
44
  let ws: any = null;
41
45
  let queue: unknown[] = [];
42
46
  let closed = false;
47
+ // A reconnect already scheduled. Without this, export() — which runs on every flush, i.e. every
48
+ // ~200ms — would open a fresh socket the moment the previous one failed, defeating the backoff
49
+ // entirely. A browser logs EVERY refused WebSocket connect to the console itself, before any
50
+ // onerror handler runs, so that turned "no GUI running" into a console flooded at 5 errors/sec.
51
+ let retryTimer: any = null;
52
+ let backoffMs = reconnectMs;
43
53
 
44
54
  function enqueue(ev: unknown) {
45
55
  queue.push(ev);
@@ -48,21 +58,33 @@ export function createWebSocketReporter(url: string, opts: WebSocketReporterOpti
48
58
  function rawSend(ev: unknown) {
49
59
  try { ws.send(JSON.stringify(ev)); } catch { enqueue(ev); }
50
60
  }
61
+ function scheduleRetry() {
62
+ if (closed || retryTimer) return;
63
+ retryTimer = setTimeout(() => { retryTimer = null; ensure(); }, backoffMs);
64
+ // Widen the gap each time the viewer stays absent, so a dev session with no GUI settles at one
65
+ // attempt per maxReconnectMs instead of one per flush. Reset to `reconnectMs` on a real open.
66
+ backoffMs = Math.min(backoffMs * 2, maxReconnectMs);
67
+ if (typeof retryTimer?.unref === 'function') retryTimer.unref();
68
+ }
69
+
51
70
  function ensure() {
52
71
  if (closed || !WS) return;
53
72
  if (ws && (ws.readyState === 0 || ws.readyState === 1)) return;
73
+ if (retryTimer) return; // a reconnect is already pending — do not stack another socket on top
54
74
  try {
55
75
  ws = new WS(url);
56
76
  ws.onopen = () => {
77
+ backoffMs = reconnectMs; // the viewer is back; retry promptly if it goes away again
57
78
  if (opts.announce !== undefined) rawSend({ __source: opts.announce }); // identify this device first
58
79
  const q = queue; queue = []; for (const e of q) rawSend(e);
59
80
  };
60
- ws.onclose = () => { ws = null; if (!closed) setTimeout(ensure, reconnectMs); };
81
+ ws.onclose = () => { ws = null; scheduleRetry(); };
61
82
  ws.onerror = () => {};
62
83
  // Bidirectional only when a handler was supplied (dev/replay path). Never throws inward.
63
84
  if (opts.onMessage) ws.onmessage = (m: any) => { try { opts.onMessage!(JSON.parse(m.data)); } catch { /* ignore malformed inbound */ } };
64
85
  } catch {
65
86
  ws = null;
87
+ scheduleRetry();
66
88
  }
67
89
  }
68
90
 
@@ -77,6 +99,7 @@ export function createWebSocketReporter(url: string, opts: WebSocketReporterOpti
77
99
  },
78
100
  close() {
79
101
  closed = true;
102
+ if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
80
103
  try { ws?.close(); } catch {}
81
104
  ws = null;
82
105
  },
@@ -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,17 +77,31 @@ 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
  }
81
101
  }
82
102
 
83
103
  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[]) {
104
+ const wrapped = function observed(this: unknown, ...args: unknown[]) {
85
105
  const corrId = 'rpc-' + ++seq;
86
106
  // Consume the invoke tag ONCE, and only for the exact method being replayed.
87
107
  let tag: Record<string, unknown> = {};
@@ -111,6 +131,18 @@ function wrapFn(report: Report, endpoint: string, fn: any, self: any, preview: (
111
131
  }
112
132
  return result;
113
133
  };
134
+
135
+ // Carry the original identity across. An app may read `fn.name` — keet's own System Log does
136
+ // (`method: apiFn.name`), and every entry showed "observed" instead of the endpoint. `length`
137
+ // goes too, since arity is the other thing reflection commonly checks. Both are configurable
138
+ // on a function, so this cannot throw; guarded anyway, because the tap must never break a client.
139
+ try {
140
+ Object.defineProperty(wrapped, 'name', { value: fn.name, configurable: true });
141
+ Object.defineProperty(wrapped, 'length', { value: fn.length, configurable: true });
142
+ } catch {
143
+ /* identity is a nicety; never fail the wrap over it */
144
+ }
145
+ return wrapped;
114
146
  }
115
147
 
116
148
  /**