@avivbenhamo/polly-kit 0.1.0 → 0.5.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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +298 -138
  3. package/dist/adapters/encoding.d.ts +7 -0
  4. package/dist/adapters/encoding.js +46 -0
  5. package/dist/adapters/undici.d.ts +36 -0
  6. package/dist/adapters/undici.js +231 -0
  7. package/dist/browser.d.ts +13 -5
  8. package/dist/browser.js +21 -25
  9. package/dist/cli.d.ts +21 -0
  10. package/dist/cli.js +141 -0
  11. package/dist/defaults.d.ts +40 -0
  12. package/dist/defaults.js +40 -0
  13. package/dist/node.d.ts +56 -0
  14. package/dist/node.js +108 -0
  15. package/dist/studio/body.d.ts +28 -0
  16. package/dist/studio/body.js +66 -0
  17. package/dist/studio/browser.d.ts +22 -0
  18. package/dist/studio/browser.js +24 -0
  19. package/dist/studio/client.bundle.js +290 -0
  20. package/dist/studio/edit.d.ts +42 -0
  21. package/dist/studio/edit.js +90 -0
  22. package/dist/studio/etag.d.ts +6 -0
  23. package/dist/studio/etag.js +14 -0
  24. package/dist/studio/har.d.ts +19 -0
  25. package/dist/studio/har.js +36 -0
  26. package/dist/studio/headers.d.ts +31 -0
  27. package/dist/studio/headers.js +59 -0
  28. package/dist/studio/index.d.ts +9 -0
  29. package/dist/studio/index.js +5 -0
  30. package/dist/studio/server.d.ts +35 -0
  31. package/dist/studio/server.js +262 -0
  32. package/dist/studio/stores/apply.d.ts +6 -0
  33. package/dist/studio/stores/apply.js +20 -0
  34. package/dist/studio/stores/fs-store.d.ts +70 -0
  35. package/dist/studio/stores/fs-store.js +186 -0
  36. package/dist/studio/stores/http-store.d.ts +30 -0
  37. package/dist/studio/stores/http-store.js +67 -0
  38. package/dist/studio/stores/local-storage-store.d.ts +25 -0
  39. package/dist/studio/stores/local-storage-store.js +109 -0
  40. package/dist/studio/stores/types.d.ts +45 -0
  41. package/dist/studio/stores/types.js +7 -0
  42. package/dist/studio/types.d.ts +92 -0
  43. package/dist/studio/types.js +7 -0
  44. package/dist/studio/ui/app.d.ts +12 -0
  45. package/dist/studio/ui/app.js +180 -0
  46. package/dist/studio/ui/client.d.ts +2 -0
  47. package/dist/studio/ui/client.js +6 -0
  48. package/dist/studio/ui/page.d.ts +53 -0
  49. package/dist/studio/ui/page.js +360 -0
  50. package/dist/studio/ui/render.d.ts +25 -0
  51. package/dist/studio/ui/render.js +171 -0
  52. package/dist/vite.d.ts +0 -17
  53. package/dist/vite.js +44 -7
  54. package/package.json +55 -7
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 avivbenhamo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,138 +1,298 @@
1
- # @avivbenhamo/polly-kit
2
-
3
- Record a Vite app's real HTTP traffic to HAR files, then replay it so the app runs with no
4
- backends. Wraps Polly.JS, the recording store and the [polly-studio](https://www.npmjs.com/package/@avivbenhamo/polly-studio)
5
- editor into one plugin and one function.
6
-
7
- Nothing in here knows a hostname. You supply a policy; the package is the machinery.
8
-
9
- ## Install
10
-
11
- ```sh
12
- npm i -D @avivbenhamo/polly-kit
13
- ```
14
-
15
- ## Three steps
16
-
17
- **1. `vite.config.ts`** — one plugin, dev-server only (it registers in `configureServer`, so a
18
- production build never sees it):
19
-
20
- ```ts
21
- import pollyKit from '@avivbenhamo/polly-kit/vite';
22
-
23
- export default defineConfig({
24
- plugins: [react(), pollyKit({recordingsDir: 'recordings'})],
25
- });
26
- ```
27
-
28
- **2. A config file** — the only file you write:
29
-
30
- ```ts
31
- // src/polly/config.ts
32
- import type {PollyPolicyConfig} from '@avivbenhamo/polly-kit';
33
-
34
- export const pollyConfig: PollyPolicyConfig = {
35
- recordingName: 'my-app',
36
- policy: {kind: 'except', hosts: [import.meta.env.VITE_AUTH_URL]},
37
- };
38
- ```
39
-
40
- **3. A bootstrap module, imported FIRST in your entry point:**
41
-
42
- ```ts
43
- // src/polly/bootstrap.ts
44
- import {resolveMode, startPolly} from '@avivbenhamo/polly-kit';
45
- import {pollyConfig} from './config';
46
-
47
- const mode = import.meta.env.PROD ? 'off' : resolveMode(import.meta.env.VITE_POLLY_MODE);
48
-
49
- startPolly({...pollyConfig, mode});
50
- ```
51
-
52
- ```ts
53
- // src/index.tsx — FIRST import, above everything
54
- import './polly/bootstrap';
55
- ```
56
-
57
- `bootstrap.ts` exists so that `startPolly()` runs before your component tree is evaluated. ES
58
- module imports are hoisted and fully evaluated before any statement in the importing module's
59
- body, so a bare `startPolly()` call in `index.tsx` runs *after* `./App` and everything it
60
- imports — and any module that fires a request at import time would escape interception,
61
- silently and only sometimes.
62
-
63
- Add `/recordings` to `.gitignore`. A HAR holds complete response bodies.
64
-
65
- ## Running it
66
-
67
- Off unless `VITE_POLLY_MODE` is set, so the default behaviour of your app is unchanged.
68
-
69
- ```powershell
70
- $env:VITE_POLLY_MODE='record'; npm run dev # capture real traffic
71
- $env:VITE_POLLY_MODE='replay'; npm run dev # serve from recordings
72
- ```
73
-
74
- ```sh
75
- VITE_POLLY_MODE=record npm run dev # bash
76
- ```
77
-
78
- Record once against reachable backends, then replay forever. Recordings land in
79
- `recordings/<recordingName>_<hash>/recording.har`, editable at **http://localhost:<port>/__polly**
80
- — the URL is printed on startup.
81
-
82
- ## Policies
83
-
84
- ```ts
85
- policy: {kind: 'all'} // mock every host
86
- policy: {kind: 'except', hosts: [...]} // mock everything EXCEPT these
87
- policy: {kind: 'only', hosts: [...]} // mock ONLY these
88
- ```
89
-
90
- Hosts match by origin, so a trailing slash or a path on the value is fine — they are `baseURL`s
91
- in practice, and `toOrigin` reduces them. An entry that resolves to `undefined` (an env var
92
- that was never set) is skipped rather than throwing at startup.
93
-
94
- The app's own origin is always passed through: it serves the HMR socket, the recording store
95
- and every static asset, so mocking it breaks the dev server.
96
-
97
- **Passed-through hosts still hit the real network in `replay` mode.** That is what passthrough
98
- means, but it has a sharp edge: the hosts you exclude are usually auth and analytics, which are
99
- also the least likely to be reachable from an isolated environment. Replaying off-network then
100
- gives you hanging calls to exactly those while everything else replays fine — which reads as
101
- "Polly is broken" and isn't. For true isolation use `{kind: 'all'}` and record them too.
102
-
103
- ## API
104
-
105
- | Export | From | What |
106
- |---|---|---|
107
- | `startPolly(options)` | `.` | Starts Polly. Returns `null` when `mode` is `'off'`, having constructed nothing. |
108
- | `resolveMode(raw)` | `.` | Narrows an env value to `'off' \| 'record' \| 'replay'`. Anything unrecognised is `off`. |
109
- | `toOrigin` / `toOrigins` | `.` | The origin reduction the policy matcher uses. |
110
- | `pollyKit(options)` | `./vite` | The Vite plugin: recording store + studio. |
111
- | `recordingStore(options)` | `./store` | The store as a bare connect middleware, for mounting on something that isn't Vite. |
112
-
113
- `startPolly` options beyond the policy config: `mode` (required), `alwaysPassthrough`,
114
- `apiNamespace`, `saveDebounceMs`. `pollyKit` options: `recordingsDir`, `studioPath`,
115
- `apiNamespace`, `studio`. Change `apiNamespace` in one place and you must change it in both.
116
-
117
- ## Notes on why it is built this way
118
-
119
- **The XHR adapter, not fetch.** axios resolves its adapter as `['xhr', 'http', 'fetch']`, so in
120
- a browser it uses `XMLHttpRequest`. `@pollyjs/adapter-fetch` would attach cleanly and then see
121
- no traffic at all. `adapter-xhr` patches `window.XMLHttpRequest`, which axios picks up with no
122
- awareness — so no axios instance in your app needs to change.
123
-
124
- **The `global` shim.** `@pollyjs/persister-rest` reads a bare, unbound `global` at module top
125
- level, which throws in a browser. `startPolly`'s module imports the shim as its own first
126
- import, so you cannot get the order wrong.
127
-
128
- **Recordings flush while you browse.** Polly only persists on `stop()`, which suits a test that
129
- ends; a dev server page stays open and would write nothing. The kit debounces a `persist()` per
130
- response instead.
131
-
132
- **Defaults that differ from Polly's.** `recordIfMissing: false` (a replay miss must fail loudly
133
- — the default quietly hits the network and records it, which looks identical to replay working),
134
- `recordFailedRequests: true`, `matchRequestsBy.order: false` (screens refetch) and
135
- `.headers: false` (bearer tokens would pin a recording to one session).
136
-
137
- **If you also run MSW, Polly wins.** It patches `XMLHttpRequest` in-page, so the request is
138
- answered before the service worker sees it. Run one or the other.
1
+ # @avivbenhamo/polly-kit
2
+
3
+ Record an app's real HTTP traffic to HAR files, then replay it so the app runs with no
4
+ backends. Polly.JS, the recording store and the HAR editor in one package — one install, no
5
+ companion to keep in step.
6
+
7
+ Entry points:
8
+
9
+ | | |
10
+ |---|---|
11
+ | `@avivbenhamo/polly-kit` | a Vite app, in the browser |
12
+ | [`@avivbenhamo/polly-kit/node`](#non-browser-apps) | a Node process — NestJS, a service, a CLI. `http(s)` **and** the built-in `fetch` |
13
+ | [`@avivbenhamo/polly-kit/studio`](#the-editor) | the editor's handler and HAR API, to mount anywhere |
14
+ | `@avivbenhamo/polly-kit/studio/browser` | the editor for a browser app with no server at all |
15
+ | `@avivbenhamo/polly-kit/vite` | the Vite plugin |
16
+ | `@avivbenhamo/polly-kit/store` | the recording store as bare connect middleware |
17
+
18
+ Nothing in here knows a hostname. You supply a policy; the package is the machinery.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ npm i -D @avivbenhamo/polly-kit
24
+ ```
25
+
26
+ ## Three steps
27
+
28
+ **1. `vite.config.ts`** — one plugin, dev-server only (it registers in `configureServer`, so a
29
+ production build never sees it):
30
+
31
+ ```ts
32
+ import pollyKit from '@avivbenhamo/polly-kit/vite';
33
+
34
+ export default defineConfig({
35
+ plugins: [react(), pollyKit({recordingsDir: 'recordings'})],
36
+ });
37
+ ```
38
+
39
+ **2. A config file** — the only file you write:
40
+
41
+ ```ts
42
+ // src/polly/config.ts
43
+ import type {PollyPolicyConfig} from '@avivbenhamo/polly-kit';
44
+
45
+ export const pollyConfig: PollyPolicyConfig = {
46
+ recordingName: 'my-app',
47
+ policy: {kind: 'except', hosts: [import.meta.env.VITE_AUTH_URL]},
48
+ };
49
+ ```
50
+
51
+ **3. A bootstrap module, imported FIRST in your entry point:**
52
+
53
+ ```ts
54
+ // src/polly/bootstrap.ts
55
+ import {resolveMode, startPolly} from '@avivbenhamo/polly-kit';
56
+ import {pollyConfig} from './config';
57
+
58
+ const mode = import.meta.env.PROD ? 'off' : resolveMode(import.meta.env.VITE_POLLY_MODE);
59
+
60
+ startPolly({...pollyConfig, mode});
61
+ ```
62
+
63
+ ```ts
64
+ // src/index.tsx — FIRST import, above everything
65
+ import './polly/bootstrap';
66
+ ```
67
+
68
+ `bootstrap.ts` exists so that `startPolly()` runs before your component tree is evaluated. ES
69
+ module imports are hoisted and fully evaluated before any statement in the importing module's
70
+ body, so a bare `startPolly()` call in `index.tsx` runs *after* `./App` and everything it
71
+ imports — and any module that fires a request at import time would escape interception,
72
+ silently and only sometimes.
73
+
74
+ Add `/recordings` to `.gitignore`. A HAR holds complete response bodies.
75
+
76
+ ## Running it
77
+
78
+ Off unless `VITE_POLLY_MODE` is set, so the default behaviour of your app is unchanged.
79
+
80
+ ```powershell
81
+ $env:VITE_POLLY_MODE='record'; npm run dev # capture real traffic
82
+ $env:VITE_POLLY_MODE='replay'; npm run dev # serve from recordings
83
+ ```
84
+
85
+ ```sh
86
+ VITE_POLLY_MODE=record npm run dev # bash
87
+ ```
88
+
89
+ Record once against reachable backends, then replay forever. Recordings land in
90
+ `recordings/<recordingName>_<hash>/recording.har`, editable at **http://localhost:<port>/__polly**
91
+ — the URL is printed on startup.
92
+
93
+ ## Non-browser apps
94
+
95
+ `@avivbenhamo/polly-kit/node` is the same thing for a Node process — NestJS, a plain
96
+ service, a CLI. It intercepts **both** transports: `http(s).request`/`get` (axios, got,
97
+ Nest's `HttpModule`) and Node's built-in `fetch`.
98
+
99
+ ```ts
100
+ // src/polly/mock.ts
101
+ import {startPolly, resolveMode} from '@avivbenhamo/polly-kit/node';
102
+
103
+ export const polly = startPolly({
104
+ mode: resolveMode(process.env.POLLY_MODE),
105
+ recordingName: 'my-api',
106
+ recordingsDir: './recordings',
107
+ policy: {kind: 'only', hosts: [process.env.PARTNER_API_URL]},
108
+ });
109
+ ```
110
+
111
+ ```ts
112
+ // main.ts
113
+ import {polly} from './polly/mock'; // FIRST import
114
+ import {pollyUi} from '@avivbenhamo/polly-kit/node';
115
+
116
+ // startPolly() runs above NestFactory.create: providers can fire requests during
117
+ // module init, and anything that goes out ahead of it is not recorded.
118
+ const app = await NestFactory.create(AppModule);
119
+
120
+ if (polly) {
121
+ app.use('/__polly', pollyUi({recordingsDir: './recordings'}));
122
+ }
123
+
124
+ // Polly persists on stop. A SIGKILL still loses whatever the debounce had not flushed.
125
+ for (const signal of ['SIGINT', 'SIGTERM']) {
126
+ process.on(signal, async () => {
127
+ await polly?.stop();
128
+ process.exit(0);
129
+ });
130
+ }
131
+ ```
132
+
133
+ ```powershell
134
+ $env:POLLY_MODE='record'; npm run start:dev
135
+ ```
136
+
137
+ No Vite plugin and no recording store: `persister-fs` writes the HAR straight to disk.
138
+ Add `/recordings` to `.gitignore`.
139
+
140
+ **Use `{kind: 'only'}`, not `{kind: 'all'}`.** In a browser Polly sees the page's own
141
+ requests. In Node the adapters are process-wide: they also see your service talking to
142
+ Postgres, Redis, the broker, its health checks and its telemetry collector. `{kind: 'all'}`
143
+ on a server mocks the database. `startPolly` logs the policy it resolved on startup — read
144
+ that line once.
145
+
146
+ **Call `http.get(...)` off the namespace, never `import {get} from 'node:http'`.** A named
147
+ ESM import of a builtin is bound before the adapter patches the module object, and the
148
+ already-bound name never sees the patch. Measured on Node v22.14.0: every request completes
149
+ against the real upstream and Polly records none of them. axios is unaffected — it resolves
150
+ `request` off the module object at call time.
151
+
152
+ **Streaming is buffered.** Polly hands an adapter a complete response, so a
153
+ server-sent-events or long-poll endpoint will not stream in record mode. Put such hosts in
154
+ `alwaysPassthrough`.
155
+
156
+ **Compressed responses are decompressed before they are stored**, and their
157
+ `content-encoding` and `content-length` go with the compression. The undici dispatcher sees
158
+ bodies still gzipped — `fetch` decompresses in a layer above it — so storing them raw would
159
+ put base64 of compressed bytes in the HAR and leave nothing editable in the editor.
160
+
161
+ **Quieter than Polly's default.** `logLevel` defaults to `'error'` here, not Polly's
162
+ `'warn'`: `@pollyjs/adapter` warns on every recorded request when
163
+ `'navigator' in global && !navigator.onLine`, and Node has a global `navigator` with no
164
+ `onLine` — so the claim is always false, and each one drags a full stringified request and
165
+ config into the log. Pass `logLevel: 'warn'` to get Polly's default back.
166
+
167
+ Requires Node >= 22.6. Node 24 ships undici 7, whose handler protocol differs; `fetch`
168
+ interception then fails with a message naming your Node version rather than silently
169
+ recording nothing.
170
+
171
+ ## Policies
172
+
173
+ ```ts
174
+ policy: {kind: 'all'} // mock every host
175
+ policy: {kind: 'except', hosts: [...]} // mock everything EXCEPT these
176
+ policy: {kind: 'only', hosts: [...]} // mock ONLY these
177
+ ```
178
+
179
+ Hosts match by origin, so a trailing slash or a path on the value is fine — they are `baseURL`s
180
+ in practice, and `toOrigin` reduces them. An entry that resolves to `undefined` (an env var
181
+ that was never set) is skipped rather than throwing at startup.
182
+
183
+ **What is never mocked differs by entry point.** In the browser, the app's own origin is
184
+ always passed through: it serves the HMR socket, the recording store and every static asset,
185
+ so mocking it breaks the dev server. On `./node` there is no such origin, so
186
+ `alwaysPassthrough` starts **empty** — nothing is exempt until you name it. That is deliberate:
187
+ the adapters are process-wide, and a default that guessed which of your hosts were
188
+ infrastructure would guess wrong quietly.
189
+
190
+ **Passed-through hosts still hit the real network in `replay` mode.** That is what passthrough
191
+ means, but it has a sharp edge: the hosts you exclude are usually auth and analytics, which are
192
+ also the least likely to be reachable from an isolated environment. Replaying off-network then
193
+ gives you hanging calls to exactly those while everything else replays fine — which reads as
194
+ "Polly is broken" and isn't. For true isolation use `{kind: 'all'}` and record them too.
195
+
196
+ ## The editor
197
+
198
+ The editor ships inside this package — there is nothing else to install. On the Vite path the
199
+ plugin mounts it for you at `/__polly`. Anywhere else, `pollyUi()` is a bare `(req, res)`
200
+ handler:
201
+
202
+ ```ts
203
+ import {pollyUi} from '@avivbenhamo/polly-kit/studio';
204
+
205
+ app.use('/__polly', pollyUi({recordingsDir: './recordings'}));
206
+ ```
207
+
208
+ On a raw `http.createServer` also pass `basePath: '/__polly'`, since nothing stripped the
209
+ prefix for you. Options: `recordingsDir` (required), `basePath`, `cwd`, and `enabled`, which
210
+ defaults to `process.env.NODE_ENV !== 'production'`.
211
+
212
+ ### Without an app
213
+
214
+ ```sh
215
+ npx polly-kit studio --dir recordings # http://127.0.0.1:7788
216
+ ```
217
+
218
+ For a checkout with no dev server to mount on: reviewing the recordings in a pull request,
219
+ pruning a stale one, opening somebody else's `recordings/` without installing their app. Same
220
+ editor, same files — the recordings directory is the only state, so an app recording into it
221
+ from another process shows up without restarting anything. If the port is taken it walks up to
222
+ the next free one and says so.
223
+
224
+ **The mounted form is still the default**, and the better one when there is a dev server: no
225
+ second process, no second port, no CORS, and it stops when the server does. Recordings kept in
226
+ a browser's `localStorage` cannot be served this way at all — no separate process can see them,
227
+ which is what `mountPollyStudio` is for.
228
+
229
+ Unlike the mounted handler this ignores `NODE_ENV`. Something mounted into an app can be
230
+ deployed by accident; an `npx` invocation cannot, and a shell that happens to export
231
+ `NODE_ENV=production` would otherwise get 404s with no explanation.
232
+
233
+ **It answers 404, not 403, when disabled** — the same answer an unmounted path gives, rather
234
+ than confirming something is there. Recordings hold real request and response bodies, so
235
+ "someone mounted this and shipped it" must not become a data leak.
236
+
237
+ For a browser app with no server at all, recording through Polly's
238
+ `persister-local-storage`, `@avivbenhamo/polly-kit/studio/browser` mounts the same UI against
239
+ that one localStorage key:
240
+
241
+ ```ts
242
+ import {mountPollyStudio} from '@avivbenhamo/polly-kit/studio/browser';
243
+
244
+ mountPollyStudio(document.getElementById('polly')!);
245
+ ```
246
+
247
+ ## API
248
+
249
+ | Export | From | What |
250
+ |---|---|---|
251
+ | `startPolly(options)` | `.` | Starts Polly. Returns `null` when `mode` is `'off'`, having constructed nothing. |
252
+ | `resolveMode(raw)` | `.` | Narrows an env value to `'off' \| 'record' \| 'replay'`. Anything unrecognised is `off`. |
253
+ | `toOrigin` / `toOrigins` | `.` | The origin reduction the policy matcher uses. |
254
+ | `pollyKit(options)` | `./vite` | The Vite plugin: recording store + studio. |
255
+ | `recordingStore(options)` | `./store` | The store as a bare connect middleware, for mounting on something that isn't Vite. |
256
+ | `startPolly` / `resolveMode` | `./node` | The Node entry: `http(s)` and `fetch`, HAR straight to disk. |
257
+ | `pollyUi(options)` | `./node`, `./studio` | The editor as a bare `(req, res)` handler. Mounts on Express, Fastify, Koa, Vite or raw `http`. |
258
+ | `FsStore` / `serializeHar` | `./studio` | The HAR file store the editor reads and writes through. |
259
+ | `parseHar` / `findEntry` | `./studio` | Reading a HAR, with the Polly-specific quirks handled. |
260
+ | `applyResponsePatch` / `deleteEntry` | `./studio` | Editing an entry, returning a typed `EditError` rather than throwing. |
261
+ | `mountPollyStudio(root)` | `./studio/browser` | The editor with no server at all, against Polly's `persister-local-storage`. |
262
+
263
+ `startPolly` options beyond the policy config: `mode` (required), `alwaysPassthrough`,
264
+ `apiNamespace`, `saveDebounceMs`; on `./node`, `recordingsDir` and `logLevel` instead of
265
+ `apiNamespace`. `pollyKit` options: `recordingsDir`, `studioPath`,
266
+ `apiNamespace`, `studio`. Change `apiNamespace` in one place and you must change it in both.
267
+
268
+ ## Notes on why it is built this way
269
+
270
+ **In the browser: both adapters.** A browser has two request transports and an app can use
271
+ either, so covering one covers half the app — silently, because an adapter attaches cleanly
272
+ whether or not any traffic reaches it. `adapter-xhr` patches `window.XMLHttpRequest`, which is
273
+ what axios uses (it resolves its adapter as `['xhr', 'http', 'fetch']`), so no axios instance
274
+ in your app needs to change. `adapter-fetch` patches `window.fetch`, which is what code
275
+ written against the platform calls directly. They patch different globals and do not interact.
276
+
277
+ **On Node: two adapters, one of them ours.** `@pollyjs/adapter-node-http` patches
278
+ `http.request`/`http.get`, which covers axios, got and Nest's `HttpModule`. It sees nothing of
279
+ the built-in `fetch`: that is undici, which reads its dispatcher off a global symbol and opens
280
+ a socket without going through `http.request`. Polly ships no adapter for that layer, so this
281
+ package contains one. An `http`-only wrapper would attach cleanly and record none of your
282
+ `fetch` traffic — silently, which is the failure this package exists to prevent.
283
+
284
+ **The `global` shim.** `@pollyjs/persister-rest` reads a bare, unbound `global` at module top
285
+ level, which throws in a browser. `startPolly`'s module imports the shim as its own first
286
+ import, so you cannot get the order wrong.
287
+
288
+ **Recordings flush while you browse.** Polly only persists on `stop()`, which suits a test that
289
+ ends; a dev server page stays open and would write nothing. The kit debounces a `persist()` per
290
+ response instead.
291
+
292
+ **Defaults that differ from Polly's.** `recordIfMissing: false` (a replay miss must fail loudly
293
+ — the default quietly hits the network and records it, which looks identical to replay working),
294
+ `recordFailedRequests: true`, `matchRequestsBy.order: false` (screens refetch) and
295
+ `.headers: false` (bearer tokens would pin a recording to one session).
296
+
297
+ **If you also run MSW, Polly wins.** It patches `XMLHttpRequest` and `fetch` in-page, so the
298
+ request is answered before the service worker sees it. Run one or the other.
@@ -0,0 +1,7 @@
1
+ export declare function decompress(body: Buffer, contentEncoding: string | undefined): Buffer;
2
+ /**
3
+ * `content-encoding` goes because the stored body is no longer encoded.
4
+ * `content-length` goes with it: it described the compressed bytes, and nothing
5
+ * recomputes it on replay.
6
+ */
7
+ export declare function stripEncodingHeaders(headers: Record<string, string>): Record<string, string>;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The undici dispatcher sees response bodies still compressed — `fetch`
3
+ * decompresses in a layer above it. Measured, not assumed.
4
+ *
5
+ * Stored as-is, a gzipped JSON response is not utf8-representable, so it lands in
6
+ * the HAR as base64 and polly-studio shows an opaque blob. Since editing
7
+ * recordings is the reason polly-studio exists, the adapter decompresses at record
8
+ * time and drops the headers that described the compression.
9
+ */
10
+ import { brotliDecompressSync, gunzipSync, inflateSync } from 'node:zlib';
11
+ const DROPPED = new Set(['content-encoding', 'content-length']);
12
+ export function decompress(body, contentEncoding) {
13
+ if (body.length === 0 || contentEncoding === undefined) {
14
+ return body;
15
+ }
16
+ // A comma-separated list is legal; the last applied encoding is the outermost.
17
+ const encoding = contentEncoding.split(',').pop()?.trim().toLowerCase();
18
+ try {
19
+ switch (encoding) {
20
+ case 'gzip':
21
+ case 'x-gzip':
22
+ return gunzipSync(body);
23
+ case 'deflate':
24
+ return inflateSync(body);
25
+ case 'br':
26
+ return brotliDecompressSync(body);
27
+ default:
28
+ // `identity`, or something we do not know how to undo. Leave it
29
+ // alone and leave its header on, so replay stays consistent.
30
+ return body;
31
+ }
32
+ }
33
+ catch {
34
+ // A truncated or mislabelled body is not worth failing the request over.
35
+ // It stays compressed, its header stays, and replay still works.
36
+ return body;
37
+ }
38
+ }
39
+ /**
40
+ * `content-encoding` goes because the stored body is no longer encoded.
41
+ * `content-length` goes with it: it described the compressed bytes, and nothing
42
+ * recomputes it on replay.
43
+ */
44
+ export function stripEncodingHeaders(headers) {
45
+ return Object.fromEntries(Object.entries(headers).filter(([name]) => !DROPPED.has(name.toLowerCase())));
46
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * A Polly adapter for Node's built-in `fetch`.
3
+ *
4
+ * `@pollyjs/adapter-node-http` patches `http.request`/`http.get` on the module
5
+ * object, which covers axios, got, node-fetch and Nest's HttpModule. It does not
6
+ * cover `fetch`: that is undici, which reads its dispatcher off a well-known
7
+ * global symbol and opens a socket without ever calling `http.request`. Polly
8
+ * ships no adapter for that layer, so this is it.
9
+ *
10
+ * The interception is ported from `mock-env/src/adapters/undici.ts`, where every
11
+ * detail below was measured on Node 22.14 rather than read off a doc. What is new
12
+ * here is the mapping onto Polly's four hooks.
13
+ */
14
+ import Adapter from '@pollyjs/adapter';
15
+ export default class UndiciAdapter extends Adapter {
16
+ /**
17
+ * A getter, not a static field. `@pollyjs/adapter` defines `id` as a static
18
+ * getter that asserts, and its declaration types it as a readonly property —
19
+ * so a field assignment compiles and then dies at import time with "Cannot set
20
+ * property id of class Adapter which has only a getter". The cast is for the
21
+ * declaration; the getter is what the runtime requires.
22
+ */
23
+ static get id(): string;
24
+ private previous;
25
+ onConnect(): void;
26
+ onDisconnect(): void;
27
+ /**
28
+ * Everything is wrapped. An escaped rejection here means the app's `fetch()`
29
+ * never settles — no response, no error, just a hang.
30
+ */
31
+ private dispatch;
32
+ /** Record and passthrough: delegate to the dispatcher we displaced. */
33
+ onFetchResponse(pollyRequest: never): Promise<never>;
34
+ /** Deliver whatever Polly settled on to the caller's handler. */
35
+ onRespond(pollyRequest: never, error?: Error): Promise<void>;
36
+ }