@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.
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
@@ -0,0 +1,182 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
2
+ import { createXHRTransport } from '../core/contracts/transport'
3
+ import { UploadAbortError } from '../core/contracts/transport/transport.libs'
4
+
5
+ /**
6
+ * Minimal `XMLHttpRequest` mock. Captures method/url/headers/body and exposes
7
+ * hooks to simulate `onload` / `onerror` / `onabort` and per-progress events.
8
+ */
9
+ class MockXHR {
10
+ static instances: MockXHR[] = []
11
+
12
+ method = ''
13
+ url = ''
14
+ body: unknown = null
15
+ headers: Record<string, string> = {}
16
+ status = 0
17
+ upload = { onprogress: null as ((e: ProgressEvent) => void) | null }
18
+ onload: ((e: Event) => void) | null = null
19
+ onerror: ((e: Event) => void) | null = null
20
+ onabort: ((e: Event) => void) | null = null
21
+ responseHeadersRaw = ''
22
+ responseHeaders: Record<string, string> = {}
23
+
24
+ constructor() {
25
+ MockXHR.instances.push(this)
26
+ }
27
+
28
+ open(method: string, url: string) {
29
+ this.method = method
30
+ this.url = url
31
+ }
32
+
33
+ setRequestHeader(name: string, value: string) {
34
+ this.headers[name.toLowerCase()] = value
35
+ }
36
+
37
+ send(body: unknown) {
38
+ this.body = body
39
+ }
40
+
41
+ abort() {
42
+ this.onabort?.(new Event('abort'))
43
+ }
44
+
45
+ getResponseHeader(name: string): string | null {
46
+ return this.responseHeaders[name.toLowerCase()] ?? null
47
+ }
48
+
49
+ getAllResponseHeaders(): string {
50
+ return this.responseHeadersRaw
51
+ }
52
+
53
+ // Test helpers
54
+ fireSuccess(status: number, headers: Record<string, string> = {}) {
55
+ this.status = status
56
+ this.responseHeaders = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]))
57
+ this.responseHeadersRaw = Object.entries(headers)
58
+ .map(([k, v]) => `${k}: ${v}`)
59
+ .join('\r\n')
60
+ this.onload?.(new Event('load'))
61
+ }
62
+
63
+ fireError(status = 0) {
64
+ this.status = status
65
+ this.onerror?.(new Event('error'))
66
+ }
67
+ }
68
+
69
+ const originalXHR = (globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest
70
+
71
+ beforeEach(() => {
72
+ MockXHR.instances = []
73
+ ;(globalThis as unknown as { XMLHttpRequest: typeof MockXHR }).XMLHttpRequest = MockXHR
74
+ })
75
+
76
+ afterEach(() => {
77
+ if (originalXHR) (globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = originalXHR
78
+ else delete (globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest
79
+ })
80
+
81
+ describe('createXHRTransport.put', () => {
82
+ test('resolves on 2xx and parses ETag', async () => {
83
+ const t = createXHRTransport()
84
+ const ctl = new AbortController()
85
+ const promise = t.put({
86
+ url: 'https://example.com/part',
87
+ body: new Blob(['x']),
88
+ signal: ctl.signal,
89
+ headers: { 'x-foo': 'bar' },
90
+ })
91
+
92
+ const xhr = MockXHR.instances[0]
93
+ expect(xhr.method).toBe('PUT')
94
+ expect(xhr.url).toBe('https://example.com/part')
95
+ expect(xhr.headers['x-foo']).toBe('bar')
96
+
97
+ xhr.fireSuccess(200, { ETag: '"abc"' })
98
+ const result = await promise
99
+ expect(result.etag).toBe('abc')
100
+ })
101
+
102
+ test('rejects with a status-tagged Error on non-2xx status', async () => {
103
+ const t = createXHRTransport()
104
+ const ctl = new AbortController()
105
+ const promise = t.put({ url: 'https://example.com/part', body: new Blob(['x']), signal: ctl.signal })
106
+
107
+ const xhr = MockXHR.instances[0]
108
+ xhr.fireSuccess(503)
109
+ await expect(promise).rejects.toThrow(/status 503/)
110
+ })
111
+
112
+ test('rejects with a CORS-tagged Error when status=0', async () => {
113
+ const t = createXHRTransport()
114
+ const ctl = new AbortController()
115
+ const promise = t.put({ url: 'https://example.com/part', body: new Blob(['x']), signal: ctl.signal })
116
+
117
+ const xhr = MockXHR.instances[0]
118
+ xhr.fireError(0)
119
+ await expect(promise).rejects.toThrow(/CORS/)
120
+ })
121
+
122
+ test('rejects with UploadAbortError when the signal aborts', async () => {
123
+ const t = createXHRTransport()
124
+ const ctl = new AbortController()
125
+ const promise = t.put({ url: 'https://example.com/part', body: new Blob(['x']), signal: ctl.signal })
126
+
127
+ ctl.abort('cancel')
128
+ await expect(promise).rejects.toBeInstanceOf(UploadAbortError)
129
+ await expect(promise).rejects.toMatchObject({ code: 'aborted', reason: 'cancel' })
130
+ })
131
+
132
+ test('rejects non-absolute URLs', async () => {
133
+ const t = createXHRTransport()
134
+ const ctl = new AbortController()
135
+ await expect(t.put({ url: '/relative/path', body: new Blob(['x']), signal: ctl.signal })).rejects.toThrow(
136
+ /absolute URL/,
137
+ )
138
+ })
139
+
140
+ test('forwards upload progress events', async () => {
141
+ const t = createXHRTransport()
142
+ const ctl = new AbortController()
143
+ const onProgress = vi.fn()
144
+ const promise = t.put({
145
+ url: 'https://example.com/part',
146
+ body: new Blob(['x']),
147
+ signal: ctl.signal,
148
+ onProgress,
149
+ })
150
+
151
+ const xhr = MockXHR.instances[0]
152
+ xhr.upload.onprogress?.({ lengthComputable: true, loaded: 5, total: 10 } as ProgressEvent)
153
+ expect(onProgress).toHaveBeenCalledWith(5, 10)
154
+
155
+ xhr.fireSuccess(200)
156
+ await promise
157
+ })
158
+ })
159
+
160
+ describe('createXHRTransport.postForm', () => {
161
+ test('builds FormData with fields + file and POSTs', async () => {
162
+ const t = createXHRTransport()
163
+ const ctl = new AbortController()
164
+ const file = new File(['payload'], 'a.txt')
165
+ const promise = t.postForm({
166
+ url: 'https://example.com/upload',
167
+ fields: { key: 'avatar/a.txt', acl: 'private' },
168
+ file,
169
+ signal: ctl.signal,
170
+ })
171
+
172
+ const xhr = MockXHR.instances[0]
173
+ expect(xhr.method).toBe('POST')
174
+ expect(xhr.body).toBeInstanceOf(FormData)
175
+ const form = xhr.body as FormData
176
+ expect(form.get('key')).toBe('avatar/a.txt')
177
+ expect(form.get('acl')).toBe('private')
178
+
179
+ xhr.fireSuccess(204)
180
+ await promise
181
+ })
182
+ })
@@ -0,0 +1,50 @@
1
+ import { Defaults } from '../utils/constants'
2
+ import type { Client } from './client.types'
3
+
4
+ /**
5
+ * Apply defaults to a partial {@link Client.IUploadConfig}.
6
+ *
7
+ * @param input Partial config from the caller. Nested groups
8
+ * (e.g. `effectQueueCompaction`) accept their own partial shape.
9
+ * @returns Fully populated config with each numeric field clamped to a sane
10
+ * minimum.
11
+ * @template P Purpose string union.
12
+ * @author wildduck2 <https://github.com/wildduck2>
13
+ */
14
+ export function resolveUploadConfig<P extends string>(input?: Client.UploadConfigInput<P>): Client.IUploadConfig<P> {
15
+ return {
16
+ maxConcurrentUploads: Math.max(1, input?.maxConcurrentUploads ?? Defaults.MAX_CONCURRENT_UPLOADS),
17
+ autoStart: input?.autoStart,
18
+ progressThrottleMs: Math.max(0, input?.progressThrottleMs ?? Defaults.PROGRESS_THROTTLE_MS),
19
+ validation: input?.validation ?? {},
20
+ maxAttempts: Math.max(1, input?.maxAttempts ?? Defaults.MAX_ATTEMPTS),
21
+ retryPolicy: input?.retryPolicy,
22
+ maxItems: input?.maxItems === null ? null : Math.max(1, input?.maxItems ?? Defaults.MAX_ITEMS),
23
+ completedItemTTL: input?.completedItemTTL,
24
+ effectConcurrency: Math.max(1, input?.effectConcurrency ?? Defaults.EFFECT_CONCURRENCY),
25
+ effectTimeoutMs: Math.max(0, input?.effectTimeoutMs ?? Defaults.EFFECT_TIMEOUT_MS),
26
+ errorContextInMessage: input?.errorContextInMessage ?? false,
27
+ keepRawCause: input?.keepRawCause ?? false,
28
+ effectQueueCompaction: {
29
+ minHead: Math.max(1, input?.effectQueueCompaction?.minHead ?? 64),
30
+ ratioDenom: Math.max(2, input?.effectQueueCompaction?.ratioDenom ?? 2),
31
+ maxQueueLength:
32
+ input?.effectQueueCompaction?.maxQueueLength === null
33
+ ? null
34
+ : Math.max(1, input?.effectQueueCompaction?.maxQueueLength ?? 10_000),
35
+ },
36
+ checksumMaxSize:
37
+ input?.checksumMaxSize === undefined
38
+ ? null
39
+ : input.checksumMaxSize === null
40
+ ? null
41
+ : Math.max(0, input.checksumMaxSize),
42
+ checksumChunkBytes:
43
+ input?.checksumChunkBytes === undefined
44
+ ? null
45
+ : input.checksumChunkBytes === null
46
+ ? null
47
+ : Math.max(1, input.checksumChunkBytes),
48
+ strictRebindType: input?.strictRebindType ?? false,
49
+ }
50
+ }
@@ -0,0 +1,184 @@
1
+ import type { Contracts } from '../contracts'
2
+ import type { Engine } from '../engine/engine.types'
3
+ import type { Reducer } from '../engine/reducer'
4
+ import type { Store } from '../engine/store'
5
+ import type { PersistenceError } from '../persistence/persistence.types'
6
+
7
+ /**
8
+ * Client-facing configuration, hooks, plugins, and retry policy types.
9
+ *
10
+ * @author wildduck2 <https://github.com/wildduck2>
11
+ */
12
+ export namespace Client {
13
+ // -------------------- Retry --------------------
14
+
15
+ /** Output of the retry policy. `delayMs` is a backoff before re-attempt. */
16
+ export type RetryDecision = { retryable: false } | { retryable: true; delayMs: number }
17
+
18
+ // -------------------- Config --------------------
19
+
20
+ /** Effect-queue compaction tuning knobs. */
21
+ export interface IEffectQueueCompaction {
22
+ /** Absolute head-count floor before compaction. Default 64. */
23
+ minHead: number
24
+ /** Triggers compaction when `head * denom > length`. Default 2. */
25
+ ratioDenom: number
26
+ /**
27
+ * Hard upper bound on queue length. New effects past this cap are
28
+ * dropped + logged. Defends against a pathological dispatch storm that
29
+ * grows the queue faster than compaction drains. `null` disables.
30
+ * Default `10_000`.
31
+ */
32
+ maxQueueLength: number | null
33
+ }
34
+
35
+ /** Global upload engine configuration. */
36
+ export interface IUploadConfig<P extends string> {
37
+ /** Maximum concurrent in-flight uploads. Default `3`. */
38
+ maxConcurrentUploads: number
39
+ /** Auto-start `ready` items. */
40
+ autoStart?: readonly P[] | ((purpose: P) => boolean)
41
+ /** Throttle for `upload.progress` events in ms. Default `100`. */
42
+ progressThrottleMs: number
43
+ /** Per-purpose validation rules. */
44
+ validation: Partial<Record<P, Contracts.IValidationRules>>
45
+ /** Maximum attempts per phase before a failure is final. Default `3`. */
46
+ maxAttempts: number
47
+ /** Override the default retry policy. */
48
+ retryPolicy?: (ctx: {
49
+ phase: 'intent' | 'upload' | 'complete'
50
+ attempt: number
51
+ error: Contracts.Error
52
+ }) => RetryDecision
53
+ /** Cap on items kept in state. Set to `null` for no cap. Default `100`. */
54
+ maxItems: number | null
55
+ /** Auto-remove `completed`/`canceled` items after this many ms. */
56
+ completedItemTTL?: number
57
+ /** Concurrency cap for side-effects. Default `8`. */
58
+ effectConcurrency: number
59
+ /** Per-effect watchdog timeout in ms. `0` disables. Default `60000`. */
60
+ effectTimeoutMs: number
61
+ /** Include file context in persisted error messages. Default `false` (PII). */
62
+ errorContextInMessage: boolean
63
+ /** Keep raw thrown value as `error.cause`. Default `false` (PII). */
64
+ keepRawCause: boolean
65
+ /** Effect-queue compaction tuning. */
66
+ effectQueueCompaction: IEffectQueueCompaction
67
+ /**
68
+ * Skip `crypto.subtle.digest` (and the dedupe lookup that depends on it)
69
+ * for files larger than this byte threshold. The browser has no streaming
70
+ * SHA-256, so digesting a 10GB file allocates 10GB of heap; the cap lets
71
+ * the consumer trade dedupe coverage for memory. Default `null` (no cap).
72
+ */
73
+ checksumMaxSize: number | null
74
+ /**
75
+ * Hash only the first N bytes of each file. Yields a fast approximate
76
+ * fingerprint at the cost of strict uniqueness: files that share the
77
+ * leading window collide. Combine with `size + lastModified + chunk
78
+ * checksum` on the backend to form a unique dedupe key. Default
79
+ * `null` (full-file hash, exact match).
80
+ *
81
+ * @remarks Set to a small value (e.g. `1024 * 64`) for fast
82
+ * checksums on very large files where exact dedupe is not
83
+ * required.
84
+ */
85
+ checksumChunkBytes: number | null
86
+ /**
87
+ * When `true`, `rebind` requires the rebound `File.type` to match the
88
+ * paused item's fingerprint type. Default `false` because browsers
89
+ * disagree on MIME detection (Safari often omits MIME types Chrome
90
+ * attaches). Enable when both sessions are guaranteed to run in the
91
+ * same browser/runtime.
92
+ */
93
+ strictRebindType: boolean
94
+ }
95
+
96
+ /**
97
+ * Partial form accepted by `createUploadStore`. Nested config groups
98
+ * (e.g. `effectQueueCompaction`) accept their own partial shape so
99
+ * callers can override one knob without restating the rest.
100
+ */
101
+ export type UploadConfigInput<P extends string> = Partial<Omit<IUploadConfig<P>, 'effectQueueCompaction'>> & {
102
+ effectQueueCompaction?: Partial<IEffectQueueCompaction>
103
+ }
104
+
105
+ // -------------------- Hooks --------------------
106
+
107
+ /** Optional observability hooks. Diagnostics / analytics / devtools / Sentry. */
108
+ export interface IUploadHooks<
109
+ M extends Contracts.IntentMap,
110
+ C extends Contracts.CursorMap<M>,
111
+ P extends string,
112
+ R extends Contracts.IResultBase = Contracts.IResultBase,
113
+ > {
114
+ /** Fires after every internal event applied by the reducer. */
115
+ onInternalEvent?: (event: Engine.InternalEvent<M, C, P, R>, state: Reducer.IState<M, C, P, R>) => void
116
+ /** Fires when a persistence adapter operation fails. */
117
+ onPersistenceError?: (error: PersistenceError) => void
118
+ /** Fires on listener / plugin / emitter / batch throws. */
119
+ onListenerError?: (
120
+ error: unknown,
121
+ context: { kind: 'subscriber' | 'plugin-setup' | 'emitter' | 'batch'; name?: string },
122
+ ) => void
123
+ /**
124
+ * Fires when the effect queue rejects a new effect because it is at
125
+ * `effectQueueCompaction.maxQueueLength`. Production telemetry hook so
126
+ * dropped effects are not silently swallowed. Passes the current queue
127
+ * length (`queueLength`), active in-flight effect count (`active`), the
128
+ * hard cap (`cap`), and the optional `localId` of the upload that
129
+ * triggered the enqueue (when the caller threaded it through).
130
+ */
131
+ onEffectDropped?: (context: { queueLength: number; active: number; cap: number; localId?: string }) => void
132
+ /**
133
+ * Fires when `scheduleWork`'s re-entrant pass loop hits the soft cap
134
+ * (default 100). Production telemetry hook so a runaway feedback
135
+ * loop is visible even when dev-mode `console.error` is gated out.
136
+ */
137
+ onScheduleStarved?: (context: { passes: number }) => void
138
+ /**
139
+ * Fires when `flushPersistence`'s trailing-flush loop hits the soft
140
+ * cap. A pathological feedback loop (e.g. `onPersistenceError`
141
+ * dispatching state changes that schedule another flush) would
142
+ * otherwise spin forever. Defaults to 100 trailing iterations.
143
+ */
144
+ onPersistenceLoopStarved?: (context: { passes: number }) => void
145
+ }
146
+
147
+ // -------------------- Plugin --------------------
148
+
149
+ /** Store plugin. Subscribed once at construction with a narrow store view. */
150
+ export interface IUploadPlugin<
151
+ M extends Contracts.IntentMap,
152
+ C extends Contracts.CursorMap<M>,
153
+ P extends string,
154
+ R extends Contracts.IResultBase = Contracts.IResultBase,
155
+ > {
156
+ /** Plugin name (used in debugging output + `onListenerError` context). */
157
+ name: string
158
+ /**
159
+ * Called once at construction. May return a `Promise` to delay
160
+ * `store.ready` until the plugin finishes initializing (e.g. fetching
161
+ * remote feature flags). Sync throws and async rejections are both
162
+ * routed through `onListenerError`.
163
+ *
164
+ * Wider surface than R23: plugins now also see `flush` / `cleanup` /
165
+ * `purge` / `has` / `waitFor` / `subscribe` so a plugin can await
166
+ * durability, do bulk operations, or subscribe via the public
167
+ * mechanism instead of holding raw `rt` references.
168
+ */
169
+ setup: (
170
+ ctx: Pick<
171
+ Store.IUploadStore<M, C, P, R>,
172
+ 'on' | 'off' | 'dispatch' | 'getSnapshot' | 'subscribe' | 'waitFor' | 'flush' | 'cleanup' | 'purge' | 'has'
173
+ >,
174
+ ) => void | Promise<void>
175
+ /**
176
+ * Called once during `store.destroy()` so the plugin can release its
177
+ * own resources (timers, external subscriptions, network handles).
178
+ * May return a `Promise` -- `destroy()` awaits all plugin disposes
179
+ * before resolving. Sync throws and async rejections route through
180
+ * `onListenerError` and never reject `destroy()`.
181
+ */
182
+ dispose?: () => void | Promise<void>
183
+ }
184
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Public contract types grouped under the `Contracts` namespace.
3
+ *
4
+ * Each sub-namespace owns one concern (Intent, Cursor, Result, Fingerprint,
5
+ * Errors, Validation, Strategy, BackendApi).
6
+ *
7
+ * @author wildduck2 <https://github.com/wildduck2>
8
+ */
9
+ export namespace Contracts {
10
+ // -------------------- Intent --------------------
11
+
12
+ /** Base shape for all upload intents. */
13
+ export interface IIntentBase<K extends string = string> {
14
+ /** Strategy name (discriminant). */
15
+ strategy: K
16
+ /** Unique backend file identifier. */
17
+ fileId: string
18
+ }
19
+
20
+ /** Registry of intent types mapped by strategy name. */
21
+ export type IntentMap = Record<string, IIntentBase<string>>
22
+
23
+ /** Union of strategy keys for an intent map. */
24
+ export type StrategyKey<M extends IntentMap> = keyof M & string
25
+
26
+ /** Union of all intent variants for an intent map. */
27
+ export type AnyIntent<M extends IntentMap> = M[StrategyKey<M>]
28
+
29
+ // -------------------- Cursor --------------------
30
+
31
+ /** Registry of cursor payload types mapped by strategy name. */
32
+ export type CursorMap<M extends IntentMap> = Partial<Record<StrategyKey<M>, unknown>>
33
+
34
+ /** Discriminated union of all cursor variants. */
35
+ export type AnyCursor<C extends Record<string, unknown>> = {
36
+ [K in keyof C & string]: { strategy: K; value?: C[K] }
37
+ }[keyof C & string]
38
+
39
+ // -------------------- Result --------------------
40
+
41
+ /** Base shape for upload completion results returned by your backend. */
42
+ export interface IResultBase {
43
+ /** Backend file identifier. */
44
+ fileId: string
45
+ /** Storage key or path used for signed URLs. */
46
+ key: string
47
+ }
48
+
49
+ // -------------------- Fingerprint --------------------
50
+
51
+ /** Deterministic client-side identity for a file. */
52
+ export interface IFileFingerprint {
53
+ /** File name. */
54
+ name: string
55
+ /** File size in bytes. */
56
+ size: number
57
+ /** File MIME type. */
58
+ type: string
59
+ /** File lastModified timestamp (ms). */
60
+ lastModified: number
61
+ /** Optional checksum (e.g. SHA-256 hex). */
62
+ checksum?: string
63
+ }
64
+
65
+ // -------------------- Errors --------------------
66
+
67
+ /** File rejection reasons produced by client-side validation. */
68
+ export type RejectReason =
69
+ | { code: 'empty_file' }
70
+ | { code: 'file_too_large'; maxBytes: number; size: number }
71
+ | { code: 'type_not_allowed'; allowed: string[]; got: string }
72
+ | { code: 'too_many_files'; max: number }
73
+
74
+ /** Base shape for custom errors. */
75
+ export interface IErrorBase {
76
+ /** Stable error code. */
77
+ code: string
78
+ /** Human-friendly message (safe for UI). */
79
+ message: string
80
+ /** Optional cause (original error, response, etc.). */
81
+ cause?: unknown
82
+ /** Whether the error should be retried. */
83
+ retryable?: boolean
84
+ }
85
+
86
+ /** Built-in engine errors. */
87
+ export type BuiltInError =
88
+ | { code: 'validation_failed'; message: string; reason: RejectReason; retryable?: false }
89
+ | { code: 'strategy_missing'; message: string; strategy: string; retryable?: false }
90
+ | { code: 'aborted'; message: string; reason: 'pause' | 'cancel' | 'unknown'; cause?: unknown; retryable?: false }
91
+ | { code: 'network'; message: string; cause?: unknown; retryable?: boolean }
92
+ | { code: 'http'; message: string; status: number; statusText?: string; cause?: unknown; retryable?: boolean }
93
+ | { code: 'timeout'; message: string; cause?: unknown; retryable?: boolean }
94
+ | { code: 'auth'; message: string; cause?: unknown; retryable?: false }
95
+ | { code: 'rate_limit'; message: string; retryAfterMs?: number; cause?: unknown; retryable?: boolean }
96
+ | { code: 'server'; message: string; serverCode?: string; cause?: unknown; retryable?: boolean }
97
+ | { code: 'unknown'; message: string; cause?: unknown; retryable?: boolean }
98
+
99
+ /** The error type used everywhere in the engine. */
100
+ export type Error = BuiltInError | (IErrorBase & Record<string, unknown>)
101
+
102
+ // -------------------- Validation --------------------
103
+
104
+ /** Validation rules applied on `addFiles`. */
105
+ export interface IValidationRules {
106
+ /** Maximum number of files allowed. */
107
+ maxFiles?: number
108
+ /** Maximum file size in bytes. */
109
+ maxSizeBytes?: number
110
+ /** Minimum file size in bytes. */
111
+ minSizeBytes?: number
112
+ /** Allowed MIME types. */
113
+ allowedTypes?: string[]
114
+ /** Allowed file extensions. */
115
+ allowedExtensions?: string[]
116
+ }
117
+
118
+ // -------------------- Backend API --------------------
119
+
120
+ /** Common options object for backend calls. */
121
+ export type WithSignal = { signal?: AbortSignal }
122
+
123
+ /** Backend adapter the engine calls into. */
124
+ export interface IUploadApi<M extends IntentMap, P extends string, R extends IResultBase = IResultBase> {
125
+ /**
126
+ * Create an upload intent. The backend decides the strategy and any
127
+ * presigned URLs / form fields.
128
+ */
129
+ createIntent(
130
+ args: { purpose: P; contentType: string; size: number; filename: string; checksum?: string },
131
+ opts?: WithSignal,
132
+ ): Promise<M[keyof M]>
133
+
134
+ /** Finalize an upload after all bytes are on the server. */
135
+ complete(args: { fileId: string }, opts?: WithSignal): Promise<R>
136
+
137
+ /** Optional helper to mint a signed preview URL for a finished upload. */
138
+ getSignedPreviewUrl?(args: { fileId: string; key: string; purpose: P }, opts?: WithSignal): Promise<string>
139
+
140
+ /** Optional checksum-based dedupe lookup. */
141
+ findByChecksum?(args: { checksum: string; purpose: P }, opts?: WithSignal): Promise<R | null>
142
+
143
+ /** Multipart-strategy operations. Required when using the multipart strategy. */
144
+ multipart?: {
145
+ /** Sign one part for upload. */
146
+ signPart(
147
+ args: { fileId: string; uploadId: string; partNumber: number; checksum?: string },
148
+ opts?: WithSignal,
149
+ ): Promise<{ url: string; headers?: Record<string, string> }>
150
+
151
+ /** Tell the backend to assemble the parts into the final object. */
152
+ completeMultipart(
153
+ args: { fileId: string; uploadId: string; parts: Array<{ partNumber: number; etag: string }> },
154
+ opts?: WithSignal,
155
+ ): Promise<unknown>
156
+
157
+ /** Optional: list already-uploaded parts (used by resume diagnostics). */
158
+ listParts?(
159
+ args: { fileId: string; uploadId: string },
160
+ opts?: WithSignal,
161
+ ): Promise<Array<{ partNumber: number; etag?: string; size?: number }>>
162
+
163
+ /** Optional: abort an in-progress multipart session on the backend. */
164
+ abort?(args: { fileId: string; uploadId: string }, opts?: WithSignal): Promise<void>
165
+ }
166
+
167
+ /** TUS-strategy operations. */
168
+ tus?: {
169
+ /** Create a TUS upload session, returning its URL. */
170
+ create(
171
+ args: { fileId: string; size: number; filename: string; contentType: string },
172
+ opts?: WithSignal,
173
+ ): Promise<{ uploadUrl: string }>
174
+
175
+ /** Read the current TUS upload offset (used for resume). */
176
+ getOffset(args: { uploadUrl: string }, opts?: WithSignal): Promise<{ offset: number }>
177
+ }
178
+ }
179
+ }
@@ -1,29 +1,26 @@
1
1
  import { isRecord } from '../../utils/guards'
2
2
 
3
- // ============================================================================
4
- // ABORT HANDLING UTILITIES
5
- // ============================================================================
6
-
7
3
  export function abortReason(signal?: AbortSignal): unknown {
8
4
  return signal?.reason
9
5
  }
10
6
 
7
+ /**
8
+ * Map `controller.abort(reason)` payloads to `'pause' | 'cancel'`. Accepts a
9
+ * bare string, `{ reason }`, or `{ kind }` shape.
10
+ */
11
11
  export function normalizeAbortReason(r: unknown): 'pause' | 'cancel' | 'unknown' {
12
12
  if (!r) return 'unknown'
13
13
 
14
- // Direct string match
15
14
  if (typeof r === 'string') {
16
15
  if (r === 'pause' || r === 'cancel') return r
17
16
  return 'unknown'
18
17
  }
19
18
 
20
- // Object with 'reason' property (from controller.abort({ reason: 'pause' }))
21
19
  if (isRecord(r) && 'reason' in r) {
22
20
  const v = r.reason
23
21
  if (v === 'pause' || v === 'cancel') return v
24
22
  }
25
23
 
26
- // Object with 'kind' property (alternative format)
27
24
  if (isRecord(r) && 'kind' in r) {
28
25
  const v = r.kind
29
26
  if (v === 'pause' || v === 'cancel') return v
@@ -46,20 +43,9 @@ export function makeAbortError(reason: unknown): UploadAbortError {
46
43
  return new UploadAbortError(reason)
47
44
  }
48
45
 
49
- // ============================================================================
50
- // ERROR HANDLING HELPERS
51
- // ============================================================================
52
-
53
- /**
54
- * Creates an error for network failures, including CORS detection.
55
- *
56
- * @param xhr - XMLHttpRequest instance to check status
57
- * @param defaultMessage - Default error message if status is non-zero
58
- *
59
- * @returns {Error} Appropriate error message based on XHR status
60
- */
46
+ /** XHR network error with `status === 0` heuristic for CORS detection. */
61
47
  export function createNetworkError(xhr: XMLHttpRequest, defaultMessage: string): Error {
62
- // Status 0 typically indicates CORS failure or network error
48
+ // status 0 → CORS failure or transport-level network error.
63
49
  if (xhr.status === 0) {
64
50
  return new Error(
65
51
  'CORS error: The upload server does not allow requests from this origin. Please check CORS configuration.',
@@ -68,18 +54,7 @@ export function createNetworkError(xhr: XMLHttpRequest, defaultMessage: string):
68
54
  return new Error(`${defaultMessage} (status: ${xhr.status})`)
69
55
  }
70
56
 
71
- // ============================================================================
72
- // RESPONSE PARSING
73
- // ============================================================================
74
-
75
- /**
76
- * Parses XHR response headers into a key-value object.
77
- * Headers are normalized to lowercase keys.
78
- *
79
- * @param xhr - XMLHttpRequest instance
80
- *
81
- * @returns {Record<string, string>} Parsed headers object
82
- */
57
+ /** Parse XHR headers into a lowercase-keyed record. */
83
58
  export function parseHeaders(xhr: XMLHttpRequest): Record<string, string> {
84
59
  const headers: Record<string, string> = {}
85
60
  const headerStr = xhr.getAllResponseHeaders()