@brftech/filex-core 0.19.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +34 -4
  2. package/dist/filex-core.js +10626 -6501
  3. package/dist/filex-core.js.map +1 -1
  4. package/dist/filex-core.umd.cjs +94 -63
  5. package/dist/filex-core.umd.cjs.map +1 -1
  6. package/dist/index.d.ts +1026 -7
  7. package/dist/style.css +1 -1
  8. package/package.json +3 -3
  9. package/src/FileExplorer.vue +103 -68
  10. package/src/components/ConnectionGuideView.vue +333 -0
  11. package/src/components/ConnectionsPanel.vue +912 -0
  12. package/src/components/NFSExportsPanel.vue +283 -0
  13. package/src/components/S3KeysPanel.vue +381 -0
  14. package/src/components/SSHKeysPanel.vue +222 -0
  15. package/src/components/StorageFields.vue +362 -0
  16. package/src/components/TokensPanel.vue +191 -0
  17. package/src/components/UploadProgress.vue +5 -1
  18. package/src/composables/useConnections.ts +271 -0
  19. package/src/composables/useFileApi.ts +15 -2
  20. package/src/composables/useNFSExports.ts +148 -0
  21. package/src/composables/useS3Keys.ts +175 -0
  22. package/src/composables/useSSHKeys.ts +119 -0
  23. package/src/composables/useThumbs.ts +1 -1
  24. package/src/composables/useTokens.ts +121 -0
  25. package/src/composables/useUploadChunked.ts +433 -164
  26. package/src/index.ts +77 -2
  27. package/src/lib/connectionGuides.ts +1279 -0
  28. package/src/lib/realtime.ts +1 -1
  29. package/src/lib/uploadResume.ts +157 -0
  30. package/src/locales/en.ts +413 -0
  31. package/src/locales/tr.ts +416 -0
  32. package/src/modals/ConvertModal.vue +1 -1
  33. package/src/styles/base.css +12 -12
  34. package/src/types/Connections.ts +122 -0
  35. package/src/types/ExplorerConfig.ts +23 -2
  36. package/src/types/NFSExports.ts +47 -0
  37. package/src/types/S3Keys.ts +55 -0
  38. package/src/types/SSHKeys.ts +54 -0
  39. package/src/types/Tokens.ts +39 -0
@@ -1,32 +1,78 @@
1
1
  /**
2
- * useUploadChunked — S3 multipart upload orchestrator for the browser.
2
+ * useUploadChunked — chunked, resumable uploads for every browser surface.
3
3
  *
4
- * Contract (matches the standard /api/files/upload/{init,finalize,abort}):
5
- * 1. POST init {path, filename, size, mime}
6
- * → {uploadId, parts: [{partNumber, presignedUrl}], chunkBytes, totalParts}
7
- * 2. For each part: PUT <presignedUrl> <chunk bytes>, collect ETag header
8
- * 3. POST finalize {uploadId, parts: [{partNumber, etag}]}
9
- * → {s3Key, url, size}
10
- * Abort path: POST abort {uploadId} any time.
4
+ * It speaks the STAGED protocol (docs/UPLOADS.md), which works on every storage
5
+ * driver:
11
6
  *
12
- * We parallelize up to `parallelChunks` uploads (default 4). Progress is
13
- * reported as an aggregate percentage across all parts — individual part
14
- * percentages are tracked internally and summed.
7
+ * POST /api/files/upload/begin {path,name,size,mime?,hash?,chunk_size?}
8
+ * {id, chunk_size, offset, total_size}
9
+ * PUT /api/files/upload/{id} Content-Range: bytes A-B/total + body
10
+ * → {offset, received, total_size, state}
11
+ * GET /api/files/upload/{id} → {offset, state, complete, …}
12
+ * POST /api/files/upload/{id}/commit→ 202 {op_id, node_id}
13
+ * DELETE /api/files/upload/{id} → abort + delete staging
14
+ *
15
+ * It used to speak the S3-presigned one (`/upload/{init,finalize,abort}`), which
16
+ * needed the S3 driver — every other backend answered 501 — and on which filex
17
+ * never saw the bytes at all. That path still exists on the server and is
18
+ * untouched; nothing in the client calls it any more.
19
+ *
20
+ * Three properties are the point of the rewrite, and each is a test:
21
+ *
22
+ * - **The offset comes from the server.** After any failure the client asks
23
+ * GET /upload/{id} rather than trusting its own counter. A chunk can fail
24
+ * after its bytes landed (the response was lost); re-sending is merely slow,
25
+ * assuming success is wrong.
26
+ * - **Progress is filex's ingest.** `uploadedBytes` counts bytes filex has
27
+ * acknowledged plus the in-flight chunk — not bytes handed to a socket, and
28
+ * not the backend's own write, which happens afterwards in the ops worker
29
+ * and shows as the `transferring` phase.
30
+ * - **A reload is resumable, not a restart.** The upload id is bookmarked in
31
+ * localStorage (lib/uploadResume). The browser will not hand a File back
32
+ * without a fresh gesture, so recovery is "pick the same file and it
33
+ * continues from where filex stopped", with `job.resumedFrom` set so the UI
34
+ * can say so rather than silently starting over.
15
35
  */
16
36
 
17
37
  import type { ExplorerConfig } from '../types/ExplorerConfig';
18
38
  import type { FileApi } from './useFileApi';
19
- import type { UploadInitResponse, UploadFinalizeResponse } from '../types/FileNode';
39
+ import {
40
+ clearResume,
41
+ defaultResumeStorage,
42
+ listResume,
43
+ loadResume,
44
+ saveResume,
45
+ uploadFingerprint,
46
+ type ResumeRecord,
47
+ type ResumeStorage,
48
+ } from '../lib/uploadResume';
20
49
 
21
50
  export interface UploadJob {
22
51
  id: string; // local uuid
23
52
  file: File;
24
53
  path: string;
54
+ /** Server-side staged upload id, once `begin` has answered. */
25
55
  uploadId?: string;
26
56
  totalBytes: number;
57
+ /** Bytes filex has acknowledged (plus the chunk currently in flight). */
27
58
  uploadedBytes: number;
28
59
  percent: number;
29
- status: 'pending' | 'initializing' | 'uploading' | 'finalizing' | 'done' | 'error' | 'aborted';
60
+ status:
61
+ | 'pending'
62
+ | 'initializing'
63
+ | 'uploading'
64
+ /** Every byte is in filex; the commit is being accepted. */
65
+ | 'committing'
66
+ /** Committed and listed; the ops worker is writing it to the backend. */
67
+ | 'transferring'
68
+ | 'done'
69
+ | 'error'
70
+ | 'aborted';
71
+ /** Byte offset an interrupted session was picked up from, when it was. */
72
+ resumedFrom?: number;
73
+ /** Background transfer op, once the commit is accepted. */
74
+ opId?: number;
75
+ nodeId?: number;
30
76
  error?: string;
31
77
  cancel(): void;
32
78
  }
@@ -35,19 +81,131 @@ export interface UploadOptions {
35
81
  path: string;
36
82
  file: File;
37
83
  chunkSize?: number;
38
- parallelChunks?: number;
39
84
  onProgress?: (job: UploadJob) => void;
40
- onDone?: (job: UploadJob, result: UploadFinalizeResponse) => void;
85
+ onDone?: (job: UploadJob, result: UploadResult) => void;
41
86
  onError?: (job: UploadJob, err: Error) => void;
87
+ /**
88
+ * Wait for the backend transfer before resolving. Default false: the node is
89
+ * listed the moment the commit is accepted, which is the whole point of
90
+ * staging. `true` is for a caller that must not report success until the
91
+ * bytes are on the driver.
92
+ */
93
+ waitForTransfer?: boolean;
94
+ }
95
+
96
+ /** What `commit` answers, plus the fields a caller wants afterwards. */
97
+ export interface UploadResult {
98
+ id: string;
99
+ op_id?: number;
100
+ node_id?: number;
101
+ path?: string;
102
+ transfer_state?: string;
103
+ }
104
+
105
+ interface BeginResponse {
106
+ id: string;
107
+ chunk_size?: number;
108
+ chunkSize?: number;
109
+ offset?: number;
110
+ total_size?: number;
111
+ }
112
+
113
+ interface StatusResponse {
114
+ id?: string;
115
+ offset?: number;
116
+ received?: number;
117
+ total_size?: number;
118
+ chunk_size?: number;
119
+ chunkSize?: number;
120
+ state?: string;
121
+ complete?: boolean;
122
+ error?: string;
123
+ op_id?: number;
124
+ node_id?: number;
42
125
  }
43
126
 
44
- export function useUploadChunked(config: ExplorerConfig, api: FileApi) {
45
- const DEFAULT_CHUNK = config.chunkSize ?? 5 * 1024 * 1024;
46
- const DEFAULT_PARALLEL = Math.max(1, Math.min(8, config.parallelChunks ?? 4));
127
+ /** Only a session still taking chunks can be continued. */
128
+ function resumableState(state: string | undefined): boolean {
129
+ return !state || state === 'staging';
130
+ }
131
+
132
+ /** Error carrying "this server has no staged path" — the ONLY condition under
133
+ * which a caller may retry the file on the single-POST path. Any other
134
+ * failure has either sent bytes or been refused on the merits. */
135
+ export interface UnsupportedUploadError extends Error {
136
+ stagedUnsupported?: true;
137
+ }
138
+
139
+ function markUnsupported(err: Error): UnsupportedUploadError {
140
+ (err as UnsupportedUploadError).stagedUnsupported = true;
141
+ return err;
142
+ }
143
+
144
+ /** True when the staged protocol is simply not there. */
145
+ export function isStagedUnsupported(err: unknown): boolean {
146
+ return !!(err as UnsupportedUploadError)?.stagedUnsupported;
147
+ }
148
+
149
+ export function useUploadChunked(
150
+ config: ExplorerConfig,
151
+ api: FileApi,
152
+ storage: ResumeStorage | null = defaultResumeStorage(),
153
+ ) {
154
+ /** Client-side default. The server's answer at `begin` is binding — this only
155
+ * decides what to ask for and what counts as "large enough to chunk". */
156
+ const DEFAULT_CHUNK = config.chunkSize ?? 8 * 1024 * 1024;
157
+
158
+ /** Base of the staged routes, derived from the manager endpoint the same way
159
+ * every other derived route is, so an embedder that passes only `endpoint`
160
+ * still reaches them. */
161
+ function stagedBase(): string {
162
+ const explicit = api.endpoints.uploadBegin;
163
+ if (explicit) return explicit.replace(/\/begin$/, '');
164
+ return api.endpoints.manager.replace(/\/manager(\?.*)?$/, '/upload');
165
+ }
166
+
167
+ /** Is the staged protocol reachable at all? */
168
+ function available(): boolean {
169
+ return !!stagedBase();
170
+ }
171
+
172
+ /** Files at or above this go chunked; smaller ones use the single-POST fast
173
+ * path, which is fine for a 20 KB text file. */
174
+ function threshold(): number {
175
+ return DEFAULT_CHUNK;
176
+ }
177
+
178
+ function shouldChunk(file: { size: number }): boolean {
179
+ return available() && file.size > threshold();
180
+ }
181
+
182
+ /** The bookmark for this (destination, file), if there is one. Exposed so a
183
+ * host can tell the user an upload is resumable BEFORE starting it. */
184
+ function resumableFor(path: string, file: File): ResumeRecord | null {
185
+ return loadResume(storage, uploadFingerprint(path, file));
186
+ }
187
+
188
+ /** Every unfinished upload this browser remembers. */
189
+ function listResumable(): ResumeRecord[] {
190
+ return listResume(storage);
191
+ }
47
192
 
48
- async function uploadFile(opts: UploadOptions): Promise<UploadFinalizeResponse> {
49
- const chunkSize = opts.chunkSize ?? DEFAULT_CHUNK;
50
- const parallel = opts.parallelChunks ?? DEFAULT_PARALLEL;
193
+ /** Forget a bookmark and, best effort, drop the server-side staging with it. */
194
+ async function discardResumable(rec: ResumeRecord): Promise<void> {
195
+ clearResume(storage, uploadFingerprint(rec.path, { name: rec.name, size: rec.size, lastModified: rec.lastModified }));
196
+ try {
197
+ await api.jsonFetch(`${stagedBase()}/${encodeURIComponent(rec.uploadId)}`, { method: 'DELETE' });
198
+ } catch {
199
+ /* already gone, or being transferred — the sweeper handles the rest */
200
+ }
201
+ }
202
+
203
+ async function status(uploadId: string): Promise<StatusResponse> {
204
+ return api.jsonFetch<StatusResponse>(`${stagedBase()}/${encodeURIComponent(uploadId)}`);
205
+ }
206
+
207
+ async function uploadFile(opts: UploadOptions): Promise<UploadResult> {
208
+ const key = uploadFingerprint(opts.path, opts.file);
51
209
 
52
210
  const job: UploadJob = {
53
211
  id: crypto.randomUUID(),
@@ -57,194 +215,305 @@ export function useUploadChunked(config: ExplorerConfig, api: FileApi) {
57
215
  uploadedBytes: 0,
58
216
  percent: 0,
59
217
  status: 'initializing',
60
- cancel: () => {}, // rebound below
218
+ cancel: () => {},
61
219
  };
62
220
 
63
221
  let cancelled = false;
64
- const aborters: AbortController[] = [];
222
+ let inFlight: XMLHttpRequest | null = null;
65
223
  job.cancel = () => {
66
224
  cancelled = true;
67
- for (const a of aborters) a.abort();
225
+ inFlight?.abort();
68
226
  };
69
227
 
70
- function report() {
228
+ /** Acknowledged bytes; the in-flight chunk is added on top for display. */
229
+ let acked = 0;
230
+ function report(inflight = 0) {
231
+ job.uploadedBytes = Math.min(job.totalBytes, acked + inflight);
71
232
  job.percent =
72
233
  job.totalBytes > 0
73
234
  ? Math.min(100, Math.round((job.uploadedBytes / job.totalBytes) * 100))
74
- : 0;
235
+ : job.status === 'done'
236
+ ? 100
237
+ : 0;
75
238
  opts.onProgress?.(job);
76
239
  }
77
240
 
78
241
  try {
79
- if (!api.endpoints.uploadInit) throw new Error('uploadInit not configured');
80
- // 1) init
81
- const init = await api.jsonFetch<UploadInitResponse & { chunkBytes?: number; totalParts?: number }>(
82
- api.endpoints.uploadInit,
83
- {
84
- method: 'POST',
85
- headers: { 'Content-Type': 'application/json' },
86
- body: JSON.stringify({
87
- path: opts.path,
88
- filename: opts.file.name,
89
- size: opts.file.size,
90
- mime: opts.file.type || 'application/octet-stream',
91
- }),
92
- },
93
- );
94
- job.uploadId = init.uploadId;
95
- job.status = 'uploading';
96
- report();
97
-
98
- const actualChunk = init.chunkBytes ?? chunkSize;
99
- void actualChunk; // referenced below — keep TS happy with `chunkSize` shadow
100
- const partProgress = new Map<number, number>();
101
-
102
- // 2) upload parts in parallel windows
103
- const completed: Array<{ partNumber: number; etag: string }> = [];
104
- let cursor = 0;
105
-
106
- async function uploadPart(entry: { partNumber: number; presignedUrl: string }): Promise<void> {
107
- const idx = entry.partNumber - 1;
108
- const start = idx * (init.chunkBytes ?? chunkSize);
109
- const end = Math.min(start + (init.chunkBytes ?? chunkSize), opts.file.size);
110
- const blob = opts.file.slice(start, end);
111
-
112
- const ctl = new AbortController();
113
- aborters.push(ctl);
114
-
115
- await new Promise<void>((resolve, reject) => {
116
- const xhr = new XMLHttpRequest();
117
- xhr.open('PUT', entry.presignedUrl);
118
- xhr.upload.onprogress = (ev) => {
119
- if (ev.lengthComputable) {
120
- const prev = partProgress.get(entry.partNumber) ?? 0;
121
- const delta = ev.loaded - prev;
122
- partProgress.set(entry.partNumber, ev.loaded);
123
- job.uploadedBytes += delta;
124
- report();
125
- }
126
- };
127
- xhr.onload = () => {
128
- if (xhr.status >= 200 && xhr.status < 300) {
129
- const etag = (xhr.getResponseHeader('ETag') || xhr.getResponseHeader('etag') || '').trim();
130
- if (!etag) {
131
- reject(new Error('Missing ETag on S3 response'));
132
- return;
133
- }
134
- // Top off in case the last progress event under-reported.
135
- const blobSize = end - start;
136
- const prev = partProgress.get(entry.partNumber) ?? 0;
137
- if (prev < blobSize) {
138
- job.uploadedBytes += blobSize - prev;
139
- partProgress.set(entry.partNumber, blobSize);
140
- }
141
- completed.push({ partNumber: entry.partNumber, etag });
142
- resolve();
143
- } else {
144
- reject(
145
- new Error(`S3 PUT ${entry.partNumber} → ${xhr.status}: ${xhr.responseText.slice(0, 200)}`),
146
- );
147
- }
148
- };
149
- xhr.onerror = () => reject(new Error(`S3 PUT ${entry.partNumber} network error`));
150
- xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError'));
151
- ctl.signal.addEventListener('abort', () => xhr.abort());
152
- xhr.send(blob);
153
- });
242
+ if (!available()) {
243
+ throw markUnsupported(new Error('staged upload endpoint not configured'));
154
244
  }
155
245
 
156
- /**
157
- * Wrap `uploadPart` in a tiny exponential-backoff retry so a
158
- * single hiccup on the edge (intermittent TCP RST, transient
159
- * 5xx) doesn't kill the whole multipart session. Counter is
160
- * per-part a flaky one doesn't burn the budget for healthy
161
- * neighbours. We DON'T retry user cancellations or AbortError.
162
- */
163
- const MAX_RETRIES = 2;
164
- async function uploadPartWithRetry(
165
- entry: { partNumber: number; presignedUrl: string },
166
- attempt = 0,
167
- ): Promise<void> {
246
+ const base = stagedBase();
247
+ let uploadId = '';
248
+ let chunkSize = opts.chunkSize ?? DEFAULT_CHUNK;
249
+
250
+ // ── resume, or begin ────────────────────────────────────────────────
251
+ const bookmark = loadResume(storage, key);
252
+ if (bookmark) {
168
253
  try {
169
- await uploadPart(entry);
170
- } catch (err) {
171
- if (cancelled) throw err;
172
- const e = err instanceof Error ? err : new Error(String(err));
173
- if (e.name === 'AbortError') throw e;
174
- if (attempt >= MAX_RETRIES) throw e;
175
-
176
- // Roll back any partial uploadedBytes so progress doesn't
177
- // double-count after the retry succeeds.
178
- const leaked = partProgress.get(entry.partNumber) ?? 0;
179
- if (leaked > 0) {
180
- job.uploadedBytes = Math.max(0, job.uploadedBytes - leaked);
181
- partProgress.delete(entry.partNumber);
182
- report();
254
+ const st = await status(bookmark.uploadId);
255
+ if (resumableState(st.state) && (st.total_size ?? 0) === opts.file.size) {
256
+ uploadId = bookmark.uploadId;
257
+ chunkSize = st.chunk_size ?? st.chunkSize ?? bookmark.chunkSize;
258
+ acked = st.offset ?? 0;
259
+ job.resumedFrom = acked;
260
+ } else {
261
+ clearResume(storage, key);
183
262
  }
184
-
185
- // Exponential backoff with jitter 500ms, 1s, 1.5s
186
- const delay = 500 * (attempt + 1) + Math.floor(Math.random() * 250);
187
- await new Promise((r) => setTimeout(r, delay));
188
- return uploadPartWithRetry(entry, attempt + 1);
263
+ } catch {
264
+ // Swept, aborted, or belongs to someone else now. Not a failure —
265
+ // `begin` below decides what happens next.
266
+ clearResume(storage, key);
189
267
  }
190
268
  }
191
269
 
192
- async function worker() {
193
- while (!cancelled && cursor < init.parts.length) {
194
- const entry = init.parts[cursor++];
195
- await uploadPartWithRetry(entry);
270
+ if (!uploadId) {
271
+ let begun: BeginResponse;
272
+ try {
273
+ begun = await api.jsonFetch<BeginResponse>(`${base}/begin`, {
274
+ method: 'POST',
275
+ headers: { 'Content-Type': 'application/json' },
276
+ body: JSON.stringify({
277
+ path: opts.path,
278
+ name: opts.file.name,
279
+ size: opts.file.size,
280
+ mime: opts.file.type || 'application/octet-stream',
281
+ // Asked for, not imposed: the server clamps this to its own
282
+ // limits and its answer is what the loop below uses.
283
+ chunk_size: opts.chunkSize ?? DEFAULT_CHUNK,
284
+ }),
285
+ });
286
+ } catch (err) {
287
+ // 404 = an older server with no staged routes; 501 = staging not
288
+ // configured on this instance. Both mean "use the other path", and
289
+ // both happen before a byte is sent, so the caller can safely fall
290
+ // back. Everything else is a real refusal (quota, permission, disk)
291
+ // and must be reported, NOT retried as a whole-file POST.
292
+ const st = (err as Error & { status?: number }).status;
293
+ if (st === 404 || st === 501) markUnsupported(err as Error);
294
+ throw err;
196
295
  }
296
+ if (!begun?.id) throw new Error('begin returned no upload id');
297
+ uploadId = begun.id;
298
+ chunkSize = begun.chunk_size ?? begun.chunkSize ?? chunkSize;
299
+ acked = begun.offset ?? 0;
197
300
  }
198
301
 
199
- const workers: Promise<void>[] = [];
200
- for (let i = 0; i < parallel; i++) {
201
- workers.push(worker());
202
- }
203
- await Promise.all(workers);
302
+ job.uploadId = uploadId;
303
+ job.status = 'uploading';
304
+ // Bookmarked BEFORE the first chunk: a tab closed between `begin` and the
305
+ // first PUT would otherwise leave a staging directory nobody can name.
306
+ saveResume(storage, key, {
307
+ uploadId,
308
+ path: opts.path,
309
+ name: opts.file.name,
310
+ size: opts.file.size,
311
+ lastModified: opts.file.lastModified,
312
+ chunkSize,
313
+ offset: acked,
314
+ });
315
+ report();
316
+
317
+ // ── chunks ──────────────────────────────────────────────────────────
318
+ const MAX_ATTEMPTS = 4;
319
+ while (acked < opts.file.size) {
320
+ if (cancelled) throw new DOMException('Aborted by user', 'AbortError');
321
+ const end = Math.min(acked + chunkSize, opts.file.size);
322
+ const blob = opts.file.slice(acked, end);
204
323
 
205
- if (cancelled) {
206
- throw new DOMException('Aborted by user', 'AbortError');
324
+ let next = -1;
325
+ let lastErr: Error | null = null;
326
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
327
+ if (attempt > 0) {
328
+ await sleep(300 * attempt + Math.floor(Math.random() * 150));
329
+ if (cancelled) throw new DOMException('Aborted by user', 'AbortError');
330
+ // Re-ask before re-sending: the previous attempt may have landed
331
+ // and lost only its response.
332
+ try {
333
+ const st = await status(uploadId);
334
+ if ((st.offset ?? 0) >= end) {
335
+ next = st.offset ?? end;
336
+ break;
337
+ }
338
+ if ((st.offset ?? 0) !== acked) {
339
+ acked = st.offset ?? acked;
340
+ report();
341
+ next = -2; // grid moved — recompute the slice
342
+ break;
343
+ }
344
+ } catch {
345
+ /* keep retrying the chunk itself */
346
+ }
347
+ }
348
+ try {
349
+ next = await putChunk(uploadId, blob, acked, end, opts.file.size);
350
+ break;
351
+ } catch (err) {
352
+ const e = err instanceof Error ? err : new Error(String(err));
353
+ if (cancelled || e.name === 'AbortError') throw e;
354
+ const st = (e as Error & { status?: number }).status;
355
+ // 403/404/413 will not become true by repeating them; a dropped
356
+ // body (400 SHORT_CHUNK) or a transient 5xx will.
357
+ if (st && st !== 400 && st !== 409 && st < 500) throw e;
358
+ lastErr = e;
359
+ }
360
+ }
361
+ if (next === -2) continue; // offset moved under us; re-slice
362
+ if (next < 0) throw lastErr ?? new Error('chunk upload failed');
363
+ if (next <= acked) throw new Error(`upload stalled at byte ${acked}`);
364
+ acked = next;
365
+ report();
366
+ saveResume(storage, key, {
367
+ uploadId,
368
+ path: opts.path,
369
+ name: opts.file.name,
370
+ size: opts.file.size,
371
+ lastModified: opts.file.lastModified,
372
+ chunkSize,
373
+ offset: acked,
374
+ });
207
375
  }
208
376
 
209
- // 3) finalize
210
- if (!api.endpoints.uploadFinalize) throw new Error('uploadFinalize not configured');
211
- job.status = 'finalizing';
212
- completed.sort((a, b) => a.partNumber - b.partNumber);
213
- const final = await api.jsonFetch<UploadFinalizeResponse>(api.endpoints.uploadFinalize, {
214
- method: 'POST',
215
- headers: { 'Content-Type': 'application/json' },
216
- body: JSON.stringify({ uploadId: init.uploadId, parts: completed }),
217
- });
377
+ if (cancelled) throw new DOMException('Aborted by user', 'AbortError');
378
+
379
+ // ── commit ──────────────────────────────────────────────────────────
380
+ job.status = 'committing';
381
+ report();
382
+ const result = await api.jsonFetch<UploadResult>(
383
+ `${base}/${encodeURIComponent(uploadId)}/commit`,
384
+ { method: 'POST' },
385
+ );
386
+ // Committed: the node is listed and the bytes are filex's problem now,
387
+ // so the bookmark has nothing left to recover.
388
+ clearResume(storage, key);
389
+ job.opId = result?.op_id;
390
+ job.nodeId = result?.node_id;
391
+
392
+ if (opts.waitForTransfer && result?.op_id && api.endpoints.opsShow) {
393
+ job.status = 'transferring';
394
+ report();
395
+ await waitForOp(result.op_id);
396
+ }
218
397
 
219
398
  job.status = 'done';
220
- job.uploadedBytes = job.totalBytes;
221
- job.percent = 100;
399
+ acked = job.totalBytes;
222
400
  report();
223
- opts.onDone?.(job, final);
224
- return final;
401
+ opts.onDone?.(job, result);
402
+ return result;
225
403
  } catch (err) {
226
404
  const asError = err instanceof Error ? err : new Error(String(err));
227
405
  job.status = asError.name === 'AbortError' ? 'aborted' : 'error';
228
406
  job.error = asError.message;
229
407
  report();
230
408
 
231
- // Best-effort server-side cleanup
232
- if (job.uploadId && api.endpoints.uploadAbort && job.status === 'aborted') {
409
+ // A user-cancelled upload is meant to be gone; a failed one is meant to
410
+ // be resumable. So only the abort releases the server's staging — an
411
+ // error keeps both the staging directory and the bookmark, which is what
412
+ // makes the retry cost nothing.
413
+ if (job.uploadId && job.status === 'aborted') {
414
+ clearResume(storage, key);
233
415
  try {
234
- await api.jsonFetch(api.endpoints.uploadAbort, {
235
- method: 'POST',
236
- headers: { 'Content-Type': 'application/json' },
237
- body: JSON.stringify({ uploadId: job.uploadId }),
416
+ await api.jsonFetch(`${stagedBase()}/${encodeURIComponent(job.uploadId)}`, {
417
+ method: 'DELETE',
238
418
  });
239
419
  } catch {
240
- /* swallow — DB row will expire, S3 TTL handles orphan parts */
420
+ /* swallow — the staging sweeper collects it either way */
241
421
  }
242
422
  }
243
423
 
244
424
  opts.onError?.(job, asError);
245
425
  throw asError;
246
426
  }
427
+
428
+ /** One chunk, over XHR because fetch cannot report upload progress. */
429
+ function putChunk(
430
+ uploadId: string,
431
+ blob: Blob,
432
+ start: number,
433
+ end: number,
434
+ total: number,
435
+ ): Promise<number> {
436
+ return new Promise<number>((resolve, reject) => {
437
+ const xhr = new XMLHttpRequest();
438
+ inFlight = xhr;
439
+ xhr.open('PUT', `${stagedBase()}/${encodeURIComponent(uploadId)}`);
440
+ const headers = api.authHeadersSync({
441
+ 'Content-Type': 'application/octet-stream',
442
+ 'Content-Range': `bytes ${start}-${end - 1}/${total}`,
443
+ });
444
+ for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
445
+ xhr.withCredentials = api.credentialsMode() === 'include';
446
+ xhr.upload.onprogress = (ev) => {
447
+ if (ev.lengthComputable) report(ev.loaded);
448
+ };
449
+ xhr.onload = () => {
450
+ inFlight = null;
451
+ if (xhr.status >= 200 && xhr.status < 300) {
452
+ try {
453
+ const body = JSON.parse(xhr.responseText) as StatusResponse;
454
+ resolve(body.offset ?? end);
455
+ } catch {
456
+ // A 2xx we cannot parse still means the bytes landed; the offset
457
+ // is re-read from the server rather than guessed.
458
+ resolve(end);
459
+ }
460
+ return;
461
+ }
462
+ const err = new Error(
463
+ `chunk ${start}-${end - 1} → ${xhr.status}`,
464
+ ) as Error & { status?: number; detail?: string };
465
+ err.status = xhr.status;
466
+ err.detail = xhr.responseText.slice(0, 200);
467
+ reject(err);
468
+ };
469
+ xhr.onerror = () => {
470
+ inFlight = null;
471
+ reject(new Error(`chunk ${start}-${end - 1}: network error`));
472
+ };
473
+ xhr.onabort = () => {
474
+ inFlight = null;
475
+ reject(new DOMException('Aborted', 'AbortError'));
476
+ };
477
+ xhr.send(blob);
478
+ });
479
+ }
480
+
481
+ /** Poll the shared ops tray until the transfer leaves the queue. */
482
+ async function waitForOp(opId: number): Promise<void> {
483
+ const tmpl = api.endpoints.opsShow;
484
+ if (!tmpl) return;
485
+ const url = tmpl.replace('{id}', String(opId));
486
+ let delay = 200;
487
+ for (;;) {
488
+ if (cancelled) throw new DOMException('Aborted by user', 'AbortError');
489
+ try {
490
+ const op = await api.jsonFetch<{ status?: string; error?: string }>(url);
491
+ if (op?.status === 'ok') return;
492
+ if (op?.status === 'failed' || op?.status === 'partial') {
493
+ throw new Error(op.error || 'transfer failed');
494
+ }
495
+ } catch (err) {
496
+ if ((err as Error).message === 'transfer failed' || (err as Error).name === 'AbortError') throw err;
497
+ /* a hiccup reading the tray is not a failed transfer */
498
+ }
499
+ await sleep(delay);
500
+ delay = Math.min(delay * 2, 2000);
501
+ }
502
+ }
247
503
  }
248
504
 
249
- return { uploadFile };
505
+ return {
506
+ uploadFile,
507
+ /** True when this file is big enough (and the endpoints exist) to chunk. */
508
+ shouldChunk,
509
+ /** The size at which chunking kicks in. */
510
+ threshold,
511
+ resumableFor,
512
+ listResumable,
513
+ discardResumable,
514
+ };
515
+ }
516
+
517
+ function sleep(ms: number): Promise<void> {
518
+ return new Promise((r) => setTimeout(r, ms));
250
519
  }