@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.
- package/README.md +108 -38
- package/dist/authorization-url.d.ts +2 -1
- package/dist/authorization-url.js +5 -7
- package/dist/browser/integrity.json +1 -1
- package/dist/browser/mdbase-connect.min.js +3 -3
- package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
- package/dist/collection-client.d.ts +34 -31
- package/dist/collection-client.js +107 -61
- package/dist/connect.d.ts +4 -3
- package/dist/connect.js +53 -38
- package/dist/connection-file-transport.d.ts +31 -0
- package/dist/connection-file-transport.js +42 -0
- package/dist/connection-notifications.js +21 -21
- package/dist/connection-transport.d.ts +2 -0
- package/dist/connection-transport.js +95 -37
- package/dist/connection-types.d.ts +2 -1
- package/dist/connection.d.ts +49 -45
- package/dist/connection.js +73 -33
- package/dist/crypto.d.ts +5 -6
- package/dist/crypto.js +13 -13
- package/dist/errors.d.ts +38 -24
- package/dist/errors.js +130 -71
- package/dist/file-crypto.d.ts +25 -0
- package/dist/file-crypto.js +132 -0
- package/dist/file-sha256.d.ts +12 -0
- package/dist/file-sha256.js +143 -0
- package/dist/file-stream-source.d.ts +17 -0
- package/dist/file-stream-source.js +157 -0
- package/dist/files.d.ts +91 -0
- package/dist/files.js +644 -0
- package/dist/hosted-file-request.d.ts +6 -0
- package/dist/hosted-file-request.js +63 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/internal-types.d.ts +3 -1
- package/dist/local-file-transport.d.ts +30 -0
- package/dist/local-file-transport.js +367 -0
- package/dist/notifications.js +5 -5
- package/dist/operation-helpers.d.ts +2 -2
- package/dist/operation-helpers.js +22 -18
- package/dist/operation-types.d.ts +3 -4
- package/dist/outcomes.d.ts +51 -0
- package/dist/outcomes.js +168 -0
- package/dist/runtime-utils.d.ts +6 -1
- package/dist/runtime-utils.js +90 -11
- package/dist/selection.js +3 -3
- package/dist/session.d.ts +9 -8
- package/dist/session.js +31 -20
- 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 {};
|