@hakam-aldeen-kh/blix 0.3.2
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 +21 -0
- package/README.md +720 -0
- package/dist/DevToolsPanel-GBMCZRMW.js +5895 -0
- package/dist/capture/index.d.ts +548 -0
- package/dist/capture/index.js +18 -0
- package/dist/chunk-MRTSIXR7.js +1076 -0
- package/dist/chunk-OQHKPRNI.js +287 -0
- package/dist/chunk-PQIOIMAQ.js +7 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.js +40 -0
- package/package.json +106 -0
package/README.md
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
# @hakam-aldeen-kh/blix
|
|
2
|
+
|
|
3
|
+
An in-app dev-tools panel for React apps. Captures HTTP requests, Redux
|
|
4
|
+
actions, TanStack Query cache events and realtime traffic, and renders them in
|
|
5
|
+
a dockable panel with a waterfall, diffing, replay and HAR/cURL export.
|
|
6
|
+
|
|
7
|
+
The entire panel is eliminated from production builds — see
|
|
8
|
+
[Production elimination](#production-elimination).
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm add -D @hakam-aldeen-kh/blix
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or with npm:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -D @hakam-aldeen-kh/blix
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
It installs as a `devDependency`, but the mount component that renders
|
|
25
|
+
`<Blix />` imports it from your application code — so any build that drops
|
|
26
|
+
devDependencies *before* the build step (`npm ci --omit=dev`,
|
|
27
|
+
`pnpm install --prod`, the usual shape of a multi-stage Docker image) fails
|
|
28
|
+
while resolving that import. devDependencies have to be present at build time.
|
|
29
|
+
Dropping them from the final runtime image is fine: nothing from Blix reaches
|
|
30
|
+
the production output anyway.
|
|
31
|
+
|
|
32
|
+
### Peer dependencies
|
|
33
|
+
|
|
34
|
+
`react` and `react-dom` (v19) are required. `axios`, `@reduxjs/toolkit` and
|
|
35
|
+
`@tanstack/react-query` are **optional** peers — you only need the ones whose
|
|
36
|
+
capture you actually use. The capture layer is structurally typed against each
|
|
37
|
+
of them and never imports any of them at runtime, so installing Blix does not
|
|
38
|
+
pull a data-fetching or state library into your tree.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## The module-init call-order contract
|
|
43
|
+
|
|
44
|
+
**This is the part that is easy to get wrong.** Capture functions must be
|
|
45
|
+
called where the thing they wrap is *constructed*, at module scope — not from
|
|
46
|
+
inside a component body or a `useEffect`.
|
|
47
|
+
|
|
48
|
+
If you call them from a component, capture only starts once React mounts, and
|
|
49
|
+
every request fired before that point — auth bootstrap, session restore,
|
|
50
|
+
prefetches, anything at module-eval time — is silently missed. The panel then
|
|
51
|
+
shows a log with a hole at the beginning, which is exactly when you most need
|
|
52
|
+
it.
|
|
53
|
+
|
|
54
|
+
> **Import capture from `@hakam-aldeen-kh/blix/capture`, not from the package
|
|
55
|
+
> root.** The root entry carries a `"use client"` directive so that `<Blix />`
|
|
56
|
+
> can be rendered from a server component, which means every module that
|
|
57
|
+
> imports from it lands inside a client boundary. Your axios module, your store
|
|
58
|
+
> and your realtime adapter generally also evaluate on the server — in the
|
|
59
|
+
> Next.js App Router they always do — where the root entry is at best dead
|
|
60
|
+
> weight and at worst a boundary violation. Keep the root import in the single
|
|
61
|
+
> client component that mounts the panel. See [Entry points](#entry-points).
|
|
62
|
+
|
|
63
|
+
| Function | Where to call it | Timing |
|
|
64
|
+
| --- | --- | --- |
|
|
65
|
+
| `attachHttpMonitor` | after your own interceptors are registered on the instance | module scope |
|
|
66
|
+
| `createReduxMonitorMiddleware` | in `configureStore`'s `middleware` callback | module scope |
|
|
67
|
+
| `tapRealtimeAdapter` | where the adapter singleton is constructed | module scope |
|
|
68
|
+
| `tapQueryClient` | a `useEffect` in your query provider | see below |
|
|
69
|
+
| `captureEncrypted` | inside your own encrypt/decrypt functions, on success paths only | per request — optional, see below |
|
|
70
|
+
|
|
71
|
+
Every one of these is a no-op in production, but the call sites still cost you
|
|
72
|
+
bytes unless you guard them. See
|
|
73
|
+
[Guard your call sites](#guard-your-call-sites) — it is one line per site and
|
|
74
|
+
it is the difference between shipping the capture runtime and not.
|
|
75
|
+
|
|
76
|
+
### HTTP — `attachHttpMonitor(instance, options?)`
|
|
77
|
+
|
|
78
|
+
Call it **after your own interceptors are registered**. That one placement is
|
|
79
|
+
what puts Blix on the plaintext side of *both* legs of the request, for two
|
|
80
|
+
different reasons:
|
|
81
|
+
|
|
82
|
+
- **On the way out**, axios runs request interceptors LIFO, so registering last
|
|
83
|
+
makes Blix's interceptor run **first** — before any encryption or
|
|
84
|
+
serialization step. It sees the plaintext body.
|
|
85
|
+
- **On the way back**, axios runs response interceptors FIFO, so registering
|
|
86
|
+
last makes Blix's interceptor run **last** — after your decrypt interceptor.
|
|
87
|
+
It sees the decrypted body.
|
|
88
|
+
|
|
89
|
+
The two orders are opposite, and they happen to agree on the same answer:
|
|
90
|
+
register last.
|
|
91
|
+
|
|
92
|
+
> **Registering earlier is a silent wrong reading, not an error.** If
|
|
93
|
+
> `attachHttpMonitor` runs before your decrypt interceptor, Blix's response
|
|
94
|
+
> interceptor runs before it too, and the **Response** tab fills with
|
|
95
|
+
> ciphertext presented as an ordinary response body. Nothing throws and nothing
|
|
96
|
+
> warns — the panel just shows you base64 where it should show you an object.
|
|
97
|
+
> On the request side the mirror-image mistake gives you a **Payload** tab full
|
|
98
|
+
> of ciphertext.
|
|
99
|
+
|
|
100
|
+
> **axios 1.19+ can invert the request-side half.** The request-interceptor
|
|
101
|
+
> LIFO order is now governed by the transitional flag
|
|
102
|
+
> `legacyInterceptorReqResOrdering`, which still defaults to `true`. If you set
|
|
103
|
+
> `transitional: { legacyInterceptorReqResOrdering: false }`, request
|
|
104
|
+
> interceptors become FIFO and the request-side rule flips to "register
|
|
105
|
+
> `attachHttpMonitor` **first**" — while the response-side rule still says
|
|
106
|
+
> last, so the two orders no longer agree and you must pick which leg matters
|
|
107
|
+
> more. Response-interceptor order is unaffected by the flag. Blix does not
|
|
108
|
+
> read this flag and cannot detect the situation.
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
// src/network/axios.ts
|
|
112
|
+
import axios from "axios";
|
|
113
|
+
import { attachHttpMonitor, withInitiatorCapture } from "@hakam-aldeen-kh/blix/capture";
|
|
114
|
+
|
|
115
|
+
export const apiClient = axios.create({ baseURL: "/api" });
|
|
116
|
+
|
|
117
|
+
apiClient.interceptors.request.use(addAuthHeader);
|
|
118
|
+
apiClient.interceptors.request.use(encryptBody);
|
|
119
|
+
|
|
120
|
+
// After your interceptors, at module scope — NOT in a hook, NOT in a component.
|
|
121
|
+
if (process.env.NODE_ENV === "development" && typeof window !== "undefined") {
|
|
122
|
+
attachHttpMonitor(apiClient);
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
#### Factory and lazy-singleton clients
|
|
127
|
+
|
|
128
|
+
The rule is **causal, not positional**. "Bottom of the module" is shorthand
|
|
129
|
+
that only holds when your interceptors are registered by statements physically
|
|
130
|
+
above the call. If your instance comes from a factory or a lazy singleton, the
|
|
131
|
+
interceptors are registered inside that factory, on first call — so what
|
|
132
|
+
matters is that *something has already triggered construction*:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
// ApiClientFactory.getInstance() registers the interceptors on its first call.
|
|
136
|
+
export const apiClient = withInitiatorCapture(ApiClientFactory.getInstance());
|
|
137
|
+
|
|
138
|
+
// Safe: getInstance() ran on the line above, so the interceptors exist by now.
|
|
139
|
+
if (process.env.NODE_ENV === "development" && typeof window !== "undefined") {
|
|
140
|
+
attachHttpMonitor(apiClient);
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
If nothing above the call has constructed the instance, `attachHttpMonitor`
|
|
145
|
+
registers **first** rather than last, and you get the silent wrong reading
|
|
146
|
+
described above. There is no need for `queueMicrotask`, `setTimeout` or any
|
|
147
|
+
other deferral: Blix does not require one, and deferring only hides whether the
|
|
148
|
+
ordering is actually correct.
|
|
149
|
+
|
|
150
|
+
#### Failed requests and the shape of your rejection
|
|
151
|
+
|
|
152
|
+
Blix correlates a response — success **or** error — back to its entry through
|
|
153
|
+
`error.config`. If your response interceptor normalises errors into your own
|
|
154
|
+
domain type, a very common pattern, the value Blix receives is a plain object
|
|
155
|
+
with no `config` on it:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
// ❌ Blix can no longer see the entry: no `.config` on the rejected value
|
|
159
|
+
instance.interceptors.response.use(undefined, (error) =>
|
|
160
|
+
Promise.reject(error.response?.data ?? fallbackError),
|
|
161
|
+
);
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The consequence is silent and total: **every non-2xx request stays `pending`
|
|
165
|
+
in the panel for the rest of the session.** No error, no warning, no Failed
|
|
166
|
+
filter. (The 30-second pending cap only bounds the width of the waterfall bar;
|
|
167
|
+
it does not resolve the entry.)
|
|
168
|
+
|
|
169
|
+
Two ways out, and you currently have to choose one:
|
|
170
|
+
|
|
171
|
+
1. **Keep the config on your normalised error** — attach `config` (or the
|
|
172
|
+
original `AxiosError`) to the object you reject with. This preserves
|
|
173
|
+
plaintext request capture and is the recommended fix.
|
|
174
|
+
2. **Register Blix before your normalising handler**, so it runs first on the
|
|
175
|
+
response path:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
attachHttpMonitor(apiClient); // first on the response path
|
|
179
|
+
apiClient.interceptors.request.use(addAuth);
|
|
180
|
+
apiClient.interceptors.response.use(undefined, normaliseError);
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
This costs you plaintext request capture, because Blix's request interceptor
|
|
184
|
+
now runs last — after encryption.
|
|
185
|
+
|
|
186
|
+
From a single `attachHttpMonitor` call you cannot currently have both plaintext
|
|
187
|
+
request bodies and correlated errors while also discarding the `AxiosError`.
|
|
188
|
+
|
|
189
|
+
#### Encrypted payloads — `captureEncrypted(config, payload)`
|
|
190
|
+
|
|
191
|
+
*Since 0.3.0.*
|
|
192
|
+
|
|
193
|
+
**Entirely optional.** An app that never calls it behaves exactly as it did
|
|
194
|
+
before this API existed, and its panel shows no Encrypted tab at all — the tab
|
|
195
|
+
appears only on entries that actually carry ciphertext.
|
|
196
|
+
|
|
197
|
+
Blix cannot capture the encrypted forms by itself. It has no knowledge of your
|
|
198
|
+
encryption scheme, and — by the design above — its interceptor deliberately
|
|
199
|
+
sits on the *plaintext* side, so at the moment Blix captures, the ciphertext
|
|
200
|
+
does not exist yet. `captureEncrypted` is the hand-off: you call it from inside
|
|
201
|
+
your own interceptors, where the ciphertext does exist, and pass back the same
|
|
202
|
+
config object Blix already saw.
|
|
203
|
+
|
|
204
|
+
Two calls, one on the way out and one on the way back:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
// src/network/axios.ts
|
|
208
|
+
import { attachHttpMonitor, captureEncrypted } from "@hakam-aldeen-kh/blix/capture";
|
|
209
|
+
|
|
210
|
+
apiClient.interceptors.request.use((config) => {
|
|
211
|
+
const encrypted = encryptBody(config.data);
|
|
212
|
+
captureEncrypted(config, { request: encrypted });
|
|
213
|
+
return { ...config, data: encrypted };
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
apiClient.interceptors.response.use((response) => {
|
|
217
|
+
captureEncrypted(response.config, { response: response.data });
|
|
218
|
+
return { ...response, data: decryptBody(response.data) };
|
|
219
|
+
});
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Both calls work on the *copies* those interceptors return, not on the objects
|
|
223
|
+
axios created — see [How correlation works](#how-correlation-works) for why
|
|
224
|
+
that still resolves. On the response side it is `response.config` that has to
|
|
225
|
+
carry the stamp, and it does: axios threads the object returned by the last
|
|
226
|
+
request interceptor straight through to `response.config`, so the
|
|
227
|
+
`{ ...config, data: encrypted }` above is literally the object you get back.
|
|
228
|
+
|
|
229
|
+
##### Dropping it into an interceptor you already have
|
|
230
|
+
|
|
231
|
+
The example above builds a fresh interceptor that returns a spread copy. Most
|
|
232
|
+
real pipelines have one large multi-step interceptor that mutates `config.data`
|
|
233
|
+
in place. `captureEncrypted` needs no restructuring for that — it is two lines:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
private static async encryptRequest(config) {
|
|
237
|
+
const encrypted = await encryptionService.encryptApiPayload(
|
|
238
|
+
JSON.stringify(config.data),
|
|
239
|
+
key,
|
|
240
|
+
);
|
|
241
|
+
captureEncrypted(config, { request: encrypted }); // ← add
|
|
242
|
+
config.data = encrypted;
|
|
243
|
+
return config;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private static async decryptResponse(response) {
|
|
247
|
+
captureEncrypted(response.config, { response: response.data }); // ← add, BEFORE decrypting
|
|
248
|
+
return { ...response, data: await encryptionService.decryptApiResponse(response) };
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Note the placement on the response side: call it **before** you decrypt, so the
|
|
253
|
+
value you hand over is the wire form. Calling it after decryption puts your
|
|
254
|
+
plaintext response — secrets included — under a tab labelled Encrypted, which
|
|
255
|
+
is both wrong and a disclosure. See the redaction note below.
|
|
256
|
+
|
|
257
|
+
##### Call it only where encryption actually happened
|
|
258
|
+
|
|
259
|
+
The examples above call `captureEncrypted` unconditionally, which is only
|
|
260
|
+
correct because they have no path that skips encryption. Real pipelines do: an
|
|
261
|
+
endpoint on an exclusion list, an explicit `skipEncryption` flag, a missing or
|
|
262
|
+
not-yet-derived session key, a `FormData` body carrying only file parts, an
|
|
263
|
+
encryption failure the interceptor swallows so the request can still go out.
|
|
264
|
+
|
|
265
|
+
On any of those paths the value you would hand over is **plaintext**, and Blix
|
|
266
|
+
has no way to know that — it labels whatever you pass as the encrypted wire
|
|
267
|
+
form and shows it under the Encrypted tab. The result is a reading that looks
|
|
268
|
+
authoritative and is wrong, which is worse than no reading at all.
|
|
269
|
+
|
|
270
|
+
So put the call on the **success path, inside the function that encrypts** —
|
|
271
|
+
next to the line that produced the ciphertext, where it cannot outlive the
|
|
272
|
+
condition that made it true — rather than in the interceptor after the
|
|
273
|
+
function returns:
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
// Inside your encryption module, not in the interceptor.
|
|
277
|
+
function encryptBody(config) {
|
|
278
|
+
if (shouldSkip(config)) return config; // no call — nothing encrypted
|
|
279
|
+
const key = sessionKey();
|
|
280
|
+
if (!key) return config; // no call — nothing encrypted
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const encrypted = seal(config.data, key);
|
|
284
|
+
captureEncrypted(config, { request: encrypted }); // only here
|
|
285
|
+
config.data = encrypted;
|
|
286
|
+
} catch {
|
|
287
|
+
// Encryption failed and we're sending plaintext — deliberately no call.
|
|
288
|
+
}
|
|
289
|
+
return config;
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
The same rule governs the response side: call it only where you know the body
|
|
294
|
+
you are holding is the pre-decryption wire form.
|
|
295
|
+
|
|
296
|
+
##### How correlation works
|
|
297
|
+
|
|
298
|
+
Requests and responses are correlated by the **identity of the config object**,
|
|
299
|
+
never by URL or timing, so two concurrent calls to the same endpoint stay
|
|
300
|
+
correctly apart. Pass the object axios handed you; a `{ ...config }` copy made
|
|
301
|
+
by your own interceptor resolves too.
|
|
302
|
+
|
|
303
|
+
The copy resolves because there are two stamps, not one:
|
|
304
|
+
|
|
305
|
+
| Stamp | Visibility | Survives `{ ...config }` |
|
|
306
|
+
| --- | --- | --- |
|
|
307
|
+
| a registry-global `Symbol`, non-enumerable | invisible to `Object.keys`, `JSON.stringify`, `Object.entries` | **no** — spread copies only enumerable own properties |
|
|
308
|
+
| `__monitorId`, a plain enumerable string property | shows up in `Object.keys` and a `JSON.stringify` of the *config* | **yes** |
|
|
309
|
+
|
|
310
|
+
The symbol is the primary; `__monitorId` is what the resolver falls back to,
|
|
311
|
+
and it is the same property `attachHttpMonitor` has set since 0.2.1 for its own
|
|
312
|
+
response interceptor. A third fallback, a `WeakMap`, covers only a frozen or
|
|
313
|
+
sealed config that rejects `defineProperty`.
|
|
314
|
+
|
|
315
|
+
Two consequences worth being explicit about. **Neither stamp reaches the
|
|
316
|
+
wire** — they live on the axios config, and axios serializes only `data`. But
|
|
317
|
+
`__monitorId` *is* enumerable, so it will appear if you log or stringify the
|
|
318
|
+
config object itself; only the symbol is fully invisible. And a copy that
|
|
319
|
+
enumerates fields **explicitly** — `{ url, method, data: encrypted }` rather
|
|
320
|
+
than a spread — carries neither stamp and will not resolve, making the call a
|
|
321
|
+
silent no-op. Spread, or mutate in place.
|
|
322
|
+
|
|
323
|
+
The two calls are independent and order-free: the request-side ciphertext is
|
|
324
|
+
produced early and the response-side arrives late, possibly after Blix has
|
|
325
|
+
already finalized the entry. Either way it merges into the existing entry —
|
|
326
|
+
never creating one of its own — and the panel updates.
|
|
327
|
+
|
|
328
|
+
It is a **silent no-op** — never a throw, never a console warning — in every
|
|
329
|
+
one of these:
|
|
330
|
+
|
|
331
|
+
- outside development (`process.env.NODE_ENV !== "development"`);
|
|
332
|
+
- **outside the browser** — the gate is also `typeof window !== "undefined"`,
|
|
333
|
+
so every call made while rendering on the server does nothing, by design (see
|
|
334
|
+
[Production elimination](#production-elimination));
|
|
335
|
+
- when `attachHttpMonitor` was never called on the instance;
|
|
336
|
+
- when the config carries no stamp — a retry that built a fresh config, a
|
|
337
|
+
config assembled field-by-field rather than spread, or a request that started
|
|
338
|
+
while capture was **paused** from the panel's toolbar;
|
|
339
|
+
- when `payload` is missing, or both `request` and `response` on it are
|
|
340
|
+
`null`/`undefined` (a bare `{ request }` will not blank a `response` captured
|
|
341
|
+
by an earlier call);
|
|
342
|
+
- when the entry has already been evicted from the buffer.
|
|
343
|
+
|
|
344
|
+
In production it is eliminated entirely, along with the rest of capture.
|
|
345
|
+
|
|
346
|
+
Values may be a string, a plain object, or an `ArrayBuffer`/typed array (kept
|
|
347
|
+
as a bounded hex preview plus byte length). They go through the same
|
|
348
|
+
serialization and truncation rules as the plaintext bodies, both in the panel
|
|
349
|
+
and in IndexedDB, so a multi-megabyte ciphertext cannot blow out the log.
|
|
350
|
+
|
|
351
|
+
> **Redaction.** Blix masks sensitive *headers* (`authorization`, `cookie`, …).
|
|
352
|
+
> It does **not**, and cannot, redact anything inside the values you pass here
|
|
353
|
+
> — they are bodies, and Blix has no way to tell ciphertext from plaintext. If
|
|
354
|
+
> you pass an already-decrypted body as `response`, whatever secrets it
|
|
355
|
+
> contains are shown in the panel verbatim and written to IndexedDB when
|
|
356
|
+
> preserve-log is on. Pass the wire form, not the decrypted one.
|
|
357
|
+
|
|
358
|
+
#### `withInitiatorCapture(instance)`
|
|
359
|
+
|
|
360
|
+
Optional. Wraps the instance in a `Proxy` so each request records the stack of
|
|
361
|
+
its own call site, which the panel shows as the "Initiator" of a row. Wrap
|
|
362
|
+
once, export the wrapped instance:
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
export const apiClient = withInitiatorCapture(axios.create({ baseURL: "/api" }));
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
**It does not matter which of the two you hand to `attachHttpMonitor`.** The
|
|
369
|
+
proxy forwards property reads to the underlying instance, and `interceptors` is
|
|
370
|
+
not a function, so it comes back untouched — `wrapped.interceptors` and
|
|
371
|
+
`original.interceptors` are the same object. Registering on either registers on
|
|
372
|
+
both. There is no wrong choice here and no silent failure.
|
|
373
|
+
|
|
374
|
+
What *does* matter is which one your app calls through. Only the proxy's traps
|
|
375
|
+
record a stack, so every request made against the unwrapped instance is still
|
|
376
|
+
captured but arrives with an empty Initiator column. Export the wrapped one and
|
|
377
|
+
keep the original private:
|
|
378
|
+
|
|
379
|
+
```ts
|
|
380
|
+
const client = axios.create({ baseURL: "/api" });
|
|
381
|
+
client.interceptors.request.use(encryptBody);
|
|
382
|
+
attachHttpMonitor(client); // either one works
|
|
383
|
+
|
|
384
|
+
export const apiClient = withInitiatorCapture(client); // this is what callers use
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
The traps cover the callable form (`apiClient(config)`) plus `request`, `get`,
|
|
388
|
+
`post`, `put`, `patch`, `delete` and `head`. Other entry points — `options`,
|
|
389
|
+
the `*Form` helpers — pass through unwrapped: still captured, just with no
|
|
390
|
+
initiator stack.
|
|
391
|
+
|
|
392
|
+
> **Known limitation.** The Initiator column is produced by filtering your own
|
|
393
|
+
> HTTP wrapper's frames out of the captured stack, and that filter currently
|
|
394
|
+
> matches a fixed set of module paths rather than deriving them from where
|
|
395
|
+
> `withInitiatorCapture` was called. If your axios module does not sit at one
|
|
396
|
+
> of those paths, the top frame reported will be your own wrapper rather than
|
|
397
|
+
> the true call site. There is no option to extend the filter yet.
|
|
398
|
+
|
|
399
|
+
### Redux — `createReduxMonitorMiddleware(options?)`
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
// src/store.ts
|
|
403
|
+
import { configureStore } from "@reduxjs/toolkit";
|
|
404
|
+
import { createReduxMonitorMiddleware } from "@hakam-aldeen-kh/blix/capture";
|
|
405
|
+
|
|
406
|
+
const devMiddleware =
|
|
407
|
+
process.env.NODE_ENV === "development" && typeof window !== "undefined"
|
|
408
|
+
? createReduxMonitorMiddleware({
|
|
409
|
+
ignore: ["analytics/*", "some/noisyAction"],
|
|
410
|
+
})
|
|
411
|
+
: undefined;
|
|
412
|
+
|
|
413
|
+
export const store = configureStore({
|
|
414
|
+
reducer,
|
|
415
|
+
middleware: (getDefault) =>
|
|
416
|
+
devMiddleware ? getDefault().concat(devMiddleware) : getDefault(),
|
|
417
|
+
});
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Options: `ignore` (exact types or `"prefix/*"` globs), `coalesceMs` (repeat
|
|
421
|
+
dispatches of one type inside this window fold into a single row), and
|
|
422
|
+
`maxActionsPerSecond` (above this rate, capture drops to type + timing and
|
|
423
|
+
skips diffing). Outside development the factory returns a pure pass-through
|
|
424
|
+
middleware, so calling it unguarded is *behaviourally* free — but see
|
|
425
|
+
[Guard your call sites](#guard-your-call-sites) for why the guard above is
|
|
426
|
+
still worth the extra three lines.
|
|
427
|
+
|
|
428
|
+
### Realtime — `tapRealtimeAdapter(adapter, transport)`
|
|
429
|
+
|
|
430
|
+
Returns the adapter wrapped; use the return value. `transport` is a free-form
|
|
431
|
+
label shown in the panel (`"pusher"`, `"socket.io"`, …).
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
// src/realtime/adapter.ts
|
|
435
|
+
import { tapRealtimeAdapter } from "@hakam-aldeen-kh/blix/capture";
|
|
436
|
+
|
|
437
|
+
const adapter = new PusherAdapter();
|
|
438
|
+
|
|
439
|
+
export const realtime =
|
|
440
|
+
process.env.NODE_ENV === "development" && typeof window !== "undefined"
|
|
441
|
+
? tapRealtimeAdapter(adapter, "pusher")
|
|
442
|
+
: adapter;
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
Your adapter only needs to structurally satisfy `RealtimeAdapterLike`:
|
|
446
|
+
`connect`, `disconnect`, `subscribe`, `onMessage`, `onPresenceUpdate`. In
|
|
447
|
+
production the adapter is returned untouched.
|
|
448
|
+
|
|
449
|
+
**The tap is transparent, not narrowing.** It returns a `Proxy` typed as your
|
|
450
|
+
adapter's own type, so class-based adapters keep working through it:
|
|
451
|
+
|
|
452
|
+
- methods **outside** `RealtimeAdapterLike` pass straight through — the proxy's
|
|
453
|
+
fallback binds and forwards any property that is not one of the tapped
|
|
454
|
+
methods;
|
|
455
|
+
- `instanceof` still works against your concrete class, because the proxy has
|
|
456
|
+
no `getPrototypeOf` trap and forwards to the target.
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
// Both of these still work through the tap.
|
|
460
|
+
if (this.adapter instanceof ActionCableAdapter) {
|
|
461
|
+
this.adapter.setSubscriptionContext({ accountId, userId }); // not in RealtimeAdapterLike
|
|
462
|
+
}
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
You do not need to keep a second reference to the untapped adapter.
|
|
466
|
+
|
|
467
|
+
### TanStack Query — `tapQueryClient(client)`
|
|
468
|
+
|
|
469
|
+
**`tapQueryClient` is the one exception to the module-scope rule.** Call it
|
|
470
|
+
from an effect in your query provider, *not* from the `useState` initializer
|
|
471
|
+
that creates the client:
|
|
472
|
+
|
|
473
|
+
```tsx
|
|
474
|
+
"use client";
|
|
475
|
+
|
|
476
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
477
|
+
import { tapQueryClient } from "@hakam-aldeen-kh/blix/capture";
|
|
478
|
+
import { useEffect, useState } from "react";
|
|
479
|
+
|
|
480
|
+
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
|
481
|
+
const [client] = useState(() => new QueryClient());
|
|
482
|
+
|
|
483
|
+
useEffect(() => tapQueryClient(client), [client]);
|
|
484
|
+
|
|
485
|
+
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
|
486
|
+
}
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
Why the effect at all: React Strict Mode double-invokes `useState`
|
|
490
|
+
initializers, so tapping there taps a client that is immediately discarded.
|
|
491
|
+
Installing from an effect is correct — because parent effects run after child
|
|
492
|
+
effects, the tap backfills from `getQueryCache().getAll()` on install rather
|
|
493
|
+
than starting blind, so no early events are lost.
|
|
494
|
+
|
|
495
|
+
Returning the disposer is fine: it unsubscribes from both caches and unmarks
|
|
496
|
+
the client, so the Strict Mode cycle — subscribe, dispose, subscribe again —
|
|
497
|
+
reinstalls cleanly and the second install backfills the same way the first did.
|
|
498
|
+
|
|
499
|
+
> **Fixed in 0.3.2.** In 0.3.1 and earlier the disposer unsubscribed but left
|
|
500
|
+
> the client marked as tapped, so the reinstall short-circuited on the tap's
|
|
501
|
+
> internal idempotency guard without resubscribing — leaving the Query tab
|
|
502
|
+
> empty for the whole session, with no error and no warning. On those versions
|
|
503
|
+
> the workaround is to call `tapQueryClient(client)` from the effect without
|
|
504
|
+
> returning its result.
|
|
505
|
+
|
|
506
|
+
If you construct the `QueryClient` at module scope rather than in a component,
|
|
507
|
+
you can tap it at module scope too — the rule is "tap the client that actually
|
|
508
|
+
survives", which in the common React pattern means an effect.
|
|
509
|
+
|
|
510
|
+
---
|
|
511
|
+
|
|
512
|
+
## Mounting the panel
|
|
513
|
+
|
|
514
|
+
Render `<Blix />` **exactly once**. Mounting it more than once gives you
|
|
515
|
+
duplicate panels reading the same log.
|
|
516
|
+
|
|
517
|
+
Mount it from a small client component of its own, and import that component —
|
|
518
|
+
and nothing else Blix-related — from your layout:
|
|
519
|
+
|
|
520
|
+
```tsx
|
|
521
|
+
// app/BlixMount.tsx
|
|
522
|
+
"use client";
|
|
523
|
+
|
|
524
|
+
import { Blix } from "@hakam-aldeen-kh/blix";
|
|
525
|
+
import { apiClient } from "@/src/network/axios";
|
|
526
|
+
import { store } from "@/src/store";
|
|
527
|
+
|
|
528
|
+
// This file is the client boundary on purpose. `apiClient` and `store` are
|
|
529
|
+
// module-scope singletons that build themselves during module evaluation —
|
|
530
|
+
// importing them from a Server Component pulls axios, cookie access, your
|
|
531
|
+
// encryption service and any "use client" helpers they touch into the RSC
|
|
532
|
+
// module graph, which fails the production build. Importing them here keeps
|
|
533
|
+
// that evaluation on the client side of the boundary.
|
|
534
|
+
export default function BlixMount() {
|
|
535
|
+
if (process.env.NODE_ENV !== "development") return null;
|
|
536
|
+
return <Blix store={store} apiClient={apiClient} dbName="my-app-devtools" />;
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
```tsx
|
|
541
|
+
// app/layout.tsx — stays a Server Component; imports only the mount
|
|
542
|
+
import BlixMount from "./BlixMount";
|
|
543
|
+
|
|
544
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
545
|
+
return (
|
|
546
|
+
<html lang="en">
|
|
547
|
+
<body>
|
|
548
|
+
{children}
|
|
549
|
+
<BlixMount />
|
|
550
|
+
</body>
|
|
551
|
+
</html>
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
**The client boundary is a leaf.** It does not make your layout or your
|
|
557
|
+
children client components — only this one file and what it imports. There is
|
|
558
|
+
no bundle cost to isolating it this way, and it is what keeps your store and
|
|
559
|
+
HTTP client out of the server graph.
|
|
560
|
+
|
|
561
|
+
`<Blix />` itself carries a `"use client"` directive and can be rendered from a
|
|
562
|
+
server component directly. That is not the reason for the wrapper file: the
|
|
563
|
+
directive governs Blix's own module, not the modules *you* import alongside it.
|
|
564
|
+
Importing `store` and `apiClient` into `app/layout.tsx` is what breaks the
|
|
565
|
+
build, and it breaks it with an opaque module-resolution error from
|
|
566
|
+
`next build`, far from the mount site.
|
|
567
|
+
|
|
568
|
+
The directive on the root entry also means **everything** re-exported from
|
|
569
|
+
`@hakam-aldeen-kh/blix` — capture functions included — is inside that client
|
|
570
|
+
boundary. That is the reason for the split entry point: import capture from
|
|
571
|
+
`@hakam-aldeen-kh/blix/capture` in any module that runs on the server. See
|
|
572
|
+
[Entry points](#entry-points).
|
|
573
|
+
|
|
574
|
+
### Props — all optional
|
|
575
|
+
|
|
576
|
+
| Prop | Effect when omitted |
|
|
577
|
+
| --- | --- |
|
|
578
|
+
| `store` | The **State** tab renders `— Redux store not provided —`, and **Re-dispatch** is disabled with the reason `Redux store not provided`. Everything else works. |
|
|
579
|
+
| `apiClient` | **Replay request** is disabled with the reason `HTTP client not provided`. Everything else works. |
|
|
580
|
+
| `dbName` | Defaults to `"nm-devtools"`. |
|
|
581
|
+
|
|
582
|
+
`store` and `apiClient` are structurally typed — they need
|
|
583
|
+
`getState`/`subscribe`/`dispatch` and `request` respectively. A redux-toolkit
|
|
584
|
+
store and an axios instance satisfy them as-is. (The interfaces are named
|
|
585
|
+
`StoreLike` and `HttpClientLike` in the source, but they are not exported from
|
|
586
|
+
the package; only `Blix` and `BlixProps` are. You never need to name them —
|
|
587
|
+
structural typing means you just pass your store and client.)
|
|
588
|
+
|
|
589
|
+
Passing neither still gives you a fully working capture log; you only lose the
|
|
590
|
+
two features that need a live handle on the app.
|
|
591
|
+
|
|
592
|
+
### `dbName` — when you need it
|
|
593
|
+
|
|
594
|
+
The panel persists its log to IndexedDB so it survives a reload. IndexedDB is
|
|
595
|
+
scoped **per origin**, not per app — so two apps served from the same origin
|
|
596
|
+
(different ports in dev are different origins, but path-based routing,
|
|
597
|
+
multi-zone Next.js setups and anything behind one reverse proxy are not) both
|
|
598
|
+
open `nm-devtools` and interleave their logs into one database.
|
|
599
|
+
|
|
600
|
+
Give each app its own name to keep them separate:
|
|
601
|
+
|
|
602
|
+
```tsx
|
|
603
|
+
<Blix store={store} apiClient={apiClient} dbName="checkout-devtools" />
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
You can also set it from the capture side, which is useful when capture starts
|
|
607
|
+
before the panel mounts:
|
|
608
|
+
|
|
609
|
+
```ts
|
|
610
|
+
attachHttpMonitor(apiClient, { dbName: "checkout-devtools" });
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
Either call must happen before the database is first opened, which the panel
|
|
614
|
+
does on mount. If both are set, the `<Blix />` prop wins, since render runs
|
|
615
|
+
after module init.
|
|
616
|
+
|
|
617
|
+
---
|
|
618
|
+
|
|
619
|
+
## Production elimination
|
|
620
|
+
|
|
621
|
+
The panel is gated on a **literal** `process.env.NODE_ENV === "development"`
|
|
622
|
+
check that survives verbatim into the published `dist/`. Your bundler
|
|
623
|
+
substitutes it at *your* build time, folds the condition to `false`, and drops
|
|
624
|
+
the dynamic `import()` of the panel along with the whole branch — so no panel
|
|
625
|
+
code reaches your production bundle, and no chunk is emitted for it.
|
|
626
|
+
|
|
627
|
+
This is why the check is written inline rather than imported as a boolean
|
|
628
|
+
constant: cross-module constant propagation is not guaranteed by every
|
|
629
|
+
bundler, but a literal `process.env.NODE_ENV` comparison in the same file is
|
|
630
|
+
handled by all of them.
|
|
631
|
+
|
|
632
|
+
The capture layer is gated on the same condition **plus a
|
|
633
|
+
`typeof window !== "undefined"` check**, so `attachHttpMonitor` and friends
|
|
634
|
+
become no-ops in production even though their call sites remain — and also
|
|
635
|
+
during SSR, in the same dev build where they are live in the browser.
|
|
636
|
+
|
|
637
|
+
That second half is not a bundling concern but a correctness one. The capture
|
|
638
|
+
module is reachable from your HTTP-client module, which typically also runs on
|
|
639
|
+
the server, and the monitor's buffer is a module-level singleton: on a
|
|
640
|
+
long-lived Node process it would otherwise accumulate every user's request
|
|
641
|
+
payloads for the lifetime of the server. Keeping the server-side singleton
|
|
642
|
+
permanently empty is the point.
|
|
643
|
+
|
|
644
|
+
Practically: a `captureEncrypted` call that runs during SSR does nothing, and
|
|
645
|
+
`<Blix />` returns `null` there — it checks `typeof window` alongside
|
|
646
|
+
`NODE_ENV` before touching the panel import.
|
|
647
|
+
|
|
648
|
+
### Guard your call sites
|
|
649
|
+
|
|
650
|
+
**No-op is not the same as eliminated.** The argument above works for the
|
|
651
|
+
panel because the literal check lives in the file that makes the dynamic
|
|
652
|
+
import. It does *not* carry over to the capture layer, and the reason is the
|
|
653
|
+
same one that motivated writing the check inline in the first place.
|
|
654
|
+
|
|
655
|
+
Every capture function tests a single internal constant inside its own body,
|
|
656
|
+
and that constant lives in Blix's module, not yours. Your bundler folds the
|
|
657
|
+
constant to `false` — but it keeps the function bodies that reference it,
|
|
658
|
+
because your call site still imports them. The result is tens of kilobytes of
|
|
659
|
+
capture runtime in a production bundle where every entry point is dead.
|
|
660
|
+
|
|
661
|
+
To drop it, write the same literal check in the file that makes the call,
|
|
662
|
+
exactly as Blix does internally:
|
|
663
|
+
|
|
664
|
+
```ts
|
|
665
|
+
// ✅ folded away in production — the whole capture chunk is dropped
|
|
666
|
+
export const apiClient =
|
|
667
|
+
process.env.NODE_ENV === "development"
|
|
668
|
+
? withInitiatorCapture(createClient())
|
|
669
|
+
: createClient();
|
|
670
|
+
|
|
671
|
+
if (process.env.NODE_ENV === "development" && typeof window !== "undefined") {
|
|
672
|
+
attachHttpMonitor(apiClient);
|
|
673
|
+
}
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
```ts
|
|
677
|
+
// ❌ works, but keeps the capture runtime in your production bundle
|
|
678
|
+
export const apiClient = withInitiatorCapture(createClient());
|
|
679
|
+
attachHttpMonitor(apiClient);
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
Two rules for writing the guard:
|
|
683
|
+
|
|
684
|
+
- **Write the condition out literally, in the file that makes the call.**
|
|
685
|
+
Hoisting it into a shared `const IS_DEV` defeats the folding, for the same
|
|
686
|
+
reason Blix writes it inline in its own source.
|
|
687
|
+
- **Include `typeof window !== "undefined"`** when the module also evaluates on
|
|
688
|
+
the server, which in the Next.js App Router it generally does. It is
|
|
689
|
+
redundant with Blix's internal guard, but it keeps the folded branch
|
|
690
|
+
unambiguous for the bundler and matches the condition Blix uses internally.
|
|
691
|
+
|
|
692
|
+
`createReduxMonitorMiddleware` needs the middleware callback restructured
|
|
693
|
+
rather than a one-line guard — see the [Redux](#redux--createreduxmonitormiddlewareoptions)
|
|
694
|
+
example above, which is written in the guarded form.
|
|
695
|
+
|
|
696
|
+
---
|
|
697
|
+
|
|
698
|
+
## Entry points
|
|
699
|
+
|
|
700
|
+
| Import | Contents | `"use client"` |
|
|
701
|
+
| --- | --- | --- |
|
|
702
|
+
| `@hakam-aldeen-kh/blix` | `Blix`, `BlixProps` + everything below | **yes** |
|
|
703
|
+
| `@hakam-aldeen-kh/blix/capture` | capture functions and types only — no React | no |
|
|
704
|
+
|
|
705
|
+
Import capture functions from `/capture` in modules that run during SSR or at
|
|
706
|
+
module-eval time. It pulls in no React code and carries no `"use client"`
|
|
707
|
+
directive, so it stays usable from a server module — which the root entry, by
|
|
708
|
+
virtue of the directive that lets `<Blix />` be rendered from a server
|
|
709
|
+
component, is not.
|
|
710
|
+
|
|
711
|
+
The `/capture` entry exports `attachHttpMonitor`, `captureEncrypted`,
|
|
712
|
+
`createReduxMonitorMiddleware`, `tapQueryClient`, `tapRealtimeAdapter`,
|
|
713
|
+
`withInitiatorCapture`, and the supporting types (`EncryptedPayload`,
|
|
714
|
+
`ReduxCaptureOptions`, `RealtimeAdapterLike`, `MonitorEntry`, …).
|
|
715
|
+
|
|
716
|
+
---
|
|
717
|
+
|
|
718
|
+
## License
|
|
719
|
+
|
|
720
|
+
MIT — see [LICENSE](./LICENSE).
|