@avivbenhamo/polly-kit 0.1.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +298 -138
- 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.d.ts +13 -5
- package/dist/browser.js +21 -25
- package/dist/cli.d.ts +21 -0
- package/dist/cli.js +141 -0
- 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.d.ts +0 -17
- package/dist/vite.js +44 -7
- package/package.json +55 -7
|
@@ -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.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,8 +28,10 @@
|
|
|
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';
|
|
34
|
+
import { KEEP_UNUSED_REQUESTS, POLLY_DEFAULTS } from './defaults.js';
|
|
25
35
|
import { applyPolicy } from './policy.js';
|
|
26
36
|
export { toOrigin, toOrigins } from './policy.js';
|
|
27
37
|
const MODES = ['off', 'record', 'replay'];
|
|
@@ -64,35 +74,21 @@ export function startPolly(options) {
|
|
|
64
74
|
}
|
|
65
75
|
if (!registered) {
|
|
66
76
|
Polly.register(XHRAdapter);
|
|
77
|
+
Polly.register(FetchAdapter);
|
|
67
78
|
Polly.register(RestPersister);
|
|
68
79
|
registered = true;
|
|
69
80
|
}
|
|
70
81
|
const polly = new Polly(recordingName, {
|
|
71
82
|
mode,
|
|
72
|
-
adapters: ['xhr'],
|
|
83
|
+
adapters: ['xhr', 'fetch'],
|
|
73
84
|
persister: 'rest',
|
|
74
85
|
persisterOptions: {
|
|
75
|
-
|
|
76
|
-
// this session. Exercising one screen would throw away every other
|
|
77
|
-
// screen's recording.
|
|
78
|
-
keepUnusedRequests: true,
|
|
86
|
+
keepUnusedRequests: KEEP_UNUSED_REQUESTS,
|
|
79
87
|
rest: { host: window.location.origin, apiNamespace },
|
|
80
88
|
},
|
|
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
|
-
},
|
|
89
|
+
// The opinionated defaults, shared with the Node entry so the two cannot
|
|
90
|
+
// drift. Each one's reasoning is in defaults.ts.
|
|
91
|
+
...POLLY_DEFAULTS,
|
|
96
92
|
});
|
|
97
93
|
applyPolicy(polly.server, { policy, alwaysPassthrough });
|
|
98
94
|
if (mode === 'record') {
|
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
|
+
}
|
|
@@ -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;
|
package/dist/node.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record and replay a Node app's HTTP calls with Polly.JS.
|
|
3
|
+
*
|
|
4
|
+
* The browser entry's sibling. Two differences that matter:
|
|
5
|
+
*
|
|
6
|
+
* 1. `persister-fs` writes the HAR straight to disk, so there is no recording
|
|
7
|
+
* store to host and no `apiNamespace` to keep in sync with a Vite plugin.
|
|
8
|
+
* 2. The adapters are PROCESS-WIDE. They also see this app talking to its
|
|
9
|
+
* database, its broker, its health checks and its telemetry collector.
|
|
10
|
+
* `{kind: 'all'}` on a server mocks Postgres. Prefer `{kind: 'only'}` and
|
|
11
|
+
* read the policy line this logs at startup.
|
|
12
|
+
*
|
|
13
|
+
* `global-shim.js` is deliberately NOT imported here. It works around
|
|
14
|
+
* `@pollyjs/persister-rest` reading an unbound `global`, which this entry does
|
|
15
|
+
* not use, and assigning `global` in a real Node process is not harmless.
|
|
16
|
+
*/
|
|
17
|
+
import { Polly } from '@pollyjs/core';
|
|
18
|
+
import { type PollyPolicyConfig } from './policy.js';
|
|
19
|
+
export type { HostPolicy, PollyPolicyConfig } from './policy.js';
|
|
20
|
+
export { toOrigin, toOrigins } from './policy.js';
|
|
21
|
+
/**
|
|
22
|
+
* Re-exported so an app mounting the editor installs and imports one package. On
|
|
23
|
+
* the Vite path this package mounts polly-studio itself; on a server the app owns
|
|
24
|
+
* its own middleware chain, and reaching into a transitive dependency for
|
|
25
|
+
* `pollyUi` would work only by hoisting luck.
|
|
26
|
+
*/
|
|
27
|
+
export { pollyUi } from './studio/index.js';
|
|
28
|
+
export type PollyMode = 'off' | 'record' | 'replay';
|
|
29
|
+
/**
|
|
30
|
+
* Narrows whatever the app read out of its environment to a mode.
|
|
31
|
+
*
|
|
32
|
+
* Anything unrecognised — including `undefined` from an unset variable — is
|
|
33
|
+
* `off`, so a typo in the variable disables mocking rather than half-enabling it.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveMode(raw: unknown): PollyMode;
|
|
36
|
+
export interface StartPollyNodeOptions extends PollyPolicyConfig {
|
|
37
|
+
/** Resolve it with `resolveMode(process.env.POLLY_MODE)`. */
|
|
38
|
+
mode: PollyMode;
|
|
39
|
+
/** Where HARs are read from and written to. Gitignore it. */
|
|
40
|
+
recordingsDir?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Hosts that are never mocked, whatever the policy says. Empty by default:
|
|
43
|
+
* unlike a browser there is no one origin that must survive, and the honest
|
|
44
|
+
* default is to make the caller name its own infrastructure.
|
|
45
|
+
*/
|
|
46
|
+
alwaysPassthrough?: readonly (string | undefined | null)[];
|
|
47
|
+
/** Debounce on flushing to disk while recording. */
|
|
48
|
+
saveDebounceMs?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Polly's log level. Defaults to `'error'` — see `DEFAULT_LOG_LEVEL` for why
|
|
51
|
+
* Polly's own `'warn'` is unusable in a Node process.
|
|
52
|
+
*/
|
|
53
|
+
logLevel?: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent';
|
|
54
|
+
}
|
|
55
|
+
/** Returns null when the mode is `off`, having constructed and patched nothing. */
|
|
56
|
+
export declare function startPolly(options: StartPollyNodeOptions): Polly | null;
|