@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,171 @@
|
|
|
1
|
+
import { decodeContent } from '../body.js';
|
|
2
|
+
import { getHeader } from '../headers.js';
|
|
3
|
+
/**
|
|
4
|
+
* Rendering is string-in, string-out and lives apart from the DOM so it can be tested
|
|
5
|
+
* without a browser. Recorded bodies are attacker-shaped by nature — they came off the
|
|
6
|
+
* network — so everything interpolated goes through `escapeHtml`.
|
|
7
|
+
*
|
|
8
|
+
* The parameter is not `string`, and the body opens with `String(value)`. `parseHar`'s
|
|
9
|
+
* `isHarShaped` (`src/har.ts`) is a shape check, not a schema validator: it confirms
|
|
10
|
+
* `log.entries` is an array and casts the rest of the parsed JSON to `Har`, so a field
|
|
11
|
+
* this module's types call `string` can be a number, `null` or `undefined` on disk — and
|
|
12
|
+
* `.replaceAll` does not exist on any of those. Design spec §7 requires the UI to open
|
|
13
|
+
* exactly the recordings Polly would reject, so that is an expected input.
|
|
14
|
+
*
|
|
15
|
+
* It is a union of the JSON primitives rather than `unknown`, which is the narrowest
|
|
16
|
+
* type that accepts everything a malformed HAR can put in a text field while still
|
|
17
|
+
* failing the build on `escapeHtml(someObject)` — a call that is always a mistake, and
|
|
18
|
+
* that `unknown` would have accepted silently as `[object Object]`. Callers holding an
|
|
19
|
+
* `unknown` say `String(value)` themselves, which makes the coercion visible at the site
|
|
20
|
+
* that chose it.
|
|
21
|
+
*/
|
|
22
|
+
export function escapeHtml(value) {
|
|
23
|
+
return String(value)
|
|
24
|
+
.replaceAll('&', '&')
|
|
25
|
+
.replaceAll('<', '<')
|
|
26
|
+
.replaceAll('>', '>')
|
|
27
|
+
.replaceAll('"', '"')
|
|
28
|
+
.replaceAll("'", ''');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A render-boundary guard for fields the *type* declares `number` but that actually
|
|
32
|
+
* came off disk. `isHarShaped` (`src/har.ts`) is a shape check, not a schema
|
|
33
|
+
* validator — it confirms `log.entries` is an array and casts the rest of the parsed
|
|
34
|
+
* JSON to `Har` unchecked, so `_order`, `response.status` and `content.size` are
|
|
35
|
+
* whatever the file says they are. Design spec §7 requires this UI to list and open
|
|
36
|
+
* recordings Polly itself would reject, so a hand-edited or foreign HAR is an expected
|
|
37
|
+
* input, not an edge case. Reasoning per-field about which numbers are "safe" is what
|
|
38
|
+
* produced the gap this closes — a value with the shape `'0" onmouseover=alert(1) x="'`
|
|
39
|
+
* sails straight through a template literal typed as `number`. Stringifying and escaping
|
|
40
|
+
* uniformly, at every site that interpolates one of these fields, closes it without
|
|
41
|
+
* having to trust the type checker over the file.
|
|
42
|
+
*/
|
|
43
|
+
function renderNumeric(value) {
|
|
44
|
+
return escapeHtml(String(value));
|
|
45
|
+
}
|
|
46
|
+
export function renderRecordingList(recordings) {
|
|
47
|
+
if (recordings.length === 0) {
|
|
48
|
+
return '<p class="empty">No recordings here yet. Run your app in Polly\'s record mode first.</p>';
|
|
49
|
+
}
|
|
50
|
+
const rows = recordings
|
|
51
|
+
.map((recording) => {
|
|
52
|
+
// The note sits outside the button: it describes the recording rather than offering
|
|
53
|
+
// an action, and a warning nested inside a control reads as part of the label.
|
|
54
|
+
const note = recording.problem
|
|
55
|
+
? `<p class="ps-rec-note problem">${escapeHtml(recording.problem)}</p>`
|
|
56
|
+
: recording.pollyWillReject
|
|
57
|
+
? `<p class="ps-rec-note warn">Polly will reject this: ${escapeHtml(recording.pollyWillReject)}</p>`
|
|
58
|
+
: '';
|
|
59
|
+
// The count moved inside the button so the row is one target with the id and its
|
|
60
|
+
// size on one line. `app.ts` finds the button with `closest('button')` and reads
|
|
61
|
+
// `dataset.id` off it, so nesting spans inside changes nothing for the click.
|
|
62
|
+
return (`<li><button class="recording" data-id="${escapeHtml(recording.id)}">` +
|
|
63
|
+
`<span class="ps-rec-name">${escapeHtml(recording.id)}</span>` +
|
|
64
|
+
`<span class="count">${renderNumeric(recording.entryCount)}</span></button>${note}</li>`);
|
|
65
|
+
})
|
|
66
|
+
.join('');
|
|
67
|
+
return `<ul class="recordings">${rows}</ul>`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The other half of the same problem `escapeHtml`'s `String(value)` closes, and the half
|
|
71
|
+
* stringifying a leaf cannot reach. `isHarShaped` checks only that `log.entries` is an
|
|
72
|
+
* array — so `entries: [{}]` is a shape-valid HAR whose entries have no `request` and no
|
|
73
|
+
* `response` at all, and `entry.request.method` or `response.headers.map(...)` dies on a
|
|
74
|
+
* property access before any value reaches `escapeHtml`.
|
|
75
|
+
*
|
|
76
|
+
* Spec §7 wants such a recording listed and opened rather than hidden, so each entry is
|
|
77
|
+
* rendered inside its own boundary: an unreadable entry becomes a row or panel saying so,
|
|
78
|
+
* the readable entries beside it still render, and nothing escapes to the caller. The
|
|
79
|
+
* alternative — validating every field in `parseHar` — is a far larger change and would
|
|
80
|
+
* turn "shows you what is wrong with your file" back into "refuses to open it".
|
|
81
|
+
*
|
|
82
|
+
* The message is escaped like any other interpolated value: it can quote data from the
|
|
83
|
+
* file that produced it.
|
|
84
|
+
*/
|
|
85
|
+
function unrenderable(err) {
|
|
86
|
+
return escapeHtml(err instanceof Error ? err.message : String(err));
|
|
87
|
+
}
|
|
88
|
+
export function renderEntryRow(entry) {
|
|
89
|
+
try {
|
|
90
|
+
// _order is the only thing distinguishing two entries with the same _id, so it is
|
|
91
|
+
// shown rather than hidden — a list with two identical-looking rows is unusable.
|
|
92
|
+
const order = entry._order > 0 ? ` <span class="order">#${renderNumeric(entry._order)}</span>` : '';
|
|
93
|
+
return (`<li><button class="entry" data-id="${escapeHtml(entry._id)}" data-order="${renderNumeric(entry._order)}">` +
|
|
94
|
+
`<span class="method">${escapeHtml(entry.request.method)}</span> ` +
|
|
95
|
+
`<span class="status s${Math.floor(entry.response.status / 100)}">${renderNumeric(entry.response.status)}</span> ` +
|
|
96
|
+
`<span class="url">${escapeHtml(entry.request.url)}</span>${order}</button></li>`);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
// The catch wraps the whole entry body, so it swallows a genuine bug in this file
|
|
100
|
+
// just as readily as a malformed recording. The message reaches the page, but a
|
|
101
|
+
// stack does not — log it, or a defect on an uncommon path leaves no trace anywhere.
|
|
102
|
+
console.error('polly-studio: could not render entry row', err);
|
|
103
|
+
return `<li class="problem">this entry could not be rendered: ${unrenderable(err)}</li>`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function renderEntryDetail(entry) {
|
|
107
|
+
try {
|
|
108
|
+
return entryDetail(entry);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
// Logged for the same reason as the row guard above: the message reaches the page,
|
|
112
|
+
// the stack would otherwise reach nothing.
|
|
113
|
+
console.error('polly-studio: could not render entry detail', err);
|
|
114
|
+
// Deliberately not `class="detail"`: `app.ts`'s click handler finds the entry a Save
|
|
115
|
+
// or Delete button belongs to with `closest('.detail')` and reads `_id`/`_order` off
|
|
116
|
+
// its dataset. This panel has neither button and no trustworthy id, so it must not
|
|
117
|
+
// look like one to that handler.
|
|
118
|
+
return `<section class="problem"><p>this entry could not be rendered: ${unrenderable(err)}</p></section>`;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function entryDetail(entry) {
|
|
122
|
+
const response = entry.response;
|
|
123
|
+
const decoded = decodeContent(response.content);
|
|
124
|
+
// Deliberately not a `readonly` textarea for the binary case, however much tidier two
|
|
125
|
+
// identical-looking fields would be: `readPatch` (`src/ui/app.ts`) reads `bodyText`
|
|
126
|
+
// by name and cannot tell a disabled field from an edited one, so the only safe way to
|
|
127
|
+
// say "this body is not editable" is for the field not to exist.
|
|
128
|
+
const body = decoded.kind === 'binary'
|
|
129
|
+
? `<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>`
|
|
130
|
+
: `<textarea name="bodyText" rows="16" spellcheck="false" aria-label="Response body">${escapeHtml(decoded.text)}</textarea>`;
|
|
131
|
+
// `name` immediately followed by `value`, with one space and nothing between: the
|
|
132
|
+
// fixed attribute order the DOM-free test harness in `test/http-store.test.ts` reads
|
|
133
|
+
// a field's current value back with. Anything else goes after `value`.
|
|
134
|
+
const headers = response.headers
|
|
135
|
+
.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>`)
|
|
136
|
+
.join('');
|
|
137
|
+
return `
|
|
138
|
+
<section class="detail" data-id="${escapeHtml(entry._id)}" data-order="${renderNumeric(entry._order)}">
|
|
139
|
+
<div class="ps-block ps-block--request">
|
|
140
|
+
<h2>Request <small>read-only</small></h2>
|
|
141
|
+
<p class="note">Polly matches a replayed request on <code>_id</code>, an md5 of the request built
|
|
142
|
+
from your <code>matchRequestsBy</code> config — and that config is not stored in the recording.
|
|
143
|
+
Editing any of this would leave an entry that looks right and never matches again.</p>
|
|
144
|
+
<dl>
|
|
145
|
+
<dt>Method</dt><dd>${escapeHtml(entry.request.method)}</dd>
|
|
146
|
+
<dt>URL</dt><dd>${escapeHtml(entry.request.url)}</dd>
|
|
147
|
+
<dt>Recorded</dt><dd>${escapeHtml(entry.startedDateTime)}</dd>
|
|
148
|
+
<dt>_id / _order</dt><dd><code>${escapeHtml(entry._id)}</code> / ${renderNumeric(entry._order)}</dd>
|
|
149
|
+
</dl>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div class="ps-block ps-block--response">
|
|
153
|
+
<h2>Response</h2>
|
|
154
|
+
<div class="ps-fields">
|
|
155
|
+
<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>
|
|
156
|
+
<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>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<h3 class="ps-group-title">Headers</h3>
|
|
160
|
+
${headers === '' ? '<p class="empty">This response was recorded without headers.</p>' : `<div class="headers">${headers}</div>`}
|
|
161
|
+
|
|
162
|
+
<h3 class="ps-group-title">Body</h3>
|
|
163
|
+
${body}
|
|
164
|
+
|
|
165
|
+
<div class="actions">
|
|
166
|
+
<button name="save">Save response</button>
|
|
167
|
+
<button name="delete" class="danger">Delete entry</button>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</section>`;
|
|
171
|
+
}
|
package/dist/vite.js
CHANGED
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avivbenhamo/polly-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Drop-in record/replay of an app's HTTP traffic with Polly.JS, plus the polly-studio editor. Bring your own host policy.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"//engines": "The README states this; without the field npm never enforces it. >=22.6 is what the undici adapter's handler protocol and the studio's server both assume.",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22.6"
|
|
10
|
+
},
|
|
7
11
|
"files": [
|
|
8
12
|
"dist",
|
|
9
13
|
"README.md"
|
|
@@ -13,9 +17,24 @@
|
|
|
13
17
|
"//typesVersions": "For consumers still on moduleResolution 'node', which ignores `exports`. Drop once everything is on 'bundler'.",
|
|
14
18
|
"typesVersions": {
|
|
15
19
|
"*": {
|
|
16
|
-
"vite": [
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
"vite": [
|
|
21
|
+
"./dist/vite.d.ts"
|
|
22
|
+
],
|
|
23
|
+
"store": [
|
|
24
|
+
"./dist/store.d.ts"
|
|
25
|
+
],
|
|
26
|
+
"global-shim": [
|
|
27
|
+
"./dist/global-shim.d.ts"
|
|
28
|
+
],
|
|
29
|
+
"node": [
|
|
30
|
+
"./dist/node.d.ts"
|
|
31
|
+
],
|
|
32
|
+
"studio": [
|
|
33
|
+
"./dist/studio/index.d.ts"
|
|
34
|
+
],
|
|
35
|
+
"studio/browser": [
|
|
36
|
+
"./dist/studio/browser.d.ts"
|
|
37
|
+
]
|
|
19
38
|
}
|
|
20
39
|
},
|
|
21
40
|
"exports": {
|
|
@@ -34,21 +53,41 @@
|
|
|
34
53
|
"./global-shim": {
|
|
35
54
|
"types": "./dist/global-shim.d.ts",
|
|
36
55
|
"default": "./dist/global-shim.js"
|
|
56
|
+
},
|
|
57
|
+
"./node": {
|
|
58
|
+
"types": "./dist/node.d.ts",
|
|
59
|
+
"default": "./dist/node.js"
|
|
60
|
+
},
|
|
61
|
+
"./studio": {
|
|
62
|
+
"types": "./dist/studio/index.d.ts",
|
|
63
|
+
"default": "./dist/studio/index.js"
|
|
64
|
+
},
|
|
65
|
+
"./studio/browser": {
|
|
66
|
+
"types": "./dist/studio/browser.d.ts",
|
|
67
|
+
"default": "./dist/studio/browser.js"
|
|
37
68
|
}
|
|
38
69
|
},
|
|
39
70
|
"publishConfig": {
|
|
40
71
|
"access": "public"
|
|
41
72
|
},
|
|
42
73
|
"//scripts": "dist/ is gitignored, so `prepack` builds it — npm runs prepack before both `npm pack` and `npm publish`. Deliberately not `prepare`: that would also fire in a consumer's tree, where there is no src/ or tsconfig to compile.",
|
|
74
|
+
"//test": "Against dist/, not src/: src imports with `.js` specifiers that Node cannot resolve to the `.ts` files on disk. The build is also what gets published, so every test exercises the artifact.",
|
|
43
75
|
"scripts": {
|
|
44
|
-
"build": "tsc -p tsconfig.json",
|
|
45
|
-
"prepack": "tsc -p tsconfig.json"
|
|
76
|
+
"build": "tsc -p tsconfig.json && npm run bundle",
|
|
77
|
+
"prepack": "tsc -p tsconfig.json && npm run bundle",
|
|
78
|
+
"//bundle": "From dist/, not src/: the client imports `./app.js`, which exists only once tsc has emitted it. server.ts resolves the bundle as `new URL('./client.bundle.js', import.meta.url)` relative to the compiled dist/studio/server.js, so this outfile is the path it looks for.",
|
|
79
|
+
"bundle": "esbuild dist/studio/ui/client.js --bundle --format=esm --target=es2022 --outfile=dist/studio/client.bundle.js",
|
|
80
|
+
"fixture": "node --experimental-strip-types --disable-warning=ExperimentalWarning test/studio/fixtures/record.ts",
|
|
81
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
82
|
+
"test": "npm run build && node --experimental-strip-types --disable-warning=ExperimentalWarning --test \"test/**/*.test.ts\""
|
|
46
83
|
},
|
|
47
84
|
"dependencies": {
|
|
48
|
-
"@
|
|
85
|
+
"@pollyjs/adapter": "^6.0.6",
|
|
86
|
+
"@pollyjs/adapter-node-http": "^6.0.6",
|
|
49
87
|
"@pollyjs/adapter-xhr": "^6.0.6",
|
|
50
88
|
"@pollyjs/core": "^6.0.6",
|
|
51
89
|
"@pollyjs/node-server": "^6.0.6",
|
|
90
|
+
"@pollyjs/persister-fs": "^6.0.6",
|
|
52
91
|
"@pollyjs/persister-rest": "^6.0.6",
|
|
53
92
|
"buffer": "^6.0.3"
|
|
54
93
|
},
|
|
@@ -62,6 +101,9 @@
|
|
|
62
101
|
},
|
|
63
102
|
"//devDependencies": "vite is here only so `./vite` typechecks standalone; it stays an optional peer at runtime.",
|
|
64
103
|
"devDependencies": {
|
|
104
|
+
"@types/node": "^22.20.4",
|
|
105
|
+
"axios": "^1.20.0",
|
|
106
|
+
"esbuild": "^0.24.2",
|
|
65
107
|
"typescript": "^5.3.3",
|
|
66
108
|
"vite": "^5.0.0"
|
|
67
109
|
}
|