@avivbenhamo/polly-kit 0.1.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +298 -138
  3. package/dist/adapters/encoding.d.ts +7 -0
  4. package/dist/adapters/encoding.js +46 -0
  5. package/dist/adapters/undici.d.ts +36 -0
  6. package/dist/adapters/undici.js +231 -0
  7. package/dist/browser.d.ts +13 -5
  8. package/dist/browser.js +21 -25
  9. package/dist/cli.d.ts +21 -0
  10. package/dist/cli.js +141 -0
  11. package/dist/defaults.d.ts +40 -0
  12. package/dist/defaults.js +40 -0
  13. package/dist/node.d.ts +56 -0
  14. package/dist/node.js +108 -0
  15. package/dist/studio/body.d.ts +28 -0
  16. package/dist/studio/body.js +66 -0
  17. package/dist/studio/browser.d.ts +22 -0
  18. package/dist/studio/browser.js +24 -0
  19. package/dist/studio/client.bundle.js +290 -0
  20. package/dist/studio/edit.d.ts +42 -0
  21. package/dist/studio/edit.js +90 -0
  22. package/dist/studio/etag.d.ts +6 -0
  23. package/dist/studio/etag.js +14 -0
  24. package/dist/studio/har.d.ts +19 -0
  25. package/dist/studio/har.js +36 -0
  26. package/dist/studio/headers.d.ts +31 -0
  27. package/dist/studio/headers.js +59 -0
  28. package/dist/studio/index.d.ts +9 -0
  29. package/dist/studio/index.js +5 -0
  30. package/dist/studio/server.d.ts +35 -0
  31. package/dist/studio/server.js +262 -0
  32. package/dist/studio/stores/apply.d.ts +6 -0
  33. package/dist/studio/stores/apply.js +20 -0
  34. package/dist/studio/stores/fs-store.d.ts +70 -0
  35. package/dist/studio/stores/fs-store.js +186 -0
  36. package/dist/studio/stores/http-store.d.ts +30 -0
  37. package/dist/studio/stores/http-store.js +67 -0
  38. package/dist/studio/stores/local-storage-store.d.ts +25 -0
  39. package/dist/studio/stores/local-storage-store.js +109 -0
  40. package/dist/studio/stores/types.d.ts +45 -0
  41. package/dist/studio/stores/types.js +7 -0
  42. package/dist/studio/types.d.ts +92 -0
  43. package/dist/studio/types.js +7 -0
  44. package/dist/studio/ui/app.d.ts +12 -0
  45. package/dist/studio/ui/app.js +180 -0
  46. package/dist/studio/ui/client.d.ts +2 -0
  47. package/dist/studio/ui/client.js +6 -0
  48. package/dist/studio/ui/page.d.ts +53 -0
  49. package/dist/studio/ui/page.js +360 -0
  50. package/dist/studio/ui/render.d.ts +25 -0
  51. package/dist/studio/ui/render.js +171 -0
  52. package/dist/vite.d.ts +0 -17
  53. package/dist/vite.js +44 -7
  54. package/package.json +55 -7
@@ -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.d.ts CHANGED
@@ -1,20 +1,3 @@
1
- /**
2
- * One Vite plugin: the recording store plus the polly-studio editor.
3
- *
4
- * Two plugins would be honest about the two concerns, but they always go
5
- * together and each needed the same `recordingsDir` — so the host app's
6
- * vite.config gains one import and one line.
7
- *
8
- * WHY THE STUDIO'S `basePath` IS NOT SET. Connect strips the mount path before
9
- * the handler runs — `GET /__polly/api/recordings` arrives as
10
- * `/api/recordings` — so the handler must not strip it a second time. The
11
- * browser side does not need to be told either: the page derives its API base
12
- * from `location.pathname`. Set `basePath` here and every API call 404s.
13
- *
14
- * Dev only, deliberately. This hooks `configureServer`, which a production build
15
- * never calls, and `pollyUi()` disables itself when `NODE_ENV === 'production'`
16
- * anyway — recordings hold real recorded request and response bodies.
17
- */
18
1
  import type { Plugin } from 'vite';
19
2
  export interface PollyKitOptions {
20
3
  /** Where HARs are read from and written to. Gitignore it. */
package/dist/vite.js CHANGED
@@ -1,6 +1,40 @@
1
- import { pollyUi } from '@avivbenhamo/polly-studio';
1
+ /**
2
+ * One Vite plugin: the recording store plus the polly-studio editor.
3
+ *
4
+ * Two plugins would be honest about the two concerns, but they always go
5
+ * together and each needed the same `recordingsDir` — so the host app's
6
+ * vite.config gains one import and one line.
7
+ *
8
+ * WHY THE STUDIO'S `basePath` IS NOT SET. Connect strips the mount path before
9
+ * the handler runs — `GET /__polly/api/recordings` arrives as
10
+ * `/api/recordings` — so the handler must not strip it a second time. The
11
+ * browser side does not need to be told either: the page derives its API base
12
+ * from `location.pathname`. Set `basePath` here and every API call 404s.
13
+ *
14
+ * Dev only, deliberately. This hooks `configureServer`, which a production build
15
+ * never calls, and `pollyUi()` disables itself when `NODE_ENV === 'production'`
16
+ * anyway — recordings hold real recorded request and response bodies.
17
+ */
18
+ import { createRequire } from 'node:module';
19
+ import { pollyUi } from './studio/index.js';
2
20
  import { recordingStore, STORE_NAMESPACE } from './store.js';
3
21
  const STUDIO_PATH = '/__polly';
22
+ /**
23
+ * The npm `buffer` package, resolved to an absolute path from THIS file.
24
+ *
25
+ * Resolving it from here rather than aliasing to the bare specifier `buffer/`
26
+ * matters twice. Vite warns on a non-absolute alias replacement because it
27
+ * cannot dedupe the result ("will lead to duplicated modules"). And the bare
28
+ * form is resolved from the consuming app's root, so it only works when the
29
+ * installer has hoisted `buffer` out of this package and into theirs. Measured
30
+ * with a `file:` link, where npm does not: the dep optimizer fails with "Cannot
31
+ * optimize dependency: buffer" and every response then errors on `Buffer.from`.
32
+ * A strict node_modules layout (pnpm's default, Yarn PnP) should hit the same
33
+ * thing for the same reason — not measured.
34
+ *
35
+ * `buffer` is a dependency of this package, so this resolution always succeeds.
36
+ */
37
+ const BUFFER_PATH = createRequire(import.meta.url).resolve('buffer/');
4
38
  export default function pollyKit(options = {}) {
5
39
  const { recordingsDir = 'recordings', studioPath = STUDIO_PATH, apiNamespace = STORE_NAMESPACE, studio = true, } = options;
6
40
  return {
@@ -39,18 +73,21 @@ export default function pollyKit(options = {}) {
39
73
  // resolves to a stub and every response errors with
40
74
  // "Cannot read properties of undefined (reading 'from')".
41
75
  //
42
- // The trailing slash is what makes this resolve to the npm
43
- // `buffer` package rather than back to the Node builtin. The
44
- // anchored regex keeps it from also rewriting `buffer/...`.
76
+ // The anchored regex keeps it from also rewriting
77
+ // `buffer/...`; see BUFFER_PATH for why the replacement is
78
+ // an absolute path from this package rather than `buffer/`.
45
79
  find: /^buffer$/,
46
- replacement: 'buffer/',
80
+ replacement: BUFFER_PATH,
47
81
  },
48
82
  ],
49
83
  },
50
84
  optimizeDeps: {
51
85
  // `buffer`: pre-bundled alongside the Polly packages that import
52
86
  // it, so the dep optimizer does not treat it as an unresolved
53
- // external on a cold start.
87
+ // external on a cold start. By absolute path for the same reason
88
+ // the alias is — a bare `'buffer'` here is resolved from the
89
+ // consuming app's root, which has no `buffer` unless npm hoisted
90
+ // ours into it.
54
91
  //
55
92
  // This package itself: without it, Vite discovers the dependency
56
93
  // when the entry module first requests it, optimizes, and triggers
@@ -60,7 +97,7 @@ export default function pollyKit(options = {}) {
60
97
  // `.vite` cache missed every request, the reload after it recorded
61
98
  // fine. Naming it here makes the optimizer run before the server
62
99
  // accepts requests, so the first load is the optimized one.
63
- include: ['buffer', '@avivbenhamo/polly-kit'],
100
+ include: [BUFFER_PATH, '@avivbenhamo/polly-kit'],
64
101
  // The `define` above does not reach pre-bundled dependencies, and
65
102
  // persister-rest is one. This is the half that actually fixes it.
66
103
  esbuildOptions: { define: { global: 'globalThis' } },
package/package.json CHANGED
@@ -1,21 +1,44 @@
1
1
  {
2
2
  "name": "@avivbenhamo/polly-kit",
3
- "version": "0.1.0",
3
+ "version": "0.5.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"
10
14
  ],
15
+ "//bin": "The studio on its own, for a checkout with no dev server to mount it in. The mounted form stays the default.",
16
+ "bin": {
17
+ "polly-kit": "./dist/cli.js"
18
+ },
11
19
  "main": "./dist/browser.js",
12
20
  "types": "./dist/browser.d.ts",
13
21
  "//typesVersions": "For consumers still on moduleResolution 'node', which ignores `exports`. Drop once everything is on 'bundler'.",
14
22
  "typesVersions": {
15
23
  "*": {
16
- "vite": ["./dist/vite.d.ts"],
17
- "store": ["./dist/store.d.ts"],
18
- "global-shim": ["./dist/global-shim.d.ts"]
24
+ "vite": [
25
+ "./dist/vite.d.ts"
26
+ ],
27
+ "store": [
28
+ "./dist/store.d.ts"
29
+ ],
30
+ "global-shim": [
31
+ "./dist/global-shim.d.ts"
32
+ ],
33
+ "node": [
34
+ "./dist/node.d.ts"
35
+ ],
36
+ "studio": [
37
+ "./dist/studio/index.d.ts"
38
+ ],
39
+ "studio/browser": [
40
+ "./dist/studio/browser.d.ts"
41
+ ]
19
42
  }
20
43
  },
21
44
  "exports": {
@@ -34,21 +57,43 @@
34
57
  "./global-shim": {
35
58
  "types": "./dist/global-shim.d.ts",
36
59
  "default": "./dist/global-shim.js"
60
+ },
61
+ "./node": {
62
+ "types": "./dist/node.d.ts",
63
+ "default": "./dist/node.js"
64
+ },
65
+ "./studio": {
66
+ "types": "./dist/studio/index.d.ts",
67
+ "default": "./dist/studio/index.js"
68
+ },
69
+ "./studio/browser": {
70
+ "types": "./dist/studio/browser.d.ts",
71
+ "default": "./dist/studio/browser.js"
37
72
  }
38
73
  },
39
74
  "publishConfig": {
40
75
  "access": "public"
41
76
  },
42
77
  "//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.",
78
+ "//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
79
  "scripts": {
44
- "build": "tsc -p tsconfig.json",
45
- "prepack": "tsc -p tsconfig.json"
80
+ "build": "tsc -p tsconfig.json && npm run bundle",
81
+ "prepack": "tsc -p tsconfig.json && npm run bundle",
82
+ "//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.",
83
+ "bundle": "esbuild dist/studio/ui/client.js --bundle --format=esm --target=es2022 --outfile=dist/studio/client.bundle.js",
84
+ "fixture": "node --experimental-strip-types --disable-warning=ExperimentalWarning test/studio/fixtures/record.ts",
85
+ "typecheck": "tsc -p tsconfig.json --noEmit",
86
+ "test": "npm run build && node --experimental-strip-types --disable-warning=ExperimentalWarning --test \"test/**/*.test.ts\"",
87
+ "studio": "node --experimental-strip-types --disable-warning=ExperimentalWarning src/cli.ts studio"
46
88
  },
47
89
  "dependencies": {
48
- "@avivbenhamo/polly-studio": "^0.1.0",
90
+ "@pollyjs/adapter": "^6.0.6",
91
+ "@pollyjs/adapter-fetch": "^6.0.7",
92
+ "@pollyjs/adapter-node-http": "^6.0.6",
49
93
  "@pollyjs/adapter-xhr": "^6.0.6",
50
94
  "@pollyjs/core": "^6.0.6",
51
95
  "@pollyjs/node-server": "^6.0.6",
96
+ "@pollyjs/persister-fs": "^6.0.6",
52
97
  "@pollyjs/persister-rest": "^6.0.6",
53
98
  "buffer": "^6.0.3"
54
99
  },
@@ -62,6 +107,9 @@
62
107
  },
63
108
  "//devDependencies": "vite is here only so `./vite` typechecks standalone; it stays an optional peer at runtime.",
64
109
  "devDependencies": {
110
+ "@types/node": "^22.20.4",
111
+ "axios": "^1.20.0",
112
+ "esbuild": "^0.24.2",
65
113
  "typescript": "^5.3.3",
66
114
  "vite": "^5.0.0"
67
115
  }