@pipelex/sdk 0.46.1 → 0.48.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 +20 -9
- package/dist/cli/script.d.ts +1 -1
- package/dist/cli/source.d.ts +8 -7
- package/dist/cli/source.js +11 -5
- package/dist/cli/source.js.map +1 -1
- package/dist/client.d.ts +455 -237
- package/dist/client.js +1067 -522
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +49 -19
- package/dist/errors.js +50 -21
- package/dist/errors.js.map +1 -1
- package/dist/hooks/bundle-gather.d.ts +28 -40
- package/dist/hooks/bundle-gather.js +55 -230
- package/dist/hooks/bundle-gather.js.map +1 -1
- package/dist/hooks/claude-mthds-check.d.ts +7 -5
- package/dist/hooks/claude-mthds-check.js +8 -53
- package/dist/hooks/claude-mthds-check.js.map +1 -1
- package/dist/hooks/validate-stage.d.ts +27 -0
- package/dist/hooks/validate-stage.js +57 -0
- package/dist/hooks/validate-stage.js.map +1 -0
- package/dist/index.d.ts +7 -7
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/method-trees.d.ts +344 -0
- package/dist/method-trees.js +876 -0
- package/dist/method-trees.js.map +1 -0
- package/dist/models.d.ts +162 -59
- package/dist/prepare-inputs.d.ts +46 -27
- package/dist/prepare-inputs.js +123 -42
- package/dist/prepare-inputs.js.map +1 -1
- package/dist/product-models.d.ts +97 -75
- package/dist/runs.d.ts +5 -3
- package/dist/runs.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/architecture.md +20 -21
- package/docs/cli.md +1 -1
- package/docs/crate-routes.md +42 -20
- package/docs/errors.md +7 -6
- package/docs/hook-bundle.md +1 -1
- package/docs/input-preparation.md +15 -40
- package/docs/method-trees.md +199 -0
- package/docs/run-results.md +5 -2
- package/package.json +2 -2
- package/dist/bundle-resources.d.ts +0 -62
- package/dist/bundle-resources.js +0 -165
- package/dist/bundle-resources.js.map +0 -1
- package/dist/method-source.d.ts +0 -19
- package/dist/method-source.js +0 -50
- package/dist/method-source.js.map +0 -1
- package/docs/bundle-resources.md +0 -107
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A method's tree on the client side (see `docs/method-trees.md`): the forms a caller gives a
|
|
3
|
+
* method's tree in, the listing of a directory through the `mthds` package's own lister, how a
|
|
4
|
+
* tree becomes the `files` map a request sends, inline or by hash, the blob routes that let a
|
|
5
|
+
* large tree travel by hash (mthds/docs/spec/protocol.md, "Sending files by hash"), the upload of
|
|
6
|
+
* a blob the receiver lacks, and the writing of a saved tree to disk.
|
|
7
|
+
*
|
|
8
|
+
* The wire types (`MethodTreeSource`, `BlobRef`, `MissingBlob` and the rest) are the standard's,
|
|
9
|
+
* imported from `mthds/protocol`. A directory is listed by `mthds`'s `listMethodTree`, the
|
|
10
|
+
* standard's lister with its membership rule, which this module loads by a dynamic import when a
|
|
11
|
+
* directory is given, so the SDK's static import graph keeps no Node built-in. The choice between
|
|
12
|
+
* inline and by hash follows `mthds`'s `MthdsApiClient` rule exactly, but its helpers are not
|
|
13
|
+
* exported on a subpath free of Node built-ins, so this module carries its own copy of them: the
|
|
14
|
+
* same threshold, the same weighing of the inline map, the same text-or-Base64 choice per file.
|
|
15
|
+
* SHA-256 is Web Crypto's, and Base64 is written here. Only `writeMethodTreeTo` and
|
|
16
|
+
* `listDirectoryTree` need Node, which each loads when called.
|
|
17
|
+
*/
|
|
18
|
+
import { parseJsonBody, readProblemBody } from "mthds/protocol";
|
|
19
|
+
import { fallbackVerdict } from "./error-verdicts.js";
|
|
20
|
+
import { MethodFileTransferError, RequestArgumentError, } from "./errors.js";
|
|
21
|
+
import { throwIfAborted } from "./runs.js";
|
|
22
|
+
/**
|
|
23
|
+
* What keeps `value` from being a listed tree as far as the client reads one before any request,
|
|
24
|
+
* an object with a `files` list, a `digest` string and a `readFile` function, in words for a
|
|
25
|
+
* refusal ("a list", "an object without them", or its type); `undefined` when it is one. A tree
|
|
26
|
+
* holding no file passes here, and is refused where it would be sent.
|
|
27
|
+
*/
|
|
28
|
+
export function treeShapeFault(value) {
|
|
29
|
+
if (value !== null &&
|
|
30
|
+
typeof value === "object" &&
|
|
31
|
+
Array.isArray(value.files) &&
|
|
32
|
+
typeof value.digest === "string" &&
|
|
33
|
+
typeof value.readFile === "function") {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
if (Array.isArray(value))
|
|
37
|
+
return "a list";
|
|
38
|
+
return value !== null && typeof value === "object" ? "an object without them" : typeof value;
|
|
39
|
+
}
|
|
40
|
+
// ── Choosing how a tree travels ──────────────────────────────────────
|
|
41
|
+
/**
|
|
42
|
+
* The heaviest inline map, in bytes of its JSON serialization in UTF-8, that a request or a save
|
|
43
|
+
* sends inline to a deployment keeping a blob store, when the deployment announces no lower inline
|
|
44
|
+
* limit. Below it a tree is cheaper to send whole than to check and upload; above it every file
|
|
45
|
+
* travels by hash. `mthds`'s clients use the same threshold.
|
|
46
|
+
*/
|
|
47
|
+
export const INLINE_TREE_MAX_BYTES = 256 * 1024;
|
|
48
|
+
/**
|
|
49
|
+
* How long a version handshake is trusted before the client asks again, `mthds`'s
|
|
50
|
+
* `HANDSHAKE_TTL_MS`: a deployment's blob store and tree limits change only with a deploy, so a
|
|
51
|
+
* client asks once per this span rather than before every request, and drops the answer sooner
|
|
52
|
+
* when a refusal shows it stale (`POST /v1/blobs/missing` gone, a tree refused as too large).
|
|
53
|
+
*/
|
|
54
|
+
export const HANDSHAKE_TTL_MS = 5 * 60_000;
|
|
55
|
+
/** How many blobs a request or a save uploads at once, and how many files `writeMethodTree` downloads at once. */
|
|
56
|
+
export const MAX_CONCURRENT_TRANSFERS = 8;
|
|
57
|
+
/** A save's: a tree holding no file is given as `files: null`, which empties a draft. */
|
|
58
|
+
export const SAVE_SENDING = {
|
|
59
|
+
unsent: "the method was not saved",
|
|
60
|
+
emptyRemedy: "give files: null for an empty draft",
|
|
61
|
+
};
|
|
62
|
+
/** A request's: a method has at least one file, so there is nothing to give instead. */
|
|
63
|
+
export const REQUEST_SENDING = { unsent: "the request was not sent" };
|
|
64
|
+
/**
|
|
65
|
+
* A map a crate request was given, or `undefined` when it selects or sends nothing: absent, `null`,
|
|
66
|
+
* or a plain object holding no key, the empty-as-absent rule of the run options and of the Python
|
|
67
|
+
* SDK. Any other value is kept as given, so the server refuses a `files` that is not a map as the
|
|
68
|
+
* shape it is rather than reading it as absent.
|
|
69
|
+
*/
|
|
70
|
+
export function presentMap(value) {
|
|
71
|
+
if (value === undefined || value === null)
|
|
72
|
+
return undefined;
|
|
73
|
+
const isEmptyObject = Object.prototype.toString.call(value) === "[object Object]" &&
|
|
74
|
+
Object.keys(value).length === 0;
|
|
75
|
+
return isEmptyObject ? undefined : value;
|
|
76
|
+
}
|
|
77
|
+
/** The refusal of a tree holding no file, `call` naming the caller's method. */
|
|
78
|
+
export function emptyTreeRefusal(call, what, sending) {
|
|
79
|
+
const remedy = sending.emptyRemedy === undefined ? "." : `; ${sending.emptyRemedy}.`;
|
|
80
|
+
return new RequestArgumentError(`${call}() got ${what} holding no file, and a method tree holds at least one${remedy}`);
|
|
81
|
+
}
|
|
82
|
+
/** Whether a version handshake declares a blob store (`blob_store: true`). */
|
|
83
|
+
export function declaresBlobStore(version) {
|
|
84
|
+
return version.blob_store === true;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The tree limits a version handshake announces under `limits.method_tree`. A value that is not a
|
|
88
|
+
* positive safe integer is left out rather than trusted, and a limit not announced is absent, so a
|
|
89
|
+
* bare runner, which announces none, yields no limit.
|
|
90
|
+
*/
|
|
91
|
+
export function announcedTreeLimits(version) {
|
|
92
|
+
const limits = objectMember(version, "limits");
|
|
93
|
+
const tree = limits === undefined ? undefined : objectMember(limits, "method_tree");
|
|
94
|
+
if (tree === undefined)
|
|
95
|
+
return {};
|
|
96
|
+
const read = (name) => {
|
|
97
|
+
const value = tree[name];
|
|
98
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0
|
|
99
|
+
? value
|
|
100
|
+
: undefined;
|
|
101
|
+
};
|
|
102
|
+
const announced = {};
|
|
103
|
+
const maxFiles = read("max_files");
|
|
104
|
+
const maxFileBytes = read("max_file_bytes");
|
|
105
|
+
const maxTotalBytes = read("max_total_bytes");
|
|
106
|
+
const maxInlineBytes = read("max_inline_bytes");
|
|
107
|
+
if (maxFiles !== undefined)
|
|
108
|
+
announced.maxFiles = maxFiles;
|
|
109
|
+
if (maxFileBytes !== undefined)
|
|
110
|
+
announced.maxFileBytes = maxFileBytes;
|
|
111
|
+
if (maxTotalBytes !== undefined)
|
|
112
|
+
announced.maxTotalBytes = maxTotalBytes;
|
|
113
|
+
if (maxInlineBytes !== undefined)
|
|
114
|
+
announced.maxInlineBytes = maxInlineBytes;
|
|
115
|
+
return announced;
|
|
116
|
+
}
|
|
117
|
+
/** The announced limits as the lister takes them, so it stops at the first breach before hashing. */
|
|
118
|
+
export function listerLimitsOf(announced) {
|
|
119
|
+
const limits = {};
|
|
120
|
+
if (announced.maxFiles !== undefined)
|
|
121
|
+
limits.maxFileCount = announced.maxFiles;
|
|
122
|
+
if (announced.maxFileBytes !== undefined)
|
|
123
|
+
limits.maxFileSize = announced.maxFileBytes;
|
|
124
|
+
if (announced.maxTotalBytes !== undefined)
|
|
125
|
+
limits.maxTotalSize = announced.maxTotalBytes;
|
|
126
|
+
return limits;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The inline threshold a tree is held to against a receiver keeping a blob store: the lower of
|
|
130
|
+
* `INLINE_TREE_MAX_BYTES` and the inline limit the receiver announces, as `mthds`'s
|
|
131
|
+
* `MthdsApiClient` reads it.
|
|
132
|
+
*/
|
|
133
|
+
export function inlineThreshold(announced) {
|
|
134
|
+
return Math.min(INLINE_TREE_MAX_BYTES, announced.maxInlineBytes ?? Number.POSITIVE_INFINITY);
|
|
135
|
+
}
|
|
136
|
+
/** What an inline map spends in a request: its JSON serialization with no whitespace, in UTF-8. */
|
|
137
|
+
export function inlineMapBytes(files) {
|
|
138
|
+
return UTF8_ENCODER.encode(JSON.stringify(files)).length;
|
|
139
|
+
}
|
|
140
|
+
/** The total of a tree's file sizes, in bytes. */
|
|
141
|
+
export function treeTotalSize(tree) {
|
|
142
|
+
return tree.files.reduce((total, file) => total + file.size, 0);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Every file's bytes, by tree path, each read and checked by the tree, one file at a time. `signal`
|
|
146
|
+
* is read before each file: once it has aborted, no further file is read and the abort is thrown.
|
|
147
|
+
*/
|
|
148
|
+
export async function readTreeBytes(tree, signal) {
|
|
149
|
+
const bytes = new Map();
|
|
150
|
+
for (const file of tree.files) {
|
|
151
|
+
throwIfAborted(signal);
|
|
152
|
+
bytes.set(file.path, await tree.readFile(file.path));
|
|
153
|
+
}
|
|
154
|
+
return bytes;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Every file's bytes, by tree path, read into `read`: a file already there is not read again, so
|
|
158
|
+
* a request that weighs a tree and then uploads from it, or prepares it a second time, reads each
|
|
159
|
+
* file once. `signal` is read before each file read, as `readTreeBytes` reads it.
|
|
160
|
+
*/
|
|
161
|
+
export async function readTreeBytesInto(tree, read, signal) {
|
|
162
|
+
for (const file of tree.files) {
|
|
163
|
+
if (read.has(file.path))
|
|
164
|
+
continue;
|
|
165
|
+
throwIfAborted(signal);
|
|
166
|
+
read.set(file.path, await tree.readFile(file.path));
|
|
167
|
+
}
|
|
168
|
+
return read;
|
|
169
|
+
}
|
|
170
|
+
/** The whole tree as inline entries, from bytes already read by `readTreeBytes`. */
|
|
171
|
+
export function inlineFilesOf(tree, bytesByPath) {
|
|
172
|
+
const files = {};
|
|
173
|
+
for (const file of tree.files) {
|
|
174
|
+
const bytes = bytesByPath.get(file.path);
|
|
175
|
+
if (bytes === undefined)
|
|
176
|
+
throw new Error(`No bytes were read for '${file.path}'.`);
|
|
177
|
+
defineFileEntry(files, file.path, inlineFileEntry(bytes));
|
|
178
|
+
}
|
|
179
|
+
return files;
|
|
180
|
+
}
|
|
181
|
+
/** The whole tree as hash entries, which the platform resolves in the organization's blobs. */
|
|
182
|
+
export function hashMethodFiles(tree) {
|
|
183
|
+
const files = {};
|
|
184
|
+
for (const file of tree.files) {
|
|
185
|
+
defineFileEntry(files, file.path, { sha256: file.sha256, size: file.size });
|
|
186
|
+
}
|
|
187
|
+
return files;
|
|
188
|
+
}
|
|
189
|
+
/** Each blob of the tree once, in the tree's order: files with the same bytes share one blob. */
|
|
190
|
+
export function uniqueBlobs(tree) {
|
|
191
|
+
const seen = new Set();
|
|
192
|
+
const blobs = [];
|
|
193
|
+
for (const file of tree.files) {
|
|
194
|
+
if (seen.has(file.sha256))
|
|
195
|
+
continue;
|
|
196
|
+
seen.add(file.sha256);
|
|
197
|
+
blobs.push({ sha256: file.sha256, size: file.size });
|
|
198
|
+
}
|
|
199
|
+
return blobs;
|
|
200
|
+
}
|
|
201
|
+
/** A file of the tree holding each blob, so a missing blob's bytes can be read and uploaded. */
|
|
202
|
+
export function fileByBlob(tree) {
|
|
203
|
+
const files = new Map();
|
|
204
|
+
for (const file of tree.files) {
|
|
205
|
+
if (!files.has(file.sha256))
|
|
206
|
+
files.set(file.sha256, file);
|
|
207
|
+
}
|
|
208
|
+
return files;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Run `task` over `items`, at most `limit` at a time. The first task to fail stops the run: no
|
|
212
|
+
* queued item is handed out after it, `signal` is aborted so the tasks still in flight can stop
|
|
213
|
+
* early, and once they have settled the first failure is thrown.
|
|
214
|
+
*/
|
|
215
|
+
export async function forEachConcurrently(items, limit, task) {
|
|
216
|
+
const controller = new AbortController();
|
|
217
|
+
let next = 0;
|
|
218
|
+
let failure;
|
|
219
|
+
const worker = async () => {
|
|
220
|
+
while (failure === undefined && next < items.length) {
|
|
221
|
+
const item = items[next];
|
|
222
|
+
next += 1;
|
|
223
|
+
try {
|
|
224
|
+
await task(item, controller.signal);
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (failure === undefined) {
|
|
228
|
+
failure = { error };
|
|
229
|
+
controller.abort(error);
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
236
|
+
if (failure !== undefined)
|
|
237
|
+
throw failure.error;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Whether an answer refuses a tree as past one of the receiver's limits, as `mthds`'s
|
|
241
|
+
* `MthdsApiClient` reads one: a `422` whose problem is the core registry's
|
|
242
|
+
* `method.package_invalid` carrying its numeric `limit` parameter, the hosted platform's
|
|
243
|
+
* `method_tree_too_large` code, or pipelex-api's `MethodTreeTooLarge` error type.
|
|
244
|
+
*/
|
|
245
|
+
export function isMethodTreeTooLarge(status, body) {
|
|
246
|
+
if (status !== 422)
|
|
247
|
+
return false;
|
|
248
|
+
const reading = parseJsonBody(body);
|
|
249
|
+
if (!reading.ok || !isJsonObject(reading.value))
|
|
250
|
+
return false;
|
|
251
|
+
const answer = reading.value;
|
|
252
|
+
if (answer["code"] === "method.package_invalid") {
|
|
253
|
+
const params = answer["params"];
|
|
254
|
+
return isJsonObject(params) && typeof params["limit"] === "number";
|
|
255
|
+
}
|
|
256
|
+
return (answer["code"] === "method_tree_too_large" || answer["error_type"] === "MethodTreeTooLarge");
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The blobs a `422` lists as missing, as `mthds`'s `MthdsApiClient` reads them: the
|
|
260
|
+
* `params.missing_blobs` of a problem coded `request.blobs_missing`, or `undefined` when the
|
|
261
|
+
* answer is no such problem or the list holds anything but strings.
|
|
262
|
+
*/
|
|
263
|
+
export function missingBlobsOf(status, body) {
|
|
264
|
+
if (status !== 422)
|
|
265
|
+
return undefined;
|
|
266
|
+
const problem = readProblemBody(body);
|
|
267
|
+
if (problem === undefined || problem.code !== "request.blobs_missing")
|
|
268
|
+
return undefined;
|
|
269
|
+
const listed = problem.params?.["missing_blobs"];
|
|
270
|
+
if (!Array.isArray(listed) || !listed.every((entry) => typeof entry === "string")) {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
return listed;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* A signal that aborts when either does, with the reason of the first to abort, and `dispose`,
|
|
277
|
+
* which detaches it from both once it is no longer needed, so a long-lived signal does not keep
|
|
278
|
+
* a listener per call. `second` absent gives `first` itself. Built by hand rather than with
|
|
279
|
+
* `AbortSignal.any`, which older browsers lack.
|
|
280
|
+
*/
|
|
281
|
+
export function eitherSignal(first, second) {
|
|
282
|
+
if (second === undefined || first.aborted)
|
|
283
|
+
return { signal: first, dispose: () => undefined };
|
|
284
|
+
if (second.aborted)
|
|
285
|
+
return { signal: second, dispose: () => undefined };
|
|
286
|
+
const controller = new AbortController();
|
|
287
|
+
const dispose = () => {
|
|
288
|
+
first.removeEventListener("abort", onFirst);
|
|
289
|
+
second.removeEventListener("abort", onSecond);
|
|
290
|
+
};
|
|
291
|
+
const onFirst = () => {
|
|
292
|
+
dispose();
|
|
293
|
+
controller.abort(first.reason);
|
|
294
|
+
};
|
|
295
|
+
const onSecond = () => {
|
|
296
|
+
dispose();
|
|
297
|
+
controller.abort(second.reason);
|
|
298
|
+
};
|
|
299
|
+
first.addEventListener("abort", onFirst, { once: true });
|
|
300
|
+
second.addEventListener("abort", onSecond, { once: true });
|
|
301
|
+
return { signal: controller.signal, dispose };
|
|
302
|
+
}
|
|
303
|
+
function isJsonObject(value) {
|
|
304
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
305
|
+
}
|
|
306
|
+
// ── A tree from bytes in memory ──────────────────────────────────────
|
|
307
|
+
/**
|
|
308
|
+
* The tree whose files are `contents`, each file's bytes or its text by tree path, a text encoded
|
|
309
|
+
* in UTF-8. The keys are sent as they are, and the platform refuses one that is not a tree path,
|
|
310
|
+
* with the `422` `invalid_method_files` naming it. `call` names the caller's method in a refusal.
|
|
311
|
+
*
|
|
312
|
+
* Throws `RequestArgumentError` for `contents` holding no file, for a value that is neither a
|
|
313
|
+
* string nor bytes, and for a text holding a lone surrogate, which no file can hold. `sending` says
|
|
314
|
+
* what the tree is for, which the refusal of an empty one names.
|
|
315
|
+
*/
|
|
316
|
+
export async function contentsTreeSource(contents, call, sending) {
|
|
317
|
+
const bytesByPath = new Map();
|
|
318
|
+
for (const [treePath, content] of Object.entries(contents)) {
|
|
319
|
+
if (typeof content === "string") {
|
|
320
|
+
if (LONE_SURROGATE.test(content)) {
|
|
321
|
+
throw new RequestArgumentError(`${call}() got contents['${treePath}'] holding a lone surrogate, which is no text a file ` +
|
|
322
|
+
"can hold: give its bytes instead.");
|
|
323
|
+
}
|
|
324
|
+
bytesByPath.set(treePath, UTF8_ENCODER.encode(content));
|
|
325
|
+
}
|
|
326
|
+
else if (content instanceof Uint8Array) {
|
|
327
|
+
bytesByPath.set(treePath, content);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
throw new RequestArgumentError(`${call}() got contents['${treePath}'] that is neither a string nor a Uint8Array.`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (bytesByPath.size === 0)
|
|
334
|
+
throw emptyTreeRefusal(call, "contents", sending);
|
|
335
|
+
const files = [];
|
|
336
|
+
for (const [treePath, bytes] of bytesByPath) {
|
|
337
|
+
files.push({ path: treePath, size: bytes.length, sha256: await sha256Hex(bytes) });
|
|
338
|
+
}
|
|
339
|
+
files.sort((left, right) => compareUtf8(left.path, right.path));
|
|
340
|
+
const digest = await treeDigestOf(files);
|
|
341
|
+
return {
|
|
342
|
+
files,
|
|
343
|
+
digest,
|
|
344
|
+
readFile: async (treePath) => {
|
|
345
|
+
const bytes = bytesByPath.get(treePath);
|
|
346
|
+
if (bytes === undefined)
|
|
347
|
+
throw new Error(`The tree holds no file '${treePath}'.`);
|
|
348
|
+
return bytes;
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The tree of the method directory `directory`, listed by the `mthds` package's `listMethodTree`
|
|
354
|
+
* under the MTHDS standard's membership rule and Manifest Discovery, stopping at the first of
|
|
355
|
+
* `limits` it breaches, before any file is hashed. A run's inputs file is never in it. Node only:
|
|
356
|
+
* `mthds`'s root entry, which reads the file system, is loaded by this call alone.
|
|
357
|
+
*
|
|
358
|
+
* Throws `RequestArgumentError` naming `call` when the directory cannot be listed as a tree, the
|
|
359
|
+
* `mthds` package's `MethodTreeError` as its `cause` with its `code` (`not_found`,
|
|
360
|
+
* `symbolic_link`, `limit_exceeded`, `empty_tree` and the rest): a path that names nothing, a
|
|
361
|
+
* symbolic link or a special file in the tree, a tree past one of `limits`, a tree holding no file.
|
|
362
|
+
*/
|
|
363
|
+
export async function listDirectoryTree(directory, limits, call) {
|
|
364
|
+
const { listMethodTree, MethodTreeError } = await import("mthds");
|
|
365
|
+
try {
|
|
366
|
+
return await listMethodTree(directory, { limits });
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
if (err instanceof MethodTreeError) {
|
|
370
|
+
throw new RequestArgumentError(`${call}() could not list the method's tree at '${directory}': ${err.message}`, { cause: err });
|
|
371
|
+
}
|
|
372
|
+
throw err;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* The tree digest of a tree's files: `sha256:` and the SHA-256 of its listing, one line
|
|
377
|
+
* `<sha256> <path>` per file, in tree path order.
|
|
378
|
+
*/
|
|
379
|
+
export async function treeDigestOf(files) {
|
|
380
|
+
const sorted = [...files].sort((left, right) => compareUtf8(left.path, right.path));
|
|
381
|
+
const listing = sorted.map((file) => `${file.sha256} ${file.path}\n`).join("");
|
|
382
|
+
return `sha256:${await sha256Hex(UTF8_ENCODER.encode(listing))}`;
|
|
383
|
+
}
|
|
384
|
+
/** The SHA-256 of `bytes`, in lowercase hexadecimal, by Web Crypto. */
|
|
385
|
+
export async function sha256Hex(bytes) {
|
|
386
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
|
387
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Compare two strings byte for byte on their UTF-8 encoding, the order tree paths are sorted in.
|
|
391
|
+
* JavaScript's own string order compares UTF-16 code units, which puts a character above U+FFFF
|
|
392
|
+
* before one between U+E000 and U+FFFF, where UTF-8 puts it after.
|
|
393
|
+
*/
|
|
394
|
+
export function compareUtf8(left, right) {
|
|
395
|
+
if (left === right)
|
|
396
|
+
return 0;
|
|
397
|
+
const leftBytes = UTF8_ENCODER.encode(left);
|
|
398
|
+
const rightBytes = UTF8_ENCODER.encode(right);
|
|
399
|
+
const shared = Math.min(leftBytes.length, rightBytes.length);
|
|
400
|
+
for (let index = 0; index < shared; index += 1) {
|
|
401
|
+
const difference = leftBytes[index] - rightBytes[index];
|
|
402
|
+
if (difference !== 0)
|
|
403
|
+
return difference;
|
|
404
|
+
}
|
|
405
|
+
return leftBytes.length - rightBytes.length;
|
|
406
|
+
}
|
|
407
|
+
// ── File entries ─────────────────────────────────────────────────────
|
|
408
|
+
/** The entry a file travels as inline: its text when its bytes are UTF-8, its Base64 otherwise. */
|
|
409
|
+
export function inlineFileEntry(bytes) {
|
|
410
|
+
const text = utf8TextOf(bytes);
|
|
411
|
+
return text ?? { base64: encodeBase64(bytes) };
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Add a file to a map of file entries as an own property, since assigning to a key such as
|
|
415
|
+
* `__proto__`, a tree path like any other, would set the object's prototype instead of adding the
|
|
416
|
+
* file.
|
|
417
|
+
*/
|
|
418
|
+
export function defineFileEntry(files, treePath, entry) {
|
|
419
|
+
Object.defineProperty(files, treePath, {
|
|
420
|
+
value: entry,
|
|
421
|
+
enumerable: true,
|
|
422
|
+
writable: true,
|
|
423
|
+
configurable: true,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
/** The bytes as text when they are UTF-8, or `null` when they are not. */
|
|
427
|
+
function utf8TextOf(bytes) {
|
|
428
|
+
try {
|
|
429
|
+
return STRICT_UTF8.decode(bytes);
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/** Canonical padded standard Base64 (RFC 4648), with no line break and its pad bits zero. */
|
|
436
|
+
export function encodeBase64(bytes) {
|
|
437
|
+
const chunks = [];
|
|
438
|
+
let line = "";
|
|
439
|
+
const full = bytes.length - (bytes.length % 3);
|
|
440
|
+
for (let index = 0; index < full; index += 3) {
|
|
441
|
+
const triple = (bytes[index] << 16) | (bytes[index + 1] << 8) | bytes[index + 2];
|
|
442
|
+
line +=
|
|
443
|
+
BASE64_ALPHABET[(triple >> 18) & 63] +
|
|
444
|
+
BASE64_ALPHABET[(triple >> 12) & 63] +
|
|
445
|
+
BASE64_ALPHABET[(triple >> 6) & 63] +
|
|
446
|
+
BASE64_ALPHABET[triple & 63];
|
|
447
|
+
// Flush now and then, so a file of many megabytes is not one string grown a character at a time.
|
|
448
|
+
if (line.length >= 8192) {
|
|
449
|
+
chunks.push(line);
|
|
450
|
+
line = "";
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const rest = bytes.length - full;
|
|
454
|
+
if (rest === 1) {
|
|
455
|
+
const value = bytes[full] << 16;
|
|
456
|
+
line += BASE64_ALPHABET[(value >> 18) & 63] + BASE64_ALPHABET[(value >> 12) & 63] + "==";
|
|
457
|
+
}
|
|
458
|
+
else if (rest === 2) {
|
|
459
|
+
const value = (bytes[full] << 16) | (bytes[full + 1] << 8);
|
|
460
|
+
line +=
|
|
461
|
+
BASE64_ALPHABET[(value >> 18) & 63] +
|
|
462
|
+
BASE64_ALPHABET[(value >> 12) & 63] +
|
|
463
|
+
BASE64_ALPHABET[(value >> 6) & 63] +
|
|
464
|
+
"=";
|
|
465
|
+
}
|
|
466
|
+
chunks.push(line);
|
|
467
|
+
return chunks.join("");
|
|
468
|
+
}
|
|
469
|
+
// ── Moving a file to and from storage ────────────────────────────────
|
|
470
|
+
/**
|
|
471
|
+
* Upload one blob's bytes to the target `POST /v1/blobs/missing` named: the bytes as the whole body
|
|
472
|
+
* of a request with the target's method to its URL, carrying exactly the target's headers and no
|
|
473
|
+
* credential of the client, since the signed URL authorizes the upload itself. A `412` from a
|
|
474
|
+
* target asking for a create-only write (`If-None-Match`) means the bytes are already stored, and
|
|
475
|
+
* counts as uploaded.
|
|
476
|
+
*
|
|
477
|
+
* Throws `MethodFileTransferError` (`operation` `upload`) when storage gives no answer within
|
|
478
|
+
* `timeoutMs` or answers any other non-2xx; neither its message nor any member carries the URL,
|
|
479
|
+
* nor storage's error body, which can echo the signed request. Its message ends with what did not
|
|
480
|
+
* happen because of it, `options.unsent`.
|
|
481
|
+
*/
|
|
482
|
+
export async function uploadBlob(blob, bytes, options) {
|
|
483
|
+
const { sha256, upload } = blob;
|
|
484
|
+
const host = hostOf(upload.url);
|
|
485
|
+
await storageExchange(upload.url, {
|
|
486
|
+
method: upload.method,
|
|
487
|
+
headers: { ...upload.headers },
|
|
488
|
+
body: bytes,
|
|
489
|
+
}, options, (code) => new MethodFileTransferError(`Uploading the file with SHA-256 ${sha256} to ${host} failed: no answer came back ` +
|
|
490
|
+
`(${code}), so ${options.unsent}.`, { operation: "upload", code: "unreachable", sha256 }), async (response) => {
|
|
491
|
+
// Storage's body is read and dropped: it can echo the signed request.
|
|
492
|
+
await response.arrayBuffer().catch(() => undefined);
|
|
493
|
+
if (response.ok || (response.status === 412 && isCreateOnly(upload.headers)))
|
|
494
|
+
return;
|
|
495
|
+
throw new MethodFileTransferError(`Uploading the file with SHA-256 ${sha256} to ${host} failed (${statusLine(response)}), ` +
|
|
496
|
+
`so ${options.unsent}.`, {
|
|
497
|
+
operation: "upload",
|
|
498
|
+
code: methodFileTransferCodeOfStatus(response.status),
|
|
499
|
+
sha256,
|
|
500
|
+
status: response.status,
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* The code of a transfer storage answered with a non-2xx `status`: `storage_error` for one the
|
|
506
|
+
* fallback table calls retryable (`408`, `429`, a `5xx` other than `501`), `refused` otherwise.
|
|
507
|
+
*/
|
|
508
|
+
export function methodFileTransferCodeOfStatus(status) {
|
|
509
|
+
return fallbackVerdict(status, undefined, true).retryable ? "storage_error" : "refused";
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Write a saved tree to disk, each file of `files` at its tree path under `destination`; see
|
|
513
|
+
* `PipelexApiClient.writeMethodTree`. Node only: the file system modules are loaded when it runs.
|
|
514
|
+
*/
|
|
515
|
+
export async function writeMethodTreeTo(files, destination, options) {
|
|
516
|
+
refuseUnwritableListing(files);
|
|
517
|
+
const fs = await import("node:fs/promises");
|
|
518
|
+
const path = await import("node:path");
|
|
519
|
+
const target = path.resolve(destination);
|
|
520
|
+
await refuseOccupiedDestination(fs, target);
|
|
521
|
+
const parent = path.dirname(target);
|
|
522
|
+
await fs.mkdir(parent, { recursive: true });
|
|
523
|
+
const staging = path.join(parent, `.${path.basename(target)}.${randomHex(8)}.writing`);
|
|
524
|
+
await fs.mkdir(staging);
|
|
525
|
+
let inPlace = false;
|
|
526
|
+
try {
|
|
527
|
+
await forEachConcurrently(files, MAX_CONCURRENT_TRANSFERS, async (file, signal) => {
|
|
528
|
+
const bytes = await downloadMethodFile(file, { timeoutMs: options.timeoutMs, signal });
|
|
529
|
+
const filePath = path.join(staging, ...file.path.split("/"));
|
|
530
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
531
|
+
await fs.writeFile(filePath, bytes, { flag: "wx" });
|
|
532
|
+
});
|
|
533
|
+
// `rmdir` removes only an empty directory, so a file written there meanwhile is never lost.
|
|
534
|
+
if (await isDirectory(fs, target))
|
|
535
|
+
await fs.rmdir(target);
|
|
536
|
+
await fs.rename(staging, target);
|
|
537
|
+
inPlace = true;
|
|
538
|
+
}
|
|
539
|
+
finally {
|
|
540
|
+
if (!inPlace)
|
|
541
|
+
await fs.rm(staging, { recursive: true, force: true }).catch(() => undefined);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Download one file of a listing and check its bytes against it, reading no more than its listed
|
|
546
|
+
* size and one byte. Throws `MethodFileTransferError` (`operation` `download`) when storage gives no
|
|
547
|
+
* answer, refuses the request, or serves bytes that are not the file listed.
|
|
548
|
+
*/
|
|
549
|
+
async function downloadMethodFile(file, options) {
|
|
550
|
+
const host = hostOf(file.url);
|
|
551
|
+
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 });
|
|
552
|
+
const received = await storageExchange(file.url, { method: "GET" }, options, unreachable, async (response, failureCode) => {
|
|
553
|
+
if (!response.ok) {
|
|
554
|
+
await response.body?.cancel().catch(() => undefined);
|
|
555
|
+
throw new MethodFileTransferError(`Downloading '${file.path}' from ${host} failed (${statusLine(response)}). A refused ` +
|
|
556
|
+
"link may have expired: read the method again for fresh links.", {
|
|
557
|
+
operation: "download",
|
|
558
|
+
code: methodFileTransferCodeOfStatus(response.status),
|
|
559
|
+
sha256: file.sha256,
|
|
560
|
+
path: file.path,
|
|
561
|
+
status: response.status,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
try {
|
|
565
|
+
return await readAtMost(response, file.size + 1);
|
|
566
|
+
}
|
|
567
|
+
catch (cause) {
|
|
568
|
+
throw unreachable(failureCode(cause));
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
const receivedSha256 = await sha256Hex(received);
|
|
572
|
+
if (received.length !== file.size || receivedSha256 !== file.sha256) {
|
|
573
|
+
const cameBack = received.length > file.size
|
|
574
|
+
? "more than that came back."
|
|
575
|
+
: `${received.length} bytes came back, hashing to ${receivedSha256}.`;
|
|
576
|
+
throw new MethodFileTransferError(`The bytes downloaded for '${file.path}' are not the file listed: the listing names ` +
|
|
577
|
+
`${file.size} bytes hashing to ${file.sha256}, and ${cameBack}`, { operation: "download", code: "mismatch", sha256: file.sha256, path: file.path });
|
|
578
|
+
}
|
|
579
|
+
return received;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* One exchange with storage: send the request with no credential of the client, then `consume` the
|
|
583
|
+
* answer, the whole of it bounded by `timeoutMs` and stopped by `signal`, so a body that stalls
|
|
584
|
+
* times out as a request with no answer does. A redirect is not followed, since a signed link names
|
|
585
|
+
* its object, so it reads as a refusal. A request that gets no answer throws what `unreachable`
|
|
586
|
+
* builds from the network code alone, with no cause: the failure fetch throws can quote the URL,
|
|
587
|
+
* whose query is a credential. `consume` is handed the code that names a body read that failed:
|
|
588
|
+
* `ABORT_TIMEOUT` once the exchange's own time limit ran out, whatever error the aborted read
|
|
589
|
+
* raised, `stopped` once the caller stopped it, and the network code of the failure otherwise.
|
|
590
|
+
*/
|
|
591
|
+
async function storageExchange(url, init, options, unreachable, consume) {
|
|
592
|
+
const controller = new AbortController();
|
|
593
|
+
let timedOut = false;
|
|
594
|
+
const timer = setTimeout(() => {
|
|
595
|
+
timedOut = true;
|
|
596
|
+
controller.abort(new DOMException("The transfer timed out.", "TimeoutError"));
|
|
597
|
+
}, options.timeoutMs);
|
|
598
|
+
const callerSignal = options.signal;
|
|
599
|
+
const onCallerAbort = () => controller.abort(callerSignal?.reason);
|
|
600
|
+
if (callerSignal?.aborted === true)
|
|
601
|
+
controller.abort(callerSignal.reason);
|
|
602
|
+
else
|
|
603
|
+
callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
604
|
+
// The time limit is read before the caller's signal: an abort the timer made is a timeout, and
|
|
605
|
+
// the error the aborted request or read raises says nothing reliable about which abort it was.
|
|
606
|
+
const failureCode = (cause) => {
|
|
607
|
+
if (timedOut)
|
|
608
|
+
return "ABORT_TIMEOUT";
|
|
609
|
+
if (callerSignal?.aborted === true)
|
|
610
|
+
return "stopped";
|
|
611
|
+
return networkCodeOf(cause);
|
|
612
|
+
};
|
|
613
|
+
try {
|
|
614
|
+
let response;
|
|
615
|
+
try {
|
|
616
|
+
response = await fetch(url, {
|
|
617
|
+
...init,
|
|
618
|
+
credentials: "omit",
|
|
619
|
+
redirect: "manual",
|
|
620
|
+
signal: controller.signal,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
catch (cause) {
|
|
624
|
+
throw unreachable(failureCode(cause));
|
|
625
|
+
}
|
|
626
|
+
return await consume(response, failureCode);
|
|
627
|
+
}
|
|
628
|
+
finally {
|
|
629
|
+
clearTimeout(timer);
|
|
630
|
+
callerSignal?.removeEventListener("abort", onCallerAbort);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/** A response's bytes, stopped once more than `limit` bytes came in. */
|
|
634
|
+
async function readAtMost(response, limit) {
|
|
635
|
+
const body = response.body;
|
|
636
|
+
if (body === null)
|
|
637
|
+
return new Uint8Array(0);
|
|
638
|
+
const reader = body.getReader();
|
|
639
|
+
const chunks = [];
|
|
640
|
+
let length = 0;
|
|
641
|
+
try {
|
|
642
|
+
for (;;) {
|
|
643
|
+
const { done, value } = await reader.read();
|
|
644
|
+
if (done)
|
|
645
|
+
break;
|
|
646
|
+
chunks.push(value);
|
|
647
|
+
length += value.length;
|
|
648
|
+
if (length >= limit)
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
finally {
|
|
653
|
+
await reader.cancel().catch(() => undefined);
|
|
654
|
+
}
|
|
655
|
+
const bytes = new Uint8Array(length);
|
|
656
|
+
let offset = 0;
|
|
657
|
+
for (const chunk of chunks) {
|
|
658
|
+
bytes.set(chunk, offset);
|
|
659
|
+
offset += chunk.length;
|
|
660
|
+
}
|
|
661
|
+
return bytes;
|
|
662
|
+
}
|
|
663
|
+
// ── A listing written to disk ────────────────────────────────────────
|
|
664
|
+
/**
|
|
665
|
+
* Refuse a listing whose paths could not be written as a tree, before anything is downloaded. A
|
|
666
|
+
* platform's listing holds tree paths that never collide; the check guards the disk against any
|
|
667
|
+
* other listing a caller hands over, such as one built by hand, since a path such as `../x` would
|
|
668
|
+
* be written outside the destination. Throws `RequestArgumentError`.
|
|
669
|
+
*/
|
|
670
|
+
export function refuseUnwritableListing(files) {
|
|
671
|
+
const seen = new Set();
|
|
672
|
+
for (const file of files) {
|
|
673
|
+
const violation = treePathViolation(file.path);
|
|
674
|
+
if (violation !== null) {
|
|
675
|
+
throw new RequestArgumentError(`The listing holds '${file.path}', which is not a tree path (${violation}), so the tree ` +
|
|
676
|
+
"cannot be written.");
|
|
677
|
+
}
|
|
678
|
+
if (seen.has(file.path)) {
|
|
679
|
+
throw new RequestArgumentError(`The listing holds '${file.path}' twice, so the tree cannot be written.`);
|
|
680
|
+
}
|
|
681
|
+
seen.add(file.path);
|
|
682
|
+
}
|
|
683
|
+
const colliding = pathCollisions([...seen]);
|
|
684
|
+
if (colliding.length > 0) {
|
|
685
|
+
throw new RequestArgumentError("The listing holds paths that collide once case-folded, or where one names a directory of " +
|
|
686
|
+
`another: ${colliding.join(", ")}.`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Why `candidate` is not a tree path, or `null` when it is one (mthds/docs/spec/method-tree.md,
|
|
691
|
+
* "Tree Paths"), the rule the `mthds` package's `treePathViolation` holds.
|
|
692
|
+
*/
|
|
693
|
+
export function treePathViolation(candidate) {
|
|
694
|
+
if (candidate === "")
|
|
695
|
+
return "the path is empty";
|
|
696
|
+
if (LONE_SURROGATE.test(candidate))
|
|
697
|
+
return "the path holds a code point UTF-8 cannot encode";
|
|
698
|
+
if (candidate.startsWith("/") || candidate.endsWith("/")) {
|
|
699
|
+
return "the path starts or ends with '/'";
|
|
700
|
+
}
|
|
701
|
+
for (const segment of candidate.split("/")) {
|
|
702
|
+
if (segment === "")
|
|
703
|
+
return "the path holds an empty segment";
|
|
704
|
+
if (segment === "." || segment === "..")
|
|
705
|
+
return `the path holds the segment '${segment}'`;
|
|
706
|
+
if (segment.includes("\\"))
|
|
707
|
+
return "the path holds a backslash";
|
|
708
|
+
if (segment.includes(":"))
|
|
709
|
+
return "the path holds a colon";
|
|
710
|
+
if (CONTROL_CHARACTER.test(segment))
|
|
711
|
+
return "the path holds a control character";
|
|
712
|
+
if (UTF8_ENCODER.encode(segment).length > MAX_SEGMENT_BYTES) {
|
|
713
|
+
return `a segment is longer than ${MAX_SEGMENT_BYTES} bytes`;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (candidate.normalize("NFC") !== candidate) {
|
|
717
|
+
return "the path is not in Unicode Normalization Form C";
|
|
718
|
+
}
|
|
719
|
+
if (UTF8_ENCODER.encode(candidate).length > MAX_TREE_PATH_BYTES) {
|
|
720
|
+
return `the path is longer than ${MAX_TREE_PATH_BYTES} bytes`;
|
|
721
|
+
}
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* The paths that collide: those equal to another once case-folded, and those naming, once
|
|
726
|
+
* case-folded, a directory of another, with the paths under it, sorted byte for byte. The fold is
|
|
727
|
+
* `toUpperCase().toLowerCase()`, which agrees with Unicode full case folding on the scripts a file
|
|
728
|
+
* name uses; a pair it misses still fails the write, which never writes over a file.
|
|
729
|
+
*/
|
|
730
|
+
export function pathCollisions(treePaths) {
|
|
731
|
+
const fold = (text) => text.toUpperCase().toLowerCase();
|
|
732
|
+
const byFolded = new Map();
|
|
733
|
+
const underFoldedDirectory = new Map();
|
|
734
|
+
for (const treePath of treePaths) {
|
|
735
|
+
const folded = fold(treePath);
|
|
736
|
+
const group = byFolded.get(folded) ?? new Set();
|
|
737
|
+
group.add(treePath);
|
|
738
|
+
byFolded.set(folded, group);
|
|
739
|
+
const segments = treePath.split("/");
|
|
740
|
+
for (let depth = 1; depth < segments.length; depth += 1) {
|
|
741
|
+
const directory = fold(segments.slice(0, depth).join("/"));
|
|
742
|
+
const under = underFoldedDirectory.get(directory) ?? new Set();
|
|
743
|
+
under.add(treePath);
|
|
744
|
+
underFoldedDirectory.set(directory, under);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const colliding = new Set();
|
|
748
|
+
for (const group of byFolded.values()) {
|
|
749
|
+
if (group.size > 1)
|
|
750
|
+
for (const treePath of group)
|
|
751
|
+
colliding.add(treePath);
|
|
752
|
+
}
|
|
753
|
+
for (const treePath of treePaths) {
|
|
754
|
+
const under = underFoldedDirectory.get(fold(treePath));
|
|
755
|
+
if (under !== undefined) {
|
|
756
|
+
colliding.add(treePath);
|
|
757
|
+
for (const other of under)
|
|
758
|
+
colliding.add(other);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return [...colliding].sort(compareUtf8);
|
|
762
|
+
}
|
|
763
|
+
/** Refuse a destination that exists and is not an empty directory. Throws `RequestArgumentError`. */
|
|
764
|
+
async function refuseOccupiedDestination(fs, target) {
|
|
765
|
+
let entries;
|
|
766
|
+
try {
|
|
767
|
+
const found = await fs.lstat(target);
|
|
768
|
+
if (!found.isDirectory())
|
|
769
|
+
throw occupied(target);
|
|
770
|
+
entries = await fs.readdir(target);
|
|
771
|
+
}
|
|
772
|
+
catch (err) {
|
|
773
|
+
if (err.code === "ENOENT")
|
|
774
|
+
return;
|
|
775
|
+
throw err;
|
|
776
|
+
}
|
|
777
|
+
if (entries.length > 0)
|
|
778
|
+
throw occupied(target);
|
|
779
|
+
}
|
|
780
|
+
function occupied(target) {
|
|
781
|
+
return new RequestArgumentError(`Cannot write a method tree at '${target}': it already exists and is not an empty ` +
|
|
782
|
+
"directory, and a written tree holds nothing but its files.");
|
|
783
|
+
}
|
|
784
|
+
async function isDirectory(fs, target) {
|
|
785
|
+
try {
|
|
786
|
+
return (await fs.lstat(target)).isDirectory();
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
return false;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
793
|
+
const UTF8_ENCODER = new TextEncoder();
|
|
794
|
+
// `ignoreBOM` keeps a leading byte order mark in the text, so the entry gives back every byte.
|
|
795
|
+
const STRICT_UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
796
|
+
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
797
|
+
// With the `u` flag a surrogate pair is one code point, so only a lone surrogate matches.
|
|
798
|
+
const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
|
|
799
|
+
const CONTROL_CHARACTER = /\p{Cc}/u;
|
|
800
|
+
const MAX_SEGMENT_BYTES = 255;
|
|
801
|
+
const REDACTED = "[redacted]";
|
|
802
|
+
// A link up to its query, then its query, which a redaction replaces.
|
|
803
|
+
const LINK_QUERY = /(\bhttps?:\/\/[^\s"'<>?#]*)\?[^\s"'<>]*/gi;
|
|
804
|
+
const MAX_TREE_PATH_BYTES = 1024;
|
|
805
|
+
function objectMember(holder, name) {
|
|
806
|
+
const value = holder[name];
|
|
807
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
808
|
+
? value
|
|
809
|
+
: undefined;
|
|
810
|
+
}
|
|
811
|
+
/** Whether an upload target asks for a create-only write, whose `412` means already stored. */
|
|
812
|
+
function isCreateOnly(headers) {
|
|
813
|
+
return Object.keys(headers).some((name) => name.toLowerCase() === "if-none-match");
|
|
814
|
+
}
|
|
815
|
+
/** The host a link names, for a message: never the link itself, whose query is a credential. */
|
|
816
|
+
function hostOf(url) {
|
|
817
|
+
try {
|
|
818
|
+
return new URL(url).host || "storage";
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
return "storage";
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* `text`, an answer that carries signed links, with every link hidden, for an error to hold: in a
|
|
826
|
+
* JSON body every `url` member, at any depth, is replaced whole, and in any text, JSON or not, a
|
|
827
|
+
* truncated body included, the query of every `http(s)` link is replaced, since a signed link's
|
|
828
|
+
* signature rides its query. A link is a bearer credential, so no error, message or member may
|
|
829
|
+
* carry one.
|
|
830
|
+
*/
|
|
831
|
+
export function redactSignedLinks(text) {
|
|
832
|
+
let redacted = text;
|
|
833
|
+
try {
|
|
834
|
+
redacted = JSON.stringify(withUrlMembersRedacted(JSON.parse(text)));
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
// Not JSON: the pattern below still hides every link's query.
|
|
838
|
+
}
|
|
839
|
+
return redacted.replace(LINK_QUERY, "$1?[redacted]");
|
|
840
|
+
}
|
|
841
|
+
function withUrlMembersRedacted(value) {
|
|
842
|
+
if (Array.isArray(value))
|
|
843
|
+
return value.map(withUrlMembersRedacted);
|
|
844
|
+
if (value !== null && typeof value === "object") {
|
|
845
|
+
// `fromEntries` defines each member as an own property, so a `__proto__` member stays one.
|
|
846
|
+
return Object.fromEntries(Object.entries(value).map(([name, member]) => [
|
|
847
|
+
name,
|
|
848
|
+
name === "url" ? REDACTED : withUrlMembersRedacted(member),
|
|
849
|
+
]));
|
|
850
|
+
}
|
|
851
|
+
return value;
|
|
852
|
+
}
|
|
853
|
+
function statusLine(response) {
|
|
854
|
+
return `${response.status} ${response.statusText}`.trimEnd();
|
|
855
|
+
}
|
|
856
|
+
/** The network code of a failed fetch, as the client's own requests report it. */
|
|
857
|
+
function networkCodeOf(err) {
|
|
858
|
+
if (err instanceof DOMException && err.name === "TimeoutError")
|
|
859
|
+
return "ABORT_TIMEOUT";
|
|
860
|
+
if (err instanceof Error) {
|
|
861
|
+
const cause = err.cause;
|
|
862
|
+
if (cause !== null && typeof cause === "object" && "code" in cause) {
|
|
863
|
+
const code = cause.code;
|
|
864
|
+
if (typeof code === "string")
|
|
865
|
+
return code;
|
|
866
|
+
}
|
|
867
|
+
if (err.name === "TimeoutError")
|
|
868
|
+
return "ABORT_TIMEOUT";
|
|
869
|
+
}
|
|
870
|
+
return "network error";
|
|
871
|
+
}
|
|
872
|
+
function randomHex(byteCount) {
|
|
873
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(byteCount));
|
|
874
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
875
|
+
}
|
|
876
|
+
//# sourceMappingURL=method-trees.js.map
|