@gentleduck/upload 0.2.6 → 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 (136) hide show
  1. package/README.md +170 -193
  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 -51
  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/{__test__ → __tests__}/emitter.test.ts +4 -4
  18. package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
  19. package/src/{__test__ → __tests__}/guards.test.ts +15 -5
  20. package/src/__tests__/id.test.ts +67 -0
  21. package/src/__tests__/mime-sniff.test.ts +142 -0
  22. package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
  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/{__test__ → __tests__}/store-libs.test.ts +57 -3
  27. package/src/__tests__/transport-fetch.test.ts +108 -0
  28. package/src/{__test__ → __tests__}/transport.test.ts +5 -5
  29. package/src/core/__tests__/errors.test.ts +152 -0
  30. package/src/core/client.ts +3 -0
  31. package/src/core/contracts/contracts.types.ts +335 -141
  32. package/src/core/contracts/index.ts +3 -8
  33. package/src/core/contracts/transport/index.ts +3 -2
  34. package/src/core/contracts/transport/transport.fetch.ts +203 -0
  35. package/src/core/contracts/transport/transport.libs.ts +7 -8
  36. package/src/core/contracts/transport/transport.ts +91 -83
  37. package/src/core/contracts/transport/transport.types.ts +71 -35
  38. package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
  39. package/src/core/engine/engine.libs.ts +35 -0
  40. package/src/core/engine/engine.types.ts +367 -241
  41. package/src/core/engine/index.ts +3 -8
  42. package/src/core/engine/reducer.ts +89 -43
  43. package/src/core/engine/store/handlers/add-file.ts +39 -29
  44. package/src/core/engine/store/handlers/cancel.ts +13 -8
  45. package/src/core/engine/store/handlers/clean-up.ts +11 -8
  46. package/src/core/engine/store/handlers/create-intent.ts +82 -27
  47. package/src/core/engine/store/handlers/finalize.ts +30 -13
  48. package/src/core/engine/store/handlers/index.ts +7 -7
  49. package/src/core/engine/store/handlers/pause.ts +8 -6
  50. package/src/core/engine/store/handlers/run-upload.ts +49 -34
  51. package/src/core/engine/store/index.ts +27 -21
  52. package/src/core/engine/store/store.dispatch.ts +10 -8
  53. package/src/core/engine/store/store.libs.ts +184 -41
  54. package/src/core/engine/store/store.runtime.ts +45 -36
  55. package/src/core/engine/store/store.schedule.ts +27 -21
  56. package/src/core/engine/store/store.types.ts +177 -227
  57. package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
  58. package/src/core/engine/validation/file.ts +7 -3
  59. package/src/core/engine/validation/index.ts +1 -0
  60. package/src/core/engine/validation/intent.ts +49 -27
  61. package/src/core/engine/validation/list.ts +14 -15
  62. package/src/core/engine/validation/mime.ts +39 -0
  63. package/src/core/errors.ts +271 -0
  64. package/src/core/index.ts +28 -5
  65. package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
  66. package/src/core/persistence/__tests__/persistence.test.ts +85 -0
  67. package/src/core/persistence/adapters.indexeddb.ts +9 -3
  68. package/src/core/persistence/adapters.local.ts +9 -3
  69. package/src/core/persistence/adapters.memory.ts +35 -13
  70. package/src/core/persistence/index.ts +5 -5
  71. package/src/core/persistence/persistence.ts +73 -60
  72. package/src/core/persistence/persistence.types.ts +140 -60
  73. package/src/core/utils/__tests__/guards.test.ts +93 -0
  74. package/src/core/utils/__tests__/url-safety.test.ts +112 -0
  75. package/src/core/utils/async.ts +7 -2
  76. package/src/core/utils/constants.ts +3 -9
  77. package/src/core/utils/emitter/emitter.ts +7 -24
  78. package/src/core/utils/emitter/emitter.types.ts +33 -16
  79. package/src/core/utils/emitter/index.ts +2 -74
  80. package/src/core/utils/fingerprint.ts +16 -5
  81. package/src/core/utils/guards.ts +91 -1
  82. package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
  83. package/src/core/utils/hash/index.ts +126 -0
  84. package/src/core/utils/hash/sha256.ts +179 -0
  85. package/src/core/utils/id.ts +33 -1
  86. package/src/core/utils/index.ts +15 -6
  87. package/src/core/utils/mime-sniff.ts +119 -0
  88. package/src/core/utils/sanitize-filename.ts +85 -0
  89. package/src/core/utils/url-safety.ts +156 -0
  90. package/src/react/__tests__/use-dropzone.test.ts +64 -0
  91. package/src/react/index.ts +5 -2
  92. package/src/react/upload-provider.tsx +68 -53
  93. package/src/react/uploader.types.ts +58 -61
  94. package/src/react/use-dropzone.tsx +226 -0
  95. package/src/react/use-uploader.tsx +98 -91
  96. package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
  97. package/src/strategies/_shared/retry.ts +79 -0
  98. package/src/strategies/index.ts +9 -15
  99. package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
  100. package/src/strategies/multipart/index.ts +206 -108
  101. package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
  102. package/src/strategies/post/index.ts +115 -33
  103. package/src/strategies/put/__tests__/put.test.ts +132 -0
  104. package/src/strategies/put/index.ts +139 -0
  105. package/src/strategies/registry/index.ts +12 -9
  106. package/src/strategies/tus/__tests__/tus.test.ts +159 -0
  107. package/src/strategies/tus/index.ts +211 -0
  108. package/dist/async-DxRWu4le.js +0 -1
  109. package/dist/core-CMkrP9DX.js +0 -2
  110. package/dist/index-C38Jmizu.d.ts +0 -1005
  111. package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
  112. package/src/__test__/effect-pool.test.ts +0 -5
  113. package/src/__test__/id.test.ts +0 -14
  114. package/src/__test__/store-integration.test.ts +0 -5
  115. package/src/core/client/client.ts +0 -50
  116. package/src/core/client/client.types.ts +0 -184
  117. package/src/core/client/config.types.ts +0 -77
  118. package/src/core/client/index.ts +0 -4
  119. package/src/core/client/plugins.types.ts +0 -46
  120. package/src/core/client/retry.types.ts +0 -6
  121. package/src/core/client/uploadClient.ts +0 -19
  122. package/src/core/contracts/backendApi.ts +0 -62
  123. package/src/core/contracts/cursor.types.ts +0 -23
  124. package/src/core/contracts/errors.types.ts +0 -67
  125. package/src/core/contracts/fingerprint.types.ts +0 -18
  126. package/src/core/contracts/intent.types.ts +0 -39
  127. package/src/core/contracts/result.types.ts +0 -11
  128. package/src/core/contracts/strategy.types.ts +0 -45
  129. package/src/core/engine/commands.types.ts +0 -17
  130. package/src/core/engine/event-map.types.ts +0 -45
  131. package/src/core/engine/internal-events.types.ts +0 -202
  132. package/src/core/engine/outcome.types.ts +0 -9
  133. package/src/core/engine/progress.types.ts +0 -15
  134. package/src/core/engine/store/handlers.ts +0 -819
  135. package/src/core/engine/validation.ts +0 -216
  136. /package/src/{__test__ → __tests__}/transport-libs.test.ts +0 -0
@@ -0,0 +1,203 @@
1
+ import { abortReason, makeAbortError } from './transport.libs'
2
+ import type { Transport } from './transport.types'
3
+
4
+ /**
5
+ * `fetch`-based transport for non-browser runtimes (Node, edge, workers, SSR)
6
+ * and any environment without `XMLHttpRequest`.
7
+ *
8
+ * Trade-off vs {@link createXHRTransport}: `fetch` has no portable upload
9
+ * progress API, so `put`/`postForm`/`patch` report a single terminal
10
+ * `onProgress(total, total)` on success rather than incremental bytes. Download
11
+ * (`get`) streams the response body, so its progress is real and incremental.
12
+ *
13
+ * @author wildduck2 <https://github.com/wildduck2>
14
+ */
15
+
16
+ function isAbsoluteHttpUrl(url: string): boolean {
17
+ return /^https?:\/\//i.test(url)
18
+ }
19
+
20
+ /** Read a single-quotes-stripped ETag from response headers. */
21
+ function readEtag(headers: Headers): string | undefined {
22
+ const raw = headers.get('etag') ?? headers.get('ETag')
23
+ if (!raw) return undefined
24
+ return raw.replace(/"/g, '')
25
+ }
26
+
27
+ /** Collect response headers into a lowercase-keyed record. */
28
+ function headersToRecord(headers: Headers): Record<string, string> {
29
+ const out: Record<string, string> = {}
30
+ headers.forEach((value, key) => {
31
+ out[key.toLowerCase()] = value
32
+ })
33
+ return out
34
+ }
35
+
36
+ async function fetchRequest(args: {
37
+ method: 'POST' | 'PUT' | 'PATCH'
38
+ url: string
39
+ body: BodyInit
40
+ signal: AbortSignal
41
+ headers?: Record<string, string> | undefined
42
+ onProgress?: ((loaded: number, total: number) => void) | undefined
43
+ byteLength: number
44
+ }): Promise<{ headers: Record<string, string>; etag?: string | undefined }> {
45
+ if (!args.url || !isAbsoluteHttpUrl(args.url)) {
46
+ throw new Error(`UploadTransport(fetch): expected absolute URL, got: ${String(args.url)}`)
47
+ }
48
+ if (args.signal.aborted) {
49
+ throw makeAbortError(abortReason(args.signal))
50
+ }
51
+
52
+ const init: RequestInit = { method: args.method, body: args.body, signal: args.signal }
53
+ if (args.headers) init.headers = args.headers
54
+
55
+ let res: Response
56
+ try {
57
+ res = await fetch(args.url, init)
58
+ } catch (err) {
59
+ if (args.signal.aborted || (err instanceof Error && err.name === 'AbortError')) {
60
+ throw makeAbortError(abortReason(args.signal))
61
+ }
62
+ throw err instanceof Error ? err : new Error(String(err))
63
+ }
64
+
65
+ if (res.status < 200 || res.status >= 300) {
66
+ throw new Error(`Upload failed with status ${res.status}`)
67
+ }
68
+
69
+ // fetch cannot report streamed upload progress portably; emit a single
70
+ // terminal tick so consumers can settle their progress bars.
71
+ args.onProgress?.(args.byteLength, args.byteLength)
72
+
73
+ return { headers: headersToRecord(res.headers), etag: readEtag(res.headers) }
74
+ }
75
+
76
+ /**
77
+ * Create a `fetch`-backed {@link Transport.Options} implementation.
78
+ * @author wildduck2 <https://github.com/wildduck2>
79
+ */
80
+ export function createFetchTransport(): Transport.Options {
81
+ return {
82
+ async postForm(args) {
83
+ const form = new FormData()
84
+ for (const [k, v] of Object.entries(args.fields)) form.append(k, v)
85
+ form.append('file', args.file, args.filename ?? 'file')
86
+
87
+ const out = await fetchRequest({
88
+ method: 'POST',
89
+ url: args.url,
90
+ body: form,
91
+ signal: args.signal,
92
+ byteLength: args.file.size,
93
+ onProgress: args.onProgress,
94
+ })
95
+ return out.etag !== undefined ? { headers: out.headers, etag: out.etag } : { headers: out.headers }
96
+ },
97
+
98
+ async put(args) {
99
+ const out = await fetchRequest({
100
+ method: 'PUT',
101
+ url: args.url,
102
+ body: args.body,
103
+ signal: args.signal,
104
+ headers: args.headers ?? {},
105
+ byteLength: args.body.size,
106
+ onProgress: args.onProgress,
107
+ })
108
+ return { headers: out.headers, etag: out.etag }
109
+ },
110
+
111
+ async patch(args) {
112
+ const body = args.body instanceof Blob ? args.body : new Blob([args.body])
113
+ const out = await fetchRequest({
114
+ method: 'PATCH',
115
+ url: args.url,
116
+ body,
117
+ signal: args.signal,
118
+ headers: args.headers ?? {},
119
+ byteLength: body.size,
120
+ onProgress: args.onProgress,
121
+ })
122
+ return { headers: out.headers }
123
+ },
124
+
125
+ async get(args) {
126
+ if (!args.url || !isAbsoluteHttpUrl(args.url)) {
127
+ throw new Error(`UploadTransport(fetch): expected absolute URL, got: ${String(args.url)}`)
128
+ }
129
+ if (args.signal.aborted) {
130
+ throw makeAbortError(abortReason(args.signal))
131
+ }
132
+
133
+ const init: RequestInit = { method: 'GET', signal: args.signal }
134
+ if (args.headers) init.headers = args.headers
135
+
136
+ let res: Response
137
+ try {
138
+ res = await fetch(args.url, init)
139
+ } catch (err) {
140
+ if (args.signal.aborted || (err instanceof Error && err.name === 'AbortError')) {
141
+ throw makeAbortError(abortReason(args.signal))
142
+ }
143
+ throw err instanceof Error ? err : new Error(String(err))
144
+ }
145
+
146
+ if (res.status < 200 || res.status >= 300) {
147
+ throw new Error(`Download failed with status ${res.status}`)
148
+ }
149
+
150
+ const headers = headersToRecord(res.headers)
151
+ const total = Number(res.headers.get('content-length')) || 0
152
+
153
+ // Stream when possible for incremental progress; otherwise fall back to a
154
+ // buffered read.
155
+ if (res.body && typeof res.body.getReader === 'function') {
156
+ const reader = res.body.getReader()
157
+ const chunks: Uint8Array[] = []
158
+ let loaded = 0
159
+ for (;;) {
160
+ const { done, value } = await reader.read()
161
+ if (done) break
162
+ if (value) {
163
+ chunks.push(value)
164
+ loaded += value.byteLength
165
+ args.onProgress?.(loaded, total || loaded)
166
+ }
167
+ }
168
+ const blob = new Blob(chunks as BlobPart[], {
169
+ type: res.headers.get('content-type') ?? 'application/octet-stream',
170
+ })
171
+ return { blob, status: res.status, headers }
172
+ }
173
+
174
+ const blob = await res.blob()
175
+ args.onProgress?.(blob.size, total || blob.size)
176
+ return { blob, status: res.status, headers }
177
+ },
178
+
179
+ async head(args) {
180
+ if (!args.url || !isAbsoluteHttpUrl(args.url)) {
181
+ throw new Error(`UploadTransport(fetch): expected absolute URL, got: ${String(args.url)}`)
182
+ }
183
+ if (args.signal.aborted) {
184
+ throw makeAbortError(abortReason(args.signal))
185
+ }
186
+
187
+ const init: RequestInit = { method: 'HEAD', signal: args.signal }
188
+ if (args.headers) init.headers = args.headers
189
+
190
+ let res: Response
191
+ try {
192
+ res = await fetch(args.url, init)
193
+ } catch (err) {
194
+ if (args.signal.aborted || (err instanceof Error && err.name === 'AbortError')) {
195
+ throw makeAbortError(abortReason(args.signal))
196
+ }
197
+ throw err instanceof Error ? err : new Error(String(err))
198
+ }
199
+
200
+ return { status: res.status, headers: headersToRecord(res.headers) }
201
+ },
202
+ }
203
+ }
@@ -17,25 +17,24 @@ export function normalizeAbortReason(r: unknown): 'pause' | 'cancel' | 'unknown'
17
17
  }
18
18
 
19
19
  if (isRecord(r) && 'reason' in r) {
20
- const v = r.reason
20
+ const v = r['reason']
21
21
  if (v === 'pause' || v === 'cancel') return v
22
22
  }
23
23
 
24
24
  if (isRecord(r) && 'kind' in r) {
25
- const v = r.kind
25
+ const v = r['kind']
26
26
  if (v === 'pause' || v === 'cancel') return v
27
27
  }
28
28
 
29
29
  return 'unknown'
30
30
  }
31
31
 
32
- export class UploadAbortError extends Error {
33
- readonly code: 'aborted' = 'aborted'
34
- reason: 'pause' | 'cancel' | 'unknown'
32
+ import { UploadAbortError as CentralAbortError } from '../../errors'
35
33
 
34
+ export class UploadAbortError extends CentralAbortError {
36
35
  constructor(reason: unknown) {
37
- super('Upload aborted')
38
- this.reason = normalizeAbortReason(reason)
36
+ const normalized = normalizeAbortReason(reason)
37
+ super(normalized)
39
38
  }
40
39
  }
41
40
 
@@ -45,7 +44,7 @@ export function makeAbortError(reason: unknown): UploadAbortError {
45
44
 
46
45
  /** XHR network error with `status === 0` heuristic for CORS detection. */
47
46
  export function createNetworkError(xhr: XMLHttpRequest, defaultMessage: string): Error {
48
- // status 0 → CORS failure or transport-level network error.
47
+ // status 0 -> CORS failure or transport-level network error.
49
48
  if (xhr.status === 0) {
50
49
  return new Error(
51
50
  'CORS error: The upload server does not allow requests from this origin. Please check CORS configuration.',
@@ -1,12 +1,5 @@
1
1
  import { abortReason, createNetworkError, makeAbortError, parseHeaders } from './transport.libs'
2
- import type { UploadTransport } from './transport.types'
3
-
4
- type XhrCommonArgs = {
5
- url: string
6
- signal?: AbortSignal
7
- headers?: Record<string, string>
8
- onProgress?: (loaded: number, total: number) => void
9
- }
2
+ import type { Transport } from './transport.types'
10
3
 
11
4
  function isAbsoluteHttpUrl(url: string) {
12
5
  return /^https?:\/\//i.test(url)
@@ -20,102 +13,106 @@ function readEtag(xhr: XMLHttpRequest) {
20
13
  }
21
14
 
22
15
  function xhrRequest(args: {
23
- method: 'POST' | 'PUT' | 'PATCH'
16
+ method: 'POST' | 'PUT' | 'PATCH' | 'HEAD'
24
17
  url: string
25
18
  body: Document | BodyInit | null
26
19
  signal?: AbortSignal
27
20
  headers?: Record<string, string>
28
21
  onProgress?: (loaded: number, total: number) => void
29
22
  }) {
30
- return new Promise<{ headers: Record<string, string>; etag?: string }>((resolve, reject) => {
31
- if (!args.url || typeof args.url !== 'string') {
32
- reject(new Error(`UploadTransport: missing url`))
33
- return
34
- }
35
- if (!isAbsoluteHttpUrl(args.url)) {
36
- reject(new Error(`UploadTransport: expected absolute URL, got: ${String(args.url)}`))
37
- return
38
- }
39
-
40
- const xhr = new XMLHttpRequest()
41
- let settled = false
42
-
43
- const settle = (fn: () => void) => {
44
- if (settled) return
45
- settled = true
46
- cleanup()
47
- fn()
48
- }
49
-
50
- const onAbort = () => {
51
- try {
52
- xhr.abort()
53
- } finally {
54
- settle(() => reject(makeAbortError(abortReason(args.signal))))
23
+ return new Promise<{ status: number; headers: Record<string, string>; etag?: string | undefined }>(
24
+ (resolve, reject) => {
25
+ if (!args.url || typeof args.url !== 'string') {
26
+ reject(new Error(`UploadTransport: missing url`))
27
+ return
28
+ }
29
+ if (!isAbsoluteHttpUrl(args.url)) {
30
+ reject(new Error(`UploadTransport: expected absolute URL, got: ${String(args.url)}`))
31
+ return
55
32
  }
56
- }
57
33
 
58
- const cleanup = () => {
59
- if (args.signal) args.signal.removeEventListener('abort', onAbort)
34
+ const xhr = new XMLHttpRequest()
35
+ let settled = false
60
36
 
61
- xhr.upload.onprogress = null
62
- xhr.onload = null
63
- xhr.onerror = null
64
- xhr.onabort = null
65
- }
37
+ const settle = (fn: () => void) => {
38
+ if (settled) return
39
+ settled = true
40
+ cleanup()
41
+ fn()
42
+ }
66
43
 
67
- if (args.signal?.aborted) {
68
- settle(() => reject(makeAbortError(abortReason(args.signal))))
69
- return
70
- }
44
+ const onAbort = () => {
45
+ try {
46
+ xhr.abort()
47
+ } finally {
48
+ settle(() => reject(makeAbortError(abortReason(args.signal))))
49
+ }
50
+ }
71
51
 
72
- if (args.signal) args.signal.addEventListener('abort', onAbort, { once: true })
52
+ const cleanup = () => {
53
+ if (args.signal) args.signal.removeEventListener('abort', onAbort)
73
54
 
74
- if (args.onProgress) {
75
- xhr.upload.onprogress = (e) => {
76
- if (e.lengthComputable) args.onProgress?.(e.loaded, e.total)
55
+ xhr.upload.onprogress = null
56
+ xhr.onload = null
57
+ xhr.onerror = null
58
+ xhr.onabort = null
77
59
  }
78
- }
79
-
80
- xhr.onload = () => {
81
- const status = xhr.status
82
- // S3 multipart UploadPart returns 200, tus PATCH returns 204 typically.
83
- if (status >= 200 && status < 300) {
84
- settle(() =>
85
- resolve({
86
- headers: parseHeaders(xhr),
87
- etag: readEtag(xhr),
88
- }),
89
- )
90
- } else {
91
- settle(() => reject(new Error(`Upload failed with status ${status}`)))
60
+
61
+ if (args.signal?.aborted) {
62
+ settle(() => reject(makeAbortError(abortReason(args.signal))))
63
+ return
92
64
  }
93
- }
94
65
 
95
- xhr.onerror = () => settle(() => reject(createNetworkError(xhr, 'Network error during upload')))
66
+ if (args.signal) args.signal.addEventListener('abort', onAbort, { once: true })
96
67
 
97
- xhr.onabort = () =>
98
- settle(() => reject(makeAbortError(args.signal?.aborted ? abortReason(args.signal) : 'unknown')))
68
+ if (args.onProgress) {
69
+ xhr.upload.onprogress = (e) => {
70
+ if (e.lengthComputable) args.onProgress?.(e.loaded, e.total)
71
+ }
72
+ }
99
73
 
100
- xhr.open(args.method, args.url)
74
+ xhr.onload = () => {
75
+ const status = xhr.status
76
+ // S3 multipart UploadPart returns 200, tus PATCH returns 204 typically.
77
+ if (status >= 200 && status < 300) {
78
+ settle(() =>
79
+ resolve({
80
+ status,
81
+ headers: parseHeaders(xhr),
82
+ etag: readEtag(xhr),
83
+ }),
84
+ )
85
+ } else {
86
+ settle(() => reject(new Error(`Upload failed with status ${status}`)))
87
+ }
88
+ }
89
+
90
+ xhr.onerror = () => settle(() => reject(createNetworkError(xhr, 'Network error during upload')))
91
+
92
+ xhr.onabort = () =>
93
+ settle(() => reject(makeAbortError(args.signal?.aborted ? abortReason(args.signal) : 'unknown')))
101
94
 
102
- if (args.headers) {
103
- for (const [k, v] of Object.entries(args.headers)) {
104
- xhr.setRequestHeader(k, v)
95
+ xhr.open(args.method, args.url)
96
+
97
+ if (args.headers) {
98
+ for (const [k, v] of Object.entries(args.headers)) {
99
+ xhr.setRequestHeader(k, v)
100
+ }
105
101
  }
106
- }
107
102
 
108
- xhr.send(args.body as XMLHttpRequestBodyInit | Document | null)
109
- })
103
+ xhr.send(args.body as XMLHttpRequestBodyInit | Document | null)
104
+ },
105
+ )
110
106
  }
111
107
 
112
108
  /**
113
109
  * Browser-native XHR transport. Uses `XMLHttpRequest` over `fetch` because
114
110
  * `fetch` lacks cross-browser upload-progress events.
111
+ * @author wildduck2 <https://github.com/wildduck2>
115
112
  */
116
- export function createXHRTransport(): UploadTransport & {
113
+ export function createXHRTransport(): Transport.Options & {
117
114
  /** Optional PATCH for tus-style strategies. */
118
- patch?: (args: XhrCommonArgs & { body: Blob | ArrayBuffer }) => Promise<{ headers: Record<string, string> }>
115
+ patch?: (args: Transport.XhrArgs & { body: Blob | ArrayBuffer }) => Promise<{ headers: Record<string, string> }>
119
116
  } {
120
117
  return {
121
118
  async postForm(args) {
@@ -129,7 +126,7 @@ export function createXHRTransport(): UploadTransport & {
129
126
  url: args.url,
130
127
  body: form,
131
128
  signal: args.signal,
132
- onProgress: args.onProgress,
129
+ onProgress: args.onProgress ?? (() => {}),
133
130
  })
134
131
 
135
132
  return { headers: out.headers }
@@ -141,8 +138,8 @@ export function createXHRTransport(): UploadTransport & {
141
138
  url: args.url,
142
139
  body: args.body,
143
140
  signal: args.signal,
144
- headers: args.headers,
145
- onProgress: args.onProgress,
141
+ headers: args.headers ?? {},
142
+ onProgress: args.onProgress ?? (() => {}),
146
143
  })
147
144
 
148
145
  // S3 multipart needs the ETag from each part response.
@@ -154,11 +151,22 @@ export function createXHRTransport(): UploadTransport & {
154
151
  method: 'PATCH',
155
152
  url: args.url,
156
153
  body: args.body,
157
- signal: args.signal,
158
- headers: args.headers,
159
- onProgress: args.onProgress,
154
+ signal: args.signal ?? new AbortController().signal,
155
+ headers: args.headers ?? {},
156
+ onProgress: args.onProgress ?? (() => {}),
160
157
  })
161
158
  return { headers: out.headers }
162
159
  },
160
+
161
+ async head(args) {
162
+ const out = await xhrRequest({
163
+ method: 'HEAD',
164
+ url: args.url,
165
+ body: null,
166
+ signal: args.signal,
167
+ headers: args.headers ?? {},
168
+ })
169
+ return { status: out.status, headers: out.headers }
170
+ },
163
171
  }
164
172
  }
@@ -1,44 +1,80 @@
1
1
  /**
2
- * Abstraction layer for network operations.
3
- * Allows switching between XHR (browser) or other implementations (testing/Node).
4
- *
5
- * Designed to support:
6
- * - Deterministic progress tracking (requires XHR in browsers)
7
- * - Cancellation via AbortSignal
8
- * - Header parsing (ETag extraction)
2
+ * Transport contracts: the network layer the engine uses to move bytes.
3
+ * @author wildduck2 <https://github.com/wildduck2>
9
4
  */
10
- export interface UploadTransport {
5
+ export namespace Transport {
11
6
  /**
12
- * Performs an HTTP PUT request (typically for S3 signed URLs).
7
+ * Abstraction layer for network operations.
8
+ * Allows switching between XHR (browser) or other implementations (testing/Node).
9
+ *
10
+ * Designed to support:
11
+ * - Deterministic progress tracking (requires XHR in browsers)
12
+ * - Cancellation via AbortSignal
13
+ * - Header parsing (ETag extraction)
13
14
  */
14
- put(args: {
15
- url: string
16
- body: Blob
17
- headers?: Record<string, string>
18
- signal: AbortSignal
19
- onProgress?: (u: number, t: number) => void
20
- }): Promise<{ etag?: string; headers?: Record<string, string> }>
15
+ export type Options = {
16
+ /**
17
+ * Performs an HTTP PUT request (typically for S3 signed URLs).
18
+ */
19
+ put(args: {
20
+ url: string
21
+ body: Blob
22
+ headers?: Record<string, string>
23
+ signal: AbortSignal
24
+ onProgress?: (u: number, t: number) => void
25
+ }): Promise<{ etag?: string | undefined; headers?: Record<string, string> }>
21
26
 
22
- /**
23
- * Performs an HTTP POST multipart request.
24
- */
25
- postForm(args: {
26
- url: string
27
- fields: Record<string, string>
28
- file: File | Blob
29
- filename?: string
30
- signal: AbortSignal
31
- onProgress?: (uploadedBytes: number, totalBytes: number) => void
32
- }): Promise<{ etag?: string; headers?: Record<string, string> }>
27
+ /**
28
+ * Performs an HTTP POST multipart request.
29
+ */
30
+ postForm(args: {
31
+ url: string
32
+ fields: Record<string, string>
33
+ file: File | Blob
34
+ filename?: string
35
+ signal: AbortSignal
36
+ onProgress?: (uploadedBytes: number, totalBytes: number) => void
37
+ }): Promise<{ etag?: string; headers?: Record<string, string> }>
33
38
 
34
- /**
35
- * Performs an HTTP request.
36
- */
37
- patch(args: {
39
+ /**
40
+ * Performs an HTTP request.
41
+ */
42
+ patch(args: {
43
+ url: string
44
+ body: Blob | ArrayBuffer
45
+ headers?: Record<string, string>
46
+ signal: AbortSignal
47
+ onProgress?: (u: number, t: number) => void
48
+ }): Promise<{ headers?: Record<string, string> }>
49
+
50
+ /**
51
+ * Optional HTTP GET download. Streams the response so `onProgress` can
52
+ * report bytes as they arrive. Not every transport implements it — check
53
+ * for presence before calling.
54
+ */
55
+ get?(args: {
56
+ url: string
57
+ headers?: Record<string, string>
58
+ signal: AbortSignal
59
+ onProgress?: (loaded: number, total: number) => void
60
+ }): Promise<{ blob: Blob; status: number; headers?: Record<string, string> }>
61
+
62
+ /**
63
+ * Optional HTTP HEAD. Used by resumable strategies (tus) to read the
64
+ * server's current `Upload-Offset` before resuming. Not every transport
65
+ * implements it — check for presence before calling.
66
+ */
67
+ head?(args: {
68
+ url: string
69
+ headers?: Record<string, string>
70
+ signal: AbortSignal
71
+ }): Promise<{ status: number; headers: Record<string, string> }>
72
+ }
73
+
74
+ export type XhrArgs = {
38
75
  url: string
39
- body: Blob | ArrayBuffer
76
+ signal?: AbortSignal
40
77
  headers?: Record<string, string>
41
- signal: AbortSignal
42
- onProgress?: (u: number, t: number) => void
43
- }): Promise<{ headers?: Record<string, string> }>
78
+ onProgress?: (loaded: number, total: number) => void
79
+ }
44
80
  }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { resolveUploadConfig } from '../engine.libs'
3
+
4
+ describe('resolveUploadConfig - NaN-bypass defense', () => {
5
+ test('NaN maxAttempts falls back to default (does NOT propagate NaN)', () => {
6
+ const cfg = resolveUploadConfig({ maxAttempts: Number.NaN })
7
+ expect(Number.isFinite(cfg.maxAttempts)).toBe(true)
8
+ expect(cfg.maxAttempts).toBeGreaterThanOrEqual(1)
9
+ })
10
+
11
+ test('Infinity maxAttempts falls back to default (no infinite retry loop)', () => {
12
+ const cfg = resolveUploadConfig({ maxAttempts: Number.POSITIVE_INFINITY })
13
+ expect(Number.isFinite(cfg.maxAttempts)).toBe(true)
14
+ })
15
+
16
+ test('NaN maxConcurrentUploads falls back', () => {
17
+ const cfg = resolveUploadConfig({ maxConcurrentUploads: Number.NaN })
18
+ expect(Number.isFinite(cfg.maxConcurrentUploads)).toBe(true)
19
+ })
20
+
21
+ test('Infinity progressThrottleMs falls back (no scheduler wedge)', () => {
22
+ const cfg = resolveUploadConfig({ progressThrottleMs: Number.POSITIVE_INFINITY })
23
+ expect(Number.isFinite(cfg.progressThrottleMs)).toBe(true)
24
+ })
25
+
26
+ test('NaN maxItems falls back', () => {
27
+ const cfg = resolveUploadConfig({ maxItems: Number.NaN })
28
+ expect(Number.isFinite(cfg.maxItems)).toBe(true)
29
+ })
30
+
31
+ test('negative maxAttempts clamps to 1', () => {
32
+ const cfg = resolveUploadConfig({ maxAttempts: -5 })
33
+ expect(cfg.maxAttempts).toBe(1)
34
+ })
35
+
36
+ test('finite positive maxAttempts is preserved', () => {
37
+ const cfg = resolveUploadConfig({ maxAttempts: 7 })
38
+ expect(cfg.maxAttempts).toBe(7)
39
+ })
40
+ })
@@ -0,0 +1,35 @@
1
+ import {
2
+ DEFAULT_MAX_ATTEMPTS,
3
+ DEFAULT_MAX_CONCURRENT_UPLOADS,
4
+ DEFAULT_MAX_ITEMS,
5
+ DEFAULT_PROGRESS_THROTTLE_MS,
6
+ } from '../utils/constants'
7
+ import type { Engine } from './engine.types'
8
+
9
+ /**
10
+ * Clamp a numeric config field, rejecting non-finite values (NaN, Infinity)
11
+ * that would otherwise slip past a plain `Math.max`.
12
+ */
13
+ function finitePositive(input: number | undefined, fallback: number, min = 1): number {
14
+ if (typeof input !== 'number' || !Number.isFinite(input)) return fallback
15
+ return Math.max(min, input)
16
+ }
17
+
18
+ /**
19
+ * Normalizes user config by applying sensible defaults.
20
+ * @author wildduck2 <https://github.com/wildduck2>
21
+ */
22
+ export function resolveUploadConfig<P extends string>(input?: Partial<Engine.Config<P>>): Engine.Config<P> {
23
+ return {
24
+ maxConcurrentUploads: finitePositive(input?.maxConcurrentUploads, DEFAULT_MAX_CONCURRENT_UPLOADS),
25
+ autoStart: input?.autoStart,
26
+ progressThrottleMs: finitePositive(input?.progressThrottleMs, DEFAULT_PROGRESS_THROTTLE_MS, 0),
27
+ validation: input?.validation ?? {},
28
+ maxAttempts: finitePositive(input?.maxAttempts, DEFAULT_MAX_ATTEMPTS),
29
+ retryPolicy: input?.retryPolicy,
30
+ maxItems: finitePositive(input?.maxItems, DEFAULT_MAX_ITEMS),
31
+ completedItemTTL: input?.completedItemTTL,
32
+ strictMimeMatch: input?.strictMimeMatch ?? false,
33
+ checksumMaxSize: input?.checksumMaxSize ?? null,
34
+ }
35
+ }