@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,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('<', '&lt;')
26
+ .replaceAll('>', '&gt;')
27
+ .replaceAll('"', '&quot;')
28
+ .replaceAll("'", '&#39;');
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
@@ -1,4 +1,4 @@
1
- import { pollyUi } from '@avivbenhamo/polly-studio';
1
+ import { pollyUi } from './studio/index.js';
2
2
  import { recordingStore, STORE_NAMESPACE } from './store.js';
3
3
  const STUDIO_PATH = '/__polly';
4
4
  export default function pollyKit(options = {}) {
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@avivbenhamo/polly-kit",
3
- "version": "0.1.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": ["./dist/vite.d.ts"],
17
- "store": ["./dist/store.d.ts"],
18
- "global-shim": ["./dist/global-shim.d.ts"]
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
- "@avivbenhamo/polly-studio": "^0.1.0",
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
  }