@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,250 @@
|
|
|
1
|
+
import type { UploadConfig, UploadConfigInput, UploadHooks, UploadPlugin } from '../../client'
|
|
2
|
+
import type {
|
|
3
|
+
CursorMap,
|
|
4
|
+
FileFingerprint,
|
|
5
|
+
IntentMap,
|
|
6
|
+
RejectReason,
|
|
7
|
+
StrategyRegistry,
|
|
8
|
+
UploadApi,
|
|
9
|
+
UploadError,
|
|
10
|
+
UploadResultBase,
|
|
11
|
+
UploadTransport,
|
|
12
|
+
} from '../../contracts'
|
|
13
|
+
import type { PersistedSnapshot, PersistenceAdapter } from '../../persistence'
|
|
14
|
+
import type { TypedEmitter } from '../../utils/emitter'
|
|
15
|
+
import type { UploadCommand } from '../commands.types'
|
|
16
|
+
import type { UploadEventMap } from '../event-map.types'
|
|
17
|
+
import type { InternalEvent } from '../internal-events.types'
|
|
18
|
+
import type { UploadOutcome } from '../outcome.types'
|
|
19
|
+
import type { createReducer, UploadState } from '../reducer'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Upload store interface used by UI adapters and application code.
|
|
23
|
+
*
|
|
24
|
+
* The store is intentionally minimal:
|
|
25
|
+
* - state is read through {@link UploadStore.getSnapshot}
|
|
26
|
+
* - updates are observed through {@link UploadStore.subscribe} and {@link UploadStore.on}
|
|
27
|
+
* - actions are performed through {@link UploadStore.dispatch}
|
|
28
|
+
*
|
|
29
|
+
* @template M - Intent map type (keyed by strategy id)
|
|
30
|
+
* @template C - Cursor map type (keyed by strategy id)
|
|
31
|
+
* @template P - Purpose string union type
|
|
32
|
+
*/
|
|
33
|
+
export interface UploadStore<
|
|
34
|
+
M extends IntentMap,
|
|
35
|
+
C extends CursorMap<M>,
|
|
36
|
+
P extends string,
|
|
37
|
+
R extends UploadResultBase = UploadResultBase,
|
|
38
|
+
> {
|
|
39
|
+
/**
|
|
40
|
+
* Returns the current immutable state snapshot.
|
|
41
|
+
* Useful for React loops or debugging.
|
|
42
|
+
*/
|
|
43
|
+
getSnapshot(): UploadState<M, C, P, R>
|
|
44
|
+
/**
|
|
45
|
+
* Subscribes to ANY state change.
|
|
46
|
+
* The listener is called after the reducer has run and effects have been scheduled.
|
|
47
|
+
* @param listener - Listener function to call on state change
|
|
48
|
+
*/
|
|
49
|
+
subscribe(listener: () => void): () => void
|
|
50
|
+
/**
|
|
51
|
+
* Main entry point for user actions.
|
|
52
|
+
* Converts external commands into internal state transitions and side effects.
|
|
53
|
+
*
|
|
54
|
+
* @param cmd The command to execute (e.g. 'addFiles', 'start', 'pause')
|
|
55
|
+
*/
|
|
56
|
+
dispatch(cmd: UploadCommand<P>): void
|
|
57
|
+
/**
|
|
58
|
+
* Subscribes to specific events (like 'file.added', 'upload.progress').
|
|
59
|
+
* Wraps the internal typed emitter.
|
|
60
|
+
*/
|
|
61
|
+
on: <K extends keyof UploadEventMap<M, C, P, R> & string>(
|
|
62
|
+
type: K,
|
|
63
|
+
cb: (payload: UploadEventMap<M, C, P, R>[K]) => void,
|
|
64
|
+
) => () => void
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Unsubscribes from specific events (like 'file.added', 'upload.progress').
|
|
68
|
+
* Wraps the internal typed emitter.
|
|
69
|
+
*/
|
|
70
|
+
off: <K extends keyof UploadEventMap<M, C, P, R> & string>(
|
|
71
|
+
type: K,
|
|
72
|
+
cb: (payload: UploadEventMap<M, C, P, R>[K]) => void,
|
|
73
|
+
) => () => void
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Waits for the given localIds to reach a terminal state.
|
|
77
|
+
*/
|
|
78
|
+
waitFor(localIds: string[]): Promise<Array<UploadOutcome<R>>>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Options used to construct a store runtime.
|
|
83
|
+
*
|
|
84
|
+
* In practice you provide:
|
|
85
|
+
* - a backend adapter ({@link UploadApi}) that creates intents and finalizes uploads
|
|
86
|
+
* - a transport implementation (XHR, fetch, etc.) used by strategies
|
|
87
|
+
* - a strategy registry describing what upload strategies exist and how to run them
|
|
88
|
+
* - configuration rules for validation, concurrency, retries, and progress throttling
|
|
89
|
+
*
|
|
90
|
+
* Hooks and plugins are optional and let you observe or extend behavior without forking
|
|
91
|
+
* the engine.
|
|
92
|
+
*
|
|
93
|
+
* @template M - Intent map type (keyed by strategy id)
|
|
94
|
+
* @template C - Cursor map type (keyed by strategy id)
|
|
95
|
+
* @template P - Purpose string union type
|
|
96
|
+
*/
|
|
97
|
+
export interface StoreOptions<
|
|
98
|
+
M extends IntentMap,
|
|
99
|
+
C extends CursorMap<M>,
|
|
100
|
+
P extends string,
|
|
101
|
+
R extends UploadResultBase = UploadResultBase,
|
|
102
|
+
> {
|
|
103
|
+
/** Initial state to hydrate from (e.g. from localStorage) */
|
|
104
|
+
initialState?: UploadState<M, C, P, R>
|
|
105
|
+
/** Static configuration rules (defaults are applied). */
|
|
106
|
+
config?: UploadConfigInput<P>
|
|
107
|
+
/** Persistence adapter and options */
|
|
108
|
+
persistence?: PersistenceOptions<M, C, P, R>
|
|
109
|
+
/** Backend API adapter for creating intents and finalizing uploads */
|
|
110
|
+
api: UploadApi<M, P, R>
|
|
111
|
+
/** Network transport (XHR/Fetch) for the actual file data transfer */
|
|
112
|
+
transport?: UploadTransport
|
|
113
|
+
/** Registry of available upload strategies (multipart, simple, etc.) */
|
|
114
|
+
strategies: StrategyRegistry<M, C, P, R>
|
|
115
|
+
/** Optional plugins to extend functionality (e.g. debugging, metrics) */
|
|
116
|
+
plugins?: Array<UploadPlugin<M, C, P, R>>
|
|
117
|
+
/** Lifecycle hooks for observing internal events */
|
|
118
|
+
hooks?: UploadHooks<M, C, P, R>
|
|
119
|
+
/**
|
|
120
|
+
* Optional custom fingerprinting (e.g. include sha256).
|
|
121
|
+
* Must be synchronous to keep addFiles fast.
|
|
122
|
+
*/
|
|
123
|
+
fingerprint?: (file: File) => FileFingerprint
|
|
124
|
+
/**
|
|
125
|
+
* If returns non-null, it rejects the file.
|
|
126
|
+
* Runs after built-in config validation.
|
|
127
|
+
*/
|
|
128
|
+
validateFile?: (file: File, purpose: P) => RejectReason | null
|
|
129
|
+
/**
|
|
130
|
+
* Optional custom error normalizer.
|
|
131
|
+
* Convert raw errors (from fetch, XHR, etc.) into your custom UploadError shape.
|
|
132
|
+
*/
|
|
133
|
+
errorNormalizer?: (err: unknown) => UploadError
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Persistence options.
|
|
138
|
+
*
|
|
139
|
+
* Used to configure the upload store persistence layer.
|
|
140
|
+
*/
|
|
141
|
+
export type DeserializeContext<M extends IntentMap, C extends CursorMap<M>, P extends string> = {
|
|
142
|
+
isPurpose?: (value: string) => value is P
|
|
143
|
+
isIntent?: (value: unknown) => value is M[keyof M]
|
|
144
|
+
hasStrategy: (value: string) => boolean
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type PersistenceOptions<
|
|
148
|
+
M extends IntentMap,
|
|
149
|
+
C extends CursorMap<M>,
|
|
150
|
+
P extends string,
|
|
151
|
+
R extends UploadResultBase,
|
|
152
|
+
> = {
|
|
153
|
+
/** Persistence key (e.g. localStorage key) */
|
|
154
|
+
key: string
|
|
155
|
+
/** Persistence schema version (not app version) */
|
|
156
|
+
version: number
|
|
157
|
+
/** Debounce delay in milliseconds (default: 200) */
|
|
158
|
+
debounceMs?: number
|
|
159
|
+
/** Persistence adapter */
|
|
160
|
+
adapter: PersistenceAdapter
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Optional custom snapshot serializer.
|
|
164
|
+
* Convert the upload state into a JSON-safe snapshot.
|
|
165
|
+
*/
|
|
166
|
+
serialize?: (state: UploadState<M, C, P, R>, version: number) => PersistedSnapshot<M, C, P>
|
|
167
|
+
/**
|
|
168
|
+
* Optional custom snapshot deserializer.
|
|
169
|
+
* Convert a JSON-safe snapshot into the upload state.
|
|
170
|
+
*/
|
|
171
|
+
deserialize?: (raw: unknown, ctx: DeserializeContext<M, C, P>) => UploadState<M, C, P, R> | null
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Runtime guard for purpose strings when using the default deserializer.
|
|
175
|
+
*/
|
|
176
|
+
isPurpose?: (value: string) => value is P
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Runtime guard for intent objects when using the default deserializer.
|
|
180
|
+
*/
|
|
181
|
+
isIntent?: (value: unknown) => value is M[keyof M]
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Internal record describing a currently running upload request.
|
|
186
|
+
*
|
|
187
|
+
* The store keeps one inflight entry per `localId` while the strategy's `start()` is running.
|
|
188
|
+
* The {@link AbortController} is used to pause/cancel, while `mode` tracks which user intent
|
|
189
|
+
* caused the abort so the handler can map it back into a correct state transition.
|
|
190
|
+
*/
|
|
191
|
+
export type InflightUpload = {
|
|
192
|
+
controller: AbortController
|
|
193
|
+
mode: 'normal' | 'pause' | 'cancel'
|
|
194
|
+
started: boolean
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Internal runtime container shared across dispatch, scheduler, and handlers.
|
|
199
|
+
*
|
|
200
|
+
* @template M - Intent map type
|
|
201
|
+
* @template C - Cursor map type
|
|
202
|
+
* @template P - Purpose string union type
|
|
203
|
+
*/
|
|
204
|
+
export type StoreRuntime<
|
|
205
|
+
M extends IntentMap,
|
|
206
|
+
C extends CursorMap<M>,
|
|
207
|
+
P extends string,
|
|
208
|
+
R extends UploadResultBase = UploadResultBase,
|
|
209
|
+
> = {
|
|
210
|
+
/** Store options */
|
|
211
|
+
opts: StoreOptions<M, C, P, R> & { config: UploadConfig<P>; transport: UploadTransport }
|
|
212
|
+
/** Current state */
|
|
213
|
+
state: UploadState<M, C, P, R>
|
|
214
|
+
|
|
215
|
+
/** Listeners for external events */
|
|
216
|
+
listeners: Set<() => void>
|
|
217
|
+
/** Typed event emitter */
|
|
218
|
+
emitter: TypedEmitter<UploadEventMap<M, C, P, R>>
|
|
219
|
+
/** Reducer */
|
|
220
|
+
reduce: ReturnType<typeof createReducer<M, C, P, R>>
|
|
221
|
+
|
|
222
|
+
/** In-flight uploads */
|
|
223
|
+
inflightUploads: Map<string, InflightUpload>
|
|
224
|
+
/** In-flight intents */
|
|
225
|
+
inflightIntents: Map<string, AbortController>
|
|
226
|
+
/** In-flight completes */
|
|
227
|
+
inflightCompletes: Map<string, AbortController>
|
|
228
|
+
|
|
229
|
+
/** Effect queue */
|
|
230
|
+
effectQueue: Array<() => Promise<void>>
|
|
231
|
+
/** Effect queue processing */
|
|
232
|
+
processingEffects: boolean
|
|
233
|
+
/** Effect queue scheduling */
|
|
234
|
+
scheduling: boolean
|
|
235
|
+
|
|
236
|
+
/** Notifies listeners */
|
|
237
|
+
notify: () => void
|
|
238
|
+
/** Schedules work */
|
|
239
|
+
scheduleWork: () => void
|
|
240
|
+
/** Applies an internal event */
|
|
241
|
+
applyInternal: (event: InternalEvent<M, C, P, R>) => void
|
|
242
|
+
/** Applies a command */
|
|
243
|
+
applyCommand: (cmd: UploadCommand<P>) => void
|
|
244
|
+
/** Enqueues an effect */
|
|
245
|
+
enqueueEffect: (effect: () => Promise<void>) => void
|
|
246
|
+
/** Processes effects */
|
|
247
|
+
processEffects: () => Promise<void>
|
|
248
|
+
/** Set by createUploadStore after construction; used by handlers for retries etc. */
|
|
249
|
+
dispatch: (cmd: UploadCommand<P>) => void
|
|
250
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { UploadConfig } from '../../client'
|
|
2
|
+
import type { RejectReason } from '../../contracts'
|
|
3
|
+
|
|
4
|
+
export function validateFile<P extends string>(file: File, purpose: P, config: UploadConfig<P>): RejectReason | null {
|
|
5
|
+
// Get rules for this purpose, skip validation if no rules defined
|
|
6
|
+
const rules = config.validation?.[purpose]
|
|
7
|
+
if (!rules) return null
|
|
8
|
+
|
|
9
|
+
// Check for empty file
|
|
10
|
+
if (file.size === 0) return { code: 'empty_file' }
|
|
11
|
+
|
|
12
|
+
// Check maximum size limit
|
|
13
|
+
if (rules.maxSizeBytes !== undefined && file.size > rules.maxSizeBytes) {
|
|
14
|
+
return { code: 'file_too_large', maxBytes: rules.maxSizeBytes, size: file.size }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Check minimum size limit (treated as empty for simplicity)
|
|
18
|
+
if (rules.minSizeBytes !== undefined && file.size < rules.minSizeBytes) {
|
|
19
|
+
return { code: 'empty_file' }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Check file type and extension restrictions
|
|
23
|
+
if (rules.allowedTypes || rules.allowedExtensions) {
|
|
24
|
+
const allowed = rules.allowedTypes || []
|
|
25
|
+
const extensions = rules.allowedExtensions || []
|
|
26
|
+
|
|
27
|
+
// Extract file extension (lowercase, without dot)
|
|
28
|
+
const fileExt = file.name.split('.').pop()?.toLowerCase()
|
|
29
|
+
// File MIME type (may be empty string for unknown types)
|
|
30
|
+
const mimeType = (file.type || '').toLowerCase()
|
|
31
|
+
|
|
32
|
+
const typeMatches =
|
|
33
|
+
allowed.length === 0
|
|
34
|
+
? false
|
|
35
|
+
: allowed.some((type) => {
|
|
36
|
+
if (type.endsWith('/*')) {
|
|
37
|
+
// Wildcard match: 'image/*' matches 'image/jpeg', 'image/png', etc.
|
|
38
|
+
const prefix = type.slice(0, -2).toLowerCase()
|
|
39
|
+
return mimeType.startsWith(prefix + '/')
|
|
40
|
+
}
|
|
41
|
+
// Exact match
|
|
42
|
+
return mimeType === type.toLowerCase()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const extMatches =
|
|
46
|
+
!!fileExt && extensions.length > 0 ? extensions.some((ext) => ext.toLowerCase() === fileExt) : false
|
|
47
|
+
|
|
48
|
+
// Determine if file passes type/extension check
|
|
49
|
+
const hasTypeRules = allowed.length > 0
|
|
50
|
+
const hasExtRules = extensions.length > 0
|
|
51
|
+
|
|
52
|
+
// If both rules exist, allow either to match (OR logic)
|
|
53
|
+
// If only one exists, require it to match
|
|
54
|
+
const ok = (hasTypeRules ? typeMatches : true) && (hasExtRules ? extMatches : true)
|
|
55
|
+
const okEither = hasTypeRules && hasExtRules ? typeMatches || extMatches : ok
|
|
56
|
+
|
|
57
|
+
if (!okEither) {
|
|
58
|
+
return {
|
|
59
|
+
code: 'type_not_allowed',
|
|
60
|
+
allowed: [...allowed, ...extensions],
|
|
61
|
+
got: file.type || file.name,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// All checks passed
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validates a batch of files, filtering them into 'valid' and 'rejected' lists.
|
|
72
|
+
* Also enforces the `maxFiles` limit relative to the existing count.
|
|
73
|
+
*
|
|
74
|
+
* @param files Array of files to validate
|
|
75
|
+
* @param purpose Upload purpose
|
|
76
|
+
* @param existingCount Number of files already uploaded (to enforce maxFiles)
|
|
77
|
+
*/
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { isRecord } from '../../utils/guards'
|
|
2
|
+
|
|
3
|
+
export function validateIntent(intent: unknown, strategy: string): Error | null {
|
|
4
|
+
if (!isRecord(intent)) {
|
|
5
|
+
return new Error('Invalid intent: must be an object')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Check required fields
|
|
9
|
+
if (typeof intent.strategy !== 'string') {
|
|
10
|
+
return new Error('Invalid intent: missing or invalid strategy field')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (intent.strategy !== strategy) {
|
|
14
|
+
return new Error(`Invalid intent: strategy mismatch (expected ${strategy}, got ${intent.strategy})`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (typeof intent.fileId !== 'string' || !intent.fileId) {
|
|
18
|
+
return new Error('Invalid intent: missing or invalid fileId')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Strategy-specific validation
|
|
22
|
+
if (strategy === 'post') {
|
|
23
|
+
// POST strategy requires url and fields
|
|
24
|
+
if (typeof intent.url !== 'string' || !intent.url) {
|
|
25
|
+
return new Error('Invalid post intent: missing or invalid url')
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(intent.url)
|
|
29
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
30
|
+
return new Error('Invalid post intent: url must use http or https protocol')
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
return new Error('Invalid post intent: url is not a valid URL')
|
|
34
|
+
}
|
|
35
|
+
if (!intent.fields || typeof intent.fields !== 'object') {
|
|
36
|
+
return new Error('Invalid post intent: missing or invalid fields')
|
|
37
|
+
}
|
|
38
|
+
} else if (strategy === 'multipart') {
|
|
39
|
+
// Multipart strategy requires uploadId and partSize
|
|
40
|
+
if (typeof intent.uploadId !== 'string' || !intent.uploadId) {
|
|
41
|
+
return new Error('Invalid multipart intent: missing or invalid uploadId')
|
|
42
|
+
}
|
|
43
|
+
if (typeof intent.partSize !== 'number' || intent.partSize <= 0) {
|
|
44
|
+
return new Error('Invalid multipart intent: missing or invalid partSize')
|
|
45
|
+
}
|
|
46
|
+
// Parts array is optional (can be fetched later)
|
|
47
|
+
if ('parts' in intent && intent.parts !== undefined) {
|
|
48
|
+
if (!Array.isArray(intent.parts)) {
|
|
49
|
+
return new Error('Invalid multipart intent: parts must be an array if provided')
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { UploadConfig } from '../../client'
|
|
2
|
+
import type { RejectReason } from '../../contracts'
|
|
3
|
+
import { validateFile } from './file'
|
|
4
|
+
|
|
5
|
+
export function validateFileList<P extends string>(
|
|
6
|
+
files: File[],
|
|
7
|
+
purpose: P,
|
|
8
|
+
config: UploadConfig<P>,
|
|
9
|
+
existingCount: number = 0,
|
|
10
|
+
): { valid: File[]; rejected: Array<{ file: File; reason: RejectReason }> } {
|
|
11
|
+
const rules = config.validation?.[purpose]
|
|
12
|
+
const valid: File[] = []
|
|
13
|
+
const rejected: Array<{ file: File; reason: RejectReason }> = []
|
|
14
|
+
|
|
15
|
+
// Calculate remaining slots based on maxFiles limit
|
|
16
|
+
const maxFiles = rules?.maxFiles
|
|
17
|
+
let remaining = maxFiles !== undefined ? Math.max(0, maxFiles - existingCount) : Number.POSITIVE_INFINITY
|
|
18
|
+
|
|
19
|
+
for (const file of files) {
|
|
20
|
+
// Check if we've reached the file limit
|
|
21
|
+
if (remaining <= 0) {
|
|
22
|
+
const limit = maxFiles ?? 0
|
|
23
|
+
rejected.push({ file, reason: { code: 'too_many_files', max: limit } })
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Run individual file validation
|
|
28
|
+
const reason = validateFile(file, purpose, config)
|
|
29
|
+
if (reason) {
|
|
30
|
+
rejected.push({ file, reason })
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// File is valid, add to list and decrement remaining slots
|
|
35
|
+
valid.push(file)
|
|
36
|
+
remaining -= 1
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { valid, rejected }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validates an intent object from the backend.
|
|
44
|
+
* Ensures required fields are present and URLs are valid.
|
|
45
|
+
*
|
|
46
|
+
* @param intent - Intent object to validate
|
|
47
|
+
* @param strategy - Expected strategy type (from the intent itself)
|
|
48
|
+
*
|
|
49
|
+
* @returns {Error | null} Error if validation fails, null if valid
|
|
50
|
+
*/
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { PersistenceAdapter } from './persistence.types'
|
|
2
|
+
|
|
3
|
+
const DB_NAME = 'upload-engine'
|
|
4
|
+
const STORE = 'snapshots'
|
|
5
|
+
const VERSION = 1
|
|
6
|
+
|
|
7
|
+
function openDB(): Promise<IDBDatabase> {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const req = indexedDB.open(DB_NAME, VERSION)
|
|
10
|
+
req.onupgradeneeded = () => {
|
|
11
|
+
const db = req.result
|
|
12
|
+
if (!db.objectStoreNames.contains(STORE)) {
|
|
13
|
+
db.createObjectStore(STORE)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
req.onsuccess = () => resolve(req.result)
|
|
17
|
+
req.onerror = () => reject(req.error)
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function reqToPromise<T>(req: IDBRequest<T>): Promise<T> {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
req.onsuccess = () => resolve(req.result)
|
|
24
|
+
req.onerror = () => reject(req.error)
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function txDone(tx: IDBTransaction): Promise<void> {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
tx.oncomplete = () => resolve()
|
|
31
|
+
tx.onabort = () => reject(tx.error ?? new Error('IndexedDB transaction aborted'))
|
|
32
|
+
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction error'))
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const IndexedDBAdapter: PersistenceAdapter = {
|
|
37
|
+
async load(key) {
|
|
38
|
+
if (typeof indexedDB === 'undefined') return null
|
|
39
|
+
const db = await openDB()
|
|
40
|
+
const tx = db.transaction(STORE, 'readonly')
|
|
41
|
+
const store = tx.objectStore(STORE)
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const value = await reqToPromise(store.get(key))
|
|
45
|
+
await txDone(tx)
|
|
46
|
+
return value ?? null
|
|
47
|
+
} catch {
|
|
48
|
+
try {
|
|
49
|
+
await txDone(tx)
|
|
50
|
+
} catch {
|
|
51
|
+
// ignore
|
|
52
|
+
}
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
async save(key, snapshot) {
|
|
58
|
+
if (typeof indexedDB === 'undefined') return
|
|
59
|
+
const db = await openDB()
|
|
60
|
+
const tx = db.transaction(STORE, 'readwrite')
|
|
61
|
+
const store = tx.objectStore(STORE)
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
store.put(snapshot, key)
|
|
65
|
+
await txDone(tx)
|
|
66
|
+
} catch {
|
|
67
|
+
try {
|
|
68
|
+
tx.abort()
|
|
69
|
+
} catch {
|
|
70
|
+
// ignore
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async clear(key) {
|
|
76
|
+
if (typeof indexedDB === 'undefined') return
|
|
77
|
+
const db = await openDB()
|
|
78
|
+
const tx = db.transaction(STORE, 'readwrite')
|
|
79
|
+
const store = tx.objectStore(STORE)
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
store.delete(key)
|
|
83
|
+
await txDone(tx)
|
|
84
|
+
} catch {
|
|
85
|
+
try {
|
|
86
|
+
tx.abort()
|
|
87
|
+
} catch {
|
|
88
|
+
// ignore
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PersistenceAdapter } from './persistence.types'
|
|
2
|
+
|
|
3
|
+
export const LocalStorageAdapter: PersistenceAdapter = {
|
|
4
|
+
load(key) {
|
|
5
|
+
if (typeof localStorage === 'undefined') return null
|
|
6
|
+
const raw = localStorage.getItem(key)
|
|
7
|
+
if (!raw) return null
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(raw)
|
|
10
|
+
} catch {
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
save(key, snapshot) {
|
|
15
|
+
if (typeof localStorage === 'undefined') return
|
|
16
|
+
localStorage.setItem(key, JSON.stringify(snapshot))
|
|
17
|
+
},
|
|
18
|
+
clear(key) {
|
|
19
|
+
if (typeof localStorage === 'undefined') return
|
|
20
|
+
localStorage.removeItem(key)
|
|
21
|
+
},
|
|
22
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PersistenceAdapter } from './persistence.types'
|
|
2
|
+
|
|
3
|
+
const store = new Map<string, unknown>()
|
|
4
|
+
|
|
5
|
+
export const MemoryAdapter: PersistenceAdapter = {
|
|
6
|
+
load(key) {
|
|
7
|
+
return store.get(key) ?? null
|
|
8
|
+
},
|
|
9
|
+
save(key, snapshot) {
|
|
10
|
+
store.set(key, snapshot)
|
|
11
|
+
},
|
|
12
|
+
clear(key) {
|
|
13
|
+
store.delete(key)
|
|
14
|
+
},
|
|
15
|
+
}
|