@gentleduck/upload 0.2.4 → 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.
Files changed (42) hide show
  1. package/README.md +320 -0
  2. package/dist/core/index.d.ts +1 -1
  3. package/dist/core/index.js +1 -1
  4. package/dist/{core-BNxznRif.js → core-CMkrP9DX.js} +2 -2
  5. package/dist/{index-BXg9WIce.d.ts → index-C38Jmizu.d.ts} +51 -33
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/react/index.d.ts +8 -47
  9. package/dist/react/index.js +1 -1
  10. package/dist/strategies/index.d.ts +5 -5
  11. package/package.json +1 -1
  12. package/src/__test__/effect-pool.test.ts +5 -0
  13. package/src/__test__/emitter.test.ts +85 -0
  14. package/src/__test__/fingerprint.test.ts +49 -0
  15. package/src/__test__/guards.test.ts +30 -0
  16. package/src/__test__/id.test.ts +14 -0
  17. package/src/__test__/persistence.test.ts +156 -0
  18. package/src/__test__/store-integration.test.ts +5 -0
  19. package/src/__test__/store-libs.test.ts +138 -0
  20. package/src/__test__/transport-libs.test.ts +42 -0
  21. package/src/__test__/transport.test.ts +182 -0
  22. package/src/core/client/client.ts +50 -0
  23. package/src/core/client/client.types.ts +184 -0
  24. package/src/core/contracts/contracts.types.ts +179 -0
  25. package/src/core/contracts/transport/transport.libs.ts +7 -32
  26. package/src/core/contracts/transport/transport.ts +5 -7
  27. package/src/core/engine/engine.types.ts +364 -0
  28. package/src/core/engine/internal-events.types.ts +3 -23
  29. package/src/core/engine/reducer.ts +5 -11
  30. package/src/core/engine/store/handlers/add-file.ts +6 -21
  31. package/src/core/engine/store/handlers/run-upload.ts +7 -7
  32. package/src/core/engine/store/handlers.ts +819 -0
  33. package/src/core/engine/store/store.libs.ts +17 -57
  34. package/src/core/engine/store/store.runtime.ts +10 -20
  35. package/src/core/engine/validation/file.ts +1 -1
  36. package/src/core/engine/validation.ts +216 -0
  37. package/src/core/utils/emitter/emitter.ts +143 -0
  38. package/src/react/upload-provider.tsx +7 -58
  39. package/src/react/uploader.types.ts +83 -0
  40. package/src/react/use-uploader.tsx +2 -5
  41. package/src/strategies/multipart/index.ts +9 -12
  42. 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
- * Determines whether items for a given purpose should auto-start.
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
- * Builds a lightweight fingerprint from a {@link File}.
44
- *
45
- * This fingerprint is used for display and basic identity. If you need stronger identity
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
- * Normalizes unknown thrown values into the engine's {@link UploadError} shape.
53
- *
54
- * You can provide a custom normalizer through {@link StoreOptions.errorNormalizer}.
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 abort errors can carry {code:'aborted', reason:'pause'|'cancel'}
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
- * Small helper to narrow unknown values to objects.
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
- * Creates a new runtime instance with initial state, emitter, reducer, and effect queue.
16
- *
17
- * Notes:
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 persistence flush. Persistence writes a single namespace `key`,
38
- // and the adapter can choose how to store multiple item records under it.
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
- // If there's nothing to persist, clear the namespace to keep storage clean.
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
- // replaced by createUploadStore
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
- // Hydrate from persistence when no initialState was provided.
168
- // For async adapters (IndexedDB), this happens after construction.
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
- !!fileExt && extensions.length > 0 ? extensions.some((ext) => ext.toLowerCase() === fileExt) : false
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
+ }