@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.
- package/README.md +34 -4
- package/dist/filex-core.js +10626 -6501
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +94 -63
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index.d.ts +1026 -7
- package/dist/style.css +1 -1
- package/package.json +3 -3
- package/src/FileExplorer.vue +103 -68
- package/src/components/ConnectionGuideView.vue +333 -0
- package/src/components/ConnectionsPanel.vue +912 -0
- package/src/components/NFSExportsPanel.vue +283 -0
- package/src/components/S3KeysPanel.vue +381 -0
- package/src/components/SSHKeysPanel.vue +222 -0
- package/src/components/StorageFields.vue +362 -0
- package/src/components/TokensPanel.vue +191 -0
- package/src/components/UploadProgress.vue +5 -1
- package/src/composables/useConnections.ts +271 -0
- package/src/composables/useFileApi.ts +15 -2
- package/src/composables/useNFSExports.ts +148 -0
- package/src/composables/useS3Keys.ts +175 -0
- package/src/composables/useSSHKeys.ts +119 -0
- package/src/composables/useThumbs.ts +1 -1
- package/src/composables/useTokens.ts +121 -0
- package/src/composables/useUploadChunked.ts +433 -164
- package/src/index.ts +77 -2
- package/src/lib/connectionGuides.ts +1279 -0
- package/src/lib/realtime.ts +1 -1
- package/src/lib/uploadResume.ts +157 -0
- package/src/locales/en.ts +413 -0
- package/src/locales/tr.ts +416 -0
- package/src/modals/ConvertModal.vue +1 -1
- package/src/styles/base.css +12 -12
- package/src/types/Connections.ts +122 -0
- package/src/types/ExplorerConfig.ts +23 -2
- package/src/types/NFSExports.ts +47 -0
- package/src/types/S3Keys.ts +55 -0
- package/src/types/SSHKeys.ts +54 -0
- package/src/types/Tokens.ts +39 -0
|
@@ -1,32 +1,78 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* useUploadChunked —
|
|
2
|
+
* useUploadChunked — chunked, resumable uploads for every browser surface.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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: () => {},
|
|
218
|
+
cancel: () => {},
|
|
61
219
|
};
|
|
62
220
|
|
|
63
221
|
let cancelled = false;
|
|
64
|
-
|
|
222
|
+
let inFlight: XMLHttpRequest | null = null;
|
|
65
223
|
job.cancel = () => {
|
|
66
224
|
cancelled = true;
|
|
67
|
-
|
|
225
|
+
inFlight?.abort();
|
|
68
226
|
};
|
|
69
227
|
|
|
70
|
-
|
|
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
|
-
:
|
|
235
|
+
: job.status === 'done'
|
|
236
|
+
? 100
|
|
237
|
+
: 0;
|
|
75
238
|
opts.onProgress?.(job);
|
|
76
239
|
}
|
|
77
240
|
|
|
78
241
|
try {
|
|
79
|
-
if (!
|
|
80
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
await
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
221
|
-
job.percent = 100;
|
|
399
|
+
acked = job.totalBytes;
|
|
222
400
|
report();
|
|
223
|
-
opts.onDone?.(job,
|
|
224
|
-
return
|
|
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
|
-
//
|
|
232
|
-
|
|
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(
|
|
235
|
-
method: '
|
|
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 —
|
|
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 {
|
|
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
|
}
|