@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.
- package/LICENSE +21 -0
- package/README.md +148 -10
- package/dist/adapters/encoding.d.ts +7 -0
- package/dist/adapters/encoding.js +46 -0
- package/dist/adapters/undici.d.ts +36 -0
- package/dist/adapters/undici.js +231 -0
- package/dist/browser.js +5 -19
- package/dist/defaults.d.ts +40 -0
- package/dist/defaults.js +40 -0
- package/dist/node.d.ts +56 -0
- package/dist/node.js +108 -0
- package/dist/studio/body.d.ts +28 -0
- package/dist/studio/body.js +66 -0
- package/dist/studio/browser.d.ts +22 -0
- package/dist/studio/browser.js +24 -0
- package/dist/studio/client.bundle.js +290 -0
- package/dist/studio/edit.d.ts +42 -0
- package/dist/studio/edit.js +90 -0
- package/dist/studio/etag.d.ts +6 -0
- package/dist/studio/etag.js +14 -0
- package/dist/studio/har.d.ts +19 -0
- package/dist/studio/har.js +36 -0
- package/dist/studio/headers.d.ts +31 -0
- package/dist/studio/headers.js +59 -0
- package/dist/studio/index.d.ts +9 -0
- package/dist/studio/index.js +5 -0
- package/dist/studio/server.d.ts +35 -0
- package/dist/studio/server.js +262 -0
- package/dist/studio/stores/apply.d.ts +6 -0
- package/dist/studio/stores/apply.js +20 -0
- package/dist/studio/stores/fs-store.d.ts +70 -0
- package/dist/studio/stores/fs-store.js +186 -0
- package/dist/studio/stores/http-store.d.ts +30 -0
- package/dist/studio/stores/http-store.js +67 -0
- package/dist/studio/stores/local-storage-store.d.ts +25 -0
- package/dist/studio/stores/local-storage-store.js +109 -0
- package/dist/studio/stores/types.d.ts +45 -0
- package/dist/studio/stores/types.js +7 -0
- package/dist/studio/types.d.ts +92 -0
- package/dist/studio/types.js +7 -0
- package/dist/studio/ui/app.d.ts +12 -0
- package/dist/studio/ui/app.js +180 -0
- package/dist/studio/ui/client.d.ts +2 -0
- package/dist/studio/ui/client.js +6 -0
- package/dist/studio/ui/page.d.ts +53 -0
- package/dist/studio/ui/page.js +360 -0
- package/dist/studio/ui/render.d.ts +25 -0
- package/dist/studio/ui/render.js +171 -0
- package/dist/vite.js +1 -1
- 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
|
+
}
|