@gentleduck/upload 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +297 -0
- package/dist/core/index.d.ts +2 -3
- package/dist/core/index.js +1 -1
- package/dist/core-BVgloTiP.js +10 -0
- package/dist/index-DCqyBVcx.d.ts +1441 -0
- package/dist/index-DsZHPmJm.d.ts +225 -0
- package/dist/index-KROXtLQh.d.ts +281 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +2 -90
- package/dist/react/index.js +1 -1
- package/dist/react-euPrDv24.js +1 -0
- package/dist/strategies/index.d.ts +2 -56
- package/dist/strategies/index.js +1 -1
- package/dist/strategies-CUF0s68j.js +1 -0
- package/package.json +3 -2
- package/src/__tests__/emitter.test.ts +85 -0
- package/src/__tests__/fingerprint.test.ts +49 -0
- package/src/__tests__/guards.test.ts +40 -0
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/__tests__/persistence.test.ts +191 -0
- package/src/__tests__/sanitize-filename.test.ts +109 -0
- package/src/__tests__/sleep.test.ts +34 -0
- package/src/__tests__/static-analysis.test.ts +145 -0
- package/src/__tests__/store-libs.test.ts +192 -0
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/__tests__/transport-libs.test.ts +42 -0
- package/src/__tests__/transport.test.ts +182 -0
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +373 -0
- package/src/core/contracts/index.ts +3 -8
- package/src/core/contracts/transport/index.ts +3 -2
- package/src/core/contracts/transport/transport.fetch.ts +203 -0
- package/src/core/contracts/transport/transport.libs.ts +13 -39
- package/src/core/contracts/transport/transport.ts +96 -90
- package/src/core/contracts/transport/transport.types.ts +71 -35
- package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
- package/src/core/engine/engine.libs.ts +35 -0
- package/src/core/engine/engine.types.ts +490 -0
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +93 -53
- package/src/core/engine/store/handlers/add-file.ts +43 -48
- package/src/core/engine/store/handlers/cancel.ts +13 -8
- package/src/core/engine/store/handlers/clean-up.ts +11 -8
- package/src/core/engine/store/handlers/create-intent.ts +82 -27
- package/src/core/engine/store/handlers/finalize.ts +30 -13
- package/src/core/engine/store/handlers/index.ts +7 -7
- package/src/core/engine/store/handlers/pause.ts +8 -6
- package/src/core/engine/store/handlers/run-upload.ts +54 -39
- package/src/core/engine/store/index.ts +27 -21
- package/src/core/engine/store/store.dispatch.ts +10 -8
- package/src/core/engine/store/store.libs.ts +197 -94
- package/src/core/engine/store/store.runtime.ts +52 -53
- package/src/core/engine/store/store.schedule.ts +27 -21
- package/src/core/engine/store/store.types.ts +177 -227
- package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
- package/src/core/engine/validation/file.ts +8 -4
- package/src/core/engine/validation/index.ts +1 -0
- package/src/core/engine/validation/intent.ts +49 -27
- package/src/core/engine/validation/list.ts +14 -15
- package/src/core/engine/validation/mime.ts +39 -0
- package/src/core/errors.ts +271 -0
- package/src/core/index.ts +28 -5
- package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
- package/src/core/persistence/__tests__/persistence.test.ts +85 -0
- package/src/core/persistence/adapters.indexeddb.ts +9 -3
- package/src/core/persistence/adapters.local.ts +9 -3
- package/src/core/persistence/adapters.memory.ts +35 -13
- package/src/core/persistence/index.ts +5 -5
- package/src/core/persistence/persistence.ts +73 -60
- package/src/core/persistence/persistence.types.ts +140 -60
- package/src/core/utils/__tests__/guards.test.ts +93 -0
- package/src/core/utils/__tests__/url-safety.test.ts +112 -0
- package/src/core/utils/async.ts +7 -2
- package/src/core/utils/constants.ts +3 -9
- package/src/core/utils/emitter/emitter.ts +126 -0
- package/src/core/utils/emitter/emitter.types.ts +33 -16
- package/src/core/utils/emitter/index.ts +2 -74
- package/src/core/utils/fingerprint.ts +16 -5
- package/src/core/utils/guards.ts +91 -1
- package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
- package/src/core/utils/hash/index.ts +126 -0
- package/src/core/utils/hash/sha256.ts +179 -0
- package/src/core/utils/id.ts +33 -1
- package/src/core/utils/index.ts +15 -6
- package/src/core/utils/mime-sniff.ts +119 -0
- package/src/core/utils/sanitize-filename.ts +85 -0
- package/src/core/utils/url-safety.ts +156 -0
- package/src/react/__tests__/use-dropzone.test.ts +64 -0
- package/src/react/index.ts +5 -2
- package/src/react/upload-provider.tsx +59 -95
- package/src/react/uploader.types.ts +80 -0
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +97 -93
- package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
- package/src/strategies/_shared/retry.ts +79 -0
- package/src/strategies/index.ts +9 -15
- package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
- package/src/strategies/multipart/index.ts +211 -116
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +113 -71
- package/src/strategies/put/__tests__/put.test.ts +132 -0
- package/src/strategies/put/index.ts +139 -0
- package/src/strategies/registry/index.ts +12 -9
- package/src/strategies/tus/__tests__/tus.test.ts +159 -0
- package/src/strategies/tus/index.ts +211 -0
- package/dist/async-DxRWu4le.js +0 -1
- package/dist/core-BNxznRif.js +0 -2
- package/dist/index-BXg9WIce.d.ts +0 -987
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- package/src/core/client/config.types.ts +0 -77
- package/src/core/client/index.ts +0 -4
- package/src/core/client/plugins.types.ts +0 -46
- package/src/core/client/retry.types.ts +0 -6
- package/src/core/client/uploadClient.ts +0 -19
- package/src/core/contracts/backendApi.ts +0 -62
- package/src/core/contracts/cursor.types.ts +0 -23
- package/src/core/contracts/errors.types.ts +0 -67
- package/src/core/contracts/fingerprint.types.ts +0 -18
- package/src/core/contracts/intent.types.ts +0 -39
- package/src/core/contracts/result.types.ts +0 -11
- package/src/core/contracts/strategy.types.ts +0 -45
- package/src/core/engine/commands.types.ts +0 -17
- package/src/core/engine/event-map.types.ts +0 -45
- package/src/core/engine/internal-events.types.ts +0 -222
- package/src/core/engine/outcome.types.ts +0 -9
- package/src/core/engine/progress.types.ts +0 -15
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type * as React from 'react'
|
|
2
|
+
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
3
|
+
import type { Contracts } from '../core'
|
|
4
|
+
import type { Store } from '../core/engine/store'
|
|
5
|
+
import { useUploadStore } from './upload-provider'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Test a file against one token from an `accept` string. A token is a MIME type
|
|
9
|
+
* (`image/png`), a MIME wildcard (`image/*`), or a filename extension (`.pdf`).
|
|
10
|
+
* @internal exported for tests.
|
|
11
|
+
*/
|
|
12
|
+
export function matchesAcceptToken(file: File, token: string): boolean {
|
|
13
|
+
const t = token.trim().toLowerCase()
|
|
14
|
+
if (!t) return false
|
|
15
|
+
|
|
16
|
+
if (t.startsWith('.')) {
|
|
17
|
+
return file.name.toLowerCase().endsWith(t)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const type = (file.type || '').toLowerCase()
|
|
21
|
+
if (t.endsWith('/*')) {
|
|
22
|
+
return type.startsWith(`${t.slice(0, -1)}`) // "image/" prefix
|
|
23
|
+
}
|
|
24
|
+
return type === t
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* True when `file` satisfies an `accept` list (comma-separated tokens). An empty
|
|
29
|
+
* or absent list accepts everything.
|
|
30
|
+
* @internal exported for tests.
|
|
31
|
+
*/
|
|
32
|
+
export function fileMatchesAccept(file: File, accept?: string): boolean {
|
|
33
|
+
if (!accept) return true
|
|
34
|
+
const tokens = accept
|
|
35
|
+
.split(',')
|
|
36
|
+
.map((s) => s.trim())
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
if (tokens.length === 0) return true
|
|
39
|
+
return tokens.some((tok) => matchesAcceptToken(file, tok))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Filter a dropped/selected file list by `accept` and collapse to a single file
|
|
44
|
+
* when `multiple` is false.
|
|
45
|
+
* @internal exported for tests.
|
|
46
|
+
*/
|
|
47
|
+
export function selectFiles(
|
|
48
|
+
files: File[],
|
|
49
|
+
opts: { accept?: string | undefined; multiple?: boolean | undefined },
|
|
50
|
+
): File[] {
|
|
51
|
+
const accepted = files.filter((f) => fileMatchesAccept(f, opts.accept))
|
|
52
|
+
return opts.multiple === false ? accepted.slice(0, 1) : accepted
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Options for {@link useDropzone}. */
|
|
56
|
+
export type UseDropzoneOptions<P extends string> = {
|
|
57
|
+
/** Purpose forwarded to the `addFiles` command. */
|
|
58
|
+
purpose: P
|
|
59
|
+
/** Optional caller metadata forwarded to every backend API call. */
|
|
60
|
+
meta?: Record<string, unknown>
|
|
61
|
+
/**
|
|
62
|
+
* Comma-separated `accept` list (`"image/*,.pdf"`). Filters dropped files and
|
|
63
|
+
* populates the input's `accept` attribute. Absent = accept everything.
|
|
64
|
+
*/
|
|
65
|
+
accept?: string
|
|
66
|
+
/** Allow multiple files. Defaults to `true`. */
|
|
67
|
+
multiple?: boolean
|
|
68
|
+
/** Disable all interaction (no drop, no click-to-open). Defaults to `false`. */
|
|
69
|
+
disabled?: boolean
|
|
70
|
+
/** Do not open the file dialog when the root is clicked. Defaults to `false`. */
|
|
71
|
+
noClick?: boolean
|
|
72
|
+
/**
|
|
73
|
+
* Override the default dispatch. When provided, accepted files are handed here
|
|
74
|
+
* instead of being dispatched as `addFiles` to the store.
|
|
75
|
+
*/
|
|
76
|
+
onFiles?: (files: File[]) => void
|
|
77
|
+
/** Store instance to dispatch to. Falls back to the `<UploadProvider>` store. */
|
|
78
|
+
store?: Store.UploadStore<Contracts.Intent.Map, Contracts.Cursor.Map<Contracts.Intent.Map>, P, Contracts.Result.Base>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Return value of {@link useDropzone}. */
|
|
82
|
+
export type UseDropzoneReturn = {
|
|
83
|
+
/** True while files are being dragged over the root. */
|
|
84
|
+
isDragging: boolean
|
|
85
|
+
/** Programmatically open the native file picker. */
|
|
86
|
+
open: () => void
|
|
87
|
+
/** Ref to attach to the hidden `<input type="file">`. */
|
|
88
|
+
inputRef: React.RefObject<HTMLInputElement | null>
|
|
89
|
+
/** Spread onto the drop-target element. */
|
|
90
|
+
getRootProps: (extra?: React.HTMLAttributes<HTMLElement>) => React.HTMLAttributes<HTMLElement>
|
|
91
|
+
/** Spread onto the hidden `<input type="file">`. */
|
|
92
|
+
getInputProps: (
|
|
93
|
+
extra?: React.InputHTMLAttributes<HTMLInputElement>,
|
|
94
|
+
) => React.InputHTMLAttributes<HTMLInputElement> & { ref: React.Ref<HTMLInputElement> }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Headless drag-and-drop / file-picker hook.
|
|
99
|
+
*
|
|
100
|
+
* Wires drop and file-input events to the upload store's `addFiles` command
|
|
101
|
+
* (or a custom `onFiles` handler). Returns prop-getters for the drop target and
|
|
102
|
+
* a hidden file input plus a live `isDragging` flag — styling and markup are
|
|
103
|
+
* entirely yours.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```tsx
|
|
107
|
+
* function Drop() {
|
|
108
|
+
* const { getRootProps, getInputProps, isDragging, open } = useDropzone({
|
|
109
|
+
* purpose: 'attachment',
|
|
110
|
+
* accept: 'image/*',
|
|
111
|
+
* });
|
|
112
|
+
* return (
|
|
113
|
+
* <div {...getRootProps()} data-active={isDragging}>
|
|
114
|
+
* <input {...getInputProps()} />
|
|
115
|
+
* <button type="button" onClick={open}>Choose files</button>
|
|
116
|
+
* </div>
|
|
117
|
+
* );
|
|
118
|
+
* }
|
|
119
|
+
* ```
|
|
120
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
121
|
+
*/
|
|
122
|
+
export function useDropzone<P extends string>(options: UseDropzoneOptions<P>): UseDropzoneReturn {
|
|
123
|
+
const { purpose, meta, accept, multiple = true, disabled = false, noClick = false, onFiles } = options
|
|
124
|
+
|
|
125
|
+
// Read the context store unconditionally (hooks rule); prefer an explicit one.
|
|
126
|
+
const contextStore = useUploadStore<
|
|
127
|
+
Contracts.Intent.Map,
|
|
128
|
+
Contracts.Cursor.Map<Contracts.Intent.Map>,
|
|
129
|
+
P,
|
|
130
|
+
Contracts.Result.Base
|
|
131
|
+
>()
|
|
132
|
+
const store = options.store ?? contextStore
|
|
133
|
+
|
|
134
|
+
const inputRef = useRef<HTMLInputElement | null>(null)
|
|
135
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
136
|
+
// Depth counter so nested dragenter/dragleave don't flicker the flag.
|
|
137
|
+
const dragDepth = useRef(0)
|
|
138
|
+
|
|
139
|
+
const handleFiles = useCallback(
|
|
140
|
+
(list: FileList | null) => {
|
|
141
|
+
if (!list || list.length === 0) return
|
|
142
|
+
const chosen = selectFiles(Array.from(list), { accept, multiple })
|
|
143
|
+
if (chosen.length === 0) return
|
|
144
|
+
if (onFiles) {
|
|
145
|
+
onFiles(chosen)
|
|
146
|
+
} else {
|
|
147
|
+
store.dispatch({ type: 'addFiles', files: chosen, purpose, meta })
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
[accept, multiple, onFiles, store, purpose, meta],
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const open = useCallback(() => {
|
|
154
|
+
if (disabled) return
|
|
155
|
+
inputRef.current?.click()
|
|
156
|
+
}, [disabled])
|
|
157
|
+
|
|
158
|
+
const getRootProps = useCallback<UseDropzoneReturn['getRootProps']>(
|
|
159
|
+
(extra = {}) => ({
|
|
160
|
+
...extra,
|
|
161
|
+
onDragEnter: (e) => {
|
|
162
|
+
extra.onDragEnter?.(e)
|
|
163
|
+
if (disabled) return
|
|
164
|
+
e.preventDefault()
|
|
165
|
+
e.stopPropagation()
|
|
166
|
+
dragDepth.current += 1
|
|
167
|
+
setIsDragging(true)
|
|
168
|
+
},
|
|
169
|
+
onDragOver: (e) => {
|
|
170
|
+
extra.onDragOver?.(e)
|
|
171
|
+
if (disabled) return
|
|
172
|
+
// Required so the browser fires `drop` instead of navigating.
|
|
173
|
+
e.preventDefault()
|
|
174
|
+
e.stopPropagation()
|
|
175
|
+
},
|
|
176
|
+
onDragLeave: (e) => {
|
|
177
|
+
extra.onDragLeave?.(e)
|
|
178
|
+
if (disabled) return
|
|
179
|
+
e.preventDefault()
|
|
180
|
+
e.stopPropagation()
|
|
181
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1)
|
|
182
|
+
if (dragDepth.current === 0) setIsDragging(false)
|
|
183
|
+
},
|
|
184
|
+
onDrop: (e) => {
|
|
185
|
+
extra.onDrop?.(e)
|
|
186
|
+
if (disabled) return
|
|
187
|
+
e.preventDefault()
|
|
188
|
+
e.stopPropagation()
|
|
189
|
+
dragDepth.current = 0
|
|
190
|
+
setIsDragging(false)
|
|
191
|
+
handleFiles(e.dataTransfer?.files ?? null)
|
|
192
|
+
},
|
|
193
|
+
onClick: (e) => {
|
|
194
|
+
extra.onClick?.(e)
|
|
195
|
+
if (disabled || noClick) return
|
|
196
|
+
open()
|
|
197
|
+
},
|
|
198
|
+
}),
|
|
199
|
+
[disabled, noClick, open, handleFiles],
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
const getInputProps = useCallback<UseDropzoneReturn['getInputProps']>(
|
|
203
|
+
(extra = {}) => ({
|
|
204
|
+
...extra,
|
|
205
|
+
ref: inputRef,
|
|
206
|
+
type: 'file',
|
|
207
|
+
multiple,
|
|
208
|
+
disabled,
|
|
209
|
+
...(accept ? { accept } : {}),
|
|
210
|
+
// Visually hidden; caller styles can extend but not un-hide by accident.
|
|
211
|
+
style: { display: 'none', ...extra.style },
|
|
212
|
+
onChange: (e) => {
|
|
213
|
+
extra.onChange?.(e)
|
|
214
|
+
handleFiles(e.target.files)
|
|
215
|
+
// Reset so selecting the same file again re-fires change.
|
|
216
|
+
e.target.value = ''
|
|
217
|
+
},
|
|
218
|
+
}),
|
|
219
|
+
[multiple, disabled, accept, handleFiles],
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
return useMemo(
|
|
223
|
+
() => ({ isDragging, open, inputRef, getRootProps, getInputProps }),
|
|
224
|
+
[isDragging, open, getRootProps, getInputProps],
|
|
225
|
+
)
|
|
226
|
+
}
|
|
@@ -1,108 +1,90 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
1
|
import * as React from 'react'
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
UploadItem,
|
|
10
|
-
UploadPhase,
|
|
11
|
-
UploadResultBase,
|
|
12
|
-
} from '../core'
|
|
13
|
-
import type { UploadStore } from '../core/engine/store'
|
|
14
|
-
import { useOptionalUploadStore, useUploadStore } from './upload-provider'
|
|
15
|
-
|
|
16
|
-
export function createUploadFactory<
|
|
17
|
-
M extends IntentMap,
|
|
18
|
-
C extends CursorMap<M>,
|
|
19
|
-
P extends string,
|
|
20
|
-
R extends UploadResultBase = UploadResultBase,
|
|
21
|
-
>(store: UploadStore<M, C, P, R>) {
|
|
22
|
-
return () => useUploader<M, C, P, R>(store)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export type PickItemsByPhase<Phase extends UploadPhase> = Extract<
|
|
26
|
-
UploadItem<IntentMap, CursorMap<IntentMap>, string, UploadResultBase>,
|
|
27
|
-
{ phase: Phase }
|
|
28
|
-
>
|
|
29
|
-
|
|
30
|
-
type Uploader<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase> = {
|
|
31
|
-
items: UploadItem<M, C, P, R>[]
|
|
32
|
-
byPhase: Record<string, UploadItem<M, C, P, R>[]>
|
|
33
|
-
dispatch: (cmd: UploadCommand<P>) => void
|
|
34
|
-
on: <K extends keyof UploadEventMap<M, C, P, R> & string>(
|
|
35
|
-
type: K,
|
|
36
|
-
cb: (payload: UploadEventMap<M, C, P, R>[K]) => void,
|
|
37
|
-
) => () => void
|
|
38
|
-
off: <K extends keyof UploadEventMap<M, C, P, R> & string>(
|
|
39
|
-
type: K,
|
|
40
|
-
cb: (payload: UploadEventMap<M, C, P, R>[K]) => void,
|
|
41
|
-
) => () => void
|
|
42
|
-
uploading: UploadItem<M, C, P, R>[]
|
|
43
|
-
paused: UploadItem<M, C, P, R>[]
|
|
44
|
-
completed: UploadItem<M, C, P, R>[]
|
|
45
|
-
failed: UploadItem<M, C, P, R>[]
|
|
46
|
-
ready: UploadItem<M, C, P, R>[]
|
|
47
|
-
}
|
|
2
|
+
import { useMemo, useSyncExternalStore } from 'react'
|
|
3
|
+
import type { Contracts, Engine } from '../core'
|
|
4
|
+
import type { Store } from '../core/engine/store'
|
|
5
|
+
import { useUploadStore } from './upload-provider'
|
|
6
|
+
import type { Uploader } from './uploader.types'
|
|
48
7
|
|
|
49
8
|
/**
|
|
50
|
-
*
|
|
9
|
+
* Hook to consume the upload store state, track files, and interact with the upload engine.
|
|
51
10
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
11
|
+
* Exposes files, categorizes items by status phase (ready, uploading, paused, completed, failed),
|
|
12
|
+
* and registers action dispatchers.
|
|
13
|
+
*
|
|
14
|
+
* Can optionally be passed a specific store instance directly. Otherwise, it will read the store from context.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```tsx
|
|
18
|
+
* function UploadForm() {
|
|
19
|
+
* const { items, uploading, dispatch } = useUploader();
|
|
20
|
+
*
|
|
21
|
+
* const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
22
|
+
* if (e.target.files) {
|
|
23
|
+
* dispatch({ type: 'addFiles', files: Array.from(e.target.files), purpose: 'avatar' });
|
|
24
|
+
* }
|
|
25
|
+
* };
|
|
26
|
+
*
|
|
27
|
+
* return (
|
|
28
|
+
* <div>
|
|
29
|
+
* <input type="file" onChange={onFileChange} multiple />
|
|
30
|
+
* <ul>
|
|
31
|
+
* {items.map(item => (
|
|
32
|
+
* <li key={item.localId}>{item.fingerprint.name} - {item.phase}</li>
|
|
33
|
+
* ))}
|
|
34
|
+
* </ul>
|
|
35
|
+
* </div>
|
|
36
|
+
* );
|
|
37
|
+
* }
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* @template M - Intent map type
|
|
41
|
+
* @template C - Cursor map type
|
|
42
|
+
* @template P - Purpose string union type
|
|
43
|
+
* @template R - Backend result shape
|
|
44
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
55
45
|
*/
|
|
56
|
-
export type UploaderActions<
|
|
57
|
-
M extends IntentMap,
|
|
58
|
-
C extends CursorMap<M>,
|
|
59
|
-
P extends string,
|
|
60
|
-
R extends UploadResultBase = UploadResultBase,
|
|
61
|
-
> = Pick<UploadStore<M, C, P, R>, 'dispatch' | 'on'> & {
|
|
62
|
-
store: UploadStore<M, C, P, R>
|
|
63
|
-
}
|
|
64
|
-
|
|
65
46
|
export function useUploader<
|
|
66
|
-
M extends
|
|
67
|
-
C extends
|
|
47
|
+
M extends Contracts.Intent.Map,
|
|
48
|
+
C extends Contracts.Cursor.Map<M>,
|
|
68
49
|
P extends string,
|
|
69
|
-
R extends
|
|
70
|
-
>(): Uploader<M, C, P, R>
|
|
71
|
-
|
|
72
|
-
M extends IntentMap,
|
|
73
|
-
C extends CursorMap<M>,
|
|
74
|
-
P extends string,
|
|
75
|
-
R extends UploadResultBase = UploadResultBase,
|
|
76
|
-
>(store: UploadStore<M, C, P, R>): Uploader<M, C, P, R>
|
|
77
|
-
export function useUploader<
|
|
78
|
-
M extends IntentMap,
|
|
79
|
-
C extends CursorMap<M>,
|
|
80
|
-
P extends string,
|
|
81
|
-
R extends UploadResultBase = UploadResultBase,
|
|
82
|
-
>(providedStore?: UploadStore<M, C, P, R> | undefined) {
|
|
83
|
-
const contextStore = useOptionalUploadStore<M, C, P, R>()
|
|
50
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
51
|
+
>(providedStore?: Store.UploadStore<M, C, P, R> | undefined): Uploader.State<M, C, P, R> {
|
|
52
|
+
const contextStore = useUploadStore<M, C, P, R>()
|
|
84
53
|
const store = providedStore ?? contextStore
|
|
54
|
+
|
|
85
55
|
if (!store) {
|
|
86
|
-
throw new Error('useUploader must be used within UploadProvider
|
|
56
|
+
throw new Error('useUploader must be used within an <UploadProvider> or passed a store instance directly.')
|
|
87
57
|
}
|
|
88
58
|
|
|
89
|
-
const
|
|
90
|
-
store.subscribe
|
|
91
|
-
store.getSnapshot
|
|
92
|
-
store.getSnapshot.
|
|
59
|
+
const state = useSyncExternalStore(
|
|
60
|
+
store.subscribe,
|
|
61
|
+
store.getSnapshot,
|
|
62
|
+
store.getSnapshot, // SSR fallback placeholder.
|
|
93
63
|
)
|
|
94
64
|
|
|
95
|
-
const items =
|
|
96
|
-
return Array.from(
|
|
97
|
-
}, [
|
|
65
|
+
const items = useMemo(() => {
|
|
66
|
+
return Array.from(state.items.values())
|
|
67
|
+
}, [state])
|
|
98
68
|
|
|
99
|
-
const byPhase =
|
|
100
|
-
const result: Record<
|
|
69
|
+
const byPhase = useMemo(() => {
|
|
70
|
+
const result: Record<Engine.Phase, Array<Engine.Item<M, C, P, R>>> = {
|
|
71
|
+
validating: [],
|
|
72
|
+
creating_intent: [],
|
|
73
|
+
ready: [],
|
|
74
|
+
queued: [],
|
|
75
|
+
uploading: [],
|
|
76
|
+
paused: [],
|
|
77
|
+
completing: [],
|
|
78
|
+
completed: [],
|
|
79
|
+
error: [],
|
|
80
|
+
canceled: [],
|
|
81
|
+
}
|
|
101
82
|
items.forEach((item) => {
|
|
102
|
-
|
|
103
|
-
|
|
83
|
+
const phase = item.phase
|
|
84
|
+
if (!result[phase]) {
|
|
85
|
+
result[phase] = []
|
|
104
86
|
}
|
|
105
|
-
result[
|
|
87
|
+
result[phase].push(item)
|
|
106
88
|
})
|
|
107
89
|
return result
|
|
108
90
|
}, [items])
|
|
@@ -121,12 +103,19 @@ export function useUploader<
|
|
|
121
103
|
}
|
|
122
104
|
}
|
|
123
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Returns the store's imperative actions (dispatch, event bindings) along with a direct
|
|
108
|
+
* reference to the store instance. Does not trigger React re-renders on upload progress or state changes.
|
|
109
|
+
*
|
|
110
|
+
* Use this when you only need to trigger commands or subscribe to events without binding UI lists.
|
|
111
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
112
|
+
*/
|
|
124
113
|
export function useUploaderActions<
|
|
125
|
-
M extends
|
|
126
|
-
C extends
|
|
114
|
+
M extends Contracts.Intent.Map,
|
|
115
|
+
C extends Contracts.Cursor.Map<M>,
|
|
127
116
|
P extends string,
|
|
128
|
-
R extends
|
|
129
|
-
>():
|
|
117
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
118
|
+
>(): Uploader.IActions<M, C, P, R> {
|
|
130
119
|
const store = useUploadStore<M, C, P, R>()
|
|
131
120
|
|
|
132
121
|
const dispatch = React.useMemo(() => store.dispatch.bind(store), [store])
|
|
@@ -134,3 +123,18 @@ export function useUploaderActions<
|
|
|
134
123
|
|
|
135
124
|
return { dispatch, on, store }
|
|
136
125
|
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Creates a bound `useUploader` hook pre-configured for a specific store instance.
|
|
129
|
+
*
|
|
130
|
+
* Useful for building static multi-uploader widgets or context-free setups.
|
|
131
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
132
|
+
*/
|
|
133
|
+
export function createUploadFactory<
|
|
134
|
+
M extends Contracts.Intent.Map,
|
|
135
|
+
C extends Contracts.Cursor.Map<M>,
|
|
136
|
+
P extends string,
|
|
137
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
138
|
+
>(store: Store.UploadStore<M, C, P, R>) {
|
|
139
|
+
return () => useUploader<M, C, P, R>(store)
|
|
140
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { DEFAULT_MAX_RETRIES, isAbort, isRetryable, withRetry } from '../retry'
|
|
3
|
+
|
|
4
|
+
describe('isAbort', () => {
|
|
5
|
+
it('detects AbortError by name', () => {
|
|
6
|
+
const err = new Error('cancelled')
|
|
7
|
+
err.name = 'AbortError'
|
|
8
|
+
expect(isAbort(err)).toBe(true)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('detects abort by message', () => {
|
|
12
|
+
expect(isAbort(new Error('request was aborted'))).toBe(true)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('is false for plain errors and non-errors', () => {
|
|
16
|
+
expect(isAbort(new Error('network down'))).toBe(false)
|
|
17
|
+
expect(isAbort('abort')).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('isRetryable', () => {
|
|
22
|
+
it('matches transient network wording and codes', () => {
|
|
23
|
+
expect(isRetryable(new Error('network error'))).toBe(true)
|
|
24
|
+
expect(isRetryable(new Error('socket timeout'))).toBe(true)
|
|
25
|
+
expect(isRetryable(new Error('Upload failed with status 503'))).toBe(true)
|
|
26
|
+
expect(isRetryable(new Error('read ECONNRESET'))).toBe(true)
|
|
27
|
+
expect(isRetryable(new Error('connect EHOSTUNREACH'))).toBe(true)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('is false for non-transient errors', () => {
|
|
31
|
+
expect(isRetryable(new Error('validation failed'))).toBe(false)
|
|
32
|
+
expect(isRetryable(new Error('Upload failed with status 403'))).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('withRetry', () => {
|
|
37
|
+
it('returns first-attempt result without retrying', async () => {
|
|
38
|
+
const fn = vi.fn(async () => 'ok')
|
|
39
|
+
await expect(withRetry(fn)).resolves.toBe('ok')
|
|
40
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('retries transient failures then succeeds', async () => {
|
|
44
|
+
let calls = 0
|
|
45
|
+
const fn = vi.fn(async () => {
|
|
46
|
+
calls++
|
|
47
|
+
if (calls < 3) throw new Error('network glitch')
|
|
48
|
+
return calls
|
|
49
|
+
})
|
|
50
|
+
await expect(withRetry(fn, { maxRetries: 5 })).resolves.toBe(3)
|
|
51
|
+
expect(fn).toHaveBeenCalledTimes(3)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('passes the zero-based attempt index to fn', async () => {
|
|
55
|
+
const seen: number[] = []
|
|
56
|
+
let calls = 0
|
|
57
|
+
await withRetry(
|
|
58
|
+
async (attempt) => {
|
|
59
|
+
seen.push(attempt)
|
|
60
|
+
calls++
|
|
61
|
+
if (calls < 3) throw new Error('timeout')
|
|
62
|
+
return null
|
|
63
|
+
},
|
|
64
|
+
{ maxRetries: 5 },
|
|
65
|
+
)
|
|
66
|
+
expect(seen).toEqual([0, 1, 2])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('gives up after maxRetries and rethrows', async () => {
|
|
70
|
+
const fn = vi.fn(async () => {
|
|
71
|
+
throw new Error('network down')
|
|
72
|
+
})
|
|
73
|
+
await expect(withRetry(fn, { maxRetries: 2 })).rejects.toThrow('network down')
|
|
74
|
+
// first attempt + 2 retries
|
|
75
|
+
expect(fn).toHaveBeenCalledTimes(3)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('does not retry non-retryable errors', async () => {
|
|
79
|
+
const fn = vi.fn(async () => {
|
|
80
|
+
throw new Error('validation failed')
|
|
81
|
+
})
|
|
82
|
+
await expect(withRetry(fn)).rejects.toThrow('validation failed')
|
|
83
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('does not retry aborts', async () => {
|
|
87
|
+
const err = new Error('aborted')
|
|
88
|
+
err.name = 'AbortError'
|
|
89
|
+
const fn = vi.fn(async () => {
|
|
90
|
+
throw err
|
|
91
|
+
})
|
|
92
|
+
await expect(withRetry(fn)).rejects.toThrow('aborted')
|
|
93
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('stops immediately when the signal is already aborted', async () => {
|
|
97
|
+
const controller = new AbortController()
|
|
98
|
+
controller.abort()
|
|
99
|
+
const fn = vi.fn(async () => {
|
|
100
|
+
throw new Error('network down')
|
|
101
|
+
})
|
|
102
|
+
await expect(withRetry(fn, { signal: controller.signal })).rejects.toThrow('network down')
|
|
103
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('honors a custom retryable predicate', async () => {
|
|
107
|
+
let calls = 0
|
|
108
|
+
const fn = vi.fn(async () => {
|
|
109
|
+
calls++
|
|
110
|
+
if (calls < 2) throw new Error('please retry me')
|
|
111
|
+
return 'done'
|
|
112
|
+
})
|
|
113
|
+
await expect(withRetry(fn, { retryable: (e) => /retry me/.test(String(e)) })).resolves.toBe('done')
|
|
114
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('exposes a sane default retry count', () => {
|
|
118
|
+
expect(DEFAULT_MAX_RETRIES).toBe(3)
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared retry helpers used by upload strategies.
|
|
3
|
+
*
|
|
4
|
+
* Centralizes the abort detection, transient-error classification, and
|
|
5
|
+
* exponential-backoff loop that `multipart` grew inline and that `post` and
|
|
6
|
+
* `tus` reuse.
|
|
7
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { sleep } from '../../core/utils/async'
|
|
11
|
+
|
|
12
|
+
/** Default number of retries (attempts after the first) for transient failures. */
|
|
13
|
+
export const DEFAULT_MAX_RETRIES = 3
|
|
14
|
+
|
|
15
|
+
/** Base backoff in ms; delay for retry `n` (0-indexed) is `2 ** n * BASE`. */
|
|
16
|
+
const BACKOFF_BASE_MS = 500
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* True when `err` represents an aborted request (either an `AbortError` or a
|
|
20
|
+
* message mentioning "abort"). Aborts are never retried.
|
|
21
|
+
*/
|
|
22
|
+
export function isAbort(err: unknown): boolean {
|
|
23
|
+
return err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Classify an error as a transient network failure worth retrying. Matches
|
|
28
|
+
* network/timeout wording, 5xx status codes, and common Node socket errnos.
|
|
29
|
+
*/
|
|
30
|
+
export function isRetryable(err: unknown): boolean {
|
|
31
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
32
|
+
return (
|
|
33
|
+
/network/i.test(msg) ||
|
|
34
|
+
/timeout/i.test(msg) ||
|
|
35
|
+
/5\d\d/.test(msg) ||
|
|
36
|
+
/ECONNRESET/i.test(msg) ||
|
|
37
|
+
/EHOSTUNREACH/i.test(msg)
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Options for {@link withRetry}. */
|
|
42
|
+
export type WithRetryOptions = {
|
|
43
|
+
/** Retries after the first attempt. Defaults to {@link DEFAULT_MAX_RETRIES}. */
|
|
44
|
+
maxRetries?: number
|
|
45
|
+
/**
|
|
46
|
+
* Abort signal. When aborted (or the error is an abort) the loop stops
|
|
47
|
+
* immediately and rethrows without further retries.
|
|
48
|
+
*/
|
|
49
|
+
signal?: AbortSignal
|
|
50
|
+
/**
|
|
51
|
+
* Custom retry predicate. Defaults to {@link isRetryable}. Aborts are always
|
|
52
|
+
* terminal regardless of this predicate.
|
|
53
|
+
*/
|
|
54
|
+
retryable?: (err: unknown) => boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Run `fn` and retry transient failures with exponential backoff.
|
|
59
|
+
*
|
|
60
|
+
* Aborts (signal aborted, or an abort-shaped error) short-circuit and rethrow
|
|
61
|
+
* immediately. Non-retryable errors rethrow on first failure. `fn` receives the
|
|
62
|
+
* zero-based attempt index.
|
|
63
|
+
*/
|
|
64
|
+
export async function withRetry<T>(fn: (attempt: number) => Promise<T>, opts: WithRetryOptions = {}): Promise<T> {
|
|
65
|
+
const maxRetries = Math.max(0, opts.maxRetries ?? DEFAULT_MAX_RETRIES)
|
|
66
|
+
const retryable = opts.retryable ?? isRetryable
|
|
67
|
+
|
|
68
|
+
let attempt = 0
|
|
69
|
+
for (;;) {
|
|
70
|
+
try {
|
|
71
|
+
return await fn(attempt)
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (opts.signal?.aborted || isAbort(err)) throw err
|
|
74
|
+
if (attempt >= maxRetries || !retryable(err)) throw err
|
|
75
|
+
await sleep(2 ** attempt * BACKOFF_BASE_MS)
|
|
76
|
+
attempt++
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/strategies/index.ts
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* @module upload-strategies
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
export * from './multipart'
|
|
14
|
-
export * from './post'
|
|
15
|
-
export * from './registry'
|
|
1
|
+
export type { MultipartStrategy } from './multipart'
|
|
2
|
+
export { multipartStrategy } from './multipart'
|
|
3
|
+
export type { PostIntent } from './post'
|
|
4
|
+
export { PostStrategy } from './post'
|
|
5
|
+
export type { PutIntent } from './put'
|
|
6
|
+
export { PutStrategy } from './put'
|
|
7
|
+
export { createStrategyRegistry } from './registry'
|
|
8
|
+
export type { TusIntent } from './tus'
|
|
9
|
+
export { TusStrategy } from './tus'
|