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/gui/server.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// GUI server — DEPENDENCY-FREE (no `ws`), so `npx hrpc-inspector gui` needs zero install.
|
|
2
|
+
// Serves the inspector and accepts events over a hand-rolled RFC6455 WebSocket at /ws.
|
|
3
|
+
// Apps stream events in; browsers (identified by a {__hello:'browser'} frame) get history +
|
|
4
|
+
// live fan-out. Node built-ins only: http + crypto.
|
|
5
|
+
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
13
|
+
|
|
14
|
+
// CSRF / cross-origin guard for the WS upgrade. A browser WebSocket ALWAYS sends an Origin header,
|
|
15
|
+
// so this stops a random page you're visiting from opening ws://127.0.0.1:PORT and driving replay
|
|
16
|
+
// (the 127.0.0.1 bind is NOT an access boundary — any local page can reach it). App reporters
|
|
17
|
+
// (Node/RN, non-browser) send NO Origin header at all and are allowed. Allowed: absent Origin,
|
|
18
|
+
// loopback Origin, or same-host as the served page (covers a deliberate LAN --host bind).
|
|
19
|
+
//
|
|
20
|
+
// Origin: null is REJECTED. It is not a non-browser marker: a sandboxed or cross-origin-redirected
|
|
21
|
+
// browsing context serializes its OPAQUE origin as exactly the string "null", so allowing it lets
|
|
22
|
+
// any page a developer visits open ws://127.0.0.1:PORT inside a sandboxed iframe and read the whole
|
|
23
|
+
// unredacted history, drive replay, and send __clear. Absent-Origin already covers real reporters.
|
|
24
|
+
// `allowNullOrigin` exists only for a runtime whose WebSocket client genuinely sends "null"; it
|
|
25
|
+
// re-opens the hole for browsers too, so it is off by default and warns loudly when on.
|
|
26
|
+
export function originAllowed(req, { allowNullOrigin = false } = {}) {
|
|
27
|
+
const origin = req.headers['origin'];
|
|
28
|
+
if (!origin) return true; // non-browser client (app reporter)
|
|
29
|
+
if (origin === 'null') return allowNullOrigin === true; // opaque origin — a browser, unless opted in
|
|
30
|
+
let host;
|
|
31
|
+
try { host = new URL(origin).hostname; } catch { return false; } // malformed → reject
|
|
32
|
+
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') return true;
|
|
33
|
+
const reqHost = String(req.headers['host'] || '').split(':')[0];
|
|
34
|
+
return host === reqHost; // same-origin as the page we served
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---- minimal WebSocket frame codec (text frames; masks client→server; handles ping/close) ----
|
|
38
|
+
function encodeFrame(payload, opcode = 0x1) {
|
|
39
|
+
const len = payload.length;
|
|
40
|
+
let header;
|
|
41
|
+
if (len < 126) header = Buffer.from([0x80 | opcode, len]);
|
|
42
|
+
else if (len < 65536) { header = Buffer.alloc(4); header[0] = 0x80 | opcode; header[1] = 126; header.writeUInt16BE(len, 2); }
|
|
43
|
+
else { header = Buffer.alloc(10); header[0] = 0x80 | opcode; header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2); }
|
|
44
|
+
return Buffer.concat([header, payload]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function wsConnection(socket, onText, onClose) {
|
|
48
|
+
let buf = Buffer.alloc(0);
|
|
49
|
+
let closed = false;
|
|
50
|
+
const done = () => { if (!closed) { closed = true; onClose(); } };
|
|
51
|
+
socket.on('data', (chunk) => {
|
|
52
|
+
buf = Buffer.concat([buf, chunk]);
|
|
53
|
+
for (;;) {
|
|
54
|
+
if (buf.length < 2) return;
|
|
55
|
+
const opcode = buf[0] & 0x0f;
|
|
56
|
+
const masked = (buf[1] & 0x80) !== 0;
|
|
57
|
+
let len = buf[1] & 0x7f;
|
|
58
|
+
let offset = 2;
|
|
59
|
+
if (len === 126) { if (buf.length < 4) return; len = buf.readUInt16BE(2); offset = 4; }
|
|
60
|
+
else if (len === 127) { if (buf.length < 10) return; len = Number(buf.readBigUInt64BE(2)); offset = 10; }
|
|
61
|
+
const need = offset + (masked ? 4 : 0) + len;
|
|
62
|
+
if (buf.length < need) return;
|
|
63
|
+
const mask = masked ? buf.subarray(offset, offset + 4) : null;
|
|
64
|
+
const payload = Buffer.from(buf.subarray(offset + (masked ? 4 : 0), need)); // copy out before advancing
|
|
65
|
+
if (masked) for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
|
|
66
|
+
buf = buf.subarray(need);
|
|
67
|
+
if (opcode === 0x8) { try { socket.end(); } catch {} done(); return; } // close
|
|
68
|
+
else if (opcode === 0x9) { try { socket.write(encodeFrame(payload, 0xA)); } catch {} } // ping → pong
|
|
69
|
+
else if (opcode === 0x1 || opcode === 0x0) onText(payload.toString('utf8')); // text / continuation
|
|
70
|
+
// binary (0x2) and others ignored — this tool speaks JSON text only
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
socket.on('close', done);
|
|
74
|
+
socket.on('error', done);
|
|
75
|
+
return { send(str) { try { socket.write(encodeFrame(Buffer.from(str, 'utf8'), 0x1)); } catch {} } };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function startGui({ port = 9420, demo = false, host = '127.0.0.1', allowNullOrigin = false } = {}) {
|
|
79
|
+
const DIR = dirname(fileURLToPath(import.meta.url));
|
|
80
|
+
const HISTORY = [];
|
|
81
|
+
const MAX_HISTORY = 2000;
|
|
82
|
+
// Source announces are ONE-TIME frames; if kept in the rolling HISTORY they get evicted after
|
|
83
|
+
// MAX_HISTORY events, and late-connecting browsers then never learn a source's label (the chip
|
|
84
|
+
// falls back to the raw, URL-encoded id — "%2F…"). Keep the latest announce per id here instead,
|
|
85
|
+
// unbounded by event volume, and replay them before the event history on every browser connect.
|
|
86
|
+
const sources = new Map(); // source id -> the {__source} frame
|
|
87
|
+
const sourceRefs = new Map(); // source id -> # of open emitter connections announcing it
|
|
88
|
+
const sourceConns = new Map(); // source id -> Set<emitter conn> (for routing GUI replays back)
|
|
89
|
+
const browsers = new Set();
|
|
90
|
+
|
|
91
|
+
// Single ingest path for both app frames and --demo: announces are sticky (sources map),
|
|
92
|
+
// everything else is bounded event history. Both fan out to connected browsers.
|
|
93
|
+
const ingest = (ev) => {
|
|
94
|
+
if (ev && ev.__source) { if (ev.__source.id) sources.set(ev.__source.id, ev); }
|
|
95
|
+
else { HISTORY.push(ev); if (HISTORY.length > MAX_HISTORY) HISTORY.shift(); }
|
|
96
|
+
for (const b of browsers) b.send(JSON.stringify(ev));
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const server = createServer((req, res) => {
|
|
100
|
+
if (req.url === '/' || req.url === '/index.html') {
|
|
101
|
+
res.writeHead(200, { 'content-type': 'text/html' });
|
|
102
|
+
res.end(readFileSync(join(DIR, 'index.html')));
|
|
103
|
+
} else { res.writeHead(404); res.end('not found'); }
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
server.on('upgrade', (req, socket) => {
|
|
107
|
+
if (req.url !== '/ws' || !req.headers['sec-websocket-key']) { socket.destroy(); return; }
|
|
108
|
+
if (!originAllowed(req, { allowNullOrigin })) { socket.destroy(); return; } // reject cross-origin browser pages (CSRF)
|
|
109
|
+
const accept = createHash('sha1').update(req.headers['sec-websocket-key'] + GUID).digest('base64');
|
|
110
|
+
socket.write(
|
|
111
|
+
'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
|
|
112
|
+
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`
|
|
113
|
+
);
|
|
114
|
+
let isBrowser = false;
|
|
115
|
+
const announced = new Set(); // source ids THIS emitter connection announced
|
|
116
|
+
const conn = wsConnection(socket, onText, onClose);
|
|
117
|
+
function onClose() {
|
|
118
|
+
browsers.delete(conn);
|
|
119
|
+
// An emitter went away → drop the sources it announced so the chip stops lingering, and tell
|
|
120
|
+
// viewers to remove them. Refcounted so a reconnect that re-announces before this close fires
|
|
121
|
+
// (TCP can deliver the new socket's announce first) doesn't evict a live source.
|
|
122
|
+
for (const id of announced) {
|
|
123
|
+
const set = sourceConns.get(id); if (set) { set.delete(conn); if (set.size === 0) sourceConns.delete(id); }
|
|
124
|
+
const n = (sourceRefs.get(id) || 1) - 1;
|
|
125
|
+
if (n > 0) { sourceRefs.set(id, n); continue; }
|
|
126
|
+
sourceRefs.delete(id); sources.delete(id);
|
|
127
|
+
for (const b of browsers) b.send(JSON.stringify({ __source_gone: id }));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function onText(text) {
|
|
131
|
+
let ev; try { ev = JSON.parse(text); } catch { return; }
|
|
132
|
+
if (ev && ev.__hello === 'browser') { // a viewer: replay sources, then history, then live
|
|
133
|
+
isBrowser = true; browsers.add(conn);
|
|
134
|
+
for (const s of sources.values()) conn.send(JSON.stringify(s)); // labels first, never evicted
|
|
135
|
+
for (const e of HISTORY) conn.send(JSON.stringify(e));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// A viewer replaying a logged call: route {__invoke:{corrId,target,invokeId,args?}} to ONE
|
|
139
|
+
// live emitter for the target source — but only if that source advertised caps.invoke.
|
|
140
|
+
// Boundary, precisely: the METHOD is not on the wire — the app resolves it from its own log
|
|
141
|
+
// for that corrId, so a viewer cannot redirect the replay to a different method. The `args`
|
|
142
|
+
// ARE viewer-supplied (the GUI's edit-and-replay sends them) and this server does not
|
|
143
|
+
// inspect them; the app only checks that they are a JSON array. So an opted-in app can be
|
|
144
|
+
// asked to re-run a method it already called, with arbitrary arguments. The app's
|
|
145
|
+
// `canReplay(method, args)` hook is the only argument check in the system.
|
|
146
|
+
if (isBrowser) {
|
|
147
|
+
// A viewer purging server-side history so a Clear actually sticks across reconnect/refresh
|
|
148
|
+
// (browsers replay HISTORY on connect). scope 'all' wipes everything; 'device' wipes one
|
|
149
|
+
// source's events. Source announces (chips) are left intact — the devices are still live.
|
|
150
|
+
// The purge is broadcast to ALL browsers so every open viewer clears in lockstep.
|
|
151
|
+
if (ev && ev.__clear) {
|
|
152
|
+
const scope = ev.__clear.scope, src = ev.__clear.src;
|
|
153
|
+
if (scope === 'device' && src) {
|
|
154
|
+
for (let k = HISTORY.length - 1; k >= 0; k--) { const e = HISTORY[k]; if (e && e.src === src) HISTORY.splice(k, 1); }
|
|
155
|
+
for (const b of browsers) b.send(JSON.stringify({ __cleared: { scope: 'device', src } }));
|
|
156
|
+
} else {
|
|
157
|
+
HISTORY.length = 0;
|
|
158
|
+
for (const b of browsers) b.send(JSON.stringify({ __cleared: { scope: 'all' } }));
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (ev && ev.__invoke && ev.__invoke.target) {
|
|
163
|
+
const t = ev.__invoke.target, iid = ev.__invoke.invokeId;
|
|
164
|
+
const set = sourceConns.get(t);
|
|
165
|
+
const src = sources.get(t);
|
|
166
|
+
if (!set || set.size === 0 || !src?.__source?.caps?.invoke) {
|
|
167
|
+
conn.send(JSON.stringify({ type: 'invoke.error', invokeId: iid, replayOf: ev.__invoke.corrId, origin: 'gui', error: 'target-not-invocable', t: Date.now() }));
|
|
168
|
+
} else {
|
|
169
|
+
[...set].at(-1).send(JSON.stringify(ev)); // most-recent connection; avoids double-exec across reconnect sockets
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return; // browsers never inject events into history
|
|
173
|
+
}
|
|
174
|
+
if (ev && ev.__source && ev.__source.id && !announced.has(ev.__source.id)) {
|
|
175
|
+
announced.add(ev.__source.id);
|
|
176
|
+
sourceRefs.set(ev.__source.id, (sourceRefs.get(ev.__source.id) || 0) + 1);
|
|
177
|
+
(sourceConns.get(ev.__source.id) ?? sourceConns.set(ev.__source.id, new Set()).get(ev.__source.id)).add(conn);
|
|
178
|
+
}
|
|
179
|
+
ingest(ev);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
server.listen(port, host, () => {
|
|
184
|
+
console.log(`observe-gui: http://localhost:${port} (ws://…:${port}/ws) bound ${host}`);
|
|
185
|
+
console.log('Android: `adb reverse tcp:%d tcp:%d`; iOS sim: 127.0.0.1.', port, port);
|
|
186
|
+
// Default localhost keeps the replay/invoke channel off the LAN (privacy-reviewer #1). Only a
|
|
187
|
+
// deliberate --host 0.0.0.0 exposes it — and then any LAN host can reach a dev build's core.
|
|
188
|
+
if (host !== '127.0.0.1' && host !== 'localhost') {
|
|
189
|
+
console.log('⚠️ bound %s (not localhost): the GUI — including replay into a running dev app — is reachable from the LAN. Prefer `adb reverse`/a tunnel and keep 127.0.0.1.', host);
|
|
190
|
+
}
|
|
191
|
+
if (allowNullOrigin) {
|
|
192
|
+
console.log('⚠️ allowNullOrigin: WS upgrades with `Origin: null` are accepted. A sandboxed cross-origin iframe on ANY page you visit sends exactly that origin, so it can read this GUI\'s full unredacted history, drive replay into your dev app, and clear it. Only use this for a non-browser client that insists on sending "null".');
|
|
193
|
+
}
|
|
194
|
+
if (demo) startDemo(ingest);
|
|
195
|
+
});
|
|
196
|
+
return server;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// --demo: synthetic events (incl. multiple sources + a subscription stream) so the GUI is
|
|
200
|
+
// visible without an app. `emit` fans a single event out to browsers + history.
|
|
201
|
+
function startDemo(emit) {
|
|
202
|
+
const eps = ['core.getVersion', 'notes.create', 'swarm.ready', 'feedback.send', 'notes.send'];
|
|
203
|
+
const sources = [
|
|
204
|
+
{ id: 'd=devA;a=sample-app', deviceId: 'devA', device: 'devA', appId: 'sample-app', label: 'sample-app · devA', runtime: 'react-native' },
|
|
205
|
+
{ id: 'd=devB;a=sample-app', deviceId: 'devB', device: 'Phone (sim)', appId: 'sample-app', label: 'sample-app · Phone (sim)', runtime: 'react-native' },
|
|
206
|
+
{ id: 'd=devA;a=sample-app-nightly', deviceId: 'devA', device: 'devA', appId: 'sample-app-nightly', label: 'sample-app-nightly · devA', runtime: 'react-native' },
|
|
207
|
+
];
|
|
208
|
+
for (const s of sources) emit({ __source: s });
|
|
209
|
+
const srcOf = (n) => sources[n % sources.length].id;
|
|
210
|
+
let i = 0;
|
|
211
|
+
emit({ type: 'request.start', method: 'notes.subscribe', corrId: 'demo-sub', args: [{ roomId: 'r-1' }], t: nowish(), src: 'd=devA;a=sample-app' });
|
|
212
|
+
emit({ type: 'stream.open', method: 'notes.subscribe', corrId: 'demo-sub', t: nowish(), src: 'd=devA;a=sample-app' });
|
|
213
|
+
let sseq = 0;
|
|
214
|
+
setInterval(() => emit({ type: 'stream.data', method: 'notes.subscribe', corrId: 'demo-sub', seq: ++sseq, item: { revision: 100 + sseq }, t: nowish(), src: 'd=devA;a=sample-app' }), 1500);
|
|
215
|
+
setInterval(() => {
|
|
216
|
+
const method = eps[i % eps.length], corrId = 'demo-' + i++, src = srcOf(i), dur = 8 + (i % 5) * 22;
|
|
217
|
+
emit({ type: 'request.start', method, corrId, args: [{ roomId: 'r-' + (i % 3) }], t: nowish(), src });
|
|
218
|
+
const fail = method === 'feedback.send' && i % 4 === 0;
|
|
219
|
+
setTimeout(() => emit(fail
|
|
220
|
+
? { type: 'request.end', method, corrId, status: 'error', error: 'timeout', dur, t: nowish(), src }
|
|
221
|
+
: { type: 'request.end', method, corrId, status: 'ok', response: { ok: true, n: i }, dur, t: nowish(), src }), dur);
|
|
222
|
+
}, 900);
|
|
223
|
+
}
|
|
224
|
+
function nowish() { return Date.now(); }
|
|
225
|
+
|
|
226
|
+
// Run directly for local debugging (equivalent to the old `node tools/observe-gui/server.mjs`):
|
|
227
|
+
// node gui/server.mjs [--port N] [--demo] [--host 0.0.0.0] [--allow-null-origin]
|
|
228
|
+
// Default host is 127.0.0.1 (localhost-only). --host 0.0.0.0 opts into LAN exposure (warns).
|
|
229
|
+
// --allow-null-origin re-admits `Origin: null` WS upgrades — a browser CSRF hole, off by default.
|
|
230
|
+
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
|
231
|
+
const a = process.argv.slice(2);
|
|
232
|
+
const i = a.indexOf('--port');
|
|
233
|
+
const h = a.indexOf('--host');
|
|
234
|
+
startGui({
|
|
235
|
+
port: i >= 0 ? Number(a[i + 1]) : Number(process.env.OBSERVE_PORT || 9420),
|
|
236
|
+
demo: a.includes('--demo'),
|
|
237
|
+
host: h >= 0 ? a[h + 1] : (process.env.OBSERVE_HOST || '127.0.0.1'),
|
|
238
|
+
allowNullOrigin: a.includes('--allow-null-origin'),
|
|
239
|
+
});
|
|
240
|
+
}
|
package/package.json
CHANGED
|
@@ -1 +1,65 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "hrpc-inspector",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"hrpc-inspector": "bin/hrpc-inspector.mjs"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./src/index.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./detect": {
|
|
17
|
+
"types": "./src/detect.ts",
|
|
18
|
+
"default": "./dist/detect.js"
|
|
19
|
+
},
|
|
20
|
+
"./observe": {
|
|
21
|
+
"types": "./src/observe.ts",
|
|
22
|
+
"default": "./dist/observe.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"bin",
|
|
28
|
+
"gui",
|
|
29
|
+
"src",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"NOTICE",
|
|
32
|
+
"README.md",
|
|
33
|
+
"USAGE.md"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "rm -rf dist && esbuild src/index.ts src/detect.ts src/observe.ts --bundle --format=esm --platform=neutral --external:hrpc-inspector-probe --external:hrpc-inspector-protocol --external:hyperswarm --external:hypercore-crypto --outdir=dist",
|
|
37
|
+
"prepare": "npm run build",
|
|
38
|
+
"test": "bash test/run-all.sh"
|
|
39
|
+
},
|
|
40
|
+
"repository": "git+https://github.com/holepunchto/hrpc-inspector.git",
|
|
41
|
+
"license": "Apache-2.0",
|
|
42
|
+
"sideEffects": false,
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"hrpc-inspector-probe": "^0.1.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"hypercore-crypto": "^3.0.0",
|
|
48
|
+
"hyperswarm": "^4.0.0",
|
|
49
|
+
"react-native-bare-kit": "*"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"hypercore-crypto": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"hyperswarm": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"react-native-bare-kit": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"esbuild": "^0.28.2"
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/detect.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Runtime detection — the basis of "plug and play". The SAME observability core
|
|
2
|
+
// runs in every target; only the host that hosts it differs:
|
|
3
|
+
//
|
|
4
|
+
// plain Pear app → Bare runtime (Pear global present)
|
|
5
|
+
// plain Bare app → Bare runtime
|
|
6
|
+
// React Native app → a react-native-bare-kit WORKLET, which is Bare again
|
|
7
|
+
// Electron / Node CLI → Node
|
|
8
|
+
// browser / RN JS side → no Bare; export over a bridge instead
|
|
9
|
+
//
|
|
10
|
+
// Because RN runs the core inside a Bare worklet, `detectRuntime()` called from
|
|
11
|
+
// INSIDE that worklet returns 'bare' — which is exactly right: the hyperswarm
|
|
12
|
+
// auto-swarm path applies there just as it does for a plain Pear app.
|
|
13
|
+
|
|
14
|
+
export type Runtime = 'pear' | 'bare' | 'react-native' | 'electron' | 'node' | 'browser' | 'unknown';
|
|
15
|
+
|
|
16
|
+
export interface RuntimeInfo {
|
|
17
|
+
runtime: Runtime;
|
|
18
|
+
/** Bare is available (Pear, plain Bare, or an RN bare-kit worklet) → hyperswarm can run in-process. */
|
|
19
|
+
isBareRuntime: boolean;
|
|
20
|
+
/** A browser/RN JS context where we must export over a bridge, not a socket. */
|
|
21
|
+
isBridgeOnly: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Pure and injectable (pass a fake global in tests). Order matters: most specific first. */
|
|
25
|
+
export function detectRuntime(env: any = globalThis): RuntimeInfo {
|
|
26
|
+
const has = (v: unknown) => typeof v !== 'undefined' && v !== null;
|
|
27
|
+
|
|
28
|
+
// Pear implies Bare, but is more specific — report it distinctly.
|
|
29
|
+
if (has(env.Pear)) return { runtime: 'pear', isBareRuntime: true, isBridgeOnly: false };
|
|
30
|
+
if (has(env.Bare)) return { runtime: 'bare', isBareRuntime: true, isBridgeOnly: false };
|
|
31
|
+
|
|
32
|
+
// React Native JS side (Hermes/JSC). Not Bare — the core belongs in a worklet.
|
|
33
|
+
const rn =
|
|
34
|
+
env.navigator?.product === 'ReactNative' ||
|
|
35
|
+
has(env.__fbBatchedBridge) ||
|
|
36
|
+
has(env.HermesInternal);
|
|
37
|
+
if (rn) return { runtime: 'react-native', isBareRuntime: false, isBridgeOnly: true };
|
|
38
|
+
|
|
39
|
+
if (has(env.process?.versions?.electron)) {
|
|
40
|
+
return { runtime: 'electron', isBareRuntime: false, isBridgeOnly: false };
|
|
41
|
+
}
|
|
42
|
+
if (has(env.process?.versions?.node)) {
|
|
43
|
+
return { runtime: 'node', isBareRuntime: false, isBridgeOnly: false };
|
|
44
|
+
}
|
|
45
|
+
if (has(env.window) && has(env.document)) {
|
|
46
|
+
return { runtime: 'browser', isBareRuntime: false, isBridgeOnly: true };
|
|
47
|
+
}
|
|
48
|
+
return { runtime: 'unknown', isBareRuntime: false, isBridgeOnly: false };
|
|
49
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// hrpc-inspector — one plug-and-play package for P2P request observability across
|
|
2
|
+
// React Native (bare-kit worklets), plain Pear/Bare apps, and desktop (Electron/Node).
|
|
3
|
+
//
|
|
4
|
+
// Zero-config:
|
|
5
|
+
// import { observe } from 'hrpc-inspector';
|
|
6
|
+
// const obs = observe(); // auto-detects runtime + hub transport
|
|
7
|
+
// instrumentDataChannel(dc, peerId, obs.sink); // wire your transport (any adapter)
|
|
8
|
+
//
|
|
9
|
+
// Everything below is re-exported so a consumer needs exactly one dependency.
|
|
10
|
+
|
|
11
|
+
export { observe, DEFAULT_TOPIC } from './observe.ts';
|
|
12
|
+
export type { ObserveOptions, ObserveHandle } from './observe.ts';
|
|
13
|
+
export { detectRuntime } from './detect.ts';
|
|
14
|
+
export type { Runtime, RuntimeInfo } from './detect.ts';
|
|
15
|
+
|
|
16
|
+
// Generic RPC/service-client tap (endpoint + request + response + subscription streams) —
|
|
17
|
+
// works in any runtime. Usually reached via observe().wrapClient(...).
|
|
18
|
+
export { wrapClient } from './wrap-client.ts';
|
|
19
|
+
export type { Report, WrapClientOptions } from './wrap-client.ts';
|
|
20
|
+
// Reliable local-dev viewer transport for every runtime.
|
|
21
|
+
export { createWebSocketReporter } from './reporters.ts';
|
|
22
|
+
export type { Reporter, WebSocketReporterOptions } from './reporters.ts';
|
|
23
|
+
|
|
24
|
+
// L1 adapters (tap your transport)
|
|
25
|
+
export {
|
|
26
|
+
instrumentDataChannel,
|
|
27
|
+
instrumentPeerConnection,
|
|
28
|
+
} from 'hrpc-inspector-probe/adapters/webrtc';
|
|
29
|
+
export { instrumentWebSocket } from 'hrpc-inspector-probe/adapters/websocket';
|
|
30
|
+
export { instrumentHyperswarmStream } from 'hrpc-inspector-probe/adapters/hyperswarm';
|
|
31
|
+
|
|
32
|
+
// L2 collector + framing + exporter (usually reached via observe(), exported for advanced use)
|
|
33
|
+
export { CollectorSink } from 'hrpc-inspector-probe/core/sink';
|
|
34
|
+
export { BatchFlusher } from 'hrpc-inspector-probe/flush';
|
|
35
|
+
export { createHyperswarmExporter } from 'hrpc-inspector-probe/exporters/hyperswarm';
|
|
36
|
+
export { StreamFramer, encodeFrame } from 'hrpc-inspector-probe/transport/framing';
|
|
37
|
+
export type { EventSink, L2Event, L2EventType } from 'hrpc-inspector-probe/sink';
|
package/src/init.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Pure planner for `hrpc-inspector init`. Given a host project's package.json (and a
|
|
2
|
+
// file-existence probe), it decides the project type and returns a PLAN of actions.
|
|
3
|
+
// Pure + injectable so it is unit-tested without touching a real filesystem.
|
|
4
|
+
//
|
|
5
|
+
// Design choice (deliberate): there is NO postinstall that silently edits a host's
|
|
6
|
+
// metro/babel/pear config. Auto-mutating build config on install is fragile and a
|
|
7
|
+
// supply-chain-trust smell. `init` is one explicit command, and it DEFAULTS TO A
|
|
8
|
+
// DRY RUN — nothing is written without `--write`.
|
|
9
|
+
|
|
10
|
+
/** @typedef {{ path: string, kind: 'create', reason: string, contents: string }} PlanAction */
|
|
11
|
+
|
|
12
|
+
const WORKLET_SCAFFOLD = `// hrpc-inspector.worklet.mjs — runs INSIDE a react-native-bare-kit Worklet (Bare runtime).
|
|
13
|
+
// The observability core (probe + collector + hyperswarm export) lives here, NOT on the
|
|
14
|
+
// RN JS thread. The RN side pipes its transport handles in over the worklet IPC.
|
|
15
|
+
//
|
|
16
|
+
// UNVERIFIED on-device: needs react-native-bare-kit + a device/simulator to run
|
|
17
|
+
// (no React Native toolchain in this environment). Verify the bare-kit Worklet API against
|
|
18
|
+
// current Holepunch docs before shipping.
|
|
19
|
+
import { observe } from 'hrpc-inspector';
|
|
20
|
+
|
|
21
|
+
// Replay (the GUI re-running a call the app already made) is OPT-IN and off by default: pass
|
|
22
|
+
// allowInvoke: true ONLY in a dev build, and only with a 'websocket:' viewer. Leaving it out is
|
|
23
|
+
// what keeps a shipped release from exposing an inbound execution surface.
|
|
24
|
+
const obs = observe(); // detects Bare → auto-dials the hub topic over Hyperswarm
|
|
25
|
+
// Receive transport events from the RN side over IPC and feed obs.sink, e.g.:
|
|
26
|
+
// BareKit.IPC.on('data', (buf) => obs.sink.emit(JSON.parse(buf.toString())));
|
|
27
|
+
// or instrument a transport opened inside the worklet directly.
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
const RN_SNIPPET = `// RN JS side — start the worklet (react-native-bare-kit):
|
|
31
|
+
// import { Worklet } from 'react-native-bare-kit';
|
|
32
|
+
// import source from './hrpc-inspector.worklet.mjs'; // bundled by bare-kit
|
|
33
|
+
// const worklet = new Worklet();
|
|
34
|
+
// worklet.start('/hrpc-inspector.worklet.mjs', source);
|
|
35
|
+
// // pipe your transport's events to worklet.IPC`;
|
|
36
|
+
|
|
37
|
+
const DESKTOP_SNIPPET = `// Desktop (Electron main / Node) — the core runs in-process:
|
|
38
|
+
// import { observe, instrumentHyperswarmStream } from 'hrpc-inspector';
|
|
39
|
+
// const obs = observe({
|
|
40
|
+
// websocket: 'ws://127.0.0.1:9420/ws', // stream to the local GUI (npx hrpc-inspector gui)
|
|
41
|
+
// allowInvoke: true, // REPLAY IS OPT-IN. Dev builds only — it lets the GUI
|
|
42
|
+
// }); // re-run a call the app already made. Never ship it.
|
|
43
|
+
// instrumentHyperswarmStream(conn, obs.sink, { });
|
|
44
|
+
// // Omit 'websocket' instead and, under Bare/Pear, observe() auto-dials the Hyperswarm hub.`;
|
|
45
|
+
|
|
46
|
+
const PEAR_SNIPPET = `// Pear / Bare app — nothing to configure; observe() auto-dials the hub:
|
|
47
|
+
// import { observe } from 'hrpc-inspector';
|
|
48
|
+
// const obs = observe(); // detects Pear/Bare → joins hub topic`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {{ pkg?: any, hasFile?: (p: string) => boolean }} input
|
|
52
|
+
* @returns {{ runtime: string, actions: PlanAction[], notes: string[] }}
|
|
53
|
+
*/
|
|
54
|
+
export function planInit({ pkg = {}, hasFile = () => false } = {}) {
|
|
55
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
56
|
+
const has = (name) => Object.prototype.hasOwnProperty.call(deps, name);
|
|
57
|
+
|
|
58
|
+
let runtime;
|
|
59
|
+
if (has('react-native') || has('expo')) runtime = 'react-native';
|
|
60
|
+
else if (pkg.pear || has('pear') || has('bare') || has('hyperswarm')) runtime = 'pear';
|
|
61
|
+
else if (has('electron')) runtime = 'electron';
|
|
62
|
+
else runtime = 'node';
|
|
63
|
+
|
|
64
|
+
/** @type {PlanAction[]} */
|
|
65
|
+
const actions = [];
|
|
66
|
+
const notes = [];
|
|
67
|
+
|
|
68
|
+
if (runtime === 'react-native') {
|
|
69
|
+
if (!hasFile('hrpc-inspector.worklet.mjs')) {
|
|
70
|
+
actions.push({
|
|
71
|
+
path: 'hrpc-inspector.worklet.mjs',
|
|
72
|
+
kind: 'create',
|
|
73
|
+
reason: 'Bare worklet that runs the observability core inside react-native-bare-kit',
|
|
74
|
+
contents: WORKLET_SCAFFOLD,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
notes.push('Install peers: npm i react-native-bare-kit hyperswarm hypercore-crypto');
|
|
78
|
+
notes.push(RN_SNIPPET);
|
|
79
|
+
} else if (runtime === 'pear') {
|
|
80
|
+
notes.push('No files needed — observe() auto-dials the hub under Pear/Bare.');
|
|
81
|
+
notes.push('Ensure hyperswarm + hypercore-crypto are dependencies.');
|
|
82
|
+
notes.push(PEAR_SNIPPET);
|
|
83
|
+
} else {
|
|
84
|
+
notes.push('Desktop/Node: observe() runs in-process. Under Node (not Bare), attach() a hub stream.');
|
|
85
|
+
notes.push(DESKTOP_SNIPPET);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { runtime, actions, notes };
|
|
89
|
+
}
|