@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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +298 -138
  3. package/dist/adapters/encoding.d.ts +7 -0
  4. package/dist/adapters/encoding.js +46 -0
  5. package/dist/adapters/undici.d.ts +36 -0
  6. package/dist/adapters/undici.js +231 -0
  7. package/dist/browser.d.ts +13 -5
  8. package/dist/browser.js +21 -25
  9. package/dist/cli.d.ts +21 -0
  10. package/dist/cli.js +141 -0
  11. package/dist/defaults.d.ts +40 -0
  12. package/dist/defaults.js +40 -0
  13. package/dist/node.d.ts +56 -0
  14. package/dist/node.js +108 -0
  15. package/dist/studio/body.d.ts +28 -0
  16. package/dist/studio/body.js +66 -0
  17. package/dist/studio/browser.d.ts +22 -0
  18. package/dist/studio/browser.js +24 -0
  19. package/dist/studio/client.bundle.js +290 -0
  20. package/dist/studio/edit.d.ts +42 -0
  21. package/dist/studio/edit.js +90 -0
  22. package/dist/studio/etag.d.ts +6 -0
  23. package/dist/studio/etag.js +14 -0
  24. package/dist/studio/har.d.ts +19 -0
  25. package/dist/studio/har.js +36 -0
  26. package/dist/studio/headers.d.ts +31 -0
  27. package/dist/studio/headers.js +59 -0
  28. package/dist/studio/index.d.ts +9 -0
  29. package/dist/studio/index.js +5 -0
  30. package/dist/studio/server.d.ts +35 -0
  31. package/dist/studio/server.js +262 -0
  32. package/dist/studio/stores/apply.d.ts +6 -0
  33. package/dist/studio/stores/apply.js +20 -0
  34. package/dist/studio/stores/fs-store.d.ts +70 -0
  35. package/dist/studio/stores/fs-store.js +186 -0
  36. package/dist/studio/stores/http-store.d.ts +30 -0
  37. package/dist/studio/stores/http-store.js +67 -0
  38. package/dist/studio/stores/local-storage-store.d.ts +25 -0
  39. package/dist/studio/stores/local-storage-store.js +109 -0
  40. package/dist/studio/stores/types.d.ts +45 -0
  41. package/dist/studio/stores/types.js +7 -0
  42. package/dist/studio/types.d.ts +92 -0
  43. package/dist/studio/types.js +7 -0
  44. package/dist/studio/ui/app.d.ts +12 -0
  45. package/dist/studio/ui/app.js +180 -0
  46. package/dist/studio/ui/client.d.ts +2 -0
  47. package/dist/studio/ui/client.js +6 -0
  48. package/dist/studio/ui/page.d.ts +53 -0
  49. package/dist/studio/ui/page.js +360 -0
  50. package/dist/studio/ui/render.d.ts +25 -0
  51. package/dist/studio/ui/render.js +171 -0
  52. package/dist/vite.d.ts +0 -17
  53. package/dist/vite.js +44 -7
  54. package/package.json +55 -7
package/dist/node.js ADDED
@@ -0,0 +1,108 @@
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 NodeHttpAdapter from '@pollyjs/adapter-node-http';
19
+ import FSPersister from '@pollyjs/persister-fs';
20
+ import UndiciAdapter from './adapters/undici.js';
21
+ import { KEEP_UNUSED_REQUESTS, POLLY_DEFAULTS } from './defaults.js';
22
+ import { applyPolicy, toOrigins } from './policy.js';
23
+ export { toOrigin, toOrigins } from './policy.js';
24
+ /**
25
+ * Re-exported so an app mounting the editor installs and imports one package. On
26
+ * the Vite path this package mounts polly-studio itself; on a server the app owns
27
+ * its own middleware chain, and reaching into a transitive dependency for
28
+ * `pollyUi` would work only by hoisting luck.
29
+ */
30
+ export { pollyUi } from './studio/index.js';
31
+ const MODES = ['off', 'record', 'replay'];
32
+ const RECORDINGS_DIR = './recordings';
33
+ /** How long to wait for more responses before flushing a recording to disk. */
34
+ const SAVE_DEBOUNCE_MS = 500;
35
+ /**
36
+ * Polly's own default is `'warn'`, and in a Node process the only
37
+ * WARN it emits is a false one: `@pollyjs/adapter` warns on EVERY recorded request
38
+ * when `'navigator' in global && !navigator.onLine`, and Node 22 has a global
39
+ * `navigator` with no `onLine` — so the check is always true, the claim is always
40
+ * wrong, and each occurrence drags a full stringified request and config into the
41
+ * log. A service recording fifty requests logs fifty paragraphs.
42
+ *
43
+ * Errors still print. The other WARN Polly has, recording expiry, needs `expiresIn`,
44
+ * which this entry never sets. Pass `logLevel` to get Polly's default back.
45
+ */
46
+ const DEFAULT_LOG_LEVEL = 'error';
47
+ let registered = false;
48
+ /**
49
+ * Narrows whatever the app read out of its environment to a mode.
50
+ *
51
+ * Anything unrecognised — including `undefined` from an unset variable — is
52
+ * `off`, so a typo in the variable disables mocking rather than half-enabling it.
53
+ */
54
+ export function resolveMode(raw) {
55
+ return MODES.includes(raw) ? raw : 'off';
56
+ }
57
+ /**
58
+ * Flush recordings while the process runs.
59
+ *
60
+ * Polly persists on `stop()`, which suits a test that ends. A long-running
61
+ * server would hold every recording in memory and lose all of them to a SIGKILL.
62
+ * Debounced rather than per-response because each save rewrites the whole HAR.
63
+ */
64
+ function autoSave(polly, debounceMs) {
65
+ let timer;
66
+ polly.server.any().on('response', () => {
67
+ clearTimeout(timer);
68
+ timer = setTimeout(() => {
69
+ void polly.persister?.persist();
70
+ }, debounceMs);
71
+ });
72
+ }
73
+ /** Returns null when the mode is `off`, having constructed and patched nothing. */
74
+ export function startPolly(options) {
75
+ const { mode, recordingName, policy, recordingsDir = RECORDINGS_DIR, alwaysPassthrough = [], saveDebounceMs = SAVE_DEBOUNCE_MS, logLevel = DEFAULT_LOG_LEVEL, } = options;
76
+ if (mode === 'off') {
77
+ return null;
78
+ }
79
+ if (!registered) {
80
+ Polly.register(NodeHttpAdapter);
81
+ Polly.register(UndiciAdapter);
82
+ Polly.register(FSPersister);
83
+ registered = true;
84
+ }
85
+ const polly = new Polly(recordingName, {
86
+ mode,
87
+ logLevel,
88
+ adapters: ['node-http', 'undici'],
89
+ persister: 'fs',
90
+ persisterOptions: {
91
+ keepUnusedRequests: KEEP_UNUSED_REQUESTS,
92
+ fs: { recordingsDir },
93
+ },
94
+ ...POLLY_DEFAULTS,
95
+ });
96
+ applyPolicy(polly.server, { policy, alwaysPassthrough });
97
+ // The adapters are process-wide, so which hosts are and are not mocked belongs
98
+ // in the boot log. Without it, "why is my database call being intercepted" is
99
+ // answered by reading source instead of scrollback.
100
+ const scope = policy.kind === 'all'
101
+ ? 'every host'
102
+ : `${policy.kind === 'only' ? 'only' : 'all except'} ${toOrigins(policy.hosts).join(', ') || '(none)'}`;
103
+ console.log(`polly-kit: ${mode} mode, mocking ${scope}; recordings in ${recordingsDir}`);
104
+ if (mode === 'record') {
105
+ autoSave(polly, saveDebounceMs);
106
+ }
107
+ return polly;
108
+ }
@@ -0,0 +1,28 @@
1
+ import type { HarContent } from './types.js';
2
+ /**
3
+ * Bodies, in the two forms Polly stores them. Nothing here uses `Buffer`: this module
4
+ * runs unchanged in the browser mount, where there is no Node.
5
+ */
6
+ export declare function utf8ByteLength(text: string): number;
7
+ /**
8
+ * A base64 body may or may not be text. `encoding: 'base64'` is set by the adapter for
9
+ * any binary buffer, so a PNG and a UTF-8 string with an odd byte both arrive the same
10
+ * way; only decoding tells them apart.
11
+ *
12
+ * `binary` is not a failure. It is the signal that the editor must show this entry
13
+ * read-only, because writing plaintext into a base64 field produces a mock that replays
14
+ * garbage with nothing to indicate it.
15
+ */
16
+ export declare function decodeContent(content: HarContent): {
17
+ kind: 'text';
18
+ text: string;
19
+ } | {
20
+ kind: 'binary';
21
+ };
22
+ /** Puts edited text back in whichever form the entry was stored in. */
23
+ export declare function encodeContent(text: string, encoding: string | undefined): string;
24
+ /**
25
+ * The length of the bytes an app will receive, which is what a `Content-Length` header
26
+ * has to agree with — not the length of the base64 text that describes them.
27
+ */
28
+ export declare function bodyByteLength(text: string, encoding: string | undefined): number;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Bodies, in the two forms Polly stores them. Nothing here uses `Buffer`: this module
3
+ * runs unchanged in the browser mount, where there is no Node.
4
+ */
5
+ export function utf8ByteLength(text) {
6
+ return new TextEncoder().encode(text).length;
7
+ }
8
+ /**
9
+ * A base64 body may or may not be text. `encoding: 'base64'` is set by the adapter for
10
+ * any binary buffer, so a PNG and a UTF-8 string with an odd byte both arrive the same
11
+ * way; only decoding tells them apart.
12
+ *
13
+ * `binary` is not a failure. It is the signal that the editor must show this entry
14
+ * read-only, because writing plaintext into a base64 field produces a mock that replays
15
+ * garbage with nothing to indicate it.
16
+ */
17
+ export function decodeContent(content) {
18
+ const text = content.text ?? '';
19
+ if (content.encoding !== 'base64')
20
+ return { kind: 'text', text };
21
+ const bytes = base64ToBytes(text);
22
+ if (bytes === null)
23
+ return { kind: 'binary' };
24
+ try {
25
+ return { kind: 'text', text: new TextDecoder('utf-8', { fatal: true }).decode(bytes) };
26
+ }
27
+ catch {
28
+ return { kind: 'binary' };
29
+ }
30
+ }
31
+ /** Puts edited text back in whichever form the entry was stored in. */
32
+ export function encodeContent(text, encoding) {
33
+ if (encoding !== 'base64')
34
+ return text;
35
+ return bytesToBase64(new TextEncoder().encode(text));
36
+ }
37
+ /**
38
+ * The length of the bytes an app will receive, which is what a `Content-Length` header
39
+ * has to agree with — not the length of the base64 text that describes them.
40
+ */
41
+ export function bodyByteLength(text, encoding) {
42
+ if (encoding !== 'base64')
43
+ return utf8ByteLength(text);
44
+ return base64ToBytes(text)?.length ?? 0;
45
+ }
46
+ function base64ToBytes(text) {
47
+ try {
48
+ const binary = atob(text);
49
+ const bytes = new Uint8Array(binary.length);
50
+ for (let i = 0; i < binary.length; i++)
51
+ bytes[i] = binary.charCodeAt(i);
52
+ return bytes;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ function bytesToBase64(bytes) {
59
+ let binary = '';
60
+ // Chunked so a large body cannot blow the argument limit of String.fromCharCode.
61
+ const chunk = 0x8000;
62
+ for (let i = 0; i < bytes.length; i += chunk) {
63
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
64
+ }
65
+ return btoa(binary);
66
+ }
@@ -0,0 +1,22 @@
1
+ import type { RecordingStore } from './stores/types.js';
2
+ export interface MountOptions {
3
+ /** Polly's localStorage namespace. Defaults to `'pollyjs'`. */
4
+ key?: string;
5
+ storage?: Storage;
6
+ /** Overrides the store entirely. Mainly for tests. */
7
+ store?: RecordingStore;
8
+ }
9
+ /**
10
+ * Mounts the editor into a browser app that has no server at all — the case where
11
+ * Polly records through `persister-local-storage` and there are no HAR *files* anywhere,
12
+ * only a JSON blob under one key.
13
+ *
14
+ * ```js
15
+ * mountPollyStudio(document.getElementById('polly'))
16
+ * ```
17
+ */
18
+ export declare function mountPollyStudio(root: HTMLElement, options?: MountOptions): void;
19
+ export { LocalStorageStore } from './stores/local-storage-store.js';
20
+ export { applyResponsePatch, deleteEntry } from './edit.js';
21
+ export { parseHar } from './har.js';
22
+ export type { Har, HarEntry, ResponsePatch } from './types.js';
@@ -0,0 +1,24 @@
1
+ import { mountStudio } from './ui/app.js';
2
+ import { LocalStorageStore } from './stores/local-storage-store.js';
3
+ import { STUDIO_MARKUP, STUDIO_STYLE } from './ui/page.js';
4
+ /**
5
+ * Mounts the editor into a browser app that has no server at all — the case where
6
+ * Polly records through `persister-local-storage` and there are no HAR *files* anywhere,
7
+ * only a JSON blob under one key.
8
+ *
9
+ * ```js
10
+ * mountPollyStudio(document.getElementById('polly'))
11
+ * ```
12
+ */
13
+ export function mountPollyStudio(root, options = {}) {
14
+ const store = options.store ??
15
+ new LocalStorageStore({
16
+ ...(options.key === undefined ? {} : { key: options.key }),
17
+ ...(options.storage === undefined ? {} : { storage: options.storage }),
18
+ });
19
+ root.innerHTML = `<style>${STUDIO_STYLE}</style>${STUDIO_MARKUP}`;
20
+ mountStudio(root, store);
21
+ }
22
+ export { LocalStorageStore } from './stores/local-storage-store.js';
23
+ export { applyResponsePatch, deleteEntry } from './edit.js';
24
+ export { parseHar } from './har.js';
@@ -0,0 +1,290 @@
1
+ // dist/studio/body.js
2
+ function decodeContent(content) {
3
+ const text = content.text ?? "";
4
+ if (content.encoding !== "base64")
5
+ return { kind: "text", text };
6
+ const bytes = base64ToBytes(text);
7
+ if (bytes === null)
8
+ return { kind: "binary" };
9
+ try {
10
+ return { kind: "text", text: new TextDecoder("utf-8", { fatal: true }).decode(bytes) };
11
+ } catch {
12
+ return { kind: "binary" };
13
+ }
14
+ }
15
+ function base64ToBytes(text) {
16
+ try {
17
+ const binary = atob(text);
18
+ const bytes = new Uint8Array(binary.length);
19
+ for (let i = 0; i < binary.length; i++)
20
+ bytes[i] = binary.charCodeAt(i);
21
+ return bytes;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ // dist/studio/headers.js
28
+ function getHeader(headers, name) {
29
+ const wanted = name.toLowerCase();
30
+ return headers.find((header) => header.name.toLowerCase() === wanted)?.value;
31
+ }
32
+
33
+ // dist/studio/ui/render.js
34
+ function escapeHtml(value) {
35
+ return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
36
+ }
37
+ function renderNumeric(value) {
38
+ return escapeHtml(String(value));
39
+ }
40
+ function renderRecordingList(recordings) {
41
+ if (recordings.length === 0) {
42
+ return `<p class="empty">No recordings here yet. Run your app in Polly's record mode first.</p>`;
43
+ }
44
+ const rows = recordings.map((recording) => {
45
+ const note = recording.problem ? `<p class="ps-rec-note problem">${escapeHtml(recording.problem)}</p>` : recording.pollyWillReject ? `<p class="ps-rec-note warn">Polly will reject this: ${escapeHtml(recording.pollyWillReject)}</p>` : "";
46
+ return `<li><button class="recording" data-id="${escapeHtml(recording.id)}"><span class="ps-rec-name">${escapeHtml(recording.id)}</span><span class="count">${renderNumeric(recording.entryCount)}</span></button>${note}</li>`;
47
+ }).join("");
48
+ return `<ul class="recordings">${rows}</ul>`;
49
+ }
50
+ function unrenderable(err) {
51
+ return escapeHtml(err instanceof Error ? err.message : String(err));
52
+ }
53
+ function renderEntryRow(entry) {
54
+ try {
55
+ const order = entry._order > 0 ? ` <span class="order">#${renderNumeric(entry._order)}</span>` : "";
56
+ return `<li><button class="entry" data-id="${escapeHtml(entry._id)}" data-order="${renderNumeric(entry._order)}"><span class="method">${escapeHtml(entry.request.method)}</span> <span class="status s${Math.floor(entry.response.status / 100)}">${renderNumeric(entry.response.status)}</span> <span class="url">${escapeHtml(entry.request.url)}</span>${order}</button></li>`;
57
+ } catch (err) {
58
+ console.error("polly-studio: could not render entry row", err);
59
+ return `<li class="problem">this entry could not be rendered: ${unrenderable(err)}</li>`;
60
+ }
61
+ }
62
+ function renderEntryDetail(entry) {
63
+ try {
64
+ return entryDetail(entry);
65
+ } catch (err) {
66
+ console.error("polly-studio: could not render entry detail", err);
67
+ return `<section class="problem"><p>this entry could not be rendered: ${unrenderable(err)}</p></section>`;
68
+ }
69
+ }
70
+ function entryDetail(entry) {
71
+ const response = entry.response;
72
+ const decoded = decodeContent(response.content);
73
+ const body = decoded.kind === "binary" ? `<p class="ps-notice warn">This body is binary (${escapeHtml(getHeader(response.headers, "content-type") ?? "unknown type")}, ${renderNumeric(response.content.size)} bytes). Editing it as text would replace the recorded bytes with a UTF-8 rendering of them, so it is shown read-only.</p>` : `<textarea name="bodyText" rows="16" spellcheck="false" aria-label="Response body">${escapeHtml(decoded.text)}</textarea>`;
74
+ const headers = response.headers.map((header, index) => `<div class="header-row"><input name="headerName${index}" value="${escapeHtml(header.name)}" class="ps-header-name" spellcheck="false" aria-label="Header name"><input name="headerValue${index}" value="${escapeHtml(header.value)}" class="ps-header-value" spellcheck="false" aria-label="Header value"></div>`).join("");
75
+ return `
76
+ <section class="detail" data-id="${escapeHtml(entry._id)}" data-order="${renderNumeric(entry._order)}">
77
+ <div class="ps-block ps-block--request">
78
+ <h2>Request <small>read-only</small></h2>
79
+ <p class="note">Polly matches a replayed request on <code>_id</code>, an md5 of the request built
80
+ from your <code>matchRequestsBy</code> config \u2014 and that config is not stored in the recording.
81
+ Editing any of this would leave an entry that looks right and never matches again.</p>
82
+ <dl>
83
+ <dt>Method</dt><dd>${escapeHtml(entry.request.method)}</dd>
84
+ <dt>URL</dt><dd>${escapeHtml(entry.request.url)}</dd>
85
+ <dt>Recorded</dt><dd>${escapeHtml(entry.startedDateTime)}</dd>
86
+ <dt>_id / _order</dt><dd><code>${escapeHtml(entry._id)}</code> / ${renderNumeric(entry._order)}</dd>
87
+ </dl>
88
+ </div>
89
+
90
+ <div class="ps-block ps-block--response">
91
+ <h2>Response</h2>
92
+ <div class="ps-fields">
93
+ <label class="ps-field"><span class="ps-field-label">Status</span><input name="status" type="number" value="${renderNumeric(response.status)}" class="ps-input" min="100" max="599"></label>
94
+ <label class="ps-field"><span class="ps-field-label">Status text</span><input name="statusText" value="${escapeHtml(response.statusText)}" class="ps-input" spellcheck="false"></label>
95
+ </div>
96
+
97
+ <h3 class="ps-group-title">Headers</h3>
98
+ ${headers === "" ? '<p class="empty">This response was recorded without headers.</p>' : `<div class="headers">${headers}</div>`}
99
+
100
+ <h3 class="ps-group-title">Body</h3>
101
+ ${body}
102
+
103
+ <div class="actions">
104
+ <button name="save">Save response</button>
105
+ <button name="delete" class="danger">Delete entry</button>
106
+ </div>
107
+ </div>
108
+ </section>`;
109
+ }
110
+
111
+ // dist/studio/stores/types.js
112
+ var StoreError = class extends Error {
113
+ constructor(code, message) {
114
+ super(message);
115
+ this.name = "StoreError";
116
+ this.code = code;
117
+ }
118
+ };
119
+
120
+ // dist/studio/ui/app.js
121
+ function mountStudio(root, store) {
122
+ const recordings = required(root, "#recordings");
123
+ const entries = required(root, "#entries");
124
+ const detail = required(root, "#detail");
125
+ let currentId = null;
126
+ let current = null;
127
+ refreshRecordings().catch(report);
128
+ async function refreshRecordings() {
129
+ recordings.innerHTML = renderRecordingList(await store.list());
130
+ }
131
+ async function openRecording(id) {
132
+ const read = await store.read(id);
133
+ currentId = id;
134
+ current = { har: read.har, etag: read.etag };
135
+ entries.innerHTML = `<ul>${read.har.log.entries.map(renderEntryRow).join("")}</ul>`;
136
+ detail.innerHTML = "";
137
+ }
138
+ function openEntry(entryId, order) {
139
+ const entry = current?.har.log.entries.find((e) => String(e._id) === entryId && e._order === order);
140
+ if (entry === void 0) {
141
+ detail.innerHTML = "";
142
+ report(new Error(`no entry with _id ${entryId} and _order ${order} in this recording`));
143
+ return;
144
+ }
145
+ detail.innerHTML = renderEntryDetail(entry);
146
+ }
147
+ async function save(entryId, order) {
148
+ if (currentId === null || current === null)
149
+ return;
150
+ try {
151
+ const patch = readPatch(detail, entryId, order, current.har);
152
+ await store.patchEntry(currentId, entryId, order, current.etag, patch);
153
+ await openRecording(currentId);
154
+ openEntry(entryId, order);
155
+ } catch (err) {
156
+ report(err);
157
+ }
158
+ }
159
+ async function remove(entryId, order) {
160
+ if (currentId === null || current === null)
161
+ return;
162
+ try {
163
+ await store.deleteEntry(currentId, entryId, order, current.etag);
164
+ await openRecording(currentId);
165
+ } catch (err) {
166
+ report(err);
167
+ }
168
+ }
169
+ function report(err) {
170
+ const message = err instanceof StoreError && err.code === "conflict" ? "the recording changed underneath \u2014 reload and retry" : err instanceof Error ? err.message : String(err);
171
+ const banner = detail.ownerDocument.createElement("p");
172
+ banner.className = "problem";
173
+ banner.textContent = message;
174
+ detail.prepend(banner);
175
+ }
176
+ root.addEventListener("click", (event) => {
177
+ const target = event.target?.closest("button");
178
+ if (target === null || target === void 0)
179
+ return;
180
+ if (target.classList.contains("recording")) {
181
+ openRecording(target.dataset["id"]).catch(report);
182
+ return;
183
+ }
184
+ if (target.classList.contains("entry")) {
185
+ try {
186
+ openEntry(target.dataset["id"], Number(target.dataset["order"]));
187
+ } catch (err) {
188
+ report(err);
189
+ }
190
+ return;
191
+ }
192
+ const section = target.closest(".detail");
193
+ if (section === null)
194
+ return;
195
+ const entryId = section.dataset["id"];
196
+ const order = Number(section.dataset["order"]);
197
+ if (target.getAttribute("name") === "save")
198
+ void save(entryId, order);
199
+ if (target.getAttribute("name") === "delete")
200
+ void remove(entryId, order);
201
+ });
202
+ }
203
+ function required(root, selector) {
204
+ const el = root.querySelector(selector);
205
+ if (el === null) {
206
+ throw new Error(`polly-studio: mountStudio could not find "${selector}" in the given root`);
207
+ }
208
+ return el;
209
+ }
210
+ function readPatch(root, entryId, order, har) {
211
+ const value = (name) => root.querySelector(`[name="${name}"]`)?.value;
212
+ const headers = [];
213
+ for (let index = 0; ; index++) {
214
+ const name = value(`headerName${index}`);
215
+ if (name === void 0)
216
+ break;
217
+ const existing = har.log.entries.find((e) => String(e._id) === entryId && e._order === order)?.response.headers[index];
218
+ headers.push({
219
+ ...existing?._fromType === "array" ? { _fromType: "array" } : {},
220
+ name,
221
+ value: value(`headerValue${index}`) ?? ""
222
+ });
223
+ }
224
+ const rawStatus = value("status");
225
+ const status = rawStatus === void 0 || rawStatus === "" ? void 0 : Number(rawStatus);
226
+ if (status !== void 0 && (!Number.isFinite(status) || status < 100 || status > 599)) {
227
+ throw new Error(`status must be a number between 100 and 599, got "${rawStatus}"`);
228
+ }
229
+ const bodyText = value("bodyText");
230
+ return {
231
+ ...status === void 0 ? {} : { status },
232
+ ...value("statusText") === void 0 ? {} : { statusText: value("statusText") },
233
+ headers,
234
+ ...bodyText === void 0 ? {} : { bodyText }
235
+ };
236
+ }
237
+
238
+ // dist/studio/stores/http-store.js
239
+ var HttpStore = class {
240
+ constructor(basePath) {
241
+ this.basePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
242
+ }
243
+ async list() {
244
+ const body = await this.json("GET", "/api/recordings");
245
+ return body.recordings;
246
+ }
247
+ async read(id) {
248
+ return await this.json("GET", `/api/recordings/${encodeURIComponent(id)}`);
249
+ }
250
+ /**
251
+ * Not on the wire protocol. The UI edits through `patchEntry`/`deleteEntry`. The
252
+ * parameters are declared (and ignored) rather than dropped so a caller holding a
253
+ * concrete `HttpStore` — not just a `RecordingStore` — can still call this with the
254
+ * interface's arity; TypeScript checks a direct call against the class's own
255
+ * declared signature, not the interface it implements.
256
+ */
257
+ async write(id, har, etag) {
258
+ throw new StoreError("forbidden", "the HTTP API applies edits itself \u2014 use patchEntry");
259
+ }
260
+ async delete(id) {
261
+ throw new StoreError("forbidden", "deleting a whole recording is not exposed over the API");
262
+ }
263
+ async patchEntry(id, entryId, order, etag, patch) {
264
+ return await this.json("PATCH", `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}`, { etag, patch });
265
+ }
266
+ async deleteEntry(id, entryId, order, etag) {
267
+ return await this.json("DELETE", `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}?etag=${encodeURIComponent(etag)}`);
268
+ }
269
+ async json(method, path, body) {
270
+ const res = await fetch(`${this.basePath}${path}`, {
271
+ method,
272
+ ...body === void 0 ? {} : { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }
273
+ });
274
+ const parsed = await res.json().catch(() => ({}));
275
+ if (res.ok)
276
+ return parsed;
277
+ const code = res.status === 404 ? "not-found" : res.status === 409 ? "conflict" : res.status === 403 ? "forbidden" : res.status === 422 ? "unprocessable" : null;
278
+ if (code !== null)
279
+ throw new StoreError(code, parsed.error ?? `${method} ${path} failed`);
280
+ throw new Error(parsed.error ?? `${method} ${path} failed with ${res.status}`);
281
+ }
282
+ };
283
+
284
+ // dist/studio/ui/client.js
285
+ function start(basePath) {
286
+ mountStudio(document, new HttpStore(basePath));
287
+ }
288
+ export {
289
+ start
290
+ };
@@ -0,0 +1,42 @@
1
+ import type { Har, ResponsePatch } from './types.js';
2
+ export type EditError = {
3
+ code: 'entry-not-found' | 'binary-body';
4
+ message: string;
5
+ };
6
+ export type EditResult = {
7
+ ok: true;
8
+ har: Har;
9
+ } | {
10
+ ok: false;
11
+ error: EditError;
12
+ };
13
+ /**
14
+ * Applies a response edit and re-derives everything that described the old response.
15
+ *
16
+ * Five fields are read at replay (`adapter/src/utils/normalize-recorded-response.js`):
17
+ * `status`, `statusText`, `headers`, `content.text` and `content.encoding`. Those must
18
+ * be right. `Content-Length` counts as one of them, because headers are handed to the
19
+ * app verbatim — a stale length contradicts the body beside it.
20
+ *
21
+ * The rest — `content.size`, `bodySize`, `headersSize`, `content.mimeType`,
22
+ * `redirectURL` — are HAR metadata Polly never reads. They are maintained anyway,
23
+ * because these are HAR files and other tools do read them.
24
+ *
25
+ * `cookies` is the exception. Polly builds it with `set-cookie-parser`, keeping
26
+ * attributes this package has no dependency to reproduce, so a regenerated list would
27
+ * be strictly worse than the recorded one. It is left alone, except that removing the
28
+ * last `Set-Cookie` header clears it — the one case where leaving it would be plainly
29
+ * wrong rather than merely stale.
30
+ */
31
+ export declare function applyResponsePatch(har: Har, id: string, order: number, patch: ResponsePatch): EditResult;
32
+ export declare function notFound(id: string, order: number): string;
33
+ /**
34
+ * Removes one entry and closes the gap it leaves in its `_id` group.
35
+ *
36
+ * `_order` is not a label, it is a position. Polly derives a request's order at replay
37
+ * by counting how many earlier requests in the recording produced the same `_id`,
38
+ * starting at 0 — so after deleting the `_order: 0` member of a pair, the survivor
39
+ * sitting at `_order: 1` is asked for as order 0 and never found again. It stays in the
40
+ * file, looks correct, and is dead. Renumbering the group from 0 is the whole fix.
41
+ */
42
+ export declare function deleteEntry(har: Har, id: string, order: number): EditResult;
@@ -0,0 +1,90 @@
1
+ import { findEntry } from './har.js';
2
+ import { bodyByteLength, decodeContent, encodeContent } from './body.js';
3
+ import { getHeader, hasHeader, responseHeadersSize, setHeader } from './headers.js';
4
+ /**
5
+ * Applies a response edit and re-derives everything that described the old response.
6
+ *
7
+ * Five fields are read at replay (`adapter/src/utils/normalize-recorded-response.js`):
8
+ * `status`, `statusText`, `headers`, `content.text` and `content.encoding`. Those must
9
+ * be right. `Content-Length` counts as one of them, because headers are handed to the
10
+ * app verbatim — a stale length contradicts the body beside it.
11
+ *
12
+ * The rest — `content.size`, `bodySize`, `headersSize`, `content.mimeType`,
13
+ * `redirectURL` — are HAR metadata Polly never reads. They are maintained anyway,
14
+ * because these are HAR files and other tools do read them.
15
+ *
16
+ * `cookies` is the exception. Polly builds it with `set-cookie-parser`, keeping
17
+ * attributes this package has no dependency to reproduce, so a regenerated list would
18
+ * be strictly worse than the recorded one. It is left alone, except that removing the
19
+ * last `Set-Cookie` header clears it — the one case where leaving it would be plainly
20
+ * wrong rather than merely stale.
21
+ */
22
+ export function applyResponsePatch(har, id, order, patch) {
23
+ const existing = findEntry(har, id, order);
24
+ if (existing === undefined) {
25
+ return { ok: false, error: { code: 'entry-not-found', message: notFound(id, order) } };
26
+ }
27
+ if (patch.bodyText !== undefined && decodeContent(existing.response.content).kind === 'binary') {
28
+ return {
29
+ ok: false,
30
+ error: {
31
+ code: 'binary-body',
32
+ message: 'this response body is binary — editing it as text would replace the recorded bytes with a UTF-8 rendering of them',
33
+ },
34
+ };
35
+ }
36
+ const next = structuredClone(har);
37
+ const entry = findEntry(next, id, order);
38
+ const response = entry.response;
39
+ if (patch.status !== undefined)
40
+ response.status = patch.status;
41
+ if (patch.statusText !== undefined)
42
+ response.statusText = patch.statusText;
43
+ const hadSetCookie = hasHeader(response.headers, 'set-cookie');
44
+ if (patch.headers !== undefined)
45
+ response.headers = patch.headers;
46
+ if (patch.bodyText !== undefined) {
47
+ response.content.text = encodeContent(patch.bodyText, response.content.encoding);
48
+ }
49
+ const bytes = bodyByteLength(response.content.text ?? '', response.content.encoding);
50
+ response.content.size = bytes;
51
+ response.bodySize = bytes;
52
+ // Set it only where the recording had one: adding a Content-Length to a response
53
+ // that was chunked would describe the transfer differently than it was recorded.
54
+ if (hasHeader(response.headers, 'content-length')) {
55
+ response.headers = setHeader(response.headers, 'content-length', String(bytes));
56
+ }
57
+ response.content.mimeType = getHeader(response.headers, 'content-type') ?? 'text/plain';
58
+ response.redirectURL = getHeader(response.headers, 'location') ?? '';
59
+ response.headersSize = responseHeadersSize(response.headers);
60
+ if (hadSetCookie && !hasHeader(response.headers, 'set-cookie')) {
61
+ response.cookies = [];
62
+ }
63
+ return { ok: true, har: next };
64
+ }
65
+ export function notFound(id, order) {
66
+ return `no entry with _id ${id} and _order ${order} — it may have been deleted since the page loaded`;
67
+ }
68
+ /**
69
+ * Removes one entry and closes the gap it leaves in its `_id` group.
70
+ *
71
+ * `_order` is not a label, it is a position. Polly derives a request's order at replay
72
+ * by counting how many earlier requests in the recording produced the same `_id`,
73
+ * starting at 0 — so after deleting the `_order: 0` member of a pair, the survivor
74
+ * sitting at `_order: 1` is asked for as order 0 and never found again. It stays in the
75
+ * file, looks correct, and is dead. Renumbering the group from 0 is the whole fix.
76
+ */
77
+ export function deleteEntry(har, id, order) {
78
+ if (findEntry(har, id, order) === undefined) {
79
+ return { ok: false, error: { code: 'entry-not-found', message: notFound(id, order) } };
80
+ }
81
+ const next = structuredClone(har);
82
+ next.log.entries = next.log.entries.filter((entry) => !(entry._id === id && entry._order === order));
83
+ const group = next.log.entries
84
+ .filter((entry) => entry._id === id)
85
+ .sort((a, b) => a._order - b._order);
86
+ group.forEach((entry, index) => {
87
+ entry._order = index;
88
+ });
89
+ return { ok: true, har: next };
90
+ }