@pipelex/sdk 0.46.0 → 0.47.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.
@@ -0,0 +1,689 @@
1
+ /**
2
+ * A saved method's tree on the client side (see `docs/method-trees.md`): the source a save reads a
3
+ * tree from, how that tree becomes the `files` map a save sends, inline or by hash, the blob routes
4
+ * that let a large tree travel by hash (mthds/docs/spec/protocol.md, "Sending files by hash"), the
5
+ * upload of a blob the platform lacks, and the writing of a saved tree to disk.
6
+ *
7
+ * The tree-sending part mirrors the `mthds` npm package's own helpers, which its published
8
+ * `mthds/protocol` subpath does not export yet. This SDK imports `mthds` through that subpath alone,
9
+ * so it carries this copy, held to the same rule: the same inline threshold, the same text-or-Base64
10
+ * choice per file, and each file added as an own property. It needs no Node built-in: SHA-256 is Web
11
+ * Crypto's, and Base64 is written here. Only `writeMethodTreeTo` needs Node, which it loads when
12
+ * called.
13
+ */
14
+ import { fallbackVerdict } from "./error-verdicts.js";
15
+ import { MethodFileTransferError, RequestArgumentError, } from "./errors.js";
16
+ // ── Choosing how a tree travels ──────────────────────────────────────
17
+ /**
18
+ * The heaviest inline map, in bytes of its JSON serialization in UTF-8, that a save sends inline
19
+ * to a deployment keeping a blob store. Below it a tree is cheaper to send whole than to check and
20
+ * upload; above it every file travels by hash. `mthds`'s clients use the same threshold.
21
+ */
22
+ export const INLINE_TREE_MAX_BYTES = 256 * 1024;
23
+ /** How many blobs a save uploads at once, and how many files `writeMethodTree` downloads at once. */
24
+ export const MAX_CONCURRENT_TRANSFERS = 8;
25
+ /** Whether a version handshake declares a blob store (`blob_store: true`). */
26
+ export function declaresBlobStore(version) {
27
+ return version.blob_store === true;
28
+ }
29
+ /**
30
+ * The tree limits a version handshake announces under `limits.method_tree`. A value that is not a
31
+ * positive safe integer is left out rather than trusted, and a limit not announced is absent, so a
32
+ * bare runner, which announces none, yields no limit.
33
+ */
34
+ export function announcedTreeLimits(version) {
35
+ const limits = objectMember(version, "limits");
36
+ const tree = limits === undefined ? undefined : objectMember(limits, "method_tree");
37
+ if (tree === undefined)
38
+ return {};
39
+ const read = (name) => {
40
+ const value = tree[name];
41
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0
42
+ ? value
43
+ : undefined;
44
+ };
45
+ const announced = {};
46
+ const maxFileCount = read("max_files");
47
+ const maxFileSize = read("max_file_bytes");
48
+ const maxTotalSize = read("max_total_bytes");
49
+ if (maxFileCount !== undefined)
50
+ announced.maxFileCount = maxFileCount;
51
+ if (maxFileSize !== undefined)
52
+ announced.maxFileSize = maxFileSize;
53
+ if (maxTotalSize !== undefined)
54
+ announced.maxTotalSize = maxTotalSize;
55
+ return announced;
56
+ }
57
+ /** What an inline map spends in a request: its JSON serialization with no whitespace, in UTF-8. */
58
+ export function inlineMapBytes(files) {
59
+ return UTF8_ENCODER.encode(JSON.stringify(files)).length;
60
+ }
61
+ /** The total of a tree's file sizes, in bytes. */
62
+ export function treeTotalSize(tree) {
63
+ return tree.files.reduce((total, file) => total + file.size, 0);
64
+ }
65
+ /** Every file's bytes, by tree path, each read and checked by the tree, one file at a time. */
66
+ export async function readTreeBytes(tree) {
67
+ const bytes = new Map();
68
+ for (const file of tree.files) {
69
+ bytes.set(file.path, await tree.readFile(file.path));
70
+ }
71
+ return bytes;
72
+ }
73
+ /** The whole tree as inline entries, from bytes already read by `readTreeBytes`. */
74
+ export function inlineFilesOf(tree, bytesByPath) {
75
+ const files = {};
76
+ for (const file of tree.files) {
77
+ const bytes = bytesByPath.get(file.path);
78
+ if (bytes === undefined)
79
+ throw new Error(`No bytes were read for '${file.path}'.`);
80
+ defineFileEntry(files, file.path, inlineFileEntry(bytes));
81
+ }
82
+ return files;
83
+ }
84
+ /** The whole tree as hash entries, which the platform resolves in the organization's blobs. */
85
+ export function hashMethodFiles(tree) {
86
+ const files = {};
87
+ for (const file of tree.files) {
88
+ defineFileEntry(files, file.path, { sha256: file.sha256, size: file.size });
89
+ }
90
+ return files;
91
+ }
92
+ /** Each blob of the tree once, in the tree's order: files with the same bytes share one blob. */
93
+ export function uniqueBlobs(tree) {
94
+ const seen = new Set();
95
+ const blobs = [];
96
+ for (const file of tree.files) {
97
+ if (seen.has(file.sha256))
98
+ continue;
99
+ seen.add(file.sha256);
100
+ blobs.push({ sha256: file.sha256, size: file.size });
101
+ }
102
+ return blobs;
103
+ }
104
+ /** A file of the tree holding each blob, so a missing blob's bytes can be read and uploaded. */
105
+ export function fileByBlob(tree) {
106
+ const files = new Map();
107
+ for (const file of tree.files) {
108
+ if (!files.has(file.sha256))
109
+ files.set(file.sha256, file);
110
+ }
111
+ return files;
112
+ }
113
+ /**
114
+ * Run `task` over `items`, at most `limit` at a time. The first task to fail stops the run: no
115
+ * queued item is handed out after it, `signal` is aborted so the tasks still in flight can stop
116
+ * early, and once they have settled the first failure is thrown.
117
+ */
118
+ export async function forEachConcurrently(items, limit, task) {
119
+ const controller = new AbortController();
120
+ let next = 0;
121
+ let failure;
122
+ const worker = async () => {
123
+ while (failure === undefined && next < items.length) {
124
+ const item = items[next];
125
+ next += 1;
126
+ try {
127
+ await task(item, controller.signal);
128
+ }
129
+ catch (error) {
130
+ if (failure === undefined) {
131
+ failure = { error };
132
+ controller.abort(error);
133
+ }
134
+ return;
135
+ }
136
+ }
137
+ };
138
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
139
+ if (failure !== undefined)
140
+ throw failure.error;
141
+ }
142
+ // ── A tree from bytes in memory ──────────────────────────────────────
143
+ /**
144
+ * The tree whose files are `contents`, each file's bytes or its text by tree path, a text encoded
145
+ * in UTF-8. The keys are sent as they are, and the platform refuses one that is not a tree path,
146
+ * with the `422` `invalid_method_files` naming it. `call` names the caller's method in a refusal.
147
+ *
148
+ * Throws `RequestArgumentError` for `contents` holding no file, for a value that is neither a
149
+ * string nor bytes, and for a text holding a lone surrogate, which no file can hold.
150
+ */
151
+ export async function contentsTreeSource(contents, call) {
152
+ const bytesByPath = new Map();
153
+ for (const [treePath, content] of Object.entries(contents)) {
154
+ if (typeof content === "string") {
155
+ if (LONE_SURROGATE.test(content)) {
156
+ throw new RequestArgumentError(`${call}() got contents['${treePath}'] holding a lone surrogate, which is no text a file ` +
157
+ "can hold: give its bytes instead.");
158
+ }
159
+ bytesByPath.set(treePath, UTF8_ENCODER.encode(content));
160
+ }
161
+ else if (content instanceof Uint8Array) {
162
+ bytesByPath.set(treePath, content);
163
+ }
164
+ else {
165
+ throw new RequestArgumentError(`${call}() got contents['${treePath}'] that is neither a string nor a Uint8Array.`);
166
+ }
167
+ }
168
+ if (bytesByPath.size === 0) {
169
+ throw new RequestArgumentError(`${call}() got contents holding no file, and a method tree holds at least one; give ` +
170
+ "files: null for an empty draft.");
171
+ }
172
+ const files = [];
173
+ for (const [treePath, bytes] of bytesByPath) {
174
+ files.push({ path: treePath, size: bytes.length, sha256: await sha256Hex(bytes) });
175
+ }
176
+ files.sort((left, right) => compareUtf8(left.path, right.path));
177
+ const digest = await treeDigestOf(files);
178
+ return {
179
+ files,
180
+ digest,
181
+ readFile: async (treePath) => {
182
+ const bytes = bytesByPath.get(treePath);
183
+ if (bytes === undefined)
184
+ throw new Error(`The tree holds no file '${treePath}'.`);
185
+ return bytes;
186
+ },
187
+ };
188
+ }
189
+ /**
190
+ * The tree digest of a tree's files: `sha256:` and the SHA-256 of its listing, one line
191
+ * `<sha256> <path>` per file, in tree path order.
192
+ */
193
+ export async function treeDigestOf(files) {
194
+ const sorted = [...files].sort((left, right) => compareUtf8(left.path, right.path));
195
+ const listing = sorted.map((file) => `${file.sha256} ${file.path}\n`).join("");
196
+ return `sha256:${await sha256Hex(UTF8_ENCODER.encode(listing))}`;
197
+ }
198
+ /** The SHA-256 of `bytes`, in lowercase hexadecimal, by Web Crypto. */
199
+ export async function sha256Hex(bytes) {
200
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
201
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
202
+ }
203
+ /**
204
+ * Compare two strings byte for byte on their UTF-8 encoding, the order tree paths are sorted in.
205
+ * JavaScript's own string order compares UTF-16 code units, which puts a character above U+FFFF
206
+ * before one between U+E000 and U+FFFF, where UTF-8 puts it after.
207
+ */
208
+ export function compareUtf8(left, right) {
209
+ if (left === right)
210
+ return 0;
211
+ const leftBytes = UTF8_ENCODER.encode(left);
212
+ const rightBytes = UTF8_ENCODER.encode(right);
213
+ const shared = Math.min(leftBytes.length, rightBytes.length);
214
+ for (let index = 0; index < shared; index += 1) {
215
+ const difference = leftBytes[index] - rightBytes[index];
216
+ if (difference !== 0)
217
+ return difference;
218
+ }
219
+ return leftBytes.length - rightBytes.length;
220
+ }
221
+ // ── File entries ─────────────────────────────────────────────────────
222
+ /** The entry a file travels as inline: its text when its bytes are UTF-8, its Base64 otherwise. */
223
+ export function inlineFileEntry(bytes) {
224
+ const text = utf8TextOf(bytes);
225
+ return text ?? { base64: encodeBase64(bytes) };
226
+ }
227
+ /**
228
+ * Add a file to a map of file entries as an own property, since assigning to a key such as
229
+ * `__proto__`, a tree path like any other, would set the object's prototype instead of adding the
230
+ * file.
231
+ */
232
+ export function defineFileEntry(files, treePath, entry) {
233
+ Object.defineProperty(files, treePath, {
234
+ value: entry,
235
+ enumerable: true,
236
+ writable: true,
237
+ configurable: true,
238
+ });
239
+ }
240
+ /** The bytes as text when they are UTF-8, or `null` when they are not. */
241
+ function utf8TextOf(bytes) {
242
+ try {
243
+ return STRICT_UTF8.decode(bytes);
244
+ }
245
+ catch {
246
+ return null;
247
+ }
248
+ }
249
+ /** Canonical padded standard Base64 (RFC 4648), with no line break and its pad bits zero. */
250
+ export function encodeBase64(bytes) {
251
+ const chunks = [];
252
+ let line = "";
253
+ const full = bytes.length - (bytes.length % 3);
254
+ for (let index = 0; index < full; index += 3) {
255
+ const triple = (bytes[index] << 16) | (bytes[index + 1] << 8) | bytes[index + 2];
256
+ line +=
257
+ BASE64_ALPHABET[(triple >> 18) & 63] +
258
+ BASE64_ALPHABET[(triple >> 12) & 63] +
259
+ BASE64_ALPHABET[(triple >> 6) & 63] +
260
+ BASE64_ALPHABET[triple & 63];
261
+ // Flush now and then, so a file of many megabytes is not one string grown a character at a time.
262
+ if (line.length >= 8192) {
263
+ chunks.push(line);
264
+ line = "";
265
+ }
266
+ }
267
+ const rest = bytes.length - full;
268
+ if (rest === 1) {
269
+ const value = bytes[full] << 16;
270
+ line += BASE64_ALPHABET[(value >> 18) & 63] + BASE64_ALPHABET[(value >> 12) & 63] + "==";
271
+ }
272
+ else if (rest === 2) {
273
+ const value = (bytes[full] << 16) | (bytes[full + 1] << 8);
274
+ line +=
275
+ BASE64_ALPHABET[(value >> 18) & 63] +
276
+ BASE64_ALPHABET[(value >> 12) & 63] +
277
+ BASE64_ALPHABET[(value >> 6) & 63] +
278
+ "=";
279
+ }
280
+ chunks.push(line);
281
+ return chunks.join("");
282
+ }
283
+ // ── Moving a file to and from storage ────────────────────────────────
284
+ /**
285
+ * Upload one blob's bytes to the target `POST /v1/blobs/missing` named: the bytes as the whole body
286
+ * of a request with the target's method to its URL, carrying exactly the target's headers and no
287
+ * credential of the client, since the signed URL authorizes the upload itself. A `412` from a
288
+ * target asking for a create-only write (`If-None-Match`) means the bytes are already stored, and
289
+ * counts as uploaded.
290
+ *
291
+ * Throws `MethodFileTransferError` (`operation` `upload`) when storage gives no answer within
292
+ * `timeoutMs` or answers any other non-2xx; neither its message nor any member carries the URL,
293
+ * nor storage's error body, which can echo the signed request.
294
+ */
295
+ export async function uploadBlob(blob, bytes, options) {
296
+ const { sha256, upload } = blob;
297
+ const host = hostOf(upload.url);
298
+ await storageExchange(upload.url, {
299
+ method: upload.method,
300
+ headers: { ...upload.headers },
301
+ body: bytes,
302
+ }, options, (code) => new MethodFileTransferError(`Uploading the file with SHA-256 ${sha256} to ${host} failed: no answer came back ` +
303
+ `(${code}), so the method was not saved.`, { operation: "upload", code: "unreachable", sha256 }), async (response) => {
304
+ // Storage's body is read and dropped: it can echo the signed request.
305
+ await response.arrayBuffer().catch(() => undefined);
306
+ if (response.ok || (response.status === 412 && isCreateOnly(upload.headers)))
307
+ return;
308
+ throw new MethodFileTransferError(`Uploading the file with SHA-256 ${sha256} to ${host} failed (${statusLine(response)}), ` +
309
+ "so the method was not saved.", {
310
+ operation: "upload",
311
+ code: methodFileTransferCodeOfStatus(response.status),
312
+ sha256,
313
+ status: response.status,
314
+ });
315
+ });
316
+ }
317
+ /**
318
+ * The code of a transfer storage answered with a non-2xx `status`: `storage_error` for one the
319
+ * fallback table calls retryable (`408`, `429`, a `5xx` other than `501`), `refused` otherwise.
320
+ */
321
+ export function methodFileTransferCodeOfStatus(status) {
322
+ return fallbackVerdict(status, undefined, true).retryable ? "storage_error" : "refused";
323
+ }
324
+ /**
325
+ * Write a saved tree to disk, each file of `files` at its tree path under `destination`; see
326
+ * `PipelexApiClient.writeMethodTree`. Node only: the file system modules are loaded when it runs.
327
+ */
328
+ export async function writeMethodTreeTo(files, destination, options) {
329
+ refuseUnwritableListing(files);
330
+ const fs = await import("node:fs/promises");
331
+ const path = await import("node:path");
332
+ const target = path.resolve(destination);
333
+ await refuseOccupiedDestination(fs, target);
334
+ const parent = path.dirname(target);
335
+ await fs.mkdir(parent, { recursive: true });
336
+ const staging = path.join(parent, `.${path.basename(target)}.${randomHex(8)}.writing`);
337
+ await fs.mkdir(staging);
338
+ let inPlace = false;
339
+ try {
340
+ await forEachConcurrently(files, MAX_CONCURRENT_TRANSFERS, async (file, signal) => {
341
+ const bytes = await downloadMethodFile(file, { timeoutMs: options.timeoutMs, signal });
342
+ const filePath = path.join(staging, ...file.path.split("/"));
343
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
344
+ await fs.writeFile(filePath, bytes, { flag: "wx" });
345
+ });
346
+ // `rmdir` removes only an empty directory, so a file written there meanwhile is never lost.
347
+ if (await isDirectory(fs, target))
348
+ await fs.rmdir(target);
349
+ await fs.rename(staging, target);
350
+ inPlace = true;
351
+ }
352
+ finally {
353
+ if (!inPlace)
354
+ await fs.rm(staging, { recursive: true, force: true }).catch(() => undefined);
355
+ }
356
+ }
357
+ /**
358
+ * Download one file of a listing and check its bytes against it, reading no more than its listed
359
+ * size and one byte. Throws `MethodFileTransferError` (`operation` `download`) when storage gives no
360
+ * answer, refuses the request, or serves bytes that are not the file listed.
361
+ */
362
+ async function downloadMethodFile(file, options) {
363
+ const host = hostOf(file.url);
364
+ const unreachable = (code) => new MethodFileTransferError(`Downloading '${file.path}' from ${host} failed: no answer came back (${code}).`, { operation: "download", code: "unreachable", sha256: file.sha256, path: file.path });
365
+ const received = await storageExchange(file.url, { method: "GET" }, options, unreachable, async (response, failureCode) => {
366
+ if (!response.ok) {
367
+ await response.body?.cancel().catch(() => undefined);
368
+ throw new MethodFileTransferError(`Downloading '${file.path}' from ${host} failed (${statusLine(response)}). A refused ` +
369
+ "link may have expired: read the method again for fresh links.", {
370
+ operation: "download",
371
+ code: methodFileTransferCodeOfStatus(response.status),
372
+ sha256: file.sha256,
373
+ path: file.path,
374
+ status: response.status,
375
+ });
376
+ }
377
+ try {
378
+ return await readAtMost(response, file.size + 1);
379
+ }
380
+ catch (cause) {
381
+ throw unreachable(failureCode(cause));
382
+ }
383
+ });
384
+ const receivedSha256 = await sha256Hex(received);
385
+ if (received.length !== file.size || receivedSha256 !== file.sha256) {
386
+ const cameBack = received.length > file.size
387
+ ? "more than that came back."
388
+ : `${received.length} bytes came back, hashing to ${receivedSha256}.`;
389
+ throw new MethodFileTransferError(`The bytes downloaded for '${file.path}' are not the file listed: the listing names ` +
390
+ `${file.size} bytes hashing to ${file.sha256}, and ${cameBack}`, { operation: "download", code: "mismatch", sha256: file.sha256, path: file.path });
391
+ }
392
+ return received;
393
+ }
394
+ /**
395
+ * One exchange with storage: send the request with no credential of the client, then `consume` the
396
+ * answer, the whole of it bounded by `timeoutMs` and stopped by `signal`, so a body that stalls
397
+ * times out as a request with no answer does. A redirect is not followed, since a signed link names
398
+ * its object, so it reads as a refusal. A request that gets no answer throws what `unreachable`
399
+ * builds from the network code alone, with no cause: the failure fetch throws can quote the URL,
400
+ * whose query is a credential. `consume` is handed the code that names a body read that failed:
401
+ * `ABORT_TIMEOUT` once the exchange's own time limit ran out, whatever error the aborted read
402
+ * raised, `stopped` once the caller stopped it, and the network code of the failure otherwise.
403
+ */
404
+ async function storageExchange(url, init, options, unreachable, consume) {
405
+ const controller = new AbortController();
406
+ let timedOut = false;
407
+ const timer = setTimeout(() => {
408
+ timedOut = true;
409
+ controller.abort(new DOMException("The transfer timed out.", "TimeoutError"));
410
+ }, options.timeoutMs);
411
+ const callerSignal = options.signal;
412
+ const onCallerAbort = () => controller.abort(callerSignal?.reason);
413
+ if (callerSignal?.aborted === true)
414
+ controller.abort(callerSignal.reason);
415
+ else
416
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
417
+ // The time limit is read before the caller's signal: an abort the timer made is a timeout, and
418
+ // the error the aborted request or read raises says nothing reliable about which abort it was.
419
+ const failureCode = (cause) => {
420
+ if (timedOut)
421
+ return "ABORT_TIMEOUT";
422
+ if (callerSignal?.aborted === true)
423
+ return "stopped";
424
+ return networkCodeOf(cause);
425
+ };
426
+ try {
427
+ let response;
428
+ try {
429
+ response = await fetch(url, {
430
+ ...init,
431
+ credentials: "omit",
432
+ redirect: "manual",
433
+ signal: controller.signal,
434
+ });
435
+ }
436
+ catch (cause) {
437
+ throw unreachable(failureCode(cause));
438
+ }
439
+ return await consume(response, failureCode);
440
+ }
441
+ finally {
442
+ clearTimeout(timer);
443
+ callerSignal?.removeEventListener("abort", onCallerAbort);
444
+ }
445
+ }
446
+ /** A response's bytes, stopped once more than `limit` bytes came in. */
447
+ async function readAtMost(response, limit) {
448
+ const body = response.body;
449
+ if (body === null)
450
+ return new Uint8Array(0);
451
+ const reader = body.getReader();
452
+ const chunks = [];
453
+ let length = 0;
454
+ try {
455
+ for (;;) {
456
+ const { done, value } = await reader.read();
457
+ if (done)
458
+ break;
459
+ chunks.push(value);
460
+ length += value.length;
461
+ if (length >= limit)
462
+ break;
463
+ }
464
+ }
465
+ finally {
466
+ await reader.cancel().catch(() => undefined);
467
+ }
468
+ const bytes = new Uint8Array(length);
469
+ let offset = 0;
470
+ for (const chunk of chunks) {
471
+ bytes.set(chunk, offset);
472
+ offset += chunk.length;
473
+ }
474
+ return bytes;
475
+ }
476
+ // ── A listing written to disk ────────────────────────────────────────
477
+ /**
478
+ * Refuse a listing whose paths could not be written as a tree, before anything is downloaded. A
479
+ * platform's listing holds tree paths that never collide; the check guards the disk against any
480
+ * other listing a caller hands over, such as one built by hand, since a path such as `../x` would
481
+ * be written outside the destination. Throws `RequestArgumentError`.
482
+ */
483
+ export function refuseUnwritableListing(files) {
484
+ const seen = new Set();
485
+ for (const file of files) {
486
+ const violation = treePathViolation(file.path);
487
+ if (violation !== null) {
488
+ throw new RequestArgumentError(`The listing holds '${file.path}', which is not a tree path (${violation}), so the tree ` +
489
+ "cannot be written.");
490
+ }
491
+ if (seen.has(file.path)) {
492
+ throw new RequestArgumentError(`The listing holds '${file.path}' twice, so the tree cannot be written.`);
493
+ }
494
+ seen.add(file.path);
495
+ }
496
+ const colliding = pathCollisions([...seen]);
497
+ if (colliding.length > 0) {
498
+ throw new RequestArgumentError("The listing holds paths that collide once case-folded, or where one names a directory of " +
499
+ `another: ${colliding.join(", ")}.`);
500
+ }
501
+ }
502
+ /**
503
+ * Why `candidate` is not a tree path, or `null` when it is one (mthds/docs/spec/method-tree.md,
504
+ * "Tree Paths"), the rule the `mthds` package's `treePathViolation` holds.
505
+ */
506
+ export function treePathViolation(candidate) {
507
+ if (candidate === "")
508
+ return "the path is empty";
509
+ if (LONE_SURROGATE.test(candidate))
510
+ return "the path holds a code point UTF-8 cannot encode";
511
+ if (candidate.startsWith("/") || candidate.endsWith("/")) {
512
+ return "the path starts or ends with '/'";
513
+ }
514
+ for (const segment of candidate.split("/")) {
515
+ if (segment === "")
516
+ return "the path holds an empty segment";
517
+ if (segment === "." || segment === "..")
518
+ return `the path holds the segment '${segment}'`;
519
+ if (segment.includes("\\"))
520
+ return "the path holds a backslash";
521
+ if (segment.includes(":"))
522
+ return "the path holds a colon";
523
+ if (CONTROL_CHARACTER.test(segment))
524
+ return "the path holds a control character";
525
+ if (UTF8_ENCODER.encode(segment).length > MAX_SEGMENT_BYTES) {
526
+ return `a segment is longer than ${MAX_SEGMENT_BYTES} bytes`;
527
+ }
528
+ }
529
+ if (candidate.normalize("NFC") !== candidate) {
530
+ return "the path is not in Unicode Normalization Form C";
531
+ }
532
+ if (UTF8_ENCODER.encode(candidate).length > MAX_TREE_PATH_BYTES) {
533
+ return `the path is longer than ${MAX_TREE_PATH_BYTES} bytes`;
534
+ }
535
+ return null;
536
+ }
537
+ /**
538
+ * The paths that collide: those equal to another once case-folded, and those naming, once
539
+ * case-folded, a directory of another, with the paths under it, sorted byte for byte. The fold is
540
+ * `toUpperCase().toLowerCase()`, which agrees with Unicode full case folding on the scripts a file
541
+ * name uses; a pair it misses still fails the write, which never writes over a file.
542
+ */
543
+ export function pathCollisions(treePaths) {
544
+ const fold = (text) => text.toUpperCase().toLowerCase();
545
+ const byFolded = new Map();
546
+ const underFoldedDirectory = new Map();
547
+ for (const treePath of treePaths) {
548
+ const folded = fold(treePath);
549
+ const group = byFolded.get(folded) ?? new Set();
550
+ group.add(treePath);
551
+ byFolded.set(folded, group);
552
+ const segments = treePath.split("/");
553
+ for (let depth = 1; depth < segments.length; depth += 1) {
554
+ const directory = fold(segments.slice(0, depth).join("/"));
555
+ const under = underFoldedDirectory.get(directory) ?? new Set();
556
+ under.add(treePath);
557
+ underFoldedDirectory.set(directory, under);
558
+ }
559
+ }
560
+ const colliding = new Set();
561
+ for (const group of byFolded.values()) {
562
+ if (group.size > 1)
563
+ for (const treePath of group)
564
+ colliding.add(treePath);
565
+ }
566
+ for (const treePath of treePaths) {
567
+ const under = underFoldedDirectory.get(fold(treePath));
568
+ if (under !== undefined) {
569
+ colliding.add(treePath);
570
+ for (const other of under)
571
+ colliding.add(other);
572
+ }
573
+ }
574
+ return [...colliding].sort(compareUtf8);
575
+ }
576
+ /** Refuse a destination that exists and is not an empty directory. Throws `RequestArgumentError`. */
577
+ async function refuseOccupiedDestination(fs, target) {
578
+ let entries;
579
+ try {
580
+ const found = await fs.lstat(target);
581
+ if (!found.isDirectory())
582
+ throw occupied(target);
583
+ entries = await fs.readdir(target);
584
+ }
585
+ catch (err) {
586
+ if (err.code === "ENOENT")
587
+ return;
588
+ throw err;
589
+ }
590
+ if (entries.length > 0)
591
+ throw occupied(target);
592
+ }
593
+ function occupied(target) {
594
+ return new RequestArgumentError(`Cannot write a method tree at '${target}': it already exists and is not an empty ` +
595
+ "directory, and a written tree holds nothing but its files.");
596
+ }
597
+ async function isDirectory(fs, target) {
598
+ try {
599
+ return (await fs.lstat(target)).isDirectory();
600
+ }
601
+ catch {
602
+ return false;
603
+ }
604
+ }
605
+ // ── Helpers ──────────────────────────────────────────────────────────
606
+ const UTF8_ENCODER = new TextEncoder();
607
+ // `ignoreBOM` keeps a leading byte order mark in the text, so the entry gives back every byte.
608
+ const STRICT_UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
609
+ const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
610
+ // With the `u` flag a surrogate pair is one code point, so only a lone surrogate matches.
611
+ const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
612
+ const CONTROL_CHARACTER = /\p{Cc}/u;
613
+ const MAX_SEGMENT_BYTES = 255;
614
+ const REDACTED = "[redacted]";
615
+ // A link up to its query, then its query, which a redaction replaces.
616
+ const LINK_QUERY = /(\bhttps?:\/\/[^\s"'<>?#]*)\?[^\s"'<>]*/gi;
617
+ const MAX_TREE_PATH_BYTES = 1024;
618
+ function objectMember(holder, name) {
619
+ const value = holder[name];
620
+ return value !== null && typeof value === "object" && !Array.isArray(value)
621
+ ? value
622
+ : undefined;
623
+ }
624
+ /** Whether an upload target asks for a create-only write, whose `412` means already stored. */
625
+ function isCreateOnly(headers) {
626
+ return Object.keys(headers).some((name) => name.toLowerCase() === "if-none-match");
627
+ }
628
+ /** The host a link names, for a message: never the link itself, whose query is a credential. */
629
+ function hostOf(url) {
630
+ try {
631
+ return new URL(url).host || "storage";
632
+ }
633
+ catch {
634
+ return "storage";
635
+ }
636
+ }
637
+ /**
638
+ * `text`, an answer that carries signed links, with every link hidden, for an error to hold: in a
639
+ * JSON body every `url` member, at any depth, is replaced whole, and in any text, JSON or not, a
640
+ * truncated body included, the query of every `http(s)` link is replaced, since a signed link's
641
+ * signature rides its query. A link is a bearer credential, so no error, message or member may
642
+ * carry one.
643
+ */
644
+ export function redactSignedLinks(text) {
645
+ let redacted = text;
646
+ try {
647
+ redacted = JSON.stringify(withUrlMembersRedacted(JSON.parse(text)));
648
+ }
649
+ catch {
650
+ // Not JSON: the pattern below still hides every link's query.
651
+ }
652
+ return redacted.replace(LINK_QUERY, "$1?[redacted]");
653
+ }
654
+ function withUrlMembersRedacted(value) {
655
+ if (Array.isArray(value))
656
+ return value.map(withUrlMembersRedacted);
657
+ if (value !== null && typeof value === "object") {
658
+ // `fromEntries` defines each member as an own property, so a `__proto__` member stays one.
659
+ return Object.fromEntries(Object.entries(value).map(([name, member]) => [
660
+ name,
661
+ name === "url" ? REDACTED : withUrlMembersRedacted(member),
662
+ ]));
663
+ }
664
+ return value;
665
+ }
666
+ function statusLine(response) {
667
+ return `${response.status} ${response.statusText}`.trimEnd();
668
+ }
669
+ /** The network code of a failed fetch, as the client's own requests report it. */
670
+ function networkCodeOf(err) {
671
+ if (err instanceof DOMException && err.name === "TimeoutError")
672
+ return "ABORT_TIMEOUT";
673
+ if (err instanceof Error) {
674
+ const cause = err.cause;
675
+ if (cause !== null && typeof cause === "object" && "code" in cause) {
676
+ const code = cause.code;
677
+ if (typeof code === "string")
678
+ return code;
679
+ }
680
+ if (err.name === "TimeoutError")
681
+ return "ABORT_TIMEOUT";
682
+ }
683
+ return "network error";
684
+ }
685
+ function randomHex(byteCount) {
686
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(byteCount));
687
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
688
+ }
689
+ //# sourceMappingURL=method-trees.js.map