@avivbenhamo/polly-kit 0.1.0 → 0.3.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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -10
  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.js +5 -19
  8. package/dist/defaults.d.ts +40 -0
  9. package/dist/defaults.js +40 -0
  10. package/dist/node.d.ts +56 -0
  11. package/dist/node.js +108 -0
  12. package/dist/studio/body.d.ts +28 -0
  13. package/dist/studio/body.js +66 -0
  14. package/dist/studio/browser.d.ts +22 -0
  15. package/dist/studio/browser.js +24 -0
  16. package/dist/studio/client.bundle.js +290 -0
  17. package/dist/studio/edit.d.ts +42 -0
  18. package/dist/studio/edit.js +90 -0
  19. package/dist/studio/etag.d.ts +6 -0
  20. package/dist/studio/etag.js +14 -0
  21. package/dist/studio/har.d.ts +19 -0
  22. package/dist/studio/har.js +36 -0
  23. package/dist/studio/headers.d.ts +31 -0
  24. package/dist/studio/headers.js +59 -0
  25. package/dist/studio/index.d.ts +9 -0
  26. package/dist/studio/index.js +5 -0
  27. package/dist/studio/server.d.ts +35 -0
  28. package/dist/studio/server.js +262 -0
  29. package/dist/studio/stores/apply.d.ts +6 -0
  30. package/dist/studio/stores/apply.js +20 -0
  31. package/dist/studio/stores/fs-store.d.ts +70 -0
  32. package/dist/studio/stores/fs-store.js +186 -0
  33. package/dist/studio/stores/http-store.d.ts +30 -0
  34. package/dist/studio/stores/http-store.js +67 -0
  35. package/dist/studio/stores/local-storage-store.d.ts +25 -0
  36. package/dist/studio/stores/local-storage-store.js +109 -0
  37. package/dist/studio/stores/types.d.ts +45 -0
  38. package/dist/studio/stores/types.js +7 -0
  39. package/dist/studio/types.d.ts +92 -0
  40. package/dist/studio/types.js +7 -0
  41. package/dist/studio/ui/app.d.ts +12 -0
  42. package/dist/studio/ui/app.js +180 -0
  43. package/dist/studio/ui/client.d.ts +2 -0
  44. package/dist/studio/ui/client.js +6 -0
  45. package/dist/studio/ui/page.d.ts +53 -0
  46. package/dist/studio/ui/page.js +360 -0
  47. package/dist/studio/ui/render.d.ts +25 -0
  48. package/dist/studio/ui/render.js +171 -0
  49. package/dist/vite.js +1 -1
  50. package/package.json +49 -7
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A content hash for optimistic concurrency, not for security. FNV-1a keeps this
3
+ * dependency-free and identical in Node and the browser, which matters because the
4
+ * page compares an etag it was given against one the store computed.
5
+ */
6
+ export declare function etagFor(serialized: string): string;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A content hash for optimistic concurrency, not for security. FNV-1a keeps this
3
+ * dependency-free and identical in Node and the browser, which matters because the
4
+ * page compares an etag it was given against one the store computed.
5
+ */
6
+ export function etagFor(serialized) {
7
+ const bytes = new TextEncoder().encode(serialized);
8
+ let hash = 0x811c9dc5;
9
+ for (const byte of bytes) {
10
+ hash ^= byte;
11
+ hash = Math.imul(hash, 0x01000193) >>> 0;
12
+ }
13
+ return `${hash.toString(16).padStart(8, '0')}-${bytes.length.toString(16)}`;
14
+ }
@@ -0,0 +1,19 @@
1
+ import type { Har, HarEntry } from './types.js';
2
+ /** `Persister.findRecording` asserts on this exact string. */
3
+ export declare const POLLY_CREATOR_NAME = "Polly.JS";
4
+ export type ParseResult = {
5
+ ok: true;
6
+ har: Har;
7
+ /**
8
+ * Non-null when the file is readable but Polly itself will refuse it. The UI
9
+ * shows this rather than pretending the recording is fine; the alternative is
10
+ * a user editing a file that their app has already given up on.
11
+ */
12
+ pollyWillReject: string | null;
13
+ } | {
14
+ ok: false;
15
+ problem: string;
16
+ };
17
+ export declare function parseHar(raw: string): ParseResult;
18
+ /** Replay's own lookup, reproduced: `entry._id === id && entry._order === order`. */
19
+ export declare function findEntry(har: Har, id: string, order: number): HarEntry | undefined;
@@ -0,0 +1,36 @@
1
+ /** `Persister.findRecording` asserts on this exact string. */
2
+ export const POLLY_CREATOR_NAME = 'Polly.JS';
3
+ export function parseHar(raw) {
4
+ let parsed;
5
+ try {
6
+ parsed = JSON.parse(raw);
7
+ }
8
+ catch (err) {
9
+ return { ok: false, problem: err instanceof Error ? err.message : String(err) };
10
+ }
11
+ if (!isHarShaped(parsed)) {
12
+ return { ok: false, problem: 'valid JSON, but not a HAR log with an entries array' };
13
+ }
14
+ const creator = parsed.log.creator?.name;
15
+ const pollyWillReject = creator === POLLY_CREATOR_NAME
16
+ ? null
17
+ : `log.creator.name is ${JSON.stringify(creator ?? null)}, but Polly only reads recordings whose creator is "${POLLY_CREATOR_NAME}" — it will ask you to delete this file`;
18
+ return { ok: true, har: parsed, pollyWillReject };
19
+ }
20
+ /**
21
+ * A shape check, not a schema validator. It exists to tell a HAR apart from some other
22
+ * JSON that landed in the directory — enough for a listing to say something true,
23
+ * without restating the types as runtime assertions.
24
+ */
25
+ function isHarShaped(value) {
26
+ if (value === null || typeof value !== 'object')
27
+ return false;
28
+ const log = value.log;
29
+ if (log === null || typeof log !== 'object')
30
+ return false;
31
+ return Array.isArray(log.entries);
32
+ }
33
+ /** Replay's own lookup, reproduced: `entry._id === id && entry._order === order`. */
34
+ export function findEntry(har, id, order) {
35
+ return har.log.entries.find((entry) => entry._id === id && entry._order === order);
36
+ }
@@ -0,0 +1,31 @@
1
+ import type { NvPair } from './types.js';
2
+ /**
3
+ * HAR headers are an ordered list, not a map. Repeats carry meaning — two `Set-Cookie`
4
+ * values are two cookies — and Polly's `normalizeHeaders` folds repeats into an array
5
+ * on replay, so collapsing them here would change what the app receives.
6
+ *
7
+ * Everything below returns a new array. The store writes what it is given, so a helper
8
+ * that mutated its input would edit a recording the caller had not decided to save.
9
+ */
10
+ export declare function getHeader(headers: NvPair[], name: string): string | undefined;
11
+ export declare function hasHeader(headers: NvPair[], name: string): boolean;
12
+ /**
13
+ * Replaces every occurrence of `name` with a single pair holding `value`, at the
14
+ * position of the first occurrence, or appends when there was none.
15
+ *
16
+ * Collapsing repeats is deliberate: this is only ever called with a single value, and
17
+ * leaving the other occurrences in place would mean setting `Content-Length` to 12
18
+ * while a second `Content-Length: 7` still followed it. The recorded spelling of the
19
+ * name and its `_fromType` marker are kept, since replay reads the latter.
20
+ */
21
+ export declare function setHeader(headers: NvPair[], name: string, value: string): NvPair[];
22
+ /**
23
+ * `persister/src/har/response.js`, reproduced:
24
+ *
25
+ * getByteLength(keys.join() + values.join()) + keys.length * 2 + 2 + 2
26
+ *
27
+ * Nothing at replay reads this — it is HAR metadata for other tools — but leaving it
28
+ * describing the previous headers is the kind of inconsistency that makes a file look
29
+ * hand-mangled.
30
+ */
31
+ export declare function responseHeadersSize(headers: NvPair[]): number;
@@ -0,0 +1,59 @@
1
+ import { utf8ByteLength } from './body.js';
2
+ /**
3
+ * HAR headers are an ordered list, not a map. Repeats carry meaning — two `Set-Cookie`
4
+ * values are two cookies — and Polly's `normalizeHeaders` folds repeats into an array
5
+ * on replay, so collapsing them here would change what the app receives.
6
+ *
7
+ * Everything below returns a new array. The store writes what it is given, so a helper
8
+ * that mutated its input would edit a recording the caller had not decided to save.
9
+ */
10
+ export function getHeader(headers, name) {
11
+ const wanted = name.toLowerCase();
12
+ return headers.find((header) => header.name.toLowerCase() === wanted)?.value;
13
+ }
14
+ export function hasHeader(headers, name) {
15
+ return getHeader(headers, name) !== undefined;
16
+ }
17
+ /**
18
+ * Replaces every occurrence of `name` with a single pair holding `value`, at the
19
+ * position of the first occurrence, or appends when there was none.
20
+ *
21
+ * Collapsing repeats is deliberate: this is only ever called with a single value, and
22
+ * leaving the other occurrences in place would mean setting `Content-Length` to 12
23
+ * while a second `Content-Length: 7` still followed it. The recorded spelling of the
24
+ * name and its `_fromType` marker are kept, since replay reads the latter.
25
+ */
26
+ export function setHeader(headers, name, value) {
27
+ const wanted = name.toLowerCase();
28
+ const first = headers.find((header) => header.name.toLowerCase() === wanted);
29
+ if (first === undefined)
30
+ return [...headers, { name, value }];
31
+ const replacement = { ...first, value };
32
+ const result = [];
33
+ let placed = false;
34
+ for (const header of headers) {
35
+ if (header.name.toLowerCase() !== wanted) {
36
+ result.push(header);
37
+ continue;
38
+ }
39
+ if (!placed) {
40
+ result.push(replacement);
41
+ placed = true;
42
+ }
43
+ }
44
+ return result;
45
+ }
46
+ /**
47
+ * `persister/src/har/response.js`, reproduced:
48
+ *
49
+ * getByteLength(keys.join() + values.join()) + keys.length * 2 + 2 + 2
50
+ *
51
+ * Nothing at replay reads this — it is HAR metadata for other tools — but leaving it
52
+ * describing the previous headers is the kind of inconsistency that makes a file look
53
+ * hand-mangled.
54
+ */
55
+ export function responseHeadersSize(headers) {
56
+ const names = headers.map((header) => header.name);
57
+ const values = headers.map((header) => header.value);
58
+ return utf8ByteLength(names.join(',') + values.join(',')) + names.length * 2 + 2 + 2;
59
+ }
@@ -0,0 +1,9 @@
1
+ export { pollyUi } from './server.js';
2
+ export type { UiOptions } from './server.js';
3
+ export { FsStore, serializeHar } from './stores/fs-store.js';
4
+ export { StoreError } from './stores/types.js';
5
+ export type { RecordingStore, RecordingSummary, ReadResult } from './stores/types.js';
6
+ export { parseHar, findEntry, POLLY_CREATOR_NAME } from './har.js';
7
+ export { applyResponsePatch, deleteEntry } from './edit.js';
8
+ export type { EditError, EditResult } from './edit.js';
9
+ export type { Har, HarEntry, HarLog, HarRequest, HarResponse, HarContent, NvPair, ResponsePatch, } from './types.js';
@@ -0,0 +1,5 @@
1
+ export { pollyUi } from './server.js';
2
+ export { FsStore, serializeHar } from './stores/fs-store.js';
3
+ export { StoreError } from './stores/types.js';
4
+ export { parseHar, findEntry, POLLY_CREATOR_NAME } from './har.js';
5
+ export { applyResponsePatch, deleteEntry } from './edit.js';
@@ -0,0 +1,35 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ export interface UiOptions {
3
+ /** Where Polly's recordings live. Relative paths resolve against `cwd`. */
4
+ recordingsDir: string;
5
+ /**
6
+ * Path the handler is mounted at, stripped from the URL before routing.
7
+ *
8
+ * Express strips the mount path itself, so `app.use('/__polly', pollyUi(...))` needs
9
+ * nothing here. A raw `http.createServer` does not, so pass the prefix you route on.
10
+ */
11
+ basePath?: string;
12
+ /**
13
+ * Defaults to `process.env.NODE_ENV !== 'production'`.
14
+ *
15
+ * Recordings hold recorded request and response bodies. A handler that served them
16
+ * regardless of environment would turn "someone mounted this and shipped it" into a
17
+ * data leak, so when disabled it answers 404 — the same answer an unmounted path
18
+ * gives, rather than a 403 that confirms something is there.
19
+ */
20
+ enabled?: boolean;
21
+ cwd?: string;
22
+ }
23
+ /**
24
+ * A mountable editor for Polly recordings, in the style of swagger-ui: the app serves
25
+ * it, so there is no second process and no extra port.
26
+ *
27
+ * ```ts
28
+ * app.use('/__polly', pollyUi({ recordingsDir: './recordings' }))
29
+ * ```
30
+ *
31
+ * Returns a bare `(req, res)` handler rather than an Express router so the package
32
+ * takes no framework dependency — this signature is what Express, Fastify's compat
33
+ * layer and `http.createServer` all accept.
34
+ */
35
+ export declare function pollyUi(options: UiOptions): (req: IncomingMessage, res: ServerResponse) => void;
@@ -0,0 +1,262 @@
1
+ import { isAbsolute, join } from 'node:path';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { FsStore } from './stores/fs-store.js';
5
+ import { StoreError } from './stores/types.js';
6
+ import { applyResponsePatch, deleteEntry } from './edit.js';
7
+ import { renderPage } from './ui/page.js';
8
+ /**
9
+ * A mountable editor for Polly recordings, in the style of swagger-ui: the app serves
10
+ * it, so there is no second process and no extra port.
11
+ *
12
+ * ```ts
13
+ * app.use('/__polly', pollyUi({ recordingsDir: './recordings' }))
14
+ * ```
15
+ *
16
+ * Returns a bare `(req, res)` handler rather than an Express router so the package
17
+ * takes no framework dependency — this signature is what Express, Fastify's compat
18
+ * layer and `http.createServer` all accept.
19
+ */
20
+ export function pollyUi(options) {
21
+ const cwd = options.cwd ?? process.cwd();
22
+ const root = isAbsolute(options.recordingsDir)
23
+ ? options.recordingsDir
24
+ : join(cwd, options.recordingsDir);
25
+ const store = new FsStore(root);
26
+ const basePath = trimTrailingSlash(options.basePath ?? '');
27
+ const enabled = options.enabled ?? process.env['NODE_ENV'] !== 'production';
28
+ return (req, res) => {
29
+ handle(req, res, { store, basePath, enabled }).catch((err) => {
30
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
31
+ });
32
+ };
33
+ }
34
+ async function handle(req, res, ctx) {
35
+ if (!ctx.enabled) {
36
+ // Bare: no body, no content-type. A JSON `{"error":"not found"}` would fingerprint the
37
+ // handler as mounted-and-disabled against a framework's own HTML 404, which is the
38
+ // opposite of what "indistinguishable from an unmounted path" asks for.
39
+ res.writeHead(404);
40
+ res.end();
41
+ return;
42
+ }
43
+ const url = new URL(req.url ?? '/', 'http://localhost');
44
+ let path = url.pathname;
45
+ // The prefix must end at a segment boundary. A bare `startsWith` also matches
46
+ // `/__pollyapi/recordings/x`, which slices to `api/recordings/x` — byte-identical to
47
+ // what the legitimate `/__polly/api/recordings/x` produces, so it would serve the
48
+ // recording from a URL outside the mount.
49
+ if (ctx.basePath !== '' && (path === ctx.basePath || path.startsWith(`${ctx.basePath}/`))) {
50
+ path = path.slice(ctx.basePath.length);
51
+ }
52
+ if (path === '')
53
+ path = '/';
54
+ try {
55
+ if (path === '/' && (req.method === 'GET' || req.method === 'HEAD')) {
56
+ // Rendered before a single header is written. `renderPage` is a placeholder that
57
+ // cannot throw today, but Task 10 replaces its body — and a throw after `writeHead`
58
+ // would make the catch-all's own `writeHead(500)` raise ERR_HTTP_HEADERS_SENT inside
59
+ // a `.catch()`, i.e. an unhandled rejection that takes the host app down with it.
60
+ const html = req.method === 'HEAD' ? '' : renderPage(await clientScript());
61
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
62
+ res.end(html);
63
+ return;
64
+ }
65
+ if (path === '/api/recordings' && req.method === 'GET') {
66
+ sendJson(res, 200, { recordings: await ctx.store.list() });
67
+ return;
68
+ }
69
+ // /api/recordings/:id and /api/recordings/:id/entries/:_id/:_order
70
+ const segments = path.split('/').filter((segment) => segment !== '');
71
+ const rawId = segments[2];
72
+ if (segments[0] === 'api' && segments[1] === 'recordings' && rawId !== undefined) {
73
+ const id = decodeSegment(rawId);
74
+ if (id === undefined) {
75
+ sendJson(res, 400, { error: `recording id ${rawId} is not valid percent-encoding` });
76
+ return;
77
+ }
78
+ if (segments.length === 3 && req.method === 'GET') {
79
+ const { har, etag, pollyWillReject } = await ctx.store.read(id);
80
+ sendJson(res, 200, { har, etag, pollyWillReject });
81
+ return;
82
+ }
83
+ const rawEntryId = segments[4];
84
+ if (segments.length === 6 && segments[3] === 'entries' && rawEntryId !== undefined) {
85
+ const entryId = decodeSegment(rawEntryId);
86
+ if (entryId === undefined) {
87
+ sendJson(res, 400, { error: `entry id ${rawEntryId} is not valid percent-encoding` });
88
+ return;
89
+ }
90
+ const order = Number(segments[5]);
91
+ if (req.method === 'PATCH') {
92
+ const body = await readJson(req);
93
+ if (!body.ok) {
94
+ if (body.status === 413)
95
+ res.setHeader('connection', 'close');
96
+ sendJson(res, body.status, { error: body.error });
97
+ return;
98
+ }
99
+ // A body of `null`, `[]` or `"x"` is valid JSON but has no fields. Coercing to
100
+ // `{}` keeps destructuring off a non-object, which would throw into the 500.
101
+ const fields = (typeof body.value === 'object' && body.value !== null ? body.value : {});
102
+ await mutate(ctx, res, id, fields.etag ?? '', (har) => applyResponsePatch(har, entryId, order, fields.patch ?? {}));
103
+ return;
104
+ }
105
+ if (req.method === 'DELETE') {
106
+ const etag = url.searchParams.get('etag') ?? '';
107
+ await mutate(ctx, res, id, etag, (har) => deleteEntry(har, entryId, order));
108
+ return;
109
+ }
110
+ }
111
+ }
112
+ sendJson(res, 404, { error: 'not found' });
113
+ }
114
+ catch (err) {
115
+ if (err instanceof StoreError) {
116
+ // `unprocessable` is unreachable while this handler calls only list/read/write, but
117
+ // Task 11's HttpStore and any switch to `store.patchEntry` make it reachable — and
118
+ // it would arrive as a binary-body refusal, which is a 422, not a 403.
119
+ const status = err.code === 'not-found'
120
+ ? 404
121
+ : err.code === 'conflict'
122
+ ? 409
123
+ : err.code === 'unprocessable'
124
+ ? 422
125
+ : 403;
126
+ sendJson(res, status, { error: err.message, code: err.code });
127
+ return;
128
+ }
129
+ throw err;
130
+ }
131
+ }
132
+ /** Read, apply, write — with the etag the page was holding, so a clash is a 409. */
133
+ async function mutate(ctx, res, id, etag, apply) {
134
+ const read = await ctx.store.read(id);
135
+ if (read.etag !== etag) {
136
+ sendJson(res, 409, {
137
+ error: `recording ${id} changed since it was read — reload before saving`,
138
+ code: 'conflict',
139
+ });
140
+ return;
141
+ }
142
+ const result = apply(read.har);
143
+ if (!result.ok) {
144
+ sendJson(res, result.error.code === 'entry-not-found' ? 404 : 422, {
145
+ error: result.error.message,
146
+ code: result.error.code,
147
+ });
148
+ return;
149
+ }
150
+ await ctx.store.write(id, result.har, read.etag);
151
+ const after = await ctx.store.read(id);
152
+ sendJson(res, 200, { har: after.har, etag: after.etag, pollyWillReject: after.pollyWillReject });
153
+ }
154
+ let cachedScript = null;
155
+ /**
156
+ * The page's script, inlined rather than fetched.
157
+ *
158
+ * A second route would need a URL, and the handler cannot know one: under
159
+ * `app.use('/__polly', pollyUi(...))` Express strips the mount path before this code
160
+ * runs, so `basePath` is `''` and a `<script src>` the page emitted would point at
161
+ * `/client.js` — a path that never reaches this handler. Inlining removes the question.
162
+ * The script derives its own API base from `location.pathname` at run time, which is
163
+ * the only place the mount path is still visible.
164
+ */
165
+ async function clientScript() {
166
+ if (cachedScript !== null)
167
+ return cachedScript;
168
+ try {
169
+ cachedScript = await readFile(fileURLToPath(new URL('./client.bundle.js', import.meta.url)), 'utf8');
170
+ }
171
+ catch {
172
+ // Running from source: `npm test` and `--experimental-strip-types` never build.
173
+ // Self-executing, not an exported `start()`: nothing imports this string, so an
174
+ // export would define a function no one calls and ship a blank page instead of the
175
+ // explanation. Module scripts are deferred, so `document.body` exists by now.
176
+ cachedScript =
177
+ 'document.body.innerHTML = "<p>polly-studio was not built — run <code>npm run build</code>.</p>"';
178
+ }
179
+ return cachedScript;
180
+ }
181
+ /**
182
+ * `decodeURIComponent` throws `URIError` on a malformed sequence — `%zz`, a lone `%` —
183
+ * and WHATWG URL parsing leaves both intact in `pathname`, so they reach this code. Left
184
+ * to propagate, the throw lands in `pollyUi`'s catch-all and reports a 500: a server
185
+ * fault for what is a malformed request. `undefined` becomes a 400 at the call site.
186
+ */
187
+ function decodeSegment(segment) {
188
+ try {
189
+ return decodeURIComponent(segment);
190
+ }
191
+ catch {
192
+ return undefined;
193
+ }
194
+ }
195
+ /**
196
+ * The cap on a PATCH body. Generous for the JSON around one edited HAR entry, and far
197
+ * below what it takes to hurt the host process.
198
+ */
199
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
200
+ /**
201
+ * The request body, bounded and parsed.
202
+ *
203
+ * Both failures are the client's, so neither may reach the catch-all as a 500. An
204
+ * unbounded read would let one PATCH drive the host process to OOM — and requirement 1's
205
+ * own threat model assumes `enabled` can be true in production, so that surface is real.
206
+ * A `SyntaxError` from `JSON.parse` is the same failure class as the malformed
207
+ * percent-encoding handled in `decodeSegment`: a malformed request, not a server fault.
208
+ */
209
+ async function readJson(req) {
210
+ const chunks = [];
211
+ let bytes = 0;
212
+ let overflowed = false;
213
+ for await (const chunk of req) {
214
+ const buffer = chunk;
215
+ bytes += buffer.length;
216
+ if (overflowed)
217
+ continue;
218
+ if (bytes > MAX_BODY_BYTES) {
219
+ // Drop what was accumulated and stop accumulating, but keep draining. The cap is
220
+ // about retained memory, and this bounds it. Neither `req.destroy()` nor
221
+ // `req.pause()` works here: destroying resets the socket so a client still
222
+ // uploading sees ECONNRESET rather than the 413, and pausing stalls the upload so
223
+ // the response is never read — measured, the request simply hung.
224
+ overflowed = true;
225
+ chunks.length = 0;
226
+ continue;
227
+ }
228
+ chunks.push(buffer);
229
+ }
230
+ if (overflowed) {
231
+ return { ok: false, status: 413, error: `request body exceeds ${MAX_BODY_BYTES} bytes` };
232
+ }
233
+ const raw = Buffer.concat(chunks).toString('utf8');
234
+ if (raw === '')
235
+ return { ok: true, value: {} };
236
+ try {
237
+ return { ok: true, value: JSON.parse(raw) };
238
+ }
239
+ catch (err) {
240
+ return {
241
+ ok: false,
242
+ status: 400,
243
+ error: `request body is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
244
+ };
245
+ }
246
+ }
247
+ function sendJson(res, status, body) {
248
+ // The catch-all calls this after a route may already have started responding. Writing
249
+ // a second set of headers throws ERR_HTTP_HEADERS_SENT from inside a `.catch()`, which
250
+ // is an unhandled rejection and fatal by default on Node >= 15.
251
+ if (res.writableEnded)
252
+ return;
253
+ if (res.headersSent) {
254
+ res.end();
255
+ return;
256
+ }
257
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
258
+ res.end(JSON.stringify(body));
259
+ }
260
+ function trimTrailingSlash(value) {
261
+ return value.endsWith('/') ? value.slice(0, -1) : value;
262
+ }
@@ -0,0 +1,6 @@
1
+ import type { ReadResult, RecordingStore } from './types.js';
2
+ import type { ResponsePatch } from '../types.js';
3
+ type Local = Pick<RecordingStore, 'read' | 'write'>;
4
+ export declare function patchEntryVia(store: Local, id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
5
+ export declare function deleteEntryVia(store: Local, id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
6
+ export {};
@@ -0,0 +1,20 @@
1
+ import { applyResponsePatch, deleteEntry as deleteEntryIn } from '../edit.js';
2
+ import { StoreError } from './types.js';
3
+ export function patchEntryVia(store, id, entryId, order, etag, patch) {
4
+ return applyEntryEdit(store, id, etag, (har) => applyResponsePatch(har, entryId, order, patch));
5
+ }
6
+ export function deleteEntryVia(store, id, entryId, order, etag) {
7
+ return applyEntryEdit(store, id, etag, (har) => deleteEntryIn(har, entryId, order));
8
+ }
9
+ async function applyEntryEdit(store, id, etag, apply) {
10
+ const read = await store.read(id);
11
+ if (read.etag !== etag) {
12
+ throw new StoreError('conflict', `recording ${id} changed since it was read — reload before saving`);
13
+ }
14
+ const result = apply(read.har);
15
+ if (!result.ok) {
16
+ throw new StoreError(result.error.code === 'entry-not-found' ? 'not-found' : 'unprocessable', result.error.message);
17
+ }
18
+ await store.write(id, result.har, read.etag);
19
+ return store.read(id);
20
+ }