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