@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.
Files changed (129) hide show
  1. package/README.md +297 -0
  2. package/dist/core/index.d.ts +2 -3
  3. package/dist/core/index.js +1 -1
  4. package/dist/core-BVgloTiP.js +10 -0
  5. package/dist/index-DCqyBVcx.d.ts +1441 -0
  6. package/dist/index-DsZHPmJm.d.ts +225 -0
  7. package/dist/index-KROXtLQh.d.ts +281 -0
  8. package/dist/index.d.ts +4 -5
  9. package/dist/index.js +1 -1
  10. package/dist/react/index.d.ts +2 -90
  11. package/dist/react/index.js +1 -1
  12. package/dist/react-euPrDv24.js +1 -0
  13. package/dist/strategies/index.d.ts +2 -56
  14. package/dist/strategies/index.js +1 -1
  15. package/dist/strategies-CUF0s68j.js +1 -0
  16. package/package.json +3 -2
  17. package/src/__tests__/emitter.test.ts +85 -0
  18. package/src/__tests__/fingerprint.test.ts +49 -0
  19. package/src/__tests__/guards.test.ts +40 -0
  20. package/src/__tests__/id.test.ts +67 -0
  21. package/src/__tests__/mime-sniff.test.ts +142 -0
  22. package/src/__tests__/persistence.test.ts +191 -0
  23. package/src/__tests__/sanitize-filename.test.ts +109 -0
  24. package/src/__tests__/sleep.test.ts +34 -0
  25. package/src/__tests__/static-analysis.test.ts +145 -0
  26. package/src/__tests__/store-libs.test.ts +192 -0
  27. package/src/__tests__/transport-fetch.test.ts +108 -0
  28. package/src/__tests__/transport-libs.test.ts +42 -0
  29. package/src/__tests__/transport.test.ts +182 -0
  30. package/src/core/__tests__/errors.test.ts +152 -0
  31. package/src/core/client.ts +3 -0
  32. package/src/core/contracts/contracts.types.ts +373 -0
  33. package/src/core/contracts/index.ts +3 -8
  34. package/src/core/contracts/transport/index.ts +3 -2
  35. package/src/core/contracts/transport/transport.fetch.ts +203 -0
  36. package/src/core/contracts/transport/transport.libs.ts +13 -39
  37. package/src/core/contracts/transport/transport.ts +96 -90
  38. package/src/core/contracts/transport/transport.types.ts +71 -35
  39. package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
  40. package/src/core/engine/engine.libs.ts +35 -0
  41. package/src/core/engine/engine.types.ts +490 -0
  42. package/src/core/engine/index.ts +3 -8
  43. package/src/core/engine/reducer.ts +93 -53
  44. package/src/core/engine/store/handlers/add-file.ts +43 -48
  45. package/src/core/engine/store/handlers/cancel.ts +13 -8
  46. package/src/core/engine/store/handlers/clean-up.ts +11 -8
  47. package/src/core/engine/store/handlers/create-intent.ts +82 -27
  48. package/src/core/engine/store/handlers/finalize.ts +30 -13
  49. package/src/core/engine/store/handlers/index.ts +7 -7
  50. package/src/core/engine/store/handlers/pause.ts +8 -6
  51. package/src/core/engine/store/handlers/run-upload.ts +54 -39
  52. package/src/core/engine/store/index.ts +27 -21
  53. package/src/core/engine/store/store.dispatch.ts +10 -8
  54. package/src/core/engine/store/store.libs.ts +197 -94
  55. package/src/core/engine/store/store.runtime.ts +52 -53
  56. package/src/core/engine/store/store.schedule.ts +27 -21
  57. package/src/core/engine/store/store.types.ts +177 -227
  58. package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
  59. package/src/core/engine/validation/file.ts +8 -4
  60. package/src/core/engine/validation/index.ts +1 -0
  61. package/src/core/engine/validation/intent.ts +49 -27
  62. package/src/core/engine/validation/list.ts +14 -15
  63. package/src/core/engine/validation/mime.ts +39 -0
  64. package/src/core/errors.ts +271 -0
  65. package/src/core/index.ts +28 -5
  66. package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
  67. package/src/core/persistence/__tests__/persistence.test.ts +85 -0
  68. package/src/core/persistence/adapters.indexeddb.ts +9 -3
  69. package/src/core/persistence/adapters.local.ts +9 -3
  70. package/src/core/persistence/adapters.memory.ts +35 -13
  71. package/src/core/persistence/index.ts +5 -5
  72. package/src/core/persistence/persistence.ts +73 -60
  73. package/src/core/persistence/persistence.types.ts +140 -60
  74. package/src/core/utils/__tests__/guards.test.ts +93 -0
  75. package/src/core/utils/__tests__/url-safety.test.ts +112 -0
  76. package/src/core/utils/async.ts +7 -2
  77. package/src/core/utils/constants.ts +3 -9
  78. package/src/core/utils/emitter/emitter.ts +126 -0
  79. package/src/core/utils/emitter/emitter.types.ts +33 -16
  80. package/src/core/utils/emitter/index.ts +2 -74
  81. package/src/core/utils/fingerprint.ts +16 -5
  82. package/src/core/utils/guards.ts +91 -1
  83. package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
  84. package/src/core/utils/hash/index.ts +126 -0
  85. package/src/core/utils/hash/sha256.ts +179 -0
  86. package/src/core/utils/id.ts +33 -1
  87. package/src/core/utils/index.ts +15 -6
  88. package/src/core/utils/mime-sniff.ts +119 -0
  89. package/src/core/utils/sanitize-filename.ts +85 -0
  90. package/src/core/utils/url-safety.ts +156 -0
  91. package/src/react/__tests__/use-dropzone.test.ts +64 -0
  92. package/src/react/index.ts +5 -2
  93. package/src/react/upload-provider.tsx +59 -95
  94. package/src/react/uploader.types.ts +80 -0
  95. package/src/react/use-dropzone.tsx +226 -0
  96. package/src/react/use-uploader.tsx +97 -93
  97. package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
  98. package/src/strategies/_shared/retry.ts +79 -0
  99. package/src/strategies/index.ts +9 -15
  100. package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
  101. package/src/strategies/multipart/index.ts +211 -116
  102. package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
  103. package/src/strategies/post/index.ts +113 -71
  104. package/src/strategies/put/__tests__/put.test.ts +132 -0
  105. package/src/strategies/put/index.ts +139 -0
  106. package/src/strategies/registry/index.ts +12 -9
  107. package/src/strategies/tus/__tests__/tus.test.ts +159 -0
  108. package/src/strategies/tus/index.ts +211 -0
  109. package/dist/async-DxRWu4le.js +0 -1
  110. package/dist/core-BNxznRif.js +0 -2
  111. package/dist/index-BXg9WIce.d.ts +0 -987
  112. package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
  113. package/src/core/client/config.types.ts +0 -77
  114. package/src/core/client/index.ts +0 -4
  115. package/src/core/client/plugins.types.ts +0 -46
  116. package/src/core/client/retry.types.ts +0 -6
  117. package/src/core/client/uploadClient.ts +0 -19
  118. package/src/core/contracts/backendApi.ts +0 -62
  119. package/src/core/contracts/cursor.types.ts +0 -23
  120. package/src/core/contracts/errors.types.ts +0 -67
  121. package/src/core/contracts/fingerprint.types.ts +0 -18
  122. package/src/core/contracts/intent.types.ts +0 -39
  123. package/src/core/contracts/result.types.ts +0 -11
  124. package/src/core/contracts/strategy.types.ts +0 -45
  125. package/src/core/engine/commands.types.ts +0 -17
  126. package/src/core/engine/event-map.types.ts +0 -45
  127. package/src/core/engine/internal-events.types.ts +0 -222
  128. package/src/core/engine/outcome.types.ts +0 -9
  129. 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 type {
5
- CursorMap,
6
- IntentMap,
7
- UploadCommand,
8
- UploadEventMap,
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
- * Stable action surface for React consumers that need imperative store access.
9
+ * Hook to consume the upload store state, track files, and interact with the upload engine.
51
10
  *
52
- * This is declared explicitly instead of relying on an inferred object return
53
- * type so the generated package declarations do not leak the internal generic
54
- * names created by `store.on.bind(store)`.
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 IntentMap,
67
- C extends CursorMap<M>,
47
+ M extends Contracts.Intent.Map,
48
+ C extends Contracts.Cursor.Map<M>,
68
49
  P extends string,
69
- R extends UploadResultBase = UploadResultBase,
70
- >(): Uploader<M, C, P, R>
71
- export function useUploader<
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 when no store argument is provided')
56
+ throw new Error('useUploader must be used within an <UploadProvider> or passed a store instance directly.')
87
57
  }
88
58
 
89
- const snapshot = React.useSyncExternalStore(
90
- store.subscribe.bind(store),
91
- store.getSnapshot.bind(store),
92
- store.getSnapshot.bind(store),
59
+ const state = useSyncExternalStore(
60
+ store.subscribe,
61
+ store.getSnapshot,
62
+ store.getSnapshot, // SSR fallback placeholder.
93
63
  )
94
64
 
95
- const items = React.useMemo(() => {
96
- return Array.from(snapshot.items.values())
97
- }, [snapshot.items])
65
+ const items = useMemo(() => {
66
+ return Array.from(state.items.values())
67
+ }, [state])
98
68
 
99
- const byPhase = React.useMemo(() => {
100
- const result: Record<string, UploadItem<M, C, P, R>[]> = {}
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
- if (!result[item.phase]) {
103
- result[item.phase] = []
83
+ const phase = item.phase
84
+ if (!result[phase]) {
85
+ result[phase] = []
104
86
  }
105
- result[item.phase].push(item)
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 IntentMap,
126
- C extends CursorMap<M>,
114
+ M extends Contracts.Intent.Map,
115
+ C extends Contracts.Cursor.Map<M>,
127
116
  P extends string,
128
- R extends UploadResultBase = UploadResultBase,
129
- >(): UploaderActions<M, C, P, R> {
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
+ }
@@ -1,15 +1,9 @@
1
- /**
2
- * @fileoverview Public exports for upload strategies.
3
- *
4
- * Re-exports all strategy implementations and types for convenient importing.
5
- *
6
- * Available strategies:
7
- * - PostStrategy: Simple POST uploads (presigned forms)
8
- * - multipartStrategy: Chunked multipart uploads (S3-style, resumable)
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'