@gentleduck/upload 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +170 -193
- package/dist/core/index.d.ts +2 -3
- package/dist/core/index.js +1 -1
- package/dist/core-BVgloTiP.js +10 -0
- package/dist/index-DCqyBVcx.d.ts +1441 -0
- package/dist/index-DsZHPmJm.d.ts +225 -0
- package/dist/index-KROXtLQh.d.ts +281 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +2 -51
- package/dist/react/index.js +1 -1
- package/dist/react-euPrDv24.js +1 -0
- package/dist/strategies/index.d.ts +2 -56
- package/dist/strategies/index.js +1 -1
- package/dist/strategies-CUF0s68j.js +1 -0
- package/package.json +3 -2
- package/src/{__test__ → __tests__}/emitter.test.ts +4 -4
- package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
- package/src/{__test__ → __tests__}/guards.test.ts +15 -5
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
- package/src/__tests__/sanitize-filename.test.ts +109 -0
- package/src/__tests__/sleep.test.ts +34 -0
- package/src/__tests__/static-analysis.test.ts +145 -0
- package/src/{__test__ → __tests__}/store-libs.test.ts +57 -3
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/{__test__ → __tests__}/transport.test.ts +5 -5
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +335 -141
- package/src/core/contracts/index.ts +3 -8
- package/src/core/contracts/transport/index.ts +3 -2
- package/src/core/contracts/transport/transport.fetch.ts +203 -0
- package/src/core/contracts/transport/transport.libs.ts +7 -8
- package/src/core/contracts/transport/transport.ts +91 -83
- package/src/core/contracts/transport/transport.types.ts +71 -35
- package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
- package/src/core/engine/engine.libs.ts +35 -0
- package/src/core/engine/engine.types.ts +367 -241
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +89 -43
- package/src/core/engine/store/handlers/add-file.ts +39 -29
- package/src/core/engine/store/handlers/cancel.ts +13 -8
- package/src/core/engine/store/handlers/clean-up.ts +11 -8
- package/src/core/engine/store/handlers/create-intent.ts +82 -27
- package/src/core/engine/store/handlers/finalize.ts +30 -13
- package/src/core/engine/store/handlers/index.ts +7 -7
- package/src/core/engine/store/handlers/pause.ts +8 -6
- package/src/core/engine/store/handlers/run-upload.ts +49 -34
- package/src/core/engine/store/index.ts +27 -21
- package/src/core/engine/store/store.dispatch.ts +10 -8
- package/src/core/engine/store/store.libs.ts +184 -41
- package/src/core/engine/store/store.runtime.ts +45 -36
- package/src/core/engine/store/store.schedule.ts +27 -21
- package/src/core/engine/store/store.types.ts +177 -227
- package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
- package/src/core/engine/validation/file.ts +7 -3
- package/src/core/engine/validation/index.ts +1 -0
- package/src/core/engine/validation/intent.ts +49 -27
- package/src/core/engine/validation/list.ts +14 -15
- package/src/core/engine/validation/mime.ts +39 -0
- package/src/core/errors.ts +271 -0
- package/src/core/index.ts +28 -5
- package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
- package/src/core/persistence/__tests__/persistence.test.ts +85 -0
- package/src/core/persistence/adapters.indexeddb.ts +9 -3
- package/src/core/persistence/adapters.local.ts +9 -3
- package/src/core/persistence/adapters.memory.ts +35 -13
- package/src/core/persistence/index.ts +5 -5
- package/src/core/persistence/persistence.ts +73 -60
- package/src/core/persistence/persistence.types.ts +140 -60
- package/src/core/utils/__tests__/guards.test.ts +93 -0
- package/src/core/utils/__tests__/url-safety.test.ts +112 -0
- package/src/core/utils/async.ts +7 -2
- package/src/core/utils/constants.ts +3 -9
- package/src/core/utils/emitter/emitter.ts +7 -24
- package/src/core/utils/emitter/emitter.types.ts +33 -16
- package/src/core/utils/emitter/index.ts +2 -74
- package/src/core/utils/fingerprint.ts +16 -5
- package/src/core/utils/guards.ts +91 -1
- package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
- package/src/core/utils/hash/index.ts +126 -0
- package/src/core/utils/hash/sha256.ts +179 -0
- package/src/core/utils/id.ts +33 -1
- package/src/core/utils/index.ts +15 -6
- package/src/core/utils/mime-sniff.ts +119 -0
- package/src/core/utils/sanitize-filename.ts +85 -0
- package/src/core/utils/url-safety.ts +156 -0
- package/src/react/__tests__/use-dropzone.test.ts +64 -0
- package/src/react/index.ts +5 -2
- package/src/react/upload-provider.tsx +68 -53
- package/src/react/uploader.types.ts +58 -61
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +98 -91
- package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
- package/src/strategies/_shared/retry.ts +79 -0
- package/src/strategies/index.ts +9 -15
- package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
- package/src/strategies/multipart/index.ts +206 -108
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +115 -33
- package/src/strategies/put/__tests__/put.test.ts +132 -0
- package/src/strategies/put/index.ts +139 -0
- package/src/strategies/registry/index.ts +12 -9
- package/src/strategies/tus/__tests__/tus.test.ts +159 -0
- package/src/strategies/tus/index.ts +211 -0
- package/dist/async-DxRWu4le.js +0 -1
- package/dist/core-CMkrP9DX.js +0 -2
- package/dist/index-C38Jmizu.d.ts +0 -1005
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- package/src/__test__/effect-pool.test.ts +0 -5
- package/src/__test__/id.test.ts +0 -14
- package/src/__test__/store-integration.test.ts +0 -5
- package/src/core/client/client.ts +0 -50
- package/src/core/client/client.types.ts +0 -184
- package/src/core/client/config.types.ts +0 -77
- package/src/core/client/index.ts +0 -4
- package/src/core/client/plugins.types.ts +0 -46
- package/src/core/client/retry.types.ts +0 -6
- package/src/core/client/uploadClient.ts +0 -19
- package/src/core/contracts/backendApi.ts +0 -62
- package/src/core/contracts/cursor.types.ts +0 -23
- package/src/core/contracts/errors.types.ts +0 -67
- package/src/core/contracts/fingerprint.types.ts +0 -18
- package/src/core/contracts/intent.types.ts +0 -39
- package/src/core/contracts/result.types.ts +0 -11
- package/src/core/contracts/strategy.types.ts +0 -45
- package/src/core/engine/commands.types.ts +0 -17
- package/src/core/engine/event-map.types.ts +0 -45
- package/src/core/engine/internal-events.types.ts +0 -202
- package/src/core/engine/outcome.types.ts +0 -9
- package/src/core/engine/progress.types.ts +0 -15
- package/src/core/engine/store/handlers.ts +0 -819
- package/src/core/engine/validation.ts +0 -216
- /package/src/{__test__ → __tests__}/transport-libs.test.ts +0 -0
|
@@ -1,250 +1,200 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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'
|
|
1
|
+
import type { Contracts, Transport } from '../../contracts'
|
|
2
|
+
import type { UploadPersistence } from '../../persistence'
|
|
3
|
+
import type { Emitter } from '../../utils'
|
|
4
|
+
import type { Engine } from '../engine.types'
|
|
5
|
+
import type { createReducer } from '../reducer'
|
|
20
6
|
|
|
21
7
|
/**
|
|
22
|
-
*
|
|
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
|
|
8
|
+
* Store-level namespaces and contracts.
|
|
9
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
32
10
|
*/
|
|
33
|
-
export
|
|
34
|
-
M extends IntentMap,
|
|
35
|
-
C extends CursorMap<M>,
|
|
36
|
-
P extends string,
|
|
37
|
-
R extends UploadResultBase = UploadResultBase,
|
|
38
|
-
> {
|
|
11
|
+
export namespace Store {
|
|
39
12
|
/**
|
|
40
|
-
*
|
|
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.
|
|
13
|
+
* Public upload store interface used by UI adapters and application code.
|
|
53
14
|
*
|
|
54
|
-
*
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
*
|
|
59
|
-
*
|
|
15
|
+
* Coordinates commands, subscriptions, event bindings, and lifecycle states.
|
|
16
|
+
*
|
|
17
|
+
* @template M - Intent map type (keyed by strategy id)
|
|
18
|
+
* @template C - Cursor map type (keyed by strategy id)
|
|
19
|
+
* @template P - Purpose string union type
|
|
20
|
+
* @template R - Backend result shape
|
|
60
21
|
*/
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
22
|
+
export type UploadStore<
|
|
23
|
+
M extends Contracts.Intent.Map,
|
|
24
|
+
C extends Contracts.Cursor.Map<M>,
|
|
25
|
+
P extends string,
|
|
26
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
27
|
+
> = {
|
|
28
|
+
/**
|
|
29
|
+
* Returns the current immutable state snapshot.
|
|
30
|
+
* Use this in custom React lifecycle bindings or debug inspectors.
|
|
31
|
+
*/
|
|
32
|
+
getSnapshot(): Engine.State<M, C, P, R>
|
|
65
33
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Subscribes to any internal state change.
|
|
36
|
+
* Triggered after reducers process events and side effects are queued.
|
|
37
|
+
*
|
|
38
|
+
* @param listener - Callback invoked on state updates.
|
|
39
|
+
* @returns An unsubscribe function.
|
|
40
|
+
*/
|
|
41
|
+
subscribe(listener: () => void): () => void
|
|
74
42
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Dispatches a command to initiate state transitions (e.g. adding files, pausing, canceling).
|
|
45
|
+
*
|
|
46
|
+
* @param cmd - Public command payload.
|
|
47
|
+
*/
|
|
48
|
+
dispatch(cmd: Engine.Command<P>): void
|
|
80
49
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
}
|
|
50
|
+
/**
|
|
51
|
+
* Binds a listener to a specific typed event (e.g. `'upload.progress'`).
|
|
52
|
+
*
|
|
53
|
+
* @param type - Event name key.
|
|
54
|
+
* @param cb - Event listener callback.
|
|
55
|
+
* @returns An unsubscribe function.
|
|
56
|
+
*/
|
|
57
|
+
on: <K extends keyof Engine.EventMap<M, C, P, R> & string>(
|
|
58
|
+
type: K,
|
|
59
|
+
cb: (payload: Engine.EventMap<M, C, P, R>[K]) => void,
|
|
60
|
+
) => () => void
|
|
135
61
|
|
|
136
|
-
/**
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Removes an event listener.
|
|
64
|
+
*
|
|
65
|
+
* @param type - Event name key.
|
|
66
|
+
* @param cb - Event listener callback.
|
|
67
|
+
*/
|
|
68
|
+
off: <K extends keyof Engine.EventMap<M, C, P, R> & string>(
|
|
69
|
+
type: K,
|
|
70
|
+
cb: (payload: Engine.EventMap<M, C, P, R>[K]) => void,
|
|
71
|
+
) => void
|
|
146
72
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
73
|
+
/**
|
|
74
|
+
* Resolves when the specified items reach a terminal state
|
|
75
|
+
* (completed, failed, canceled, or missing).
|
|
76
|
+
*
|
|
77
|
+
* @param localIds - Array of registered client file IDs.
|
|
78
|
+
*/
|
|
79
|
+
waitFor(localIds: string[]): Promise<Array<Engine.Outcome<R>>>
|
|
80
|
+
}
|
|
161
81
|
|
|
162
82
|
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
*
|
|
169
|
-
* Convert a JSON-safe snapshot into the upload state.
|
|
83
|
+
* Configuration options for constructing an upload store.
|
|
84
|
+
*
|
|
85
|
+
* @template M - Intent map type (keyed by strategy id)
|
|
86
|
+
* @template C - Cursor map type (keyed by strategy id)
|
|
87
|
+
* @template P - Purpose string union type
|
|
88
|
+
* @template R - Backend result shape
|
|
170
89
|
*/
|
|
171
|
-
|
|
90
|
+
export type Options<
|
|
91
|
+
M extends Contracts.Intent.Map,
|
|
92
|
+
C extends Contracts.Cursor.Map<M>,
|
|
93
|
+
P extends string,
|
|
94
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
95
|
+
> = {
|
|
96
|
+
/** Initial state to hydrate from (e.g. recovered from a persistence snapshot). */
|
|
97
|
+
initialState?: Engine.State<M, C, P, R>
|
|
98
|
+
|
|
99
|
+
/** Engine configuration (defaults applied for unspecified settings). */
|
|
100
|
+
config?: Partial<Engine.Config<P>>
|
|
101
|
+
|
|
102
|
+
/** Persistence adapter configuration (key prefix, adapters, storage handlers). */
|
|
103
|
+
persistence?: UploadPersistence.Options<M, C, P, R>
|
|
104
|
+
|
|
105
|
+
/** Your backend API implementation. */
|
|
106
|
+
api: Contracts.Api.Me<M, P, R>
|
|
107
|
+
|
|
108
|
+
/** Transport layer options for HTTP byte transfers. */
|
|
109
|
+
transport?: Transport.Options
|
|
110
|
+
|
|
111
|
+
/** Registry of available upload strategies (multipart, POST forms, simple PUTs, TUS). */
|
|
112
|
+
strategies: Contracts.Strategy.Registry<M, C, P, R>
|
|
113
|
+
|
|
114
|
+
/** Plugin modules to extend behavior (metrics, logging, analytics). */
|
|
115
|
+
plugins?: Array<Engine.Plugin<M, C, P, R>>
|
|
116
|
+
|
|
117
|
+
/** Lifecycle hooks for observing internal events. */
|
|
118
|
+
hooks?: Engine.Hooks<M, C, P, R>
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Override to use a custom fingerprinting implementation (e.g. a custom hash).
|
|
122
|
+
*/
|
|
123
|
+
fingerprint?: (file: File) => Contracts.FingerprintFile
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Custom per-file validator called after built-in config constraints pass.
|
|
127
|
+
* Return a rejection to block the file; return null to allow it.
|
|
128
|
+
*/
|
|
129
|
+
validateFile?: (file: File, purpose: P) => Contracts.Validation.Rejection | null
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Custom error normalizer.
|
|
133
|
+
* Converts raw thrown values into a standardized Contracts.Errors.Error.
|
|
134
|
+
*/
|
|
135
|
+
errorNormalizer?: (err: unknown) => Contracts.Errors.Error
|
|
136
|
+
}
|
|
172
137
|
|
|
173
138
|
/**
|
|
174
|
-
*
|
|
139
|
+
* Tracks an in-flight upload operation.
|
|
175
140
|
*/
|
|
176
|
-
|
|
141
|
+
export type InflightUpload = {
|
|
142
|
+
/** AbortController for this upload. */
|
|
143
|
+
controller: AbortController
|
|
144
|
+
/** Whether the upload is running normally, being paused, or being canceled. */
|
|
145
|
+
mode: 'normal' | 'pause' | 'cancel'
|
|
146
|
+
/** True once the strategy's start() has been called. */
|
|
147
|
+
started: boolean
|
|
148
|
+
}
|
|
177
149
|
|
|
178
150
|
/**
|
|
179
|
-
*
|
|
151
|
+
* Internal execution context shared across store utilities, handlers, and schedulers.
|
|
180
152
|
*/
|
|
181
|
-
|
|
182
|
-
|
|
153
|
+
export type Runtime<
|
|
154
|
+
M extends Contracts.Intent.Map,
|
|
155
|
+
C extends Contracts.Cursor.Map<M>,
|
|
156
|
+
P extends string,
|
|
157
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
158
|
+
> = {
|
|
159
|
+
/** Resolved store options with all defaults applied. */
|
|
160
|
+
opts: Options<M, C, P, R> & { config: Engine.Config<P>; transport: Transport.Options }
|
|
161
|
+
/** Current engine state. */
|
|
162
|
+
state: Engine.State<M, C, P, R>
|
|
183
163
|
|
|
184
|
-
/**
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
*/
|
|
191
|
-
export type InflightUpload = {
|
|
192
|
-
controller: AbortController
|
|
193
|
-
mode: 'normal' | 'pause' | 'cancel'
|
|
194
|
-
started: boolean
|
|
195
|
-
}
|
|
164
|
+
/** Active state change subscribers. */
|
|
165
|
+
listeners: Set<() => void>
|
|
166
|
+
/** Typed event emitter. */
|
|
167
|
+
emitter: Emitter.TypedEmitter<Engine.EventMap<M, C, P, R>>
|
|
168
|
+
/** Reducer function. */
|
|
169
|
+
reduce: ReturnType<typeof createReducer<M, C, P, R>>
|
|
196
170
|
|
|
197
|
-
/**
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
171
|
+
/** Inflight upload operations keyed by localId. */
|
|
172
|
+
inflightUploads: Map<string, InflightUpload>
|
|
173
|
+
/** Inflight intent creation requests keyed by localId. */
|
|
174
|
+
inflightIntents: Map<string, AbortController>
|
|
175
|
+
/** Inflight complete() calls keyed by localId. */
|
|
176
|
+
inflightCompletes: Map<string, AbortController>
|
|
177
|
+
|
|
178
|
+
/** Queued async side effects. */
|
|
179
|
+
effectQueue: Array<() => Promise<void>>
|
|
180
|
+
/** True while the effect queue is being drained. */
|
|
181
|
+
processingEffects: boolean
|
|
182
|
+
/** True while scheduleWork is running, preventing re-entrant calls. */
|
|
183
|
+
scheduling: boolean
|
|
184
|
+
|
|
185
|
+
/** Notifies all state change subscribers. */
|
|
186
|
+
notify: () => void
|
|
187
|
+
/** Starts queued uploads when concurrency slots are available. */
|
|
188
|
+
scheduleWork: () => void
|
|
189
|
+
/** Applies an internal event through the reducer and notifies subscribers. */
|
|
190
|
+
applyInternal: (event: Engine.Event<M, C, P, R>) => void
|
|
191
|
+
/** Runs a command through the reducer. */
|
|
192
|
+
applyCommand: (cmd: Engine.Command<P>) => void
|
|
193
|
+
/** Adds an async side effect to the execution queue. */
|
|
194
|
+
enqueueEffect: (effect: () => Promise<void>) => void
|
|
195
|
+
/** Drains the effect queue, running each side effect in order. */
|
|
196
|
+
processEffects: () => Promise<void>
|
|
197
|
+
/** Dispatches a public command. */
|
|
198
|
+
dispatch: (cmd: Engine.Command<P>) => void
|
|
199
|
+
}
|
|
250
200
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { validateIntent } from '../intent'
|
|
3
|
+
|
|
4
|
+
describe('validateIntent - base shape', () => {
|
|
5
|
+
test('rejects non-object', () => {
|
|
6
|
+
expect(validateIntent(null, 'post')?.message).toMatch(/must be an object/)
|
|
7
|
+
expect(validateIntent('str', 'post')?.message).toMatch(/must be an object/)
|
|
8
|
+
})
|
|
9
|
+
test('rejects missing strategy', () => {
|
|
10
|
+
expect(validateIntent({}, 'post')?.message).toMatch(/missing or invalid strategy/)
|
|
11
|
+
})
|
|
12
|
+
test('rejects strategy mismatch', () => {
|
|
13
|
+
expect(validateIntent({ strategy: 'multipart' }, 'post')?.message).toMatch(/strategy mismatch/)
|
|
14
|
+
})
|
|
15
|
+
test('rejects missing fileId', () => {
|
|
16
|
+
expect(validateIntent({ strategy: 'post' }, 'post')?.message).toMatch(/missing or invalid fileId/)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('validateIntent - post', () => {
|
|
21
|
+
const base = { strategy: 'post', fileId: 'f1', fields: {} }
|
|
22
|
+
test('rejects file://', () => {
|
|
23
|
+
expect(validateIntent({ ...base, url: 'file:///x' }, 'post')?.message).toMatch(/url must use http/)
|
|
24
|
+
})
|
|
25
|
+
test('rejects malformed url', () => {
|
|
26
|
+
expect(validateIntent({ ...base, url: 'not a url' }, 'post')?.message).toMatch(/not a valid URL/)
|
|
27
|
+
})
|
|
28
|
+
test('accepts well-formed', () => {
|
|
29
|
+
expect(validateIntent({ ...base, url: 'https://x.example/up' }, 'post')).toBeNull()
|
|
30
|
+
})
|
|
31
|
+
test('rejects non-object fields', () => {
|
|
32
|
+
expect(validateIntent({ ...base, url: 'https://x.example/up', fields: 'bad' }, 'post')?.message).toMatch(/fields/)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('validateIntent - multipart NaN-bypass defense', () => {
|
|
37
|
+
const base = { strategy: 'multipart', fileId: 'f1', uploadId: 'u1' }
|
|
38
|
+
|
|
39
|
+
test('rejects NaN partSize', () => {
|
|
40
|
+
expect(validateIntent({ ...base, partSize: Number.NaN }, 'multipart')?.message).toMatch(/partSize/)
|
|
41
|
+
})
|
|
42
|
+
test('rejects Infinity partSize', () => {
|
|
43
|
+
expect(validateIntent({ ...base, partSize: Number.POSITIVE_INFINITY }, 'multipart')?.message).toMatch(/partSize/)
|
|
44
|
+
})
|
|
45
|
+
test('rejects zero partSize', () => {
|
|
46
|
+
expect(validateIntent({ ...base, partSize: 0 }, 'multipart')?.message).toMatch(/partSize/)
|
|
47
|
+
})
|
|
48
|
+
test('rejects negative partSize', () => {
|
|
49
|
+
expect(validateIntent({ ...base, partSize: -1 }, 'multipart')?.message).toMatch(/partSize/)
|
|
50
|
+
})
|
|
51
|
+
test('rejects string partSize', () => {
|
|
52
|
+
expect(validateIntent({ ...base, partSize: '5' }, 'multipart')?.message).toMatch(/partSize/)
|
|
53
|
+
})
|
|
54
|
+
test('accepts finite positive partSize', () => {
|
|
55
|
+
expect(validateIntent({ ...base, partSize: 5_242_880 }, 'multipart')).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('validateIntent - multipart parts array', () => {
|
|
60
|
+
const base = { strategy: 'multipart', fileId: 'f1', uploadId: 'u1', partSize: 5_242_880 }
|
|
61
|
+
test('omits parts -> accepted', () => {
|
|
62
|
+
expect(validateIntent(base, 'multipart')).toBeNull()
|
|
63
|
+
})
|
|
64
|
+
test('parts as object -> rejected', () => {
|
|
65
|
+
expect(validateIntent({ ...base, parts: { 1: 'x' } }, 'multipart')?.message).toMatch(/parts/)
|
|
66
|
+
})
|
|
67
|
+
test('parts as array -> accepted', () => {
|
|
68
|
+
expect(validateIntent({ ...base, parts: [] }, 'multipart')).toBeNull()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { Contracts } from '../../contracts'
|
|
2
|
+
import type { Engine } from '../engine.types'
|
|
3
3
|
|
|
4
|
-
export function validateFile<P extends string>(
|
|
4
|
+
export function validateFile<P extends string>(
|
|
5
|
+
file: File,
|
|
6
|
+
purpose: P,
|
|
7
|
+
config: Engine.Config<P>,
|
|
8
|
+
): Contracts.Validation.Rejection | null {
|
|
5
9
|
// Get rules for this purpose, skip validation if no rules defined
|
|
6
10
|
const rules = config.validation?.[purpose]
|
|
7
11
|
if (!rules) return null
|
|
@@ -1,52 +1,74 @@
|
|
|
1
|
+
import { UploadEngineError } from '../../errors'
|
|
1
2
|
import { isRecord } from '../../utils/guards'
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
function isFinitePositive(v: unknown): v is number {
|
|
5
|
+
return typeof v === 'number' && Number.isFinite(v) && v > 0
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validates the structure and properties of an upload intent returned from the backend.
|
|
10
|
+
*
|
|
11
|
+
* Ensures all required fields are present and valid for the specified strategy.
|
|
12
|
+
*
|
|
13
|
+
* @param intent - The intent payload to validate.
|
|
14
|
+
* @param strategy - The strategy discriminant matching the intent.
|
|
15
|
+
* @returns An `UploadEngineError` if invalid, or `null` if the intent is well-formed.
|
|
16
|
+
*/
|
|
17
|
+
export function validateIntent(intent: unknown, strategy: string): UploadEngineError | null {
|
|
4
18
|
if (!isRecord(intent)) {
|
|
5
|
-
return new
|
|
19
|
+
return new UploadEngineError('validation_failed', { message: 'Invalid intent: must be an object' })
|
|
6
20
|
}
|
|
7
21
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return new Error('Invalid intent: missing or invalid strategy field')
|
|
22
|
+
if (typeof intent['strategy'] !== 'string') {
|
|
23
|
+
return new UploadEngineError('validation_failed', { message: 'Invalid intent: missing or invalid strategy field' })
|
|
11
24
|
}
|
|
12
25
|
|
|
13
|
-
if (intent
|
|
14
|
-
return new
|
|
26
|
+
if (intent['strategy'] !== strategy) {
|
|
27
|
+
return new UploadEngineError('validation_failed', {
|
|
28
|
+
message: `Invalid intent: strategy mismatch (expected ${strategy}, got ${intent['strategy']})`,
|
|
29
|
+
})
|
|
15
30
|
}
|
|
16
31
|
|
|
17
|
-
if (typeof intent
|
|
18
|
-
return new
|
|
32
|
+
if (typeof intent['fileId'] !== 'string' || !intent['fileId']) {
|
|
33
|
+
return new UploadEngineError('validation_failed', { message: 'Invalid intent: missing or invalid fileId' })
|
|
19
34
|
}
|
|
20
35
|
|
|
21
|
-
// Strategy-specific validation
|
|
22
36
|
if (strategy === 'post') {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return new Error('Invalid post intent: missing or invalid url')
|
|
37
|
+
if (typeof intent['url'] !== 'string' || !intent['url']) {
|
|
38
|
+
return new UploadEngineError('validation_failed', { message: 'Invalid post intent: missing or invalid url' })
|
|
26
39
|
}
|
|
27
40
|
try {
|
|
28
|
-
const url = new URL(intent
|
|
41
|
+
const url = new URL(intent['url'] as string)
|
|
29
42
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
30
|
-
return new
|
|
43
|
+
return new UploadEngineError('validation_failed', {
|
|
44
|
+
message: 'Invalid post intent: url must use http or https protocol',
|
|
45
|
+
})
|
|
31
46
|
}
|
|
32
47
|
} catch {
|
|
33
|
-
return new
|
|
48
|
+
return new UploadEngineError('validation_failed', { message: 'Invalid post intent: url is not a valid URL' })
|
|
34
49
|
}
|
|
35
|
-
if (!intent
|
|
36
|
-
return new
|
|
50
|
+
if (!intent['fields'] || typeof intent['fields'] !== 'object') {
|
|
51
|
+
return new UploadEngineError('validation_failed', { message: 'Invalid post intent: missing or invalid fields' })
|
|
37
52
|
}
|
|
38
53
|
} else if (strategy === 'multipart') {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
54
|
+
if (typeof intent['uploadId'] !== 'string' || !intent['uploadId']) {
|
|
55
|
+
return new UploadEngineError('validation_failed', {
|
|
56
|
+
message: 'Invalid multipart intent: missing or invalid uploadId',
|
|
57
|
+
})
|
|
42
58
|
}
|
|
43
|
-
|
|
44
|
-
|
|
59
|
+
// NaN-bypass defense: typeof NaN === 'number' and NaN > 0 is false, so a
|
|
60
|
+
// plain `partSize <= 0` check would let NaN through. isFinitePositive rejects it.
|
|
61
|
+
if (!isFinitePositive(intent['partSize'])) {
|
|
62
|
+
return new UploadEngineError('validation_failed', {
|
|
63
|
+
message: 'Invalid multipart intent: missing or invalid partSize',
|
|
64
|
+
})
|
|
45
65
|
}
|
|
46
|
-
// Parts array is optional (can be fetched later)
|
|
47
|
-
if ('parts' in intent && intent
|
|
48
|
-
if (!Array.isArray(intent
|
|
49
|
-
return new
|
|
66
|
+
// Parts array is optional (can be fetched later).
|
|
67
|
+
if ('parts' in intent && intent['parts'] !== undefined) {
|
|
68
|
+
if (!Array.isArray(intent['parts'])) {
|
|
69
|
+
return new UploadEngineError('validation_failed', {
|
|
70
|
+
message: 'Invalid multipart intent: parts must be an array if provided',
|
|
71
|
+
})
|
|
50
72
|
}
|
|
51
73
|
}
|
|
52
74
|
}
|