@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
@@ -0,0 +1,108 @@
1
+ import { afterEach, describe, expect, test, vi } from 'vitest'
2
+ import { createFetchTransport } from '../core/contracts/transport'
3
+ import { UploadAbortError } from '../core/contracts/transport/transport.libs'
4
+
5
+ const URL_OK = 'https://up.example.com/o'
6
+
7
+ function stubFetch(impl: (url: string, init: RequestInit) => Promise<Response> | Response) {
8
+ const fn = vi.fn(impl)
9
+ vi.stubGlobal('fetch', fn)
10
+ return fn
11
+ }
12
+
13
+ afterEach(() => {
14
+ vi.unstubAllGlobals()
15
+ })
16
+
17
+ describe('createFetchTransport.put', () => {
18
+ test('PUTs the body and returns the ETag', async () => {
19
+ const fetchMock = stubFetch(() => new Response(null, { status: 200, headers: { ETag: '"deadbeef"' } }))
20
+ const t = createFetchTransport()
21
+ const onProgress = vi.fn()
22
+ const out = await t.put({
23
+ url: URL_OK,
24
+ body: new Blob([new Uint8Array(10)]),
25
+ headers: { 'Content-Type': 'application/octet-stream' },
26
+ signal: new AbortController().signal,
27
+ onProgress,
28
+ })
29
+
30
+ expect(out.etag).toBe('deadbeef')
31
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
32
+ expect(url).toBe(URL_OK)
33
+ expect(init.method).toBe('PUT')
34
+ // Terminal progress tick.
35
+ expect(onProgress).toHaveBeenCalledWith(10, 10)
36
+ })
37
+
38
+ test('rejects non-2xx with the status', async () => {
39
+ stubFetch(() => new Response('nope', { status: 500 }))
40
+ const t = createFetchTransport()
41
+ await expect(t.put({ url: URL_OK, body: new Blob(['x']), signal: new AbortController().signal })).rejects.toThrow(
42
+ /status 500/,
43
+ )
44
+ })
45
+
46
+ test('rejects a relative URL', async () => {
47
+ stubFetch(() => new Response(null, { status: 200 }))
48
+ const t = createFetchTransport()
49
+ await expect(
50
+ t.put({ url: '/relative', body: new Blob(['x']), signal: new AbortController().signal }),
51
+ ).rejects.toThrow(/absolute URL/)
52
+ })
53
+
54
+ test('maps an aborted signal to UploadAbortError', async () => {
55
+ stubFetch(() => new Response(null, { status: 200 }))
56
+ const controller = new AbortController()
57
+ controller.abort('cancel')
58
+ const t = createFetchTransport()
59
+ await expect(t.put({ url: URL_OK, body: new Blob(['x']), signal: controller.signal })).rejects.toBeInstanceOf(
60
+ UploadAbortError,
61
+ )
62
+ })
63
+ })
64
+
65
+ describe('createFetchTransport.postForm', () => {
66
+ test('sends multipart form data', async () => {
67
+ const fetchMock = stubFetch(() => new Response(null, { status: 204 }))
68
+ const t = createFetchTransport()
69
+ await t.postForm({
70
+ url: URL_OK,
71
+ fields: { key: 'v', policy: 'p' },
72
+ file: new File(['abc'], 'a.txt'),
73
+ signal: new AbortController().signal,
74
+ })
75
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
76
+ expect(init.method).toBe('POST')
77
+ expect(init.body).toBeInstanceOf(FormData)
78
+ })
79
+ })
80
+
81
+ describe('createFetchTransport.get', () => {
82
+ test('streams the body and reports incremental progress', async () => {
83
+ const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])
84
+ stubFetch(
85
+ () =>
86
+ new Response(payload, {
87
+ status: 200,
88
+ headers: { 'Content-Length': String(payload.length), 'Content-Type': 'application/octet-stream' },
89
+ }),
90
+ )
91
+ const t = createFetchTransport()
92
+ const onProgress = vi.fn()
93
+ const out = await t.get?.({ url: URL_OK, signal: new AbortController().signal, onProgress })
94
+ expect(out?.status).toBe(200)
95
+ const bytes = new Uint8Array(await out!.blob.arrayBuffer())
96
+ expect(Array.from(bytes)).toEqual(Array.from(payload))
97
+ // Final progress equals total.
98
+ const last = onProgress.mock.calls.at(-1)
99
+ expect(last?.[0]).toBe(payload.length)
100
+ expect(last?.[1]).toBe(payload.length)
101
+ })
102
+
103
+ test('rejects a failed download', async () => {
104
+ stubFetch(() => new Response('nope', { status: 404 }))
105
+ const t = createFetchTransport()
106
+ await expect(t.get?.({ url: URL_OK, signal: new AbortController().signal })).rejects.toThrow(/status 404/)
107
+ })
108
+ })
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { makeAbortError, normalizeAbortReason, UploadAbortError } from '../core/contracts/transport/transport.libs'
3
+
4
+ describe('normalizeAbortReason', () => {
5
+ test('passes bare strings through when valid', () => {
6
+ expect(normalizeAbortReason('pause')).toBe('pause')
7
+ expect(normalizeAbortReason('cancel')).toBe('cancel')
8
+ })
9
+
10
+ test('unknown bare strings degrade to "unknown"', () => {
11
+ expect(normalizeAbortReason('shutdown')).toBe('unknown')
12
+ expect(normalizeAbortReason(undefined)).toBe('unknown')
13
+ expect(normalizeAbortReason(null)).toBe('unknown')
14
+ })
15
+
16
+ test('extracts .reason and .kind from object payloads', () => {
17
+ expect(normalizeAbortReason({ reason: 'pause' })).toBe('pause')
18
+ expect(normalizeAbortReason({ kind: 'cancel' })).toBe('cancel')
19
+ })
20
+
21
+ test('arrays do not crash and degrade to unknown', () => {
22
+ expect(normalizeAbortReason(['pause'])).toBe('unknown')
23
+ })
24
+ })
25
+
26
+ describe('UploadAbortError / makeAbortError', () => {
27
+ test('exposes a stable code', () => {
28
+ const e = makeAbortError('pause')
29
+ expect(e).toBeInstanceOf(UploadAbortError)
30
+ expect(e.code).toBe('aborted')
31
+ expect(e.reason).toBe('pause')
32
+ })
33
+
34
+ test('defaults unknown reasons', () => {
35
+ expect(makeAbortError(undefined).reason).toBe('unknown')
36
+ })
37
+
38
+ test('structured payloads with a known reason still normalize to pause/cancel', () => {
39
+ expect(new UploadAbortError({ reason: 'cancel', extra: 'meta' }).reason).toBe('cancel')
40
+ expect(new UploadAbortError({ kind: 'pause' }).reason).toBe('pause')
41
+ })
42
+ })
@@ -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,152 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { UploadEngineError } from '../errors'
3
+
4
+ const MALICIOUS_FILENAME = '<img src=x onerror=alert(1)>.png'
5
+
6
+ describe('UploadEngineError', () => {
7
+ test('message is static, does NOT contain the raw filename', () => {
8
+ const err = new UploadEngineError('upload_failed', { context: { filename: MALICIOUS_FILENAME } })
9
+ expect(err.message).not.toContain(MALICIOUS_FILENAME)
10
+ expect(err.message).not.toContain('<img')
11
+ expect(err.message).toBe('upload transfer failed')
12
+ })
13
+
14
+ test('context.filename carries the raw filename intact', () => {
15
+ const err = new UploadEngineError('upload_failed', { context: { filename: MALICIOUS_FILENAME } })
16
+ expect(err.context?.['filename']).toBe(MALICIOUS_FILENAME)
17
+ })
18
+
19
+ test('is an instance of Error and UploadEngineError; code is preserved', () => {
20
+ const err = new UploadEngineError('intent_failed')
21
+ expect(err).toBeInstanceOf(Error)
22
+ expect(err).toBeInstanceOf(UploadEngineError)
23
+ expect(err.code).toBe('intent_failed')
24
+ expect(err.name).toBe('UploadEngineError')
25
+ })
26
+
27
+ test('cause is preserved on the ES2022 cause field', () => {
28
+ const original = new Error('boom')
29
+ const err = new UploadEngineError('upload_failed', { cause: original })
30
+ expect(err.cause).toBe(original)
31
+ })
32
+
33
+ test('unknown code falls back to a generic static message', () => {
34
+ const err = new UploadEngineError('something_new')
35
+ expect(err.message).toBe('upload error (something_new)')
36
+ expect(err.code).toBe('something_new')
37
+ })
38
+ })
39
+
40
+ describe('engine handlers filename-in-message inverse assertion', () => {
41
+ // The legacy contract was: `error.message.includes(filename)`. The new
42
+ // contract is the opposite: the filename MUST NOT appear in `message`. The
43
+ // handlers no longer interpolate filename into `message`; verifying that by
44
+ // exercising the handlers directly is covered by the integration suite, but
45
+ // we keep the contract pinned here so future refactors stay safe.
46
+ test('a structured error keeps the message clean and surfaces filename only on context', () => {
47
+ const base = { code: 'unknown' as const, message: 'Unknown error', retryable: false }
48
+ const error = {
49
+ ...base,
50
+ context: { filename: MALICIOUS_FILENAME, size: 12, purpose: 'avatar' },
51
+ }
52
+ expect(error.message).not.toContain(MALICIOUS_FILENAME)
53
+ expect(error.message).not.toContain('<img')
54
+ expect(error.context['filename']).toBe(MALICIOUS_FILENAME)
55
+ })
56
+ })
57
+
58
+ import {
59
+ UploadAbortError,
60
+ UploadAuthError,
61
+ UploadHttpError,
62
+ UploadNetworkError,
63
+ UploadRateLimitError,
64
+ UploadServerError,
65
+ UploadStrategyMissingError,
66
+ UploadTimeoutError,
67
+ UploadUnknownError,
68
+ UploadValidationError,
69
+ } from '../errors'
70
+
71
+ describe('UploadEngineError subclasses', () => {
72
+ test('UploadValidationError', () => {
73
+ const reason = { code: 'empty_file' as const }
74
+ const err = new UploadValidationError(reason)
75
+ expect(err).toBeInstanceOf(UploadEngineError)
76
+ expect(err.code).toBe('validation_failed')
77
+ expect(err.reason).toBe(reason)
78
+ expect(err.retryable).toBe(false)
79
+ })
80
+
81
+ test('UploadStrategyMissingError', () => {
82
+ const err = new UploadStrategyMissingError('custom')
83
+ expect(err).toBeInstanceOf(UploadEngineError)
84
+ expect(err.code).toBe('strategy_missing')
85
+ expect(err.strategy).toBe('custom')
86
+ expect(err.retryable).toBe(false)
87
+ })
88
+
89
+ test('UploadAbortError', () => {
90
+ const err = new UploadAbortError('pause')
91
+ expect(err).toBeInstanceOf(UploadEngineError)
92
+ expect(err.code).toBe('aborted')
93
+ expect(err.reason).toBe('pause')
94
+ expect(err.retryable).toBe(false)
95
+ })
96
+
97
+ test('UploadNetworkError', () => {
98
+ const err = new UploadNetworkError()
99
+ expect(err).toBeInstanceOf(UploadEngineError)
100
+ expect(err.code).toBe('network')
101
+ expect(err.retryable).toBe(true)
102
+ })
103
+
104
+ test('UploadHttpError', () => {
105
+ const err = new UploadHttpError(500, 'Internal Server Error', { statusText: 'Server Error' })
106
+ expect(err).toBeInstanceOf(UploadEngineError)
107
+ expect(err.code).toBe('http')
108
+ expect(err.status).toBe(500)
109
+ expect(err.statusText).toBe('Server Error')
110
+ expect(err.retryable).toBe(true)
111
+
112
+ const err400 = new UploadHttpError(400, 'Bad Request')
113
+ expect(err400.retryable).toBe(false)
114
+ })
115
+
116
+ test('UploadTimeoutError', () => {
117
+ const err = new UploadTimeoutError()
118
+ expect(err).toBeInstanceOf(UploadEngineError)
119
+ expect(err.code).toBe('timeout')
120
+ expect(err.retryable).toBe(true)
121
+ })
122
+
123
+ test('UploadAuthError', () => {
124
+ const err = new UploadAuthError()
125
+ expect(err).toBeInstanceOf(UploadEngineError)
126
+ expect(err.code).toBe('auth')
127
+ expect(err.retryable).toBe(false)
128
+ })
129
+
130
+ test('UploadRateLimitError', () => {
131
+ const err = new UploadRateLimitError('Too many requests', { retryAfterMs: 5000 })
132
+ expect(err).toBeInstanceOf(UploadEngineError)
133
+ expect(err.code).toBe('rate_limit')
134
+ expect(err.retryAfterMs).toBe(5000)
135
+ expect(err.retryable).toBe(true)
136
+ })
137
+
138
+ test('UploadServerError', () => {
139
+ const err = new UploadServerError('DB Error', { serverCode: 'DB_ERROR' })
140
+ expect(err).toBeInstanceOf(UploadEngineError)
141
+ expect(err.code).toBe('server')
142
+ expect(err.serverCode).toBe('DB_ERROR')
143
+ expect(err.retryable).toBe(true)
144
+ })
145
+
146
+ test('UploadUnknownError', () => {
147
+ const err = new UploadUnknownError('Something exploded')
148
+ expect(err).toBeInstanceOf(UploadEngineError)
149
+ expect(err.code).toBe('unknown')
150
+ expect(err.retryable).toBe(false)
151
+ })
152
+ })
@@ -0,0 +1,3 @@
1
+ export { resolveUploadConfig } from './engine/engine.libs'
2
+ export type { Store } from './engine/store'
3
+ export { createUploadStore } from './engine/store'