@mountra/mountra-sdk 0.1.0 → 0.2.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/dist/index.cjs CHANGED
@@ -21,13 +21,18 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var src_exports = {};
22
22
  __export(src_exports, {
23
23
  DEFAULT_S3_MULTIPART_PART_SIZE_BYTES: () => DEFAULT_S3_MULTIPART_PART_SIZE_BYTES,
24
+ DownloadHttpError: () => DownloadHttpError,
25
+ InMemoryDownloadCheckpointStore: () => InMemoryDownloadCheckpointStore,
24
26
  InMemoryJumboChunkCheckpointStore: () => InMemoryJumboChunkCheckpointStore,
27
+ IndexedDBDownloadCheckpointStore: () => IndexedDBDownloadCheckpointStore,
25
28
  JUMBO_CHUNK_THRESHOLD_BYTES: () => JUMBO_CHUNK_THRESHOLD_BYTES,
26
29
  LocalStorageJumboChunkCheckpointStore: () => LocalStorageJumboChunkCheckpointStore,
27
30
  MAX_S3_MULTIPART_PARTS: () => MAX_S3_MULTIPART_PARTS,
28
31
  MIN_S3_MULTIPART_PART_SIZE_BYTES: () => MIN_S3_MULTIPART_PART_SIZE_BYTES,
29
32
  MountraApiError: () => MountraApiError,
30
33
  MountraClient: () => MountraClient,
34
+ MountraDownloader: () => MountraDownloader,
35
+ MountraFile: () => MountraFile,
31
36
  MountraSDK: () => MountraClient,
32
37
  S3MultipartUploader: () => S3MultipartUploader,
33
38
  createMountraClient: () => createMountraClient,
@@ -37,6 +42,916 @@ __export(src_exports, {
37
42
  });
38
43
  module.exports = __toCommonJS(src_exports);
39
44
 
45
+ // src/download.ts
46
+ function copyCheckpoint(checkpoint) {
47
+ return {
48
+ ...checkpoint,
49
+ request: { ...checkpoint.request },
50
+ chunks: checkpoint.chunks.map((chunk) => ({ ...chunk }))
51
+ };
52
+ }
53
+ var InMemoryDownloadCheckpointStore = class {
54
+ records = /* @__PURE__ */ new Map();
55
+ async load(key) {
56
+ const value = this.records.get(key);
57
+ return value ? copyCheckpoint(value) : null;
58
+ }
59
+ async save(key, checkpoint) {
60
+ this.records.set(key, copyCheckpoint(checkpoint));
61
+ }
62
+ async remove(key) {
63
+ this.records.delete(key);
64
+ }
65
+ async list() {
66
+ return [...this.records.values()].map(copyCheckpoint);
67
+ }
68
+ };
69
+ var DOWNLOAD_DATABASE_NAME = "mountra-sdk";
70
+ var DOWNLOAD_DATABASE_VERSION = 1;
71
+ var DOWNLOAD_STORE_NAME = "download-checkpoints";
72
+ function getIndexedDb() {
73
+ return globalThis.indexedDB ?? null;
74
+ }
75
+ var IndexedDBDownloadCheckpointStore = class {
76
+ fallback = new InMemoryDownloadCheckpointStore();
77
+ databasePromise;
78
+ async load(key) {
79
+ const database = await this.openDatabase();
80
+ if (!database) {
81
+ return this.fallback.load(key);
82
+ }
83
+ try {
84
+ const value = await this.request(
85
+ database,
86
+ "readonly",
87
+ (store) => store.get(key)
88
+ );
89
+ return value ? copyCheckpoint(value) : null;
90
+ } catch {
91
+ return this.fallback.load(key);
92
+ }
93
+ }
94
+ async save(key, checkpoint) {
95
+ const value = copyCheckpoint(checkpoint);
96
+ const database = await this.openDatabase();
97
+ if (!database) {
98
+ await this.fallback.save(key, value);
99
+ return;
100
+ }
101
+ try {
102
+ await this.request(database, "readwrite", (store) => store.put(value, key));
103
+ } catch {
104
+ await this.fallback.save(key, value);
105
+ }
106
+ }
107
+ async remove(key) {
108
+ const database = await this.openDatabase();
109
+ if (!database) {
110
+ await this.fallback.remove(key);
111
+ return;
112
+ }
113
+ try {
114
+ await this.request(database, "readwrite", (store) => store.delete(key));
115
+ } catch {
116
+ await this.fallback.remove(key);
117
+ }
118
+ }
119
+ async list() {
120
+ const database = await this.openDatabase();
121
+ if (!database) {
122
+ return this.fallback.list();
123
+ }
124
+ try {
125
+ const values = await this.request(
126
+ database,
127
+ "readonly",
128
+ (store) => store.getAll()
129
+ );
130
+ return values.map(copyCheckpoint);
131
+ } catch {
132
+ return this.fallback.list();
133
+ }
134
+ }
135
+ async openDatabase() {
136
+ if (!this.databasePromise) {
137
+ const indexedDb = getIndexedDb();
138
+ if (!indexedDb) {
139
+ this.databasePromise = Promise.resolve(null);
140
+ } else {
141
+ this.databasePromise = new Promise((resolve) => {
142
+ let request;
143
+ try {
144
+ request = indexedDb.open(DOWNLOAD_DATABASE_NAME, DOWNLOAD_DATABASE_VERSION);
145
+ } catch {
146
+ resolve(null);
147
+ return;
148
+ }
149
+ request.onupgradeneeded = () => {
150
+ if (!request.result.objectStoreNames.contains(DOWNLOAD_STORE_NAME)) {
151
+ request.result.createObjectStore(DOWNLOAD_STORE_NAME);
152
+ }
153
+ };
154
+ request.onsuccess = () => resolve(request.result);
155
+ request.onerror = () => resolve(null);
156
+ request.onblocked = () => resolve(null);
157
+ });
158
+ }
159
+ }
160
+ return this.databasePromise;
161
+ }
162
+ request(database, mode, createRequest) {
163
+ return new Promise((resolve, reject) => {
164
+ let transaction;
165
+ let request;
166
+ try {
167
+ transaction = database.transaction(DOWNLOAD_STORE_NAME, mode);
168
+ request = createRequest(transaction.objectStore(DOWNLOAD_STORE_NAME));
169
+ } catch (error) {
170
+ reject(error);
171
+ return;
172
+ }
173
+ let result;
174
+ request.onsuccess = () => {
175
+ result = request.result;
176
+ };
177
+ request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
178
+ transaction.oncomplete = () => resolve(result);
179
+ transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed"));
180
+ transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted"));
181
+ });
182
+ }
183
+ };
184
+ var DownloadHttpError = class extends Error {
185
+ status;
186
+ constructor(status, message) {
187
+ super(message);
188
+ this.name = "DownloadHttpError";
189
+ this.status = status;
190
+ }
191
+ };
192
+ function getShowSaveFilePicker() {
193
+ return globalThis.showSaveFilePicker ?? null;
194
+ }
195
+ function isRecord(value) {
196
+ return typeof value === "object" && value !== null;
197
+ }
198
+ function readSafeNumber(value, field) {
199
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
200
+ throw new TypeError(`Mountra export response field ${field} is invalid`);
201
+ }
202
+ return value;
203
+ }
204
+ function readString(value, field) {
205
+ if (typeof value !== "string" || !value) {
206
+ throw new TypeError(`Mountra export response field ${field} is invalid`);
207
+ }
208
+ return value;
209
+ }
210
+ function ensureExportFileResponse(value) {
211
+ if (!isRecord(value)) {
212
+ throw new TypeError("Mountra export response is invalid");
213
+ }
214
+ const response = {
215
+ path: readString(value.path, "path"),
216
+ storageKey: readString(value.storage_key, "storage_key"),
217
+ url: readString(value.url, "url"),
218
+ timeout: readSafeNumber(value.timeout, "timeout"),
219
+ size: readSafeNumber(value.size, "size")
220
+ };
221
+ try {
222
+ new URL(response.url);
223
+ } catch {
224
+ throw new TypeError("Mountra export response field url is invalid");
225
+ }
226
+ if (typeof value.content_type === "string" && value.content_type) {
227
+ response.contentType = value.content_type;
228
+ }
229
+ if (typeof value.chunk_method === "string") {
230
+ response.chunkMethod = value.chunk_method;
231
+ }
232
+ if (typeof value.is_fast_path === "boolean") {
233
+ response.isFastPath = value.is_fast_path;
234
+ }
235
+ if (Array.isArray(value.chunks)) {
236
+ response.chunks = value.chunks.map((rawChunk, position) => {
237
+ if (!isRecord(rawChunk)) {
238
+ throw new TypeError(`Mountra export response chunk ${position} is invalid`);
239
+ }
240
+ const chunk = {
241
+ index: readSafeNumber(rawChunk.index, `chunks[${position}].index`),
242
+ offset: readSafeNumber(rawChunk.offset, `chunks[${position}].offset`),
243
+ size: readSafeNumber(rawChunk.size, `chunks[${position}].size`),
244
+ storageKey: readString(rawChunk.storage_key, `chunks[${position}].storage_key`),
245
+ url: readString(rawChunk.url, `chunks[${position}].url`)
246
+ };
247
+ try {
248
+ new URL(chunk.url);
249
+ } catch {
250
+ throw new TypeError(`Mountra export response chunk ${position} URL is invalid`);
251
+ }
252
+ return chunk;
253
+ });
254
+ response.chunks.sort((left, right) => left.index - right.index);
255
+ response.chunks.forEach((chunk, index) => {
256
+ if (chunk.index !== index || chunk.offset + chunk.size > response.size || index > 0 && response.chunks[index - 1].offset + response.chunks[index - 1].size !== chunk.offset) {
257
+ throw new TypeError("Mountra export response has an invalid chunk layout");
258
+ }
259
+ });
260
+ const finalChunk = response.chunks[response.chunks.length - 1];
261
+ if (finalChunk && finalChunk.offset + finalChunk.size !== response.size) {
262
+ throw new TypeError("Mountra export response chunks do not cover the complete file");
263
+ }
264
+ }
265
+ return response;
266
+ }
267
+ function validateRetryOption(value, name) {
268
+ if (value === void 0) {
269
+ return void 0;
270
+ }
271
+ if (!Number.isSafeInteger(value) || value < 0) {
272
+ throw new RangeError(`${name} must be a non-negative safe integer`);
273
+ }
274
+ return value;
275
+ }
276
+ function throwIfAborted(signal) {
277
+ if (signal?.aborted) {
278
+ throw new DOMException("The download was aborted", "AbortError");
279
+ }
280
+ }
281
+ function isAbortError(error, signal) {
282
+ return signal?.aborted === true || error instanceof DOMException && error.name === "AbortError";
283
+ }
284
+ function isRetryableError(error) {
285
+ if (error instanceof DownloadHttpError) {
286
+ return [408, 425, 429, 500, 502, 503, 504].includes(error.status);
287
+ }
288
+ return true;
289
+ }
290
+ function waitForRetry(delayMs, signal) {
291
+ if (delayMs <= 0) {
292
+ throwIfAborted(signal);
293
+ return Promise.resolve();
294
+ }
295
+ return new Promise((resolve, reject) => {
296
+ let timer;
297
+ const onAbort = () => {
298
+ if (timer !== void 0) {
299
+ clearTimeout(timer);
300
+ }
301
+ signal?.removeEventListener("abort", onAbort);
302
+ reject(new DOMException("The download was aborted", "AbortError"));
303
+ };
304
+ timer = setTimeout(() => {
305
+ signal?.removeEventListener("abort", onAbort);
306
+ resolve();
307
+ }, delayMs);
308
+ signal?.addEventListener("abort", onAbort, { once: true });
309
+ if (signal?.aborted) {
310
+ onAbort();
311
+ }
312
+ });
313
+ }
314
+ var SpeedWindow = class {
315
+ samples = [];
316
+ record(bytes, at) {
317
+ this.samples.push({ at, bytes });
318
+ const cutoff = at - 1e3;
319
+ while (this.samples.length > 1 && this.samples[0].at < cutoff) {
320
+ this.samples.shift();
321
+ }
322
+ const first = this.samples[0];
323
+ if (!first || first.bytes === bytes) {
324
+ return 0;
325
+ }
326
+ const seconds = Math.max((at - first.at) / 1e3, 1e-3);
327
+ return (bytes - first.bytes) / seconds;
328
+ }
329
+ };
330
+ var BlobDownloadSink = class {
331
+ parts = [];
332
+ async write(bytes) {
333
+ this.parts.push(bytes.slice());
334
+ }
335
+ async flush() {
336
+ }
337
+ async finalize() {
338
+ }
339
+ blob(contentType) {
340
+ const buffers = this.parts.map((part) => {
341
+ const buffer = new ArrayBuffer(part.byteLength);
342
+ new Uint8Array(buffer).set(part);
343
+ return buffer;
344
+ });
345
+ return new Blob(buffers, contentType ? { type: contentType } : void 0);
346
+ }
347
+ };
348
+ var FileSystemDownloadSink = class {
349
+ constructor(handle) {
350
+ this.handle = handle;
351
+ }
352
+ handle;
353
+ writable = null;
354
+ position = 0;
355
+ openedOnce = false;
356
+ async write(bytes, position) {
357
+ if (!this.writable) {
358
+ const keepExistingData = this.openedOnce || position > 0;
359
+ this.writable = await this.handle.createWritable({ keepExistingData });
360
+ if (position > 0) {
361
+ await this.writable.seek(position);
362
+ }
363
+ this.position = position;
364
+ this.openedOnce = true;
365
+ }
366
+ if (this.position !== position) {
367
+ throw new Error("File System Access writer position does not match the download checkpoint");
368
+ }
369
+ await this.writable.write(bytes);
370
+ this.position += bytes.byteLength;
371
+ }
372
+ async flush() {
373
+ if (this.writable) {
374
+ const writable = this.writable;
375
+ await writable.close();
376
+ this.writable = null;
377
+ }
378
+ }
379
+ async finalize(size) {
380
+ if (!this.writable) {
381
+ this.writable = await this.handle.createWritable({ keepExistingData: this.openedOnce });
382
+ this.openedOnce = true;
383
+ await this.writable.seek(size);
384
+ }
385
+ await this.writable.truncate(size);
386
+ const writable = this.writable;
387
+ this.writable = null;
388
+ await writable.close();
389
+ }
390
+ async abort() {
391
+ if (!this.writable) {
392
+ return;
393
+ }
394
+ const writable = this.writable;
395
+ this.writable = null;
396
+ if (writable.abort) {
397
+ await writable.abort();
398
+ } else {
399
+ await writable.close();
400
+ }
401
+ }
402
+ };
403
+ function downloadRequestFromOptions(options) {
404
+ if (options.path === void 0 && options.ino === void 0) {
405
+ throw new TypeError("download target requires path or ino");
406
+ }
407
+ if (options.path !== void 0 && !options.path.trim()) {
408
+ throw new TypeError("download path cannot be empty");
409
+ }
410
+ return {
411
+ ...options.workspaceCuid !== void 0 ? { workspaceCuid: options.workspaceCuid } : {},
412
+ ...options.workspaceId !== void 0 ? { workspaceId: options.workspaceId } : {},
413
+ ...options.path !== void 0 ? { path: options.path } : {},
414
+ ...options.ino !== void 0 ? { ino: options.ino } : {},
415
+ ...options.timeout !== void 0 ? { timeout: options.timeout } : {},
416
+ ...options.contentDisposition !== void 0 ? { contentDisposition: options.contentDisposition } : {},
417
+ ...options.downloadFilename !== void 0 ? { downloadFilename: options.downloadFilename } : {}
418
+ };
419
+ }
420
+ function requestBodyFromCheckpoint(request) {
421
+ return {
422
+ ...request.workspaceCuid !== void 0 ? { workspace_cuid: request.workspaceCuid } : {},
423
+ ...request.workspaceId !== void 0 ? { workspace_id: request.workspaceId } : {},
424
+ ...request.path !== void 0 ? { path: request.path } : {},
425
+ ...request.ino !== void 0 ? { ino: request.ino } : {},
426
+ ...request.timeout !== void 0 ? { timeout: request.timeout } : {},
427
+ ...request.contentDisposition !== void 0 ? { content_disposition: request.contentDisposition } : {},
428
+ ...request.downloadFilename !== void 0 ? { download_filename: request.downloadFilename } : {}
429
+ };
430
+ }
431
+ function requestKey(request, baseUrl) {
432
+ return encodeURIComponent(
433
+ JSON.stringify({
434
+ baseUrl,
435
+ workspaceCuid: request.workspaceCuid ?? null,
436
+ workspaceId: request.workspaceId ?? null,
437
+ path: request.path ?? null,
438
+ ino: request.ino ?? null
439
+ })
440
+ );
441
+ }
442
+ function buildDownloadChunks(exported, useClientAssembly) {
443
+ if (useClientAssembly && exported.chunks && exported.chunks.length > 1) {
444
+ return exported.chunks;
445
+ }
446
+ return [
447
+ {
448
+ index: 0,
449
+ offset: 0,
450
+ size: exported.size,
451
+ storageKey: exported.storageKey,
452
+ url: exported.url
453
+ }
454
+ ];
455
+ }
456
+ function buildDownloadKey(exported, chunks, baseUrl, request) {
457
+ return encodeURIComponent(
458
+ JSON.stringify({
459
+ request: requestKey(request, baseUrl),
460
+ path: exported.path,
461
+ size: exported.size,
462
+ chunks: chunks.map((chunk) => [chunk.index, chunk.offset, chunk.size, chunk.storageKey])
463
+ })
464
+ );
465
+ }
466
+ function checkpointMatches(checkpoint, exported, chunks) {
467
+ if (checkpoint.version !== 1 || checkpoint.path !== exported.path || checkpoint.size !== exported.size) {
468
+ return false;
469
+ }
470
+ if (checkpoint.chunks.length !== chunks.length) {
471
+ return false;
472
+ }
473
+ return checkpoint.chunks.every((saved, index) => {
474
+ const current = chunks[index];
475
+ return Boolean(
476
+ current && saved.index === current.index && saved.offset === current.offset && saved.size === current.size && saved.storageKey === current.storageKey && saved.downloadedBytes >= 0 && saved.downloadedBytes <= saved.size
477
+ );
478
+ });
479
+ }
480
+ function createCheckpoint(key, baseUrl, request, exported, chunks, fileHandle) {
481
+ return {
482
+ version: 1,
483
+ key,
484
+ requestKey: requestKey(request, baseUrl),
485
+ request: { ...request },
486
+ path: exported.path,
487
+ size: exported.size,
488
+ chunks: chunks.map((chunk) => ({
489
+ index: chunk.index,
490
+ offset: chunk.offset,
491
+ size: chunk.size,
492
+ storageKey: chunk.storageKey,
493
+ downloadedBytes: 0,
494
+ completed: chunk.size === 0
495
+ })),
496
+ ...fileHandle ? { fileHandle } : {},
497
+ updatedAt: Date.now()
498
+ };
499
+ }
500
+ function totalDownloadedBytes(checkpoint) {
501
+ return checkpoint.chunks.reduce((total, chunk) => total + chunk.downloadedBytes, 0);
502
+ }
503
+ function completedChunkCount(checkpoint) {
504
+ return checkpoint.chunks.filter((chunk) => chunk.completed).length;
505
+ }
506
+ function defaultFilename(exported, requested) {
507
+ const candidate = requested?.trim() || exported.path.split("/").filter(Boolean).pop() || "download";
508
+ return candidate.replace(/[\\/\r\n]/g, "_") || "download";
509
+ }
510
+ function triggerBrowserDownload(blob, filename) {
511
+ const documentValue = globalThis.document;
512
+ const urlApi = globalThis.URL;
513
+ if (!documentValue || !urlApi?.createObjectURL) {
514
+ return;
515
+ }
516
+ const url = urlApi.createObjectURL(blob);
517
+ const anchor = documentValue.createElement("a");
518
+ anchor.href = url;
519
+ anchor.download = filename;
520
+ anchor.style.display = "none";
521
+ documentValue.body?.appendChild(anchor);
522
+ anchor.click();
523
+ anchor.remove();
524
+ setTimeout(() => urlApi.revokeObjectURL(url), 0);
525
+ }
526
+ var MountraDownloader = class {
527
+ transport;
528
+ constructor(transport) {
529
+ this.transport = transport;
530
+ }
531
+ async exportFile(options) {
532
+ const request = downloadRequestFromOptions(options);
533
+ return ensureExportFileResponse(
534
+ await this.transport.requestJson(
535
+ "/file/export",
536
+ requestBodyFromCheckpoint(request),
537
+ options.signal
538
+ )
539
+ );
540
+ }
541
+ async downloadFile(options) {
542
+ const preferFileSystemAccess = options.preferFileSystemAccess !== false;
543
+ const fileHandle = options.fileHandle ?? (preferFileSystemAccess && getShowSaveFilePicker() ? await this.pickSaveFile(options.downloadFilename ?? options.path?.split("/").filter(Boolean).pop()) : void 0);
544
+ const exported = await this.exportFile(options);
545
+ return this.downloadExport(exported, { ...options, ...fileHandle ? { fileHandle } : {} });
546
+ }
547
+ async downloadExport(exported, options = { path: exported.path }) {
548
+ let fileHandle = options.fileHandle;
549
+ if (!fileHandle && options.preferFileSystemAccess !== false && getShowSaveFilePicker()) {
550
+ fileHandle = await this.pickSaveFile(
551
+ options.downloadFilename ?? defaultFilename(exported),
552
+ exported.contentType
553
+ );
554
+ }
555
+ const request = downloadRequestFromOptions(
556
+ options.path === void 0 && options.ino === void 0 ? { ...options, path: exported.path } : options
557
+ );
558
+ return this.downloadResolved(exported, request, options, fileHandle, false);
559
+ }
560
+ async resumeDownload(downloadKey, options = {}) {
561
+ if (!downloadKey.trim()) {
562
+ throw new TypeError("downloadKey is required");
563
+ }
564
+ const checkpoint = await this.transport.checkpointStore.load(downloadKey);
565
+ if (!checkpoint || !checkpoint.fileHandle) {
566
+ throw new Error("No resumable File System Access download was found");
567
+ }
568
+ await this.ensureWritePermission(checkpoint.fileHandle);
569
+ const exported = await this.exportFile({
570
+ ...checkpoint.request,
571
+ signal: options.signal
572
+ });
573
+ return this.downloadResolved(
574
+ exported,
575
+ checkpoint.request,
576
+ options,
577
+ checkpoint.fileHandle,
578
+ true
579
+ );
580
+ }
581
+ listResumableDownloads() {
582
+ return this.transport.checkpointStore.list();
583
+ }
584
+ clearDownloadCheckpoint(downloadKey) {
585
+ return this.transport.checkpointStore.remove(downloadKey);
586
+ }
587
+ async pickSaveFile(requestedFilename, contentType) {
588
+ const picker = getShowSaveFilePicker();
589
+ if (!picker) {
590
+ throw new Error("File System Access API is not available");
591
+ }
592
+ const types = contentType ? [{ description: contentType, accept: { [contentType]: ["." + (requestedFilename?.split(".").pop() || "bin")] } }] : void 0;
593
+ return picker({
594
+ suggestedName: requestedFilename?.trim() || "download",
595
+ ...types ? { types } : {}
596
+ });
597
+ }
598
+ async ensureWritePermission(handle) {
599
+ if (!handle.queryPermission && !handle.requestPermission) {
600
+ return;
601
+ }
602
+ const descriptor = { mode: "readwrite" };
603
+ const current = await handle.queryPermission?.(descriptor);
604
+ if (current === "granted") {
605
+ return;
606
+ }
607
+ const requested = await handle.requestPermission?.(descriptor);
608
+ if (requested !== void 0 && requested !== "granted") {
609
+ throw new DOMException("Write permission was not granted", "NotAllowedError");
610
+ }
611
+ }
612
+ async downloadResolved(exported, request, options, fileHandle, forceResume) {
613
+ const useClientAssembly = Boolean(fileHandle && (exported.chunks?.length ?? 0) > 1);
614
+ const chunks = buildDownloadChunks(exported, useClientAssembly);
615
+ const key = buildDownloadKey(exported, chunks, this.transport.baseUrl, request);
616
+ const existing = await this.transport.checkpointStore.load(key);
617
+ const resumeRequested = !("resume" in options) || options.resume !== false;
618
+ let checkpoint = existing && checkpointMatches(existing, exported, chunks) && (forceResume || resumeRequested) && (forceResume || fileHandle && await this.isSameFileHandle(existing.fileHandle, fileHandle)) ? existing : createCheckpoint(key, this.transport.baseUrl, request, exported, chunks, fileHandle);
619
+ if (fileHandle) {
620
+ checkpoint.fileHandle = fileHandle;
621
+ }
622
+ const resumed = totalDownloadedBytes(checkpoint) > 0;
623
+ const maxRetries = validateRetryOption(options.maxRetries, "maxRetries") ?? 3;
624
+ const retryDelayMs = validateRetryOption(options.retryDelayMs, "retryDelayMs") ?? 500;
625
+ const sink = fileHandle ? new FileSystemDownloadSink(fileHandle) : new BlobDownloadSink();
626
+ const speedWindow = new SpeedWindow();
627
+ const startedAt = Date.now();
628
+ let retryCount = 0;
629
+ let lastCheckpointAt = 0;
630
+ const emit = (phase, currentChunkIndex) => {
631
+ const downloadedBytes = totalDownloadedBytes(checkpoint);
632
+ const speedBytesPerSecond = speedWindow.record(downloadedBytes, Date.now());
633
+ const remainingBytes = Math.max(exported.size - downloadedBytes, 0);
634
+ options.onProgress?.({
635
+ phase,
636
+ downloadedBytes,
637
+ totalBytes: exported.size,
638
+ downloadedChunks: completedChunkCount(checkpoint),
639
+ totalChunks: checkpoint.chunks.length,
640
+ ...currentChunkIndex !== void 0 ? { currentChunkIndex } : {},
641
+ speedBytesPerSecond,
642
+ ...speedBytesPerSecond > 0 && remainingBytes > 0 ? { estimatedRemainingSeconds: remainingBytes / speedBytesPerSecond } : {},
643
+ elapsedMs: Date.now() - startedAt,
644
+ retryCount
645
+ });
646
+ };
647
+ const persist = async (force) => {
648
+ const now = Date.now();
649
+ if (!force && now - lastCheckpointAt < 1e3) {
650
+ return;
651
+ }
652
+ await sink.flush();
653
+ checkpoint.updatedAt = now;
654
+ await this.transport.checkpointStore.save(key, checkpoint);
655
+ lastCheckpointAt = now;
656
+ };
657
+ await this.transport.checkpointStore.save(key, checkpoint);
658
+ try {
659
+ for (const chunk of chunks) {
660
+ const savedChunk = checkpoint.chunks[chunk.index];
661
+ if (!savedChunk) {
662
+ throw new Error(`Missing download checkpoint for chunk ${chunk.index}`);
663
+ }
664
+ if (savedChunk.completed) {
665
+ continue;
666
+ }
667
+ let attempt = 0;
668
+ while (true) {
669
+ throwIfAborted(options.signal);
670
+ const rangeStart = savedChunk.downloadedBytes;
671
+ try {
672
+ const response = await this.transport.fetchImpl(chunk.url, {
673
+ method: "GET",
674
+ ...rangeStart > 0 ? { headers: { Range: `bytes=${rangeStart}-` } } : {},
675
+ ...options.signal ? { signal: options.signal } : {}
676
+ });
677
+ if (!response.ok) {
678
+ throw new DownloadHttpError(
679
+ response.status,
680
+ `Download for chunk ${chunk.index} failed with HTTP ${response.status}`
681
+ );
682
+ }
683
+ await this.consumeResponse(
684
+ response,
685
+ chunk,
686
+ savedChunk,
687
+ sink,
688
+ exported.size,
689
+ emit,
690
+ persist,
691
+ options.signal
692
+ );
693
+ if (savedChunk.downloadedBytes !== savedChunk.size) {
694
+ throw new Error(
695
+ `Download for chunk ${chunk.index} ended at ${savedChunk.downloadedBytes} of ${savedChunk.size} bytes`
696
+ );
697
+ }
698
+ savedChunk.completed = true;
699
+ await persist(true);
700
+ emit("downloading", chunk.index);
701
+ break;
702
+ } catch (error) {
703
+ await persist(true);
704
+ if (isAbortError(error, options.signal) || !isRetryableError(error) || attempt >= maxRetries) {
705
+ throw error;
706
+ }
707
+ attempt += 1;
708
+ retryCount += 1;
709
+ emit("retrying", chunk.index);
710
+ await waitForRetry(Math.min(retryDelayMs * 2 ** (attempt - 1), 3e4), options.signal);
711
+ }
712
+ }
713
+ }
714
+ if (totalDownloadedBytes(checkpoint) !== exported.size) {
715
+ throw new Error("Download completed with an unexpected byte count");
716
+ }
717
+ if (fileHandle) {
718
+ await sink.finalize(exported.size);
719
+ } else {
720
+ await sink.finalize();
721
+ }
722
+ await this.transport.checkpointStore.remove(key);
723
+ emit("completed");
724
+ const blob = sink instanceof BlobDownloadSink ? sink.blob(exported.contentType) : void 0;
725
+ if (blob) {
726
+ const filename = "downloadFilename" in options ? options.downloadFilename : void 0;
727
+ triggerBrowserDownload(blob, defaultFilename(exported, filename));
728
+ }
729
+ return {
730
+ export: exported,
731
+ downloadKey: key,
732
+ downloadedBytes: exported.size,
733
+ resumed,
734
+ ...fileHandle ? { fileHandle } : {},
735
+ ...blob ? { blob } : {}
736
+ };
737
+ } catch (error) {
738
+ await persist(true);
739
+ throw error;
740
+ }
741
+ }
742
+ async consumeResponse(response, chunk, savedChunk, sink, totalSize, emit, persist, signal) {
743
+ const sourceOffset = savedChunk.downloadedBytes;
744
+ let bytesToSkip = response.status === 200 && sourceOffset > 0 ? sourceOffset : 0;
745
+ const reader = response.body?.getReader();
746
+ if (!reader) {
747
+ const body = new Uint8Array(await response.arrayBuffer());
748
+ await this.consumeBytes(body, bytesToSkip, chunk, savedChunk, sink, totalSize, emit, persist, signal);
749
+ return;
750
+ }
751
+ try {
752
+ while (true) {
753
+ throwIfAborted(signal);
754
+ const next = await reader.read();
755
+ if (next.done) {
756
+ break;
757
+ }
758
+ const bytes = next.value;
759
+ if (bytesToSkip > 0) {
760
+ const skipped = Math.min(bytesToSkip, bytes.byteLength);
761
+ bytesToSkip -= skipped;
762
+ if (skipped === bytes.byteLength) {
763
+ continue;
764
+ }
765
+ await this.consumeBytes(
766
+ bytes.subarray(skipped),
767
+ 0,
768
+ chunk,
769
+ savedChunk,
770
+ sink,
771
+ totalSize,
772
+ emit,
773
+ persist,
774
+ signal
775
+ );
776
+ } else {
777
+ await this.consumeBytes(bytes, 0, chunk, savedChunk, sink, totalSize, emit, persist, signal);
778
+ }
779
+ }
780
+ } finally {
781
+ reader.releaseLock();
782
+ }
783
+ if (bytesToSkip > 0) {
784
+ throw new Error(`Download response did not contain the saved offset for chunk ${chunk.index}`);
785
+ }
786
+ }
787
+ async consumeBytes(bytes, offset, chunk, savedChunk, sink, totalSize, emit, persist, signal) {
788
+ const remaining = chunk.size - savedChunk.downloadedBytes;
789
+ const writableBytes = bytes.byteLength - offset;
790
+ if (writableBytes > remaining) {
791
+ throw new Error(`Download response for chunk ${chunk.index} is larger than expected`);
792
+ }
793
+ if (writableBytes === 0) {
794
+ return;
795
+ }
796
+ throwIfAborted(signal);
797
+ const payload = bytes.subarray(offset);
798
+ const position = chunk.offset + savedChunk.downloadedBytes;
799
+ if (sink instanceof FileSystemDownloadSink) {
800
+ await sink.write(payload, position);
801
+ } else {
802
+ await sink.write(payload);
803
+ }
804
+ savedChunk.downloadedBytes += payload.byteLength;
805
+ emit("downloading", chunk.index);
806
+ await persist(false);
807
+ if (savedChunk.downloadedBytes > totalSize) {
808
+ throw new Error("Download byte count exceeded the exported file size");
809
+ }
810
+ }
811
+ async isSameFileHandle(savedHandle, selectedHandle) {
812
+ if (!savedHandle) {
813
+ return false;
814
+ }
815
+ if (savedHandle === selectedHandle) {
816
+ return true;
817
+ }
818
+ if (savedHandle.isSameEntry) {
819
+ try {
820
+ return await savedHandle.isSameEntry(selectedHandle);
821
+ } catch {
822
+ return false;
823
+ }
824
+ }
825
+ return false;
826
+ }
827
+ };
828
+
829
+ // src/file.ts
830
+ function normalizeMountraFileLocator(locator, fallbackWorkspace = {}) {
831
+ if (!locator || typeof locator !== "object") {
832
+ throw new TypeError("Mountra file locator is required");
833
+ }
834
+ const hasPath = locator.path !== void 0;
835
+ const hasIno = locator.ino !== void 0;
836
+ if (hasPath === hasIno) {
837
+ throw new TypeError("Mountra file locator requires exactly one of path or ino");
838
+ }
839
+ const workspaceId = locator.workspaceId ?? locator.wsid ?? fallbackWorkspace.workspaceId;
840
+ if (locator.workspaceId !== void 0 && locator.wsid !== void 0 && String(locator.workspaceId) !== String(locator.wsid)) {
841
+ throw new TypeError("workspaceId and wsid must identify the same workspace");
842
+ }
843
+ const workspaceCuid = locator.workspaceCuid ?? fallbackWorkspace.workspaceCuid;
844
+ if (workspaceCuid === void 0 && workspaceId === void 0) {
845
+ throw new TypeError("workspaceCuid, workspaceId, or wsid is required");
846
+ }
847
+ if (hasPath) {
848
+ const path = normalizePath(locator.path);
849
+ return {
850
+ ...workspaceCuid !== void 0 ? { workspaceCuid } : {},
851
+ ...workspaceId !== void 0 ? { workspaceId } : {},
852
+ path
853
+ };
854
+ }
855
+ return {
856
+ ...workspaceCuid !== void 0 ? { workspaceCuid } : {},
857
+ ...workspaceId !== void 0 ? { workspaceId } : {},
858
+ ino: normalizeIno(locator.ino)
859
+ };
860
+ }
861
+ var MountraFile = class {
862
+ constructor(client, locator) {
863
+ this.client = client;
864
+ this.locator = Object.freeze({ ...locator });
865
+ }
866
+ client;
867
+ locator;
868
+ get workspaceCuid() {
869
+ return this.locator.workspaceCuid;
870
+ }
871
+ get workspaceId() {
872
+ return this.locator.workspaceId;
873
+ }
874
+ /** `wsid` is the short alias used by the file-object API. */
875
+ get wsid() {
876
+ return this.locator.workspaceId;
877
+ }
878
+ get path() {
879
+ return "path" in this.locator ? this.locator.path : void 0;
880
+ }
881
+ get ino() {
882
+ return "ino" in this.locator ? this.locator.ino : void 0;
883
+ }
884
+ export(options = {}) {
885
+ return this.client.exportFile({ ...options, ...this.locator });
886
+ }
887
+ download(options = {}) {
888
+ return this.client.downloadFile({ ...options, ...this.locator });
889
+ }
890
+ async resume(options = {}) {
891
+ const { downloadKey, ...resumeOptions } = options;
892
+ const checkpoints = await this.client.listResumableDownloads();
893
+ const candidates = checkpoints.filter(
894
+ (checkpoint2) => checkpoint2.fileHandle && matchesLocator(checkpoint2.request, this.locator)
895
+ );
896
+ const checkpoint = downloadKey ? candidates.find((candidate) => candidate.key === downloadKey) : [...candidates].sort((left, right) => right.updatedAt - left.updatedAt)[0];
897
+ if (!checkpoint) {
898
+ if (downloadKey) {
899
+ throw new Error("Download checkpoint does not belong to this Mountra file");
900
+ }
901
+ throw new Error("No resumable download was found for this Mountra file");
902
+ }
903
+ return this.client.resumeDownload(checkpoint.key, resumeOptions);
904
+ }
905
+ };
906
+ function matchesLocator(request, locator) {
907
+ if (locator.workspaceCuid !== void 0 && request.workspaceCuid !== locator.workspaceCuid) {
908
+ return false;
909
+ }
910
+ if (locator.workspaceId !== void 0 && String(request.workspaceId) !== String(locator.workspaceId)) {
911
+ return false;
912
+ }
913
+ if ("path" in locator) {
914
+ if (request.path === void 0 || request.ino !== void 0) {
915
+ return false;
916
+ }
917
+ try {
918
+ return normalizePath(request.path) === locator.path;
919
+ } catch {
920
+ return false;
921
+ }
922
+ }
923
+ return String(request.ino) === String(locator.ino) && request.path === void 0;
924
+ }
925
+ function normalizePath(value) {
926
+ if (!value || !value.trim()) {
927
+ throw new TypeError("Mountra file path cannot be empty");
928
+ }
929
+ const raw = value.trim();
930
+ const absolute = raw.startsWith("/") ? raw : `/${raw}`;
931
+ const segments = absolute.split("/").filter((segment) => segment.length > 0 && segment !== ".");
932
+ const stack = [];
933
+ for (const segment of segments) {
934
+ if (segment === "..") {
935
+ stack.pop();
936
+ continue;
937
+ }
938
+ stack.push(segment);
939
+ }
940
+ return stack.length === 0 ? "/" : `/${stack.join("/")}`;
941
+ }
942
+ function normalizeIno(value) {
943
+ if (typeof value === "number") {
944
+ if (!Number.isSafeInteger(value) || value < 0) {
945
+ throw new TypeError("Mountra file ino must be a non-negative safe integer");
946
+ }
947
+ return value;
948
+ }
949
+ if (!/^\d+$/.test(value)) {
950
+ throw new TypeError("Mountra file ino must be a non-negative integer string");
951
+ }
952
+ return value;
953
+ }
954
+
40
955
  // src/upload.ts
41
956
  var JUMBO_CHUNK_THRESHOLD_BYTES = 128 * 1024 * 1024;
42
957
  var MIN_S3_MULTIPART_PART_SIZE_BYTES = 5 * 1024 * 1024;
@@ -56,7 +971,7 @@ var MountraApiError = class extends Error {
56
971
  };
57
972
  var memoryCheckpointRecords = /* @__PURE__ */ new Map();
58
973
  function parseJumboCheckpoint(value) {
59
- if (!isRecord(value) || value.version !== 1 || typeof value.storageKey !== "string") {
974
+ if (!isRecord2(value) || value.version !== 1 || typeof value.storageKey !== "string") {
60
975
  return null;
61
976
  }
62
977
  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)) {
@@ -64,7 +979,7 @@ function parseJumboCheckpoint(value) {
64
979
  }
65
980
  const parts = [];
66
981
  for (const part of value.parts) {
67
- if (!isRecord(part)) {
982
+ if (!isRecord2(part)) {
68
983
  return null;
69
984
  }
70
985
  if (typeof part.partNumber !== "number" || typeof part.etag !== "string" || typeof part.size !== "number" || typeof part.sha256 !== "string") {
@@ -237,17 +1152,17 @@ function getWorkspaceBody(clientSelector, requestSelector) {
237
1152
  ...workspaceId !== void 0 ? { workspace_id: workspaceId } : {}
238
1153
  };
239
1154
  }
240
- function throwIfAborted(signal) {
1155
+ function throwIfAborted2(signal) {
241
1156
  if (!signal?.aborted) {
242
1157
  return;
243
1158
  }
244
1159
  throw new DOMException("The upload was aborted", "AbortError");
245
1160
  }
246
- function isRecord(value) {
1161
+ function isRecord2(value) {
247
1162
  return typeof value === "object" && value !== null;
248
1163
  }
249
1164
  function parseApiErrorBody(body) {
250
- if (!isRecord(body)) {
1165
+ if (!isRecord2(body)) {
251
1166
  return {};
252
1167
  }
253
1168
  return {
@@ -256,7 +1171,7 @@ function parseApiErrorBody(body) {
256
1171
  };
257
1172
  }
258
1173
  function ensurePrepareResponse(value) {
259
- if (!isRecord(value) || typeof value.upload_id !== "string") {
1174
+ if (!isRecord2(value) || typeof value.upload_id !== "string") {
260
1175
  throw new TypeError("Mountra prepare response is invalid");
261
1176
  }
262
1177
  if (!Array.isArray(value.upload_targets)) {
@@ -265,13 +1180,13 @@ function ensurePrepareResponse(value) {
265
1180
  return value;
266
1181
  }
267
1182
  function ensureFinishResponse(value) {
268
- if (!isRecord(value) || typeof value.upload_id !== "string") {
1183
+ if (!isRecord2(value) || typeof value.upload_id !== "string") {
269
1184
  throw new TypeError("Mountra finish response is invalid");
270
1185
  }
271
1186
  return value;
272
1187
  }
273
1188
  function ensureUploadStatusResponse(value) {
274
- if (!isRecord(value) || typeof value.upload_id !== "string") {
1189
+ if (!isRecord2(value) || typeof value.upload_id !== "string") {
275
1190
  throw new TypeError("Mountra upload status response is invalid");
276
1191
  }
277
1192
  if (!Array.isArray(value.chunks)) {
@@ -322,6 +1237,7 @@ var MountraClient = class {
322
1237
  jumboChunkUploader;
323
1238
  jumboChunkCheckpointStore;
324
1239
  jumboPartSize;
1240
+ downloader;
325
1241
  constructor(options) {
326
1242
  this.baseUrl = normalizeBaseUrl(options.baseUrl);
327
1243
  this.apiPrefix = normalizeApiPrefix(options.apiPrefix ?? "/api");
@@ -340,6 +1256,38 @@ var MountraClient = class {
340
1256
  throw new Error("fetch is not available; provide fetch in MountraClientOptions");
341
1257
  }
342
1258
  this.fetchImpl = fetchImpl;
1259
+ this.downloader = new MountraDownloader({
1260
+ baseUrl: this.baseUrl,
1261
+ clientSelector: this.clientSelector,
1262
+ fetchImpl: this.fetchImpl,
1263
+ checkpointStore: options.downloadCheckpointStore ?? new IndexedDBDownloadCheckpointStore(),
1264
+ requestJson: (path, body, signal) => this.requestJson(path, body, signal)
1265
+ });
1266
+ }
1267
+ async exportFile(options) {
1268
+ return this.downloader.exportFile(options);
1269
+ }
1270
+ async downloadFile(options) {
1271
+ return this.downloader.downloadFile(options);
1272
+ }
1273
+ async downloadExport(exported, options) {
1274
+ return this.downloader.downloadExport(exported, options);
1275
+ }
1276
+ async resumeDownload(downloadKey, options) {
1277
+ return this.downloader.resumeDownload(downloadKey, options);
1278
+ }
1279
+ listResumableDownloads() {
1280
+ return this.downloader.listResumableDownloads();
1281
+ }
1282
+ clearDownloadCheckpoint(downloadKey) {
1283
+ return this.downloader.clearDownloadCheckpoint(downloadKey);
1284
+ }
1285
+ /** Create a stable resource object for one file in this or another workspace. */
1286
+ file(locator) {
1287
+ return new MountraFile(
1288
+ this,
1289
+ normalizeMountraFileLocator(locator, this.clientSelector)
1290
+ );
343
1291
  }
344
1292
  async prepareUpload(file, options) {
345
1293
  const chunks = splitFileIntoChunks(file, options.chunkCount ?? 1);
@@ -347,7 +1295,7 @@ var MountraClient = class {
347
1295
  const contentHash = await sha256Hex(file);
348
1296
  const chunkMethod = chunks.length === 1 ? "none" : "fix";
349
1297
  const contentType = options.contentType ?? ("type" in file ? file.type : void 0);
350
- throwIfAborted(options.signal);
1298
+ throwIfAborted2(options.signal);
351
1299
  const response = ensurePrepareResponse(
352
1300
  await this.requestJson("/upload/prepare", {
353
1301
  ...getWorkspaceBody(this.clientSelector, options),
@@ -450,7 +1398,7 @@ var MountraClient = class {
450
1398
  if (!chunk || target.chunk_index !== position) {
451
1399
  throw new Error("Mountra upload targets must contain contiguous chunk indexes");
452
1400
  }
453
- throwIfAborted(options.signal);
1401
+ throwIfAborted2(options.signal);
454
1402
  if (isJumboChunk(chunk.size)) {
455
1403
  const checkpointKey = this.buildJumboCheckpointKey(context, target, options);
456
1404
  jumboCheckpointKeys.add(checkpointKey);
@@ -493,7 +1441,7 @@ var MountraClient = class {
493
1441
  totalChunks: orderedTargets.length
494
1442
  });
495
1443
  }
496
- throwIfAborted(options.signal);
1444
+ throwIfAborted2(options.signal);
497
1445
  options.onProgress?.({
498
1446
  phase: "finishing",
499
1447
  uploadedBytes: file.size,
@@ -597,7 +1545,7 @@ var MountraClient = class {
597
1545
  let uploadedBytes = 0;
598
1546
  let uploadedParts = 0;
599
1547
  for (let partNumber = 1; partNumber <= totalParts; partNumber += 1) {
600
- throwIfAborted(options.signal);
1548
+ throwIfAborted2(options.signal);
601
1549
  const start = (partNumber - 1) * this.jumboPartSize;
602
1550
  const partBlob = chunk.blob.slice(start, Math.min(start + this.jumboPartSize, chunk.size));
603
1551
  const partHash = await sha256Hex(partBlob);
@@ -734,13 +1682,18 @@ function createMountraClient(options) {
734
1682
  // Annotate the CommonJS export names for ESM import in node:
735
1683
  0 && (module.exports = {
736
1684
  DEFAULT_S3_MULTIPART_PART_SIZE_BYTES,
1685
+ DownloadHttpError,
1686
+ InMemoryDownloadCheckpointStore,
737
1687
  InMemoryJumboChunkCheckpointStore,
1688
+ IndexedDBDownloadCheckpointStore,
738
1689
  JUMBO_CHUNK_THRESHOLD_BYTES,
739
1690
  LocalStorageJumboChunkCheckpointStore,
740
1691
  MAX_S3_MULTIPART_PARTS,
741
1692
  MIN_S3_MULTIPART_PART_SIZE_BYTES,
742
1693
  MountraApiError,
743
1694
  MountraClient,
1695
+ MountraDownloader,
1696
+ MountraFile,
744
1697
  MountraSDK,
745
1698
  S3MultipartUploader,
746
1699
  createMountraClient,