@hakam-aldeen-kh/blix 0.5.0 → 0.6.1

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
@@ -45,8 +45,15 @@ no reason to carry a second build output.
45
45
  `react` and `react-dom` (v19) are required. `axios`, `@reduxjs/toolkit` and
46
46
  `@tanstack/react-query` are **optional** peers — you only need the ones whose
47
47
  capture you actually use. The capture layer is structurally typed against each
48
- of them and never imports any of them at runtime, so installing Blix does not
49
- pull a data-fetching or state library into your tree.
48
+ of them and imports none of them — not at runtime, and not in its published
49
+ type declarations — so installing Blix does not pull a data-fetching or state
50
+ library into your tree, and your type-check never goes looking for one.
51
+
52
+ **axios is optional, and fetch-only apps are fully supported.** An app that
53
+ calls `fetch` directly — the default in a Next.js App Router project — captures
54
+ its HTTP traffic with
55
+ [`attachFetchMonitor`](#http-fetch--attachfetchmonitoroptions) and never needs
56
+ axios installed, not even for types.
50
57
 
51
58
  ---
52
59
 
@@ -74,6 +81,7 @@ it.
74
81
  | Function | Where to call it | Timing |
75
82
  | --- | --- | --- |
76
83
  | `attachHttpMonitor` | after your own interceptors are registered on the instance | module scope |
84
+ | `attachFetchMonitor` | before the first `fetch` you want captured — client-side only | module scope |
77
85
  | `createReduxMonitorMiddleware` | in `configureStore`'s `middleware` callback | module scope |
78
86
  | `tapRealtimeAdapter` | where the adapter singleton is constructed | module scope |
79
87
  | `tapQueryClient` | a `useEffect` in your query provider | see below |
@@ -134,6 +142,12 @@ if (process.env.NODE_ENV === "development" && typeof window !== "undefined") {
134
142
  }
135
143
  ```
136
144
 
145
+ `attachHttpMonitor` is idempotent per instance — a second call on the same
146
+ instance, or on its `withInitiatorCapture` wrapper, does nothing — and it
147
+ returns a disposer that ejects both interceptors, for HMR and tests. A request
148
+ cancelled through an `AbortController` or a `CancelToken` settles as
149
+ **aborted**, not as an error; a timeout is still an error.
150
+
137
151
  #### Factory and lazy-singleton clients
138
152
 
139
153
  The rule is **causal, not positional**. "Bottom of the module" is shorthand
@@ -201,6 +215,11 @@ request bodies and correlated errors while also discarding the `AxiosError`.
201
215
 
202
216
  *Since 0.3.0.*
203
217
 
218
+ > **axios only.** Correlation works through the config object your own
219
+ > interceptor is holding, and `fetch` has no interceptor stage to hold one in,
220
+ > so `captureEncrypted` has no effect on requests captured by
221
+ > [`attachFetchMonitor`](#http-fetch--attachfetchmonitoroptions).
222
+
204
223
  **Entirely optional.** An app that never calls it behaves exactly as it did
205
224
  before this API existed, and its panel shows no Encrypted tab at all — the tab
206
225
  appears only on entries that actually carry ciphertext.
@@ -409,6 +428,124 @@ initiator stack.
409
428
  > of those paths, the top frame reported will be your own wrapper rather than
410
429
  > the true call site. There is no option to extend the filter yet.
411
430
 
431
+ ### HTTP (fetch) — `attachFetchMonitor(options?)`
432
+
433
+ For requests made with `fetch` directly rather than through axios. It wraps
434
+ `globalThis.fetch`, so it captures **every** `fetch` the page makes — your own
435
+ and any library's or third-party script's — into the same Network section, in
436
+ the same shape, with the same header masking and the same absence of body
437
+ redaction (see [Security](#security)). Its rows show **Client: fetch** in the
438
+ Headers tab, and `client:fetch` filters to them. Each row records the call
439
+ stack of the `fetch` call that made it; no `withInitiatorCapture` is needed.
440
+
441
+ ```ts
442
+ // instrumentation-client.ts (Next.js 15.3+), or the first module your client bundle evaluates
443
+ import { attachFetchMonitor } from "@hakam-aldeen-kh/blix/capture";
444
+
445
+ if (process.env.NODE_ENV === "development" && typeof window !== "undefined") {
446
+ attachFetchMonitor();
447
+ }
448
+ ```
449
+
450
+ **Call it before the first `fetch` you want captured.** There are no
451
+ interceptors to order against, but anything fetched before the wrapper is
452
+ installed is simply not seen. In a Next.js App Router project,
453
+ `instrumentation-client.ts` runs on the client before your application code and
454
+ is the natural place; otherwise, the top of the first module your client bundle
455
+ evaluates.
456
+
457
+ **Client-side only.** It never patches `fetch` on the server, so Next.js's
458
+ server-side `fetch` and its data cache are untouched — and like every capture
459
+ function, it is a no-op in production.
460
+
461
+ It returns a **disposer** that restores the original `fetch`, and calling
462
+ `attachFetchMonitor` again while it is installed does nothing. The disposer
463
+ only restores `fetch` while Blix's wrapper is still the current one: if another
464
+ library has wrapped `fetch` since, restoring would silently remove that wrapper
465
+ too, so it leaves `fetch` alone and warns in development instead.
466
+
467
+ #### Bodies, streams and the size cap
468
+
469
+ Blix reads bodies from clones, so your code always receives an untouched
470
+ `Request` and `Response`. It never buffers without a limit:
471
+
472
+ | Situation | What is recorded |
473
+ | --- | --- |
474
+ | `Content-Type: text/event-stream` | status, headers, timing — body not captured, because the stream does not end |
475
+ | `Content-Length` over the cap | status, headers, timing — body not captured, reading never starts |
476
+ | body passes the cap while downloading | status, headers, timing — body not captured, reading abandoned |
477
+ | `no-cors` (opaque) response | status `0` — headers and body not captured, because the browser hides them |
478
+ | request body is a `ReadableStream` | request body not captured — reading it would consume the upload |
479
+
480
+ A skipped body is recorded as `«body not captured: …»` with the reason, so an
481
+ empty Response tab always means an empty response. The cap applies to request
482
+ and response bodies alike, defaults to **5 MB**, and is set with
483
+ `maxBodyBytes`:
484
+
485
+ ```ts
486
+ attachFetchMonitor({ maxBodyBytes: 1024 * 1024 }); // 1 MB — 0 captures no bodies
487
+ ```
488
+
489
+ The cap is enforced by **counting bytes as they arrive**, not by trusting
490
+ `Content-Length`. That header is the compressed size, and a small gzipped
491
+ response can decode to many times it; a declared length over the cap only lets
492
+ Blix skip without starting. A row's duration runs to the end of the body when
493
+ Blix read it, and to the response headers when it did not.
494
+
495
+ A non-2xx response is recorded as a failed request with its body under
496
+ **Error**, the same way an axios rejection is. An aborted request settles as
497
+ **aborted**; a network failure or an `AbortSignal.timeout()` settles as an
498
+ error. Either way the original rejection reaches your code untouched.
499
+
500
+ #### What is left out by default
501
+
502
+ A development server makes plenty of `fetch` calls of its own, and they would
503
+ bury your requests. These are **not captured by default**:
504
+
505
+ | Rule | Matches |
506
+ | --- | --- |
507
+ | `"/_next/"` | Next.js assets, HMR updates and Pages Router data requests |
508
+ | `"/__nextjs"` | the Next.js dev overlay — stack frames, source maps, open-in-editor |
509
+ | `/[?&]_rsc=/` | App Router navigations and `<Link>` prefetches |
510
+ | `".hot-update."` | webpack HMR update manifests and chunks |
511
+ | `"/__webpack_hmr"` | webpack-hot-middleware |
512
+
513
+ A string matches anywhere in the URL's path and query, a regular expression is
514
+ tested against the same string, and a function receives the resolved `URL`.
515
+ Pass an array to **replace** the defaults, or a function to **extend** them:
516
+
517
+ ```ts
518
+ attachFetchMonitor({ ignore: (defaults) => [...defaults, "/api/health"] }); // extend
519
+ attachFetchMonitor({ ignore: [] }); // capture everything
520
+ ```
521
+
522
+ **An ignored request is dropped silently** — no row records that it was
523
+ skipped. If a request you expected is missing, check it against this list
524
+ first.
525
+
526
+ #### Using it alongside axios
527
+
528
+ Installing both `attachFetchMonitor` and `attachHttpMonitor` is fine. axios's
529
+ default browser adapter is XHR, which the fetch wrapper never sees. If you
530
+ configure axios with `adapter: "fetch"`, one request passes through both — and
531
+ **the axios entry wins**. It was captured on the plaintext side of your
532
+ interceptors, keeps `captureEncrypted` and `withInitiatorCapture`, and can be
533
+ replayed; the fetch wrapper would only see the same request after encryption.
534
+ `attachHttpMonitor` marks each request on its way into axios's fetch adapter,
535
+ and the fetch wrapper skips anything carrying the mark, so every request is
536
+ logged exactly once. The order of the two calls does not matter.
537
+
538
+ #### What it does not do
539
+
540
+ - **No replay.** Fetch rows show Replay disabled, with the reason. Blix does
541
+ not record a call's `credentials`, `mode` or `cache`, or a JSON body as the
542
+ exact bytes that were sent, so a replay could not promise to be the same
543
+ request.
544
+ - **No `captureEncrypted`** — see
545
+ [Encrypted payloads](#encrypted-payloads--captureencryptedconfig-payload).
546
+ - **No timeout.** A request that never answers stays pending, exactly as an
547
+ axios request does.
548
+
412
549
  ### Redux — `createReduxMonitorMiddleware(options?)`
413
550
 
414
551
  ```ts
@@ -546,7 +683,7 @@ import { store } from "@/src/store";
546
683
  // that evaluation on the client side of the boundary.
547
684
  export default function BlixMount() {
548
685
  if (process.env.NODE_ENV !== "development") return null;
549
- return <Blix store={store} apiClient={apiClient} dbName="my-app-devtools" />;
686
+ return <Blix store={store} apiClient={apiClient} dbName="my-app" />;
550
687
  }
551
688
  ```
552
689
 
@@ -590,7 +727,7 @@ boundary. That is the reason for the split entry point: import capture from
590
727
  | --- | --- |
591
728
  | `store` | The **State** tab renders `— Redux store not provided —`, and **Re-dispatch** is disabled with the reason `Redux store not provided`. Everything else works. |
592
729
  | `apiClient` | **Replay request** is disabled with the reason `HTTP client not provided`. Everything else works. |
593
- | `dbName` | Defaults to `"nm-devtools"`. |
730
+ | `dbName` | Falls back to the shared database `blix:default`, and the panel warns in the console. See [`dbName`](#dbname--when-you-need-it). |
594
731
 
595
732
  `store` and `apiClient` are structurally typed — they need
596
733
  `getState`/`subscribe`/`dispatch` and `request` respectively. A redux-toolkit
@@ -733,27 +870,68 @@ when you opt in: **preserve-log is off by default**, and while it is off
733
870
  nothing is written to disk. See [Security](#security) for what the toggle does
734
871
  and what lands there.
735
872
 
736
- IndexedDB is scoped **per origin**, not per app so two apps served from the
737
- same origin (different ports in dev are different origins, but path-based
738
- routing, multi-zone Next.js setups and anything behind one reverse proxy are
739
- not) both open `nm-devtools` and interleave their logs into one database.
873
+ **All of Blix's storage is scoped to the origin, not to your app.** That is how
874
+ IndexedDB and `localStorage` both work, and it covers the captured log *and*
875
+ your panel preferences dock position, theme, density, the preserve-log
876
+ toggle. Two apps served from the same origin (different ports in dev are
877
+ different origins, but path-based routing, multi-zone Next.js setups and
878
+ anything behind one reverse proxy are not) share every one of them: entries
879
+ from one project appear in the other's panel, and whichever you opened last
880
+ decides where the panel is docked.
740
881
 
741
- Give each app its own name to keep them separate:
882
+ `dbName` is how projects on one origin are kept apart. Give each app its own:
742
883
 
743
884
  ```tsx
744
- <Blix store={store} apiClient={apiClient} dbName="checkout-devtools" />
885
+ <Blix store={store} apiClient={apiClient} dbName="checkout" />
745
886
  ```
746
887
 
747
888
  You can also set it from the capture side, which is useful when capture starts
748
889
  before the panel mounts:
749
890
 
750
891
  ```ts
751
- attachHttpMonitor(apiClient, { dbName: "checkout-devtools" });
892
+ attachHttpMonitor(apiClient, { dbName: "checkout" });
752
893
  ```
753
894
 
754
895
  Either call must happen before the database is first opened, which the panel
755
896
  does on mount. If both are set, the `<Blix />` prop wins, since render runs
756
- after module init.
897
+ after module init. A call that arrives after the database is open is ignored —
898
+ Blix does not switch databases at runtime — and says so in the console rather
899
+ than failing quietly.
900
+
901
+ The name you pass is prefixed: `dbName="checkout"` gives you the database
902
+ `blix:checkout` and the preferences key `blix:checkout:prefs`. Passing an
903
+ already-prefixed name is fine and does not double it. Omitting `dbName`
904
+ entirely gives you `blix:default`, shared with every other app on the origin
905
+ that also omits it — and in development the panel warns once per mount when
906
+ that happens, naming the fix.
907
+
908
+ Renaming is not a migration: the old database is left where it is rather than
909
+ moved or deleted, and the new one starts empty at default preferences.
910
+
911
+ #### Databases on this origin
912
+
913
+ The status bar always shows which database the panel is on. On the shared
914
+ default it turns amber and adds a `shared` tag, which is the visible form of
915
+ the console warning above — click it to open the screen below. The same screen
916
+ is in **⋯ More actions** and in the command palette.
917
+
918
+ Open the command palette (`Ctrl/⌘ K`) → **Databases on this origin** to see
919
+ every Blix database the origin holds — one per project, plus `nm-devtools`,
920
+ the single unprefixed database that all projects shared before names were
921
+ prefixed. Each row shows an approximate size and can be deleted; the one this
922
+ panel is using is marked and is not deletable from there, since it is open —
923
+ use **Purge saved log** for that.
924
+
925
+ **Peek** on a row lists the 50 newest entries in that database — time, method,
926
+ URL and status — so you can tell whose log it is before deleting it. It is a
927
+ read-only snapshot: Blix opens the database, reads, and closes it again, so the
928
+ list does not update and there is no detail pane, replay or export. The panel
929
+ itself always stays on its own database; to work with another project's log
930
+ properly, run that project and open the panel there.
931
+
932
+ The screen enumerates with `indexedDB.databases()`, which Firefox does not
933
+ implement. There it falls back to the databases Blix has itself opened in that
934
+ browser and labels the list as possibly incomplete.
757
935
 
758
936
  ---
759
937
 
@@ -846,6 +1024,11 @@ response body *after* your decryption interceptor. That is the whole point of
846
1024
  it, and it means the log holds whatever your traffic holds, credentials
847
1025
  included.
848
1026
 
1027
+ `attachFetchMonitor` widens that to **every `fetch` the page makes**, not only
1028
+ your own: an analytics snippet, a chat widget or a library calling `fetch`
1029
+ under the hood is captured the same way, bodies included. Use its `ignore`
1030
+ option to keep a third party's traffic out of the log.
1031
+
849
1032
  By default all of that is **in memory only**. Nothing is written to disk, and
850
1033
  a reload starts clean.
851
1034
 
@@ -886,10 +1069,15 @@ With preserve-log on, this is what is kept:
886
1069
  | HTTP entries — bodies, headers, timings | yes |
887
1070
  | Realtime frames | yes |
888
1071
  | The encrypted envelope, if you call `captureEncrypted` | yes |
889
- | Panel preferences and budget totals | yes — preferences are also mirrored to `localStorage` |
1072
+ | Panel preferences and budget totals | yes — preferences are also mirrored to `localStorage` under `blix:<dbName>:prefs` |
890
1073
  | Redux actions, payloads and diffs | only if you pin the row |
891
1074
  | Query cache rows | only if you pin the row |
892
1075
 
1076
+ One thing is written regardless of preserve-log: opening the database records
1077
+ its name in the origin-wide `localStorage` key `blix:databases`, which is how
1078
+ [Databases on this origin](#databases-on-this-origin) finds it in browsers
1079
+ without `indexedDB.databases()`. It holds database names and nothing else.
1080
+
893
1081
  ### What is redacted
894
1082
 
895
1083
  Exactly four header names, and nothing else:
@@ -908,6 +1096,10 @@ people out, and `proxy-authorization`, `x-csrf-token` and
908
1096
  `x-amz-security-token` are equally uncovered. If your auth travels in a header
909
1097
  that is not one of the four above, it is captured verbatim.
910
1098
 
1099
+ The same four are masked whichever client made the request, and whatever form
1100
+ the headers were passed in: `AxiosHeaders`, a plain object, a `Headers`
1101
+ instance or `[name, value]` pairs.
1102
+
911
1103
  Masking is partial rather than total: for a value longer than 12 characters
912
1104
  the first 8 and last 4 survive, so you can still tell which token you sent.
913
1105
  Shorter values are replaced outright. The Headers tab tags every masked row
@@ -953,11 +1145,23 @@ the toggle off.
953
1145
  Switching preserve-log **off** also clears the stored entries, so turning it
954
1146
  off is itself a way to drop everything Blix has written.
955
1147
 
1148
+ A purge can be **blocked**: IndexedDB will not delete a database that another
1149
+ tab still holds open. The panel reports that, naming the database, instead of
1150
+ reporting the log purged while it is still on disk. Close the other tabs
1151
+ running the app and purge again.
1152
+
1153
+ Other projects' databases on the same origin — and the legacy `nm-devtools`
1154
+ database that 0.5.x and earlier wrote — are deleted from
1155
+ [Databases on this origin](#databases-on-this-origin), not by Purge.
1156
+
956
1157
  Every path clears the captured entries; Purge additionally deletes the
957
1158
  IndexedDB database itself. **Your panel preferences survive either way** —
958
1159
  they are mirrored to `localStorage`, and a fresh database is re-seeded from
959
- that mirror on the next boot. There is no UI or API for clearing them, and no
960
- programmatic API for purging either.
1160
+ that mirror on the next boot. Both the database and its mirror key are scoped
1161
+ to this project's `dbName`, so a purge affects only the project that ran it and
1162
+ cannot restore — or destroy — another project's preferences on the same origin.
1163
+ There is no UI or API for clearing them, and no programmatic API for purging
1164
+ either.
961
1165
 
962
1166
  ### Threat model
963
1167
 
@@ -966,6 +1170,12 @@ rest. Any script running on that origin can read Blix's database — including
966
1170
  browser extension content scripts with access to the origin. Whatever you
967
1171
  capture is readable by whatever you have installed.
968
1172
 
1173
+ A per-project `dbName` does not change that. It keeps projects from mixing
1174
+ their logs; it does not isolate them. The panel itself can open another
1175
+ project's database on the same origin — **Peek** in
1176
+ [Databases on this origin](#databases-on-this-origin) lists its newest URLs,
1177
+ methods and statuses — and so can any other script there.
1178
+
969
1179
  Export and copy move captured data out of the browser entirely:
970
1180
 
971
1181
  | Path | Carries |
@@ -990,6 +1200,12 @@ bodies, and it is the artifact most likely to end up attached to a ticket.
990
1200
  [Redux](#redux--createreduxmonitormiddlewareoptions).
991
1201
  - **Treat an exported HAR as a credential-bearing file.** Do not attach one to
992
1202
  a public issue, and do not commit one.
1203
+ - **After upgrading from 0.5.x or earlier, delete `nm-devtools`.** Blix no
1204
+ longer reads or writes that database, and it does not delete it for you:
1205
+ anything it holds stays on disk until you remove it from
1206
+ [Databases on this origin](#databases-on-this-origin).
1207
+ - **Scope `attachFetchMonitor` with `ignore`** if third-party scripts on the
1208
+ page send data you do not want in the log.
993
1209
 
994
1210
  ---
995
1211
 
@@ -1006,9 +1222,10 @@ directive, so it stays usable from a server module — which the root entry, by
1006
1222
  virtue of the directive that lets `<Blix />` be rendered from a server
1007
1223
  component, is not.
1008
1224
 
1009
- The `/capture` entry exports `attachHttpMonitor`, `captureEncrypted`,
1010
- `createReduxMonitorMiddleware`, `tapQueryClient`, `tapRealtimeAdapter`,
1011
- `withInitiatorCapture`, and the supporting types (`EncryptedPayload`,
1225
+ The `/capture` entry exports `attachHttpMonitor`, `attachFetchMonitor`,
1226
+ `captureEncrypted`, `createReduxMonitorMiddleware`, `tapQueryClient`,
1227
+ `tapRealtimeAdapter`, `withInitiatorCapture`, and the supporting types
1228
+ (`EncryptedPayload`, `FetchMonitorOptions`, `FetchIgnoreRule`,
1012
1229
  `ReduxCaptureOptions`, `RealtimeAdapterLike`, `MonitorEntry`, …).
1013
1230
 
1014
1231
  ---