@avivbenhamo/polly-kit 0.3.0 → 0.6.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 -21
- package/README.md +298 -276
- package/dist/browser.d.ts +13 -5
- package/dist/browser.js +16 -6
- package/dist/cli.d.ts +21 -0
- package/dist/cli.js +141 -0
- package/dist/studio/ui/render.js +33 -33
- package/dist/vite.d.ts +0 -17
- package/dist/vite.js +43 -6
- package/package.json +8 -2
package/LICENSE
CHANGED
|
@@ -1,21 +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.
|
|
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,276 +1,298 @@
|
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
##
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
`
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
`
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
`
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
**
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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.
|
package/dist/browser.d.ts
CHANGED
|
@@ -5,11 +5,19 @@
|
|
|
5
5
|
* first request goes out. Import the module that calls it FIRST in your entry
|
|
6
6
|
* point — see the README.
|
|
7
7
|
*
|
|
8
|
-
* WHY
|
|
9
|
-
*
|
|
10
|
-
* adapter
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* WHY BOTH ADAPTERS. A browser has two request transports and an app can use
|
|
9
|
+
* either, so covering one is covering half the app — silently, because an
|
|
10
|
+
* adapter attaches cleanly whether or not any traffic reaches it.
|
|
11
|
+
*
|
|
12
|
+
* - `adapter-xhr` patches `window.XMLHttpRequest`. axios resolves its adapter
|
|
13
|
+
* in the order `['xhr', 'http', 'fetch']`, so in a browser it uses XHR —
|
|
14
|
+
* which means no axios instance in the host app needs to change.
|
|
15
|
+
* - `adapter-fetch` patches `window.fetch`, which is what application code
|
|
16
|
+
* written against the platform uses directly.
|
|
17
|
+
*
|
|
18
|
+
* They patch different globals and do not interact. This mirrors `./node`, which
|
|
19
|
+
* has always intercepted both of its transports (`http(s)` and `fetch`) for the
|
|
20
|
+
* same reason.
|
|
13
21
|
*
|
|
14
22
|
* Nothing here reads the environment. The caller resolves the mode and passes it
|
|
15
23
|
* in, which is what keeps this file free of any bundler's env API.
|
package/dist/browser.js
CHANGED
|
@@ -5,11 +5,19 @@
|
|
|
5
5
|
* first request goes out. Import the module that calls it FIRST in your entry
|
|
6
6
|
* point — see the README.
|
|
7
7
|
*
|
|
8
|
-
* WHY
|
|
9
|
-
*
|
|
10
|
-
* adapter
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* WHY BOTH ADAPTERS. A browser has two request transports and an app can use
|
|
9
|
+
* either, so covering one is covering half the app — silently, because an
|
|
10
|
+
* adapter attaches cleanly whether or not any traffic reaches it.
|
|
11
|
+
*
|
|
12
|
+
* - `adapter-xhr` patches `window.XMLHttpRequest`. axios resolves its adapter
|
|
13
|
+
* in the order `['xhr', 'http', 'fetch']`, so in a browser it uses XHR —
|
|
14
|
+
* which means no axios instance in the host app needs to change.
|
|
15
|
+
* - `adapter-fetch` patches `window.fetch`, which is what application code
|
|
16
|
+
* written against the platform uses directly.
|
|
17
|
+
*
|
|
18
|
+
* They patch different globals and do not interact. This mirrors `./node`, which
|
|
19
|
+
* has always intercepted both of its transports (`http(s)` and `fetch`) for the
|
|
20
|
+
* same reason.
|
|
13
21
|
*
|
|
14
22
|
* Nothing here reads the environment. The caller resolves the mode and passes it
|
|
15
23
|
* in, which is what keeps this file free of any bundler's env API.
|
|
@@ -20,6 +28,7 @@
|
|
|
20
28
|
// rather than in the host app so the host app cannot get the order wrong.
|
|
21
29
|
import './global-shim.js';
|
|
22
30
|
import { Polly } from '@pollyjs/core';
|
|
31
|
+
import FetchAdapter from '@pollyjs/adapter-fetch';
|
|
23
32
|
import XHRAdapter from '@pollyjs/adapter-xhr';
|
|
24
33
|
import RestPersister from '@pollyjs/persister-rest';
|
|
25
34
|
import { KEEP_UNUSED_REQUESTS, POLLY_DEFAULTS } from './defaults.js';
|
|
@@ -65,12 +74,13 @@ export function startPolly(options) {
|
|
|
65
74
|
}
|
|
66
75
|
if (!registered) {
|
|
67
76
|
Polly.register(XHRAdapter);
|
|
77
|
+
Polly.register(FetchAdapter);
|
|
68
78
|
Polly.register(RestPersister);
|
|
69
79
|
registered = true;
|
|
70
80
|
}
|
|
71
81
|
const polly = new Polly(recordingName, {
|
|
72
82
|
mode,
|
|
73
|
-
adapters: ['xhr'],
|
|
83
|
+
adapters: ['xhr', 'fetch'],
|
|
74
84
|
persister: 'rest',
|
|
75
85
|
persisterOptions: {
|
|
76
86
|
keepUnusedRequests: KEEP_UNUSED_REQUESTS,
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export declare function main(argv: string[]): Promise<number>;
|
|
3
|
+
export interface StudioOptions {
|
|
4
|
+
recordingsDir: string;
|
|
5
|
+
port?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface Studio {
|
|
8
|
+
/** The port it actually got, which is not always the one asked for. */
|
|
9
|
+
port: number;
|
|
10
|
+
close(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The server, without the process around it.
|
|
14
|
+
*
|
|
15
|
+
* Separate from `main` because stopping it has to be something a caller does,
|
|
16
|
+
* not something a signal does: `process.once('SIGINT')` listeners are global,
|
|
17
|
+
* so a second studio started in the same process would be stopped by the first
|
|
18
|
+
* one's Ctrl-C. That is only ever a test, but it is also the shape anything
|
|
19
|
+
* embedding this would want.
|
|
20
|
+
*/
|
|
21
|
+
export declare function startStudio(options: StudioOptions): Promise<Studio>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The studio, served on its own, for a checkout with no app running.
|
|
4
|
+
*
|
|
5
|
+
* The mounted form stays the default and is the better one when there *is* a dev
|
|
6
|
+
* server: `app.use('/__polly', pollyUi(...))` — or the Vite plugin, which does
|
|
7
|
+
* it for you — costs no second process, no second port and no CORS, and it goes
|
|
8
|
+
* away when the dev server does.
|
|
9
|
+
*
|
|
10
|
+
* This exists for the times there is no dev server to mount on. Reviewing the
|
|
11
|
+
* recordings in a pull request, pruning a stale one on a CI box, opening a
|
|
12
|
+
* colleague's `recordings/` without installing their app's dependencies: all of
|
|
13
|
+
* them are file-editing, and the editor is already a plain handler over a
|
|
14
|
+
* directory, so they do not need the app at all.
|
|
15
|
+
*
|
|
16
|
+
* What this *cannot* serve is the browser case. `mountPollyStudio` edits
|
|
17
|
+
* recordings held in the page's own localStorage, and no separate process can
|
|
18
|
+
* see those.
|
|
19
|
+
*/
|
|
20
|
+
import { createServer } from 'node:http';
|
|
21
|
+
import { existsSync } from 'node:fs';
|
|
22
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { pollyUi } from './studio/server.js';
|
|
25
|
+
const DEFAULT_PORT = 7788;
|
|
26
|
+
const DEFAULT_DIR = 'recordings';
|
|
27
|
+
/**
|
|
28
|
+
* How far to walk up from the asked-for port before giving up. A studio left
|
|
29
|
+
* open in another window is the normal case, and `EADDRINUSE` with a stack
|
|
30
|
+
* trace is a poor answer to it.
|
|
31
|
+
*/
|
|
32
|
+
const PORT_TRIES = 20;
|
|
33
|
+
const USAGE = `polly-kit — Polly recordings, in a browser
|
|
34
|
+
|
|
35
|
+
npx polly-kit studio [--dir DIR] [--port PORT]
|
|
36
|
+
|
|
37
|
+
studio Serve the recording editor on 127.0.0.1. Reads and writes the HAR
|
|
38
|
+
files under DIR (default ${DEFAULT_DIR}), the same ones the mounted
|
|
39
|
+
editor edits.
|
|
40
|
+
|
|
41
|
+
Mounted in a dev server instead — no second port, and it stops when the server
|
|
42
|
+
does — this is the Vite plugin, or app.use('/__polly', pollyUi({recordingsDir})).
|
|
43
|
+
Recordings held in a browser's localStorage are reachable only from the page
|
|
44
|
+
that owns them, through mountPollyStudio.
|
|
45
|
+
`;
|
|
46
|
+
export async function main(argv) {
|
|
47
|
+
const command = argv[0] ?? 'help';
|
|
48
|
+
if (command === 'help' || command === '--help' || command === '-h') {
|
|
49
|
+
process.stdout.write(USAGE);
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
if (command !== 'studio') {
|
|
53
|
+
process.stderr.write(`unknown command: ${command}\n\n${USAGE}`);
|
|
54
|
+
return 2;
|
|
55
|
+
}
|
|
56
|
+
const dir = flag(argv, 'dir') ?? DEFAULT_DIR;
|
|
57
|
+
const root = isAbsolute(dir) ? dir : join(process.cwd(), dir);
|
|
58
|
+
const port = Number(flag(argv, 'port') ?? DEFAULT_PORT);
|
|
59
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
60
|
+
process.stderr.write(`--port must be a port number, not "${flag(argv, 'port')}"\n`);
|
|
61
|
+
return 2;
|
|
62
|
+
}
|
|
63
|
+
// Said, not refused. An empty or missing directory is what you get before the
|
|
64
|
+
// first recording, and the editor explains that better than an exit code —
|
|
65
|
+
// but the path is printed either way, because "no recordings" almost always
|
|
66
|
+
// means the studio is looking somewhere else than the app records to.
|
|
67
|
+
if (!existsSync(root)) {
|
|
68
|
+
process.stdout.write(`no ${resolve(root)} yet — the editor will be empty until something records\n`);
|
|
69
|
+
}
|
|
70
|
+
const studio = await startStudio({ recordingsDir: root, port });
|
|
71
|
+
if (studio.port !== port)
|
|
72
|
+
process.stdout.write(`port ${port} was busy — using ${studio.port}\n`);
|
|
73
|
+
process.stdout.write(`polly studio on http://127.0.0.1:${studio.port}\n`);
|
|
74
|
+
process.stdout.write(`recordings: ${resolve(root)}\n`);
|
|
75
|
+
await new Promise((done) => {
|
|
76
|
+
process.once('SIGINT', () => done());
|
|
77
|
+
process.once('SIGTERM', () => done());
|
|
78
|
+
});
|
|
79
|
+
await studio.close();
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The server, without the process around it.
|
|
84
|
+
*
|
|
85
|
+
* Separate from `main` because stopping it has to be something a caller does,
|
|
86
|
+
* not something a signal does: `process.once('SIGINT')` listeners are global,
|
|
87
|
+
* so a second studio started in the same process would be stopped by the first
|
|
88
|
+
* one's Ctrl-C. That is only ever a test, but it is also the shape anything
|
|
89
|
+
* embedding this would want.
|
|
90
|
+
*/
|
|
91
|
+
export async function startStudio(options) {
|
|
92
|
+
// `enabled` is forced on. The mounted handler defaults it to NODE_ENV not
|
|
93
|
+
// being production, because something mounted into an app can be deployed by
|
|
94
|
+
// accident and recordings hold recorded request and response bodies. Nobody
|
|
95
|
+
// deploys an npx invocation by accident, and a shell that happens to export
|
|
96
|
+
// NODE_ENV=production would otherwise serve 404s with no explanation.
|
|
97
|
+
const server = createServer(pollyUi({ recordingsDir: options.recordingsDir, enabled: true }));
|
|
98
|
+
const port = await listenNearby(server, options.port ?? DEFAULT_PORT);
|
|
99
|
+
return {
|
|
100
|
+
port,
|
|
101
|
+
close: () => new Promise((done) => server.close(() => done())),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** Loopback only: this serves recorded bodies, and it is a tool for one person. */
|
|
105
|
+
async function listenNearby(server, port) {
|
|
106
|
+
for (let offset = 0; offset < PORT_TRIES; offset++) {
|
|
107
|
+
try {
|
|
108
|
+
await new Promise((done, fail) => {
|
|
109
|
+
server.once('error', fail);
|
|
110
|
+
server.listen(port + offset, '127.0.0.1', () => {
|
|
111
|
+
server.removeListener('error', fail);
|
|
112
|
+
done();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
return port + offset;
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err.code !== 'EADDRINUSE')
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`ports ${port}-${port + PORT_TRIES - 1} are all in use. Pass --port to pick another.`);
|
|
123
|
+
}
|
|
124
|
+
/** `--dir recordings` and `--dir=recordings`, both. */
|
|
125
|
+
function flag(argv, name) {
|
|
126
|
+
const index = argv.indexOf(`--${name}`);
|
|
127
|
+
if (index !== -1)
|
|
128
|
+
return argv[index + 1];
|
|
129
|
+
const inline = argv.find((arg) => arg.startsWith(`--${name}=`));
|
|
130
|
+
return inline === undefined ? undefined : inline.slice(name.length + 3);
|
|
131
|
+
}
|
|
132
|
+
// Only when run as a program. Importing this from a test must not start a
|
|
133
|
+
// server — and the comparison is through fileURLToPath rather than string
|
|
134
|
+
// matching on the URL, because on Windows argv[1] is `C:\...\cli.js` and
|
|
135
|
+
// import.meta.url is `file:///C:/.../cli.js`.
|
|
136
|
+
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
137
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
|
|
138
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
});
|
|
141
|
+
}
|
package/dist/studio/ui/render.js
CHANGED
|
@@ -134,38 +134,38 @@ function entryDetail(entry) {
|
|
|
134
134
|
const headers = response.headers
|
|
135
135
|
.map((header, index) => `<div class="header-row"><input name="headerName${index}" value="${escapeHtml(header.name)}" class="ps-header-name" spellcheck="false" aria-label="Header name"><input name="headerValue${index}" value="${escapeHtml(header.value)}" class="ps-header-value" spellcheck="false" aria-label="Header value"></div>`)
|
|
136
136
|
.join('');
|
|
137
|
-
return `
|
|
138
|
-
<section class="detail" data-id="${escapeHtml(entry._id)}" data-order="${renderNumeric(entry._order)}">
|
|
139
|
-
<div class="ps-block ps-block--request">
|
|
140
|
-
<h2>Request <small>read-only</small></h2>
|
|
141
|
-
<p class="note">Polly matches a replayed request on <code>_id</code>, an md5 of the request built
|
|
142
|
-
from your <code>matchRequestsBy</code> config — and that config is not stored in the recording.
|
|
143
|
-
Editing any of this would leave an entry that looks right and never matches again.</p>
|
|
144
|
-
<dl>
|
|
145
|
-
<dt>Method</dt><dd>${escapeHtml(entry.request.method)}</dd>
|
|
146
|
-
<dt>URL</dt><dd>${escapeHtml(entry.request.url)}</dd>
|
|
147
|
-
<dt>Recorded</dt><dd>${escapeHtml(entry.startedDateTime)}</dd>
|
|
148
|
-
<dt>_id / _order</dt><dd><code>${escapeHtml(entry._id)}</code> / ${renderNumeric(entry._order)}</dd>
|
|
149
|
-
</dl>
|
|
150
|
-
</div>
|
|
151
|
-
|
|
152
|
-
<div class="ps-block ps-block--response">
|
|
153
|
-
<h2>Response</h2>
|
|
154
|
-
<div class="ps-fields">
|
|
155
|
-
<label class="ps-field"><span class="ps-field-label">Status</span><input name="status" type="number" value="${renderNumeric(response.status)}" class="ps-input" min="100" max="599"></label>
|
|
156
|
-
<label class="ps-field"><span class="ps-field-label">Status text</span><input name="statusText" value="${escapeHtml(response.statusText)}" class="ps-input" spellcheck="false"></label>
|
|
157
|
-
</div>
|
|
158
|
-
|
|
159
|
-
<h3 class="ps-group-title">Headers</h3>
|
|
160
|
-
${headers === '' ? '<p class="empty">This response was recorded without headers.</p>' : `<div class="headers">${headers}</div>`}
|
|
161
|
-
|
|
162
|
-
<h3 class="ps-group-title">Body</h3>
|
|
163
|
-
${body}
|
|
164
|
-
|
|
165
|
-
<div class="actions">
|
|
166
|
-
<button name="save">Save response</button>
|
|
167
|
-
<button name="delete" class="danger">Delete entry</button>
|
|
168
|
-
</div>
|
|
169
|
-
</div>
|
|
137
|
+
return `
|
|
138
|
+
<section class="detail" data-id="${escapeHtml(entry._id)}" data-order="${renderNumeric(entry._order)}">
|
|
139
|
+
<div class="ps-block ps-block--request">
|
|
140
|
+
<h2>Request <small>read-only</small></h2>
|
|
141
|
+
<p class="note">Polly matches a replayed request on <code>_id</code>, an md5 of the request built
|
|
142
|
+
from your <code>matchRequestsBy</code> config — and that config is not stored in the recording.
|
|
143
|
+
Editing any of this would leave an entry that looks right and never matches again.</p>
|
|
144
|
+
<dl>
|
|
145
|
+
<dt>Method</dt><dd>${escapeHtml(entry.request.method)}</dd>
|
|
146
|
+
<dt>URL</dt><dd>${escapeHtml(entry.request.url)}</dd>
|
|
147
|
+
<dt>Recorded</dt><dd>${escapeHtml(entry.startedDateTime)}</dd>
|
|
148
|
+
<dt>_id / _order</dt><dd><code>${escapeHtml(entry._id)}</code> / ${renderNumeric(entry._order)}</dd>
|
|
149
|
+
</dl>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div class="ps-block ps-block--response">
|
|
153
|
+
<h2>Response</h2>
|
|
154
|
+
<div class="ps-fields">
|
|
155
|
+
<label class="ps-field"><span class="ps-field-label">Status</span><input name="status" type="number" value="${renderNumeric(response.status)}" class="ps-input" min="100" max="599"></label>
|
|
156
|
+
<label class="ps-field"><span class="ps-field-label">Status text</span><input name="statusText" value="${escapeHtml(response.statusText)}" class="ps-input" spellcheck="false"></label>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<h3 class="ps-group-title">Headers</h3>
|
|
160
|
+
${headers === '' ? '<p class="empty">This response was recorded without headers.</p>' : `<div class="headers">${headers}</div>`}
|
|
161
|
+
|
|
162
|
+
<h3 class="ps-group-title">Body</h3>
|
|
163
|
+
${body}
|
|
164
|
+
|
|
165
|
+
<div class="actions">
|
|
166
|
+
<button name="save">Save response</button>
|
|
167
|
+
<button name="delete" class="danger">Delete entry</button>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
170
|
</section>`;
|
|
171
171
|
}
|
package/dist/vite.d.ts
CHANGED
|
@@ -1,20 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One Vite plugin: the recording store plus the polly-studio editor.
|
|
3
|
-
*
|
|
4
|
-
* Two plugins would be honest about the two concerns, but they always go
|
|
5
|
-
* together and each needed the same `recordingsDir` — so the host app's
|
|
6
|
-
* vite.config gains one import and one line.
|
|
7
|
-
*
|
|
8
|
-
* WHY THE STUDIO'S `basePath` IS NOT SET. Connect strips the mount path before
|
|
9
|
-
* the handler runs — `GET /__polly/api/recordings` arrives as
|
|
10
|
-
* `/api/recordings` — so the handler must not strip it a second time. The
|
|
11
|
-
* browser side does not need to be told either: the page derives its API base
|
|
12
|
-
* from `location.pathname`. Set `basePath` here and every API call 404s.
|
|
13
|
-
*
|
|
14
|
-
* Dev only, deliberately. This hooks `configureServer`, which a production build
|
|
15
|
-
* never calls, and `pollyUi()` disables itself when `NODE_ENV === 'production'`
|
|
16
|
-
* anyway — recordings hold real recorded request and response bodies.
|
|
17
|
-
*/
|
|
18
1
|
import type { Plugin } from 'vite';
|
|
19
2
|
export interface PollyKitOptions {
|
|
20
3
|
/** Where HARs are read from and written to. Gitignore it. */
|
package/dist/vite.js
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One Vite plugin: the recording store plus the polly-studio editor.
|
|
3
|
+
*
|
|
4
|
+
* Two plugins would be honest about the two concerns, but they always go
|
|
5
|
+
* together and each needed the same `recordingsDir` — so the host app's
|
|
6
|
+
* vite.config gains one import and one line.
|
|
7
|
+
*
|
|
8
|
+
* WHY THE STUDIO'S `basePath` IS NOT SET. Connect strips the mount path before
|
|
9
|
+
* the handler runs — `GET /__polly/api/recordings` arrives as
|
|
10
|
+
* `/api/recordings` — so the handler must not strip it a second time. The
|
|
11
|
+
* browser side does not need to be told either: the page derives its API base
|
|
12
|
+
* from `location.pathname`. Set `basePath` here and every API call 404s.
|
|
13
|
+
*
|
|
14
|
+
* Dev only, deliberately. This hooks `configureServer`, which a production build
|
|
15
|
+
* never calls, and `pollyUi()` disables itself when `NODE_ENV === 'production'`
|
|
16
|
+
* anyway — recordings hold real recorded request and response bodies.
|
|
17
|
+
*/
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
1
19
|
import { pollyUi } from './studio/index.js';
|
|
2
20
|
import { recordingStore, STORE_NAMESPACE } from './store.js';
|
|
3
21
|
const STUDIO_PATH = '/__polly';
|
|
22
|
+
/**
|
|
23
|
+
* The npm `buffer` package, resolved to an absolute path from THIS file.
|
|
24
|
+
*
|
|
25
|
+
* Resolving it from here rather than aliasing to the bare specifier `buffer/`
|
|
26
|
+
* matters twice. Vite warns on a non-absolute alias replacement because it
|
|
27
|
+
* cannot dedupe the result ("will lead to duplicated modules"). And the bare
|
|
28
|
+
* form is resolved from the consuming app's root, so it only works when the
|
|
29
|
+
* installer has hoisted `buffer` out of this package and into theirs. Measured
|
|
30
|
+
* with a `file:` link, where npm does not: the dep optimizer fails with "Cannot
|
|
31
|
+
* optimize dependency: buffer" and every response then errors on `Buffer.from`.
|
|
32
|
+
* A strict node_modules layout (pnpm's default, Yarn PnP) should hit the same
|
|
33
|
+
* thing for the same reason — not measured.
|
|
34
|
+
*
|
|
35
|
+
* `buffer` is a dependency of this package, so this resolution always succeeds.
|
|
36
|
+
*/
|
|
37
|
+
const BUFFER_PATH = createRequire(import.meta.url).resolve('buffer/');
|
|
4
38
|
export default function pollyKit(options = {}) {
|
|
5
39
|
const { recordingsDir = 'recordings', studioPath = STUDIO_PATH, apiNamespace = STORE_NAMESPACE, studio = true, } = options;
|
|
6
40
|
return {
|
|
@@ -39,18 +73,21 @@ export default function pollyKit(options = {}) {
|
|
|
39
73
|
// resolves to a stub and every response errors with
|
|
40
74
|
// "Cannot read properties of undefined (reading 'from')".
|
|
41
75
|
//
|
|
42
|
-
// The
|
|
43
|
-
// `buffer
|
|
44
|
-
//
|
|
76
|
+
// The anchored regex keeps it from also rewriting
|
|
77
|
+
// `buffer/...`; see BUFFER_PATH for why the replacement is
|
|
78
|
+
// an absolute path from this package rather than `buffer/`.
|
|
45
79
|
find: /^buffer$/,
|
|
46
|
-
replacement:
|
|
80
|
+
replacement: BUFFER_PATH,
|
|
47
81
|
},
|
|
48
82
|
],
|
|
49
83
|
},
|
|
50
84
|
optimizeDeps: {
|
|
51
85
|
// `buffer`: pre-bundled alongside the Polly packages that import
|
|
52
86
|
// it, so the dep optimizer does not treat it as an unresolved
|
|
53
|
-
// external on a cold start.
|
|
87
|
+
// external on a cold start. By absolute path for the same reason
|
|
88
|
+
// the alias is — a bare `'buffer'` here is resolved from the
|
|
89
|
+
// consuming app's root, which has no `buffer` unless npm hoisted
|
|
90
|
+
// ours into it.
|
|
54
91
|
//
|
|
55
92
|
// This package itself: without it, Vite discovers the dependency
|
|
56
93
|
// when the entry module first requests it, optimizes, and triggers
|
|
@@ -60,7 +97,7 @@ export default function pollyKit(options = {}) {
|
|
|
60
97
|
// `.vite` cache missed every request, the reload after it recorded
|
|
61
98
|
// fine. Naming it here makes the optimizer run before the server
|
|
62
99
|
// accepts requests, so the first load is the optimized one.
|
|
63
|
-
include: [
|
|
100
|
+
include: [BUFFER_PATH, '@avivbenhamo/polly-kit'],
|
|
64
101
|
// The `define` above does not reach pre-bundled dependencies, and
|
|
65
102
|
// persister-rest is one. This is the half that actually fixes it.
|
|
66
103
|
esbuildOptions: { define: { global: 'globalThis' } },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avivbenhamo/polly-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Drop-in record/replay of an app's HTTP traffic with Polly.JS, plus the polly-studio editor. Bring your own host policy.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"dist",
|
|
13
13
|
"README.md"
|
|
14
14
|
],
|
|
15
|
+
"//bin": "The studio on its own, for a checkout with no dev server to mount it in. The mounted form stays the default.",
|
|
16
|
+
"bin": {
|
|
17
|
+
"polly-kit": "./dist/cli.js"
|
|
18
|
+
},
|
|
15
19
|
"main": "./dist/browser.js",
|
|
16
20
|
"types": "./dist/browser.d.ts",
|
|
17
21
|
"//typesVersions": "For consumers still on moduleResolution 'node', which ignores `exports`. Drop once everything is on 'bundler'.",
|
|
@@ -79,10 +83,12 @@
|
|
|
79
83
|
"bundle": "esbuild dist/studio/ui/client.js --bundle --format=esm --target=es2022 --outfile=dist/studio/client.bundle.js",
|
|
80
84
|
"fixture": "node --experimental-strip-types --disable-warning=ExperimentalWarning test/studio/fixtures/record.ts",
|
|
81
85
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
82
|
-
"test": "npm run build && node --experimental-strip-types --disable-warning=ExperimentalWarning --test \"test/**/*.test.ts\""
|
|
86
|
+
"test": "npm run build && node --experimental-strip-types --disable-warning=ExperimentalWarning --test \"test/**/*.test.ts\"",
|
|
87
|
+
"studio": "node --experimental-strip-types --disable-warning=ExperimentalWarning src/cli.ts studio"
|
|
83
88
|
},
|
|
84
89
|
"dependencies": {
|
|
85
90
|
"@pollyjs/adapter": "^6.0.6",
|
|
91
|
+
"@pollyjs/adapter-fetch": "^6.0.7",
|
|
86
92
|
"@pollyjs/adapter-node-http": "^6.0.6",
|
|
87
93
|
"@pollyjs/adapter-xhr": "^6.0.6",
|
|
88
94
|
"@pollyjs/core": "^6.0.6",
|