@gentleduck/upload 0.2.5 → 0.2.6
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 +320 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/{core-BNxznRif.js → core-CMkrP9DX.js} +2 -2
- package/dist/{index-BXg9WIce.d.ts → index-C38Jmizu.d.ts} +51 -33
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +8 -47
- package/dist/react/index.js +1 -1
- package/dist/strategies/index.d.ts +5 -5
- package/package.json +1 -1
- package/src/__test__/effect-pool.test.ts +5 -0
- package/src/__test__/emitter.test.ts +85 -0
- package/src/__test__/fingerprint.test.ts +49 -0
- package/src/__test__/guards.test.ts +30 -0
- package/src/__test__/id.test.ts +14 -0
- package/src/__test__/persistence.test.ts +156 -0
- package/src/__test__/store-integration.test.ts +5 -0
- package/src/__test__/store-libs.test.ts +138 -0
- package/src/__test__/transport-libs.test.ts +42 -0
- package/src/__test__/transport.test.ts +182 -0
- package/src/core/client/client.ts +50 -0
- package/src/core/client/client.types.ts +184 -0
- package/src/core/contracts/contracts.types.ts +179 -0
- package/src/core/contracts/transport/transport.libs.ts +7 -32
- package/src/core/contracts/transport/transport.ts +5 -7
- package/src/core/engine/engine.types.ts +364 -0
- package/src/core/engine/internal-events.types.ts +3 -23
- package/src/core/engine/reducer.ts +5 -11
- package/src/core/engine/store/handlers/add-file.ts +6 -21
- package/src/core/engine/store/handlers/run-upload.ts +7 -7
- package/src/core/engine/store/handlers.ts +819 -0
- package/src/core/engine/store/store.libs.ts +17 -57
- package/src/core/engine/store/store.runtime.ts +10 -20
- package/src/core/engine/validation/file.ts +1 -1
- package/src/core/engine/validation.ts +216 -0
- package/src/core/utils/emitter/emitter.ts +143 -0
- package/src/react/upload-provider.tsx +7 -58
- package/src/react/uploader.types.ts +83 -0
- package/src/react/use-uploader.tsx +2 -5
- package/src/strategies/multipart/index.ts +9 -12
- package/src/strategies/post/index.ts +6 -46
|
@@ -9,10 +9,7 @@ export { sleep } from '../../utils/async'
|
|
|
9
9
|
|
|
10
10
|
import type { StoreOptions } from './store.types'
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* Calculates SHA256 checksum of a file for deduplication.
|
|
14
|
-
* Uses Web Crypto API for efficient hashing.
|
|
15
|
-
*/
|
|
12
|
+
/** SHA-256 checksum of `file` for deduplication. */
|
|
16
13
|
export async function calculateFileChecksum(file: File): Promise<string> {
|
|
17
14
|
const buffer = await file.arrayBuffer()
|
|
18
15
|
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
|
@@ -21,12 +18,8 @@ export async function calculateFileChecksum(file: File): Promise<string> {
|
|
|
21
18
|
}
|
|
22
19
|
|
|
23
20
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* Supports three configuration shapes:
|
|
27
|
-
* - `false | undefined`: never auto-start
|
|
28
|
-
* - `P[]`: auto-start for purposes included in the list
|
|
29
|
-
* - `(purpose: P) => boolean`: custom predicate
|
|
21
|
+
* Resolve auto-start config: `false | undefined` (never), `P[]` (in list), or
|
|
22
|
+
* `(purpose) => boolean` predicate.
|
|
30
23
|
*/
|
|
31
24
|
export function isAutoStart<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
|
|
32
25
|
opts: StoreOptions<M, C, P, R>,
|
|
@@ -40,38 +33,31 @@ export function isAutoStart<M extends IntentMap, C extends CursorMap<M>, P exten
|
|
|
40
33
|
}
|
|
41
34
|
|
|
42
35
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* (e.g. SHA-256), provide a custom {@link StoreOptions.fingerprint} function or let the
|
|
47
|
-
* add-file handler compute a checksum asynchronously.
|
|
36
|
+
* Lightweight `File` fingerprint for display/identity. For stronger identity
|
|
37
|
+
* (SHA-256), pass {@link StoreOptions.fingerprint} or let add-file compute a
|
|
38
|
+
* checksum asynchronously.
|
|
48
39
|
*/
|
|
49
40
|
export { computeFingerprint, fingerprintMatches }
|
|
50
41
|
|
|
51
42
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* Built-in behavior:
|
|
56
|
-
* - detects abort errors and marks them non-retryable
|
|
57
|
-
* - classifies common network and HTTP errors as retryable where appropriate
|
|
43
|
+
* Normalize a thrown value into an {@link UploadError}. Override via
|
|
44
|
+
* {@link StoreOptions.errorNormalizer}. Default: aborts → non-retryable;
|
|
45
|
+
* network errors → retryable; HTTP 5xx/429 → retryable.
|
|
58
46
|
*/
|
|
59
47
|
export function normalizeError(err: unknown, customNormalizer?: (err: unknown) => UploadError): UploadError {
|
|
60
48
|
if (customNormalizer) return customNormalizer(err)
|
|
61
49
|
|
|
62
|
-
// Transport
|
|
50
|
+
// Transport aborts carry `{ code: 'aborted', reason: 'pause' | 'cancel' }`.
|
|
63
51
|
if (isAbortError(err)) {
|
|
64
52
|
return { code: 'aborted', message: 'Upload aborted', reason: String(err.reason ?? 'unknown'), retryable: false }
|
|
65
53
|
}
|
|
66
54
|
|
|
67
55
|
const msg = isRecord(err) && typeof err.message === 'string' ? err.message : 'Unknown error'
|
|
68
56
|
|
|
69
|
-
// Network errors
|
|
70
57
|
if (String(msg).toLowerCase().includes('network') || String(msg).toLowerCase().includes('fetch')) {
|
|
71
58
|
return { code: 'network', message: String(msg), cause: err, retryable: true }
|
|
72
59
|
}
|
|
73
60
|
|
|
74
|
-
// HTTP errors
|
|
75
61
|
if (isRecord(err) && (typeof err.status === 'number' || typeof err.statusCode === 'number')) {
|
|
76
62
|
const status = (typeof err.status === 'number' ? err.status : err.statusCode) as number
|
|
77
63
|
const retryable = status >= 500 || status === 429
|
|
@@ -81,42 +67,28 @@ export function normalizeError(err: unknown, customNormalizer?: (err: unknown) =
|
|
|
81
67
|
return { code: 'unknown', message: String(msg), cause: err, retryable: false }
|
|
82
68
|
}
|
|
83
69
|
|
|
84
|
-
/**
|
|
85
|
-
* Type guard for items that currently hold a concrete backend intent.
|
|
86
|
-
*/
|
|
70
|
+
/** Item has a concrete backend intent. */
|
|
87
71
|
export function hasIntent<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
|
|
88
72
|
item: UploadItem<M, C, P, R>,
|
|
89
73
|
): item is Extract<UploadItem<M, C, P, R>, { intent: M[keyof M] }> {
|
|
90
74
|
return 'intent' in item && !!item.intent
|
|
91
75
|
}
|
|
92
76
|
|
|
93
|
-
/**
|
|
94
|
-
* Type guard for items that currently have a bound {@link File}.
|
|
95
|
-
*
|
|
96
|
-
* Persisted items restored from storage may lack `file` until the UI re-binds it.
|
|
97
|
-
*/
|
|
77
|
+
/** Item has a bound `File`. Persisted items may lack it until the UI re-binds. */
|
|
98
78
|
export function hasFile<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
|
|
99
79
|
item: UploadItem<M, C, P, R>,
|
|
100
80
|
): item is Extract<UploadItem<M, C, P, R>, { file: File }> {
|
|
101
81
|
return 'file' in item && !!item.file
|
|
102
82
|
}
|
|
103
83
|
|
|
104
|
-
/**
|
|
105
|
-
* Type guard that narrows to item variants carrying a cursor field.
|
|
106
|
-
*
|
|
107
|
-
* Cursor presence depends on the current phase and the strategy.
|
|
108
|
-
*/
|
|
84
|
+
/** Item variant carries a cursor field (depends on phase/strategy). */
|
|
109
85
|
export function hasCursor<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
|
|
110
86
|
item: UploadItem<M, C, P, R>,
|
|
111
87
|
): item is Extract<UploadItem<M, C, P, R>, { cursor?: AnyCursor<C> }> {
|
|
112
88
|
return 'cursor' in item
|
|
113
89
|
}
|
|
114
90
|
|
|
115
|
-
/**
|
|
116
|
-
* Runtime guard for multipart intent objects.
|
|
117
|
-
*
|
|
118
|
-
* Used for best-effort abort calls when canceling multipart uploads.
|
|
119
|
-
*/
|
|
91
|
+
/** Best-effort guard used to issue multipart-abort calls on cancel. */
|
|
120
92
|
export function isMultipartIntent(
|
|
121
93
|
intent: unknown,
|
|
122
94
|
): intent is { strategy: 'multipart'; fileId: string; uploadId: string; partSize: number } {
|
|
@@ -128,24 +100,14 @@ export function isMultipartIntent(
|
|
|
128
100
|
)
|
|
129
101
|
}
|
|
130
102
|
|
|
131
|
-
/**
|
|
132
|
-
* Runtime guard for abort errors produced by the transport/strategy layer.
|
|
133
|
-
*/
|
|
134
103
|
export function isAbortError(err: unknown): err is { code: 'aborted'; reason?: unknown } {
|
|
135
104
|
return isRecord(err) && err.code === 'aborted'
|
|
136
105
|
}
|
|
137
106
|
|
|
138
107
|
/**
|
|
139
|
-
*
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Computes whether a failed operation should be retried and, if so, after what delay.
|
|
144
|
-
*
|
|
145
|
-
* If {@link UploadConfig.retryPolicy} is provided it is used as the source of truth.
|
|
146
|
-
* Otherwise the default policy:
|
|
147
|
-
* - never retries auth/validation/strategy_missing/aborted
|
|
148
|
-
* - retries up to `maxAttempts` with exponential backoff and a fixed cap
|
|
108
|
+
* Decide whether to retry. Defers to `config.retryPolicy` when set.
|
|
109
|
+
* Default: never retries `auth`/`validation_failed`/`strategy_missing`/`aborted`;
|
|
110
|
+
* otherwise exponential backoff up to `maxAttempts`, capped at `DEFAULT_RETRY_DELAY_MAX_MS`.
|
|
149
111
|
*/
|
|
150
112
|
export function retryDecision<P extends string>(
|
|
151
113
|
config: UploadConfig<P>,
|
|
@@ -153,7 +115,6 @@ export function retryDecision<P extends string>(
|
|
|
153
115
|
) {
|
|
154
116
|
if (config.retryPolicy) return config.retryPolicy(ctx)
|
|
155
117
|
|
|
156
|
-
// Default: retry network/server unknown errors, not auth/validation/strategy_missing
|
|
157
118
|
if (ctx.error.code === 'auth') return { retryable: false }
|
|
158
119
|
if (ctx.error.code === 'validation_failed') return { retryable: false }
|
|
159
120
|
if (ctx.error.code === 'strategy_missing') return { retryable: false }
|
|
@@ -163,7 +124,6 @@ export function retryDecision<P extends string>(
|
|
|
163
124
|
const retryable = ctx.attempt < maxAttempts
|
|
164
125
|
if (!retryable) return { retryable: false }
|
|
165
126
|
|
|
166
|
-
// Exponential backoff with cap
|
|
167
127
|
const delayMs = Math.min(DEFAULT_RETRY_DELAY_MAX_MS, DEFAULT_RETRY_DELAY_BASE_MS * 2 ** (ctx.attempt - 1))
|
|
168
128
|
return { retryable: true, delayMs }
|
|
169
129
|
}
|
|
@@ -12,18 +12,9 @@ import { scheduleWork } from './store.schedule'
|
|
|
12
12
|
import type { InflightUpload, StoreOptions, StoreRuntime } from './store.types'
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* - Reducers remain pure: all async work is enqueued through {@link StoreRuntime.enqueueEffect}.
|
|
19
|
-
* - {@link StoreRuntime.applyInternal} invokes hooks, performs cleanup, and then schedules more work.
|
|
20
|
-
* - {@link StoreRuntime.dispatch} is intentionally assigned later to break import cycles.
|
|
21
|
-
*
|
|
22
|
-
* @template M - Intent map type
|
|
23
|
-
* @template C - Cursor map type
|
|
24
|
-
* @template P - Purpose string union type
|
|
25
|
-
*
|
|
26
|
-
* @param opts - Construction options for the store.
|
|
15
|
+
* Build the store runtime: state, emitter, reducer, and effect queue.
|
|
16
|
+
* Reducers stay pure — async work goes through {@link StoreRuntime.enqueueEffect}.
|
|
17
|
+
* `dispatch` is assigned later by `createUploadStore` to break an import cycle.
|
|
27
18
|
*/
|
|
28
19
|
export function createStoreRuntime<
|
|
29
20
|
M extends IntentMap,
|
|
@@ -34,8 +25,8 @@ export function createStoreRuntime<
|
|
|
34
25
|
const resolvedOpts = resolveStoreOptions(opts)
|
|
35
26
|
const persistence = resolvedOpts.persistence
|
|
36
27
|
|
|
37
|
-
// Debounced
|
|
38
|
-
//
|
|
28
|
+
// Debounced flush. Persistence writes under a single namespace `key`; the
|
|
29
|
+
// adapter decides how to encode multiple item records under that key.
|
|
39
30
|
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
|
40
31
|
const persistDebounceMs = Math.max(0, persistence?.debounceMs ?? 200)
|
|
41
32
|
|
|
@@ -44,7 +35,7 @@ export function createStoreRuntime<
|
|
|
44
35
|
try {
|
|
45
36
|
const snap = (persistence.serialize ?? serializeSnapshot)(rt.state, persistence.version)
|
|
46
37
|
|
|
47
|
-
//
|
|
38
|
+
// Nothing to persist → clear the namespace.
|
|
48
39
|
if (!snap.items || Object.keys(snap.items).length === 0) {
|
|
49
40
|
await persistence.adapter.clear(persistence.key)
|
|
50
41
|
return
|
|
@@ -158,14 +149,15 @@ export function createStoreRuntime<
|
|
|
158
149
|
rt.processingEffects = false
|
|
159
150
|
},
|
|
160
151
|
|
|
161
|
-
//
|
|
152
|
+
// Replaced by createUploadStore — runtime breaks the cycle.
|
|
162
153
|
dispatch: () => {
|
|
163
154
|
throw new Error('[UploadEngine] dispatch not initialized')
|
|
164
155
|
},
|
|
165
156
|
} satisfies StoreRuntime<M, C, P, R>
|
|
166
157
|
|
|
167
|
-
//
|
|
168
|
-
//
|
|
158
|
+
// Async adapters (IndexedDB) resolve after construction; only hydrate when no
|
|
159
|
+
// initialState was provided. Merge into an empty store only — never clobber
|
|
160
|
+
// user actions that arrived before the load resolved.
|
|
169
161
|
if (persistence && !resolvedOpts.initialState && typeof window !== 'undefined') {
|
|
170
162
|
try {
|
|
171
163
|
const loaded = persistence.adapter.load(persistence.key)
|
|
@@ -178,8 +170,6 @@ export function createStoreRuntime<
|
|
|
178
170
|
})
|
|
179
171
|
if (!next) return
|
|
180
172
|
|
|
181
|
-
// Only merge into an empty store to avoid clobbering user actions
|
|
182
|
-
// that happened before the async load completed.
|
|
183
173
|
if (rt.state.items.size === 0) {
|
|
184
174
|
rt.state = next
|
|
185
175
|
} else {
|
|
@@ -43,7 +43,7 @@ export function validateFile<P extends string>(file: File, purpose: P, config: U
|
|
|
43
43
|
})
|
|
44
44
|
|
|
45
45
|
const extMatches =
|
|
46
|
-
|
|
46
|
+
fileExt && extensions.length > 0 ? extensions.some((ext) => ext.toLowerCase() === fileExt) : false
|
|
47
47
|
|
|
48
48
|
// Determine if file passes type/extension check
|
|
49
49
|
const hasTypeRules = allowed.length > 0
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type { Client } from '../client'
|
|
2
|
+
import type { Contracts } from '../contracts'
|
|
3
|
+
import { isRecord } from '../utils/guards'
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// validateFile -- single-file rule check
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validate one file against `config.validation[purpose]` rules.
|
|
11
|
+
*
|
|
12
|
+
* Empty files are always rejected.
|
|
13
|
+
*
|
|
14
|
+
* @param file File to validate.
|
|
15
|
+
* @param purpose Purpose key used to look up rules.
|
|
16
|
+
* @param config Resolved upload config.
|
|
17
|
+
* @template P Purpose discriminator string.
|
|
18
|
+
* @returns `null` when valid, otherwise a {@link Contracts.RejectReason}.
|
|
19
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
20
|
+
*/
|
|
21
|
+
export function validateFile<P extends string>(
|
|
22
|
+
file: File,
|
|
23
|
+
purpose: P,
|
|
24
|
+
config: Client.IUploadConfig<P>,
|
|
25
|
+
): Contracts.RejectReason | null {
|
|
26
|
+
if (file.size === 0) return { code: 'empty_file' }
|
|
27
|
+
|
|
28
|
+
const rules = config.validation?.[purpose]
|
|
29
|
+
if (!rules) return null
|
|
30
|
+
|
|
31
|
+
if (rules.maxSizeBytes !== undefined && file.size > rules.maxSizeBytes) {
|
|
32
|
+
return { code: 'file_too_large', maxBytes: rules.maxSizeBytes, size: file.size }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (rules.minSizeBytes !== undefined && file.size < rules.minSizeBytes) {
|
|
36
|
+
return { code: 'empty_file' }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (rules.allowedTypes || rules.allowedExtensions) {
|
|
40
|
+
const allowed = rules.allowedTypes || []
|
|
41
|
+
const extensions = rules.allowedExtensions || []
|
|
42
|
+
|
|
43
|
+
const fileExt = file.name.split('.').pop()?.toLowerCase()
|
|
44
|
+
const mimeType = (file.type || '').toLowerCase()
|
|
45
|
+
|
|
46
|
+
const typeMatches =
|
|
47
|
+
allowed.length === 0
|
|
48
|
+
? false
|
|
49
|
+
: allowed.some((type) => {
|
|
50
|
+
if (type.endsWith('/*')) {
|
|
51
|
+
const prefix = type.slice(0, -2).toLowerCase()
|
|
52
|
+
return mimeType.startsWith(`${prefix}/`)
|
|
53
|
+
}
|
|
54
|
+
return mimeType === type.toLowerCase()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const extMatches =
|
|
58
|
+
fileExt && extensions.length > 0 ? extensions.some((ext) => ext.toLowerCase() === fileExt) : false
|
|
59
|
+
|
|
60
|
+
const hasTypeRules = allowed.length > 0
|
|
61
|
+
const hasExtRules = extensions.length > 0
|
|
62
|
+
|
|
63
|
+
// When both rule kinds are present, either match is enough; otherwise the
|
|
64
|
+
// present one must match.
|
|
65
|
+
const ok = (hasTypeRules ? typeMatches : true) && (hasExtRules ? extMatches : true)
|
|
66
|
+
const okEither = hasTypeRules && hasExtRules ? typeMatches || extMatches : ok
|
|
67
|
+
|
|
68
|
+
if (!okEither) {
|
|
69
|
+
return {
|
|
70
|
+
code: 'type_not_allowed',
|
|
71
|
+
allowed: [...allowed, ...extensions],
|
|
72
|
+
got: file.type || file.name,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============================================================================
|
|
81
|
+
// validateFileList -- batch validation with maxFiles cap
|
|
82
|
+
// ============================================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validate a batch of files and split into accepted vs rejected.
|
|
86
|
+
*
|
|
87
|
+
* Enforces `maxFiles` against the existing count plus the running additions
|
|
88
|
+
* so a single `addFiles` call cannot overshoot.
|
|
89
|
+
*
|
|
90
|
+
* @param files Files to validate.
|
|
91
|
+
* @param purpose Purpose key used to look up rules.
|
|
92
|
+
* @param config Resolved upload config.
|
|
93
|
+
* @param existingCount Items already in the store for this purpose.
|
|
94
|
+
* @template P Purpose discriminator string.
|
|
95
|
+
* @returns Object with `valid` files and `rejected` files plus reasons.
|
|
96
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
97
|
+
*/
|
|
98
|
+
export function validateFileList<P extends string>(
|
|
99
|
+
files: File[],
|
|
100
|
+
purpose: P,
|
|
101
|
+
config: Client.IUploadConfig<P>,
|
|
102
|
+
existingCount: number = 0,
|
|
103
|
+
): { valid: File[]; rejected: Array<{ file: File; reason: Contracts.RejectReason }> } {
|
|
104
|
+
const rules = config.validation?.[purpose]
|
|
105
|
+
const valid: File[] = []
|
|
106
|
+
const rejected: Array<{ file: File; reason: Contracts.RejectReason }> = []
|
|
107
|
+
|
|
108
|
+
const maxFiles = rules?.maxFiles
|
|
109
|
+
let remaining = maxFiles !== undefined ? Math.max(0, maxFiles - existingCount) : Number.POSITIVE_INFINITY
|
|
110
|
+
|
|
111
|
+
for (const file of files) {
|
|
112
|
+
if (remaining <= 0) {
|
|
113
|
+
const limit = maxFiles ?? 0
|
|
114
|
+
rejected.push({ file, reason: { code: 'too_many_files', max: limit } })
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const reason = validateFile(file, purpose, config)
|
|
119
|
+
if (reason) {
|
|
120
|
+
rejected.push({ file, reason })
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
valid.push(file)
|
|
125
|
+
remaining -= 1
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { valid, rejected }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ============================================================================
|
|
132
|
+
// validateIntent -- backend-intent shape check
|
|
133
|
+
// ============================================================================
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Validate an intent payload returned by the backend before the engine
|
|
137
|
+
* commits to it.
|
|
138
|
+
*
|
|
139
|
+
* Rejects malformed shapes, protocol-foreign URLs, and cross-field
|
|
140
|
+
* inconsistencies (e.g. `partCount * partSize` cannot cover the file).
|
|
141
|
+
*
|
|
142
|
+
* @param intent Raw intent value from `createIntent`.
|
|
143
|
+
* @param strategy Expected `strategy` discriminator.
|
|
144
|
+
* @param fileSize File byte length; enables multipart cross-checks.
|
|
145
|
+
* @returns `Error` describing the failure, or `null` if valid.
|
|
146
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
147
|
+
*/
|
|
148
|
+
export function validateIntent(intent: unknown, strategy: string, fileSize?: number): Error | null {
|
|
149
|
+
if (!isRecord(intent)) {
|
|
150
|
+
return new Error('Invalid intent: must be an object')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (typeof intent.strategy !== 'string') {
|
|
154
|
+
return new Error('Invalid intent: missing or invalid strategy field')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (intent.strategy !== strategy) {
|
|
158
|
+
return new Error(`Invalid intent: strategy mismatch (expected ${strategy}, got ${intent.strategy})`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (typeof intent.fileId !== 'string' || !intent.fileId) {
|
|
162
|
+
return new Error('Invalid intent: missing or invalid fileId')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (strategy === 'post') {
|
|
166
|
+
if (typeof intent.url !== 'string' || !intent.url) {
|
|
167
|
+
return new Error('Invalid post intent: missing or invalid url')
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const url = new URL(intent.url)
|
|
171
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
172
|
+
return new Error('Invalid post intent: url must use http or https protocol')
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
return new Error('Invalid post intent: url is not a valid URL')
|
|
176
|
+
}
|
|
177
|
+
if (!intent.fields || typeof intent.fields !== 'object') {
|
|
178
|
+
return new Error('Invalid post intent: missing or invalid fields')
|
|
179
|
+
}
|
|
180
|
+
} else if (strategy === 'multipart') {
|
|
181
|
+
if (typeof intent.uploadId !== 'string' || !intent.uploadId) {
|
|
182
|
+
return new Error('Invalid multipart intent: missing or invalid uploadId')
|
|
183
|
+
}
|
|
184
|
+
if (typeof intent.partSize !== 'number' || intent.partSize <= 0) {
|
|
185
|
+
return new Error('Invalid multipart intent: missing or invalid partSize')
|
|
186
|
+
}
|
|
187
|
+
if ('partCount' in intent && intent.partCount !== undefined) {
|
|
188
|
+
if (typeof intent.partCount !== 'number' || !Number.isFinite(intent.partCount) || intent.partCount <= 0) {
|
|
189
|
+
return new Error('Invalid multipart intent: partCount must be a positive number')
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (typeof fileSize === 'number' && typeof intent.partCount === 'number' && intent.partCount > 0) {
|
|
193
|
+
const partSize = intent.partSize
|
|
194
|
+
const partCount = intent.partCount
|
|
195
|
+
const maxBytes = partCount * partSize
|
|
196
|
+
const minBytes = (partCount - 1) * partSize
|
|
197
|
+
if (maxBytes < fileSize) {
|
|
198
|
+
return new Error(
|
|
199
|
+
`Invalid multipart intent: partCount * partSize (${maxBytes}) is smaller than file size (${fileSize})`,
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
if (minBytes >= fileSize && partCount > 1) {
|
|
203
|
+
return new Error(
|
|
204
|
+
`Invalid multipart intent: partCount (${partCount}) exceeds what file size (${fileSize}) needs at partSize ${partSize}`,
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if ('parts' in intent && intent.parts !== undefined) {
|
|
209
|
+
if (!Array.isArray(intent.parts)) {
|
|
210
|
+
return new Error('Invalid multipart intent: parts must be an array if provided')
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { Emitter } from './emitter.types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Listener-throw callback. Re-exported alias for the canonical
|
|
5
|
+
* {@link Emitter.ErrorHandler} type. Kept for backwards compatibility
|
|
6
|
+
* with R30 import paths; prefer `Emitter.ErrorHandler` in new code.
|
|
7
|
+
*
|
|
8
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
9
|
+
*/
|
|
10
|
+
export type EmitterErrorHandler = Emitter.ErrorHandler
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Listener registry, bucketed by event type.
|
|
14
|
+
*
|
|
15
|
+
* @template E Event map.
|
|
16
|
+
*/
|
|
17
|
+
type ListenerMap<E extends Record<string, unknown>> = Partial<{
|
|
18
|
+
[K in keyof E & string]: Set<(payload: E[K]) => void>
|
|
19
|
+
}>
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Soft cap on nested `emit` depth. A listener that synchronously dispatches a
|
|
23
|
+
* command which fires another emit is legal -- but unbounded re-entry usually
|
|
24
|
+
* means a feedback loop. The cap surfaces it in dev without aborting.
|
|
25
|
+
*/
|
|
26
|
+
const MAX_EMIT_DEPTH = 50
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build a typed event emitter with listener-throw isolation.
|
|
30
|
+
*
|
|
31
|
+
* Listeners added or removed during an `emit` see a stable snapshot of the
|
|
32
|
+
* listener set; the mutation lands on the *next* emission.
|
|
33
|
+
*
|
|
34
|
+
* @param onError Optional error sink. Receives `(type, error, listener)` on
|
|
35
|
+
* every listener throw. Falls back to dev-only `console.error` when omitted.
|
|
36
|
+
* @returns Configured emitter exposing `on` / `off` / `emit`.
|
|
37
|
+
* @template E Event map (`Record<eventName, payloadType>`).
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* type Events = { 'file.added': { id: string }, 'upload.progress': { pct: number } }
|
|
41
|
+
* const emitter = createTypedEmitter<Events>()
|
|
42
|
+
* emitter.on('file.added', (p) => console.log(p.id))
|
|
43
|
+
* ```
|
|
44
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
45
|
+
*/
|
|
46
|
+
export function createTypedEmitter<E extends Record<string, unknown>>(
|
|
47
|
+
onError?: EmitterErrorHandler,
|
|
48
|
+
): Emitter.ITypedEmitter<E> {
|
|
49
|
+
const listeners: ListenerMap<E> = {}
|
|
50
|
+
// Memoized iteration snapshots per event type. Invalidated on `on`/`off`.
|
|
51
|
+
// Multi-listener `emit` would otherwise allocate a fresh array every call;
|
|
52
|
+
// for progress streams that adds up to one Set + spread per progress tick.
|
|
53
|
+
const snapshots = new Map<string, ReadonlyArray<(payload: unknown) => void>>()
|
|
54
|
+
let emitDepth = 0
|
|
55
|
+
let warnedDepth = false
|
|
56
|
+
|
|
57
|
+
const invalidate = (type: string) => {
|
|
58
|
+
if (snapshots.size > 0) snapshots.delete(type)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
on<K extends keyof E & string>(type: K, cb: (payload: E[K]) => void): () => void {
|
|
63
|
+
let set = listeners[type]
|
|
64
|
+
if (!set) {
|
|
65
|
+
set = new Set<(payload: E[K]) => void>()
|
|
66
|
+
listeners[type] = set
|
|
67
|
+
}
|
|
68
|
+
set.add(cb)
|
|
69
|
+
invalidate(type)
|
|
70
|
+
|
|
71
|
+
return () => {
|
|
72
|
+
const typeListeners = listeners[type]
|
|
73
|
+
if (!typeListeners) return
|
|
74
|
+
typeListeners.delete(cb)
|
|
75
|
+
invalidate(type)
|
|
76
|
+
if (typeListeners.size === 0) delete listeners[type]
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
off<K extends keyof E & string>(type: K, cb: (payload: E[K]) => void): void {
|
|
81
|
+
const typeListeners = listeners[type]
|
|
82
|
+
if (!typeListeners) return
|
|
83
|
+
typeListeners.delete(cb)
|
|
84
|
+
invalidate(type)
|
|
85
|
+
if (typeListeners.size === 0) delete listeners[type]
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
emit<K extends keyof E & string>(type: K, payload: E[K]): void {
|
|
89
|
+
const typeListeners = listeners[type]
|
|
90
|
+
if (!typeListeners || typeListeners.size === 0) return
|
|
91
|
+
|
|
92
|
+
const dispatch = (cb: (payload: E[K]) => void) => {
|
|
93
|
+
try {
|
|
94
|
+
cb(payload)
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (onError) {
|
|
97
|
+
try {
|
|
98
|
+
onError(type, err, cb as (...args: unknown[]) => void)
|
|
99
|
+
} catch {}
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
|
103
|
+
console.error(`[UploadEngine] Error in event listener for "${type}":`, err)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
emitDepth++
|
|
109
|
+
try {
|
|
110
|
+
if (emitDepth > MAX_EMIT_DEPTH && !warnedDepth && process.env.NODE_ENV !== 'production') {
|
|
111
|
+
warnedDepth = true
|
|
112
|
+
console.warn(
|
|
113
|
+
`[UploadEngine] emit depth exceeded ${MAX_EMIT_DEPTH} for "${type}". ` +
|
|
114
|
+
'A listener is likely dispatching a command that re-emits, forming a feedback loop. ' +
|
|
115
|
+
'Defer the dispatch with `queueMicrotask` to break the chain.',
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Size-1 fast path: skip the snapshot allocation when only one
|
|
120
|
+
// listener is attached. Hot path under busy progress streams where
|
|
121
|
+
// most subscribers attach exactly one listener per event type.
|
|
122
|
+
if (typeListeners.size === 1) {
|
|
123
|
+
const [only] = typeListeners
|
|
124
|
+
dispatch(only)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Multi-listener path: reuse the memoized snapshot so `on()`/`off()`
|
|
129
|
+
// from inside a listener doesn't reshape iteration mid-flight AND
|
|
130
|
+
// back-to-back emits don't re-allocate the array.
|
|
131
|
+
let snapshot = snapshots.get(type) as ReadonlyArray<(payload: E[K]) => void> | undefined
|
|
132
|
+
if (!snapshot) {
|
|
133
|
+
snapshot = Array.from(typeListeners) as ReadonlyArray<(payload: E[K]) => void>
|
|
134
|
+
snapshots.set(type, snapshot as ReadonlyArray<(payload: unknown) => void>)
|
|
135
|
+
}
|
|
136
|
+
for (const cb of snapshot) dispatch(cb)
|
|
137
|
+
} finally {
|
|
138
|
+
emitDepth--
|
|
139
|
+
if (emitDepth === 0) warnedDepth = false
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|