@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 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;
@@ -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 {};
@@ -0,0 +1,4 @@
1
+ if (typeof globalThis.global === 'undefined') {
2
+ globalThis.global = globalThis;
3
+ }
4
+ export {};
@@ -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
+ }
@@ -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
+ }