@gentleduck/upload 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/index.d.ts +1417 -0
  2. package/dist/index.js +6 -0
  3. package/package.json +43 -0
  4. package/src/core/client/config.types.ts +77 -0
  5. package/src/core/client/index.ts +4 -0
  6. package/src/core/client/plugins.types.ts +46 -0
  7. package/src/core/client/retry.types.ts +6 -0
  8. package/src/core/client/uploadClient.ts +19 -0
  9. package/src/core/contracts/backendApi.ts +62 -0
  10. package/src/core/contracts/cursor.types.ts +23 -0
  11. package/src/core/contracts/errors.types.ts +67 -0
  12. package/src/core/contracts/fingerprint.types.ts +18 -0
  13. package/src/core/contracts/index.ts +8 -0
  14. package/src/core/contracts/intent.types.ts +39 -0
  15. package/src/core/contracts/result.types.ts +11 -0
  16. package/src/core/contracts/strategy.types.ts +45 -0
  17. package/src/core/contracts/transport/index.ts +2 -0
  18. package/src/core/contracts/transport/transport.libs.ts +100 -0
  19. package/src/core/contracts/transport/transport.ts +166 -0
  20. package/src/core/contracts/transport/transport.types.ts +44 -0
  21. package/src/core/engine/commands.types.ts +17 -0
  22. package/src/core/engine/event-map.types.ts +45 -0
  23. package/src/core/engine/index.ts +8 -0
  24. package/src/core/engine/internal-events.types.ts +222 -0
  25. package/src/core/engine/outcome.types.ts +9 -0
  26. package/src/core/engine/progress.types.ts +15 -0
  27. package/src/core/engine/reducer.ts +435 -0
  28. package/src/core/engine/store/handlers/add-file.ts +103 -0
  29. package/src/core/engine/store/handlers/cancel.ts +56 -0
  30. package/src/core/engine/store/handlers/clean-up.ts +66 -0
  31. package/src/core/engine/store/handlers/create-intent.ts +76 -0
  32. package/src/core/engine/store/handlers/finalize.ts +59 -0
  33. package/src/core/engine/store/handlers/index.ts +7 -0
  34. package/src/core/engine/store/handlers/pause.ts +16 -0
  35. package/src/core/engine/store/handlers/run-upload.ts +192 -0
  36. package/src/core/engine/store/index.ts +123 -0
  37. package/src/core/engine/store/store.dispatch.ts +78 -0
  38. package/src/core/engine/store/store.libs.ts +169 -0
  39. package/src/core/engine/store/store.runtime.ts +336 -0
  40. package/src/core/engine/store/store.schedule.ts +116 -0
  41. package/src/core/engine/store/store.types.ts +250 -0
  42. package/src/core/engine/validation/file.ts +77 -0
  43. package/src/core/engine/validation/index.ts +3 -0
  44. package/src/core/engine/validation/intent.ts +55 -0
  45. package/src/core/engine/validation/list.ts +50 -0
  46. package/src/core/index.ts +5 -0
  47. package/src/core/persistence/adapters.indexeddb.ts +92 -0
  48. package/src/core/persistence/adapters.local.ts +22 -0
  49. package/src/core/persistence/adapters.memory.ts +15 -0
  50. package/src/core/persistence/index.ts +5 -0
  51. package/src/core/persistence/persistence.ts +194 -0
  52. package/src/core/persistence/persistence.types.ts +66 -0
  53. package/src/core/utils/async.ts +3 -0
  54. package/src/core/utils/constants.ts +32 -0
  55. package/src/core/utils/emitter/emitter.types.ts +23 -0
  56. package/src/core/utils/emitter/index.ts +68 -0
  57. package/src/core/utils/fingerprint.ts +20 -0
  58. package/src/core/utils/guards.ts +3 -0
  59. package/src/core/utils/id.ts +3 -0
  60. package/src/core/utils/index.ts +6 -0
  61. package/src/index.ts +3 -0
  62. package/src/react/index.ts +2 -0
  63. package/src/react/upload-provider.tsx +96 -0
  64. package/src/react/use-uploader.tsx +115 -0
  65. package/src/strategies/index.ts +15 -0
  66. package/src/strategies/multipart/index.ts +243 -0
  67. package/src/strategies/post/index.ts +93 -0
  68. package/src/strategies/registry/index.ts +30 -0
@@ -0,0 +1,194 @@
1
+ import type { AnyCursor, CursorMap, IntentMap, UploadResultBase } from '../contracts'
2
+ import type { UploadItem } from '../engine/internal-events.types'
3
+ import type { UploadState } from '../engine/reducer'
4
+ import { hasCursor, hasIntent } from '../engine/store/store.libs'
5
+ import { isRecord } from '../utils/guards'
6
+ import type { PersistedSnapshot, PersistedUploadItem } from './persistence.types'
7
+
8
+ /**
9
+ * Serializes the current upload state into a JSON-safe structure.
10
+ * Only serializes items that have a valid 'intent', as these are the only ones
11
+ * that can be resumed cleanly.
12
+ */
13
+ export function serializeSnapshot<
14
+ M extends IntentMap,
15
+ C extends CursorMap<M>,
16
+ P extends string,
17
+ R extends UploadResultBase,
18
+ >(state: UploadState<M, C, P, R>, version: number): PersistedSnapshot<M, C, P> {
19
+ const items: Record<string, PersistedUploadItem<M, C, P>> = {}
20
+
21
+ for (const item of state.items.values()) {
22
+ if (!hasIntent(item)) continue
23
+
24
+ // Do not persist terminal items. Persistence is for resuming and recovery, not history.
25
+ if (item.phase === 'completed' || item.phase === 'canceled' || item.phase === 'error') continue
26
+
27
+ const cursor = hasCursor(item) ? item.cursor : undefined
28
+
29
+ const progress =
30
+ 'progress' in item && item.progress
31
+ ? {
32
+ uploadedBytes: item.progress.uploadedBytes,
33
+ totalBytes: item.progress.totalBytes,
34
+ pct: item.progress.pct,
35
+ }
36
+ : undefined
37
+
38
+ const persisted: PersistedUploadItem<M, C, P> = {
39
+ id: item.localId,
40
+ purpose: item.purpose,
41
+ status: item.phase,
42
+ file: {
43
+ name: item.fingerprint.name,
44
+ size: item.fingerprint.size,
45
+ type: item.fingerprint.type,
46
+ lastModified: item.fingerprint.lastModified,
47
+ checksum: item.fingerprint.checksum,
48
+ },
49
+ intent: item.intent,
50
+ cursor,
51
+ progress,
52
+ }
53
+
54
+ items[persisted.id] = persisted
55
+ }
56
+
57
+ return { version, createdAt: Date.now(), items }
58
+ }
59
+
60
+ /**
61
+ * Deserializes a persisted snapshot back into a store state.
62
+ *
63
+ * Important:
64
+ * - Browser `File` objects cannot be restored from persistence.
65
+ * - We restore resumable items (those with a cursor) into the `paused` phase,
66
+ * with `file` left undefined. Your UI can ask the user to rebind the file.
67
+ */
68
+ export function deserializeSnapshot<
69
+ M extends IntentMap,
70
+ C extends CursorMap<M>,
71
+ P extends string,
72
+ R extends UploadResultBase,
73
+ >(
74
+ raw: unknown,
75
+ opts: {
76
+ isPurpose?: (value: string) => value is P
77
+ isIntent?: (value: unknown) => value is M[keyof M]
78
+ hasStrategy: (value: string) => boolean
79
+ },
80
+ ): UploadState<M, C, P, R> | null {
81
+ if (!opts.isPurpose || !opts.isIntent) return null
82
+ if (!isPersistedSnapshot(raw)) return null
83
+
84
+ const items = new Map<string, UploadItem<M, C, P, R>>()
85
+
86
+ for (const value of Object.values(raw.items)) {
87
+ const parsed = parsePersistedItem(value)
88
+ if (!parsed) continue
89
+
90
+ if (!opts.isPurpose(parsed.purpose)) continue
91
+ if (!opts.isIntent(parsed.intent)) continue
92
+
93
+ const strategy = parsed.intent.strategy
94
+ if (typeof strategy !== 'string' || !opts.hasStrategy(strategy)) continue
95
+
96
+ if (!parsed.cursor || !isCursorForRegistry<C>(parsed.cursor, opts.hasStrategy)) continue
97
+ if (parsed.cursor.strategy !== strategy) continue
98
+
99
+ const totalBytes = parsed.progress?.totalBytes ?? parsed.file.size
100
+ const uploadedBytes = parsed.progress?.uploadedBytes ?? 0
101
+ const pct =
102
+ typeof parsed.progress?.pct === 'number'
103
+ ? parsed.progress.pct
104
+ : totalBytes > 0
105
+ ? Math.min(100, Math.max(0, (uploadedBytes / totalBytes) * 100))
106
+ : 0
107
+
108
+ items.set(parsed.id, {
109
+ phase: 'paused',
110
+ localId: parsed.id,
111
+ purpose: parsed.purpose,
112
+ fingerprint: {
113
+ name: parsed.file.name,
114
+ size: parsed.file.size,
115
+ type: parsed.file.type,
116
+ lastModified: parsed.file.lastModified,
117
+ checksum: parsed.file.checksum,
118
+ },
119
+ intent: parsed.intent,
120
+ cursor: parsed.cursor,
121
+ progress: { uploadedBytes, totalBytes, pct },
122
+ pausedAt: Date.now(),
123
+ createdAt: raw.createdAt ?? Date.now(),
124
+ file: undefined,
125
+ })
126
+ }
127
+
128
+ return { items }
129
+ }
130
+
131
+ function isPersistedSnapshot(value: unknown): value is PersistedSnapshot<unknown, unknown, string> {
132
+ if (!isRecord(value)) return false
133
+ if (typeof value.version !== 'number') return false
134
+ if (typeof value.createdAt !== 'number') return false
135
+ return isRecord(value.items)
136
+ }
137
+
138
+ function parsePersistedItem(value: unknown): {
139
+ id: string
140
+ purpose: string
141
+ status: string
142
+ file: { name: string; size: number; type: string; lastModified: number; checksum?: string }
143
+ intent: unknown
144
+ cursor?: unknown
145
+ progress?: { uploadedBytes: number; totalBytes: number; pct?: number }
146
+ } | null {
147
+ if (!isRecord(value)) return null
148
+
149
+ const id = typeof value.id === 'string' ? value.id : null
150
+ const purpose = typeof value.purpose === 'string' ? value.purpose : null
151
+ const status = typeof value.status === 'string' ? value.status : null
152
+ const intent = value.intent
153
+
154
+ if (!id || !purpose || !status) return null
155
+
156
+ if (!isRecord(value.file)) return null
157
+
158
+ const name = typeof value.file.name === 'string' ? value.file.name : null
159
+ const size = typeof value.file.size === 'number' ? value.file.size : null
160
+ const type = typeof value.file.type === 'string' ? value.file.type : null
161
+ const lastModified = typeof value.file.lastModified === 'number' ? value.file.lastModified : null
162
+ const checksum = typeof value.file.checksum === 'string' ? value.file.checksum : undefined
163
+
164
+ if (!name || size === null || !type || lastModified === null) return null
165
+
166
+ const progress = parseProgress(value.progress)
167
+
168
+ return {
169
+ id,
170
+ purpose,
171
+ status,
172
+ file: { name, size, type, lastModified, checksum },
173
+ intent,
174
+ cursor: value.cursor,
175
+ progress,
176
+ }
177
+ }
178
+
179
+ function parseProgress(value: unknown): { uploadedBytes: number; totalBytes: number; pct?: number } | undefined {
180
+ if (!isRecord(value)) return undefined
181
+ if (typeof value.uploadedBytes !== 'number' || typeof value.totalBytes !== 'number') return undefined
182
+ const pct = typeof value.pct === 'number' ? value.pct : undefined
183
+ return { uploadedBytes: value.uploadedBytes, totalBytes: value.totalBytes, pct }
184
+ }
185
+
186
+ function isCursorForRegistry<C extends Record<string, unknown>>(
187
+ value: unknown,
188
+ hasStrategy: (value: string) => boolean,
189
+ ): value is AnyCursor<C> {
190
+ if (!isRecord(value)) return false
191
+ if (typeof value.strategy !== 'string') return false
192
+ if (!hasStrategy(value.strategy)) return false
193
+ return true
194
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * A minimal, serializable representation of an upload item that is safe to store
3
+ * in persistence (LocalStorage, IndexedDB, etc).
4
+ *
5
+ * This type is intentionally not the full {@link UploadItem} state.
6
+ * It captures only the fields needed to:
7
+ * - restore the UI list after refresh
8
+ * - rebind an upload to its backend intent (if already created)
9
+ * - resume an upload strategy from a known cursor (when supported)
10
+ * - restore last-known progress and phase for UX continuity
11
+ *
12
+ * @typeParam M - Map of upload intent variants keyed by intent kind (e.g. `direct`, `multipart`).
13
+ * @typeParam C - Map of cursor shapes keyed by {@link M}'s intent kinds.
14
+ * @typeParam P - Union of allowed `purpose` strings for your app (e.g. `"avatar" | "document"`).
15
+ */
16
+ export type PersistedSnapshot<M, C, P extends string> = {
17
+ /** Persistence schema version (not app version). */
18
+ version: number
19
+ /** Unix epoch milliseconds when this snapshot was written. */
20
+ createdAt: number
21
+ /** List of persisted upload items. */
22
+ items: Record<string, PersistedUploadItem<M, C, P>>
23
+ }
24
+
25
+ export type PersistedUploadItem<M, C, P extends string> = {
26
+ /** Stable, client-generated ID for this upload in local state. */
27
+ id: string
28
+ /** File name. */
29
+ file: {
30
+ /** File name. */
31
+ name: string
32
+ /** File size in bytes. */
33
+ size: number
34
+ /** File MIME type. */
35
+ type: string
36
+ /** File lastModified timestamp (ms). */
37
+ lastModified: number
38
+ /** Optional checksum (example: SHA-256 hex). */
39
+ checksum?: string
40
+ }
41
+ /** Backend-provided "intent" that describes how to upload (strategy, URLs, fields, etc). */
42
+ intent: M[keyof M]
43
+ /** Strategy cursor used to resume uploads that support continuation. */
44
+ cursor?: import('../contracts').AnyCursor<C>
45
+ /** Last-known progress for UX continuity after restoring from persistence. */
46
+ progress?: { uploadedBytes: number; totalBytes: number; pct?: number }
47
+ /** The last known state-machine phase at the time of persistence. */
48
+ status: string
49
+ /** Logical purpose for the upload (application-defined). */
50
+ purpose: P
51
+ }
52
+
53
+ /**
54
+ * A persisted snapshot of the upload store.
55
+ *
56
+ * Snapshots are versioned to allow safe migrations when the persistence schema evolves.
57
+ * They are time-stamped for diagnostics and garbage-collection policies.
58
+ */
59
+ export interface PersistenceAdapter {
60
+ /** Loads a snapshot from persistence. */
61
+ load(key: string): unknown | null | Promise<unknown | null>
62
+ /** Saves a snapshot to persistence. */
63
+ save(key: string, snapshot: unknown): void | Promise<void>
64
+ /** Clears a snapshot from persistence. */
65
+ clear(key: string): void | Promise<void>
66
+ }
@@ -0,0 +1,3 @@
1
+ export function sleep(ms: number) {
2
+ return new Promise<void>((resolve) => setTimeout(resolve, ms))
3
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Default retry delay base in milliseconds.
3
+ * Used for exponential backoff calculation: base * 2^(attempt - 1)
4
+ */
5
+ export const DEFAULT_RETRY_DELAY_BASE_MS = 500
6
+
7
+ /**
8
+ * Maximum retry delay in milliseconds.
9
+ * Caps exponential backoff to prevent excessive delays.
10
+ */
11
+ export const DEFAULT_RETRY_DELAY_MAX_MS = 10_000
12
+
13
+ /**
14
+ * Default progress event throttle in milliseconds.
15
+ * Prevents excessive progress updates from overwhelming the UI.
16
+ */
17
+ export const DEFAULT_PROGRESS_THROTTLE_MS = 100
18
+
19
+ /**
20
+ * Default maximum number of retry attempts.
21
+ */
22
+ export const DEFAULT_MAX_ATTEMPTS = 3
23
+
24
+ /**
25
+ * Default maximum number of concurrent uploads.
26
+ */
27
+ export const DEFAULT_MAX_CONCURRENT_UPLOADS = 3
28
+
29
+ /**
30
+ * Default maximum number of items to keep in memory.
31
+ */
32
+ export const DEFAULT_MAX_ITEMS = 100
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Minimal typed event emitter interface.
3
+ *
4
+ * Use this to expose strongly-typed events from your store.
5
+ *
6
+ * @typeParam E - Event map (`eventName -> payload`).
7
+ */
8
+ export type TypedEmitter<E extends Record<string, unknown>> = {
9
+ /**
10
+ * Subscribe to an event.
11
+ * @returns Unsubscribe function.
12
+ */
13
+ on<K extends keyof E & string>(type: K, cb: (payload: E[K]) => void): () => void
14
+
15
+ /**
16
+ * Unsubscribe from an event.
17
+ * @returns Unsubscribe function.
18
+ */
19
+ off<K extends keyof E & string>(type: K, cb: (payload: E[K]) => void): () => void
20
+
21
+ /** Emit an event to all listeners. */
22
+ emit<K extends keyof E & string>(type: K, payload: E[K]): void
23
+ }
@@ -0,0 +1,68 @@
1
+ import type { TypedEmitter } from './emitter.types'
2
+
3
+ /**
4
+ * Creates a lightweight, typed event emitter.
5
+ * We use this instead of `EventEmitter` or `EventTarget` to ensure:
6
+ * 1. Strict typing of event payloads (via generic E)
7
+ * 2. No dependency on Node.js built-ins (browser compatible)
8
+ * 3. Exception safety (user listeners don't crash the loop)
9
+ *
10
+ * @template E - Event map type (Record<eventName, payloadType>)
11
+ *
12
+ * @returns {TypedEmitter<E>} Typed emitter instance
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * type Events = { 'file.added': { id: string }, 'upload.progress': { pct: number } }
17
+ * const emitter = createTypedEmitter<Events>()
18
+ * emitter.on('file.added', (payload) => console.log(payload.id))
19
+ * ```
20
+ */
21
+ type ListenerMap<E extends Record<string, unknown>> = Partial<{
22
+ [K in keyof E & string]: Set<(payload: E[K]) => void>
23
+ }>
24
+
25
+ export function createTypedEmitter<E extends Record<string, unknown>>(): TypedEmitter<E> {
26
+ const listeners: ListenerMap<E> = {}
27
+
28
+ return {
29
+ on<K extends keyof E & string>(type: K, cb: (payload: E[K]) => void): () => void {
30
+ // Create listener set for this event type if it doesn't exist
31
+ const set = listeners[type] ?? (listeners[type] = new Set<(payload: E[K]) => void>())
32
+ set.add(cb)
33
+
34
+ // Return unsubscribe function
35
+ return () => {
36
+ const typeListeners = listeners[type]
37
+ if (!typeListeners) return
38
+ typeListeners.delete(cb)
39
+ if (typeListeners.size === 0) delete listeners[type]
40
+ }
41
+ },
42
+
43
+ off<K extends keyof E & string>(type: K, cb: (payload: E[K]) => void): () => void {
44
+ const typeListeners = listeners[type]
45
+ if (!typeListeners) return () => {}
46
+ typeListeners.delete(cb)
47
+ if (typeListeners.size === 0) delete listeners[type]
48
+ return () => {}
49
+ },
50
+
51
+ emit<K extends keyof E & string>(type: K, payload: E[K]): void {
52
+ const typeListeners = listeners[type]
53
+ if (!typeListeners) return
54
+
55
+ typeListeners.forEach((cb) => {
56
+ try {
57
+ cb(payload)
58
+ } catch (err) {
59
+ // Do not crash upload engine due to user callbacks
60
+ // In production, this should be sent to error tracking service
61
+ if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
62
+ console.error(`[UploadEngine] Error in event listener for "${type}":`, err)
63
+ }
64
+ }
65
+ })
66
+ },
67
+ }
68
+ }
@@ -0,0 +1,20 @@
1
+ import type { FileFingerprint } from '../contracts'
2
+
3
+ /**
4
+ * Builds a lightweight fingerprint from a {@link File}.
5
+ */
6
+ export function computeFingerprint(file: File): FileFingerprint {
7
+ return {
8
+ name: file.name,
9
+ size: file.size,
10
+ type: file.type,
11
+ lastModified: file.lastModified,
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Compares two file fingerprints.
17
+ */
18
+ export function fingerprintMatches(a: FileFingerprint, b: FileFingerprint): boolean {
19
+ return a.name === b.name && a.size === b.size && a.lastModified === b.lastModified
20
+ }
@@ -0,0 +1,3 @@
1
+ export function isRecord(value: unknown): value is Record<string, unknown> {
2
+ return !!value && typeof value === 'object'
3
+ }
@@ -0,0 +1,3 @@
1
+ export function generateId(): string {
2
+ return `upload_${Date.now()}_${Math.random().toString(36).slice(2)}`
3
+ }
@@ -0,0 +1,6 @@
1
+ export * from './async'
2
+ export * from './constants'
3
+ export * from './emitter'
4
+ export * from './fingerprint'
5
+ export * from './guards'
6
+ export * from './id'
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './core'
2
+ export * from './react'
3
+ export * from './strategies'
@@ -0,0 +1,2 @@
1
+ export * from './upload-provider'
2
+ export * from './use-uploader'
@@ -0,0 +1,96 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * @fileoverview React context provider for the upload store.
5
+ *
6
+ * Provides the upload store instance to all child components via React Context.
7
+ * This allows components to access the store without prop drilling.
8
+ *
9
+ * @module upload-provider
10
+ */
11
+
12
+ import { createContext, type ReactNode, useContext } from 'react'
13
+ import { type CursorMap, type IntentMap, isRecord, type UploadResultBase } from '../core'
14
+ import type { UploadStore } from '../core/engine/store'
15
+
16
+ /**
17
+ * React context for the upload store.
18
+ * Initialized as null and must be provided via UploadProvider.
19
+ *
20
+ * Note: Context cannot be generic at the type level in React, so we use a base type.
21
+ * The useUploadStore hook provides type safety through type assertion which is safe
22
+ * because the provider ensures the correct type is stored.
23
+ */
24
+ const UploadContext = createContext<unknown | null>(null)
25
+
26
+ /**
27
+ * Provider component that makes the upload store available to child components.
28
+ *
29
+ * @template M - Intent map type
30
+ * @template C - Cursor map type
31
+ * @template P - Purpose type
32
+ *
33
+ * @param props - Provider props
34
+ * @param props.store - Upload store instance to provide
35
+ * @param props.children - Child components that can access the store
36
+ *
37
+ * @returns {JSX.Element} Context provider wrapping children
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * <UploadProvider store={store}>
42
+ * <UploadDropzone purpose="avatar" />
43
+ * </UploadProvider>
44
+ * ```
45
+ */
46
+ export function UploadProvider<
47
+ M extends IntentMap,
48
+ C extends CursorMap<M>,
49
+ P extends string,
50
+ R extends UploadResultBase = UploadResultBase,
51
+ >({ store, children }: { store: UploadStore<M, C, P, R>; children: ReactNode }): React.JSX.Element {
52
+ return <UploadContext.Provider value={store}>{children}</UploadContext.Provider>
53
+ }
54
+
55
+ /**
56
+ * Hook to access the upload store from React context.
57
+ *
58
+ * @template M - Intent map type
59
+ * @template C - Cursor map type
60
+ * @template P - Purpose type
61
+ *
62
+ * @returns {UploadStore<M, C, P>} The upload store instance
63
+ * @throws {Error} If called outside of UploadProvider
64
+ *
65
+ * @example
66
+ * ```tsx
67
+ * const store = useUploadStore()
68
+ * const snapshot = store.getSnapshot()
69
+ * ```
70
+ */
71
+ export function useUploadStore<
72
+ M extends IntentMap,
73
+ C extends CursorMap<M>,
74
+ P extends string,
75
+ R extends UploadResultBase = UploadResultBase,
76
+ >(): UploadStore<M, C, P, R> {
77
+ const store = useContext(UploadContext)
78
+ if (!store || !isUploadStore<M, C, P, R>(store)) {
79
+ throw new Error('useUploadStore must be used within UploadProvider')
80
+ }
81
+ return store
82
+ }
83
+
84
+ function isUploadStore<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
85
+ value: unknown,
86
+ ): value is UploadStore<M, C, P, R> {
87
+ if (!isRecord(value)) return false
88
+ return (
89
+ typeof value.getSnapshot === 'function' &&
90
+ typeof value.subscribe === 'function' &&
91
+ typeof value.dispatch === 'function' &&
92
+ typeof value.on === 'function' &&
93
+ typeof value.off === 'function' &&
94
+ typeof value.waitFor === 'function'
95
+ )
96
+ }
@@ -0,0 +1,115 @@
1
+ 'use client'
2
+
3
+ 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 { 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
+ }
48
+
49
+ export function useUploader<
50
+ M extends IntentMap,
51
+ C extends CursorMap<M>,
52
+ P extends string,
53
+ R extends UploadResultBase = UploadResultBase,
54
+ >(): Uploader<M, C, P, R>
55
+ export function useUploader<
56
+ M extends IntentMap,
57
+ C extends CursorMap<M>,
58
+ P extends string,
59
+ R extends UploadResultBase = UploadResultBase,
60
+ >(store: UploadStore<M, C, P, R>): Uploader<M, C, P, R>
61
+ export function useUploader<
62
+ M extends IntentMap,
63
+ C extends CursorMap<M>,
64
+ P extends string,
65
+ R extends UploadResultBase = UploadResultBase,
66
+ >(_store?: UploadStore<M, C, P, R> | undefined) {
67
+ const store = _store ?? useUploadStore<M, C, P, R>()
68
+ const snapshot = React.useSyncExternalStore(
69
+ store.subscribe.bind(store),
70
+ store.getSnapshot.bind(store),
71
+ store.getSnapshot.bind(store),
72
+ )
73
+
74
+ const items = React.useMemo(() => {
75
+ return Array.from(snapshot.items.values())
76
+ }, [snapshot.items])
77
+
78
+ const byPhase = React.useMemo(() => {
79
+ const result: Record<string, UploadItem<M, C, P, R>[]> = {}
80
+ items.forEach((item) => {
81
+ if (!result[item.phase]) {
82
+ result[item.phase] = []
83
+ }
84
+ result[item.phase].push(item)
85
+ })
86
+ return result
87
+ }, [items])
88
+
89
+ return {
90
+ items,
91
+ byPhase,
92
+ dispatch: store.dispatch.bind(store),
93
+ on: store.on.bind(store),
94
+ off: store.off.bind(store),
95
+ uploading: byPhase.uploading || [],
96
+ paused: byPhase.paused || [],
97
+ completed: byPhase.completed || [],
98
+ failed: byPhase.error || [],
99
+ ready: byPhase.ready || [],
100
+ }
101
+ }
102
+
103
+ export function useUploaderActions<
104
+ M extends IntentMap,
105
+ C extends CursorMap<M>,
106
+ P extends string,
107
+ R extends UploadResultBase = UploadResultBase,
108
+ >() {
109
+ const store = useUploadStore<M, C, P, R>()
110
+
111
+ const dispatch = React.useMemo(() => store.dispatch.bind(store), [store])
112
+ const on = React.useMemo(() => store.on.bind(store), [store])
113
+
114
+ return { dispatch, on, store }
115
+ }
@@ -0,0 +1,15 @@
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'