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 +47 -7
- package/dist/observe.d.ts +9 -0
- package/dist/observe.js +47 -7
- package/dist/reporters.d.ts +2 -0
- package/package.json +2 -1
- package/src/observe.ts +22 -1
- package/src/reporters.ts +24 -1
- package/src/wrap-client.ts +41 -9
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
stream.
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
stream.
|
|
123
|
-
|
|
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
|
-
|
|
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/reporters.d.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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;
|
|
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
|
},
|
package/src/wrap-client.ts
CHANGED
|
@@ -57,10 +57,16 @@ function defaultPreview(v: unknown): unknown {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
60
|
+
// Observe a subscription stream WITHOUT changing its behaviour.
|
|
61
|
+
//
|
|
62
|
+
// This wraps `emit` instead of calling `.on('data', ...)`. Registering a data listener is not
|
|
63
|
+
// passive: it puts the stream into flowing mode immediately, so anything emitted before the app
|
|
64
|
+
// attaches its own listener is delivered to us and LOST to the app. That is a debug-only data
|
|
65
|
+
// loss — the app silently misses items, with no error — and it is real: keet's RPC subscriptions
|
|
66
|
+
// return their stream synchronously, while the consumer attaches a tick later.
|
|
67
|
+
//
|
|
68
|
+
// Wrapping `emit` cannot cause that: we only see events the stream was already delivering. If
|
|
69
|
+
// nothing consumes the stream, nothing is emitted and we report nothing, which is the truth.
|
|
64
70
|
function monitorStream(report: Report, endpoint: string, corrId: string, stream: any, t0: number, preview: (v: unknown) => unknown, tag: Record<string, unknown>): void {
|
|
65
71
|
report({ type: 'stream.open', method: endpoint, corrId, t: Date.now(), ...tag });
|
|
66
72
|
let n = 0;
|
|
@@ -71,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
|
-
|
|
75
|
-
|
|
76
|
-
stream.
|
|
77
|
-
|
|
80
|
+
const original = stream.emit;
|
|
81
|
+
if (typeof original !== 'function') return;
|
|
82
|
+
stream.emit = function patched(this: unknown, name: string, ...args: unknown[]): unknown {
|
|
83
|
+
// Observation must never affect delivery: record, then hand off unchanged. A throw in the
|
|
84
|
+
// reporting path would surface as a stream error in the app, so it is swallowed.
|
|
85
|
+
try {
|
|
86
|
+
if (name === 'data') {
|
|
87
|
+
report({ type: 'stream.data', method: endpoint, corrId, seq: ++n, item: preview(args[0]), t: Date.now(), ...tag });
|
|
88
|
+
} else if (name === 'error') {
|
|
89
|
+
settle('error', { error: String(args[0]) });
|
|
90
|
+
} else if (name === 'end' || name === 'close') {
|
|
91
|
+
settle('closed');
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
/* never break the stream */
|
|
95
|
+
}
|
|
96
|
+
return original.apply(this, [name, ...args] as never);
|
|
97
|
+
};
|
|
78
98
|
} catch (e) {
|
|
79
99
|
report({ type: 'request.end', method: endpoint, corrId, status: 'error', error: 'monitor-failed: ' + String(e), dur: 0, t: Date.now(), ...tag });
|
|
80
100
|
}
|
|
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
|
-
|
|
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
|
/**
|