@avivbenhamo/polly-kit 0.1.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/README.md +138 -0
- package/dist/browser.d.ts +45 -0
- package/dist/browser.js +102 -0
- package/dist/global-shim.d.ts +36 -0
- package/dist/global-shim.js +4 -0
- package/dist/policy.d.ts +59 -0
- package/dist/policy.js +58 -0
- package/dist/store.d.ts +33 -0
- package/dist/store.js +73 -0
- package/dist/vite.d.ts +31 -0
- package/dist/vite.js +85 -0
- package/package.json +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# @avivbenhamo/polly-kit
|
|
2
|
+
|
|
3
|
+
Record a Vite app's real HTTP traffic to HAR files, then replay it so the app runs with no
|
|
4
|
+
backends. Wraps Polly.JS, the recording store and the [polly-studio](https://www.npmjs.com/package/@avivbenhamo/polly-studio)
|
|
5
|
+
editor into one plugin and one function.
|
|
6
|
+
|
|
7
|
+
Nothing in here knows a hostname. You supply a policy; the package is the machinery.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm i -D @avivbenhamo/polly-kit
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Three steps
|
|
16
|
+
|
|
17
|
+
**1. `vite.config.ts`** — one plugin, dev-server only (it registers in `configureServer`, so a
|
|
18
|
+
production build never sees it):
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import pollyKit from '@avivbenhamo/polly-kit/vite';
|
|
22
|
+
|
|
23
|
+
export default defineConfig({
|
|
24
|
+
plugins: [react(), pollyKit({recordingsDir: 'recordings'})],
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**2. A config file** — the only file you write:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// src/polly/config.ts
|
|
32
|
+
import type {PollyPolicyConfig} from '@avivbenhamo/polly-kit';
|
|
33
|
+
|
|
34
|
+
export const pollyConfig: PollyPolicyConfig = {
|
|
35
|
+
recordingName: 'my-app',
|
|
36
|
+
policy: {kind: 'except', hosts: [import.meta.env.VITE_AUTH_URL]},
|
|
37
|
+
};
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**3. A bootstrap module, imported FIRST in your entry point:**
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// src/polly/bootstrap.ts
|
|
44
|
+
import {resolveMode, startPolly} from '@avivbenhamo/polly-kit';
|
|
45
|
+
import {pollyConfig} from './config';
|
|
46
|
+
|
|
47
|
+
const mode = import.meta.env.PROD ? 'off' : resolveMode(import.meta.env.VITE_POLLY_MODE);
|
|
48
|
+
|
|
49
|
+
startPolly({...pollyConfig, mode});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// src/index.tsx — FIRST import, above everything
|
|
54
|
+
import './polly/bootstrap';
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`bootstrap.ts` exists so that `startPolly()` runs before your component tree is evaluated. ES
|
|
58
|
+
module imports are hoisted and fully evaluated before any statement in the importing module's
|
|
59
|
+
body, so a bare `startPolly()` call in `index.tsx` runs *after* `./App` and everything it
|
|
60
|
+
imports — and any module that fires a request at import time would escape interception,
|
|
61
|
+
silently and only sometimes.
|
|
62
|
+
|
|
63
|
+
Add `/recordings` to `.gitignore`. A HAR holds complete response bodies.
|
|
64
|
+
|
|
65
|
+
## Running it
|
|
66
|
+
|
|
67
|
+
Off unless `VITE_POLLY_MODE` is set, so the default behaviour of your app is unchanged.
|
|
68
|
+
|
|
69
|
+
```powershell
|
|
70
|
+
$env:VITE_POLLY_MODE='record'; npm run dev # capture real traffic
|
|
71
|
+
$env:VITE_POLLY_MODE='replay'; npm run dev # serve from recordings
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
VITE_POLLY_MODE=record npm run dev # bash
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Record once against reachable backends, then replay forever. Recordings land in
|
|
79
|
+
`recordings/<recordingName>_<hash>/recording.har`, editable at **http://localhost:<port>/__polly**
|
|
80
|
+
— the URL is printed on startup.
|
|
81
|
+
|
|
82
|
+
## Policies
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
policy: {kind: 'all'} // mock every host
|
|
86
|
+
policy: {kind: 'except', hosts: [...]} // mock everything EXCEPT these
|
|
87
|
+
policy: {kind: 'only', hosts: [...]} // mock ONLY these
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Hosts match by origin, so a trailing slash or a path on the value is fine — they are `baseURL`s
|
|
91
|
+
in practice, and `toOrigin` reduces them. An entry that resolves to `undefined` (an env var
|
|
92
|
+
that was never set) is skipped rather than throwing at startup.
|
|
93
|
+
|
|
94
|
+
The app's own origin is always passed through: it serves the HMR socket, the recording store
|
|
95
|
+
and every static asset, so mocking it breaks the dev server.
|
|
96
|
+
|
|
97
|
+
**Passed-through hosts still hit the real network in `replay` mode.** That is what passthrough
|
|
98
|
+
means, but it has a sharp edge: the hosts you exclude are usually auth and analytics, which are
|
|
99
|
+
also the least likely to be reachable from an isolated environment. Replaying off-network then
|
|
100
|
+
gives you hanging calls to exactly those while everything else replays fine — which reads as
|
|
101
|
+
"Polly is broken" and isn't. For true isolation use `{kind: 'all'}` and record them too.
|
|
102
|
+
|
|
103
|
+
## API
|
|
104
|
+
|
|
105
|
+
| Export | From | What |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| `startPolly(options)` | `.` | Starts Polly. Returns `null` when `mode` is `'off'`, having constructed nothing. |
|
|
108
|
+
| `resolveMode(raw)` | `.` | Narrows an env value to `'off' \| 'record' \| 'replay'`. Anything unrecognised is `off`. |
|
|
109
|
+
| `toOrigin` / `toOrigins` | `.` | The origin reduction the policy matcher uses. |
|
|
110
|
+
| `pollyKit(options)` | `./vite` | The Vite plugin: recording store + studio. |
|
|
111
|
+
| `recordingStore(options)` | `./store` | The store as a bare connect middleware, for mounting on something that isn't Vite. |
|
|
112
|
+
|
|
113
|
+
`startPolly` options beyond the policy config: `mode` (required), `alwaysPassthrough`,
|
|
114
|
+
`apiNamespace`, `saveDebounceMs`. `pollyKit` options: `recordingsDir`, `studioPath`,
|
|
115
|
+
`apiNamespace`, `studio`. Change `apiNamespace` in one place and you must change it in both.
|
|
116
|
+
|
|
117
|
+
## Notes on why it is built this way
|
|
118
|
+
|
|
119
|
+
**The XHR adapter, not fetch.** axios resolves its adapter as `['xhr', 'http', 'fetch']`, so in
|
|
120
|
+
a browser it uses `XMLHttpRequest`. `@pollyjs/adapter-fetch` would attach cleanly and then see
|
|
121
|
+
no traffic at all. `adapter-xhr` patches `window.XMLHttpRequest`, which axios picks up with no
|
|
122
|
+
awareness — so no axios instance in your app needs to change.
|
|
123
|
+
|
|
124
|
+
**The `global` shim.** `@pollyjs/persister-rest` reads a bare, unbound `global` at module top
|
|
125
|
+
level, which throws in a browser. `startPolly`'s module imports the shim as its own first
|
|
126
|
+
import, so you cannot get the order wrong.
|
|
127
|
+
|
|
128
|
+
**Recordings flush while you browse.** Polly only persists on `stop()`, which suits a test that
|
|
129
|
+
ends; a dev server page stays open and would write nothing. The kit debounces a `persist()` per
|
|
130
|
+
response instead.
|
|
131
|
+
|
|
132
|
+
**Defaults that differ from Polly's.** `recordIfMissing: false` (a replay miss must fail loudly
|
|
133
|
+
— the default quietly hits the network and records it, which looks identical to replay working),
|
|
134
|
+
`recordFailedRequests: true`, `matchRequestsBy.order: false` (screens refetch) and
|
|
135
|
+
`.headers: false` (bearer tokens would pin a recording to one session).
|
|
136
|
+
|
|
137
|
+
**If you also run MSW, Polly wins.** It patches `XMLHttpRequest` in-page, so the request is
|
|
138
|
+
answered before the service worker sees it. Run one or the other.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record and replay a browser app's HTTP calls with Polly.JS.
|
|
3
|
+
*
|
|
4
|
+
* Polly works by patching the transport, so `startPolly()` has to run before the
|
|
5
|
+
* first request goes out. Import the module that calls it FIRST in your entry
|
|
6
|
+
* point — see the README.
|
|
7
|
+
*
|
|
8
|
+
* WHY THE XHR ADAPTER. axios resolves its adapter in the order
|
|
9
|
+
* `['xhr', 'http', 'fetch']`, so in a browser it uses XMLHttpRequest. The fetch
|
|
10
|
+
* adapter would attach cleanly and then see no traffic at all. `adapter-xhr`
|
|
11
|
+
* patches `window.XMLHttpRequest`, which axios picks up with no awareness — so no
|
|
12
|
+
* axios instance in the host app needs to change.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here reads the environment. The caller resolves the mode and passes it
|
|
15
|
+
* in, which is what keeps this file free of any bundler's env API.
|
|
16
|
+
*/
|
|
17
|
+
import './global-shim.js';
|
|
18
|
+
import { Polly } from '@pollyjs/core';
|
|
19
|
+
import { type PollyPolicyConfig } from './policy.js';
|
|
20
|
+
export type { HostPolicy, PollyPolicyConfig } from './policy.js';
|
|
21
|
+
export { toOrigin, toOrigins } from './policy.js';
|
|
22
|
+
export type PollyMode = 'off' | 'record' | 'replay';
|
|
23
|
+
/**
|
|
24
|
+
* Narrows whatever the host app read out of its environment to a mode.
|
|
25
|
+
*
|
|
26
|
+
* Anything unrecognised — including `undefined` from an unset variable — is
|
|
27
|
+
* `off`, so a typo in the variable disables Polly rather than half-enabling it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveMode(raw: unknown): PollyMode;
|
|
30
|
+
export interface StartPollyOptions extends PollyPolicyConfig {
|
|
31
|
+
/** Resolve it with `resolveMode(import.meta.env.VITE_POLLY_MODE)`, or equivalent. */
|
|
32
|
+
mode: PollyMode;
|
|
33
|
+
/**
|
|
34
|
+
* Hosts that are never mocked, whatever the policy says. Defaults to the
|
|
35
|
+
* app's own origin, which serves the dev server's HMR socket, the recording
|
|
36
|
+
* store itself and every static asset — mocking it breaks the dev server.
|
|
37
|
+
*/
|
|
38
|
+
alwaysPassthrough?: readonly (string | undefined | null)[];
|
|
39
|
+
/** Where the recording store is mounted. Must match the Vite plugin. */
|
|
40
|
+
apiNamespace?: string;
|
|
41
|
+
/** Debounce on flushing to disk while recording. */
|
|
42
|
+
saveDebounceMs?: number;
|
|
43
|
+
}
|
|
44
|
+
/** Returns null when the mode is `off`, having constructed nothing. */
|
|
45
|
+
export declare function startPolly(options: StartPollyOptions): Polly | null;
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record and replay a browser app's HTTP calls with Polly.JS.
|
|
3
|
+
*
|
|
4
|
+
* Polly works by patching the transport, so `startPolly()` has to run before the
|
|
5
|
+
* first request goes out. Import the module that calls it FIRST in your entry
|
|
6
|
+
* point — see the README.
|
|
7
|
+
*
|
|
8
|
+
* WHY THE XHR ADAPTER. axios resolves its adapter in the order
|
|
9
|
+
* `['xhr', 'http', 'fetch']`, so in a browser it uses XMLHttpRequest. The fetch
|
|
10
|
+
* adapter would attach cleanly and then see no traffic at all. `adapter-xhr`
|
|
11
|
+
* patches `window.XMLHttpRequest`, which axios picks up with no awareness — so no
|
|
12
|
+
* axios instance in the host app needs to change.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here reads the environment. The caller resolves the mode and passes it
|
|
15
|
+
* in, which is what keeps this file free of any bundler's env API.
|
|
16
|
+
*/
|
|
17
|
+
// FIRST. Defines `global`, which @pollyjs/persister-rest reads unbound at module
|
|
18
|
+
// top level. ES modules evaluate in import order, so this runs before the Polly
|
|
19
|
+
// imports below. Moving it under them reintroduces the crash — and it lives here
|
|
20
|
+
// rather than in the host app so the host app cannot get the order wrong.
|
|
21
|
+
import './global-shim.js';
|
|
22
|
+
import { Polly } from '@pollyjs/core';
|
|
23
|
+
import XHRAdapter from '@pollyjs/adapter-xhr';
|
|
24
|
+
import RestPersister from '@pollyjs/persister-rest';
|
|
25
|
+
import { applyPolicy } from './policy.js';
|
|
26
|
+
export { toOrigin, toOrigins } from './policy.js';
|
|
27
|
+
const MODES = ['off', 'record', 'replay'];
|
|
28
|
+
/** Matches the namespace the Vite plugin mounts the recording store on. */
|
|
29
|
+
const API_NAMESPACE = '/api/polly';
|
|
30
|
+
/** How long to wait for more responses before flushing a recording to disk. */
|
|
31
|
+
const SAVE_DEBOUNCE_MS = 500;
|
|
32
|
+
let registered = false;
|
|
33
|
+
/**
|
|
34
|
+
* Narrows whatever the host app read out of its environment to a mode.
|
|
35
|
+
*
|
|
36
|
+
* Anything unrecognised — including `undefined` from an unset variable — is
|
|
37
|
+
* `off`, so a typo in the variable disables Polly rather than half-enabling it.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveMode(raw) {
|
|
40
|
+
return MODES.includes(raw) ? raw : 'off';
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Flush recordings to disk as they arrive.
|
|
44
|
+
*
|
|
45
|
+
* Polly only persists on `stop()`, which suits a test that ends. A dev server
|
|
46
|
+
* page stays open, so it would hold every recording in memory and write none of
|
|
47
|
+
* them. Debounced rather than per-response because a page load fires dozens of
|
|
48
|
+
* requests and each save rewrites the whole HAR.
|
|
49
|
+
*/
|
|
50
|
+
function autoSave(polly, debounceMs) {
|
|
51
|
+
let timer;
|
|
52
|
+
polly.server.any().on('response', () => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
timer = setTimeout(() => {
|
|
55
|
+
void polly.persister?.persist();
|
|
56
|
+
}, debounceMs);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** Returns null when the mode is `off`, having constructed nothing. */
|
|
60
|
+
export function startPolly(options) {
|
|
61
|
+
const { mode, recordingName, policy, alwaysPassthrough = [window.location.origin], apiNamespace = API_NAMESPACE, saveDebounceMs = SAVE_DEBOUNCE_MS, } = options;
|
|
62
|
+
if (mode === 'off') {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (!registered) {
|
|
66
|
+
Polly.register(XHRAdapter);
|
|
67
|
+
Polly.register(RestPersister);
|
|
68
|
+
registered = true;
|
|
69
|
+
}
|
|
70
|
+
const polly = new Polly(recordingName, {
|
|
71
|
+
mode,
|
|
72
|
+
adapters: ['xhr'],
|
|
73
|
+
persister: 'rest',
|
|
74
|
+
persisterOptions: {
|
|
75
|
+
// Without this, every save drops the entries that were not replayed
|
|
76
|
+
// this session. Exercising one screen would throw away every other
|
|
77
|
+
// screen's recording.
|
|
78
|
+
keepUnusedRequests: true,
|
|
79
|
+
rest: { host: window.location.origin, apiNamespace },
|
|
80
|
+
},
|
|
81
|
+
// A replay miss must fail loudly. The default `true` quietly goes to the
|
|
82
|
+
// network and records it, which looks identical to replay working.
|
|
83
|
+
recordIfMissing: false,
|
|
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
|
+
},
|
|
96
|
+
});
|
|
97
|
+
applyPolicy(polly.server, { policy, alwaysPassthrough });
|
|
98
|
+
if (mode === 'record') {
|
|
99
|
+
autoSave(polly, saveDebounceMs);
|
|
100
|
+
}
|
|
101
|
+
return polly;
|
|
102
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Define `global` for Polly's CommonJS-era dependencies.
|
|
3
|
+
*
|
|
4
|
+
* `@pollyjs/persister-rest` reads a bare, unbound `global` at module top level:
|
|
5
|
+
*
|
|
6
|
+
* const REQUEST_ASYNC = !('navigator' in global) || ...
|
|
7
|
+
* const NativeXMLHttpRequest = global.XMLHttpRequest;
|
|
8
|
+
*
|
|
9
|
+
* Browsers have no `global`, so evaluating that module throws
|
|
10
|
+
* `ReferenceError: global is not defined` before any of our code runs.
|
|
11
|
+
*
|
|
12
|
+
* The file does contain `var global = module.exports = ...` further up, but that
|
|
13
|
+
* sits inside a `createCommonjsModule(function (module) {...})` wrapper, so it
|
|
14
|
+
* binds nothing at module scope and does not save the references above.
|
|
15
|
+
*
|
|
16
|
+
* THIS IS THE FALLBACK, NOT THE MECHANISM. Under Vite the real fix is the
|
|
17
|
+
* `define: {global: 'globalThis'}` pair contributed by `./vite.ts` — one for app
|
|
18
|
+
* code and one under `optimizeDeps.esbuildOptions` for pre-bundled dependencies,
|
|
19
|
+
* because persister-rest is one and the plain `define` does not reach it.
|
|
20
|
+
*
|
|
21
|
+
* A runtime assignment cannot do that job here. It was the mechanism while these
|
|
22
|
+
* files were app source, where being the first import in the entry point orders
|
|
23
|
+
* it before the Polly imports. Once this is a *dependency*, Vite pre-bundles it
|
|
24
|
+
* and esbuild concatenates the modules in its own dependency order, so the
|
|
25
|
+
* assignment below can land after persister-rest has already read `global` — and
|
|
26
|
+
* measurably does. Import order in `browser.ts` does not survive bundling.
|
|
27
|
+
*
|
|
28
|
+
* It stays for the paths that are not pre-bundled: a consumer who excludes this
|
|
29
|
+
* package from `optimizeDeps`, a non-Vite bundler, or `./global-shim` imported
|
|
30
|
+
* directly. Harmless when `define` already did the work — the guard below simply
|
|
31
|
+
* finds `global` defined and does nothing.
|
|
32
|
+
*/
|
|
33
|
+
declare global {
|
|
34
|
+
var global: typeof globalThis;
|
|
35
|
+
}
|
|
36
|
+
export {};
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns a host policy into Polly route registrations.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here knows a hostname — the host app supplies those. This file is the
|
|
5
|
+
* generic machinery and should stay that way.
|
|
6
|
+
*
|
|
7
|
+
* Both mechanisms below were verified against @pollyjs/core 6.0.6 by probe, not
|
|
8
|
+
* read off the docs, because the obvious reading of the API is wrong twice:
|
|
9
|
+
*
|
|
10
|
+
* 1. `server.any()` is ALWAYS middleware, never a route, and it is only
|
|
11
|
+
* *global* when called with NO argument. `_registerMiddleware` takes its
|
|
12
|
+
* global branch only for `!route || route === '*'`; anything else — including
|
|
13
|
+
* `'/*path'` — registers under host `'/'`, which `Middleware.match` compares
|
|
14
|
+
* against the request's origin. It therefore matches nothing, with no error.
|
|
15
|
+
*
|
|
16
|
+
* 2. Precedence is last-writer-wins, not most-specific-wins. `Route._valueFor`
|
|
17
|
+
* walks `[...middleware, route]` in registration order and keeps the last
|
|
18
|
+
* handler that set a key. So a host-scoped middleware registered *after* the
|
|
19
|
+
* global one overrides it — which is why order matters below.
|
|
20
|
+
*/
|
|
21
|
+
import type { PollyServer } from '@pollyjs/core';
|
|
22
|
+
export type HostPolicy = {
|
|
23
|
+
kind: 'all';
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'except';
|
|
26
|
+
hosts: string[];
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'only';
|
|
29
|
+
hosts: string[];
|
|
30
|
+
};
|
|
31
|
+
export interface PollyPolicyConfig {
|
|
32
|
+
/** One HAR per name: `recordings/<name>_<hash>/recording.har`. */
|
|
33
|
+
recordingName: string;
|
|
34
|
+
policy: HostPolicy;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Reduce a base URL to the origin Polly matches on.
|
|
38
|
+
*
|
|
39
|
+
* The config values are axios `baseURL`s, so they arrive with trailing slashes
|
|
40
|
+
* (`https://gallery-statistics-dev.../`) and sometimes a path
|
|
41
|
+
* (`${SANHEDRIN}/louvre`). Polly compares `parsedUrl.origin`, so both must be
|
|
42
|
+
* reduced or the host never matches.
|
|
43
|
+
*
|
|
44
|
+
* Returns null for anything unusable — an env var that was never set resolves
|
|
45
|
+
* to `undefined` here, and silently skipping it beats throwing during startup.
|
|
46
|
+
*/
|
|
47
|
+
export declare function toOrigin(value: string | undefined | null): string | null;
|
|
48
|
+
/** Origins, deduped and with the unusable entries dropped. */
|
|
49
|
+
export declare function toOrigins(values: readonly (string | undefined | null)[]): string[];
|
|
50
|
+
export interface ApplyPolicyOptions {
|
|
51
|
+
policy: HostPolicy;
|
|
52
|
+
/**
|
|
53
|
+
* Hosts that are never mocked, whatever the policy says. Structural rather
|
|
54
|
+
* than user config: the app's own origin serves the dev server's HMR socket,
|
|
55
|
+
* the recording store itself, and every static asset.
|
|
56
|
+
*/
|
|
57
|
+
alwaysPassthrough: readonly (string | undefined | null)[];
|
|
58
|
+
}
|
|
59
|
+
export declare function applyPolicy(server: PollyServer, { policy, alwaysPassthrough }: ApplyPolicyOptions): void;
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reduce a base URL to the origin Polly matches on.
|
|
3
|
+
*
|
|
4
|
+
* The config values are axios `baseURL`s, so they arrive with trailing slashes
|
|
5
|
+
* (`https://gallery-statistics-dev.../`) and sometimes a path
|
|
6
|
+
* (`${SANHEDRIN}/louvre`). Polly compares `parsedUrl.origin`, so both must be
|
|
7
|
+
* reduced or the host never matches.
|
|
8
|
+
*
|
|
9
|
+
* Returns null for anything unusable — an env var that was never set resolves
|
|
10
|
+
* to `undefined` here, and silently skipping it beats throwing during startup.
|
|
11
|
+
*/
|
|
12
|
+
export function toOrigin(value) {
|
|
13
|
+
if (!value) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
return new URL(value).origin;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Origins, deduped and with the unusable entries dropped. */
|
|
24
|
+
export function toOrigins(values) {
|
|
25
|
+
const origins = values
|
|
26
|
+
.map(toOrigin)
|
|
27
|
+
.filter((origin) => origin !== null);
|
|
28
|
+
return Array.from(new Set(origins));
|
|
29
|
+
}
|
|
30
|
+
export function applyPolicy(server, { policy, alwaysPassthrough }) {
|
|
31
|
+
const never = toOrigins(alwaysPassthrough);
|
|
32
|
+
if (policy.kind === 'only') {
|
|
33
|
+
// Global default: everything passes through. Must be registered BEFORE
|
|
34
|
+
// the host-scoped overrides below, per last-writer-wins.
|
|
35
|
+
server.any().passthrough();
|
|
36
|
+
for (const origin of toOrigins(policy.hosts)) {
|
|
37
|
+
// A host that is both allowlisted and always-passthrough stays
|
|
38
|
+
// passed through: we simply never opt it back in.
|
|
39
|
+
if (never.includes(origin)) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
server.host(origin, () => {
|
|
43
|
+
server.any('/*path').passthrough(false);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// `all` and `except` share the same default — Polly intercepts anything it
|
|
49
|
+
// has no route for — so only the exclusions need registering.
|
|
50
|
+
const excluded = policy.kind === 'except'
|
|
51
|
+
? toOrigins([...policy.hosts, ...alwaysPassthrough])
|
|
52
|
+
: never;
|
|
53
|
+
for (const origin of excluded) {
|
|
54
|
+
server.host(origin, () => {
|
|
55
|
+
server.any('/*path').passthrough();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Polly recording store, as a connect-style middleware.
|
|
3
|
+
*
|
|
4
|
+
* `@pollyjs/persister-rest` saves recordings by talking HTTP to a server that
|
|
5
|
+
* writes them to disk. That server ships as `@pollyjs/node-server`, but only as
|
|
6
|
+
* an express app or a standalone process on its own port — both of which would
|
|
7
|
+
* mean a second server and a CORS story for what is three routes.
|
|
8
|
+
*
|
|
9
|
+
* `API` is the part that actually touches the filesystem, independent of
|
|
10
|
+
* express, so this returns a bare `(req, res, next)` handler instead: mount it
|
|
11
|
+
* on a Vite dev server, an express app, or a plain `http.createServer`, on the
|
|
12
|
+
* same origin as the app. One command, no extra port.
|
|
13
|
+
*
|
|
14
|
+
* The contract is fixed by the persister (`@pollyjs/persister-rest/src/index.js`):
|
|
15
|
+
*
|
|
16
|
+
* GET {namespace}/{recordingId} -> 200 + HAR json, or 204 when absent
|
|
17
|
+
* POST {namespace}/{recordingId} -> 201, body is the HAR
|
|
18
|
+
* DELETE {namespace}/{recordingId} -> 200
|
|
19
|
+
*
|
|
20
|
+
* 204-rather-than-404 is deliberate on Polly's part: a missing recording is a
|
|
21
|
+
* normal outcome, and a 404 would show up as a scary red line in the console.
|
|
22
|
+
*/
|
|
23
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
24
|
+
/** Matches the persister default, and what `startPolly` is configured with. */
|
|
25
|
+
export declare const STORE_NAMESPACE = "/api/polly";
|
|
26
|
+
export interface RecordingStoreOptions {
|
|
27
|
+
recordingsDir?: string;
|
|
28
|
+
/** Maximum HAR size accepted on POST, in bytes. */
|
|
29
|
+
bodyLimit?: number;
|
|
30
|
+
}
|
|
31
|
+
/** The connect signature, declared here so this file needs no framework types. */
|
|
32
|
+
export type StoreMiddleware = (req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => void;
|
|
33
|
+
export declare function recordingStore({ recordingsDir, bodyLimit }?: RecordingStoreOptions): StoreMiddleware;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { API } from '@pollyjs/node-server';
|
|
2
|
+
/** Matches the persister default, and what `startPolly` is configured with. */
|
|
3
|
+
export const STORE_NAMESPACE = '/api/polly';
|
|
4
|
+
/** 50mb, same as node-server's own default. A HAR with image bodies gets big. */
|
|
5
|
+
const BODY_LIMIT = 50 * 1024 * 1024;
|
|
6
|
+
function readBody(req, limit) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const chunks = [];
|
|
9
|
+
let size = 0;
|
|
10
|
+
req.on('data', (chunk) => {
|
|
11
|
+
size += chunk.length;
|
|
12
|
+
if (size > limit) {
|
|
13
|
+
reject(new Error(`recording exceeds ${limit} bytes`));
|
|
14
|
+
req.destroy();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
chunks.push(chunk);
|
|
18
|
+
});
|
|
19
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
20
|
+
req.on('error', reject);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function send(res, status, body) {
|
|
24
|
+
res.statusCode = status;
|
|
25
|
+
// node-server's express version wraps these routes in `nocache` for the same
|
|
26
|
+
// reason: a cached 204 would make a recording that exists on disk look missing.
|
|
27
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
28
|
+
if (body === undefined) {
|
|
29
|
+
res.end();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
33
|
+
res.end(typeof body === 'string' ? body : JSON.stringify(body));
|
|
34
|
+
}
|
|
35
|
+
export function recordingStore({ recordingsDir = 'recordings', bodyLimit = BODY_LIMIT } = {}) {
|
|
36
|
+
const api = new API({ recordingsDir });
|
|
37
|
+
return async (req, res, next) => {
|
|
38
|
+
// Connect strips the mount path, so `req.url` is what follows the namespace.
|
|
39
|
+
const name = decodeURIComponent((req.url || '/').split('?')[0].replace(/^\//, ''));
|
|
40
|
+
// The name is joined straight into a filesystem path, and it arrives from
|
|
41
|
+
// the browser — a name containing separators could write outside
|
|
42
|
+
// `recordingsDir` entirely.
|
|
43
|
+
if (name === '' || name.includes('/') || name.includes('\\') || name.includes('..')) {
|
|
44
|
+
send(res, 400, { error: 'invalid recording id' });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
if (req.method === 'GET') {
|
|
49
|
+
// `getRecording`, singular. node-server's own `types.d.ts` calls it
|
|
50
|
+
// `getRecordings`, but `src/api.js` defines `getRecording` and that
|
|
51
|
+
// is what runs. Editors flag this; renaming it to match breaks it.
|
|
52
|
+
const { status, body } = api.getRecording(name);
|
|
53
|
+
send(res, status, status === 200 ? body : undefined);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (req.method === 'POST') {
|
|
57
|
+
const raw = await readBody(req, bodyLimit);
|
|
58
|
+
const { status } = api.saveRecording(name, JSON.parse(raw));
|
|
59
|
+
send(res, status);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (req.method === 'DELETE') {
|
|
63
|
+
const { status } = api.deleteRecording(name);
|
|
64
|
+
send(res, status);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
next();
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
send(res, 500, { error: error.message });
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
package/dist/vite.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
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 type { Plugin } from 'vite';
|
|
19
|
+
export interface PollyKitOptions {
|
|
20
|
+
/** Where HARs are read from and written to. Gitignore it. */
|
|
21
|
+
recordingsDir?: string;
|
|
22
|
+
/** Mount path for the editor UI. */
|
|
23
|
+
studioPath?: string;
|
|
24
|
+
/** Where the store is mounted. Change it and `startPolly`'s `apiNamespace` together. */
|
|
25
|
+
apiNamespace?: string;
|
|
26
|
+
/** Set false to serve the store without the editor. */
|
|
27
|
+
studio?: boolean;
|
|
28
|
+
}
|
|
29
|
+
export default function pollyKit(options?: PollyKitOptions): Plugin;
|
|
30
|
+
export { recordingStore, STORE_NAMESPACE } from './store.js';
|
|
31
|
+
export type { RecordingStoreOptions, StoreMiddleware } from './store.js';
|
package/dist/vite.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { pollyUi } from '@avivbenhamo/polly-studio';
|
|
2
|
+
import { recordingStore, STORE_NAMESPACE } from './store.js';
|
|
3
|
+
const STUDIO_PATH = '/__polly';
|
|
4
|
+
export default function pollyKit(options = {}) {
|
|
5
|
+
const { recordingsDir = 'recordings', studioPath = STUDIO_PATH, apiNamespace = STORE_NAMESPACE, studio = true, } = options;
|
|
6
|
+
return {
|
|
7
|
+
name: 'polly-kit',
|
|
8
|
+
/**
|
|
9
|
+
* Contribute the settings Polly needs in a browser, so the host app's
|
|
10
|
+
* vite.config stays one import and one line.
|
|
11
|
+
*
|
|
12
|
+
* Vite merges what a plugin returns here into the user config, and all of
|
|
13
|
+
* these are additive — an app that already declares them ends up with the
|
|
14
|
+
* same result.
|
|
15
|
+
*/
|
|
16
|
+
config() {
|
|
17
|
+
return {
|
|
18
|
+
// `@pollyjs/persister-rest` reads a bare, unbound `global` at module
|
|
19
|
+
// top level, which does not exist in a browser:
|
|
20
|
+
//
|
|
21
|
+
// const REQUEST_ASYNC = !('navigator' in global) || ...
|
|
22
|
+
//
|
|
23
|
+
// `global-shim.ts` assigns it at runtime, and being the first import
|
|
24
|
+
// in `browser.ts` orders that correctly — but ONLY while those are
|
|
25
|
+
// app source. Once this is a dependency, Vite pre-bundles it and
|
|
26
|
+
// esbuild concatenates the modules in its own dependency order, so
|
|
27
|
+
// the assignment can land after the read. Verified: it does.
|
|
28
|
+
//
|
|
29
|
+
// So rewrite the identifier as well, in both places — which of the
|
|
30
|
+
// two applies has moved between Vite versions, and pre-bundled
|
|
31
|
+
// dependencies need the esbuild one specifically.
|
|
32
|
+
define: { global: 'globalThis' },
|
|
33
|
+
resolve: {
|
|
34
|
+
alias: [
|
|
35
|
+
{
|
|
36
|
+
// `@pollyjs/utils` imports `buffer` to decide whether a
|
|
37
|
+
// response body is utf8-representable. Vite externalizes
|
|
38
|
+
// Node builtins in the browser, so without this the import
|
|
39
|
+
// resolves to a stub and every response errors with
|
|
40
|
+
// "Cannot read properties of undefined (reading 'from')".
|
|
41
|
+
//
|
|
42
|
+
// The trailing slash is what makes this resolve to the npm
|
|
43
|
+
// `buffer` package rather than back to the Node builtin. The
|
|
44
|
+
// anchored regex keeps it from also rewriting `buffer/...`.
|
|
45
|
+
find: /^buffer$/,
|
|
46
|
+
replacement: 'buffer/',
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
optimizeDeps: {
|
|
51
|
+
// `buffer`: pre-bundled alongside the Polly packages that import
|
|
52
|
+
// it, so the dep optimizer does not treat it as an unresolved
|
|
53
|
+
// external on a cold start.
|
|
54
|
+
//
|
|
55
|
+
// This package itself: without it, Vite discovers the dependency
|
|
56
|
+
// when the entry module first requests it, optimizes, and triggers
|
|
57
|
+
// a full reload — and on that first pass the esbuild `define` below
|
|
58
|
+
// has not been applied, so persister-rest crashes on `global` and
|
|
59
|
+
// Polly does not attach. Measured: the first load after a cold
|
|
60
|
+
// `.vite` cache missed every request, the reload after it recorded
|
|
61
|
+
// fine. Naming it here makes the optimizer run before the server
|
|
62
|
+
// accepts requests, so the first load is the optimized one.
|
|
63
|
+
include: ['buffer', '@avivbenhamo/polly-kit'],
|
|
64
|
+
// The `define` above does not reach pre-bundled dependencies, and
|
|
65
|
+
// persister-rest is one. This is the half that actually fixes it.
|
|
66
|
+
esbuildOptions: { define: { global: 'globalThis' } },
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
configureServer(server) {
|
|
71
|
+
server.middlewares.use(apiNamespace, recordingStore({ recordingsDir }));
|
|
72
|
+
if (!studio) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
server.middlewares.use(studioPath, pollyUi({ recordingsDir }));
|
|
76
|
+
server.httpServer?.once('listening', () => {
|
|
77
|
+
// Vite prints its own URLs just above; this rides along so the
|
|
78
|
+
// editor is discoverable without reading this file.
|
|
79
|
+
const port = server.config.server.port;
|
|
80
|
+
server.config.logger.info(` \x1b[32m➜\x1b[0m polly-studio: \x1b[36mhttp://localhost:${port}${studioPath}\x1b[0m`);
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export { recordingStore, STORE_NAMESPACE } from './store.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@avivbenhamo/polly-kit",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"main": "./dist/browser.js",
|
|
12
|
+
"types": "./dist/browser.d.ts",
|
|
13
|
+
"//typesVersions": "For consumers still on moduleResolution 'node', which ignores `exports`. Drop once everything is on 'bundler'.",
|
|
14
|
+
"typesVersions": {
|
|
15
|
+
"*": {
|
|
16
|
+
"vite": ["./dist/vite.d.ts"],
|
|
17
|
+
"store": ["./dist/store.d.ts"],
|
|
18
|
+
"global-shim": ["./dist/global-shim.d.ts"]
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/browser.d.ts",
|
|
24
|
+
"default": "./dist/browser.js"
|
|
25
|
+
},
|
|
26
|
+
"./vite": {
|
|
27
|
+
"types": "./dist/vite.d.ts",
|
|
28
|
+
"default": "./dist/vite.js"
|
|
29
|
+
},
|
|
30
|
+
"./store": {
|
|
31
|
+
"types": "./dist/store.d.ts",
|
|
32
|
+
"default": "./dist/store.js"
|
|
33
|
+
},
|
|
34
|
+
"./global-shim": {
|
|
35
|
+
"types": "./dist/global-shim.d.ts",
|
|
36
|
+
"default": "./dist/global-shim.js"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"//scripts": "dist/ is gitignored, so `prepack` builds it — npm runs prepack before both `npm pack` and `npm publish`. Deliberately not `prepare`: that would also fire in a consumer's tree, where there is no src/ or tsconfig to compile.",
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.json",
|
|
45
|
+
"prepack": "tsc -p tsconfig.json"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@avivbenhamo/polly-studio": "^0.1.0",
|
|
49
|
+
"@pollyjs/adapter-xhr": "^6.0.6",
|
|
50
|
+
"@pollyjs/core": "^6.0.6",
|
|
51
|
+
"@pollyjs/node-server": "^6.0.6",
|
|
52
|
+
"@pollyjs/persister-rest": "^6.0.6",
|
|
53
|
+
"buffer": "^6.0.3"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"vite": ">=4"
|
|
57
|
+
},
|
|
58
|
+
"peerDependenciesMeta": {
|
|
59
|
+
"vite": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"//devDependencies": "vite is here only so `./vite` typechecks standalone; it stays an optional peer at runtime.",
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"typescript": "^5.3.3",
|
|
66
|
+
"vite": "^5.0.0"
|
|
67
|
+
}
|
|
68
|
+
}
|