@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.
- package/README.md +170 -193
- package/dist/core/index.d.ts +2 -3
- package/dist/core/index.js +1 -1
- package/dist/core-BVgloTiP.js +10 -0
- package/dist/index-DCqyBVcx.d.ts +1441 -0
- package/dist/index-DsZHPmJm.d.ts +225 -0
- package/dist/index-KROXtLQh.d.ts +281 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +2 -51
- package/dist/react/index.js +1 -1
- package/dist/react-euPrDv24.js +1 -0
- package/dist/strategies/index.d.ts +2 -56
- package/dist/strategies/index.js +1 -1
- package/dist/strategies-CUF0s68j.js +1 -0
- package/package.json +3 -2
- package/src/{__test__ → __tests__}/emitter.test.ts +4 -4
- package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
- package/src/{__test__ → __tests__}/guards.test.ts +15 -5
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
- package/src/__tests__/sanitize-filename.test.ts +109 -0
- package/src/__tests__/sleep.test.ts +34 -0
- package/src/__tests__/static-analysis.test.ts +145 -0
- package/src/{__test__ → __tests__}/store-libs.test.ts +57 -3
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/{__test__ → __tests__}/transport.test.ts +5 -5
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +335 -141
- package/src/core/contracts/index.ts +3 -8
- package/src/core/contracts/transport/index.ts +3 -2
- package/src/core/contracts/transport/transport.fetch.ts +203 -0
- package/src/core/contracts/transport/transport.libs.ts +7 -8
- package/src/core/contracts/transport/transport.ts +91 -83
- package/src/core/contracts/transport/transport.types.ts +71 -35
- package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
- package/src/core/engine/engine.libs.ts +35 -0
- package/src/core/engine/engine.types.ts +367 -241
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +89 -43
- package/src/core/engine/store/handlers/add-file.ts +39 -29
- package/src/core/engine/store/handlers/cancel.ts +13 -8
- package/src/core/engine/store/handlers/clean-up.ts +11 -8
- package/src/core/engine/store/handlers/create-intent.ts +82 -27
- package/src/core/engine/store/handlers/finalize.ts +30 -13
- package/src/core/engine/store/handlers/index.ts +7 -7
- package/src/core/engine/store/handlers/pause.ts +8 -6
- package/src/core/engine/store/handlers/run-upload.ts +49 -34
- package/src/core/engine/store/index.ts +27 -21
- package/src/core/engine/store/store.dispatch.ts +10 -8
- package/src/core/engine/store/store.libs.ts +184 -41
- package/src/core/engine/store/store.runtime.ts +45 -36
- package/src/core/engine/store/store.schedule.ts +27 -21
- package/src/core/engine/store/store.types.ts +177 -227
- package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
- package/src/core/engine/validation/file.ts +7 -3
- package/src/core/engine/validation/index.ts +1 -0
- package/src/core/engine/validation/intent.ts +49 -27
- package/src/core/engine/validation/list.ts +14 -15
- package/src/core/engine/validation/mime.ts +39 -0
- package/src/core/errors.ts +271 -0
- package/src/core/index.ts +28 -5
- package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
- package/src/core/persistence/__tests__/persistence.test.ts +85 -0
- package/src/core/persistence/adapters.indexeddb.ts +9 -3
- package/src/core/persistence/adapters.local.ts +9 -3
- package/src/core/persistence/adapters.memory.ts +35 -13
- package/src/core/persistence/index.ts +5 -5
- package/src/core/persistence/persistence.ts +73 -60
- package/src/core/persistence/persistence.types.ts +140 -60
- package/src/core/utils/__tests__/guards.test.ts +93 -0
- package/src/core/utils/__tests__/url-safety.test.ts +112 -0
- package/src/core/utils/async.ts +7 -2
- package/src/core/utils/constants.ts +3 -9
- package/src/core/utils/emitter/emitter.ts +7 -24
- package/src/core/utils/emitter/emitter.types.ts +33 -16
- package/src/core/utils/emitter/index.ts +2 -74
- package/src/core/utils/fingerprint.ts +16 -5
- package/src/core/utils/guards.ts +91 -1
- package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
- package/src/core/utils/hash/index.ts +126 -0
- package/src/core/utils/hash/sha256.ts +179 -0
- package/src/core/utils/id.ts +33 -1
- package/src/core/utils/index.ts +15 -6
- package/src/core/utils/mime-sniff.ts +119 -0
- package/src/core/utils/sanitize-filename.ts +85 -0
- package/src/core/utils/url-safety.ts +156 -0
- package/src/react/__tests__/use-dropzone.test.ts +64 -0
- package/src/react/index.ts +5 -2
- package/src/react/upload-provider.tsx +68 -53
- package/src/react/uploader.types.ts +58 -61
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +98 -91
- package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
- package/src/strategies/_shared/retry.ts +79 -0
- package/src/strategies/index.ts +9 -15
- package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
- package/src/strategies/multipart/index.ts +206 -108
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +115 -33
- package/src/strategies/put/__tests__/put.test.ts +132 -0
- package/src/strategies/put/index.ts +139 -0
- package/src/strategies/registry/index.ts +12 -9
- package/src/strategies/tus/__tests__/tus.test.ts +159 -0
- package/src/strategies/tus/index.ts +211 -0
- package/dist/async-DxRWu4le.js +0 -1
- package/dist/core-CMkrP9DX.js +0 -2
- package/dist/index-C38Jmizu.d.ts +0 -1005
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- package/src/__test__/effect-pool.test.ts +0 -5
- package/src/__test__/id.test.ts +0 -14
- package/src/__test__/store-integration.test.ts +0 -5
- package/src/core/client/client.ts +0 -50
- package/src/core/client/client.types.ts +0 -184
- package/src/core/client/config.types.ts +0 -77
- package/src/core/client/index.ts +0 -4
- package/src/core/client/plugins.types.ts +0 -46
- package/src/core/client/retry.types.ts +0 -6
- package/src/core/client/uploadClient.ts +0 -19
- package/src/core/contracts/backendApi.ts +0 -62
- package/src/core/contracts/cursor.types.ts +0 -23
- package/src/core/contracts/errors.types.ts +0 -67
- package/src/core/contracts/fingerprint.types.ts +0 -18
- package/src/core/contracts/intent.types.ts +0 -39
- package/src/core/contracts/result.types.ts +0 -11
- package/src/core/contracts/strategy.types.ts +0 -45
- package/src/core/engine/commands.types.ts +0 -17
- package/src/core/engine/event-map.types.ts +0 -45
- package/src/core/engine/internal-events.types.ts +0 -202
- package/src/core/engine/outcome.types.ts +0 -9
- package/src/core/engine/progress.types.ts +0 -15
- package/src/core/engine/store/handlers.ts +0 -819
- package/src/core/engine/validation.ts +0 -216
- /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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
|
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 {
|
|
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 }>(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
59
|
-
|
|
34
|
+
const xhr = new XMLHttpRequest()
|
|
35
|
+
let settled = false
|
|
60
36
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
37
|
+
const settle = (fn: () => void) => {
|
|
38
|
+
if (settled) return
|
|
39
|
+
settled = true
|
|
40
|
+
cleanup()
|
|
41
|
+
fn()
|
|
42
|
+
}
|
|
66
43
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
44
|
+
const onAbort = () => {
|
|
45
|
+
try {
|
|
46
|
+
xhr.abort()
|
|
47
|
+
} finally {
|
|
48
|
+
settle(() => reject(makeAbortError(abortReason(args.signal))))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
71
51
|
|
|
72
|
-
|
|
52
|
+
const cleanup = () => {
|
|
53
|
+
if (args.signal) args.signal.removeEventListener('abort', onAbort)
|
|
73
54
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
55
|
+
xhr.upload.onprogress = null
|
|
56
|
+
xhr.onload = null
|
|
57
|
+
xhr.onerror = null
|
|
58
|
+
xhr.onabort = null
|
|
77
59
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
66
|
+
if (args.signal) args.signal.addEventListener('abort', onAbort, { once: true })
|
|
96
67
|
|
|
97
|
-
|
|
98
|
-
|
|
68
|
+
if (args.onProgress) {
|
|
69
|
+
xhr.upload.onprogress = (e) => {
|
|
70
|
+
if (e.lengthComputable) args.onProgress?.(e.loaded, e.total)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
99
73
|
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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():
|
|
113
|
+
export function createXHRTransport(): Transport.Options & {
|
|
117
114
|
/** Optional PATCH for tus-style strategies. */
|
|
118
|
-
patch?: (args:
|
|
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
|
-
*
|
|
3
|
-
*
|
|
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
|
|
5
|
+
export namespace Transport {
|
|
11
6
|
/**
|
|
12
|
-
*
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
76
|
+
signal?: AbortSignal
|
|
40
77
|
headers?: Record<string, string>
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
}
|