pinnace 0.0.0 → 0.2.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 (83) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +164 -0
  3. package/dist/car/car-build.d.ts +26 -0
  4. package/dist/car/car-build.d.ts.map +1 -0
  5. package/dist/car/car-build.js +113 -0
  6. package/dist/car/car-build.js.map +1 -0
  7. package/dist/ci/ci-emit.d.ts +116 -0
  8. package/dist/ci/ci-emit.d.ts.map +1 -0
  9. package/dist/ci/ci-emit.js +190 -0
  10. package/dist/ci/ci-emit.js.map +1 -0
  11. package/dist/cli/bin.d.ts +3 -0
  12. package/dist/cli/bin.d.ts.map +1 -0
  13. package/dist/cli/bin.js +17 -0
  14. package/dist/cli/bin.js.map +1 -0
  15. package/dist/cli/run.d.ts +94 -0
  16. package/dist/cli/run.d.ts.map +1 -0
  17. package/dist/cli/run.js +731 -0
  18. package/dist/cli/run.js.map +1 -0
  19. package/dist/config/config-resolution.d.ts +186 -0
  20. package/dist/config/config-resolution.d.ts.map +1 -0
  21. package/dist/config/config-resolution.js +137 -0
  22. package/dist/config/config-resolution.js.map +1 -0
  23. package/dist/deploy/deploy.d.ts +121 -0
  24. package/dist/deploy/deploy.d.ts.map +1 -0
  25. package/dist/deploy/deploy.js +150 -0
  26. package/dist/deploy/deploy.js.map +1 -0
  27. package/dist/derive/ipns-key-derivation.d.ts +37 -0
  28. package/dist/derive/ipns-key-derivation.d.ts.map +1 -0
  29. package/dist/derive/ipns-key-derivation.js +130 -0
  30. package/dist/derive/ipns-key-derivation.js.map +1 -0
  31. package/dist/index.d.ts +27 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +29 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/node/node-commands.d.ts +115 -0
  36. package/dist/node/node-commands.d.ts.map +1 -0
  37. package/dist/node/node-commands.js +231 -0
  38. package/dist/node/node-commands.js.map +1 -0
  39. package/dist/provision/cloud-init.d.ts +162 -0
  40. package/dist/provision/cloud-init.d.ts.map +1 -0
  41. package/dist/provision/cloud-init.js +460 -0
  42. package/dist/provision/cloud-init.js.map +1 -0
  43. package/dist/publisher/key-import.d.ts +105 -0
  44. package/dist/publisher/key-import.d.ts.map +1 -0
  45. package/dist/publisher/key-import.js +76 -0
  46. package/dist/publisher/key-import.js.map +1 -0
  47. package/dist/publisher/record-sequence.d.ts +90 -0
  48. package/dist/publisher/record-sequence.d.ts.map +1 -0
  49. package/dist/publisher/record-sequence.js +250 -0
  50. package/dist/publisher/record-sequence.js.map +1 -0
  51. package/dist/rpc/kubo-rpc-client.d.ts +154 -0
  52. package/dist/rpc/kubo-rpc-client.d.ts.map +1 -0
  53. package/dist/rpc/kubo-rpc-client.js +207 -0
  54. package/dist/rpc/kubo-rpc-client.js.map +1 -0
  55. package/dist/rpc/mock-kubo.d.ts +87 -0
  56. package/dist/rpc/mock-kubo.d.ts.map +1 -0
  57. package/dist/rpc/mock-kubo.js +129 -0
  58. package/dist/rpc/mock-kubo.js.map +1 -0
  59. package/dist/site/site-management.d.ts +126 -0
  60. package/dist/site/site-management.d.ts.map +1 -0
  61. package/dist/site/site-management.js +123 -0
  62. package/dist/site/site-management.js.map +1 -0
  63. package/dist/status/status-report.d.ts +145 -0
  64. package/dist/status/status-report.d.ts.map +1 -0
  65. package/dist/status/status-report.js +192 -0
  66. package/dist/status/status-report.js.map +1 -0
  67. package/package.json +45 -2
  68. package/src/car/car-build.ts +135 -0
  69. package/src/ci/ci-emit.ts +284 -0
  70. package/src/cli/bin.ts +20 -0
  71. package/src/cli/run.ts +970 -0
  72. package/src/config/config-resolution.ts +264 -0
  73. package/src/deploy/deploy.ts +250 -0
  74. package/src/derive/ipns-key-derivation.ts +173 -0
  75. package/src/index.ts +146 -0
  76. package/src/node/node-commands.ts +395 -0
  77. package/src/provision/cloud-init.ts +646 -0
  78. package/src/publisher/key-import.ts +141 -0
  79. package/src/publisher/record-sequence.ts +336 -0
  80. package/src/rpc/kubo-rpc-client.ts +281 -0
  81. package/src/rpc/mock-kubo.ts +194 -0
  82. package/src/site/site-management.ts +241 -0
  83. package/src/status/status-report.ts +291 -0
@@ -0,0 +1,281 @@
1
+ /**
2
+ * A typed, per-node Kubo RPC client. It wraps `POST {baseUrl}/api/v0/<path>`
3
+ * with an `Authorization: Bearer <token>` header on EVERY call. No Kubo binary
4
+ * runs on the client — all interaction is HTTP.
5
+ *
6
+ * This is the seam every host-agnostic pinnace operation (deploy, publish,
7
+ * status, site management) speaks. It is deliberately PER-NODE (one base URL +
8
+ * one token); multi-target fan-out lives in the deploy task, not here.
9
+ *
10
+ * Behaviour ported (not copied) from the reference prototype
11
+ * `~/searches/ipfs-hetzner/deploy-car.mjs`: the exact query params
12
+ * (`files/mkdir?arg=/sites&parents=true`, `dag/import?pin-roots=true`, ...) and
13
+ * the throw-on-`!ok` error handling.
14
+ */
15
+
16
+ /** A minimal `fetch`-shaped function, so tests can inject a recording mock. */
17
+ export type FetchLike = (
18
+ input: string | URL,
19
+ init?: {method?: string; headers?: Record<string, string>; body?: unknown},
20
+ ) => Promise<Response>;
21
+
22
+ /** Options for constructing a per-node {@link KuboRpcClient}. */
23
+ export interface KuboRpcClientOptions {
24
+ /** The node's Kubo RPC base URL, e.g. `https://ipfs-api.example.com`. */
25
+ baseUrl: string;
26
+ /** The node's bearer token (sent on every call). */
27
+ token: string;
28
+ /** Injectable fetch (defaults to the global `fetch`); tests pass the mock. */
29
+ fetchImpl?: FetchLike;
30
+ }
31
+
32
+ /**
33
+ * A loud error carrying the endpoint + HTTP status of a non-2xx Kubo response,
34
+ * so a caller (and a log) can see exactly which call failed and how.
35
+ */
36
+ export class KuboRpcError extends Error {
37
+ constructor(
38
+ /** The `/api/v0/<endpoint>` path that failed. */
39
+ readonly endpoint: string,
40
+ /** The HTTP status returned. */
41
+ readonly status: number,
42
+ /** The response body text (for diagnostics). */
43
+ readonly bodyText: string,
44
+ ) {
45
+ super(`Kubo RPC ${endpoint} failed: ${status} ${bodyText}`);
46
+ this.name = 'KuboRpcError';
47
+ }
48
+ }
49
+
50
+ /** Flags accepted by `files/mkdir`. */
51
+ export interface FilesMkdirOptions {
52
+ /** Create parent directories as needed (`parents=true`). */
53
+ parents?: boolean;
54
+ }
55
+
56
+ /** Flags accepted by `files/rm`. */
57
+ export interface FilesRmOptions {
58
+ /** Remove directories recursively (`recursive=true`). */
59
+ recursive?: boolean;
60
+ /** Ignore nonexistent files (`force=true`). */
61
+ force?: boolean;
62
+ }
63
+
64
+ /** Parameters for `name/publish`. */
65
+ export interface NamePublishOptions {
66
+ /** The `/ipfs/<cid>` path to publish. */
67
+ cidPath: string;
68
+ /** The keystore key name to sign with. */
69
+ key: string;
70
+ /** Record lifetime (e.g. `72h`). */
71
+ lifetime?: string;
72
+ /** Record TTL (e.g. `1h`). */
73
+ ttl?: string;
74
+ /** Publish even with no online peers (`allow-offline=true`). */
75
+ allowOffline?: boolean;
76
+ }
77
+
78
+ export class KuboRpcClient {
79
+ private readonly baseUrl: string;
80
+ private readonly token: string;
81
+ private readonly fetchImpl: FetchLike;
82
+
83
+ constructor(options: KuboRpcClientOptions) {
84
+ // Normalise: strip trailing slashes so path joining is unambiguous.
85
+ this.baseUrl = options.baseUrl.replace(/\/+$/, '');
86
+ this.token = options.token;
87
+ this.fetchImpl = options.fetchImpl ?? (globalThis.fetch as FetchLike);
88
+ }
89
+
90
+ /**
91
+ * The core request primitive: POST `/api/v0/<endpoint>?<query>` with the
92
+ * bearer token. Throws {@link KuboRpcError} on any non-2xx. Returns the raw
93
+ * {@link Response} so callers can decode JSON or bytes as they need.
94
+ */
95
+ async request(
96
+ endpoint: string,
97
+ query?: URLSearchParams,
98
+ body?: unknown,
99
+ extraHeaders?: Record<string, string>,
100
+ ): Promise<Response> {
101
+ const qs =
102
+ query && [...query.keys()].length > 0 ? `?${query.toString()}` : '';
103
+ const url = `${this.baseUrl}/api/v0/${endpoint}${qs}`;
104
+ const res = await this.fetchImpl(url, {
105
+ method: 'POST',
106
+ headers: {authorization: `Bearer ${this.token}`, ...(extraHeaders ?? {})},
107
+ body,
108
+ });
109
+ if (!res.ok) {
110
+ const text = await safeText(res);
111
+ throw new KuboRpcError(endpoint, res.status, text);
112
+ }
113
+ return res;
114
+ }
115
+
116
+ /** POST an endpoint and parse the JSON response body. */
117
+ private async requestJson<T = unknown>(
118
+ endpoint: string,
119
+ query?: URLSearchParams,
120
+ body?: unknown,
121
+ extraHeaders?: Record<string, string>,
122
+ ): Promise<T> {
123
+ const res = await this.request(endpoint, query, body, extraHeaders);
124
+ return (await res.json()) as T;
125
+ }
126
+
127
+ /** `id` — the node's identity (PeerID etc.). */
128
+ id<T = unknown>(): Promise<T> {
129
+ return this.requestJson<T>('id');
130
+ }
131
+
132
+ /**
133
+ * `add` — add raw bytes (returned shape is Kubo-defined). Kubo requires the
134
+ * payload as a `multipart/form-data` `file` part, NOT a raw body, so we send
135
+ * a {@link FormData} and let `fetch` set the multipart boundary itself (see
136
+ * {@link fileUpload}).
137
+ */
138
+ add<T = unknown>(data: Uint8Array): Promise<T> {
139
+ return this.fileUpload<T>('add', undefined, data);
140
+ }
141
+
142
+ /** `dag/import?pin-roots=true` — import a CAR and pin its roots. */
143
+ dagImport<T = unknown>(car: Uint8Array): Promise<T> {
144
+ const q = new URLSearchParams({'pin-roots': 'true'});
145
+ return this.fileUpload<T>('dag/import', q, car);
146
+ }
147
+
148
+ /**
149
+ * `pin/rm?arg=<cid>` — UNPIN content so Kubo can garbage-collect it and
150
+ * reclaim storage. The counterpart to `dag/import?pin-roots=true`: removing a
151
+ * site unpins its CID here so it stops being served/announced. `arg` takes a
152
+ * bare CID (or an `/ipfs/<cid>` path); callers pass the CID from `files/stat`.
153
+ */
154
+ pinRm<T = unknown>(cid: string): Promise<T> {
155
+ return this.requestJson<T>('pin/rm', new URLSearchParams({arg: cid}));
156
+ }
157
+
158
+ /** `files/mkdir?arg=<path>[&parents=true]`. */
159
+ async filesMkdir(
160
+ path: string,
161
+ options: FilesMkdirOptions = {},
162
+ ): Promise<void> {
163
+ const q = new URLSearchParams({arg: path});
164
+ if (options.parents) q.set('parents', 'true');
165
+ await this.request('files/mkdir', q);
166
+ }
167
+
168
+ /** `files/rm?arg=<path>[&recursive=true][&force=true]`. */
169
+ async filesRm(path: string, options: FilesRmOptions = {}): Promise<void> {
170
+ const q = new URLSearchParams({arg: path});
171
+ if (options.recursive) q.set('recursive', 'true');
172
+ if (options.force) q.set('force', 'true');
173
+ await this.request('files/rm', q);
174
+ }
175
+
176
+ /** `files/cp?arg=<from>&arg=<to>`. */
177
+ async filesCp(from: string, to: string): Promise<void> {
178
+ const q = new URLSearchParams();
179
+ q.append('arg', from);
180
+ q.append('arg', to);
181
+ await this.request('files/cp', q);
182
+ }
183
+
184
+ /** `files/stat?arg=<path>` — returns the MFS entry stat (Hash, Type, ...). */
185
+ filesStat<T = unknown>(path: string): Promise<T> {
186
+ return this.requestJson<T>('files/stat', new URLSearchParams({arg: path}));
187
+ }
188
+
189
+ /** `files/ls?arg=<path>[&l=true]` — list an MFS directory. */
190
+ filesLs<T = unknown>(path: string, long = true): Promise<T> {
191
+ const q = new URLSearchParams({arg: path});
192
+ if (long) q.set('long', 'true');
193
+ return this.requestJson<T>('files/ls', q);
194
+ }
195
+
196
+ /** `key/list?l=true` — list keystore keys with their IPNS ids. */
197
+ keyList<T = unknown>(): Promise<T> {
198
+ return this.requestJson<T>('key/list', new URLSearchParams({l: 'true'}));
199
+ }
200
+
201
+ /** `key/gen?arg=<name>&type=ed25519` — generate a new keystore key. */
202
+ keyGen<T = unknown>(name: string): Promise<T> {
203
+ const q = new URLSearchParams({arg: name, type: 'ed25519'});
204
+ return this.requestJson<T>('key/gen', q);
205
+ }
206
+
207
+ /**
208
+ * `key/import?arg=<name>` — import key MATERIAL (libp2p/protobuf bytes) into
209
+ * the keystore. The node, not the client, signs records with it.
210
+ */
211
+ keyImport<T = unknown>(name: string, keyBytes: Uint8Array): Promise<T> {
212
+ const q = new URLSearchParams({arg: name});
213
+ return this.fileUpload<T>('key/import', q, keyBytes);
214
+ }
215
+
216
+ /**
217
+ * Kubo's file-upload endpoints require the payload as a `multipart/form-data`
218
+ * body with the bytes as a named file part (confirmed by the Kubo RPC docs'
219
+ * `-F <field>=@…` cURL examples and a live daemon). A raw
220
+ * `application/octet-stream` body is rejected with
221
+ * `400 file argument '<field>' is required`.
222
+ *
223
+ * The field name is endpoint-specific: `add`, `dag/import` and `key/import`
224
+ * expect `file` (the default), while `routing/put` expects `value-file`.
225
+ *
226
+ * We build a {@link FormData} and pass it as the body WITHOUT a hand-set
227
+ * `content-type`: `fetch` serialises the FormData and sets
228
+ * `content-type: multipart/form-data; boundary=…` itself. Setting it manually
229
+ * would omit/override the boundary and break the request.
230
+ */
231
+ private fileUpload<T = unknown>(
232
+ endpoint: string,
233
+ query: URLSearchParams | undefined,
234
+ bytes: Uint8Array,
235
+ field = 'file',
236
+ ): Promise<T> {
237
+ const form = new FormData();
238
+ form.append(field, new Blob([bytes as BlobPart]), field);
239
+ return this.requestJson<T>(endpoint, query, form);
240
+ }
241
+
242
+ /** `name/publish?arg=<cidPath>&key=<key>&lifetime=..&ttl=..` — sign+publish IPNS. */
243
+ namePublish<T = unknown>(options: NamePublishOptions): Promise<T> {
244
+ const q = new URLSearchParams({arg: options.cidPath, key: options.key});
245
+ if (options.lifetime) q.set('lifetime', options.lifetime);
246
+ if (options.ttl) q.set('ttl', options.ttl);
247
+ if (options.allowOffline) q.set('allow-offline', 'true');
248
+ return this.requestJson<T>('name/publish', q);
249
+ }
250
+
251
+ /** `routing/get?arg=<ipnsPath>` — fetch the raw signed record for a name. */
252
+ async routingGet(ipnsPath: string): Promise<Uint8Array> {
253
+ const res = await this.request(
254
+ 'routing/get',
255
+ new URLSearchParams({arg: ipnsPath}),
256
+ );
257
+ return new Uint8Array(await res.arrayBuffer());
258
+ }
259
+
260
+ /**
261
+ * `routing/put?arg=<ipnsPath>` — (re-)announce a signed record body. Kubo's
262
+ * `routing/put` takes the record as a `multipart/form-data` file part named
263
+ * **`value-file`** (NOT the generic `file` the other upload endpoints use); a
264
+ * raw `application/octet-stream` body is rejected with
265
+ * `400 file argument 'value-file' is required`. Goes through {@link fileUpload}
266
+ * so `fetch` owns the multipart boundary (no hand-set content-type).
267
+ */
268
+ async routingPut(ipnsPath: string, record: Uint8Array): Promise<void> {
269
+ const q = new URLSearchParams({arg: ipnsPath});
270
+ await this.fileUpload('routing/put', q, record, 'value-file');
271
+ }
272
+ }
273
+
274
+ /** Read a response body as text without throwing (best-effort for errors). */
275
+ async function safeText(res: Response): Promise<string> {
276
+ try {
277
+ return await res.text();
278
+ } catch {
279
+ return '';
280
+ }
281
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * A mock Kubo HTTP API for tests: it RECORDS every incoming request (method,
3
+ * path, query, headers, body) and returns canned responses, so a test can
4
+ * assert the exact call shape a `KuboRpcClient` produced WITHOUT a live daemon.
5
+ *
6
+ * This is the primary test boundary for every host-agnostic pinnace operation
7
+ * (deploy, publish, status, site management). It is exposed from the package's
8
+ * test surface so sibling tasks (deploy-multi-target, status-report, ...) reuse
9
+ * it rather than re-inventing a Kubo fake.
10
+ */
11
+
12
+ /** One multipart file part the mock extracted from a `FormData` body. */
13
+ export interface RecordedFilePart {
14
+ /** The multipart field name (Kubo's file-upload endpoints expect `file`). */
15
+ field: string;
16
+ /** The optional filename the part was appended with. */
17
+ filename?: string;
18
+ /** The part's bytes. */
19
+ bytes: Uint8Array;
20
+ }
21
+
22
+ /** One recorded RPC request as the mock observed it. */
23
+ export interface RecordedRequest {
24
+ /** The HTTP method (Kubo RPC is always POST). */
25
+ method: string;
26
+ /** The `/api/v0/...` path, WITHOUT the base URL or query string. */
27
+ path: string;
28
+ /** Parsed query parameters (multi-valued: `arg` can repeat). */
29
+ query: URLSearchParams;
30
+ /** Lower-cased header name → value, as fetch delivered them. */
31
+ headers: Record<string, string>;
32
+ /** The raw request body as text (empty string if none). */
33
+ bodyText: string;
34
+ /**
35
+ * The effective request `content-type`. For a `FormData` body this is
36
+ * `multipart/form-data` (real `fetch` sets it, WITH a boundary, when it
37
+ * serialises FormData) even though the caller must NOT hand-set it — the
38
+ * mock derives it here so tests can assert the multipart contract Kubo
39
+ * requires for its file-upload endpoints.
40
+ */
41
+ contentType?: string;
42
+ /**
43
+ * When the body was a `FormData` (a `multipart/form-data` upload), the file
44
+ * parts it carried. Empty/undefined for non-multipart bodies. Kubo's
45
+ * `add`, `dag/import` and `key/import` require at least one file part under
46
+ * the field name `file`.
47
+ */
48
+ fileParts?: RecordedFilePart[];
49
+ /** The full URL that was fetched. */
50
+ url: string;
51
+ }
52
+
53
+ /** A canned response for a given `/api/v0/<path>` (path without query). */
54
+ export interface MockResponseSpec {
55
+ /** HTTP status to return (default 200). */
56
+ status?: number;
57
+ /** JSON body to return; serialised and sent with content-type application/json. */
58
+ json?: unknown;
59
+ /** Raw text body to return (takes precedence over `json` if both set). */
60
+ text?: string;
61
+ }
62
+
63
+ /**
64
+ * A recording mock Kubo API. Install it with {@link fetchImpl} as the `fetch`
65
+ * a {@link KuboRpcClient} uses; inspect {@link requests} afterwards.
66
+ */
67
+ export class MockKuboApi {
68
+ /** Every request the mock received, in order. */
69
+ readonly requests: RecordedRequest[] = [];
70
+
71
+ /** path (no query) → canned response. */
72
+ private readonly responses = new Map<string, MockResponseSpec>();
73
+
74
+ /** The base URL this mock pretends to be (only its path/query are used). */
75
+ constructor(readonly baseUrl: string = 'https://node.example.test') {}
76
+
77
+ /** Register a canned response for a Kubo `/api/v0/<path>` (path without query). */
78
+ on(path: string, spec: MockResponseSpec): this {
79
+ this.responses.set(path, spec);
80
+ return this;
81
+ }
82
+
83
+ /** The most recent recorded request, or undefined if none. */
84
+ get lastRequest(): RecordedRequest | undefined {
85
+ return this.requests[this.requests.length - 1];
86
+ }
87
+
88
+ /** All recorded requests whose path equals `path`. */
89
+ requestsFor(path: string): RecordedRequest[] {
90
+ return this.requests.filter((r) => r.path === path);
91
+ }
92
+
93
+ /**
94
+ * A `fetch`-compatible function that records the request and returns the
95
+ * canned (or a default empty-JSON 200) response. Pass this to the client.
96
+ */
97
+ readonly fetchImpl = async (
98
+ input: string | URL,
99
+ init?: {method?: string; headers?: Record<string, string>; body?: unknown},
100
+ ): Promise<Response> => {
101
+ const url = new URL(typeof input === 'string' ? input : input.toString());
102
+ const path = url.pathname.replace(/^\/api\/v0\//, '');
103
+ const headers: Record<string, string> = {};
104
+ for (const [k, v] of Object.entries(init?.headers ?? {})) {
105
+ headers[k.toLowerCase()] = v;
106
+ }
107
+ const {bodyText, contentType, fileParts} = await inspectBody(
108
+ init?.body,
109
+ headers,
110
+ );
111
+ this.requests.push({
112
+ method: init?.method ?? 'GET',
113
+ path,
114
+ query: new URLSearchParams(url.search),
115
+ headers,
116
+ bodyText,
117
+ contentType,
118
+ fileParts,
119
+ url: url.toString(),
120
+ });
121
+
122
+ const spec = this.responses.get(path);
123
+ const status = spec?.status ?? 200;
124
+ if (spec?.text !== undefined) {
125
+ return new Response(spec.text, {status});
126
+ }
127
+ const payload = spec?.json ?? {};
128
+ return new Response(JSON.stringify(payload), {
129
+ status,
130
+ headers: {'content-type': 'application/json'},
131
+ });
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Inspect a fetch body the way a real `fetch` would surface it to Kubo:
137
+ * derive the effective `content-type` and, for a `multipart/form-data`
138
+ * (`FormData`) body, extract the file parts. This is what lets the mock
139
+ * ENFORCE Kubo's real upload contract (multipart + a `file` part) instead of
140
+ * merely recording raw bytes — the fidelity gap that let raw octet-stream
141
+ * uploads ship and then fail against a live daemon.
142
+ */
143
+ async function inspectBody(
144
+ body: unknown,
145
+ headers: Record<string, string>,
146
+ ): Promise<{
147
+ bodyText: string;
148
+ contentType?: string;
149
+ fileParts?: RecordedFilePart[];
150
+ }> {
151
+ if (body instanceof FormData) {
152
+ // Real `fetch` serialises a FormData as `multipart/form-data; boundary=…`
153
+ // and IGNORES any hand-set content-type. Model that here.
154
+ const fileParts: RecordedFilePart[] = [];
155
+ const textLines: string[] = [];
156
+ // This TS `lib` (dom) types FormData without `entries()`/an iterator, so
157
+ // collect the parts via `forEach` (which IS typed) then process them.
158
+ const entries: Array<[string, FormDataEntryValue]> = [];
159
+ body.forEach((value, field) => {
160
+ entries.push([field, value]);
161
+ });
162
+ for (const [field, value] of entries) {
163
+ if (value instanceof Blob) {
164
+ const bytes = new Uint8Array(await value.arrayBuffer());
165
+ const filename =
166
+ typeof (value as File).name === 'string'
167
+ ? (value as File).name
168
+ : undefined;
169
+ fileParts.push({field, filename, bytes});
170
+ } else {
171
+ textLines.push(`${field}=${String(value)}`);
172
+ }
173
+ }
174
+ return {
175
+ bodyText: textLines.join('&'),
176
+ contentType: 'multipart/form-data',
177
+ fileParts,
178
+ };
179
+ }
180
+ return {
181
+ bodyText: await bodyToText(body),
182
+ contentType: headers['content-type'],
183
+ };
184
+ }
185
+
186
+ /** Best-effort stringify of a non-multipart fetch body for recording. */
187
+ async function bodyToText(body: unknown): Promise<string> {
188
+ if (body === undefined || body === null) return '';
189
+ if (typeof body === 'string') return body;
190
+ if (body instanceof Blob) return await body.text();
191
+ if (body instanceof URLSearchParams) return body.toString();
192
+ if (body instanceof Uint8Array) return Buffer.from(body).toString('utf8');
193
+ return String(body);
194
+ }