@avivbenhamo/polly-kit 0.1.0 → 0.3.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/LICENSE +21 -0
- package/README.md +148 -10
- package/dist/adapters/encoding.d.ts +7 -0
- package/dist/adapters/encoding.js +46 -0
- package/dist/adapters/undici.d.ts +36 -0
- package/dist/adapters/undici.js +231 -0
- package/dist/browser.js +5 -19
- package/dist/defaults.d.ts +40 -0
- package/dist/defaults.js +40 -0
- package/dist/node.d.ts +56 -0
- package/dist/node.js +108 -0
- package/dist/studio/body.d.ts +28 -0
- package/dist/studio/body.js +66 -0
- package/dist/studio/browser.d.ts +22 -0
- package/dist/studio/browser.js +24 -0
- package/dist/studio/client.bundle.js +290 -0
- package/dist/studio/edit.d.ts +42 -0
- package/dist/studio/edit.js +90 -0
- package/dist/studio/etag.d.ts +6 -0
- package/dist/studio/etag.js +14 -0
- package/dist/studio/har.d.ts +19 -0
- package/dist/studio/har.js +36 -0
- package/dist/studio/headers.d.ts +31 -0
- package/dist/studio/headers.js +59 -0
- package/dist/studio/index.d.ts +9 -0
- package/dist/studio/index.js +5 -0
- package/dist/studio/server.d.ts +35 -0
- package/dist/studio/server.js +262 -0
- package/dist/studio/stores/apply.d.ts +6 -0
- package/dist/studio/stores/apply.js +20 -0
- package/dist/studio/stores/fs-store.d.ts +70 -0
- package/dist/studio/stores/fs-store.js +186 -0
- package/dist/studio/stores/http-store.d.ts +30 -0
- package/dist/studio/stores/http-store.js +67 -0
- package/dist/studio/stores/local-storage-store.d.ts +25 -0
- package/dist/studio/stores/local-storage-store.js +109 -0
- package/dist/studio/stores/types.d.ts +45 -0
- package/dist/studio/stores/types.js +7 -0
- package/dist/studio/types.d.ts +92 -0
- package/dist/studio/types.js +7 -0
- package/dist/studio/ui/app.d.ts +12 -0
- package/dist/studio/ui/app.js +180 -0
- package/dist/studio/ui/client.d.ts +2 -0
- package/dist/studio/ui/client.js +6 -0
- package/dist/studio/ui/page.d.ts +53 -0
- package/dist/studio/ui/page.js +360 -0
- package/dist/studio/ui/render.d.ts +25 -0
- package/dist/studio/ui/render.js +171 -0
- package/dist/vite.js +1 -1
- package/package.json +49 -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,8 +1,19 @@
|
|
|
1
1
|
# @avivbenhamo/polly-kit
|
|
2
2
|
|
|
3
|
-
Record
|
|
4
|
-
backends.
|
|
5
|
-
|
|
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 |
|
|
6
17
|
|
|
7
18
|
Nothing in here knows a hostname. You supply a policy; the package is the machinery.
|
|
8
19
|
|
|
@@ -79,6 +90,84 @@ Record once against reachable backends, then replay forever. Recordings land in
|
|
|
79
90
|
`recordings/<recordingName>_<hash>/recording.har`, editable at **http://localhost:<port>/__polly**
|
|
80
91
|
— the URL is printed on startup.
|
|
81
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
|
+
|
|
82
171
|
## Policies
|
|
83
172
|
|
|
84
173
|
```ts
|
|
@@ -91,8 +180,12 @@ Hosts match by origin, so a trailing slash or a path on the value is fine — th
|
|
|
91
180
|
in practice, and `toOrigin` reduces them. An entry that resolves to `undefined` (an env var
|
|
92
181
|
that was never set) is skipped rather than throwing at startup.
|
|
93
182
|
|
|
94
|
-
|
|
95
|
-
|
|
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.
|
|
96
189
|
|
|
97
190
|
**Passed-through hosts still hit the real network in `replay` mode.** That is what passthrough
|
|
98
191
|
means, but it has a sharp edge: the hosts you exclude are usually auth and analytics, which are
|
|
@@ -100,6 +193,36 @@ also the least likely to be reachable from an isolated environment. Replaying of
|
|
|
100
193
|
gives you hanging calls to exactly those while everything else replays fine — which reads as
|
|
101
194
|
"Polly is broken" and isn't. For true isolation use `{kind: 'all'}` and record them too.
|
|
102
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
|
+
**It answers 404, not 403, when disabled** — the same answer an unmounted path gives, rather
|
|
213
|
+
than confirming something is there. Recordings hold real request and response bodies, so
|
|
214
|
+
"someone mounted this and shipped it" must not become a data leak.
|
|
215
|
+
|
|
216
|
+
For a browser app with no server at all, recording through Polly's
|
|
217
|
+
`persister-local-storage`, `@avivbenhamo/polly-kit/studio/browser` mounts the same UI against
|
|
218
|
+
that one localStorage key:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
import {mountPollyStudio} from '@avivbenhamo/polly-kit/studio/browser';
|
|
222
|
+
|
|
223
|
+
mountPollyStudio(document.getElementById('polly')!);
|
|
224
|
+
```
|
|
225
|
+
|
|
103
226
|
## API
|
|
104
227
|
|
|
105
228
|
| Export | From | What |
|
|
@@ -109,17 +232,32 @@ gives you hanging calls to exactly those while everything else replays fine —
|
|
|
109
232
|
| `toOrigin` / `toOrigins` | `.` | The origin reduction the policy matcher uses. |
|
|
110
233
|
| `pollyKit(options)` | `./vite` | The Vite plugin: recording store + studio. |
|
|
111
234
|
| `recordingStore(options)` | `./store` | The store as a bare connect middleware, for mounting on something that isn't Vite. |
|
|
235
|
+
| `startPolly` / `resolveMode` | `./node` | The Node entry: `http(s)` and `fetch`, HAR straight to disk. |
|
|
236
|
+
| `pollyUi(options)` | `./node`, `./studio` | The editor as a bare `(req, res)` handler. Mounts on Express, Fastify, Koa, Vite or raw `http`. |
|
|
237
|
+
| `FsStore` / `serializeHar` | `./studio` | The HAR file store the editor reads and writes through. |
|
|
238
|
+
| `parseHar` / `findEntry` | `./studio` | Reading a HAR, with the Polly-specific quirks handled. |
|
|
239
|
+
| `applyResponsePatch` / `deleteEntry` | `./studio` | Editing an entry, returning a typed `EditError` rather than throwing. |
|
|
240
|
+
| `mountPollyStudio(root)` | `./studio/browser` | The editor with no server at all, against Polly's `persister-local-storage`. |
|
|
112
241
|
|
|
113
242
|
`startPolly` options beyond the policy config: `mode` (required), `alwaysPassthrough`,
|
|
114
|
-
`apiNamespace`, `saveDebounceMs
|
|
243
|
+
`apiNamespace`, `saveDebounceMs`; on `./node`, `recordingsDir` and `logLevel` instead of
|
|
244
|
+
`apiNamespace`. `pollyKit` options: `recordingsDir`, `studioPath`,
|
|
115
245
|
`apiNamespace`, `studio`. Change `apiNamespace` in one place and you must change it in both.
|
|
116
246
|
|
|
117
247
|
## Notes on why it is built this way
|
|
118
248
|
|
|
119
|
-
**
|
|
120
|
-
a browser it uses `XMLHttpRequest`. `@pollyjs/adapter-fetch`
|
|
121
|
-
no traffic at all. `adapter-xhr` patches
|
|
122
|
-
awareness — so no axios instance in your
|
|
249
|
+
**In the browser: the XHR adapter, not fetch.** axios resolves its adapter as
|
|
250
|
+
`['xhr', 'http', 'fetch']`, so in a browser it uses `XMLHttpRequest`. `@pollyjs/adapter-fetch`
|
|
251
|
+
would attach cleanly and then see no traffic at all. `adapter-xhr` patches
|
|
252
|
+
`window.XMLHttpRequest`, which axios picks up with no awareness — so no axios instance in your
|
|
253
|
+
app needs to change.
|
|
254
|
+
|
|
255
|
+
**On Node: two adapters, one of them ours.** `@pollyjs/adapter-node-http` patches
|
|
256
|
+
`http.request`/`http.get`, which covers axios, got and Nest's `HttpModule`. It sees nothing of
|
|
257
|
+
the built-in `fetch`: that is undici, which reads its dispatcher off a global symbol and opens
|
|
258
|
+
a socket without going through `http.request`. Polly ships no adapter for that layer, so this
|
|
259
|
+
package contains one. An `http`-only wrapper would attach cleanly and record none of your
|
|
260
|
+
`fetch` traffic — silently, which is the failure this package exists to prevent.
|
|
123
261
|
|
|
124
262
|
**The `global` shim.** `@pollyjs/persister-rest` reads a bare, unbound `global` at module top
|
|
125
263
|
level, which throws in a browser. `startPolly`'s module imports the shim as its own first
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
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
|
+
import { decompress, stripEncodingHeaders } from './encoding.js';
|
|
16
|
+
const GLOBAL_DISPATCHER = Symbol.for('undici.globalDispatcher.1');
|
|
17
|
+
export default class UndiciAdapter extends Adapter {
|
|
18
|
+
/**
|
|
19
|
+
* A getter, not a static field. `@pollyjs/adapter` defines `id` as a static
|
|
20
|
+
* getter that asserts, and its declaration types it as a readonly property —
|
|
21
|
+
* so a field assignment compiles and then dies at import time with "Cannot set
|
|
22
|
+
* property id of class Adapter which has only a getter". The cast is for the
|
|
23
|
+
* declaration; the getter is what the runtime requires.
|
|
24
|
+
*/
|
|
25
|
+
static get id() {
|
|
26
|
+
return 'undici';
|
|
27
|
+
}
|
|
28
|
+
onConnect() {
|
|
29
|
+
const holder = globalThis;
|
|
30
|
+
this.previous = holder[GLOBAL_DISPATCHER];
|
|
31
|
+
const adapter = {
|
|
32
|
+
dispatch: (opts, handler) => {
|
|
33
|
+
if (typeof handler.onHeaders !== 'function') {
|
|
34
|
+
// Node 24 ships undici 7, whose handler protocol differs. Fail
|
|
35
|
+
// by name: the alternative is recording nothing, silently.
|
|
36
|
+
handler.onError(new Error(`polly-kit: this Node build (${process.version}) uses an undici handler ` +
|
|
37
|
+
'protocol polly-kit does not support. Node >=22.6 with undici 6 is required.'));
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
void this.dispatch(opts, handler);
|
|
41
|
+
return true;
|
|
42
|
+
},
|
|
43
|
+
close: async () => { },
|
|
44
|
+
destroy: async () => { },
|
|
45
|
+
};
|
|
46
|
+
holder[GLOBAL_DISPATCHER] = adapter;
|
|
47
|
+
}
|
|
48
|
+
onDisconnect() {
|
|
49
|
+
const holder = globalThis;
|
|
50
|
+
holder[GLOBAL_DISPATCHER] = this.previous;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Everything is wrapped. An escaped rejection here means the app's `fetch()`
|
|
54
|
+
* never settles — no response, no error, just a hang.
|
|
55
|
+
*/
|
|
56
|
+
async dispatch(opts, handler) {
|
|
57
|
+
try {
|
|
58
|
+
const body = await drain(opts.body);
|
|
59
|
+
await this.handleRequest({
|
|
60
|
+
url: new URL(opts.path, String(opts.origin)).href,
|
|
61
|
+
method: (opts.method ?? 'GET').toUpperCase(),
|
|
62
|
+
headers: normalizeHeaders(opts.headers),
|
|
63
|
+
body: body === null ? undefined : body.toString('utf8'),
|
|
64
|
+
requestArguments: { opts, handler, body },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
handler.onError(error instanceof Error ? error : new Error(String(error)));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Record and passthrough: delegate to the dispatcher we displaced. */
|
|
72
|
+
async onFetchResponse(pollyRequest) {
|
|
73
|
+
const { opts, body } = pollyRequest.requestArguments;
|
|
74
|
+
const previous = this.previous;
|
|
75
|
+
if (previous === undefined) {
|
|
76
|
+
throw new Error('polly-kit: record mode found no underlying dispatcher to call');
|
|
77
|
+
}
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const chunks = [];
|
|
80
|
+
let statusCode = 0;
|
|
81
|
+
let headers = {};
|
|
82
|
+
// The original body was consumed to build the match key, so hand over
|
|
83
|
+
// the bytes we drained.
|
|
84
|
+
previous.dispatch({ ...opts, body }, {
|
|
85
|
+
onConnect: () => { },
|
|
86
|
+
onError: reject,
|
|
87
|
+
onHeaders(status, rawHeaders) {
|
|
88
|
+
statusCode = status;
|
|
89
|
+
headers = fromRawHeaders(rawHeaders);
|
|
90
|
+
return true;
|
|
91
|
+
},
|
|
92
|
+
onData(chunk) {
|
|
93
|
+
chunks.push(Buffer.from(chunk));
|
|
94
|
+
return true;
|
|
95
|
+
},
|
|
96
|
+
onComplete: () => {
|
|
97
|
+
const raw = Buffer.concat(chunks);
|
|
98
|
+
const decoded = decompress(raw, headers['content-encoding']);
|
|
99
|
+
const isBinary = !isUtf8Representable(decoded);
|
|
100
|
+
resolve({
|
|
101
|
+
statusCode,
|
|
102
|
+
// Headers are stripped only when something was actually
|
|
103
|
+
// decompressed, so an `identity` or unrecognised encoding
|
|
104
|
+
// keeps its headers and replays unchanged.
|
|
105
|
+
headers: decoded === raw ? headers : stripEncodingHeaders(headers),
|
|
106
|
+
encoding: isBinary ? 'base64' : undefined,
|
|
107
|
+
body: decoded.toString(isBinary ? 'base64' : 'utf8'),
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/** Deliver whatever Polly settled on to the caller's handler. */
|
|
114
|
+
async onRespond(pollyRequest, error) {
|
|
115
|
+
const request = pollyRequest;
|
|
116
|
+
const { handler } = request.requestArguments;
|
|
117
|
+
if (error) {
|
|
118
|
+
handler.onError(error);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (request.aborted === true) {
|
|
122
|
+
handler.onError(new Error('polly-kit: request aborted'));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const { statusCode = 0, headers = {}, body, encoding } = request.response ?? {};
|
|
126
|
+
const buffer = body === undefined || body === null
|
|
127
|
+
? Buffer.alloc(0)
|
|
128
|
+
: Buffer.from(body, encoding === 'base64' ? 'base64' : 'utf8');
|
|
129
|
+
handler.onConnect(() => { });
|
|
130
|
+
handler.onHeaders(statusCode, toRawHeaders(withRealLength(headers, buffer)), () => { }, '');
|
|
131
|
+
if (buffer.length > 0) {
|
|
132
|
+
handler.onData(buffer);
|
|
133
|
+
}
|
|
134
|
+
handler.onComplete([]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* `content-length`, corrected to the body actually being delivered.
|
|
139
|
+
*
|
|
140
|
+
* A recording is meant to be edited in polly-studio — that is why the editor
|
|
141
|
+
* exists — and an edited body no longer matches the length recorded with it.
|
|
142
|
+
* undici's dispatcher layer sits below HTTP framing so it does not enforce the
|
|
143
|
+
* header, which is what makes this quiet: the body arrives intact and the header
|
|
144
|
+
* lies about it. Anything downstream that trusts `content-length` then reads a
|
|
145
|
+
* wrong value.
|
|
146
|
+
*
|
|
147
|
+
* Only rewritten when the header is already there. A response recorded with
|
|
148
|
+
* `transfer-encoding: chunked` has no length, and inventing one alongside that
|
|
149
|
+
* header would contradict it.
|
|
150
|
+
*/
|
|
151
|
+
function withRealLength(headers, body) {
|
|
152
|
+
const name = Object.keys(headers).find((key) => key.toLowerCase() === 'content-length');
|
|
153
|
+
if (name === undefined) {
|
|
154
|
+
return headers;
|
|
155
|
+
}
|
|
156
|
+
return { ...headers, [name]: String(body.length) };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Polly's own check, reimplemented rather than imported: `@pollyjs/utils` ships no
|
|
160
|
+
* declaration for `isBufferUtf8Representable`, and this is the whole of it.
|
|
161
|
+
*/
|
|
162
|
+
function isUtf8Representable(buffer) {
|
|
163
|
+
return Buffer.compare(Buffer.from(buffer.toString('utf8'), 'utf8'), buffer) === 0;
|
|
164
|
+
}
|
|
165
|
+
function normalizeHeaders(headers) {
|
|
166
|
+
const out = {};
|
|
167
|
+
if (headers === undefined) {
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
if (Array.isArray(headers)) {
|
|
171
|
+
for (let i = 0; i + 1 < headers.length; i += 2) {
|
|
172
|
+
const name = headers[i];
|
|
173
|
+
const value = headers[i + 1];
|
|
174
|
+
if (name !== undefined && value !== undefined) {
|
|
175
|
+
out[name.toLowerCase()] = value;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
181
|
+
out[name.toLowerCase()] = String(value);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
/** `rawHeaders` is flat, alternating name/value Buffers. */
|
|
186
|
+
function fromRawHeaders(raw) {
|
|
187
|
+
const out = {};
|
|
188
|
+
for (let i = 0; i + 1 < raw.length; i += 2) {
|
|
189
|
+
const name = raw[i];
|
|
190
|
+
const value = raw[i + 1];
|
|
191
|
+
if (name !== undefined && value !== undefined) {
|
|
192
|
+
out[name.toString('latin1').toLowerCase()] = value.toString('latin1');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* HTTP header values are latin1 by spec, and `fromRawHeaders` decoded them as
|
|
199
|
+
* latin1 on the way in. Encoding with the default utf8 here corrupts every byte
|
|
200
|
+
* >= 0x80 on replay: the two sides must match.
|
|
201
|
+
*/
|
|
202
|
+
function toRawHeaders(headers) {
|
|
203
|
+
const raw = [];
|
|
204
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
205
|
+
raw.push(Buffer.from(name, 'latin1'), Buffer.from(String(value), 'latin1'));
|
|
206
|
+
}
|
|
207
|
+
return raw;
|
|
208
|
+
}
|
|
209
|
+
/** `fetch` always hands the dispatcher an async iterable, never a string. */
|
|
210
|
+
async function drain(body) {
|
|
211
|
+
if (body === null || body === undefined) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
if (typeof body === 'string') {
|
|
215
|
+
return Buffer.from(body);
|
|
216
|
+
}
|
|
217
|
+
if (Buffer.isBuffer(body)) {
|
|
218
|
+
return body;
|
|
219
|
+
}
|
|
220
|
+
if (body instanceof Uint8Array) {
|
|
221
|
+
return Buffer.from(body);
|
|
222
|
+
}
|
|
223
|
+
if (typeof body[Symbol.asyncIterator] === 'function') {
|
|
224
|
+
const chunks = [];
|
|
225
|
+
for await (const chunk of body) {
|
|
226
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk));
|
|
227
|
+
}
|
|
228
|
+
return Buffer.concat(chunks);
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
package/dist/browser.js
CHANGED
|
@@ -22,6 +22,7 @@ import './global-shim.js';
|
|
|
22
22
|
import { Polly } from '@pollyjs/core';
|
|
23
23
|
import XHRAdapter from '@pollyjs/adapter-xhr';
|
|
24
24
|
import RestPersister from '@pollyjs/persister-rest';
|
|
25
|
+
import { KEEP_UNUSED_REQUESTS, POLLY_DEFAULTS } from './defaults.js';
|
|
25
26
|
import { applyPolicy } from './policy.js';
|
|
26
27
|
export { toOrigin, toOrigins } from './policy.js';
|
|
27
28
|
const MODES = ['off', 'record', 'replay'];
|
|
@@ -72,27 +73,12 @@ export function startPolly(options) {
|
|
|
72
73
|
adapters: ['xhr'],
|
|
73
74
|
persister: 'rest',
|
|
74
75
|
persisterOptions: {
|
|
75
|
-
|
|
76
|
-
// this session. Exercising one screen would throw away every other
|
|
77
|
-
// screen's recording.
|
|
78
|
-
keepUnusedRequests: true,
|
|
76
|
+
keepUnusedRequests: KEEP_UNUSED_REQUESTS,
|
|
79
77
|
rest: { host: window.location.origin, apiNamespace },
|
|
80
78
|
},
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
// A 401 or a 500 is worth having on disk. The default drops it, and the
|
|
85
|
-
// gap only surfaces later as a replay miss with no obvious cause.
|
|
86
|
-
recordFailedRequests: true,
|
|
87
|
-
matchRequestsBy: {
|
|
88
|
-
// Screens refetch, and the same request is issued many times. With
|
|
89
|
-
// `order: true` the second one looks for a second recording and misses.
|
|
90
|
-
order: false,
|
|
91
|
-
// Apps attach bearer tokens and per-session headers. Matching on
|
|
92
|
-
// headers would make a recording valid only for the session that
|
|
93
|
-
// produced it.
|
|
94
|
-
headers: false,
|
|
95
|
-
},
|
|
79
|
+
// The opinionated defaults, shared with the Node entry so the two cannot
|
|
80
|
+
// drift. Each one's reasoning is in defaults.ts.
|
|
81
|
+
...POLLY_DEFAULTS,
|
|
96
82
|
});
|
|
97
83
|
applyPolicy(polly.server, { policy, alwaysPassthrough });
|
|
98
84
|
if (mode === 'record') {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Polly options that differ from Polly's own defaults, shared by the browser
|
|
3
|
+
* and Node entry points.
|
|
4
|
+
*
|
|
5
|
+
* They live here because they are the package's opinion, and an opinion that
|
|
6
|
+
* holds in one runtime and not the other is a bug nobody notices: a replay miss
|
|
7
|
+
* that silently hits the network looks exactly like replay working.
|
|
8
|
+
*/
|
|
9
|
+
export declare const POLLY_DEFAULTS: {
|
|
10
|
+
/**
|
|
11
|
+
* A replay miss must fail loudly. Polly's default `true` quietly goes to the
|
|
12
|
+
* network and records what comes back, which is indistinguishable from
|
|
13
|
+
* replay working.
|
|
14
|
+
*/
|
|
15
|
+
readonly recordIfMissing: false;
|
|
16
|
+
/**
|
|
17
|
+
* A 401 or a 500 is worth having on disk. The default drops it, and the gap
|
|
18
|
+
* only surfaces later as a replay miss with no obvious cause.
|
|
19
|
+
*/
|
|
20
|
+
readonly recordFailedRequests: true;
|
|
21
|
+
readonly matchRequestsBy: {
|
|
22
|
+
/**
|
|
23
|
+
* Screens refetch and clients retry, so the same request is issued many
|
|
24
|
+
* times. With `order: true` the second one looks for a second recording
|
|
25
|
+
* and misses.
|
|
26
|
+
*/
|
|
27
|
+
readonly order: false;
|
|
28
|
+
/**
|
|
29
|
+
* Apps attach bearer tokens and per-session headers. Matching on headers
|
|
30
|
+
* would make a recording valid only for the session that produced it.
|
|
31
|
+
*/
|
|
32
|
+
readonly headers: false;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Without this, every save drops the entries that were not replayed this
|
|
37
|
+
* session. Exercising one screen would throw away every other screen's
|
|
38
|
+
* recording.
|
|
39
|
+
*/
|
|
40
|
+
export declare const KEEP_UNUSED_REQUESTS = true;
|
package/dist/defaults.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Polly options that differ from Polly's own defaults, shared by the browser
|
|
3
|
+
* and Node entry points.
|
|
4
|
+
*
|
|
5
|
+
* They live here because they are the package's opinion, and an opinion that
|
|
6
|
+
* holds in one runtime and not the other is a bug nobody notices: a replay miss
|
|
7
|
+
* that silently hits the network looks exactly like replay working.
|
|
8
|
+
*/
|
|
9
|
+
export const POLLY_DEFAULTS = {
|
|
10
|
+
/**
|
|
11
|
+
* A replay miss must fail loudly. Polly's default `true` quietly goes to the
|
|
12
|
+
* network and records what comes back, which is indistinguishable from
|
|
13
|
+
* replay working.
|
|
14
|
+
*/
|
|
15
|
+
recordIfMissing: false,
|
|
16
|
+
/**
|
|
17
|
+
* A 401 or a 500 is worth having on disk. The default drops it, and the gap
|
|
18
|
+
* only surfaces later as a replay miss with no obvious cause.
|
|
19
|
+
*/
|
|
20
|
+
recordFailedRequests: true,
|
|
21
|
+
matchRequestsBy: {
|
|
22
|
+
/**
|
|
23
|
+
* Screens refetch and clients retry, so the same request is issued many
|
|
24
|
+
* times. With `order: true` the second one looks for a second recording
|
|
25
|
+
* and misses.
|
|
26
|
+
*/
|
|
27
|
+
order: false,
|
|
28
|
+
/**
|
|
29
|
+
* Apps attach bearer tokens and per-session headers. Matching on headers
|
|
30
|
+
* would make a recording valid only for the session that produced it.
|
|
31
|
+
*/
|
|
32
|
+
headers: false,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Without this, every save drops the entries that were not replayed this
|
|
37
|
+
* session. Exercising one screen would throw away every other screen's
|
|
38
|
+
* recording.
|
|
39
|
+
*/
|
|
40
|
+
export const KEEP_UNUSED_REQUESTS = true;
|