@gentleduck/upload 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +297 -0
  2. package/dist/core/index.d.ts +2 -3
  3. package/dist/core/index.js +1 -1
  4. package/dist/core-BVgloTiP.js +10 -0
  5. package/dist/index-DCqyBVcx.d.ts +1441 -0
  6. package/dist/index-DsZHPmJm.d.ts +225 -0
  7. package/dist/index-KROXtLQh.d.ts +281 -0
  8. package/dist/index.d.ts +4 -5
  9. package/dist/index.js +1 -1
  10. package/dist/react/index.d.ts +2 -90
  11. package/dist/react/index.js +1 -1
  12. package/dist/react-euPrDv24.js +1 -0
  13. package/dist/strategies/index.d.ts +2 -56
  14. package/dist/strategies/index.js +1 -1
  15. package/dist/strategies-CUF0s68j.js +1 -0
  16. package/package.json +3 -2
  17. package/src/__tests__/emitter.test.ts +85 -0
  18. package/src/__tests__/fingerprint.test.ts +49 -0
  19. package/src/__tests__/guards.test.ts +40 -0
  20. package/src/__tests__/id.test.ts +67 -0
  21. package/src/__tests__/mime-sniff.test.ts +142 -0
  22. package/src/__tests__/persistence.test.ts +191 -0
  23. package/src/__tests__/sanitize-filename.test.ts +109 -0
  24. package/src/__tests__/sleep.test.ts +34 -0
  25. package/src/__tests__/static-analysis.test.ts +145 -0
  26. package/src/__tests__/store-libs.test.ts +192 -0
  27. package/src/__tests__/transport-fetch.test.ts +108 -0
  28. package/src/__tests__/transport-libs.test.ts +42 -0
  29. package/src/__tests__/transport.test.ts +182 -0
  30. package/src/core/__tests__/errors.test.ts +152 -0
  31. package/src/core/client.ts +3 -0
  32. package/src/core/contracts/contracts.types.ts +373 -0
  33. package/src/core/contracts/index.ts +3 -8
  34. package/src/core/contracts/transport/index.ts +3 -2
  35. package/src/core/contracts/transport/transport.fetch.ts +203 -0
  36. package/src/core/contracts/transport/transport.libs.ts +13 -39
  37. package/src/core/contracts/transport/transport.ts +96 -90
  38. package/src/core/contracts/transport/transport.types.ts +71 -35
  39. package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
  40. package/src/core/engine/engine.libs.ts +35 -0
  41. package/src/core/engine/engine.types.ts +490 -0
  42. package/src/core/engine/index.ts +3 -8
  43. package/src/core/engine/reducer.ts +93 -53
  44. package/src/core/engine/store/handlers/add-file.ts +43 -48
  45. package/src/core/engine/store/handlers/cancel.ts +13 -8
  46. package/src/core/engine/store/handlers/clean-up.ts +11 -8
  47. package/src/core/engine/store/handlers/create-intent.ts +82 -27
  48. package/src/core/engine/store/handlers/finalize.ts +30 -13
  49. package/src/core/engine/store/handlers/index.ts +7 -7
  50. package/src/core/engine/store/handlers/pause.ts +8 -6
  51. package/src/core/engine/store/handlers/run-upload.ts +54 -39
  52. package/src/core/engine/store/index.ts +27 -21
  53. package/src/core/engine/store/store.dispatch.ts +10 -8
  54. package/src/core/engine/store/store.libs.ts +197 -94
  55. package/src/core/engine/store/store.runtime.ts +52 -53
  56. package/src/core/engine/store/store.schedule.ts +27 -21
  57. package/src/core/engine/store/store.types.ts +177 -227
  58. package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
  59. package/src/core/engine/validation/file.ts +8 -4
  60. package/src/core/engine/validation/index.ts +1 -0
  61. package/src/core/engine/validation/intent.ts +49 -27
  62. package/src/core/engine/validation/list.ts +14 -15
  63. package/src/core/engine/validation/mime.ts +39 -0
  64. package/src/core/errors.ts +271 -0
  65. package/src/core/index.ts +28 -5
  66. package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
  67. package/src/core/persistence/__tests__/persistence.test.ts +85 -0
  68. package/src/core/persistence/adapters.indexeddb.ts +9 -3
  69. package/src/core/persistence/adapters.local.ts +9 -3
  70. package/src/core/persistence/adapters.memory.ts +35 -13
  71. package/src/core/persistence/index.ts +5 -5
  72. package/src/core/persistence/persistence.ts +73 -60
  73. package/src/core/persistence/persistence.types.ts +140 -60
  74. package/src/core/utils/__tests__/guards.test.ts +93 -0
  75. package/src/core/utils/__tests__/url-safety.test.ts +112 -0
  76. package/src/core/utils/async.ts +7 -2
  77. package/src/core/utils/constants.ts +3 -9
  78. package/src/core/utils/emitter/emitter.ts +126 -0
  79. package/src/core/utils/emitter/emitter.types.ts +33 -16
  80. package/src/core/utils/emitter/index.ts +2 -74
  81. package/src/core/utils/fingerprint.ts +16 -5
  82. package/src/core/utils/guards.ts +91 -1
  83. package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
  84. package/src/core/utils/hash/index.ts +126 -0
  85. package/src/core/utils/hash/sha256.ts +179 -0
  86. package/src/core/utils/id.ts +33 -1
  87. package/src/core/utils/index.ts +15 -6
  88. package/src/core/utils/mime-sniff.ts +119 -0
  89. package/src/core/utils/sanitize-filename.ts +85 -0
  90. package/src/core/utils/url-safety.ts +156 -0
  91. package/src/react/__tests__/use-dropzone.test.ts +64 -0
  92. package/src/react/index.ts +5 -2
  93. package/src/react/upload-provider.tsx +59 -95
  94. package/src/react/uploader.types.ts +80 -0
  95. package/src/react/use-dropzone.tsx +226 -0
  96. package/src/react/use-uploader.tsx +97 -93
  97. package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
  98. package/src/strategies/_shared/retry.ts +79 -0
  99. package/src/strategies/index.ts +9 -15
  100. package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
  101. package/src/strategies/multipart/index.ts +211 -116
  102. package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
  103. package/src/strategies/post/index.ts +113 -71
  104. package/src/strategies/put/__tests__/put.test.ts +132 -0
  105. package/src/strategies/put/index.ts +139 -0
  106. package/src/strategies/registry/index.ts +12 -9
  107. package/src/strategies/tus/__tests__/tus.test.ts +159 -0
  108. package/src/strategies/tus/index.ts +211 -0
  109. package/dist/async-DxRWu4le.js +0 -1
  110. package/dist/core-BNxznRif.js +0 -2
  111. package/dist/index-BXg9WIce.d.ts +0 -987
  112. package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
  113. package/src/core/client/config.types.ts +0 -77
  114. package/src/core/client/index.ts +0 -4
  115. package/src/core/client/plugins.types.ts +0 -46
  116. package/src/core/client/retry.types.ts +0 -6
  117. package/src/core/client/uploadClient.ts +0 -19
  118. package/src/core/contracts/backendApi.ts +0 -62
  119. package/src/core/contracts/cursor.types.ts +0 -23
  120. package/src/core/contracts/errors.types.ts +0 -67
  121. package/src/core/contracts/fingerprint.types.ts +0 -18
  122. package/src/core/contracts/intent.types.ts +0 -39
  123. package/src/core/contracts/result.types.ts +0 -11
  124. package/src/core/contracts/strategy.types.ts +0 -45
  125. package/src/core/engine/commands.types.ts +0 -17
  126. package/src/core/engine/event-map.types.ts +0 -45
  127. package/src/core/engine/internal-events.types.ts +0 -222
  128. package/src/core/engine/outcome.types.ts +0 -9
  129. package/src/core/engine/progress.types.ts +0 -15
@@ -1,10 +1,10 @@
1
- import type { CursorMap, IntentMap, UploadResultBase } from '../../contracts'
2
- import type { UploadItem } from '../internal-events.types'
1
+ import type { Contracts } from '../../contracts'
2
+ import type { Engine } from '../engine.types'
3
3
  import { createIntent } from './handlers/create-intent'
4
4
  import { finalizeUpload } from './handlers/finalize'
5
5
  import { runUpload } from './handlers/run-upload'
6
6
  import { isAutoStart } from './store.libs'
7
- import type { StoreRuntime } from './store.types'
7
+ import type { Store } from './store.types'
8
8
 
9
9
  /**
10
10
  * Central scheduler that ensures the system is always making progress towards a consistent state.
@@ -15,9 +15,12 @@ import type { StoreRuntime } from './store.types'
15
15
  *
16
16
  * This function is idempotent and safe to call multiple times.
17
17
  */
18
- export function scheduleWork<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
19
- rt: StoreRuntime<M, C, P, R>,
20
- ) {
18
+ export function scheduleWork<
19
+ M extends Contracts.Intent.Map,
20
+ C extends Contracts.Cursor.Map<M>,
21
+ P extends string,
22
+ R extends Contracts.Result.Base,
23
+ >(rt: Store.Runtime<M, C, P, R>) {
21
24
  if (rt.scheduling) return
22
25
  rt.scheduling = true
23
26
  try {
@@ -36,11 +39,11 @@ export function scheduleWork<M extends IntentMap, C extends CursorMap<M>, P exte
36
39
  * {@link StoreRuntime.inflightIntents}.
37
40
  */
38
41
  export function scheduleIntentCreations<
39
- M extends IntentMap,
40
- C extends CursorMap<M>,
42
+ M extends Contracts.Intent.Map,
43
+ C extends Contracts.Cursor.Map<M>,
41
44
  P extends string,
42
- R extends UploadResultBase,
43
- >(rt: StoreRuntime<M, C, P, R>) {
45
+ R extends Contracts.Result.Base,
46
+ >(rt: Store.Runtime<M, C, P, R>) {
44
47
  for (const item of rt.state.items.values()) {
45
48
  if (item.phase !== 'creating_intent') continue
46
49
  if (rt.inflightIntents.has(item.localId)) continue
@@ -54,11 +57,11 @@ export function scheduleIntentCreations<
54
57
  * Finalization runs as an async effect and is guarded by {@link StoreRuntime.inflightCompletes}.
55
58
  */
56
59
  export function scheduleCompletes<
57
- M extends IntentMap,
58
- C extends CursorMap<M>,
60
+ M extends Contracts.Intent.Map,
61
+ C extends Contracts.Cursor.Map<M>,
59
62
  P extends string,
60
- R extends UploadResultBase,
61
- >(rt: StoreRuntime<M, C, P, R>) {
63
+ R extends Contracts.Result.Base,
64
+ >(rt: Store.Runtime<M, C, P, R>) {
62
65
  for (const item of rt.state.items.values()) {
63
66
  if (item.phase !== 'completing') continue
64
67
  if (rt.inflightCompletes.has(item.localId)) continue
@@ -71,11 +74,11 @@ export function scheduleCompletes<
71
74
  * Respects `maxConcurrentUploads` from config.
72
75
  */
73
76
  export function scheduleUploads<
74
- M extends IntentMap,
75
- C extends CursorMap<M>,
77
+ M extends Contracts.Intent.Map,
78
+ C extends Contracts.Cursor.Map<M>,
76
79
  P extends string,
77
- R extends UploadResultBase,
78
- >(rt: StoreRuntime<M, C, P, R>) {
80
+ R extends Contracts.Result.Base,
81
+ >(rt: Store.Runtime<M, C, P, R>) {
79
82
  const maxConcurrent = Math.max(1, rt.opts.config.maxConcurrentUploads)
80
83
  const active = rt.inflightUploads.size
81
84
 
@@ -109,8 +112,11 @@ export function scheduleUploads<
109
112
  }
110
113
  }
111
114
 
112
- function isQueuedItem<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
113
- item: UploadItem<M, C, P, R>,
114
- ): item is Extract<UploadItem<M, C, P, R>, { phase: 'queued' }> {
115
+ function isQueuedItem<
116
+ M extends Contracts.Intent.Map,
117
+ C extends Contracts.Cursor.Map<M>,
118
+ P extends string,
119
+ R extends Contracts.Result.Base,
120
+ >(item: Engine.Item<M, C, P, R>): item is Extract<Engine.Item<M, C, P, R>, { phase: 'queued' }> {
115
121
  return item.phase === 'queued'
116
122
  }
@@ -1,250 +1,200 @@
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'
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
- * 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
8
+ * Store-level namespaces and contracts.
9
+ * @author wildduck2 <https://github.com/wildduck2>
32
10
  */
33
- export interface UploadStore<
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
- * 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.
13
+ * Public upload store interface used by UI adapters and application code.
53
14
  *
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.
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
- 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
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
- * 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
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
- * Waits for the given localIds to reach a terminal state.
77
- */
78
- waitFor(localIds: string[]): Promise<Array<UploadOutcome<R>>>
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
- * 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
- }
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
- * 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
- }
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
- 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
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
- * 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.
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
- deserialize?: (raw: unknown, ctx: DeserializeContext<M, C, P>) => UploadState<M, C, P, R> | null
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
- * Runtime guard for purpose strings when using the default deserializer.
139
+ * Tracks an in-flight upload operation.
175
140
  */
176
- isPurpose?: (value: string) => value is P
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
- * Runtime guard for intent objects when using the default deserializer.
151
+ * Internal execution context shared across store utilities, handlers, and schedulers.
180
152
  */
181
- isIntent?: (value: unknown) => value is M[keyof M]
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
- * 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
- }
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
- * 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
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 { UploadConfig } from '../../client'
2
- import type { RejectReason } from '../../contracts'
1
+ import type { Contracts } from '../../contracts'
2
+ import type { Engine } from '../engine.types'
3
3
 
4
- export function validateFile<P extends string>(file: File, purpose: P, config: UploadConfig<P>): RejectReason | null {
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
@@ -43,7 +47,7 @@ export function validateFile<P extends string>(file: File, purpose: P, config: U
43
47
  })
44
48
 
45
49
  const extMatches =
46
- !!fileExt && extensions.length > 0 ? extensions.some((ext) => ext.toLowerCase() === fileExt) : false
50
+ fileExt && extensions.length > 0 ? extensions.some((ext) => ext.toLowerCase() === fileExt) : false
47
51
 
48
52
  // Determine if file passes type/extension check
49
53
  const hasTypeRules = allowed.length > 0
@@ -1,3 +1,4 @@
1
1
  export { validateFile } from './file'
2
2
  export { validateIntent } from './intent'
3
3
  export { validateFileList } from './list'
4
+ export { validateMimeSignature } from './mime'