@avivbenhamo/polly-kit 0.6.0 → 0.8.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/README.md CHANGED
@@ -244,6 +244,24 @@ import {mountPollyStudio} from '@avivbenhamo/polly-kit/studio/browser';
244
244
  mountPollyStudio(document.getElementById('polly')!);
245
245
  ```
246
246
 
247
+ ### Importing a HAR
248
+
249
+ The editor takes a HAR downloaded from the
250
+ [swagger-recorder](../swagger-recorder/README.md) extension — the plain flavour, not the
251
+ zip — and turns it into a recording. Give it the recording name your app's Polly config
252
+ uses; it creates the recording if there is none, and merges into it if there is. It
253
+ reports what it did: **added 3, replaced 2, skipped 1**.
254
+
255
+ **Where a request is in both, the imported one wins.** That is usually what you want —
256
+ you re-recorded because the response changed — but it means an import can overwrite a
257
+ response you edited here. The `replaced` count is there so that is never silent.
258
+
259
+ **Only HARs from that extension can be imported**, and the reason is not caution. Polly
260
+ finds a recorded response by an `_id` that hashes the request body, and a multipart body
261
+ is not in a HAR at all — an upload records with no `postData`. So no importer can derive
262
+ an id; it can only read one that was written at capture time, and that extension is the
263
+ only thing that writes one.
264
+
247
265
  ## API
248
266
 
249
267
  | Export | From | What |
@@ -259,6 +277,8 @@ mountPollyStudio(document.getElementById('polly')!);
259
277
  | `parseHar` / `findEntry` | `./studio` | Reading a HAR, with the Polly-specific quirks handled. |
260
278
  | `applyResponsePatch` / `deleteEntry` | `./studio` | Editing an entry, returning a typed `EditError` rather than throwing. |
261
279
  | `mountPollyStudio(root)` | `./studio/browser` | The editor with no server at all, against Polly's `persister-local-storage`. |
280
+ | `mergeEntries` / `rejectImport` | `./studio` | The import rules, as pure functions. |
281
+ | `guidForRecording(name)` | `./studio` | The directory Polly looks a recording up in. |
262
282
 
263
283
  `startPolly` options beyond the policy config: `mode` (required), `alwaysPassthrough`,
264
284
  `apiNamespace`, `saveDebounceMs`; on `./node`, `recordingsDir` and `logLevel` instead of
@@ -47,6 +47,15 @@ function renderRecordingList(recordings) {
47
47
  }).join("");
48
48
  return `<ul class="recordings">${rows}</ul>`;
49
49
  }
50
+ function renderImportResult(result) {
51
+ const verb = result.created ? "Created" : "Updated";
52
+ const counts = [`added ${result.added}`, `replaced ${result.replaced}`];
53
+ if (result.skipped.length > 0)
54
+ counts.push(`skipped ${result.skipped.length}`);
55
+ const skips = result.skipped.length === 0 ? "" : `<ul class="ps-import-skips">${result.skipped.map((skip) => `<li><code>${escapeHtml(skip.url)}</code> \u2014 ${escapeHtml(skip.reason)}</li>`).join("")}</ul>`;
56
+ const rejectWarning = result.pollyWillReject === null ? "" : `<p class="ps-notice warn">Polly will reject this: ${escapeHtml(result.pollyWillReject)}</p>`;
57
+ return `<p class="ps-import-result">${verb} <code>${escapeHtml(result.id)}</code> \u2014 ${escapeHtml(counts.join(", "))}.</p>${skips}${rejectWarning}`;
58
+ }
50
59
  function unrenderable(err) {
51
60
  return escapeHtml(err instanceof Error ? err.message : String(err));
52
61
  }
@@ -102,8 +111,13 @@ function entryDetail(entry) {
102
111
 
103
112
  <div class="actions">
104
113
  <button name="save">Save response</button>
114
+ <button name="duplicate" title="Append a copy to this request's group, replayed the next time the same request is made">Duplicate for next call</button>
105
115
  <button name="delete" class="danger">Delete entry</button>
106
116
  </div>
117
+ <p class="note">Duplicating adds a second answer to the <em>same</em> request:
118
+ Polly replays <code>_order 0</code> on the first call, this copy on the second.
119
+ It cannot become a different request \u2014 the <code>_id</code> is a hash of the
120
+ request, and an edited URL under the old hash would match nothing.</p>
107
121
  </div>
108
122
  </section>`;
109
123
  }
@@ -122,6 +136,35 @@ function mountStudio(root, store) {
122
136
  const recordings = required(root, "#recordings");
123
137
  const entries = required(root, "#entries");
124
138
  const detail = required(root, "#detail");
139
+ const importForm = root.querySelector("#import");
140
+ const importReport = root.querySelector("#import-report");
141
+ importForm?.addEventListener("submit", (event) => {
142
+ event.preventDefault();
143
+ void runImport();
144
+ });
145
+ async function runImport() {
146
+ if (importForm === null || importReport === null)
147
+ return;
148
+ importReport.textContent = "";
149
+ try {
150
+ const name = importForm.querySelector("#import-name").value;
151
+ const file = importForm.querySelector("#import-file").files?.[0];
152
+ if (file === void 0) {
153
+ throw new Error("choose a .har file to import");
154
+ }
155
+ const har = JSON.parse(await file.text());
156
+ const result = await store.importHar(name, har);
157
+ importReport.innerHTML = renderImportResult(result);
158
+ try {
159
+ await refreshRecordings();
160
+ await openRecording(result.id);
161
+ } catch (err) {
162
+ report(err);
163
+ }
164
+ } catch (err) {
165
+ importReport.textContent = errorMessage(err);
166
+ }
167
+ }
125
168
  let currentId = null;
126
169
  let current = null;
127
170
  refreshRecordings().catch(report);
@@ -134,6 +177,11 @@ function mountStudio(root, store) {
134
177
  current = { har: read.har, etag: read.etag };
135
178
  entries.innerHTML = `<ul>${read.har.log.entries.map(renderEntryRow).join("")}</ul>`;
136
179
  detail.innerHTML = "";
180
+ const recordingName = read.har.log._recordingName;
181
+ const nameField = importForm === null ? null : importForm.querySelector("#import-name");
182
+ if (nameField !== null && typeof recordingName === "string" && recordingName !== "") {
183
+ nameField.value = recordingName;
184
+ }
137
185
  }
138
186
  function openEntry(entryId, order) {
139
187
  const entry = current?.har.log.entries.find((e) => String(e._id) === entryId && e._order === order);
@@ -156,6 +204,18 @@ function mountStudio(root, store) {
156
204
  report(err);
157
205
  }
158
206
  }
207
+ async function duplicate(entryId, order) {
208
+ if (currentId === null || current === null)
209
+ return;
210
+ try {
211
+ const { har } = await store.duplicateEntry(currentId, entryId, order, current.etag);
212
+ const added = har.log.entries.filter((entry) => String(entry._id) === entryId).length - 1;
213
+ await openRecording(currentId);
214
+ openEntry(entryId, added);
215
+ } catch (err) {
216
+ report(err);
217
+ }
218
+ }
159
219
  async function remove(entryId, order) {
160
220
  if (currentId === null || current === null)
161
221
  return;
@@ -166,8 +226,11 @@ function mountStudio(root, store) {
166
226
  report(err);
167
227
  }
168
228
  }
229
+ function errorMessage(err) {
230
+ return err instanceof StoreError && err.code === "conflict" ? "the recording changed underneath \u2014 reload and retry" : err instanceof Error ? err.message : String(err);
231
+ }
169
232
  function report(err) {
170
- const message = err instanceof StoreError && err.code === "conflict" ? "the recording changed underneath \u2014 reload and retry" : err instanceof Error ? err.message : String(err);
233
+ const message = errorMessage(err);
171
234
  const banner = detail.ownerDocument.createElement("p");
172
235
  banner.className = "problem";
173
236
  banner.textContent = message;
@@ -196,6 +259,8 @@ function mountStudio(root, store) {
196
259
  const order = Number(section.dataset["order"]);
197
260
  if (target.getAttribute("name") === "save")
198
261
  void save(entryId, order);
262
+ if (target.getAttribute("name") === "duplicate")
263
+ void duplicate(entryId, order);
199
264
  if (target.getAttribute("name") === "delete")
200
265
  void remove(entryId, order);
201
266
  });
@@ -263,9 +328,24 @@ var HttpStore = class {
263
328
  async patchEntry(id, entryId, order, etag, patch) {
264
329
  return await this.json("PATCH", `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}`, { etag, patch });
265
330
  }
331
+ /** POST to the entry being copied: this adds a member to its group. */
332
+ async duplicateEntry(id, entryId, order, etag) {
333
+ return await this.json("POST", `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}?etag=${encodeURIComponent(etag)}`);
334
+ }
266
335
  async deleteEntry(id, entryId, order, etag) {
267
336
  return await this.json("DELETE", `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}?etag=${encodeURIComponent(etag)}`);
268
337
  }
338
+ /**
339
+ * Not on the wire protocol: creation happens inside `/api/import`, server-side,
340
+ * the same way `write` happens inside PATCH. Declared so a caller holding a
341
+ * concrete HttpStore still type-checks against the interface's arity.
342
+ */
343
+ async create(id, har) {
344
+ throw new StoreError("forbidden", "the HTTP API creates recordings through import \u2014 use importHar");
345
+ }
346
+ async importHar(recordingName, incoming) {
347
+ return await this.json("POST", "/api/import", { recordingName, har: incoming });
348
+ }
269
349
  async json(method, path, body) {
270
350
  const res = await fetch(`${this.basePath}${path}`, {
271
351
  method,
@@ -29,6 +29,28 @@ export type EditResult = {
29
29
  * wrong rather than merely stale.
30
30
  */
31
31
  export declare function applyResponsePatch(har: Har, id: string, order: number, patch: ResponsePatch): EditResult;
32
+ /**
33
+ * Copies an entry to the end of its own `_id` group — "the *next* time this same
34
+ * request happens, reply with this".
35
+ *
36
+ * `_order` is a position within a group of identical requests, not a label:
37
+ * Polly derives it at replay by counting how many earlier requests produced the
38
+ * same `_id`, starting at 0. So a recording with two members of a group answers
39
+ * the first call with `_order: 0` and the second with `_order: 1`. Duplicating
40
+ * appends a member, which is the only honest meaning "duplicate" can have here.
41
+ *
42
+ * What it deliberately is **not** is a way to make a *different* request. The
43
+ * `_id` is an md5 over the request that this package never recomputes — see
44
+ * `import.ts` — so a copy with an edited URL would carry the original's id,
45
+ * match nothing at replay, and fail silently. The copy keeps the id, and the
46
+ * caller edits the response.
47
+ *
48
+ * The clone is inserted directly after the last member of its group rather than
49
+ * appended to the file, because the list is shown in file order and a copy that
50
+ * appeared at the bottom, pages away from its original, reads as a new request
51
+ * somebody else recorded.
52
+ */
53
+ export declare function duplicateEntry(har: Har, id: string, order: number): EditResult;
32
54
  export declare function notFound(id: string, order: number): string;
33
55
  /**
34
56
  * Removes one entry and closes the gap it leaves in its `_id` group.
@@ -62,6 +62,41 @@ export function applyResponsePatch(har, id, order, patch) {
62
62
  }
63
63
  return { ok: true, har: next };
64
64
  }
65
+ /**
66
+ * Copies an entry to the end of its own `_id` group — "the *next* time this same
67
+ * request happens, reply with this".
68
+ *
69
+ * `_order` is a position within a group of identical requests, not a label:
70
+ * Polly derives it at replay by counting how many earlier requests produced the
71
+ * same `_id`, starting at 0. So a recording with two members of a group answers
72
+ * the first call with `_order: 0` and the second with `_order: 1`. Duplicating
73
+ * appends a member, which is the only honest meaning "duplicate" can have here.
74
+ *
75
+ * What it deliberately is **not** is a way to make a *different* request. The
76
+ * `_id` is an md5 over the request that this package never recomputes — see
77
+ * `import.ts` — so a copy with an edited URL would carry the original's id,
78
+ * match nothing at replay, and fail silently. The copy keeps the id, and the
79
+ * caller edits the response.
80
+ *
81
+ * The clone is inserted directly after the last member of its group rather than
82
+ * appended to the file, because the list is shown in file order and a copy that
83
+ * appeared at the bottom, pages away from its original, reads as a new request
84
+ * somebody else recorded.
85
+ */
86
+ export function duplicateEntry(har, id, order) {
87
+ const source = findEntry(har, id, order);
88
+ if (source === undefined) {
89
+ return { ok: false, error: { code: 'entry-not-found', message: notFound(id, order) } };
90
+ }
91
+ const next = structuredClone(har);
92
+ const entries = next.log.entries;
93
+ const group = entries.filter((entry) => entry._id === id);
94
+ const last = group.reduce((at, entry) => Math.max(at, entries.indexOf(entry)), -1);
95
+ const copy = structuredClone(findEntry(next, id, order));
96
+ copy._order = group.length;
97
+ entries.splice(last + 1, 0, copy);
98
+ return { ok: true, har: next };
99
+ }
65
100
  export function notFound(id, order) {
66
101
  return `no entry with _id ${id} and _order ${order} — it may have been deleted since the page loaded`;
67
102
  }
@@ -0,0 +1,34 @@
1
+ import type { Har, HarEntry } from './types.js';
2
+ /** The creator name the extension writes. Nothing else may be imported — see spec §3. */
3
+ export declare const IMPORTABLE_CREATOR_NAME = "swagger-recorder";
4
+ export interface ImportSkip {
5
+ url: string;
6
+ reason: string;
7
+ }
8
+ export interface ImportOutcome {
9
+ entries: HarEntry[];
10
+ added: number;
11
+ replaced: number;
12
+ skipped: ImportSkip[];
13
+ }
14
+ /**
15
+ * Whole-file rejection, null when the file is importable.
16
+ *
17
+ * The creator check is not caution, it is a correctness limit. An entry's `_id`
18
+ * is an md5 over method, url and body, and a multipart body is not in a HAR at
19
+ * all — so no importer can derive an id, and only this extension writes one.
20
+ * Accepting a DevTools export would mean writing entries with no id, or guessing.
21
+ */
22
+ export declare function rejectImport(har: Har): string | null;
23
+ /**
24
+ * Incoming entries folded into existing ones, keyed on `_id`.
25
+ *
26
+ * New wins, and a replacement keeps its position: the UI lists entries in file
27
+ * order, so a merge that moved them would read as the recording having changed
28
+ * more than it did.
29
+ *
30
+ * Duplicates inside the incoming file collapse rather than appending twice.
31
+ * Polly's `findEntry` stops at the first `_id` match, so a second copy is an
32
+ * entry nothing can reach, and re-importing would grow the file without bound.
33
+ */
34
+ export declare function mergeEntries(existing: HarEntry[], incoming: HarEntry[]): ImportOutcome;
@@ -0,0 +1,93 @@
1
+ /** The creator name the extension writes. Nothing else may be imported — see spec §3. */
2
+ export const IMPORTABLE_CREATOR_NAME = 'swagger-recorder';
3
+ /**
4
+ * Whole-file rejection, null when the file is importable.
5
+ *
6
+ * The creator check is not caution, it is a correctness limit. An entry's `_id`
7
+ * is an md5 over method, url and body, and a multipart body is not in a HAR at
8
+ * all — so no importer can derive an id, and only this extension writes one.
9
+ * Accepting a DevTools export would mean writing entries with no id, or guessing.
10
+ */
11
+ export function rejectImport(har) {
12
+ const creator = har.log?.creator?.name;
13
+ if (creator !== IMPORTABLE_CREATOR_NAME) {
14
+ return `this HAR's creator is ${JSON.stringify(creator ?? null)} — only HARs downloaded from the swagger-recorder extension can be imported, because only they carry the Polly ids replay needs`;
15
+ }
16
+ if (!Array.isArray(har.log.entries) || har.log.entries.length === 0) {
17
+ return 'this HAR has no entries';
18
+ }
19
+ return null;
20
+ }
21
+ /**
22
+ * Incoming entries folded into existing ones, keyed on `_id`.
23
+ *
24
+ * New wins, and a replacement keeps its position: the UI lists entries in file
25
+ * order, so a merge that moved them would read as the recording having changed
26
+ * more than it did.
27
+ *
28
+ * Duplicates inside the incoming file collapse rather than appending twice.
29
+ * Polly's `findEntry` stops at the first `_id` match, so a second copy is an
30
+ * entry nothing can reach, and re-importing would grow the file without bound.
31
+ */
32
+ export function mergeEntries(existing, incoming) {
33
+ const entries = [...existing];
34
+ const positions = new Map();
35
+ // First-wins, not last-wins: `buildPollyHar` stamps `_order: 0` on every entry
36
+ // without deduping, so an existing recording can legitimately hold the same
37
+ // `_id` twice. Polly's `findEntry` (har.ts) resolves an id to the FIRST match,
38
+ // so that is the index a replacement has to target — overwriting the later
39
+ // occurrence would report success while changing a slot Polly never reads.
40
+ entries.forEach((entry, index) => {
41
+ if (typeof entry?._id === 'string' && !positions.has(entry._id))
42
+ positions.set(entry._id, index);
43
+ });
44
+ const skipped = [];
45
+ let added = 0;
46
+ let replaced = 0;
47
+ for (const candidate of incoming) {
48
+ const problem = unusable(candidate);
49
+ if (problem !== null) {
50
+ skipped.push({ url: urlOf(candidate), reason: problem });
51
+ continue;
52
+ }
53
+ // `_order` is stamped rather than carried: polly-kit sets
54
+ // `matchRequestsBy.order: false`, so Polly computes 0 for every request.
55
+ const entry = { ...candidate, _order: 0 };
56
+ const at = positions.get(entry._id);
57
+ if (at === undefined) {
58
+ positions.set(entry._id, entries.length);
59
+ entries.push(entry);
60
+ added++;
61
+ continue;
62
+ }
63
+ // A collision with an entry this same import just added counts as neither:
64
+ // it was already reported as `added`, and reporting it as `replaced` too
65
+ // would describe one incoming entry twice.
66
+ const wasJustAdded = at >= existing.length;
67
+ entries[at] = entry;
68
+ if (!wasJustAdded)
69
+ replaced++;
70
+ }
71
+ return { entries, added, replaced, skipped };
72
+ }
73
+ /** Null when the entry can be written. A reason naming the cause otherwise. */
74
+ function unusable(entry) {
75
+ if (entry === null || typeof entry !== 'object') {
76
+ return 'not an object — this is not a HAR entry';
77
+ }
78
+ if (typeof entry._id !== 'string' || entry._id === '') {
79
+ return 'no _id: recorded by an extension version that did not write one, so Polly could never find it. Record again with the current extension.';
80
+ }
81
+ if (entry.request === null || typeof entry.request !== 'object') {
82
+ return 'no request — this is not a HAR entry';
83
+ }
84
+ if (entry.response === null || typeof entry.response !== 'object') {
85
+ return 'no response, so there is nothing to replay';
86
+ }
87
+ return null;
88
+ }
89
+ /** Best-effort label for the skip report. Never throws on a malformed entry. */
90
+ function urlOf(entry) {
91
+ const url = entry?.request?.url;
92
+ return typeof url === 'string' ? url : '(unknown url)';
93
+ }
@@ -7,3 +7,7 @@ export { parseHar, findEntry, POLLY_CREATOR_NAME } from './har.js';
7
7
  export { applyResponsePatch, deleteEntry } from './edit.js';
8
8
  export type { EditError, EditResult } from './edit.js';
9
9
  export type { Har, HarEntry, HarLog, HarRequest, HarResponse, HarContent, NvPair, ResponsePatch, } from './types.js';
10
+ export { mergeEntries, rejectImport, IMPORTABLE_CREATOR_NAME } from './import.js';
11
+ export type { ImportOutcome, ImportSkip } from './import.js';
12
+ export { guidForRecording, rejectRecordingName } from './recording-id.js';
13
+ export type { ImportResult } from './stores/types.js';
@@ -3,3 +3,5 @@ export { FsStore, serializeHar } from './stores/fs-store.js';
3
3
  export { StoreError } from './stores/types.js';
4
4
  export { parseHar, findEntry, POLLY_CREATOR_NAME } from './har.js';
5
5
  export { applyResponsePatch, deleteEntry } from './edit.js';
6
+ export { mergeEntries, rejectImport, IMPORTABLE_CREATOR_NAME } from './import.js';
7
+ export { guidForRecording, rejectRecordingName } from './recording-id.js';
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Trimmed before it reaches Polly's hashing, not just checked: `' my-app '` and
3
+ * `'my-app'` hash to `my-app_4097172865` and `my-app_4014489609` — the slug half
4
+ * is identical, so the wrong directory looks right in a listing. Trimming here
5
+ * rather than in `rejectRecordingName` (a guard, not a transform) means every
6
+ * caller gets it whether or not they remembered to trim first.
7
+ */
8
+ export declare function guidForRecording(recordingName: string): string;
9
+ /**
10
+ * Why an empty name is refused rather than hashed.
11
+ *
12
+ * `guidForRecording('')` is `_2166136261` — a valid directory name that would be
13
+ * created happily and then never read, because no app configures an empty
14
+ * `recordingName`. Refusing names the mistake at the point it is made.
15
+ */
16
+ export declare function rejectRecordingName(name: string): string | null;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The directory Polly looks a recording up in.
3
+ *
4
+ * Taken from Polly's own module rather than copied. The ten lines are easy to
5
+ * reproduce and easy to reproduce *wrongly*: `@sindresorhus/fnv1a@2`'s default
6
+ * export is the legacy 32-bit number implementation, which is the one Polly
7
+ * calls, while its named size exports return BigInt and would yield a different
8
+ * directory. A copy that drifted would send every recording somewhere nothing
9
+ * reads — silently, since a missing directory is a normal state.
10
+ *
11
+ * `@pollyjs/core` declares no `exports` map, so the deep path resolves.
12
+ * `test/studio/recording-id.test.ts` pins the result, so a core that moves the
13
+ * file fails a test rather than changing where recordings go.
14
+ */
15
+ import guidFor from '@pollyjs/core/src/utils/guid-for-recording.js';
16
+ /**
17
+ * Trimmed before it reaches Polly's hashing, not just checked: `' my-app '` and
18
+ * `'my-app'` hash to `my-app_4097172865` and `my-app_4014489609` — the slug half
19
+ * is identical, so the wrong directory looks right in a listing. Trimming here
20
+ * rather than in `rejectRecordingName` (a guard, not a transform) means every
21
+ * caller gets it whether or not they remembered to trim first.
22
+ */
23
+ export function guidForRecording(recordingName) {
24
+ return guidFor(recordingName.trim());
25
+ }
26
+ /**
27
+ * Why an empty name is refused rather than hashed.
28
+ *
29
+ * `guidForRecording('')` is `_2166136261` — a valid directory name that would be
30
+ * created happily and then never read, because no app configures an empty
31
+ * `recordingName`. Refusing names the mistake at the point it is made.
32
+ */
33
+ export function rejectRecordingName(name) {
34
+ return name.trim() === '' ? 'a recording name is required' : null;
35
+ }
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { FsStore } from './stores/fs-store.js';
5
5
  import { StoreError } from './stores/types.js';
6
- import { applyResponsePatch, deleteEntry } from './edit.js';
6
+ import { applyResponsePatch, deleteEntry, duplicateEntry } from './edit.js';
7
7
  import { renderPage } from './ui/page.js';
8
8
  /**
9
9
  * A mountable editor for Polly recordings, in the style of swagger-ui: the app serves
@@ -66,6 +66,30 @@ async function handle(req, res, ctx) {
66
66
  sendJson(res, 200, { recordings: await ctx.store.list() });
67
67
  return;
68
68
  }
69
+ if (path === '/api/import' && req.method === 'POST') {
70
+ const body = await readJson(req, MAX_IMPORT_BYTES);
71
+ if (!body.ok) {
72
+ if (body.status === 413)
73
+ res.setHeader('connection', 'close');
74
+ sendJson(res, body.status, { error: body.error });
75
+ return;
76
+ }
77
+ // A body of `null`, `[]` or `"x"` is valid JSON with no fields. Coercing to
78
+ // `{}` keeps the destructuring off a non-object, which would throw into the 500.
79
+ const fields = (typeof body.value === 'object' && body.value !== null ? body.value : {});
80
+ if (typeof fields.recordingName !== 'string') {
81
+ sendJson(res, 400, { error: 'recordingName must be a string' });
82
+ return;
83
+ }
84
+ if (fields.har === null || typeof fields.har !== 'object') {
85
+ sendJson(res, 400, { error: 'har must be a HAR object' });
86
+ return;
87
+ }
88
+ // StoreError from here — a rejected creator, an empty name, a corrupt
89
+ // target — lands in the catch-all below, which maps `unprocessable` to 422.
90
+ sendJson(res, 200, await ctx.store.importHar(fields.recordingName, fields.har));
91
+ return;
92
+ }
69
93
  // /api/recordings/:id and /api/recordings/:id/entries/:_id/:_order
70
94
  const segments = path.split('/').filter((segment) => segment !== '');
71
95
  const rawId = segments[2];
@@ -89,7 +113,7 @@ async function handle(req, res, ctx) {
89
113
  }
90
114
  const order = Number(segments[5]);
91
115
  if (req.method === 'PATCH') {
92
- const body = await readJson(req);
116
+ const body = await readJson(req, MAX_EDIT_BYTES);
93
117
  if (!body.ok) {
94
118
  if (body.status === 413)
95
119
  res.setHeader('connection', 'close');
@@ -107,15 +131,23 @@ async function handle(req, res, ctx) {
107
131
  await mutate(ctx, res, id, etag, (har) => deleteEntry(har, entryId, order));
108
132
  return;
109
133
  }
134
+ // POST, not PUT: this creates a member of the group rather than
135
+ // replacing one, and the URL it is posted to is the entry being copied.
136
+ if (req.method === 'POST') {
137
+ const etag = url.searchParams.get('etag') ?? '';
138
+ await mutate(ctx, res, id, etag, (har) => duplicateEntry(har, entryId, order));
139
+ return;
140
+ }
110
141
  }
111
142
  }
112
143
  sendJson(res, 404, { error: 'not found' });
113
144
  }
114
145
  catch (err) {
115
146
  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.
147
+ // `unprocessable` is reachable: `/api/import` calls `store.importHar`, which raises
148
+ // it for a rejected creator, an empty recording name, or a corrupt target recording.
149
+ // Each of those — like a binary-body refusal elsewhere on the interface — is a 422,
150
+ // not a 403.
119
151
  const status = err.code === 'not-found'
120
152
  ? 404
121
153
  : err.code === 'conflict'
@@ -196,7 +228,14 @@ function decodeSegment(segment) {
196
228
  * The cap on a PATCH body. Generous for the JSON around one edited HAR entry, and far
197
229
  * below what it takes to hurt the host process.
198
230
  */
199
- const MAX_BODY_BYTES = 4 * 1024 * 1024;
231
+ const MAX_EDIT_BYTES = 4 * 1024 * 1024;
232
+ /**
233
+ * The cap on an imported HAR, which is a whole recording rather than one entry.
234
+ * Matched to the recording store's own 50 MB limit (`src/store.ts`): a file the
235
+ * store will happily persist must not be refused on the way in, or the feature
236
+ * fails on exactly the recordings worth importing — the ones with image bodies.
237
+ */
238
+ const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
200
239
  /**
201
240
  * The request body, bounded and parsed.
202
241
  *
@@ -206,7 +245,7 @@ const MAX_BODY_BYTES = 4 * 1024 * 1024;
206
245
  * A `SyntaxError` from `JSON.parse` is the same failure class as the malformed
207
246
  * percent-encoding handled in `decodeSegment`: a malformed request, not a server fault.
208
247
  */
209
- async function readJson(req) {
248
+ async function readJson(req, limit) {
210
249
  const chunks = [];
211
250
  let bytes = 0;
212
251
  let overflowed = false;
@@ -215,7 +254,7 @@ async function readJson(req) {
215
254
  bytes += buffer.length;
216
255
  if (overflowed)
217
256
  continue;
218
- if (bytes > MAX_BODY_BYTES) {
257
+ if (bytes > limit) {
219
258
  // Drop what was accumulated and stop accumulating, but keep draining. The cap is
220
259
  // about retained memory, and this bounds it. Neither `req.destroy()` nor
221
260
  // `req.pause()` works here: destroying resets the socket so a client still
@@ -228,7 +267,7 @@ async function readJson(req) {
228
267
  chunks.push(buffer);
229
268
  }
230
269
  if (overflowed) {
231
- return { ok: false, status: 413, error: `request body exceeds ${MAX_BODY_BYTES} bytes` };
270
+ return { ok: false, status: 413, error: `request body exceeds ${limit} bytes` };
232
271
  }
233
272
  const raw = Buffer.concat(chunks).toString('utf8');
234
273
  if (raw === '')
@@ -1,6 +1,17 @@
1
- import type { ReadResult, RecordingStore } from './types.js';
2
- import type { ResponsePatch } from '../types.js';
1
+ import type { ReadResult, RecordingStore, ImportResult } from './types.js';
2
+ import type { Har, ResponsePatch } from '../types.js';
3
3
  type Local = Pick<RecordingStore, 'read' | 'write'>;
4
4
  export declare function patchEntryVia(store: Local, id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
5
5
  export declare function deleteEntryVia(store: Local, id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
6
+ export declare function duplicateEntryVia(store: Local, id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
7
+ type Importable = Pick<RecordingStore, 'read' | 'write' | 'create'>;
8
+ /**
9
+ * The read-merge-write cycle, shared by every store that owns its own storage.
10
+ *
11
+ * A corrupt existing file is refused rather than overwritten. `read` reports
12
+ * unparseable JSON as `not-found`, which is indistinguishable from "absent" at
13
+ * this layer, so absence is established by `list` instead — the one call that
14
+ * tells a recording that cannot be parsed apart from one that was never written.
15
+ */
16
+ export declare function importHarVia(store: Importable & Pick<RecordingStore, 'list'>, recordingName: string, incoming: Har): Promise<ImportResult>;
6
17
  export {};