@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.
- package/dist/index.d.ts +1417 -0
- package/dist/index.js +6 -0
- package/package.json +43 -0
- package/src/core/client/config.types.ts +77 -0
- package/src/core/client/index.ts +4 -0
- package/src/core/client/plugins.types.ts +46 -0
- package/src/core/client/retry.types.ts +6 -0
- package/src/core/client/uploadClient.ts +19 -0
- package/src/core/contracts/backendApi.ts +62 -0
- package/src/core/contracts/cursor.types.ts +23 -0
- package/src/core/contracts/errors.types.ts +67 -0
- package/src/core/contracts/fingerprint.types.ts +18 -0
- package/src/core/contracts/index.ts +8 -0
- package/src/core/contracts/intent.types.ts +39 -0
- package/src/core/contracts/result.types.ts +11 -0
- package/src/core/contracts/strategy.types.ts +45 -0
- package/src/core/contracts/transport/index.ts +2 -0
- package/src/core/contracts/transport/transport.libs.ts +100 -0
- package/src/core/contracts/transport/transport.ts +166 -0
- package/src/core/contracts/transport/transport.types.ts +44 -0
- package/src/core/engine/commands.types.ts +17 -0
- package/src/core/engine/event-map.types.ts +45 -0
- package/src/core/engine/index.ts +8 -0
- package/src/core/engine/internal-events.types.ts +222 -0
- package/src/core/engine/outcome.types.ts +9 -0
- package/src/core/engine/progress.types.ts +15 -0
- package/src/core/engine/reducer.ts +435 -0
- package/src/core/engine/store/handlers/add-file.ts +103 -0
- package/src/core/engine/store/handlers/cancel.ts +56 -0
- package/src/core/engine/store/handlers/clean-up.ts +66 -0
- package/src/core/engine/store/handlers/create-intent.ts +76 -0
- package/src/core/engine/store/handlers/finalize.ts +59 -0
- package/src/core/engine/store/handlers/index.ts +7 -0
- package/src/core/engine/store/handlers/pause.ts +16 -0
- package/src/core/engine/store/handlers/run-upload.ts +192 -0
- package/src/core/engine/store/index.ts +123 -0
- package/src/core/engine/store/store.dispatch.ts +78 -0
- package/src/core/engine/store/store.libs.ts +169 -0
- package/src/core/engine/store/store.runtime.ts +336 -0
- package/src/core/engine/store/store.schedule.ts +116 -0
- package/src/core/engine/store/store.types.ts +250 -0
- package/src/core/engine/validation/file.ts +77 -0
- package/src/core/engine/validation/index.ts +3 -0
- package/src/core/engine/validation/intent.ts +55 -0
- package/src/core/engine/validation/list.ts +50 -0
- package/src/core/index.ts +5 -0
- package/src/core/persistence/adapters.indexeddb.ts +92 -0
- package/src/core/persistence/adapters.local.ts +22 -0
- package/src/core/persistence/adapters.memory.ts +15 -0
- package/src/core/persistence/index.ts +5 -0
- package/src/core/persistence/persistence.ts +194 -0
- package/src/core/persistence/persistence.types.ts +66 -0
- package/src/core/utils/async.ts +3 -0
- package/src/core/utils/constants.ts +32 -0
- package/src/core/utils/emitter/emitter.types.ts +23 -0
- package/src/core/utils/emitter/index.ts +68 -0
- package/src/core/utils/fingerprint.ts +20 -0
- package/src/core/utils/guards.ts +3 -0
- package/src/core/utils/id.ts +3 -0
- package/src/core/utils/index.ts +6 -0
- package/src/index.ts +3 -0
- package/src/react/index.ts +2 -0
- package/src/react/upload-provider.tsx +96 -0
- package/src/react/use-uploader.tsx +115 -0
- package/src/strategies/index.ts +15 -0
- package/src/strategies/multipart/index.ts +243 -0
- package/src/strategies/post/index.ts +93 -0
- 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,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
|
+
}
|
package/src/index.ts
ADDED
|
@@ -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'
|