@hakam-aldeen-kh/blix 0.3.2

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