@mountra/mountra-sdk 0.1.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 +126 -0
- package/dist/index.cjs +751 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +309 -0
- package/dist/index.d.ts +309 -0
- package/dist/index.js +711 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
// src/upload.ts
|
|
2
|
+
var JUMBO_CHUNK_THRESHOLD_BYTES = 128 * 1024 * 1024;
|
|
3
|
+
var MIN_S3_MULTIPART_PART_SIZE_BYTES = 5 * 1024 * 1024;
|
|
4
|
+
var DEFAULT_S3_MULTIPART_PART_SIZE_BYTES = 16 * 1024 * 1024;
|
|
5
|
+
var MAX_S3_MULTIPART_PARTS = 1e4;
|
|
6
|
+
var MountraApiError = class extends Error {
|
|
7
|
+
status;
|
|
8
|
+
code;
|
|
9
|
+
body;
|
|
10
|
+
constructor(message, input) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "MountraApiError";
|
|
13
|
+
this.status = input.status;
|
|
14
|
+
this.code = input.code;
|
|
15
|
+
this.body = input.body;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
var memoryCheckpointRecords = /* @__PURE__ */ new Map();
|
|
19
|
+
function parseJumboCheckpoint(value) {
|
|
20
|
+
if (!isRecord(value) || value.version !== 1 || typeof value.storageKey !== "string") {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (typeof value.chunkIndex !== "number" || typeof value.chunkSize !== "number" || typeof value.chunkSha256 !== "string" || typeof value.partSize !== "number" || typeof value.uploadId !== "string" || !Array.isArray(value.parts)) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const parts = [];
|
|
27
|
+
for (const part of value.parts) {
|
|
28
|
+
if (!isRecord(part)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (typeof part.partNumber !== "number" || typeof part.etag !== "string" || typeof part.size !== "number" || typeof part.sha256 !== "string") {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
parts.push({
|
|
35
|
+
partNumber: part.partNumber,
|
|
36
|
+
etag: part.etag,
|
|
37
|
+
size: part.size,
|
|
38
|
+
sha256: part.sha256
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
version: 1,
|
|
43
|
+
storageKey: value.storageKey,
|
|
44
|
+
chunkIndex: value.chunkIndex,
|
|
45
|
+
chunkSize: value.chunkSize,
|
|
46
|
+
chunkSha256: value.chunkSha256,
|
|
47
|
+
partSize: value.partSize,
|
|
48
|
+
uploadId: value.uploadId,
|
|
49
|
+
completed: value.completed === true,
|
|
50
|
+
completing: value.completing === true,
|
|
51
|
+
parts
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
var InMemoryJumboChunkCheckpointStore = class {
|
|
55
|
+
records = /* @__PURE__ */ new Map();
|
|
56
|
+
async load(key) {
|
|
57
|
+
return this.records.get(key) ?? null;
|
|
58
|
+
}
|
|
59
|
+
async save(key, checkpoint) {
|
|
60
|
+
this.records.set(key, {
|
|
61
|
+
...checkpoint,
|
|
62
|
+
parts: checkpoint.parts.map((part) => ({ ...part }))
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async remove(key) {
|
|
66
|
+
this.records.delete(key);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
var LocalStorageJumboChunkCheckpointStore = class {
|
|
70
|
+
prefix;
|
|
71
|
+
constructor(prefix = "mountra-sdk:jumbo:") {
|
|
72
|
+
this.prefix = prefix;
|
|
73
|
+
}
|
|
74
|
+
async load(key) {
|
|
75
|
+
const storageKey = this.storageKey(key);
|
|
76
|
+
const raw = this.getStorage()?.getItem(storageKey) ?? memoryCheckpointRecords.get(storageKey);
|
|
77
|
+
if (!raw) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
return parseJumboCheckpoint(JSON.parse(raw));
|
|
82
|
+
} catch {
|
|
83
|
+
await this.remove(key);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async save(key, checkpoint) {
|
|
88
|
+
const storageKey = this.storageKey(key);
|
|
89
|
+
const serialized = JSON.stringify(checkpoint);
|
|
90
|
+
const storage = this.getStorage();
|
|
91
|
+
if (storage) {
|
|
92
|
+
storage.setItem(storageKey, serialized);
|
|
93
|
+
} else {
|
|
94
|
+
memoryCheckpointRecords.set(storageKey, serialized);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async remove(key) {
|
|
98
|
+
const storageKey = this.storageKey(key);
|
|
99
|
+
this.getStorage()?.removeItem(storageKey);
|
|
100
|
+
memoryCheckpointRecords.delete(storageKey);
|
|
101
|
+
}
|
|
102
|
+
storageKey(key) {
|
|
103
|
+
return `${this.prefix}${key}`;
|
|
104
|
+
}
|
|
105
|
+
getStorage() {
|
|
106
|
+
try {
|
|
107
|
+
return globalThis.localStorage ?? null;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
var S3MultipartUploader = class {
|
|
114
|
+
bucket;
|
|
115
|
+
client;
|
|
116
|
+
constructor(input) {
|
|
117
|
+
const bucket = input.bucket.trim();
|
|
118
|
+
if (!bucket) {
|
|
119
|
+
throw new TypeError("S3 bucket is required");
|
|
120
|
+
}
|
|
121
|
+
this.bucket = bucket;
|
|
122
|
+
this.client = input.client;
|
|
123
|
+
}
|
|
124
|
+
createMultipartUpload(input) {
|
|
125
|
+
return this.client.createMultipartUpload({
|
|
126
|
+
bucket: this.bucket,
|
|
127
|
+
key: input.storageKey,
|
|
128
|
+
contentType: input.contentType,
|
|
129
|
+
signal: input.signal
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
uploadPart(input) {
|
|
133
|
+
return this.client.uploadPart({
|
|
134
|
+
bucket: this.bucket,
|
|
135
|
+
key: input.storageKey,
|
|
136
|
+
uploadId: input.uploadId,
|
|
137
|
+
partNumber: input.partNumber,
|
|
138
|
+
body: input.body,
|
|
139
|
+
signal: input.signal
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
completeMultipartUpload(input) {
|
|
143
|
+
return this.client.completeMultipartUpload({
|
|
144
|
+
bucket: this.bucket,
|
|
145
|
+
key: input.storageKey,
|
|
146
|
+
uploadId: input.uploadId,
|
|
147
|
+
parts: input.parts,
|
|
148
|
+
signal: input.signal
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
abortMultipartUpload(input) {
|
|
152
|
+
if (!this.client.abortMultipartUpload) {
|
|
153
|
+
return Promise.resolve();
|
|
154
|
+
}
|
|
155
|
+
return this.client.abortMultipartUpload({
|
|
156
|
+
bucket: this.bucket,
|
|
157
|
+
key: input.storageKey,
|
|
158
|
+
uploadId: input.uploadId,
|
|
159
|
+
signal: input.signal
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
function assertPositiveInteger(value, name) {
|
|
164
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
165
|
+
throw new RangeError(`${name} must be a positive safe integer`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function assertMultipartPartSize(value) {
|
|
169
|
+
assertPositiveInteger(value, "jumboPartSize");
|
|
170
|
+
if (value < MIN_S3_MULTIPART_PART_SIZE_BYTES) {
|
|
171
|
+
throw new RangeError(
|
|
172
|
+
`jumboPartSize must be at least ${MIN_S3_MULTIPART_PART_SIZE_BYTES} bytes for S3 Multipart uploads`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function normalizeBaseUrl(value) {
|
|
177
|
+
const trimmed = value.trim().replace(/\/+$/, "");
|
|
178
|
+
if (!trimmed) {
|
|
179
|
+
throw new TypeError("baseUrl is required");
|
|
180
|
+
}
|
|
181
|
+
return trimmed;
|
|
182
|
+
}
|
|
183
|
+
function normalizeApiPrefix(value) {
|
|
184
|
+
const trimmed = value.trim();
|
|
185
|
+
if (!trimmed || trimmed === "/") {
|
|
186
|
+
return "";
|
|
187
|
+
}
|
|
188
|
+
return `/${trimmed.replace(/^\/+|\/+$/g, "")}`;
|
|
189
|
+
}
|
|
190
|
+
function getWorkspaceBody(clientSelector, requestSelector) {
|
|
191
|
+
const workspaceCuid = requestSelector.workspaceCuid ?? clientSelector.workspaceCuid;
|
|
192
|
+
const workspaceId = requestSelector.workspaceId ?? clientSelector.workspaceId;
|
|
193
|
+
if (workspaceCuid === void 0 && workspaceId === void 0) {
|
|
194
|
+
throw new TypeError("workspaceCuid or workspaceId is required");
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
...workspaceCuid !== void 0 ? { workspace_cuid: workspaceCuid } : {},
|
|
198
|
+
...workspaceId !== void 0 ? { workspace_id: workspaceId } : {}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function throwIfAborted(signal) {
|
|
202
|
+
if (!signal?.aborted) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
throw new DOMException("The upload was aborted", "AbortError");
|
|
206
|
+
}
|
|
207
|
+
function isRecord(value) {
|
|
208
|
+
return typeof value === "object" && value !== null;
|
|
209
|
+
}
|
|
210
|
+
function parseApiErrorBody(body) {
|
|
211
|
+
if (!isRecord(body)) {
|
|
212
|
+
return {};
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
message: typeof body.error === "string" ? body.error : void 0,
|
|
216
|
+
code: typeof body.code === "string" ? body.code : void 0
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function ensurePrepareResponse(value) {
|
|
220
|
+
if (!isRecord(value) || typeof value.upload_id !== "string") {
|
|
221
|
+
throw new TypeError("Mountra prepare response is invalid");
|
|
222
|
+
}
|
|
223
|
+
if (!Array.isArray(value.upload_targets)) {
|
|
224
|
+
throw new TypeError("Mountra prepare response has no upload targets");
|
|
225
|
+
}
|
|
226
|
+
return value;
|
|
227
|
+
}
|
|
228
|
+
function ensureFinishResponse(value) {
|
|
229
|
+
if (!isRecord(value) || typeof value.upload_id !== "string") {
|
|
230
|
+
throw new TypeError("Mountra finish response is invalid");
|
|
231
|
+
}
|
|
232
|
+
return value;
|
|
233
|
+
}
|
|
234
|
+
function ensureUploadStatusResponse(value) {
|
|
235
|
+
if (!isRecord(value) || typeof value.upload_id !== "string") {
|
|
236
|
+
throw new TypeError("Mountra upload status response is invalid");
|
|
237
|
+
}
|
|
238
|
+
if (!Array.isArray(value.chunks)) {
|
|
239
|
+
throw new TypeError("Mountra upload status response has no chunk states");
|
|
240
|
+
}
|
|
241
|
+
return value;
|
|
242
|
+
}
|
|
243
|
+
function splitFileIntoChunks(file, chunkCount = 1) {
|
|
244
|
+
assertPositiveInteger(chunkCount, "chunkCount");
|
|
245
|
+
if (file.size === 0) {
|
|
246
|
+
return [{ index: 0, offset: 0, size: 0, blob: file.slice(0, 0) }];
|
|
247
|
+
}
|
|
248
|
+
if (chunkCount > file.size) {
|
|
249
|
+
throw new RangeError("chunkCount cannot be greater than the file size in bytes");
|
|
250
|
+
}
|
|
251
|
+
const chunkSize = Math.ceil(file.size / chunkCount);
|
|
252
|
+
const chunks = [];
|
|
253
|
+
for (let index = 0; index < chunkCount; index += 1) {
|
|
254
|
+
const offset = index * chunkSize;
|
|
255
|
+
const end = Math.min(offset + chunkSize, file.size);
|
|
256
|
+
chunks.push({
|
|
257
|
+
index,
|
|
258
|
+
offset,
|
|
259
|
+
size: end - offset,
|
|
260
|
+
blob: file.slice(offset, end)
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return chunks;
|
|
264
|
+
}
|
|
265
|
+
function isJumboChunk(size) {
|
|
266
|
+
return size > JUMBO_CHUNK_THRESHOLD_BYTES;
|
|
267
|
+
}
|
|
268
|
+
async function sha256Hex(blob) {
|
|
269
|
+
const subtle = globalThis.crypto?.subtle;
|
|
270
|
+
if (!subtle) {
|
|
271
|
+
throw new Error("Web Crypto SHA-256 is not available in this environment");
|
|
272
|
+
}
|
|
273
|
+
const digest = await subtle.digest("SHA-256", await blob.arrayBuffer());
|
|
274
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
275
|
+
}
|
|
276
|
+
var MountraClient = class {
|
|
277
|
+
baseUrl;
|
|
278
|
+
apiPrefix;
|
|
279
|
+
clientSelector;
|
|
280
|
+
token;
|
|
281
|
+
headers;
|
|
282
|
+
fetchImpl;
|
|
283
|
+
jumboChunkUploader;
|
|
284
|
+
jumboChunkCheckpointStore;
|
|
285
|
+
jumboPartSize;
|
|
286
|
+
constructor(options) {
|
|
287
|
+
this.baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
288
|
+
this.apiPrefix = normalizeApiPrefix(options.apiPrefix ?? "/api");
|
|
289
|
+
this.clientSelector = {
|
|
290
|
+
workspaceCuid: options.workspaceCuid,
|
|
291
|
+
workspaceId: options.workspaceId
|
|
292
|
+
};
|
|
293
|
+
this.token = options.token;
|
|
294
|
+
this.headers = options.headers;
|
|
295
|
+
this.jumboChunkUploader = options.jumboChunkUploader;
|
|
296
|
+
this.jumboChunkCheckpointStore = options.jumboChunkCheckpointStore ?? new LocalStorageJumboChunkCheckpointStore();
|
|
297
|
+
this.jumboPartSize = options.jumboPartSize ?? DEFAULT_S3_MULTIPART_PART_SIZE_BYTES;
|
|
298
|
+
assertMultipartPartSize(this.jumboPartSize);
|
|
299
|
+
const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
300
|
+
if (!fetchImpl) {
|
|
301
|
+
throw new Error("fetch is not available; provide fetch in MountraClientOptions");
|
|
302
|
+
}
|
|
303
|
+
this.fetchImpl = fetchImpl;
|
|
304
|
+
}
|
|
305
|
+
async prepareUpload(file, options) {
|
|
306
|
+
const chunks = splitFileIntoChunks(file, options.chunkCount ?? 1);
|
|
307
|
+
const chunkHashes = await Promise.all(chunks.map((chunk) => sha256Hex(chunk.blob)));
|
|
308
|
+
const contentHash = await sha256Hex(file);
|
|
309
|
+
const chunkMethod = chunks.length === 1 ? "none" : "fix";
|
|
310
|
+
const contentType = options.contentType ?? ("type" in file ? file.type : void 0);
|
|
311
|
+
throwIfAborted(options.signal);
|
|
312
|
+
const response = ensurePrepareResponse(
|
|
313
|
+
await this.requestJson("/upload/prepare", {
|
|
314
|
+
...getWorkspaceBody(this.clientSelector, options),
|
|
315
|
+
path: options.path,
|
|
316
|
+
size: file.size,
|
|
317
|
+
content_hash: contentHash,
|
|
318
|
+
chunks: chunks.map((chunk, index) => ({
|
|
319
|
+
chunk_index: chunk.index,
|
|
320
|
+
size: chunk.size,
|
|
321
|
+
sha256: chunkHashes[index]
|
|
322
|
+
})),
|
|
323
|
+
chunk_method: chunkMethod,
|
|
324
|
+
...contentType ? { content_type: contentType } : {},
|
|
325
|
+
...options.nameConflictStrategy ? { name_conflict_strategy: options.nameConflictStrategy } : {}
|
|
326
|
+
}, options.signal)
|
|
327
|
+
);
|
|
328
|
+
const context = {
|
|
329
|
+
uploadId: response.upload_id,
|
|
330
|
+
path: response.path,
|
|
331
|
+
fileSize: file.size,
|
|
332
|
+
contentHash,
|
|
333
|
+
chunkMethod: response.chunk_method,
|
|
334
|
+
chunks: chunks.map((chunk, index) => ({
|
|
335
|
+
index: chunk.index,
|
|
336
|
+
offset: chunk.offset,
|
|
337
|
+
size: chunk.size,
|
|
338
|
+
sha256: chunkHashes[index]
|
|
339
|
+
})),
|
|
340
|
+
...contentType ? { contentType } : {}
|
|
341
|
+
};
|
|
342
|
+
await options.onSessionPrepared?.(context);
|
|
343
|
+
return { response, chunks, chunkHashes, contentHash, context };
|
|
344
|
+
}
|
|
345
|
+
async uploadFile(file, options) {
|
|
346
|
+
const prepared = await this.prepareUpload(file, options);
|
|
347
|
+
return this.uploadPreparedChunks(file, prepared.context, prepared.chunks, prepared.response.upload_targets, options);
|
|
348
|
+
}
|
|
349
|
+
/** Generic upload entry point. It selects PUT or S3 Multipart per chunk. */
|
|
350
|
+
async upload(file, options) {
|
|
351
|
+
return this.uploadFile(file, options);
|
|
352
|
+
}
|
|
353
|
+
async getUploadStatus(uploadId, selector = {}, signal) {
|
|
354
|
+
if (!uploadId.trim()) {
|
|
355
|
+
throw new TypeError("uploadId is required");
|
|
356
|
+
}
|
|
357
|
+
return ensureUploadStatusResponse(
|
|
358
|
+
await this.requestJson("/upload/status", {
|
|
359
|
+
...getWorkspaceBody(this.clientSelector, selector),
|
|
360
|
+
upload_id: uploadId
|
|
361
|
+
}, signal)
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Resume a previously prepared session. The caller must restore the same
|
|
366
|
+
* file/blob separately; this context only describes how that file was cut.
|
|
367
|
+
*/
|
|
368
|
+
async resumeUpload(file, context, options = {}) {
|
|
369
|
+
this.assertUploadContext(context, file);
|
|
370
|
+
const actualChunkHashes = await Promise.all(
|
|
371
|
+
context.chunks.map((chunk) => sha256Hex(file.slice(chunk.offset, chunk.offset + chunk.size)))
|
|
372
|
+
);
|
|
373
|
+
if (actualChunkHashes.some(
|
|
374
|
+
(hash, index) => hash !== context.chunks[index]?.sha256
|
|
375
|
+
) || await sha256Hex(file) !== context.contentHash) {
|
|
376
|
+
throw new Error("The restored file does not match the upload context hashes");
|
|
377
|
+
}
|
|
378
|
+
const chunks = context.chunks.map((chunk) => ({
|
|
379
|
+
index: chunk.index,
|
|
380
|
+
offset: chunk.offset,
|
|
381
|
+
size: chunk.size,
|
|
382
|
+
blob: file.slice(chunk.offset, chunk.offset + chunk.size)
|
|
383
|
+
}));
|
|
384
|
+
const status = await this.getUploadStatus(context.uploadId, options, options.signal);
|
|
385
|
+
if (status.upload_id !== context.uploadId || status.size !== context.fileSize) {
|
|
386
|
+
throw new Error("Mountra upload status does not match the saved upload context");
|
|
387
|
+
}
|
|
388
|
+
if (status.chunks.length !== context.chunks.length) {
|
|
389
|
+
throw new Error("Mountra upload status has a different chunk layout");
|
|
390
|
+
}
|
|
391
|
+
for (const [index, chunk] of status.chunks.entries()) {
|
|
392
|
+
const savedChunk = context.chunks[index];
|
|
393
|
+
if (chunk.chunk_index !== savedChunk.index || chunk.offset !== savedChunk.offset || chunk.size !== savedChunk.size || chunk.sha256 !== savedChunk.sha256) {
|
|
394
|
+
throw new Error("Mountra upload status has a different chunk layout");
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return this.uploadPreparedChunks(file, context, chunks, status.chunks, options);
|
|
398
|
+
}
|
|
399
|
+
async uploadPreparedChunks(file, context, chunks, targets, options) {
|
|
400
|
+
const orderedTargets = [...targets].sort((left, right) => left.chunk_index - right.chunk_index);
|
|
401
|
+
if (orderedTargets.length !== chunks.length) {
|
|
402
|
+
throw new Error(
|
|
403
|
+
`Mountra returned ${orderedTargets.length} upload targets for ${chunks.length} chunks`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
let uploadedBytes = 0;
|
|
407
|
+
let uploadedChunks = 0;
|
|
408
|
+
const jumboCheckpointKeys = /* @__PURE__ */ new Set();
|
|
409
|
+
for (const [position, target] of orderedTargets.entries()) {
|
|
410
|
+
const chunk = chunks[target.chunk_index];
|
|
411
|
+
if (!chunk || target.chunk_index !== position) {
|
|
412
|
+
throw new Error("Mountra upload targets must contain contiguous chunk indexes");
|
|
413
|
+
}
|
|
414
|
+
throwIfAborted(options.signal);
|
|
415
|
+
if (isJumboChunk(chunk.size)) {
|
|
416
|
+
const checkpointKey = this.buildJumboCheckpointKey(context, target, options);
|
|
417
|
+
jumboCheckpointKeys.add(checkpointKey);
|
|
418
|
+
if (target.upload_required) {
|
|
419
|
+
await this.uploadJumboChunk(
|
|
420
|
+
chunk,
|
|
421
|
+
target,
|
|
422
|
+
context,
|
|
423
|
+
checkpointKey,
|
|
424
|
+
options,
|
|
425
|
+
uploadedBytes,
|
|
426
|
+
uploadedChunks,
|
|
427
|
+
orderedTargets.length
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
} else if (target.upload_required) {
|
|
431
|
+
if (!target.put_url) {
|
|
432
|
+
throw new Error(`Mountra upload target ${target.chunk_index} has no signed URL`);
|
|
433
|
+
}
|
|
434
|
+
const response = await this.fetchImpl(target.put_url, {
|
|
435
|
+
method: target.put_method ?? "PUT",
|
|
436
|
+
headers: target.put_headers ?? {},
|
|
437
|
+
body: chunk.blob,
|
|
438
|
+
...options.signal ? { signal: options.signal } : {}
|
|
439
|
+
});
|
|
440
|
+
if (!response.ok) {
|
|
441
|
+
throw new MountraApiError(
|
|
442
|
+
`Signed upload for chunk ${target.chunk_index} failed with HTTP ${response.status}`,
|
|
443
|
+
{ status: response.status }
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
uploadedBytes += chunk.size;
|
|
448
|
+
uploadedChunks += 1;
|
|
449
|
+
options.onProgress?.({
|
|
450
|
+
phase: "uploading",
|
|
451
|
+
uploadedBytes,
|
|
452
|
+
totalBytes: file.size,
|
|
453
|
+
uploadedChunks,
|
|
454
|
+
totalChunks: orderedTargets.length
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
throwIfAborted(options.signal);
|
|
458
|
+
options.onProgress?.({
|
|
459
|
+
phase: "finishing",
|
|
460
|
+
uploadedBytes: file.size,
|
|
461
|
+
totalBytes: file.size,
|
|
462
|
+
uploadedChunks: orderedTargets.length,
|
|
463
|
+
totalChunks: orderedTargets.length
|
|
464
|
+
});
|
|
465
|
+
const finished = ensureFinishResponse(
|
|
466
|
+
await this.requestJson(
|
|
467
|
+
"/upload/finish",
|
|
468
|
+
{
|
|
469
|
+
...getWorkspaceBody(this.clientSelector, options),
|
|
470
|
+
upload_id: context.uploadId
|
|
471
|
+
},
|
|
472
|
+
options.signal
|
|
473
|
+
)
|
|
474
|
+
);
|
|
475
|
+
if (finished.finish_status !== "completed") {
|
|
476
|
+
throw new Error(`Mountra upload did not finish successfully: ${finished.finish_status}`);
|
|
477
|
+
}
|
|
478
|
+
await Promise.all(
|
|
479
|
+
[...jumboCheckpointKeys].map(
|
|
480
|
+
(checkpointKey) => this.jumboChunkCheckpointStore.remove(checkpointKey)
|
|
481
|
+
)
|
|
482
|
+
);
|
|
483
|
+
return finished;
|
|
484
|
+
}
|
|
485
|
+
buildJumboCheckpointKey(context, target, selector) {
|
|
486
|
+
const workspaceCuid = selector.workspaceCuid ?? this.clientSelector.workspaceCuid ?? "";
|
|
487
|
+
const workspaceId = selector.workspaceId ?? this.clientSelector.workspaceId ?? "";
|
|
488
|
+
return encodeURIComponent(
|
|
489
|
+
[
|
|
490
|
+
this.baseUrl,
|
|
491
|
+
workspaceCuid,
|
|
492
|
+
workspaceId,
|
|
493
|
+
target.storage_key,
|
|
494
|
+
target.chunk_index,
|
|
495
|
+
context.chunks[target.chunk_index]?.sha256 ?? ""
|
|
496
|
+
].join("|")
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
async uploadJumboChunk(chunk, target, context, checkpointKey, options, baseUploadedBytes, uploadedChunks, totalChunks) {
|
|
500
|
+
const uploader = this.jumboChunkUploader;
|
|
501
|
+
if (!uploader) {
|
|
502
|
+
throw new Error(
|
|
503
|
+
`Chunk ${target.chunk_index} is larger than 128 MiB; configure jumboChunkUploader to use S3 Multipart uploads`
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
const totalParts = Math.ceil(chunk.size / this.jumboPartSize);
|
|
507
|
+
if (totalParts > MAX_S3_MULTIPART_PARTS) {
|
|
508
|
+
throw new RangeError(
|
|
509
|
+
`Jumbo chunk requires ${totalParts} parts, exceeding S3's ${MAX_S3_MULTIPART_PARTS}-part limit`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
let checkpoint = await this.jumboChunkCheckpointStore.load(checkpointKey);
|
|
513
|
+
if (checkpoint && !this.isCompatibleJumboCheckpoint(checkpoint, chunk, target, context)) {
|
|
514
|
+
await this.jumboChunkCheckpointStore.remove(checkpointKey);
|
|
515
|
+
checkpoint = null;
|
|
516
|
+
}
|
|
517
|
+
if (!checkpoint) {
|
|
518
|
+
const created = await uploader.createMultipartUpload({
|
|
519
|
+
storageKey: target.storage_key,
|
|
520
|
+
contentType: context.contentType,
|
|
521
|
+
signal: options.signal
|
|
522
|
+
});
|
|
523
|
+
if (!created.uploadId.trim()) {
|
|
524
|
+
throw new Error(`S3 Multipart create returned no upload id for chunk ${target.chunk_index}`);
|
|
525
|
+
}
|
|
526
|
+
checkpoint = {
|
|
527
|
+
version: 1,
|
|
528
|
+
storageKey: target.storage_key,
|
|
529
|
+
chunkIndex: target.chunk_index,
|
|
530
|
+
chunkSize: chunk.size,
|
|
531
|
+
chunkSha256: context.chunks[target.chunk_index]?.sha256 ?? "",
|
|
532
|
+
partSize: this.jumboPartSize,
|
|
533
|
+
uploadId: created.uploadId,
|
|
534
|
+
completed: false,
|
|
535
|
+
completing: false,
|
|
536
|
+
parts: []
|
|
537
|
+
};
|
|
538
|
+
await this.jumboChunkCheckpointStore.save(checkpointKey, checkpoint);
|
|
539
|
+
}
|
|
540
|
+
if (checkpoint.completed) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (checkpoint.completing) {
|
|
544
|
+
const status = await this.getUploadStatus(context.uploadId, options, options.signal);
|
|
545
|
+
const statusChunk = status.chunks.find(
|
|
546
|
+
(candidate) => candidate.chunk_index === target.chunk_index
|
|
547
|
+
);
|
|
548
|
+
if (statusChunk?.status === "uploaded") {
|
|
549
|
+
checkpoint.completed = true;
|
|
550
|
+
checkpoint.completing = false;
|
|
551
|
+
await this.jumboChunkCheckpointStore.save(checkpointKey, checkpoint);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
checkpoint.completing = false;
|
|
555
|
+
await this.jumboChunkCheckpointStore.save(checkpointKey, checkpoint);
|
|
556
|
+
}
|
|
557
|
+
const parts = new Map(checkpoint.parts.map((part) => [part.partNumber, part]));
|
|
558
|
+
let uploadedBytes = 0;
|
|
559
|
+
let uploadedParts = 0;
|
|
560
|
+
for (let partNumber = 1; partNumber <= totalParts; partNumber += 1) {
|
|
561
|
+
throwIfAborted(options.signal);
|
|
562
|
+
const start = (partNumber - 1) * this.jumboPartSize;
|
|
563
|
+
const partBlob = chunk.blob.slice(start, Math.min(start + this.jumboPartSize, chunk.size));
|
|
564
|
+
const partHash = await sha256Hex(partBlob);
|
|
565
|
+
const savedPart = parts.get(partNumber);
|
|
566
|
+
const part = savedPart && savedPart.size === partBlob.size && savedPart.sha256 === partHash && savedPart.etag ? savedPart : await this.uploadJumboPart(
|
|
567
|
+
uploader,
|
|
568
|
+
target.storage_key,
|
|
569
|
+
checkpoint.uploadId,
|
|
570
|
+
partNumber,
|
|
571
|
+
partBlob,
|
|
572
|
+
partHash,
|
|
573
|
+
options.signal
|
|
574
|
+
);
|
|
575
|
+
parts.set(partNumber, part);
|
|
576
|
+
checkpoint.parts = [...parts.values()].sort(
|
|
577
|
+
(left, right) => left.partNumber - right.partNumber
|
|
578
|
+
);
|
|
579
|
+
await this.jumboChunkCheckpointStore.save(checkpointKey, checkpoint);
|
|
580
|
+
uploadedBytes += partBlob.size;
|
|
581
|
+
uploadedParts += 1;
|
|
582
|
+
options.onProgress?.({
|
|
583
|
+
phase: "uploading",
|
|
584
|
+
uploadedBytes: baseUploadedBytes + uploadedBytes,
|
|
585
|
+
totalBytes: context.fileSize,
|
|
586
|
+
uploadedChunks,
|
|
587
|
+
totalChunks,
|
|
588
|
+
uploadedParts,
|
|
589
|
+
totalParts
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
checkpoint.completing = true;
|
|
593
|
+
await this.jumboChunkCheckpointStore.save(checkpointKey, checkpoint);
|
|
594
|
+
try {
|
|
595
|
+
await uploader.completeMultipartUpload({
|
|
596
|
+
storageKey: target.storage_key,
|
|
597
|
+
uploadId: checkpoint.uploadId,
|
|
598
|
+
parts: checkpoint.parts.map(({ partNumber, etag }) => ({ partNumber, etag })),
|
|
599
|
+
signal: options.signal
|
|
600
|
+
});
|
|
601
|
+
} catch (error) {
|
|
602
|
+
checkpoint.completing = false;
|
|
603
|
+
await this.jumboChunkCheckpointStore.save(checkpointKey, checkpoint);
|
|
604
|
+
throw error;
|
|
605
|
+
}
|
|
606
|
+
checkpoint.completed = true;
|
|
607
|
+
checkpoint.completing = false;
|
|
608
|
+
await this.jumboChunkCheckpointStore.save(checkpointKey, checkpoint);
|
|
609
|
+
}
|
|
610
|
+
async uploadJumboPart(uploader, storageKey, uploadId, partNumber, body, sha256, signal) {
|
|
611
|
+
const result = await uploader.uploadPart({
|
|
612
|
+
storageKey,
|
|
613
|
+
uploadId,
|
|
614
|
+
partNumber,
|
|
615
|
+
body,
|
|
616
|
+
signal
|
|
617
|
+
});
|
|
618
|
+
if (!result.etag.trim()) {
|
|
619
|
+
throw new Error(`S3 Multipart upload returned no ETag for part ${partNumber}`);
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
partNumber,
|
|
623
|
+
etag: result.etag,
|
|
624
|
+
size: body.size,
|
|
625
|
+
sha256
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
isCompatibleJumboCheckpoint(checkpoint, chunk, target, context) {
|
|
629
|
+
return checkpoint.version === 1 && checkpoint.storageKey === target.storage_key && checkpoint.chunkIndex === target.chunk_index && checkpoint.chunkSize === chunk.size && checkpoint.chunkSha256 === (context.chunks[target.chunk_index]?.sha256 ?? "") && checkpoint.partSize === this.jumboPartSize && checkpoint.uploadId.trim().length > 0;
|
|
630
|
+
}
|
|
631
|
+
assertUploadContext(context, file) {
|
|
632
|
+
if (!context.uploadId.trim()) {
|
|
633
|
+
throw new TypeError("upload context uploadId is required");
|
|
634
|
+
}
|
|
635
|
+
if (context.fileSize !== file.size) {
|
|
636
|
+
throw new Error("The restored file does not match the upload context size");
|
|
637
|
+
}
|
|
638
|
+
if (!Array.isArray(context.chunks) || context.chunks.length === 0) {
|
|
639
|
+
throw new TypeError("upload context must contain chunks");
|
|
640
|
+
}
|
|
641
|
+
let expectedOffset = 0;
|
|
642
|
+
for (const [index, chunk] of context.chunks.entries()) {
|
|
643
|
+
if (chunk.index !== index || chunk.offset !== expectedOffset || chunk.offset < 0 || chunk.size < 0 || chunk.offset + chunk.size > file.size || typeof chunk.sha256 !== "string") {
|
|
644
|
+
throw new TypeError("upload context contains an invalid chunk layout");
|
|
645
|
+
}
|
|
646
|
+
expectedOffset += chunk.size;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
async finishUpload(uploadId, selector = {}) {
|
|
650
|
+
if (!uploadId.trim()) {
|
|
651
|
+
throw new TypeError("uploadId is required");
|
|
652
|
+
}
|
|
653
|
+
return ensureFinishResponse(
|
|
654
|
+
await this.requestJson("/upload/finish", {
|
|
655
|
+
...getWorkspaceBody(this.clientSelector, selector),
|
|
656
|
+
upload_id: uploadId
|
|
657
|
+
})
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
endpoint(path) {
|
|
661
|
+
return `${this.baseUrl}${this.apiPrefix}${path}`;
|
|
662
|
+
}
|
|
663
|
+
async requestJson(path, body, signal) {
|
|
664
|
+
const headers = new Headers(this.headers);
|
|
665
|
+
headers.set("Content-Type", "application/json");
|
|
666
|
+
const token = typeof this.token === "function" ? await this.token() : this.token;
|
|
667
|
+
if (token) {
|
|
668
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
669
|
+
}
|
|
670
|
+
const response = await this.fetchImpl(this.endpoint(path), {
|
|
671
|
+
method: "POST",
|
|
672
|
+
headers,
|
|
673
|
+
body: JSON.stringify(body),
|
|
674
|
+
...signal ? { signal } : {}
|
|
675
|
+
});
|
|
676
|
+
let responseBody;
|
|
677
|
+
try {
|
|
678
|
+
responseBody = await response.json();
|
|
679
|
+
} catch {
|
|
680
|
+
responseBody = void 0;
|
|
681
|
+
}
|
|
682
|
+
if (!response.ok) {
|
|
683
|
+
const error = parseApiErrorBody(responseBody);
|
|
684
|
+
throw new MountraApiError(
|
|
685
|
+
error.message ?? `Mountra API request failed with HTTP ${response.status}`,
|
|
686
|
+
{ status: response.status, code: error.code, body: responseBody }
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
return responseBody;
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
function createMountraClient(options) {
|
|
693
|
+
return new MountraClient(options);
|
|
694
|
+
}
|
|
695
|
+
export {
|
|
696
|
+
DEFAULT_S3_MULTIPART_PART_SIZE_BYTES,
|
|
697
|
+
InMemoryJumboChunkCheckpointStore,
|
|
698
|
+
JUMBO_CHUNK_THRESHOLD_BYTES,
|
|
699
|
+
LocalStorageJumboChunkCheckpointStore,
|
|
700
|
+
MAX_S3_MULTIPART_PARTS,
|
|
701
|
+
MIN_S3_MULTIPART_PART_SIZE_BYTES,
|
|
702
|
+
MountraApiError,
|
|
703
|
+
MountraClient,
|
|
704
|
+
MountraClient as MountraSDK,
|
|
705
|
+
S3MultipartUploader,
|
|
706
|
+
createMountraClient,
|
|
707
|
+
isJumboChunk,
|
|
708
|
+
sha256Hex,
|
|
709
|
+
splitFileIntoChunks
|
|
710
|
+
};
|
|
711
|
+
//# sourceMappingURL=index.js.map
|