@hakam-aldeen-kh/blix 0.10.0 → 0.12.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/README.md CHANGED
@@ -1,1409 +1,61 @@
1
- # @hakam-aldeen-kh/blix
2
- [![npm](https://img.shields.io/npm/v/@hakam-aldeen-kh/blix)](https://www.npmjs.com/package/@hakam-aldeen-kh/blix)
1
+ <h1>
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Hakam-aldeen-Kh/blix/main/docs/brand/blix-lockup-dark-bg.png">
4
+ <img alt="blix" src="https://raw.githubusercontent.com/Hakam-aldeen-Kh/blix/main/docs/brand/blix-lockup-light-bg.png" height="64">
5
+ </picture>
6
+ </h1>
3
7
 
4
- An in-app dev-tools panel for React apps. Captures HTTP requests, Redux
5
- actions, TanStack Query cache events and realtime traffic, and renders them in
6
- a dockable panel with cross-source links, diffing, replay and HAR/cURL
7
- export.
8
+ An in-app dev-tools panel for React developers: every HTTP request, Redux action, TanStack Query cache event and realtime frame your app produces, in one place, while you build.
8
9
 
9
- The entire panel is eliminated from production builds — see
10
- [Production elimination](#production-elimination). To keep it on a staging
11
- server, see [Enabling Blix in a production build](#enabling-blix-in-a-production-build-staging).
10
+ [![npm](https://img.shields.io/npm/v/@hakam-aldeen-kh/blix)](https://www.npmjs.com/package/@hakam-aldeen-kh/blix) [![CI](https://github.com/Hakam-aldeen-Kh/blix/actions/workflows/ci.yml/badge.svg)](https://github.com/Hakam-aldeen-Kh/blix/actions/workflows/ci.yml) [![license](https://img.shields.io/npm/l/@hakam-aldeen-kh/blix)](https://github.com/Hakam-aldeen-Kh/blix/blob/main/LICENSE)
12
11
 
13
- Release notes are in [CHANGELOG.md](https://github.com/Hakam-aldeen-Kh/blix/blob/main/CHANGELOG.md).
12
+ ## Why Blix
14
13
 
15
- ---
16
-
17
- ## Install
18
-
19
- ```bash
20
- pnpm add -D @hakam-aldeen-kh/blix
21
- ```
14
+ - **Four sources, one panel.** HTTP through `fetch` and axios, Redux actions with their state diffs, TanStack Query cache events, and realtime frames. The panel floats or docks to any edge of the page.
15
+ - **Linked across sources.** When a query caused a request, or one entry is a replay of another, the row links to the other side. One click instead of matching timestamps by hand.
16
+ - **Plaintext on both legs.** Attached after your own axios interceptors, Blix records the request body before you encrypt it and the response after you decrypt it.
17
+ - **Replay and export.** Re-send an axios request or re-dispatch a Redux action. Copy any request as cURL or `fetch`, or export the log as HAR, JSON, NDJSON, Markdown, CSV or a cURL script. `Authorization`, `Cookie`, `Set-Cookie` and `X-API-Key` values are masked in every one of them.
18
+ - **Gone from production builds.** The panel sits behind a literal `process.env.NODE_ENV === "development"` check, so it never loads in production. Capture calls become no-ops, and a one-line guard at the call site removes them from the bundle too. In Next.js, also set `NEXT_PUBLIC_BLIX=false` in your production env, or the panel's unused chunk is still emitted.
22
19
 
23
- Or with npm:
20
+ ## Quick start
24
21
 
25
22
  ```bash
26
23
  npm install -D @hakam-aldeen-kh/blix
27
24
  ```
28
25
 
29
- It installs as a `devDependency`, but the mount component that renders
30
- `<Blix />` imports it from your application code — so any build that drops
31
- devDependencies *before* the build step (`npm ci --omit=dev`,
32
- `pnpm install --prod`, the usual shape of a multi-stage Docker image) fails
33
- while resolving that import. devDependencies have to be present at build time.
34
- Dropping them from the final runtime image is fine: nothing from Blix reaches
35
- the production output anyway.
36
-
37
- The package ships **ESM only**. Both entry points declare `"types"` and
38
- `"import"` in `exports`, with no `"require"` and no `"default"` fallback, so
39
- `require("@hakam-aldeen-kh/blix")` and CJS-only tooling — an older Jest config
40
- is the usual one — fail with `ERR_PACKAGE_PATH_NOT_EXPORTED`. That is by
41
- design, not a packaging bug: a dev tool that is eliminated from production has
42
- no reason to carry a second build output.
43
-
44
- ### Peer dependencies
45
-
46
- `react` and `react-dom` (v19) are required. `axios`, `@reduxjs/toolkit` and
47
- `@tanstack/react-query` are **optional** peers — you only need the ones whose
48
- capture you actually use. The capture layer is structurally typed against each
49
- of them and imports none of them — not at runtime, and not in its published
50
- type declarations — so installing Blix does not pull a data-fetching or state
51
- library into your tree, and your type-check never goes looking for one.
52
-
53
- **axios is optional, and fetch-only apps are fully supported.** An app that
54
- calls `fetch` directly — the default in a Next.js App Router project — captures
55
- its HTTP traffic with
56
- [`attachFetchMonitor`](#http-fetch--attachfetchmonitoroptions) and never needs
57
- axios installed, not even for types.
58
-
59
- ---
60
-
61
- ## The module-init call-order contract
62
-
63
- **This is the part that is easy to get wrong.** Capture functions must be
64
- called where the thing they wrap is *constructed*, at module scope — not from
65
- inside a component body or a `useEffect`.
66
-
67
- If you call them from a component, capture only starts once React mounts, and
68
- every request fired before that point — auth bootstrap, session restore,
69
- prefetches, anything at module-eval time — is silently missed. The panel then
70
- shows a log with a hole at the beginning, which is exactly when you most need
71
- it.
72
-
73
- > **Import capture from `@hakam-aldeen-kh/blix/capture`, not from the package
74
- > root.** The root entry carries a `"use client"` directive so that `<Blix />`
75
- > can be rendered from a server component, which means every module that
76
- > imports from it lands inside a client boundary. Your axios module, your store
77
- > and your realtime adapter generally also evaluate on the server — in the
78
- > Next.js App Router they always do — where the root entry is at best dead
79
- > weight and at worst a boundary violation. Keep the root import in the single
80
- > client component that mounts the panel. See [Entry points](#entry-points).
81
-
82
- | Function | Where to call it | Timing |
83
- | --- | --- | --- |
84
- | `attachHttpMonitor` | after your own interceptors are registered on the instance | module scope |
85
- | `attachFetchMonitor` | before the first `fetch` you want captured — client-side only | module scope |
86
- | `createReduxMonitorMiddleware` | in `configureStore`'s `middleware` callback | module scope |
87
- | `tapRealtimeAdapter` | where the adapter singleton is constructed | module scope |
88
- | `tapQueryClient` | a `useEffect` in your query provider | see below |
89
- | `captureEncrypted` | inside your own encrypt/decrypt functions, on success paths only | per request — optional, see below |
90
-
91
- Every one of these is a no-op in production, but the call sites still cost you
92
- bytes unless you guard them. See
93
- [Guard your call sites](#guard-your-call-sites) — it is one line per site and
94
- it is the difference between shipping the capture runtime and not.
95
-
96
- ### HTTP — `attachHttpMonitor(instance, options?)`
97
-
98
- Call it **after your own interceptors are registered**. That one placement is
99
- what puts Blix on the plaintext side of *both* legs of the request, for two
100
- different reasons:
101
-
102
- - **On the way out**, axios runs request interceptors LIFO, so registering last
103
- makes Blix's interceptor run **first** — before any encryption or
104
- serialization step. It sees the plaintext body.
105
- - **On the way back**, axios runs response interceptors FIFO, so registering
106
- last makes Blix's interceptor run **last** — after your decrypt interceptor.
107
- It sees the decrypted body.
108
-
109
- The two orders are opposite, and they happen to agree on the same answer:
110
- register last.
111
-
112
- > **Registering earlier is a silent wrong reading, not an error.** If
113
- > `attachHttpMonitor` runs before your decrypt interceptor, Blix's response
114
- > interceptor runs before it too, and the **Response** tab fills with
115
- > ciphertext presented as an ordinary response body. Nothing throws and nothing
116
- > warns — the panel just shows you base64 where it should show you an object.
117
- > On the request side the mirror-image mistake gives you a **Payload** tab full
118
- > of ciphertext.
119
-
120
- > **axios 1.19+ can invert the request-side half.** The request-interceptor
121
- > LIFO order is now governed by the transitional flag
122
- > `legacyInterceptorReqResOrdering`, which still defaults to `true`. If you set
123
- > `transitional: { legacyInterceptorReqResOrdering: false }`, request
124
- > interceptors become FIFO and the request-side rule flips to "register
125
- > `attachHttpMonitor` **first**" — while the response-side rule still says
126
- > last, so the two orders no longer agree and you must pick which leg matters
127
- > more. Response-interceptor order is unaffected by the flag. Blix warns once,
128
- > in development, when a request carries the flag set to `false`.
129
-
130
- ```ts
131
- // src/network/axios.ts
132
- import axios from "axios";
133
- import { attachHttpMonitor, withInitiatorCapture } from "@hakam-aldeen-kh/blix/capture";
134
-
135
- export const apiClient = axios.create({ baseURL: "/api" });
136
-
137
- apiClient.interceptors.request.use(addAuthHeader);
138
- apiClient.interceptors.request.use(encryptBody);
139
-
140
- // After your interceptors, at module scope — NOT in a hook, NOT in a component.
141
- if (process.env.NODE_ENV === "development" && typeof window !== "undefined") {
142
- attachHttpMonitor(apiClient);
143
- }
144
- ```
145
-
146
- `attachHttpMonitor` is idempotent per instance — a second call on the same
147
- instance, or on its `withInitiatorCapture` wrapper, does nothing — and it
148
- returns a disposer that ejects both interceptors, for HMR and tests. A request
149
- cancelled through an `AbortController` or a `CancelToken` settles as
150
- **aborted**, not as an error; a timeout is still an error.
151
-
152
- #### Factory and lazy-singleton clients
153
-
154
- The rule is **causal, not positional**. "Bottom of the module" is shorthand
155
- that only holds when your interceptors are registered by statements physically
156
- above the call. If your instance comes from a factory or a lazy singleton, the
157
- interceptors are registered inside that factory, on first call — so what
158
- matters is that *something has already triggered construction*:
159
-
160
- ```ts
161
- // ApiClientFactory.getInstance() registers the interceptors on its first call.
162
- export const apiClient = withInitiatorCapture(ApiClientFactory.getInstance());
163
-
164
- // Safe: getInstance() ran on the line above, so the interceptors exist by now.
165
- if (process.env.NODE_ENV === "development" && typeof window !== "undefined") {
166
- attachHttpMonitor(apiClient);
167
- }
168
- ```
169
-
170
- If nothing above the call has constructed the instance, `attachHttpMonitor`
171
- registers **first** rather than last, and you get the silent wrong reading
172
- described above. There is no need for `queueMicrotask`, `setTimeout` or any
173
- other deferral: Blix does not require one, and deferring only hides whether the
174
- ordering is actually correct.
175
-
176
- #### Failed requests and the shape of your rejection
177
-
178
- Blix correlates a response — success **or** error — back to its entry through
179
- `error.config`. If your response interceptor normalises errors into your own
180
- domain type, a very common pattern, the value Blix receives is a plain object
181
- with no `config` on it:
182
-
183
- ```ts
184
- // ❌ Blix can no longer see the entry: no `.config` on the rejected value
185
- instance.interceptors.response.use(undefined, (error) =>
186
- Promise.reject(error.response?.data ?? fallbackError),
187
- );
188
- ```
189
-
190
- The consequence is silent and total: **every non-2xx request stays `pending`
191
- in the panel for the rest of the session.** No error, no warning, no Failed
192
- filter. Nothing times the row out either: its duration keeps counting for as
193
- long as the page stays open.
194
-
195
- Two ways out, and you currently have to choose one:
196
-
197
- 1. **Keep the config on your normalised error** — attach `config` (or the
198
- original `AxiosError`) to the object you reject with. This preserves
199
- plaintext request capture and is the recommended fix.
200
- 2. **Register Blix before your normalising handler**, so it runs first on the
201
- response path:
202
-
203
- ```ts
204
- attachHttpMonitor(apiClient); // first on the response path
205
- apiClient.interceptors.request.use(addAuth);
206
- apiClient.interceptors.response.use(undefined, normaliseError);
207
- ```
208
-
209
- This costs you plaintext request capture, because Blix's request interceptor
210
- now runs last — after encryption.
211
-
212
- From a single `attachHttpMonitor` call you cannot currently have both plaintext
213
- request bodies and correlated errors while also discarding the `AxiosError`.
214
-
215
- #### Response interceptors that unwrap
216
-
217
- The same thing happens on the success path. A response interceptor registered
218
- before Blix that returns something other than the response —
219
- `(response) => response.data` is the usual one — hands Blix a value with no
220
- `config` on it, so there is nothing to correlate:
221
-
222
- - your request is unaffected: your code receives exactly what your interceptor
223
- returned. (Before 0.7.0 Blix threw at this point, and every request on the
224
- instance failed.)
225
- - the entry **stays `pending`** for the rest of the session. Blix does not
226
- settle a request it cannot see settle.
227
-
228
- Register the unwrapping interceptor **after** `attachHttpMonitor` — response
229
- interceptors run in registration order, so Blix then sees the full response
230
- first — or unwrap at the call site instead.
231
-
232
- #### Which request headers the panel shows
233
-
234
- Blix's request interceptor runs before yours, so when it runs, the headers your
235
- interceptors add — auth, tracing, signing, locale — do not exist yet, and
236
- neither does the `Content-Type` axios sets itself. So the panel shows the
237
- headers the request **settled** with, re-read from the config axios hands back
238
- with the response or the error, and masked the same way. Until then — and for
239
- good, for a request that never settles or whose rejection carries no `config` —
240
- it shows the snapshot taken when the request started. JWT claims follow the same
241
- rule.
242
-
243
- Some headers never appear, because axios's adapter adds them to its own copy of
244
- the config, past the last point Blix can read:
245
-
246
- - **`Authorization: Basic …` from the `auth` option.** When `auth` is set, the
247
- panel keeps the `Authorization` it saw when the request started: the settled
248
- config still shows whatever your interceptors set, which is not what was
249
- sent. Every other header is still taken from the settled config.
250
- - **The XSRF header** (`X-XSRF-TOKEN` by default).
251
- - **Headers the browser manages itself** — `Cookie`, `Origin`, `User-Agent` and
252
- the like. Chrome's Network tab shows them; JavaScript never sees them.
253
-
254
- #### Encrypted payloads — `captureEncrypted(config, payload)`
255
-
256
- *Since 0.3.0.*
257
-
258
- > **axios only.** Correlation works through the config object your own
259
- > interceptor is holding, and `fetch` has no interceptor stage to hold one in,
260
- > so `captureEncrypted` has no effect on requests captured by
261
- > [`attachFetchMonitor`](#http-fetch--attachfetchmonitoroptions).
262
-
263
- **Entirely optional.** An app that never calls it behaves exactly as it did
264
- before this API existed, and its panel shows no Encrypted tab at all — the tab
265
- appears only on entries that actually carry ciphertext.
266
-
267
- Blix cannot capture the encrypted forms by itself. It has no knowledge of your
268
- encryption scheme, and — by the design above — its interceptor deliberately
269
- sits on the *plaintext* side, so at the moment Blix captures, the ciphertext
270
- does not exist yet. `captureEncrypted` is the hand-off: you call it from inside
271
- your own interceptors, where the ciphertext does exist, and pass back the same
272
- config object Blix already saw.
273
-
274
- Two calls, one on the way out and one on the way back:
275
-
276
- ```ts
277
- // src/network/axios.ts
278
- import { attachHttpMonitor, captureEncrypted } from "@hakam-aldeen-kh/blix/capture";
279
-
280
- apiClient.interceptors.request.use((config) => {
281
- const encrypted = encryptBody(config.data);
282
- captureEncrypted(config, { request: encrypted });
283
- return { ...config, data: encrypted };
284
- });
285
-
286
- apiClient.interceptors.response.use((response) => {
287
- captureEncrypted(response.config, { response: response.data });
288
- return { ...response, data: decryptBody(response.data) };
289
- });
290
- ```
291
-
292
- Both calls work on the *copies* those interceptors return, not on the objects
293
- axios created — see [How correlation works](#how-correlation-works) for why
294
- that still resolves. On the response side it is `response.config` that has to
295
- carry the stamp, and it does: axios threads the object returned by the last
296
- request interceptor straight through to `response.config`, so the
297
- `{ ...config, data: encrypted }` above is literally the object you get back.
298
-
299
- ##### Dropping it into an interceptor you already have
300
-
301
- The example above builds a fresh interceptor that returns a spread copy. Most
302
- real pipelines have one large multi-step interceptor that mutates `config.data`
303
- in place. `captureEncrypted` needs no restructuring for that — it is two lines:
304
-
305
- ```ts
306
- private static async encryptRequest(config) {
307
- const encrypted = await encryptionService.encryptApiPayload(
308
- JSON.stringify(config.data),
309
- key,
310
- );
311
- captureEncrypted(config, { request: encrypted }); // ← add
312
- config.data = encrypted;
313
- return config;
314
- }
315
-
316
- private static async decryptResponse(response) {
317
- captureEncrypted(response.config, { response: response.data }); // ← add, BEFORE decrypting
318
- return { ...response, data: await encryptionService.decryptApiResponse(response) };
319
- }
320
- ```
321
-
322
- Note the placement on the response side: call it **before** you decrypt, so the
323
- value you hand over is the wire form. Calling it after decryption puts your
324
- plaintext response — secrets included — under a tab labelled Encrypted, which
325
- is both wrong and a disclosure. See the redaction note below.
326
-
327
- ##### Call it only where encryption actually happened
328
-
329
- The examples above call `captureEncrypted` unconditionally, which is only
330
- correct because they have no path that skips encryption. Real pipelines do: an
331
- endpoint on an exclusion list, an explicit `skipEncryption` flag, a missing or
332
- not-yet-derived session key, a `FormData` body carrying only file parts, an
333
- encryption failure the interceptor swallows so the request can still go out.
334
-
335
- On any of those paths the value you would hand over is **plaintext**, and Blix
336
- has no way to know that — it labels whatever you pass as the encrypted wire
337
- form and shows it under the Encrypted tab. The result is a reading that looks
338
- authoritative and is wrong, which is worse than no reading at all.
339
-
340
- So put the call on the **success path, inside the function that encrypts** —
341
- next to the line that produced the ciphertext, where it cannot outlive the
342
- condition that made it true — rather than in the interceptor after the
343
- function returns:
344
-
345
- ```ts
346
- // Inside your encryption module, not in the interceptor.
347
- function encryptBody(config) {
348
- if (shouldSkip(config)) return config; // no call — nothing encrypted
349
- const key = sessionKey();
350
- if (!key) return config; // no call — nothing encrypted
351
-
352
- try {
353
- const encrypted = seal(config.data, key);
354
- captureEncrypted(config, { request: encrypted }); // only here
355
- config.data = encrypted;
356
- } catch {
357
- // Encryption failed and we're sending plaintext — deliberately no call.
358
- }
359
- return config;
360
- }
361
- ```
362
-
363
- The same rule governs the response side: call it only where you know the body
364
- you are holding is the pre-decryption wire form.
365
-
366
- ##### How correlation works
367
-
368
- Requests and responses are correlated by the **identity of the config object**,
369
- never by URL or timing, so two concurrent calls to the same endpoint stay
370
- correctly apart. Pass the object axios handed you; a `{ ...config }` copy made
371
- by your own interceptor resolves too.
372
-
373
- The copy resolves because there are two stamps, not one:
374
-
375
- | Stamp | Visibility | Survives `{ ...config }` |
376
- | --- | --- | --- |
377
- | a registry-global `Symbol`, non-enumerable | invisible to `Object.keys`, `JSON.stringify`, `Object.entries` | **no** — spread copies only enumerable own properties |
378
- | `__monitorId`, a plain enumerable string property | shows up in `Object.keys` and a `JSON.stringify` of the *config* | **yes** |
379
-
380
- The symbol is the primary; `__monitorId` is what the resolver falls back to,
381
- and it is the same property `attachHttpMonitor` has set since 0.2.1 for its own
382
- response interceptor. A third fallback, a `WeakMap`, covers only a frozen or
383
- sealed config that rejects `defineProperty`.
384
-
385
- Two consequences worth being explicit about. **Neither stamp reaches the
386
- wire** — they live on the axios config, and axios serializes only `data`. But
387
- `__monitorId` *is* enumerable, so it will appear if you log or stringify the
388
- config object itself; only the symbol is fully invisible. And a copy that
389
- enumerates fields **explicitly** — `{ url, method, data: encrypted }` rather
390
- than a spread — carries neither stamp and will not resolve, making the call a
391
- silent no-op. Spread, or mutate in place.
392
-
393
- The two calls are independent and order-free: the request-side ciphertext is
394
- produced early and the response-side arrives late, possibly after Blix has
395
- already finalized the entry. Either way it merges into the existing entry —
396
- never creating one of its own — and the panel updates.
397
-
398
- It is a **silent no-op** — never a throw, never a console warning — in every
399
- one of these:
400
-
401
- - outside development (`process.env.NODE_ENV !== "development"`), unless the
402
- build opted in with `NEXT_PUBLIC_BLIX=true` (see
403
- [staging](#enabling-blix-in-a-production-build-staging));
404
- - **outside the browser** — the gate is also `typeof window !== "undefined"`,
405
- so every call made while rendering on the server does nothing, by design (see
406
- [Production elimination](#production-elimination));
407
- - when `attachHttpMonitor` was never called on the instance;
408
- - when the config carries no stamp — a retry that built a fresh config, a
409
- config assembled field-by-field rather than spread, or a request that started
410
- while capture was **paused** from the panel's toolbar;
411
- - when `payload` is missing, or both `request` and `response` on it are
412
- `null`/`undefined` (a bare `{ request }` will not blank a `response` captured
413
- by an earlier call);
414
- - when the entry has already been evicted from the buffer.
415
-
416
- In production it is eliminated entirely, along with the rest of capture.
417
-
418
- Values may be a string, a plain object, or an `ArrayBuffer`/typed array (kept
419
- as a bounded hex preview plus byte length). They go through the same
420
- serialization and truncation rules as the plaintext bodies, both in the panel
421
- and in IndexedDB, so a multi-megabyte ciphertext cannot blow out the log.
422
-
423
- > **Redaction.** Blix masks sensitive *headers* — and the list is closed at
424
- > exactly four, matched exactly: `authorization`, `cookie`, `set-cookie`,
425
- > `x-api-key`. It does **not**, and cannot, redact anything inside the values
426
- > you pass here — they are bodies, and Blix has no way to tell ciphertext from
427
- > plaintext. If you pass an already-decrypted body as `response`, whatever
428
- > secrets it contains are shown in the panel verbatim and written to IndexedDB
429
- > when preserve-log is on. Pass the wire form, not the decrypted one. See
430
- > [Security](#security) for the full picture.
431
-
432
- #### `withInitiatorCapture(instance)`
433
-
434
- Optional. Wraps the instance in a `Proxy` so each request records the stack of
435
- its own call site, which the panel shows as the "Initiator" of a row. Wrap
436
- once, export the wrapped instance:
437
-
438
- ```ts
439
- export const apiClient = withInitiatorCapture(axios.create({ baseURL: "/api" }));
440
- ```
441
-
442
- **It does not matter which of the two you hand to `attachHttpMonitor`.** The
443
- proxy forwards property reads to the underlying instance, and `interceptors` is
444
- not a function, so it comes back untouched — `wrapped.interceptors` and
445
- `original.interceptors` are the same object. Registering on either registers on
446
- both. There is no wrong choice here and no silent failure.
447
-
448
- What *does* matter is which one your app calls through. Only the proxy's traps
449
- record a stack, so every request made against the unwrapped instance is still
450
- captured but arrives with no initiator. Export the wrapped one and
451
- keep the original private:
452
-
453
- ```ts
454
- const client = axios.create({ baseURL: "/api" });
455
- client.interceptors.request.use(encryptBody);
456
- attachHttpMonitor(client); // either one works
457
-
458
- export const apiClient = withInitiatorCapture(client); // this is what callers use
459
- ```
460
-
461
- The traps cover the callable form (`apiClient(config)`) plus `request`, `get`,
462
- `post`, `put`, `patch`, `delete` and `head`. Other entry points — `options`,
463
- the `*Form` helpers — pass through unwrapped: still captured, just with no
464
- initiator stack.
465
-
466
- > **Known limitation.** The initiator stack is produced by filtering your own
467
- > HTTP wrapper's frames out of the captured stack, and that filter currently
468
- > matches a fixed set of module paths rather than deriving them from where
469
- > `withInitiatorCapture` was called. If your axios module does not sit at one
470
- > of those paths, the top frame reported will be your own wrapper rather than
471
- > the true call site. There is no option to extend the filter yet.
472
-
473
- ### HTTP (fetch) — `attachFetchMonitor(options?)`
474
-
475
- For requests made with `fetch` directly rather than through axios. It wraps
476
- `globalThis.fetch`, so it captures **every** `fetch` the page makes — your own
477
- and any library's or third-party script's — into the same Network section, in
478
- the same shape, with the same header masking and the same absence of body
479
- redaction (see [Security](#security)). Its rows show **Client: fetch** in the
480
- Headers tab, and `client:fetch` filters to them. Each row records the call
481
- stack of the `fetch` call that made it; no `withInitiatorCapture` is needed.
482
-
483
- ```ts
484
- // instrumentation-client.ts (Next.js 15.3+), or the first module your client bundle evaluates
485
- import { attachFetchMonitor } from "@hakam-aldeen-kh/blix/capture";
486
-
487
- if (process.env.NODE_ENV === "development" && typeof window !== "undefined") {
488
- attachFetchMonitor();
489
- }
490
- ```
491
-
492
- **Call it before the first `fetch` you want captured.** There are no
493
- interceptors to order against, but anything fetched before the wrapper is
494
- installed is simply not seen. In a Next.js App Router project,
495
- `instrumentation-client.ts` runs on the client before your application code and
496
- is the natural place; otherwise, the top of the first module your client bundle
497
- evaluates.
498
-
499
- **Client-side only.** It never patches `fetch` on the server, so Next.js's
500
- server-side `fetch` and its data cache are untouched — and like every capture
501
- function, it is a no-op in production.
502
-
503
- It returns a **disposer** that restores the original `fetch`, and calling
504
- `attachFetchMonitor` again while it is installed does nothing. The disposer
505
- only restores `fetch` while Blix's wrapper is still the current one: if another
506
- library has wrapped `fetch` since, restoring would silently remove that wrapper
507
- too, so it leaves `fetch` alone and warns in development instead.
508
-
509
- #### Bodies, streams and the size cap
510
-
511
- Blix reads bodies from clones, so your code always receives an untouched
512
- `Request` and `Response`. It never buffers without a limit:
513
-
514
- | Situation | What is recorded |
515
- | --- | --- |
516
- | `Content-Type: text/event-stream` | status, headers, timing — body not captured, because the stream does not end |
517
- | `Content-Length` over the cap | status, headers, timing — body not captured, reading never starts |
518
- | body passes the cap while downloading | status, headers, timing — body not captured, reading abandoned |
519
- | `no-cors` (opaque) response | status `0` — headers and body not captured, because the browser hides them |
520
- | request body is a `ReadableStream` | request body not captured — reading it would consume the upload |
521
-
522
- A skipped body is recorded as `«body not captured: …»` with the reason, so an
523
- empty Response tab always means an empty response. The cap applies to request
524
- and response bodies alike, defaults to **5 MB**, and is set with
525
- `maxBodyBytes`:
526
-
527
- ```ts
528
- attachFetchMonitor({ maxBodyBytes: 1024 * 1024 }); // 1 MB — 0 captures no bodies
529
- ```
530
-
531
- The cap is enforced by **counting bytes as they arrive**, not by trusting
532
- `Content-Length`. That header is the compressed size, and a small gzipped
533
- response can decode to many times it; a declared length over the cap only lets
534
- Blix skip without starting. A row's duration runs to the end of the body when
535
- Blix read it, and to the response headers when it did not.
536
-
537
- A non-2xx response is recorded as a failed request with its body under
538
- **Error**, the same way an axios rejection is. An aborted request settles as
539
- **aborted**; a network failure or an `AbortSignal.timeout()` settles as an
540
- error. Either way the original rejection reaches your code untouched.
541
-
542
- #### What is left out by default
543
-
544
- A development server makes plenty of `fetch` calls of its own, and they would
545
- bury your requests. These are **not captured by default**:
546
-
547
- | Rule | Matches |
548
- | --- | --- |
549
- | `"/_next/"` | Next.js assets, HMR updates and Pages Router data requests |
550
- | `"/__nextjs"` | the Next.js dev overlay — stack frames, source maps, open-in-editor |
551
- | `/[?&]_rsc=/` | App Router navigations and `<Link>` prefetches |
552
- | `".hot-update."` | webpack HMR update manifests and chunks |
553
- | `"/__webpack_hmr"` | webpack-hot-middleware |
554
-
555
- A string matches anywhere in the URL's path and query, a regular expression is
556
- tested against the same string, and a function receives the resolved `URL`.
557
- Pass an array to **replace** the defaults, or a function to **extend** them:
558
-
559
- ```ts
560
- attachFetchMonitor({ ignore: (defaults) => [...defaults, "/api/health"] }); // extend
561
- attachFetchMonitor({ ignore: [] }); // capture everything
562
- ```
563
-
564
- **An ignored request is dropped silently** — no row records that it was
565
- skipped. If a request you expected is missing, check it against this list
566
- first.
567
-
568
- #### Using it alongside axios
569
-
570
- Installing both `attachFetchMonitor` and `attachHttpMonitor` is fine. axios's
571
- default browser adapter is XHR, which the fetch wrapper never sees. If you
572
- configure axios with `adapter: "fetch"`, one request passes through both — and
573
- **the axios entry wins**. It was captured on the plaintext side of your
574
- interceptors, keeps `captureEncrypted` and `withInitiatorCapture`, and can be
575
- replayed; the fetch wrapper would only see the same request after encryption.
576
- `attachHttpMonitor` marks each request on its way into axios's fetch adapter,
577
- and the fetch wrapper skips anything carrying the mark, so every request is
578
- logged exactly once. The order of the two calls does not matter.
579
-
580
- #### What it does not do
581
-
582
- - **No replay.** Fetch rows show Replay disabled, with the reason. Blix does
583
- not record a call's `credentials`, `mode` or `cache`, or a JSON body as the
584
- exact bytes that were sent, so a replay could not promise to be the same
585
- request.
586
- - **No `captureEncrypted`** — see
587
- [Encrypted payloads](#encrypted-payloads--captureencryptedconfig-payload).
588
- - **No timeout.** A request that never answers stays pending, exactly as an
589
- axios request does.
590
-
591
- ### Redux — `createReduxMonitorMiddleware(options?)`
592
-
593
- ```ts
594
- // src/store.ts
595
- import { configureStore } from "@reduxjs/toolkit";
596
- import { createReduxMonitorMiddleware } from "@hakam-aldeen-kh/blix/capture";
597
-
598
- const devMiddleware =
599
- process.env.NODE_ENV === "development" && typeof window !== "undefined"
600
- ? createReduxMonitorMiddleware({
601
- ignore: ["analytics/*", "some/noisyAction"],
602
- })
603
- : undefined;
604
-
605
- export const store = configureStore({
606
- reducer,
607
- middleware: (getDefault) =>
608
- devMiddleware ? getDefault().concat(devMiddleware) : getDefault(),
609
- });
610
- ```
611
-
612
- Options: `ignore` (exact types or `"prefix/*"` globs), `coalesceMs` (repeat
613
- dispatches of one type inside this window fold into a single row), and
614
- `maxActionsPerSecond` (above this rate, capture drops to type + timing and
615
- skips diffing). Outside development the factory returns a pure pass-through
616
- middleware, so calling it unguarded is *behaviourally* free — but see
617
- [Guard your call sites](#guard-your-call-sites) for why the guard above is
618
- still worth the extra three lines.
619
-
620
- ### Realtime — `tapRealtimeAdapter(adapter, transport)`
621
-
622
- Returns the adapter wrapped; use the return value. `transport` is a free-form
623
- label shown in the panel (`"pusher"`, `"socket.io"`, …).
624
-
625
- ```ts
626
- // src/realtime/adapter.ts
627
- import { tapRealtimeAdapter } from "@hakam-aldeen-kh/blix/capture";
628
-
629
- const adapter = new PusherAdapter();
630
-
631
- export const realtime =
632
- process.env.NODE_ENV === "development" && typeof window !== "undefined"
633
- ? tapRealtimeAdapter(adapter, "pusher")
634
- : adapter;
635
- ```
636
-
637
- Your adapter only needs to structurally satisfy `RealtimeAdapterLike`:
638
- `connect`, `disconnect`, `subscribe`, `onMessage`, `onPresenceUpdate`. In
639
- production the adapter is returned untouched.
640
-
641
- **The tap is transparent, not narrowing.** It returns a `Proxy` typed as your
642
- adapter's own type, so class-based adapters keep working through it:
643
-
644
- - methods **outside** `RealtimeAdapterLike` pass straight through — the proxy's
645
- fallback binds and forwards any property that is not one of the tapped
646
- methods;
647
- - `instanceof` still works against your concrete class, because the proxy has
648
- no `getPrototypeOf` trap and forwards to the target.
649
-
650
- ```ts
651
- // Both of these still work through the tap.
652
- if (this.adapter instanceof ActionCableAdapter) {
653
- this.adapter.setSubscriptionContext({ accountId, userId }); // not in RealtimeAdapterLike
654
- }
655
- ```
656
-
657
- You do not need to keep a second reference to the untapped adapter.
658
-
659
- ### TanStack Query — `tapQueryClient(client)`
660
-
661
- **`tapQueryClient` is the one exception to the module-scope rule.** Call it
662
- from an effect in your query provider, *not* from the `useState` initializer
663
- that creates the client:
664
-
665
- ```tsx
666
- "use client";
667
-
668
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
669
- import { tapQueryClient } from "@hakam-aldeen-kh/blix/capture";
670
- import { useEffect, useState } from "react";
671
-
672
- export function QueryProvider({ children }: { children: React.ReactNode }) {
673
- const [client] = useState(() => new QueryClient());
674
-
675
- useEffect(() => tapQueryClient(client), [client]);
676
-
677
- return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
678
- }
679
- ```
680
-
681
- Why the effect at all: React Strict Mode double-invokes `useState`
682
- initializers, so tapping there taps a client that is immediately discarded.
683
- Installing from an effect is correct — because parent effects run after child
684
- effects, the tap backfills from `getQueryCache().getAll()` on install rather
685
- than starting blind, so no early events are lost.
686
-
687
- Returning the disposer is fine: it unsubscribes from both caches and unmarks
688
- the client, so the Strict Mode cycle — subscribe, dispose, subscribe again —
689
- reinstalls cleanly and the second install backfills the same way the first did.
690
-
691
- > **Fixed in 0.3.2.** In 0.3.1 and earlier the disposer unsubscribed but left
692
- > the client marked as tapped, so the reinstall short-circuited on the tap's
693
- > internal idempotency guard without resubscribing — leaving the Query tab
694
- > empty for the whole session, with no error and no warning. On those versions
695
- > the workaround is to call `tapQueryClient(client)` from the effect without
696
- > returning its result.
697
-
698
- If you construct the `QueryClient` at module scope rather than in a component,
699
- you can tap it at module scope too — the rule is "tap the client that actually
700
- survives", which in the common React pattern means an effect.
701
-
702
- ---
703
-
704
- ## Mounting the panel
705
-
706
- Render `<Blix />` **exactly once**. Mounting it more than once gives you
707
- duplicate panels reading the same log.
708
-
709
- Mount it from a small client component of its own, and import that component —
710
- and nothing else Blix-related — from your layout:
26
+ Requires React 18 or 19. The package is ESM only.
711
27
 
712
28
  ```tsx
713
29
  // app/BlixMount.tsx
714
30
  "use client";
715
31
 
716
32
  import { Blix } from "@hakam-aldeen-kh/blix";
717
- import { apiClient } from "@/src/network/axios";
718
- import { store } from "@/src/store";
719
-
720
- // This file is the client boundary on purpose. `apiClient` and `store` are
721
- // module-scope singletons that build themselves during module evaluation —
722
- // importing them from a Server Component pulls axios, cookie access, your
723
- // encryption service and any "use client" helpers they touch into the RSC
724
- // module graph, which fails the production build. Importing them here keeps
725
- // that evaluation on the client side of the boundary.
726
- export default function BlixMount() {
727
- if (
728
- process.env.NODE_ENV !== "development" &&
729
- process.env.NEXT_PUBLIC_BLIX !== "true"
730
- )
731
- return null;
732
- return <Blix store={store} apiClient={apiClient} dbName="my-app" />;
733
- }
734
- ```
735
-
736
- ```tsx
737
- // app/layout.tsx — stays a Server Component; imports only the mount
738
- import BlixMount from "./BlixMount";
739
-
740
- export default function RootLayout({ children }: { children: React.ReactNode }) {
741
- return (
742
- <html lang="en">
743
- <body>
744
- {children}
745
- <BlixMount />
746
- </body>
747
- </html>
748
- );
749
- }
750
- ```
751
-
752
- **The client boundary is a leaf.** It does not make your layout or your
753
- children client components — only this one file and what it imports. There is
754
- no bundle cost to isolating it this way, and it is what keeps your store and
755
- HTTP client out of the server graph.
756
-
757
- `<Blix />` itself carries a `"use client"` directive and can be rendered from a
758
- server component directly. That is not the reason for the wrapper file: the
759
- directive governs Blix's own module, not the modules *you* import alongside it.
760
- Importing `store` and `apiClient` into `app/layout.tsx` is what breaks the
761
- build, and it breaks it with an opaque module-resolution error from
762
- `next build`, far from the mount site.
763
-
764
- The directive on the root entry also means **everything** re-exported from
765
- `@hakam-aldeen-kh/blix` — capture functions included — is inside that client
766
- boundary. That is the reason for the split entry point: import capture from
767
- `@hakam-aldeen-kh/blix/capture` in any module that runs on the server. See
768
- [Entry points](#entry-points).
769
-
770
- ### Props — all optional
771
-
772
- | Prop | Effect when omitted |
773
- | --- | --- |
774
- | `store` | The **State** tab renders `— Redux store not provided —`, and **Re-dispatch** is disabled with the reason `Redux store not provided`. Everything else works. |
775
- | `apiClient` | **Replay request** is disabled with the reason `HTTP client not provided`. Everything else works. |
776
- | `dbName` | Falls back to the shared database `blix:default`, and the panel warns in the console. See [`dbName`](#dbname--when-you-need-it). |
777
-
778
- `store` and `apiClient` are structurally typed — they need
779
- `getState`/`subscribe`/`dispatch` and `request` respectively. A redux-toolkit
780
- store and an axios instance satisfy them as-is. (The interfaces are named
781
- `StoreLike` and `HttpClientLike` in the source, but they are not exported from
782
- the package; only `Blix` and `BlixProps` are. You never need to name them —
783
- structural typing means you just pass your store and client.)
784
-
785
- Passing neither still gives you a fully working capture log; you only lose the
786
- two features that need a live handle on the app.
787
-
788
- ### Finding your way around
789
-
790
- The panel is one header, one rail and two panes.
791
-
792
- **The rail on the left is the four sources** — Network, Realtime, Redux,
793
- Query. They are not filters over one table: each has its own columns and its
794
- own notion of a row, so switching source switches the whole view. `1`–`4` jump
795
- between them, and each keeps its own selection, so stepping to Redux and back
796
- returns you to the request you were reading. Each row ends with its own number
797
- key, because a shortcut is best taught where it is used. Below the sources it
798
- carries the session totals; collapsed, it keeps the sources and their counts
799
- and drops the totals, since four numbers without their labels are four
800
- unattributed numbers.
801
-
802
- **The header is the session**, not the entry: whether capture is running, what
803
- is being filtered out, and where the panel lives. While a filter is on, the
804
- field carries its own count — `8 of 56` — and a clear button, so you never
805
- have to read the status bar to know one is applied. `.*` widens the search to
806
- request and response bodies.
807
-
808
- **The red bin beside Export clears the source you are reading**, and only
809
- that one: emptying Redux leaves Network alone. Pinned entries survive it, and
810
- it is disabled when the source has nothing left to clear. To empty every
811
- source at once, use **Clear log** in the overflow menu, or `⇧C`.
812
-
813
- **The launcher badge summarizes the log while the panel is closed.** It shows
814
- one count per source that has captured anything — Network, Realtime, Redux,
815
- Query, each with its rail icon — and, in red beside a count, how many of that
816
- source's entries failed. The dot is capture state: green while capturing,
817
- amber while paused, pulsing while something is in flight.
818
-
819
- The panel opens **floating** the first time. Dock it to the bottom or the
820
- right from the header; the choice is saved per project.
821
-
822
- **`Ctrl/⌘ K` opens the command palette**, and for several things it is the only
823
- way in — sort order, row density, dock position, the copy formats, the filter
824
- syntax. The header spends its width on what you read constantly; everything you
825
- reach for occasionally lives one keystroke away instead of costing a button
826
- each. Every row shows its key binding where it has one, so the palette teaches
827
- its own shortcuts. `?` still opens the full cheatsheet.
828
-
829
- **The columns are yours.** Right-click the column head — or open **Columns** in
830
- the overflow menu — and switch off any of Pin, Method, Size, Status, Time,
831
- Linked and Timing. The choice persists. ROUTE has no switch: it is the column
832
- that says *which* entry a row is, and a list of rows you cannot identify is not
833
- a list. A narrow pane drops columns on its own, worst-affordable first, so the
834
- route keeps the width it needs.
835
-
836
- **Linked events tie the sources together.** When Blix can see that a query
837
- caused a request, that a request came from a query, or that one entry is a
838
- replay of another, the row grows a coloured tick and the foot of the detail
839
- pane grows a chip you can click to step straight to the other side. The
840
- relation is observed, never inferred: no tick means *not known*, not
841
- *unrelated*.
842
-
843
- ### Viewing payloads
844
-
845
- Every payload pane has a format switch. The choice is remembered, so you pick
846
- it once rather than per request.
847
-
848
- | Format | Answers |
849
- | --- | --- |
850
- | **Tree** | *What's in here?* Collapsible and searchable; a collapsed subtree costs one row, so it stays fast on multi-megabyte responses. |
851
- | **Table** | *How do these records compare?* A grid, offered when the payload is a list of records or a keyed map. Sparse and surplus columns are hidden with a count. |
852
- | **JSON** | *What exactly came back?* Raw and syntax-coloured, with a fold caret on every object and array — plus **Collapse all**, which leaves the top-level keys readable and their contents folded. |
853
- | **YAML** | *What shape is this?* Indentation instead of punctuation, and multi-line strings — stack traces, SQL — shown as text rather than escapes. |
854
- | **Text** | *It isn't JSON.* An HTML error page, a CSV body, a proxy's plain-text response. |
855
-
856
- **Copy gives you what you're looking at**: Table copies CSV, YAML copies YAML.
857
-
858
- The switch is on every payload pane — request and response bodies, realtime
859
- frames, the encrypted envelope, Redux actions, and both Redux state views:
860
-
861
- - **State** shows the live store, scoped by a **slice picker**. Slices the
862
- selected action wrote to are marked with a dot, so "what does `cart` look
863
- like now" is one click rather than a hunt through a collapsed root object.
864
- - **Diff** keeps its `+ / − / ±` rows as the default view and adds the other
865
- five alongside. **Table** is the natural one — a diff *is* `path | op |
866
- before | after` — and Tree lets you open up a `before` that was an object,
867
- which the one-line rows could only ever summarise. Copying the rows view
868
- gives you a plain-text diff for a ticket, not JSON.
869
-
870
- Right-click any request for **Copy as cURL** or **Copy as fetch** — the latter
871
- pastes straight into the browser console, with the body as an editable object
872
- literal rather than a pre-serialized string.
873
-
874
- ### Exporting the log
875
-
876
- The **Export** button in the header offers six formats and a scope
877
- toggle — **Selected** (the one entry open in the detail pane: a request, a
878
- Redux action or a query), **Shown** (what the current section and filters
879
- leave visible) or **All**. It defaults to Shown, with every count on the
880
- control, so an export says what it will contain before you pick a format.
881
- Selected is disabled until you select an entry; if it was your last choice and
882
- nothing is selected, the menu opens on Shown instead.
883
-
884
- | | Format | For |
885
- | --- | --- | --- |
886
- | Tool | **HAR** | Chrome DevTools, Charles, Insomnia, Postman — with the decrypted bodies. HTTP entries only. |
887
- | | **JSON** | Everything captured: frames, Redux diffs, timings. |
888
- | | **NDJSON** | One entry per line — pipe it into `jq`. |
889
- | Person | **Markdown** | A table plus failure bodies, copied to the clipboard for an issue, a PR or Slack. |
890
- | | **CSV** | One row per entry, no bodies — sort and count in a spreadsheet. Opens as UTF-8 in Excel. |
891
- | Shell | **cURL script** | Every request in order, runnable against another environment. |
892
-
893
- Captured `Authorization` headers are masked, so cURL and fetch output carries a
894
- placeholder rather than a working token — use **Replay** for a real re-run.
895
- Every export and snippet stays masked even with Authorization shown in full in
896
- the panel (see [What is redacted](#what-is-redacted)).
897
-
898
- ### Themes
899
-
900
- Twelve themes, under the theme button in the header — it names the one
901
- currently applied:
902
-
903
- | | Theme | |
904
- | --- | --- | --- |
905
- | Dark | **Midnight** | Deep blue-black — the default |
906
- | | **Carbon** | True black, high contrast; for OLED displays |
907
- | | **Nord** | Muted arctic blues |
908
- | | **Tokyo Night** | Deep indigo, soft neon |
909
- | | **One Dark** | Atom's classic slate |
910
- | | **Mocha** | Catppuccin — gentle pastels |
911
- | | **Dracula** | Vivid purples and pinks |
912
- | | **Gruvbox** | Warm retro browns and amber |
913
- | Light | **Daylight** | Clean white — the light default |
914
- | | **GitHub** | The light theme you already read all day |
915
- | | **Latte** | Catppuccin — soft pastel light |
916
- | | **Solar** | Warm paper, low blue light |
917
-
918
- **Hovering a theme applies it to the panel behind the menu**, so you can see a
919
- real payload in it before committing; moving away puts back the one you had.
920
- Arrow keys preview the same way.
921
-
922
- The default is **System**: the panel reads the light/dark class off `<html>`
923
- and paints Midnight or Daylight to match, re-checking whenever your app's theme
924
- changes. Picking a specific theme overrides that. The choice is stored with the
925
- panel's other preferences and survives a reload.
926
-
927
- The panel never inherits your app's styling — it portals outside every stacking
928
- context and ships its own palettes, so nothing you do to your own theme can
929
- distort it. Themes are complete rather than partial: every colour the panel
930
- paints, down to the JSON syntax highlighting and the timing bars, comes from
931
- the active theme. The two tones that carry text are held to 4.5:1 against every
932
- ground they land on, which is why a few of the ported palettes differ by a
933
- shade from the originals in the slots used for dense monospace. One tone is
934
- deliberately below it and may never hold text: it is for separators, payload
935
- punctuation and disabled glyphs.
936
-
937
- There is no API for adding your own; a theme is 33 colour slots in
938
- `src/ui/themes/bx.ts` if you are working from source. Every theme fills the
939
- same set — a theme that cannot is reported in the console during development,
940
- along with any whose text tones fall under 4.5:1 on a ground they actually
941
- land on.
942
-
943
- ### `dbName` — when you need it
944
-
945
- The panel can persist its log to IndexedDB so it survives a reload, but only
946
- when you opt in: **preserve-log is off by default**, and while it is off
947
- nothing is written to disk. See [Security](#security) for what the toggle does
948
- and what lands there.
949
-
950
- **All of Blix's storage is scoped to the origin, not to your app.** That is how
951
- IndexedDB and `localStorage` both work, and it covers the captured log *and*
952
- your panel preferences — dock position, theme, density, the preserve-log
953
- toggle. Two apps served from the same origin (different ports in dev are
954
- different origins, but path-based routing, multi-zone Next.js setups and
955
- anything behind one reverse proxy are not) share every one of them: entries
956
- from one project appear in the other's panel, and whichever you opened last
957
- decides where the panel is docked.
958
-
959
- `dbName` is how projects on one origin are kept apart. Give each app its own:
960
-
961
- ```tsx
962
- <Blix store={store} apiClient={apiClient} dbName="checkout" />
963
- ```
964
-
965
- You can also set it from the capture side, which is useful when capture starts
966
- before the panel mounts:
967
-
968
- ```ts
969
- attachHttpMonitor(apiClient, { dbName: "checkout" });
970
- ```
971
-
972
- Either call must happen before the database is first opened, which the panel
973
- does on mount. If both are set, the `<Blix />` prop wins, since render runs
974
- after module init. A call that arrives after the database is open is ignored —
975
- Blix does not switch databases at runtime — and says so in the console rather
976
- than failing quietly.
977
-
978
- The name you pass is prefixed: `dbName="checkout"` gives you the database
979
- `blix:checkout` and the preferences key `blix:checkout:prefs`. Passing an
980
- already-prefixed name is fine and does not double it. Omitting `dbName`
981
- entirely gives you `blix:default`, shared with every other app on the origin
982
- that also omits it — and in development the panel warns once per mount when
983
- that happens, naming the fix.
984
-
985
- Renaming is not a migration: the old database is left where it is rather than
986
- moved or deleted, and the new one starts empty at default preferences.
987
-
988
- #### Databases on this origin
989
-
990
- The status bar always shows which database the panel is on. On the shared
991
- default it turns amber and adds a `shared` tag, which is the visible form of
992
- the console warning above — click it to open the screen below. The same screen
993
- is in **⋯ More actions** and in the command palette.
994
-
995
- Open the command palette (`Ctrl/⌘ K`) → **Databases on this origin** to see
996
- every Blix database the origin holds — one per project, plus `nm-devtools`,
997
- the single unprefixed database that all projects shared before names were
998
- prefixed. Each row shows an approximate size and can be deleted; the one this
999
- panel is using is marked and is not deletable from there, since it is open —
1000
- use **Purge saved log** for that.
1001
-
1002
- **Peek** on a row lists the 50 newest entries in that database — time, method,
1003
- URL and status — so you can tell whose log it is before deleting it. It is a
1004
- read-only snapshot: Blix opens the database, reads, and closes it again, so the
1005
- list does not update and there is no detail pane, replay or export. The panel
1006
- itself always stays on its own database; to work with another project's log
1007
- properly, run that project and open the panel there.
1008
-
1009
- The screen enumerates with `indexedDB.databases()`, which Firefox does not
1010
- implement. There it falls back to the databases Blix has itself opened in that
1011
- browser and labels the list as possibly incomplete.
1012
-
1013
- ---
1014
-
1015
- ## Production elimination
1016
-
1017
- The panel is gated on a **literal** `process.env.NODE_ENV === "development"`
1018
- check that survives verbatim into the published `dist/`. Your bundler
1019
- substitutes it at *your* build time, folds the condition to `false`, and drops
1020
- the dynamic `import()` of the panel along with the whole branch — so no panel
1021
- code reaches your production bundle, and no chunk is emitted for it.
1022
-
1023
- This is why the check is written inline rather than imported as a boolean
1024
- constant: cross-module constant propagation is not guaranteed by every
1025
- bundler, but a literal `process.env.NODE_ENV` comparison in the same file is
1026
- handled by all of them.
1027
-
1028
- The capture layer is gated on the same condition **plus a
1029
- `typeof window !== "undefined"` check**, so `attachHttpMonitor` and friends
1030
- become no-ops in production even though their call sites remain — and also
1031
- during SSR, in the same dev build where they are live in the browser.
1032
-
1033
- That second half is not a bundling concern but a correctness one. The capture
1034
- module is reachable from your HTTP-client module, which typically also runs on
1035
- the server, and the monitor's buffer is a module-level singleton: on a
1036
- long-lived Node process it would otherwise accumulate every user's request
1037
- payloads for the lifetime of the server. Keeping the server-side singleton
1038
- permanently empty is the point.
1039
-
1040
- Practically: a `captureEncrypted` call that runs during SSR does nothing, and
1041
- `<Blix />` renders nothing there. It also renders nothing during hydration, so
1042
- its first client render matches the server's HTML; the panel mounts on the
1043
- render right after. (Before 0.9.0 it branched on `typeof window` instead,
1044
- which Next.js reported as a hydration mismatch on every load.)
1045
-
1046
- ### Enabling Blix in a production build (staging)
1047
-
1048
- A staging or test server usually runs a production build, where the panel is
1049
- gone by design. Setting `NODE_ENV=development` for that build does not bring
1050
- it back on Next.js 16: `next build` runs on Turbopack and inlines `NODE_ENV`
1051
- as `"production"` whatever the environment says.
1052
-
1053
- Opt in explicitly instead, in that server's env file:
1054
-
1055
- ```bash
1056
- NEXT_PUBLIC_BLIX=true
1057
- ```
1058
-
1059
- The panel and the capture layer both check it next to `NODE_ENV`, as the same
1060
- kind of literal comparison, so Next.js inlines it at build time.
1061
-
1062
- > **Set `NEXT_PUBLIC_BLIX=false` in your production env file.** Next.js only
1063
- > inlines variables that are defined. Left unset, the comparison stays in the
1064
- > bundle as a runtime check: the panel still never loads — the variable is
1065
- > undefined in the browser — but its chunk is emitted into your production
1066
- > build. `false` lets the bundler fold the check and drop the chunk.
1067
-
1068
- **Never set `NEXT_PUBLIC_BLIX=true` for a build that real users get.** It
1069
- ships the monitor to them, and with it everything it captures — see
1070
- [Security](#security).
1071
-
1072
- **Your own guards need the variable too.** A mount component that returns
1073
- `null` outside development (as in [Mounting the panel](#mounting-the-panel)),
1074
- or a call-site guard around `attachHttpMonitor` (below), keeps Blix off on
1075
- staging regardless of the variable. Add it to each:
1076
-
1077
- ```ts
1078
- if (
1079
- (process.env.NODE_ENV === "development" ||
1080
- process.env.NEXT_PUBLIC_BLIX === "true") &&
1081
- typeof window !== "undefined"
1082
- ) {
1083
- attachHttpMonitor(apiClient);
1084
- }
1085
- ```
1086
-
1087
- The variable is read under its `NEXT_PUBLIC_` name, which is a Next.js
1088
- convention. The opt-in has been verified with Next.js 16 (Turbopack) only;
1089
- other bundlers do not inline that name by default.
1090
-
1091
- ### Guard your call sites
1092
-
1093
- **No-op is not the same as eliminated.** The argument above works for the
1094
- panel because the literal check lives in the file that makes the dynamic
1095
- import. It does *not* carry over to the capture layer, and the reason is the
1096
- same one that motivated writing the check inline in the first place.
1097
-
1098
- Every capture function tests a single internal constant inside its own body,
1099
- and that constant lives in Blix's module, not yours. Your bundler folds the
1100
- constant to `false` — but it keeps the function bodies that reference it,
1101
- because your call site still imports them. The result is tens of kilobytes of
1102
- capture runtime in a production bundle where every entry point is dead.
1103
-
1104
- To drop it, write the same literal check in the file that makes the call,
1105
- exactly as Blix does internally:
1106
-
1107
- ```ts
1108
- // ✅ folded away in production — the whole capture chunk is dropped
1109
- export const apiClient =
1110
- process.env.NODE_ENV === "development"
1111
- ? withInitiatorCapture(createClient())
1112
- : createClient();
33
+ import { attachFetchMonitor } from "@hakam-aldeen-kh/blix/capture";
1113
34
 
35
+ // Module scope, not an effect: requests made before React mounts are captured too.
1114
36
  if (process.env.NODE_ENV === "development" && typeof window !== "undefined") {
1115
- attachHttpMonitor(apiClient);
37
+ attachFetchMonitor();
1116
38
  }
1117
- ```
1118
39
 
1119
- ```ts
1120
- // ❌ works, but keeps the capture runtime in your production bundle
1121
- export const apiClient = withInitiatorCapture(createClient());
1122
- attachHttpMonitor(apiClient);
40
+ export default function BlixMount() {
41
+ return <Blix dbName="my-app" />;
42
+ }
1123
43
  ```
1124
44
 
1125
- Two rules for writing the guard:
1126
-
1127
- - **Write the condition out literally, in the file that makes the call.**
1128
- Hoisting it into a shared `const IS_DEV` defeats the folding, for the same
1129
- reason Blix writes it inline in its own source.
1130
- - **Include `typeof window !== "undefined"`** when the module also evaluates on
1131
- the server, which in the Next.js App Router it generally does. It is
1132
- redundant with Blix's internal guard, but it keeps the folded branch
1133
- unambiguous for the bundler and matches the condition Blix uses internally.
1134
-
1135
- `createReduxMonitorMiddleware` needs the middleware callback restructured
1136
- rather than a one-line guard — see the [Redux](#redux--createreduxmonitormiddlewareoptions)
1137
- example above, which is written in the guarded form.
1138
-
1139
- ---
1140
-
1141
- ## Security
1142
-
1143
- Blix is a debugger, and it captures what a debugger has to capture: **full
1144
- request and response bodies, request and response headers, Redux action
1145
- payloads and state diffs, and realtime frames**. It captures them in
1146
- plaintext — the request body *before* your encryption interceptor runs, the
1147
- response body *after* your decryption interceptor. That is the whole point of
1148
- it, and it means the log holds whatever your traffic holds, credentials
1149
- included.
1150
-
1151
- `attachFetchMonitor` widens that to **every `fetch` the page makes**, not only
1152
- your own: an analytics snippet, a chat widget or a library calling `fetch`
1153
- under the hood is captured the same way, bodies included. Use its `ignore`
1154
- option to keep a third party's traffic out of the log.
1155
-
1156
- By default all of that is **in memory only**. Nothing is written to disk, and
1157
- a reload starts clean.
1158
-
1159
- All of it is dev-only unless you opt a build in. Capture is gated on
1160
- `process.env.NODE_ENV === "development" && typeof window !== "undefined"`, and
1161
- the database is opened only when the panel mounts — see
1162
- [Production elimination](#production-elimination). A build made with
1163
- `NEXT_PUBLIC_BLIX=true` captures exactly as development does, so everything in
1164
- this section applies to whoever uses that build.
1165
-
1166
- ### What reaches disk, and when
1167
-
1168
- **Preserve-log** is the sole gate on disk writes. It is off by default, and
1169
- while it is off nothing Blix captures reaches IndexedDB — the store is
1170
- actively cleared on every panel mount.
1171
-
1172
- Four ways to toggle it:
45
+ Render `<BlixMount />` once, in your root layout. Run the dev server and the panel's launcher appears on the page; Ctrl/⌘ + backtick toggles the panel.
1173
46
 
1174
- | Where | Note |
1175
- | --- | --- |
1176
- | Header button | Keeps its icon at every width; loses its label when the panel is narrow |
1177
- | **⋯ More actions** overflow menu | — |
1178
- | Command palette (`Ctrl/⌘ K`) | Listed as **Preserve log across reloads** / **Stop preserving the log** |
1179
- | `Shift+L` | — |
47
+ Using axios? Call `attachHttpMonitor(apiClient)` in the module that creates the instance, after your own interceptors are registered. Redux, TanStack Query and realtime each take one call as well.
1180
48
 
1181
- All four require the panel to be mounted.
49
+ ## Documentation
1182
50
 
1183
- **Turning it on is retroactive.** The toggle does not mean "from now on".
1184
- Switching it on writes every entry already sitting in the live buffer — the
1185
- session you have *already* captured — to disk immediately, along with
1186
- everything that follows. If you have just reproduced a login flow and then
1187
- reach for the toggle, you have written that login flow to disk. Read that
1188
- again before you assume otherwise; it is the one behaviour here that
1189
- reasonably surprises people.
51
+ **[Full documentation → docs/en.md](https://github.com/Hakam-aldeen-Kh/blix/blob/main/docs/en.md)**
1190
52
 
1191
- With preserve-log on, this is what is kept:
1192
-
1193
- | Captured | Persisted |
1194
- | --- | --- |
1195
- | HTTP entries — bodies, headers, timings | yes |
1196
- | Realtime frames | yes |
1197
- | The encrypted envelope, if you call `captureEncrypted` | yes |
1198
- | Panel preferences and budget totals | yes — preferences are also mirrored to `localStorage` under `blix:<dbName>:prefs` |
1199
- | Redux actions, payloads and diffs | only if you pin the row |
1200
- | Query cache rows | only if you pin the row |
1201
-
1202
- One thing is written regardless of preserve-log: opening the database records
1203
- its name in the origin-wide `localStorage` key `blix:databases`, which is how
1204
- [Databases on this origin](#databases-on-this-origin) finds it in browsers
1205
- without `indexedDB.databases()`. It holds database names and nothing else.
1206
-
1207
- ### What is redacted
1208
-
1209
- Exactly four header names, and nothing else:
1210
-
1211
- | Header | Match |
1212
- | --- | --- |
1213
- | `authorization` | exact, case-insensitive |
1214
- | `cookie` | exact, case-insensitive |
1215
- | `set-cookie` | exact, case-insensitive |
1216
- | `x-api-key` | exact, case-insensitive |
1217
-
1218
- The match is **exact on the full header name** — not a prefix, not a
1219
- substring, not a pattern. Near-miss names are *not* covered and are written in
1220
- the clear: `x-auth-token` and `api-key` are the two that most often catch
1221
- people out, and `proxy-authorization`, `x-csrf-token` and
1222
- `x-amz-security-token` are equally uncovered. If your auth travels in a header
1223
- that is not one of the four above, it is captured verbatim.
1224
-
1225
- The same four are masked whichever client made the request, and whatever form
1226
- the headers were passed in: `AxiosHeaders`, a plain object, a `Headers`
1227
- instance or `[name, value]` pairs.
1228
-
1229
- Masking is partial rather than total: for a value longer than 12 characters
1230
- the first 8 and last 4 survive, so you can still tell which token you sent.
1231
- Shorter values are replaced outright. The Headers tab tags every masked row
1232
- `MASKED` rather than leaving you to infer it from an ellipsis, and the stored
1233
- value keeps a `(masked)` suffix so every export path carries the fact too.
1234
-
1235
- #### Showing `Authorization` in full
1236
-
1237
- Blix is development-only, and a developer reading their own token on their own
1238
- screen is not the risk — the token leaving the machine is. So masking of
1239
- `authorization`, and only `authorization`, can be switched off: command palette
1240
- → **Show Authorization values in full**, or **⋯ More actions** → **Show
1241
- Authorization in full**. It is a saved preference, off by default, and while it
1242
- is on the status bar reads **UNMASKED Authorization** and every such row in the
1243
- Headers tab is tagged `UNMASKED`.
1244
-
1245
- - It applies to requests captured **after** it is switched on. Earlier entries
1246
- were masked when they were captured — the full value was never kept — and
1247
- the panel cannot reveal them; their row says so. An axios request re-reads
1248
- its headers when it settles (see
1249
- [Which request headers the panel shows](#which-request-headers-the-panel-shows)),
1250
- so for one still in flight what counts is the setting at the moment it
1251
- settles.
1252
- - The full value is held in memory only. It is **never** written to IndexedDB,
1253
- and **every** export and copy path — HAR, JSON, NDJSON, CSV, Markdown, the
1254
- cURL script, **Copy as cURL** and **Copy as fetch** — still carries the
1255
- masked value. The row's own **Copy** button is the one way to take the full
1256
- value.
1257
- - Masking again discards every full value held.
1258
- - `cookie`, `set-cookie`, `x-api-key` and realtime connect tokens stay masked
1259
- regardless.
1260
-
1261
- #### JWT claims
1262
-
1263
- When an `Authorization` value is a JWT, Blix decodes it — for axios when the
1264
- request settles, for `fetch` when it is made — and keeps only `alg`, `sub`,
1265
- `iss`, `iat` and `exp`, never the token or its signature.
1266
- The **JWT** chip on that row opens them, with the time left until `exp`, in red
1267
- once it has passed. This works with masking on. The signature is not verified,
1268
- and a value that is not a well-formed JWT stores no claims at all. The claims
1269
- say where they were read: when a `fetch` call was made, from the headers an
1270
- axios request settled with, or — for an axios request that never settled, or
1271
- used the `auth` option — when it started.
1272
-
1273
- `alg` is shown but never acted on: a missing, empty or non-string `alg` still
1274
- yields every other claim, and no value of it withholds anything. The XML-DSig
1275
- URIs .NET writes into `alg` — `http://www.w3.org/2001/04/xmldsig-more#hmac-sha256`
1276
- and its HMAC, RSA, RSA-PSS and ECDSA siblings — are shown by name, as
1277
- `HMAC-SHA256 (HS256)`, with the raw value in the tooltip; the entry and the
1278
- exports keep the raw value. An `alg` of `none`, in any letter case, is flagged
1279
- as an unsigned token. The claims
1280
- are part of the entry, so — unlike the token — they are saved with
1281
- preserve-log on and included in the JSON and NDJSON exports.
1282
-
1283
- **Nothing inside a body is redacted.** Request bodies, response bodies, error
1284
- payloads, the encrypted request/response values, Redux payloads and diffs, and
1285
- realtime frames all pass through a size-only walker — it truncates large
1286
- values and never once inspects a key name. A `password`, `ssn` or
1287
- `refreshToken` field is captured, and with preserve-log on written to disk,
1288
- exactly as it appears. Blix has no mechanism to do otherwise: it has no schema
1289
- for your payloads and no way to tell a secret from any other string.
1290
-
1291
- ### Retention and clearing
1292
-
1293
- | Bound | Value |
1294
- | --- | --- |
1295
- | Records | 200 |
1296
- | Total size | 24 MB — HTTP entries only |
1297
- | Per payload field | 512 KB |
1298
- | Eviction | oldest first, once either bound is exceeded |
1299
- | Time-based expiry | none |
1300
-
1301
- There is **no TTL of any kind**. A record leaves the database when 200 newer
1302
- records or 24 MB of newer traffic push it out, or when you clear it yourself.
1303
- On a low-traffic app with preserve-log left on, a captured token stays in the
1304
- browser profile indefinitely.
1305
-
1306
- Three ways to purge:
1307
-
1308
- | Where | Note |
1309
- | --- | --- |
1310
- | The persisted-size label in the status bar — the one reading `12 saved · 3.4 MB` | The label *is* the control, and it is the only one that asks twice: click once to arm it, at which point it changes to `Purge saved log?`, and click again. It disarms itself after three seconds. Rendered only while preserve-log is on |
1311
- | **⋯ More actions** → **Purge saved log** | Deletes on a single press, with no confirmation |
1312
- | Command palette (`Ctrl/⌘ K`) → **Purge the saved log** | Deletes on a single press, with no confirmation |
1313
-
1314
- The menu and palette entries are disabled when nothing is on disk, but unlike
1315
- the status-bar label they do not depend on preserve-log being on — so a log
1316
- written earlier in the session can still be deleted after you have switched
1317
- the toggle off.
1318
-
1319
- Switching preserve-log **off** also clears the stored entries, so turning it
1320
- off is itself a way to drop everything Blix has written.
1321
-
1322
- A purge can be **blocked**: IndexedDB will not delete a database that another
1323
- tab still holds open. The panel reports that, naming the database, instead of
1324
- reporting the log purged while it is still on disk. Close the other tabs
1325
- running the app and purge again.
1326
-
1327
- Other projects' databases on the same origin — and the legacy `nm-devtools`
1328
- database that 0.5.x and earlier wrote — are deleted from
1329
- [Databases on this origin](#databases-on-this-origin), not by Purge.
1330
-
1331
- Every path clears the captured entries; Purge additionally deletes the
1332
- IndexedDB database itself. **Your panel preferences survive either way** —
1333
- they are mirrored to `localStorage`, and a fresh database is re-seeded from
1334
- that mirror on the next boot. Both the database and its mirror key are scoped
1335
- to this project's `dbName`, so a purge affects only the project that ran it and
1336
- cannot restore — or destroy — another project's preferences on the same origin.
1337
- There is no UI or API for clearing them, and no programmatic API for purging
1338
- either.
1339
-
1340
- ### Threat model
1341
-
1342
- IndexedDB is scoped **per origin, not per app**, and it is not encrypted at
1343
- rest. Any script running on that origin can read Blix's database — including
1344
- browser extension content scripts with access to the origin. Whatever you
1345
- capture is readable by whatever you have installed.
1346
-
1347
- A per-project `dbName` does not change that. It keeps projects from mixing
1348
- their logs; it does not isolate them. The panel itself can open another
1349
- project's database on the same origin — **Peek** in
1350
- [Databases on this origin](#databases-on-this-origin) lists its newest URLs,
1351
- methods and statuses — and so can any other script there.
1352
-
1353
- Export and copy move captured data out of the browser entirely:
1354
-
1355
- | Path | Carries |
1356
- | --- | --- |
1357
- | HAR export | Decrypted request and response bodies |
1358
- | JSON / NDJSON export | Everything captured — frames, Redux diffs, timings |
1359
- | **Copy as cURL** / **Copy as fetch** | Headers and bodies, with the four redacted headers masked — so the output is not a working request |
1360
-
1361
- HAR is the one to watch. It is a plain JSON file carrying your decrypted
1362
- bodies, and it is the artifact most likely to end up attached to a ticket.
1363
-
1364
- ### If you handle sensitive data
1365
-
1366
- - **Leave preserve-log off unless you actively need it.** It is off by
1367
- default. In-memory capture already gives you the entire panel; the toggle
1368
- buys you nothing but survival across a reload.
1369
- - **Clear the log after any session that captured an auth flow** —
1370
- switching preserve-log off is enough; Purge additionally deletes the
1371
- database, though neither removes your panel preferences.
1372
- - **Use the Redux `ignore` option** for action types that carry credentials or
1373
- personal data, so they are never captured in the first place. See
1374
- [Redux](#redux--createreduxmonitormiddlewareoptions).
1375
- - **Treat an exported HAR as a credential-bearing file.** Do not attach one to
1376
- a public issue, and do not commit one.
1377
- - **After upgrading from 0.5.x or earlier, delete `nm-devtools`.** Blix no
1378
- longer reads or writes that database, and it does not delete it for you:
1379
- anything it holds stays on disk until you remove it from
1380
- [Databases on this origin](#databases-on-this-origin).
1381
- - **Scope `attachFetchMonitor` with `ignore`** if third-party scripts on the
1382
- page send data you do not want in the log.
1383
-
1384
- ---
1385
-
1386
- ## Entry points
1387
-
1388
- | Import | Contents | `"use client"` |
1389
- | --- | --- | --- |
1390
- | `@hakam-aldeen-kh/blix` | `Blix`, `BlixProps` + everything below | **yes** |
1391
- | `@hakam-aldeen-kh/blix/capture` | capture functions and types only — no React | no |
1392
-
1393
- Import capture functions from `/capture` in modules that run during SSR or at
1394
- module-eval time. It pulls in no React code and carries no `"use client"`
1395
- directive, so it stays usable from a server module — which the root entry, by
1396
- virtue of the directive that lets `<Blix />` be rendered from a server
1397
- component, is not.
1398
-
1399
- The `/capture` entry exports `attachHttpMonitor`, `attachFetchMonitor`,
1400
- `captureEncrypted`, `createReduxMonitorMiddleware`, `tapQueryClient`,
1401
- `tapRealtimeAdapter`, `withInitiatorCapture`, and the supporting types
1402
- (`EncryptedPayload`, `FetchMonitorOptions`, `FetchIgnoreRule`,
1403
- `ReduxCaptureOptions`, `RealtimeAdapterLike`, `MonitorEntry`, …).
53
+ - [Install](https://github.com/Hakam-aldeen-Kh/blix/blob/main/docs/en.md#install): peer dependencies and build setup
54
+ - [Capture](https://github.com/Hakam-aldeen-Kh/blix/blob/main/docs/en.md#the-module-init-call-order-contract): where each capture call goes, and why the order matters
55
+ - [Mounting the panel](https://github.com/Hakam-aldeen-Kh/blix/blob/main/docs/en.md#mounting-the-panel): props, shortcuts, filtering, export, themes
56
+ - [Production elimination](https://github.com/Hakam-aldeen-Kh/blix/blob/main/docs/en.md#production-elimination): how it is removed, and how to keep it on a staging build
57
+ - [Upgrading](https://github.com/Hakam-aldeen-Kh/blix/blob/main/docs/en.md#upgrading-to-011): what changed for projects coming from 0.10
1404
58
 
1405
59
  ---
1406
60
 
1407
- ## License
1408
-
1409
- MIT — see [LICENSE](https://github.com/Hakam-aldeen-Kh/blix/blob/main/LICENSE).
61
+ [Changelog](https://github.com/Hakam-aldeen-Kh/blix/blob/main/CHANGELOG.md) · [Security](https://github.com/Hakam-aldeen-Kh/blix/blob/main/SECURITY.md) · [MIT License](https://github.com/Hakam-aldeen-Kh/blix/blob/main/LICENSE) · Made by [Hakam aldeen Alkhadraa](https://www.hakam-aldeen.com)