@mdbase-dev/connect 0.1.0-beta.21 → 0.1.0-beta.23

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 (49) hide show
  1. package/README.md +108 -38
  2. package/dist/authorization-url.d.ts +2 -1
  3. package/dist/authorization-url.js +5 -7
  4. package/dist/browser/integrity.json +1 -1
  5. package/dist/browser/mdbase-connect.min.js +3 -3
  6. package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
  7. package/dist/collection-client.d.ts +34 -31
  8. package/dist/collection-client.js +107 -61
  9. package/dist/connect.d.ts +4 -3
  10. package/dist/connect.js +53 -38
  11. package/dist/connection-file-transport.d.ts +31 -0
  12. package/dist/connection-file-transport.js +42 -0
  13. package/dist/connection-notifications.js +21 -21
  14. package/dist/connection-transport.d.ts +2 -0
  15. package/dist/connection-transport.js +95 -37
  16. package/dist/connection-types.d.ts +2 -1
  17. package/dist/connection.d.ts +49 -45
  18. package/dist/connection.js +73 -33
  19. package/dist/crypto.d.ts +5 -6
  20. package/dist/crypto.js +13 -13
  21. package/dist/errors.d.ts +38 -24
  22. package/dist/errors.js +130 -71
  23. package/dist/file-crypto.d.ts +25 -0
  24. package/dist/file-crypto.js +132 -0
  25. package/dist/file-sha256.d.ts +12 -0
  26. package/dist/file-sha256.js +143 -0
  27. package/dist/file-stream-source.d.ts +17 -0
  28. package/dist/file-stream-source.js +157 -0
  29. package/dist/files.d.ts +91 -0
  30. package/dist/files.js +644 -0
  31. package/dist/hosted-file-request.d.ts +6 -0
  32. package/dist/hosted-file-request.js +63 -0
  33. package/dist/index.d.ts +4 -1
  34. package/dist/index.js +3 -0
  35. package/dist/internal-types.d.ts +3 -1
  36. package/dist/local-file-transport.d.ts +30 -0
  37. package/dist/local-file-transport.js +367 -0
  38. package/dist/notifications.js +5 -5
  39. package/dist/operation-helpers.d.ts +2 -2
  40. package/dist/operation-helpers.js +22 -18
  41. package/dist/operation-types.d.ts +3 -4
  42. package/dist/outcomes.d.ts +51 -0
  43. package/dist/outcomes.js +168 -0
  44. package/dist/runtime-utils.d.ts +6 -1
  45. package/dist/runtime-utils.js +90 -11
  46. package/dist/selection.js +3 -3
  47. package/dist/session.d.ts +9 -8
  48. package/dist/session.js +31 -20
  49. package/package.json +2 -2
package/dist/files.js ADDED
@@ -0,0 +1,644 @@
1
+ import { FILE_PROTOCOL_VERSION } from "@mdbase-dev/connect-protocol";
2
+ import { MdbaseConnectError, connectError } from "./errors.js";
3
+ import { IncrementalSha256 } from "./file-sha256.js";
4
+ import { BinaryPartReader } from "./file-stream-source.js";
5
+ const HASH_CHUNK_BYTES = 1024 * 1024;
6
+ const DEFAULT_CONCURRENCY = 4;
7
+ const MAX_CONCURRENCY = 8;
8
+ const MAX_OBJECT_ATTEMPTS = 3;
9
+ const MAX_BUFFERED_DOWNLOAD_BYTES = 64 * 1024 * 1024;
10
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
11
+ const SHA256_DIGEST = /^sha256:[0-9a-f]{64}$/u;
12
+ /** Ergonomic facade over resumable, authority-specific file delivery. */
13
+ export class MdbaseFileClient {
14
+ capability;
15
+ request;
16
+ framed;
17
+ hosted;
18
+ constructor(capability, request, framed, hosted) {
19
+ this.capability = capability;
20
+ this.request = request;
21
+ this.framed = framed;
22
+ this.hosted = hosted;
23
+ }
24
+ async *list(options = {}) {
25
+ this.requireAction("list");
26
+ let after;
27
+ do {
28
+ throwIfAborted(options.signal);
29
+ const query = new URLSearchParams({
30
+ protocol_version: String(FILE_PROTOCOL_VERSION),
31
+ ...(options.folder ? { folder: options.folder } : {}),
32
+ ...(after ? { after } : {}),
33
+ ...(options.pageSize ? { limit: String(validPageSize(options.pageSize)) } : {})
34
+ });
35
+ const page = await this.request("GET", `?${query.toString()}`, undefined, options.signal);
36
+ if (page.protocol_version !== 1 || page.type !== "files_page" || !Array.isArray(page.files)) {
37
+ throw connectError("invalid_operation_response", "The authority returned an invalid file page.");
38
+ }
39
+ for (const file of page.files) {
40
+ throwIfAborted(options.signal);
41
+ yield file;
42
+ }
43
+ after = page.next;
44
+ } while (after);
45
+ }
46
+ async upload(path, source, options = {}) {
47
+ this.requireAction(options.ifRevision ? "replace" : "add");
48
+ const blob = sourceBlob(source, options.mediaType);
49
+ const concurrency = validConcurrency(options.concurrency);
50
+ const digest = await hashBlob(blob, options.signal, (transferredBytes) => {
51
+ options.onProgress?.({
52
+ phase: "hashing",
53
+ transferredBytes,
54
+ totalBytes: blob.size
55
+ });
56
+ });
57
+ return this.uploadKnownSource(path, blob.size, `sha256:${digest}`, (options.mediaType ?? blob.type) || undefined, options, concurrency, async (_partIndex, offset, length) => blob.slice(offset, offset + length));
58
+ }
59
+ /**
60
+ * Upload a one-shot byte stream without accumulating multiple file parts.
61
+ * SDK memory is bounded by one assembled negotiated part plus at most one
62
+ * source chunk; a single-part upload may therefore hold the complete file.
63
+ * Reuse `transferId` with a newly opened source after an ambiguous failure.
64
+ */
65
+ async uploadStream(path, source, options = {}) {
66
+ this.requireAction(options.ifRevision ? "replace" : "add");
67
+ if (!Number.isSafeInteger(source.size) || source.size < 0) {
68
+ throw connectError("invalid_request", "Streamed file size must be a non-negative safe integer.");
69
+ }
70
+ if (!SHA256_DIGEST.test(source.contentDigest)) {
71
+ throw connectError("invalid_request", "Streamed files require a lowercase SHA-256 content digest.");
72
+ }
73
+ const reader = new BinaryPartReader(source.stream, options.signal);
74
+ const hash = new IncrementalSha256();
75
+ return this.uploadKnownSource(path, source.size, source.contentDigest, options.mediaType ?? source.mediaType, options, 1, async (_partIndex, _offset, length) => {
76
+ const bytes = await reader.read(length);
77
+ hash.update(bytes);
78
+ return bytes;
79
+ }, async () => {
80
+ await reader.expectEnd();
81
+ if (`sha256:${hash.digestHex()}` !== source.contentDigest) {
82
+ throw connectError("invalid_request", "Streamed file bytes do not match the declared SHA-256 digest.");
83
+ }
84
+ }, () => reader.close(), (partSize) => reader.setMaxSourceChunkBytes(partSize));
85
+ }
86
+ async uploadKnownSource(path, size, contentDigest, mediaType, options, concurrency, readPart, finishSource, closeSource, prepareSource) {
87
+ const transferId = options.transferId ?? crypto.randomUUID();
88
+ if (!UUID.test(transferId)) {
89
+ throw connectError("invalid_request", "File transfer ID must be a UUID.");
90
+ }
91
+ let committed = false;
92
+ try {
93
+ const session = await this.request("POST", "uploads", {
94
+ protocol_version: FILE_PROTOCOL_VERSION,
95
+ type: "open_file_upload",
96
+ transfer_id: transferId,
97
+ path,
98
+ size,
99
+ content_digest: contentDigest,
100
+ ...(mediaType ? { media_type: mediaType } : {}),
101
+ ...(options.ifRevision ? { if_revision: options.ifRevision } : {})
102
+ }, options.signal);
103
+ requireTransferSession(session, transferId, "upload");
104
+ if (session.total_size !== size || session.strategy.kind === "object_ranges") {
105
+ throw connectError("invalid_operation_response", "The authority returned an incompatible upload strategy.");
106
+ }
107
+ const framed = session.strategy.kind === "framed_chunks";
108
+ if (framed && !this.framed) {
109
+ throw connectError("invalid_operation_response", "Encrypted file chunk delivery is unavailable.");
110
+ }
111
+ const partSize = session.strategy.kind === "object_put"
112
+ ? Math.max(1, size)
113
+ : session.strategy.kind === "framed_chunks"
114
+ ? session.strategy.chunk_size
115
+ : session.strategy.part_size;
116
+ const partCount = session.strategy.kind === "object_put"
117
+ ? 1
118
+ : Math.ceil(size / partSize);
119
+ prepareSource?.(partSize);
120
+ if (!framed && session.received.length === partCount) {
121
+ const replay = await this.tryReplayUploadCommit(transferId, session.strategy.kind === "object_multipart" ? session.uploaded_parts : undefined, options.signal);
122
+ if (replay) {
123
+ committed = true;
124
+ return replay.file;
125
+ }
126
+ }
127
+ const received = new Set(session.received);
128
+ const uploadedParts = new Map((session.uploaded_parts ?? []).map((part) => [part.part_number - 1, part]));
129
+ let transferredBytes = [...received].reduce((total, index) => total + chunkLength(size, partSize, index), 0);
130
+ const parts = await mapConcurrent(partCount, concurrency, async (partIndex) => {
131
+ const offset = partIndex * partSize;
132
+ const length = Math.min(partSize, Math.max(0, size - offset));
133
+ const part = await readPart(partIndex, offset, length);
134
+ if (uploadBodyLength(part) !== length) {
135
+ throw connectError("invalid_request", "Streamed file bytes ended before the declared size.");
136
+ }
137
+ if (received.has(partIndex))
138
+ return uploadedParts.get(partIndex) ?? null;
139
+ const uploaded = framed
140
+ ? await retryChunk(async () => this.framed.uploadChunk(session, partIndex, await uploadBodyBytes(part), options.signal), options.signal).then(() => null)
141
+ : await this.uploadPart(transferId, partIndex, offset, part, length, session.strategy.kind === "object_multipart", options.signal);
142
+ transferredBytes += length;
143
+ options.onProgress?.({
144
+ phase: "uploading",
145
+ transferredBytes,
146
+ totalBytes: size
147
+ });
148
+ return uploaded;
149
+ });
150
+ await finishSource?.();
151
+ const receipt = await this.commitUpload(transferId, session.strategy.kind === "object_multipart"
152
+ ? parts.filter((part) => part !== null)
153
+ : undefined, options.signal);
154
+ if (receipt.protocol_version !== 1
155
+ || receipt.type !== "file_upload_committed"
156
+ || receipt.transfer_id !== transferId
157
+ || receipt.file.path !== path
158
+ || receipt.file.size !== size
159
+ || receipt.file.content_digest !== contentDigest) {
160
+ throw connectError("invalid_operation_response", "The authority returned an invalid file receipt.");
161
+ }
162
+ committed = true;
163
+ return receipt.file;
164
+ }
165
+ catch (error) {
166
+ throw normalizeFileError(error);
167
+ }
168
+ finally {
169
+ await closeSource?.().catch(() => undefined);
170
+ if (!committed && options.transferId === undefined)
171
+ await this.abort(transferId);
172
+ }
173
+ }
174
+ async download(file, options = {}) {
175
+ if (file.size > MAX_BUFFERED_DOWNLOAD_BYTES) {
176
+ throw connectError("invalid_request", `download() buffers at most ${MAX_BUFFERED_DOWNLOAD_BYTES} bytes; use downloadStream() for larger files.`);
177
+ }
178
+ const stream = await this.downloadStream(file, options);
179
+ const chunks = [];
180
+ const reader = stream.getReader();
181
+ while (true) {
182
+ const result = await reader.read();
183
+ if (result.done)
184
+ break;
185
+ chunks.push(result.value);
186
+ }
187
+ return new Blob(chunks.map((chunk) => {
188
+ const copy = new Uint8Array(chunk.byteLength);
189
+ copy.set(chunk);
190
+ return copy.buffer;
191
+ }), { type: file.media_type ?? "" });
192
+ }
193
+ /** Download and verify a file with network backpressure and bounded buffering. */
194
+ async downloadStream(file, options = {}) {
195
+ this.requireAction("read");
196
+ validConcurrency(options.concurrency);
197
+ const transferId = crypto.randomUUID();
198
+ let session;
199
+ try {
200
+ session = await this.request("POST", "downloads", {
201
+ protocol_version: FILE_PROTOCOL_VERSION,
202
+ type: "open_file_download",
203
+ transfer_id: transferId,
204
+ file_id: file.file_id,
205
+ revision: file.revision
206
+ }, options.signal);
207
+ requireTransferSession(session, transferId, "download");
208
+ if (session.total_size !== file.size
209
+ || session.strategy.kind !== "object_ranges"
210
+ && session.strategy.kind !== "framed_chunks") {
211
+ throw connectError("invalid_operation_response", "The authority returned an incompatible download session.");
212
+ }
213
+ if (session.strategy.kind === "framed_chunks" && !this.framed) {
214
+ throw connectError("invalid_operation_response", "Encrypted file chunk delivery is unavailable.");
215
+ }
216
+ if (session.strategy.kind === "object_ranges" && file.size > 0 && !this.hosted) {
217
+ throw connectError("invalid_operation_response", "Authenticated hosted file delivery is unavailable.");
218
+ }
219
+ }
220
+ catch (error) {
221
+ await this.abort(transferId);
222
+ throw normalizeFileError(error);
223
+ }
224
+ const partSize = session.strategy.kind === "framed_chunks"
225
+ ? session.strategy.chunk_size
226
+ : session.strategy.part_size;
227
+ const partCount = Math.ceil(file.size / partSize);
228
+ const hash = new IncrementalSha256();
229
+ let partIndex = 0;
230
+ let partRemaining = 0;
231
+ let hostedReader = null;
232
+ let receivedBytes = 0;
233
+ let closed = false;
234
+ const finish = async () => {
235
+ if (closed)
236
+ return;
237
+ closed = true;
238
+ await hostedReader?.cancel().catch(() => undefined);
239
+ hostedReader = null;
240
+ await this.abort(transferId);
241
+ };
242
+ const verify = () => {
243
+ if (receivedBytes !== file.size || `sha256:${hash.digestHex()}` !== file.content_digest) {
244
+ throw connectError("invalid_operation_response", "Downloaded file bytes failed integrity verification.");
245
+ }
246
+ };
247
+ return new ReadableStream({
248
+ pull: async (controller) => {
249
+ try {
250
+ throwIfAborted(options.signal);
251
+ if (session.strategy.kind === "object_ranges") {
252
+ while (true) {
253
+ if (!hostedReader) {
254
+ if (partIndex >= partCount) {
255
+ verify();
256
+ await finish();
257
+ controller.close();
258
+ return;
259
+ }
260
+ const offset = partIndex * partSize;
261
+ partRemaining = Math.min(partSize, file.size - offset);
262
+ const body = await retryChunk(() => this.hosted.downloadPart(session, partIndex, partRemaining, options.signal), options.signal);
263
+ hostedReader = body.getReader();
264
+ }
265
+ const next = await hostedReader.read();
266
+ if (next.done) {
267
+ hostedReader.releaseLock();
268
+ hostedReader = null;
269
+ if (partRemaining !== 0) {
270
+ throw connectError("invalid_operation_response", "A hosted file range ended before its declared byte length.");
271
+ }
272
+ partIndex += 1;
273
+ continue;
274
+ }
275
+ if (!(next.value instanceof Uint8Array)) {
276
+ throw connectError("invalid_operation_response", "A hosted file range returned a non-byte chunk.");
277
+ }
278
+ if (next.value.byteLength === 0)
279
+ continue;
280
+ if (next.value.byteLength > partRemaining) {
281
+ throw connectError("invalid_operation_response", "A hosted file range exceeded its declared byte length.");
282
+ }
283
+ partRemaining -= next.value.byteLength;
284
+ hash.update(next.value);
285
+ receivedBytes += next.value.byteLength;
286
+ options.onProgress?.({
287
+ phase: "downloading",
288
+ transferredBytes: receivedBytes,
289
+ totalBytes: file.size
290
+ });
291
+ controller.enqueue(next.value);
292
+ return;
293
+ }
294
+ }
295
+ if (partIndex >= partCount) {
296
+ verify();
297
+ await finish();
298
+ controller.close();
299
+ return;
300
+ }
301
+ const offset = partIndex * partSize;
302
+ const length = Math.min(partSize, file.size - offset);
303
+ const chunk = await retryChunk(() => this.framed.downloadChunk(session, partIndex, options.signal), options.signal);
304
+ if (chunk.byteLength !== length) {
305
+ throw connectError("invalid_operation_response", "A file chunk had the wrong byte length.");
306
+ }
307
+ hash.update(chunk);
308
+ receivedBytes += chunk.byteLength;
309
+ partIndex += 1;
310
+ options.onProgress?.({
311
+ phase: "downloading",
312
+ transferredBytes: receivedBytes,
313
+ totalBytes: file.size
314
+ });
315
+ if (partIndex === partCount) {
316
+ verify();
317
+ }
318
+ controller.enqueue(chunk);
319
+ if (partIndex === partCount) {
320
+ await finish();
321
+ controller.close();
322
+ }
323
+ }
324
+ catch (error) {
325
+ await finish();
326
+ controller.error(normalizeFileError(error));
327
+ }
328
+ },
329
+ cancel: finish
330
+ });
331
+ }
332
+ async downloadBytes(file, options = {}) {
333
+ return new Uint8Array(await (await this.download(file, options)).arrayBuffer());
334
+ }
335
+ async move(file, path, options = {}) {
336
+ this.requireAction("move");
337
+ const mutationId = options.mutationId ?? crypto.randomUUID();
338
+ let receipt;
339
+ try {
340
+ receipt = await this.request("POST", `${encodeURIComponent(file.file_id)}/move`, {
341
+ protocol_version: FILE_PROTOCOL_VERSION,
342
+ type: "move_file",
343
+ mutation_id: mutationId,
344
+ file_id: file.file_id,
345
+ if_revision: options.ifRevision ?? file.revision,
346
+ from_path: file.path,
347
+ path,
348
+ // Reserved for a future protocol that can update record links
349
+ // atomically with a file move. Protocol v1 must remain false.
350
+ update_references: false
351
+ }, options.signal);
352
+ }
353
+ catch (error) {
354
+ throw normalizeFileError(error);
355
+ }
356
+ if (receipt.protocol_version !== 1
357
+ || receipt.type !== "file_moved"
358
+ || receipt.mutation_id !== mutationId
359
+ || receipt.file.file_id !== file.file_id
360
+ || receipt.file.path !== path) {
361
+ throw connectError("invalid_operation_response", "The authority returned an invalid file move receipt.");
362
+ }
363
+ return receipt.file;
364
+ }
365
+ async delete(file, options = {}) {
366
+ this.requireAction("delete");
367
+ const mutationId = options.mutationId ?? crypto.randomUUID();
368
+ let receipt;
369
+ try {
370
+ receipt = await this.request("POST", `${encodeURIComponent(file.file_id)}/delete`, {
371
+ protocol_version: FILE_PROTOCOL_VERSION,
372
+ type: "delete_file",
373
+ mutation_id: mutationId,
374
+ file_id: file.file_id,
375
+ if_revision: options.ifRevision ?? file.revision,
376
+ path: file.path
377
+ }, options.signal);
378
+ }
379
+ catch (error) {
380
+ throw normalizeFileError(error);
381
+ }
382
+ if (receipt.protocol_version !== 1
383
+ || receipt.type !== "file_deleted"
384
+ || receipt.mutation_id !== mutationId
385
+ || receipt.file_id !== file.file_id
386
+ || receipt.previous_path !== file.path) {
387
+ throw connectError("invalid_operation_response", "The authority returned an invalid file delete receipt.");
388
+ }
389
+ return receipt;
390
+ }
391
+ async uploadPart(transferId, partIndex, offset, body, contentLength, requireEtag, signal) {
392
+ for (let attempt = 1; attempt <= MAX_OBJECT_ATTEMPTS; attempt += 1) {
393
+ throwIfAborted(signal);
394
+ try {
395
+ const prepared = await this.request("POST", `uploads/${encodeURIComponent(transferId)}/parts`, {
396
+ protocol_version: FILE_PROTOCOL_VERSION,
397
+ type: "prepare_file_upload_part",
398
+ transfer_id: transferId,
399
+ part_number: partIndex + 1,
400
+ content_length: contentLength
401
+ }, signal);
402
+ requirePreparedPart(prepared, transferId, partIndex, offset, contentLength, "PUT");
403
+ const response = await fetch(prepared.url, {
404
+ method: prepared.method,
405
+ headers: browserObjectHeaders(prepared.headers),
406
+ body,
407
+ redirect: "error",
408
+ signal
409
+ });
410
+ if (!response.ok)
411
+ throw new Error(`Object upload failed with HTTP ${response.status}.`);
412
+ const etag = response.headers.get("etag");
413
+ if (requireEtag && !etag) {
414
+ throw new Error("Multipart object upload did not return an ETag.");
415
+ }
416
+ return { part_number: partIndex + 1, etag: etag ?? "" };
417
+ }
418
+ catch (error) {
419
+ if (signal?.aborted)
420
+ throw error;
421
+ if (attempt === MAX_OBJECT_ATTEMPTS)
422
+ throw error;
423
+ }
424
+ }
425
+ throw new Error("Unreachable upload retry state.");
426
+ }
427
+ async tryReplayUploadCommit(transferId, parts, signal) {
428
+ try {
429
+ return await this.commitUpload(transferId, parts, signal);
430
+ }
431
+ catch (error) {
432
+ if (error instanceof MdbaseConnectError && error.code === "file_upload_incomplete") {
433
+ return null;
434
+ }
435
+ throw error;
436
+ }
437
+ }
438
+ async commitUpload(transferId, parts, signal) {
439
+ let lastError;
440
+ for (let attempt = 1; attempt <= MAX_OBJECT_ATTEMPTS; attempt += 1) {
441
+ throwIfAborted(signal);
442
+ try {
443
+ return await this.request("POST", `uploads/${encodeURIComponent(transferId)}/commit`, {
444
+ protocol_version: FILE_PROTOCOL_VERSION,
445
+ type: "commit_file_upload",
446
+ transfer_id: transferId,
447
+ ...(parts === undefined ? {} : { parts })
448
+ }, signal);
449
+ }
450
+ catch (error) {
451
+ lastError = error;
452
+ if (signal?.aborted || error instanceof MdbaseConnectError || attempt === MAX_OBJECT_ATTEMPTS) {
453
+ throw error;
454
+ }
455
+ }
456
+ }
457
+ throw lastError;
458
+ }
459
+ requireAction(action) {
460
+ if (!this.capability()?.actions.includes(action)) {
461
+ throw connectError("not_authorized", `This connection is not authorized to ${action} files.`);
462
+ }
463
+ }
464
+ async abort(transferId) {
465
+ await this.request("DELETE", `transfers/${encodeURIComponent(transferId)}`).catch(() => undefined);
466
+ }
467
+ }
468
+ async function hashBlob(blob, signal, progress) {
469
+ const hash = new IncrementalSha256();
470
+ for (let offset = 0; offset < blob.size; offset += HASH_CHUNK_BYTES) {
471
+ throwIfAborted(signal);
472
+ const chunk = new Uint8Array(await blob.slice(offset, offset + HASH_CHUNK_BYTES).arrayBuffer());
473
+ hash.update(chunk);
474
+ progress(Math.min(blob.size, offset + chunk.byteLength));
475
+ }
476
+ if (blob.size === 0)
477
+ progress(0);
478
+ return hash.digestHex();
479
+ }
480
+ function sourceBlob(source, mediaType) {
481
+ if (source instanceof Blob) {
482
+ return mediaType && mediaType !== source.type
483
+ ? source.slice(0, source.size, mediaType)
484
+ : source;
485
+ }
486
+ if (source instanceof ArrayBuffer)
487
+ return new Blob([source], { type: mediaType });
488
+ const copy = new Uint8Array(source.byteLength);
489
+ copy.set(new Uint8Array(source.buffer, source.byteOffset, source.byteLength));
490
+ return new Blob([copy.buffer], { type: mediaType });
491
+ }
492
+ function uploadBodyLength(body) {
493
+ return body instanceof Blob ? body.size : body.byteLength;
494
+ }
495
+ async function uploadBodyBytes(body) {
496
+ return body instanceof Blob
497
+ ? new Uint8Array(await body.arrayBuffer())
498
+ : body;
499
+ }
500
+ function validConcurrency(value = DEFAULT_CONCURRENCY) {
501
+ if (!Number.isInteger(value) || value < 1 || value > MAX_CONCURRENCY) {
502
+ throw connectError("invalid_request", `File concurrency must be between 1 and ${MAX_CONCURRENCY}.`);
503
+ }
504
+ return value;
505
+ }
506
+ function validPageSize(value) {
507
+ if (!Number.isInteger(value) || value < 1 || value > 1_000) {
508
+ throw connectError("invalid_request", "File page size must be between 1 and 1000.");
509
+ }
510
+ return value;
511
+ }
512
+ function requireTransferSession(session, transferId, direction) {
513
+ if (session.protocol_version !== 1
514
+ || session.type !== "file_transfer"
515
+ || session.transfer_id !== transferId
516
+ || session.direction !== direction
517
+ || !Number.isSafeInteger(session.total_size)
518
+ || session.total_size < 0
519
+ || !Array.isArray(session.received)
520
+ || !validTransferStrategy(session)) {
521
+ throw connectError("invalid_operation_response", "The authority returned an invalid file transfer session.");
522
+ }
523
+ const partSize = session.strategy.kind === "framed_chunks"
524
+ ? session.strategy.chunk_size
525
+ : session.strategy.kind === "object_put"
526
+ ? Math.max(1, session.total_size)
527
+ : session.strategy.part_size;
528
+ const partCount = session.strategy.kind === "object_put"
529
+ ? 1
530
+ : Math.ceil(session.total_size / partSize);
531
+ if (new Set(session.received).size !== session.received.length
532
+ || session.received.some((index) => !Number.isSafeInteger(index) || index < 0 || index >= partCount)) {
533
+ throw connectError("invalid_operation_response", "The authority returned invalid transfer progress.");
534
+ }
535
+ const uploadedParts = session.uploaded_parts ?? [];
536
+ if (!Array.isArray(uploadedParts)
537
+ || uploadedParts.some((part, index) => !Number.isSafeInteger(part?.part_number)
538
+ || part.part_number < 1
539
+ || part.part_number > partCount
540
+ || typeof part.etag !== "string"
541
+ || part.etag.length === 0
542
+ || part.etag.length > 255
543
+ || (index > 0 && uploadedParts[index - 1].part_number >= part.part_number))
544
+ || (session.strategy.kind === "object_multipart"
545
+ ? uploadedParts.length !== session.received.length
546
+ || uploadedParts.some((part, index) => part.part_number - 1 !== session.received[index])
547
+ : uploadedParts.length !== 0)) {
548
+ throw connectError("invalid_operation_response", "The authority returned invalid uploaded part receipts.");
549
+ }
550
+ }
551
+ function validTransferStrategy(session) {
552
+ if (session.strategy.kind === "framed_chunks") {
553
+ return session.protection === "grant_aead_v1"
554
+ && Number.isSafeInteger(session.strategy.chunk_size)
555
+ && session.strategy.chunk_size > 0;
556
+ }
557
+ if (session.protection !== "transport_tls")
558
+ return false;
559
+ if (session.strategy.kind === "object_put")
560
+ return true;
561
+ return Number.isSafeInteger(session.strategy.part_size) && session.strategy.part_size > 0;
562
+ }
563
+ function chunkLength(totalSize, partSize, index) {
564
+ return Math.min(partSize, Math.max(0, totalSize - index * partSize));
565
+ }
566
+ async function retryChunk(work, signal) {
567
+ for (let attempt = 1; attempt <= MAX_OBJECT_ATTEMPTS; attempt += 1) {
568
+ throwIfAborted(signal);
569
+ try {
570
+ return await work();
571
+ }
572
+ catch (error) {
573
+ if (signal?.aborted || attempt === MAX_OBJECT_ATTEMPTS)
574
+ throw error;
575
+ }
576
+ }
577
+ throw new Error("Unreachable file chunk retry state.");
578
+ }
579
+ function requirePreparedPart(part, transferId, partIndex, offset, contentLength, method) {
580
+ let url;
581
+ try {
582
+ url = new URL(part.url);
583
+ }
584
+ catch {
585
+ throw connectError("invalid_operation_response", "The authority returned an invalid object URL.");
586
+ }
587
+ if (part.protocol_version !== 1
588
+ || part.type !== "file_part"
589
+ || part.transfer_id !== transferId
590
+ || part.part_index !== partIndex
591
+ || part.offset !== offset
592
+ || part.content_length !== contentLength
593
+ || part.method.toUpperCase() !== method
594
+ || url.protocol !== "https:"
595
+ && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname))
596
+ || url.username
597
+ || url.password) {
598
+ throw connectError("invalid_operation_response", "The authority returned an invalid prepared file part.");
599
+ }
600
+ }
601
+ function browserObjectHeaders(headers) {
602
+ const result = new Headers();
603
+ for (const [name, value] of Object.entries(headers)) {
604
+ if (["host", "content-length"].includes(name.toLowerCase()))
605
+ continue;
606
+ result.set(name, value);
607
+ }
608
+ return result;
609
+ }
610
+ async function mapConcurrent(count, concurrency, work) {
611
+ const results = new Array(count);
612
+ let next = 0;
613
+ await Promise.all(Array.from({ length: Math.min(count, concurrency) }, async () => {
614
+ while (next < count) {
615
+ const index = next;
616
+ next += 1;
617
+ results[index] = await work(index);
618
+ }
619
+ }));
620
+ return results;
621
+ }
622
+ function throwIfAborted(signal) {
623
+ if (signal?.aborted) {
624
+ throw connectError("operation_cancelled", "The file transfer was cancelled.", {
625
+ operationOutcome: "not_sent",
626
+ cause: signal.reason
627
+ });
628
+ }
629
+ }
630
+ function normalizeFileError(error) {
631
+ if (error instanceof MdbaseConnectError)
632
+ return error;
633
+ if (typeof DOMException !== "undefined"
634
+ && error instanceof DOMException
635
+ && error.name === "AbortError") {
636
+ return connectError("operation_cancelled", "The file transfer was cancelled.", {
637
+ operationOutcome: "not_sent",
638
+ cause: error
639
+ });
640
+ }
641
+ return connectError("temporarily_unavailable", "The file transfer could not be completed.", {
642
+ cause: error
643
+ });
644
+ }
@@ -0,0 +1,6 @@
1
+ import type { StoredToken } from "./internal-types.js";
2
+ type ProofHeaders = (token: StoredToken, method: "GET" | "POST" | "DELETE", url: string, body: string | undefined, credential: string) => Promise<Record<string, string>>;
3
+ export declare function sendHostedFileRequest(token: StoredToken, method: "GET" | "POST" | "DELETE", path: string, input: unknown, signal: AbortSignal | undefined, proofHeaders: ProofHeaders): Promise<Response>;
4
+ export declare function performHostedFileRequest<Result>(token: StoredToken, method: "GET" | "POST" | "DELETE", path: string, input: unknown, signal: AbortSignal | undefined, refresh: () => Promise<StoredToken>, proofHeaders: ProofHeaders): Promise<Result>;
5
+ export declare function performHostedFilePartRequest(token: StoredToken, path: string, expectedLength: number, signal: AbortSignal | undefined, refresh: () => Promise<StoredToken>, proofHeaders: ProofHeaders): Promise<ReadableStream<Uint8Array>>;
6
+ export {};