gbs-add-block 1.2.11 → 1.2.13
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 +4 -2
- package/index.cjs +7 -1
- package/package.json +1 -1
- package/source/beta-components/fileuploader/README.md +99 -0
- package/source/beta-components/fileuploader/__tests__/core.test.ts +395 -0
- package/source/beta-components/fileuploader/core/format.ts +79 -0
- package/source/beta-components/fileuploader/core/index.ts +26 -0
- package/source/beta-components/fileuploader/core/store.ts +432 -0
- package/source/beta-components/fileuploader/core/transport.ts +145 -0
- package/source/beta-components/fileuploader/core/types.ts +126 -0
- package/source/beta-components/fileuploader/core/validate.ts +83 -0
- package/source/beta-components/fileuploader/index.ts +7 -0
- package/source/beta-components/fileuploader/react/FileRow.tsx +268 -0
- package/source/beta-components/fileuploader/react/FileUploader.tsx +515 -0
- package/source/beta-components/fileuploader/react/icons.tsx +80 -0
- package/source/beta-components/fileuploader/react/locale.ts +33 -0
- package/source/beta-components/fileuploader/react/props.ts +31 -0
- package/source/beta-components/fileuploader/react/useFileUploader.ts +32 -0
- package/source/beta-components/fileuploader/styles.css +395 -0
- package/source/beta-components/toaster/README.md +128 -0
- package/source/beta-components/toaster/__tests__/core.test.ts +256 -0
- package/source/beta-components/toaster/core/api.ts +87 -0
- package/source/beta-components/toaster/core/index.ts +14 -0
- package/source/beta-components/toaster/core/store.ts +211 -0
- package/source/beta-components/toaster/core/toast.ts +12 -0
- package/source/beta-components/toaster/core/types.ts +78 -0
- package/source/beta-components/toaster/index.ts +6 -0
- package/source/beta-components/toaster/react/ToastItem.tsx +164 -0
- package/source/beta-components/toaster/react/Toaster.tsx +187 -0
- package/source/beta-components/toaster/react/icons.tsx +56 -0
- package/source/beta-components/toaster/react/locale.ts +6 -0
- package/source/beta-components/toaster/react/props.ts +15 -0
- package/source/beta-components/toaster/react/useToasts.ts +11 -0
- package/source/beta-components/toaster/styles.css +283 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { abortError, isRetryable, UploadHttpError } from "./transport";
|
|
2
|
+
import type {
|
|
3
|
+
FileRejection,
|
|
4
|
+
Transport,
|
|
5
|
+
UploaderSnapshot,
|
|
6
|
+
UploadItem,
|
|
7
|
+
UploadStatus,
|
|
8
|
+
} from "./types";
|
|
9
|
+
import { partitionFiles, type ValidationRules } from "./validate";
|
|
10
|
+
|
|
11
|
+
/** 5 MiB: small enough to retry cheaply, large enough to keep request overhead low. */
|
|
12
|
+
export const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
|
|
13
|
+
export const DEFAULT_CONCURRENCY = 3;
|
|
14
|
+
export const DEFAULT_RETRIES = 3;
|
|
15
|
+
export const DEFAULT_RETRY_DELAY = 1000;
|
|
16
|
+
|
|
17
|
+
export interface UploaderConfig extends ValidationRules {
|
|
18
|
+
/** Sends the chunks. Without one, files can be selected but not uploaded. */
|
|
19
|
+
transport?: Transport;
|
|
20
|
+
/** Bytes per request. Default 5 MiB. */
|
|
21
|
+
chunkSize?: number;
|
|
22
|
+
/** Files uploading at the same time. Chunks of one file go one after another. Default 3. */
|
|
23
|
+
concurrency?: number;
|
|
24
|
+
/** Extra attempts per chunk after a retryable failure. Default 3. */
|
|
25
|
+
retries?: number;
|
|
26
|
+
/** First retry delay in ms; doubles on each attempt. Default 1000. */
|
|
27
|
+
retryDelay?: number;
|
|
28
|
+
/** Start uploading as soon as files are added. */
|
|
29
|
+
autoUpload?: boolean;
|
|
30
|
+
/** The selected files changed (added, removed, cleared). */
|
|
31
|
+
onChange?(files: File[]): void;
|
|
32
|
+
onRejected?(rejections: FileRejection[]): void;
|
|
33
|
+
onFileSuccess?(item: UploadItem): void;
|
|
34
|
+
onFileError?(item: UploadItem, error: unknown): void;
|
|
35
|
+
/** Every file started by one `upload()` call has finished, failed, paused or been canceled. */
|
|
36
|
+
onComplete?(items: UploadItem[]): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AddResult {
|
|
40
|
+
accepted: UploadItem[];
|
|
41
|
+
rejected: FileRejection[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UploaderStore {
|
|
45
|
+
subscribe(listener: () => void): () => void;
|
|
46
|
+
getSnapshot(): UploaderSnapshot;
|
|
47
|
+
getServerSnapshot(): UploaderSnapshot;
|
|
48
|
+
addFiles(files: Iterable<File> | ArrayLike<File>): AddResult;
|
|
49
|
+
remove(id: string): void;
|
|
50
|
+
clear(): void;
|
|
51
|
+
/**
|
|
52
|
+
* Starts (or resumes, or retries) the given files, or every file that isn't
|
|
53
|
+
* uploaded yet. Resolves once they have all stopped.
|
|
54
|
+
*/
|
|
55
|
+
upload(ids?: readonly string[]): Promise<UploadItem[]>;
|
|
56
|
+
/** Stops after aborting the chunk in flight; `upload()` continues from that chunk. */
|
|
57
|
+
pause(id?: string): void;
|
|
58
|
+
/** Stops and forgets progress; `upload()` starts again from the beginning. */
|
|
59
|
+
cancel(id?: string): void;
|
|
60
|
+
dismissRejection(id?: string): void;
|
|
61
|
+
configure(config: UploaderConfig): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type StopReason = "pause" | "cancel" | "remove";
|
|
65
|
+
|
|
66
|
+
interface Running {
|
|
67
|
+
controller: AbortController;
|
|
68
|
+
reason?: StopReason;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const EMPTY: UploaderSnapshot = { items: [], rejections: [] };
|
|
72
|
+
const IN_FLIGHT: ReadonlySet<UploadStatus> = new Set(["queued", "uploading"]);
|
|
73
|
+
const STARTABLE: ReadonlySet<UploadStatus> = new Set(["idle", "paused", "error", "canceled"]);
|
|
74
|
+
const NO_TRANSPORT = "Set an endpoint or a transport to upload files.";
|
|
75
|
+
|
|
76
|
+
let sequence = 0;
|
|
77
|
+
|
|
78
|
+
/** `crypto.randomUUID` exists only on HTTPS and localhost, so plain-HTTP intranet pages fall back. */
|
|
79
|
+
export function createUploadId(): string {
|
|
80
|
+
return (
|
|
81
|
+
globalThis.crypto?.randomUUID?.() ??
|
|
82
|
+
`${Date.now().toString(36)}-${(sequence++).toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const chunkCount = (size: number, chunkSize: number) =>
|
|
87
|
+
Math.max(1, Math.ceil(size / chunkSize));
|
|
88
|
+
|
|
89
|
+
function sleep(ms: number, signal: AbortSignal) {
|
|
90
|
+
return new Promise<void>((resolve, reject) => {
|
|
91
|
+
if (signal.aborted) {
|
|
92
|
+
reject(abortError());
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const onAbort = () => {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
reject(abortError());
|
|
98
|
+
};
|
|
99
|
+
const timer = setTimeout(() => {
|
|
100
|
+
signal.removeEventListener("abort", onAbort);
|
|
101
|
+
resolve();
|
|
102
|
+
}, ms);
|
|
103
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function describeError(error: unknown): string {
|
|
108
|
+
if (error instanceof UploadHttpError) return error.message;
|
|
109
|
+
if (error instanceof Error) return error.message;
|
|
110
|
+
return String(error);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Totals across all items, for an overall progress bar. */
|
|
114
|
+
export function summarize(items: readonly UploadItem[]) {
|
|
115
|
+
let totalBytes = 0;
|
|
116
|
+
let uploadedBytes = 0;
|
|
117
|
+
const counts: Record<UploadStatus, number> = {
|
|
118
|
+
idle: 0,
|
|
119
|
+
queued: 0,
|
|
120
|
+
uploading: 0,
|
|
121
|
+
paused: 0,
|
|
122
|
+
success: 0,
|
|
123
|
+
error: 0,
|
|
124
|
+
canceled: 0,
|
|
125
|
+
};
|
|
126
|
+
for (const item of items) {
|
|
127
|
+
totalBytes += item.file.size;
|
|
128
|
+
uploadedBytes += item.uploadedBytes;
|
|
129
|
+
counts[item.status]++;
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
totalBytes,
|
|
133
|
+
uploadedBytes,
|
|
134
|
+
progress: totalBytes > 0 ? uploadedBytes / totalBytes : counts.success > 0 ? 1 : 0,
|
|
135
|
+
counts,
|
|
136
|
+
/** Something is queued or uploading. */
|
|
137
|
+
busy: counts.queued + counts.uploading > 0,
|
|
138
|
+
/** Files `upload()` would start. */
|
|
139
|
+
startable: counts.idle + counts.paused + counts.error + counts.canceled,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Selection, validation and chunked uploading, with no React. Snapshots are
|
|
145
|
+
* immutable, so the store plugs straight into `useSyncExternalStore`. Progress
|
|
146
|
+
* is published at most once per whole percent per file, so a fast connection
|
|
147
|
+
* doesn't flood the UI with renders.
|
|
148
|
+
*/
|
|
149
|
+
export function createUploaderStore(initial: UploaderConfig = {}): UploaderStore {
|
|
150
|
+
let config: UploaderConfig = { ...initial };
|
|
151
|
+
let snapshot: UploaderSnapshot = EMPTY;
|
|
152
|
+
let running = 0;
|
|
153
|
+
const listeners = new Set<() => void>();
|
|
154
|
+
const controllers = new Map<string, Running>();
|
|
155
|
+
const waiters: { ids: ReadonlySet<string>; resolve(items: UploadItem[]): void }[] = [];
|
|
156
|
+
|
|
157
|
+
const find = (id: string) => snapshot.items.find((item) => item.id === id);
|
|
158
|
+
const selectedFiles = () => snapshot.items.map((item) => item.file);
|
|
159
|
+
|
|
160
|
+
function commit(next: UploaderSnapshot) {
|
|
161
|
+
snapshot = next;
|
|
162
|
+
for (let i = waiters.length - 1; i >= 0; i--) {
|
|
163
|
+
const { ids, resolve } = waiters[i];
|
|
164
|
+
const items = snapshot.items.filter((item) => ids.has(item.id));
|
|
165
|
+
if (items.some((item) => IN_FLIGHT.has(item.status))) continue;
|
|
166
|
+
waiters.splice(i, 1);
|
|
167
|
+
resolve(items);
|
|
168
|
+
}
|
|
169
|
+
for (const listener of listeners) listener();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function patch(id: string, changes: Partial<UploadItem>) {
|
|
173
|
+
if (!find(id)) return;
|
|
174
|
+
commit({
|
|
175
|
+
...snapshot,
|
|
176
|
+
items: snapshot.items.map((item) => (item.id === id ? { ...item, ...changes } : item)),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Aborts the request in flight. Returns false when nothing was running. */
|
|
181
|
+
function stop(id: string, reason: StopReason): boolean {
|
|
182
|
+
const entry = controllers.get(id);
|
|
183
|
+
if (!entry) return false;
|
|
184
|
+
entry.reason = reason;
|
|
185
|
+
entry.controller.abort();
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function withRetries<T>(send: () => Promise<T>, signal: AbortSignal): Promise<T> {
|
|
190
|
+
const retries = Math.max(0, config.retries ?? DEFAULT_RETRIES);
|
|
191
|
+
for (let attempt = 0; ; attempt++) {
|
|
192
|
+
try {
|
|
193
|
+
return await send();
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (signal.aborted || attempt >= retries || !isRetryable(error)) throw error;
|
|
196
|
+
await sleep((config.retryDelay ?? DEFAULT_RETRY_DELAY) * 2 ** attempt, signal);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function run(id: string) {
|
|
202
|
+
const item = find(id);
|
|
203
|
+
if (!item || item.status !== "queued") return;
|
|
204
|
+
const transport = config.transport;
|
|
205
|
+
if (!transport) {
|
|
206
|
+
patch(id, { status: "error", error: NO_TRANSPORT });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const entry: Running = { controller: new AbortController() };
|
|
211
|
+
controllers.set(id, entry);
|
|
212
|
+
const { signal } = entry.controller;
|
|
213
|
+
|
|
214
|
+
// A resumed upload keeps its original chunk size, so indexes still line up on the server.
|
|
215
|
+
const fresh = item.chunksDone === 0;
|
|
216
|
+
const chunkSize = fresh ? Math.max(1, config.chunkSize ?? DEFAULT_CHUNK_SIZE) : item.chunkSize;
|
|
217
|
+
const total = fresh ? chunkCount(item.file.size, chunkSize) : item.totalChunks;
|
|
218
|
+
const { file, uploadId } = item;
|
|
219
|
+
const ratio = (bytes: number) => (file.size > 0 ? bytes / file.size : 1);
|
|
220
|
+
patch(id, { status: "uploading", chunkSize, totalChunks: total });
|
|
221
|
+
|
|
222
|
+
let percent = Math.floor(item.progress * 100);
|
|
223
|
+
const report = (bytes: number) => {
|
|
224
|
+
const next = Math.floor(ratio(bytes) * 100);
|
|
225
|
+
if (signal.aborted || next === percent) return;
|
|
226
|
+
percent = next;
|
|
227
|
+
patch(id, { uploadedBytes: bytes, progress: ratio(bytes) });
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
for (let index = item.chunksDone; index < total; index++) {
|
|
232
|
+
const offset = index * chunkSize;
|
|
233
|
+
const end = Math.min(file.size, offset + chunkSize);
|
|
234
|
+
const chunk = file.slice(offset, end);
|
|
235
|
+
const response = await withRetries(
|
|
236
|
+
() =>
|
|
237
|
+
transport({
|
|
238
|
+
file,
|
|
239
|
+
chunk,
|
|
240
|
+
index,
|
|
241
|
+
total,
|
|
242
|
+
offset,
|
|
243
|
+
uploadId,
|
|
244
|
+
signal,
|
|
245
|
+
onProgress: (loaded) => report(offset + Math.min(loaded, chunk.size)),
|
|
246
|
+
}),
|
|
247
|
+
signal,
|
|
248
|
+
);
|
|
249
|
+
percent = Math.floor(ratio(end) * 100);
|
|
250
|
+
patch(id, { chunksDone: index + 1, uploadedBytes: end, progress: ratio(end), response });
|
|
251
|
+
}
|
|
252
|
+
patch(id, { status: "success", uploadedBytes: file.size, progress: 1 });
|
|
253
|
+
const done = find(id);
|
|
254
|
+
if (done) config.onFileSuccess?.(done);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
const current = find(id);
|
|
257
|
+
if (!current || entry.reason === "remove") return;
|
|
258
|
+
const confirmed = Math.min(file.size, current.chunksDone * chunkSize);
|
|
259
|
+
|
|
260
|
+
if (entry.reason === "pause") {
|
|
261
|
+
patch(id, { status: "paused", uploadedBytes: confirmed, progress: ratio(confirmed) });
|
|
262
|
+
} else if (entry.reason === "cancel") {
|
|
263
|
+
patch(id, { status: "canceled", uploadedBytes: 0, progress: 0, chunksDone: 0 });
|
|
264
|
+
} else {
|
|
265
|
+
patch(id, {
|
|
266
|
+
status: "error",
|
|
267
|
+
error: describeError(error),
|
|
268
|
+
uploadedBytes: confirmed,
|
|
269
|
+
progress: ratio(confirmed),
|
|
270
|
+
});
|
|
271
|
+
const failed = find(id);
|
|
272
|
+
if (failed) config.onFileError?.(failed, error);
|
|
273
|
+
}
|
|
274
|
+
} finally {
|
|
275
|
+
if (controllers.get(id) === entry) controllers.delete(id);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function pump() {
|
|
280
|
+
const limit = Math.max(1, config.concurrency ?? DEFAULT_CONCURRENCY);
|
|
281
|
+
while (running < limit) {
|
|
282
|
+
const next = snapshot.items.find((item) => item.status === "queued");
|
|
283
|
+
if (!next) return;
|
|
284
|
+
running++;
|
|
285
|
+
// `run` marks the item as uploading before its first await, so the loop moves on.
|
|
286
|
+
void run(next.id).finally(() => {
|
|
287
|
+
running--;
|
|
288
|
+
pump();
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function upload(ids?: readonly string[]): Promise<UploadItem[]> {
|
|
294
|
+
const wanted = ids ? new Set(ids) : undefined;
|
|
295
|
+
const tracked = snapshot.items.filter(
|
|
296
|
+
(item) =>
|
|
297
|
+
(!wanted || wanted.has(item.id)) &&
|
|
298
|
+
(STARTABLE.has(item.status) || IN_FLIGHT.has(item.status)),
|
|
299
|
+
);
|
|
300
|
+
const starting = new Set(
|
|
301
|
+
tracked.filter((item) => STARTABLE.has(item.status)).map((item) => item.id),
|
|
302
|
+
);
|
|
303
|
+
if (tracked.length === 0) return Promise.resolve([]);
|
|
304
|
+
|
|
305
|
+
const done = new Promise<UploadItem[]>((resolve) =>
|
|
306
|
+
waiters.push({ ids: new Set(tracked.map((item) => item.id)), resolve }),
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const hasTransport = config.transport !== undefined;
|
|
310
|
+
commit({
|
|
311
|
+
...snapshot,
|
|
312
|
+
items: snapshot.items.map((item): UploadItem => {
|
|
313
|
+
if (!starting.has(item.id)) return item;
|
|
314
|
+
if (!hasTransport) return { ...item, status: "error", error: NO_TRANSPORT };
|
|
315
|
+
return item.status === "canceled"
|
|
316
|
+
? {
|
|
317
|
+
...item,
|
|
318
|
+
status: "queued",
|
|
319
|
+
error: undefined,
|
|
320
|
+
uploadId: createUploadId(),
|
|
321
|
+
uploadedBytes: 0,
|
|
322
|
+
progress: 0,
|
|
323
|
+
chunksDone: 0,
|
|
324
|
+
}
|
|
325
|
+
: { ...item, status: "queued", error: undefined };
|
|
326
|
+
}),
|
|
327
|
+
});
|
|
328
|
+
pump();
|
|
329
|
+
|
|
330
|
+
if (starting.size > 0) void done.then((items) => config.onComplete?.(items));
|
|
331
|
+
return done;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function addFiles(input: Iterable<File> | ArrayLike<File>): AddResult {
|
|
335
|
+
const current = snapshot.items;
|
|
336
|
+
const { accepted, rejected } = partitionFiles(
|
|
337
|
+
Array.from(input),
|
|
338
|
+
current.map((item) => item.file),
|
|
339
|
+
config,
|
|
340
|
+
);
|
|
341
|
+
const chunkSize = Math.max(1, config.chunkSize ?? DEFAULT_CHUNK_SIZE);
|
|
342
|
+
const added: UploadItem[] = accepted.map((file) => ({
|
|
343
|
+
id: createUploadId(),
|
|
344
|
+
uploadId: createUploadId(),
|
|
345
|
+
file,
|
|
346
|
+
status: "idle",
|
|
347
|
+
uploadedBytes: 0,
|
|
348
|
+
progress: 0,
|
|
349
|
+
chunkSize,
|
|
350
|
+
chunksDone: 0,
|
|
351
|
+
totalChunks: chunkCount(file.size, chunkSize),
|
|
352
|
+
}));
|
|
353
|
+
const rejections: FileRejection[] = rejected.map((entry) => ({
|
|
354
|
+
...entry,
|
|
355
|
+
id: createUploadId(),
|
|
356
|
+
}));
|
|
357
|
+
if (added.length === 0 && rejections.length === 0) return { accepted: [], rejected: [] };
|
|
358
|
+
|
|
359
|
+
const replace = !(config.multiple ?? false) && added.length > 0;
|
|
360
|
+
if (replace) for (const item of current) stop(item.id, "remove");
|
|
361
|
+
|
|
362
|
+
commit({ items: replace ? added : [...current, ...added], rejections });
|
|
363
|
+
if (added.length > 0) config.onChange?.(selectedFiles());
|
|
364
|
+
if (rejections.length > 0) config.onRejected?.(rejections);
|
|
365
|
+
if (added.length > 0 && config.autoUpload && config.transport) {
|
|
366
|
+
void upload(added.map((item) => item.id));
|
|
367
|
+
}
|
|
368
|
+
return { accepted: added, rejected: rejections };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function remove(id: string) {
|
|
372
|
+
if (!find(id)) return;
|
|
373
|
+
stop(id, "remove");
|
|
374
|
+
commit({ ...snapshot, items: snapshot.items.filter((item) => item.id !== id) });
|
|
375
|
+
config.onChange?.(selectedFiles());
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function clear() {
|
|
379
|
+
if (snapshot.items.length === 0 && snapshot.rejections.length === 0) return;
|
|
380
|
+
const hadItems = snapshot.items.length > 0;
|
|
381
|
+
for (const item of snapshot.items) stop(item.id, "remove");
|
|
382
|
+
commit(EMPTY);
|
|
383
|
+
if (hadItems) config.onChange?.([]);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function pause(id?: string) {
|
|
387
|
+
for (const item of snapshot.items) {
|
|
388
|
+
if ((id !== undefined && item.id !== id) || !IN_FLIGHT.has(item.status)) continue;
|
|
389
|
+
if (!stop(item.id, "pause")) patch(item.id, { status: "paused" });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function cancel(id?: string) {
|
|
394
|
+
for (const item of snapshot.items) {
|
|
395
|
+
if (id !== undefined && item.id !== id) continue;
|
|
396
|
+
if (item.status === "idle" || item.status === "success" || item.status === "canceled") {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (!stop(item.id, "cancel")) {
|
|
400
|
+
patch(item.id, { status: "canceled", uploadedBytes: 0, progress: 0, chunksDone: 0 });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function dismissRejection(id?: string) {
|
|
406
|
+
const rejections =
|
|
407
|
+
id === undefined ? [] : snapshot.rejections.filter((rejection) => rejection.id !== id);
|
|
408
|
+
if (rejections.length !== snapshot.rejections.length) commit({ ...snapshot, rejections });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
subscribe(listener) {
|
|
413
|
+
listeners.add(listener);
|
|
414
|
+
return () => {
|
|
415
|
+
listeners.delete(listener);
|
|
416
|
+
};
|
|
417
|
+
},
|
|
418
|
+
getSnapshot: () => snapshot,
|
|
419
|
+
getServerSnapshot: () => EMPTY,
|
|
420
|
+
addFiles,
|
|
421
|
+
remove,
|
|
422
|
+
clear,
|
|
423
|
+
upload,
|
|
424
|
+
pause,
|
|
425
|
+
cancel,
|
|
426
|
+
dismissRejection,
|
|
427
|
+
configure(next) {
|
|
428
|
+
config = { ...config, ...next };
|
|
429
|
+
pump();
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ChunkRequest, Transport } from "./types";
|
|
2
|
+
|
|
3
|
+
/** Form field names for each chunk request. The defaults match the Go chunk-uploader. */
|
|
4
|
+
export interface ChunkFieldNames {
|
|
5
|
+
chunk: string;
|
|
6
|
+
fileName: string;
|
|
7
|
+
chunkIndex: string;
|
|
8
|
+
totalChunks: string;
|
|
9
|
+
fileSize: string;
|
|
10
|
+
uploadId: string;
|
|
11
|
+
additionalParams: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_FIELD_NAMES: ChunkFieldNames = {
|
|
15
|
+
chunk: "chunk",
|
|
16
|
+
fileName: "fileName",
|
|
17
|
+
chunkIndex: "chunkIndex",
|
|
18
|
+
totalChunks: "totalChunks",
|
|
19
|
+
fileSize: "fileSize",
|
|
20
|
+
uploadId: "uploadId",
|
|
21
|
+
additionalParams: "additionalParams",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type UploadParams = Record<string, unknown>;
|
|
25
|
+
|
|
26
|
+
export interface HttpTransportOptions {
|
|
27
|
+
endpoint: string | ((request: ChunkRequest) => string);
|
|
28
|
+
method?: "POST" | "PUT" | "PATCH";
|
|
29
|
+
/** Static headers, or a function (async allowed) for tokens that expire. */
|
|
30
|
+
headers?:
|
|
31
|
+
| Record<string, string>
|
|
32
|
+
| ((request: ChunkRequest) => Record<string, string> | Promise<Record<string, string>>);
|
|
33
|
+
/** Send cookies on cross-origin requests. */
|
|
34
|
+
withCredentials?: boolean;
|
|
35
|
+
fieldNames?: Partial<ChunkFieldNames>;
|
|
36
|
+
/** Extra data sent as JSON in the `additionalParams` field. */
|
|
37
|
+
params?: UploadParams | ((file: File) => UploadParams);
|
|
38
|
+
/** Turns the response text into the value stored on the item. Default: JSON, else text. */
|
|
39
|
+
parseResponse?(body: string, xhr: XMLHttpRequest): unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A non-2xx response. `body` is the parsed response. */
|
|
43
|
+
export class UploadHttpError extends Error {
|
|
44
|
+
readonly status: number;
|
|
45
|
+
readonly body: unknown;
|
|
46
|
+
|
|
47
|
+
constructor(status: number, body: unknown) {
|
|
48
|
+
super(`Upload failed with status ${status}`);
|
|
49
|
+
this.name = "UploadHttpError";
|
|
50
|
+
this.status = status;
|
|
51
|
+
this.body = body;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const abortError = () => new DOMException("Upload aborted", "AbortError");
|
|
56
|
+
|
|
57
|
+
/** Network failures, timeouts, rate limits and server errors are worth another try; 4xx is not. */
|
|
58
|
+
export function isRetryable(error: unknown): boolean {
|
|
59
|
+
if (error instanceof DOMException && error.name === "AbortError") return false;
|
|
60
|
+
if (error instanceof UploadHttpError) {
|
|
61
|
+
return error.status >= 500 || error.status === 408 || error.status === 429;
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The multipart body for one chunk. Metadata comes first so streaming parsers see it before the bytes. */
|
|
67
|
+
export function buildChunkForm(
|
|
68
|
+
request: ChunkRequest,
|
|
69
|
+
options: Pick<HttpTransportOptions, "fieldNames" | "params"> = {},
|
|
70
|
+
): FormData {
|
|
71
|
+
const names = { ...DEFAULT_FIELD_NAMES, ...options.fieldNames };
|
|
72
|
+
const form = new FormData();
|
|
73
|
+
form.append(names.uploadId, request.uploadId);
|
|
74
|
+
form.append(names.fileName, request.file.name);
|
|
75
|
+
form.append(names.chunkIndex, String(request.index));
|
|
76
|
+
form.append(names.totalChunks, String(request.total));
|
|
77
|
+
form.append(names.fileSize, String(request.file.size));
|
|
78
|
+
|
|
79
|
+
const params =
|
|
80
|
+
typeof options.params === "function" ? options.params(request.file) : options.params;
|
|
81
|
+
if (params && Object.keys(params).length > 0) {
|
|
82
|
+
form.append(names.additionalParams, JSON.stringify(params));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
form.append(names.chunk, request.chunk, request.file.name);
|
|
86
|
+
return form;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseBody(text: string): unknown {
|
|
90
|
+
if (!text) return null;
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(text);
|
|
93
|
+
} catch {
|
|
94
|
+
return text;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Sends each chunk as `multipart/form-data`. Uses XMLHttpRequest rather than
|
|
100
|
+
* fetch because fetch still can't report upload progress in every browser.
|
|
101
|
+
*/
|
|
102
|
+
export function createHttpTransport(options: HttpTransportOptions): Transport {
|
|
103
|
+
return async (request) => {
|
|
104
|
+
const url =
|
|
105
|
+
typeof options.endpoint === "function" ? options.endpoint(request) : options.endpoint;
|
|
106
|
+
const headers =
|
|
107
|
+
typeof options.headers === "function" ? await options.headers(request) : options.headers;
|
|
108
|
+
const body = buildChunkForm(request, options);
|
|
109
|
+
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const { signal } = request;
|
|
112
|
+
if (signal.aborted) {
|
|
113
|
+
reject(abortError());
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const xhr = new XMLHttpRequest();
|
|
118
|
+
const onAbort = () => xhr.abort();
|
|
119
|
+
|
|
120
|
+
xhr.open(options.method ?? "POST", url);
|
|
121
|
+
xhr.withCredentials = options.withCredentials ?? false;
|
|
122
|
+
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
123
|
+
xhr.setRequestHeader(name, value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// `loaded` includes the multipart framing, so cap it at the chunk's size.
|
|
127
|
+
xhr.upload.onprogress = (event) =>
|
|
128
|
+
request.onProgress(Math.min(event.loaded, request.chunk.size));
|
|
129
|
+
xhr.onload = () => {
|
|
130
|
+
const parsed = options.parseResponse
|
|
131
|
+
? options.parseResponse(xhr.responseText, xhr)
|
|
132
|
+
: parseBody(xhr.responseText);
|
|
133
|
+
if (xhr.status >= 200 && xhr.status < 300) resolve(parsed);
|
|
134
|
+
else reject(new UploadHttpError(xhr.status, parsed));
|
|
135
|
+
};
|
|
136
|
+
xhr.onerror = () => reject(new Error("Network error"));
|
|
137
|
+
xhr.ontimeout = () => reject(new Error("Request timed out"));
|
|
138
|
+
xhr.onabort = () => reject(abortError());
|
|
139
|
+
xhr.onloadend = () => signal.removeEventListener("abort", onAbort);
|
|
140
|
+
|
|
141
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
142
|
+
xhr.send(body);
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export type UploadStatus =
|
|
2
|
+
/** Selected, not sent yet. */
|
|
3
|
+
| "idle"
|
|
4
|
+
/** Waiting for a free upload slot. */
|
|
5
|
+
| "queued"
|
|
6
|
+
| "uploading"
|
|
7
|
+
| "paused"
|
|
8
|
+
| "success"
|
|
9
|
+
| "error"
|
|
10
|
+
| "canceled";
|
|
11
|
+
|
|
12
|
+
export interface UploadItem {
|
|
13
|
+
/** Stable key for the row. */
|
|
14
|
+
id: string;
|
|
15
|
+
/**
|
|
16
|
+
* Sent with every chunk so the server can group them. Resuming after a pause
|
|
17
|
+
* or an error keeps it; starting again after a cancel gets a new one.
|
|
18
|
+
*/
|
|
19
|
+
uploadId: string;
|
|
20
|
+
file: File;
|
|
21
|
+
status: UploadStatus;
|
|
22
|
+
/** Bytes confirmed by the server plus the bytes of the chunk in flight. */
|
|
23
|
+
uploadedBytes: number;
|
|
24
|
+
/** 0 to 1. */
|
|
25
|
+
progress: number;
|
|
26
|
+
chunkSize: number;
|
|
27
|
+
chunksDone: number;
|
|
28
|
+
totalChunks: number;
|
|
29
|
+
error?: string;
|
|
30
|
+
/** The server's answer to the most recent chunk; for the last chunk, the finished file. */
|
|
31
|
+
response?: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type RejectionCode =
|
|
35
|
+
| "file-type"
|
|
36
|
+
| "file-too-large"
|
|
37
|
+
| "file-too-small"
|
|
38
|
+
| "too-many-files"
|
|
39
|
+
| "duplicate"
|
|
40
|
+
| "custom";
|
|
41
|
+
|
|
42
|
+
export interface FileRejection {
|
|
43
|
+
id: string;
|
|
44
|
+
file: File;
|
|
45
|
+
code: RejectionCode;
|
|
46
|
+
/** Set for `custom`: the text returned by `validate`. */
|
|
47
|
+
message?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface UploaderSnapshot {
|
|
51
|
+
items: readonly UploadItem[];
|
|
52
|
+
/** From the most recent selection; replaced by the next one. */
|
|
53
|
+
rejections: readonly FileRejection[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One chunk, as handed to a transport. */
|
|
57
|
+
export interface ChunkRequest {
|
|
58
|
+
file: File;
|
|
59
|
+
chunk: Blob;
|
|
60
|
+
/** 0-based. */
|
|
61
|
+
index: number;
|
|
62
|
+
total: number;
|
|
63
|
+
/** Byte position of this chunk in the file. */
|
|
64
|
+
offset: number;
|
|
65
|
+
uploadId: string;
|
|
66
|
+
signal: AbortSignal;
|
|
67
|
+
/** Report bytes of this chunk sent so far. */
|
|
68
|
+
onProgress(loaded: number): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Sends one chunk and resolves with the server's answer. Throw to fail it. */
|
|
72
|
+
export type Transport = (request: ChunkRequest) => Promise<unknown>;
|
|
73
|
+
|
|
74
|
+
/** A file already on the server, e.g. when editing a saved record. */
|
|
75
|
+
export interface ExistingFile {
|
|
76
|
+
id: string;
|
|
77
|
+
name: string;
|
|
78
|
+
size?: number;
|
|
79
|
+
type?: string;
|
|
80
|
+
/** Download link; also the preview for images. */
|
|
81
|
+
url?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type FileKind =
|
|
85
|
+
| "image"
|
|
86
|
+
| "video"
|
|
87
|
+
| "audio"
|
|
88
|
+
| "pdf"
|
|
89
|
+
| "spreadsheet"
|
|
90
|
+
| "document"
|
|
91
|
+
| "archive"
|
|
92
|
+
| "other";
|
|
93
|
+
|
|
94
|
+
export type UploaderSize = "sm" | "md" | "lg";
|
|
95
|
+
|
|
96
|
+
export interface UploaderLocaleText {
|
|
97
|
+
dropzone: string;
|
|
98
|
+
browse: string;
|
|
99
|
+
dropHere: string;
|
|
100
|
+
hintTypes(types: string): string;
|
|
101
|
+
hintMaxSize(size: string): string;
|
|
102
|
+
hintMaxFiles(count: string): string;
|
|
103
|
+
upload(count: string): string;
|
|
104
|
+
cancelAll: string;
|
|
105
|
+
clear: string;
|
|
106
|
+
dismiss: string;
|
|
107
|
+
remove(name: string): string;
|
|
108
|
+
cancel(name: string): string;
|
|
109
|
+
pause(name: string): string;
|
|
110
|
+
resume(name: string): string;
|
|
111
|
+
retry(name: string): string;
|
|
112
|
+
download(name: string): string;
|
|
113
|
+
queued: string;
|
|
114
|
+
paused: string;
|
|
115
|
+
uploaded: string;
|
|
116
|
+
canceled: string;
|
|
117
|
+
failed: string;
|
|
118
|
+
progress(sent: string, total: string, percent: string): string;
|
|
119
|
+
rejectFileType(name: string): string;
|
|
120
|
+
rejectTooLarge(name: string, max: string): string;
|
|
121
|
+
rejectTooSmall(name: string, min: string): string;
|
|
122
|
+
rejectTooMany(max: string): string;
|
|
123
|
+
rejectDuplicate(name: string): string;
|
|
124
|
+
announceDone(name: string): string;
|
|
125
|
+
announceFailed(name: string): string;
|
|
126
|
+
}
|