@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,373 @@
1
+ /**
2
+ * Public contract types grouped under the `Contracts` namespace.
3
+ *
4
+ * Each sub-namespace owns one concern (Intent, Cursor, Result, Validation,
5
+ * Fingerprint, Errors, Strategy, BackendApi).
6
+ * @author wildduck2 <https://github.com/wildduck2>
7
+ */
8
+ export namespace Contracts {
9
+ export namespace Intent {
10
+ /** Base shape for all upload intents. */
11
+ export type Base<K extends string = string> = {
12
+ /** Strategy name (discriminant). */
13
+ strategy: K
14
+ /** Unique backend file identifier. */
15
+ fileId: string
16
+ }
17
+
18
+ /** Registry of intent types mapped by strategy name. */
19
+ export type Map = Record<string, Base<string>>
20
+
21
+ /** Union of strategy keys for an intent map. */
22
+ export type StrategyKey<M extends Map> = keyof M & string
23
+
24
+ /** Union of all intent variants for an intent map. */
25
+ export type Any<M extends Map> = M[StrategyKey<M>]
26
+ }
27
+
28
+ export namespace Cursor {
29
+ /** Registry of cursor payload types mapped by strategy name. */
30
+ export type Map<M extends Intent.Map> = Partial<Record<Intent.StrategyKey<M>, unknown>>
31
+
32
+ /** Discriminated union of all cursor variants. */
33
+ export type Any<C extends Record<string, unknown>> = {
34
+ [K in keyof C & string]: {
35
+ /** Strategy name associated with this cursor. */
36
+ strategy: K
37
+ /** The custom strategy-defined state needed to resume the upload. */
38
+ value?: C[K] | undefined
39
+ }
40
+ }[keyof C & string]
41
+ }
42
+
43
+ export namespace Result {
44
+ /** Successful upload payload returned by your backend API's complete endpoint. */
45
+ export type Base = {
46
+ /** Unique backend file identifier. */
47
+ fileId: string
48
+ /** Storage key/path or public address of the uploaded file. */
49
+ key: string
50
+ }
51
+ }
52
+
53
+ export namespace Validation {
54
+ /** Why a file was rejected during client-side validation. */
55
+ export type Rejection =
56
+ | { code: 'empty_file' }
57
+ | { code: 'file_too_large'; maxBytes: number; size: number }
58
+ | { code: 'type_not_allowed'; allowed: string[]; got: string }
59
+ | { code: 'too_many_files'; max: number }
60
+ | { code: 'mime_mismatch'; claimed: string; sniffed: string }
61
+ | { code: 'filename_rejected'; reason: 'reserved' | 'too-long' | 'empty' | 'path-sep' }
62
+ }
63
+
64
+ /** Deterministic identity signature of a file on the client. */
65
+ export type FingerprintFile = {
66
+ /** Base name of the file (excluding directory path). */
67
+ name: string
68
+ /** Size of the file in bytes. */
69
+ size: number
70
+ /** MIME type of the file. */
71
+ type: string
72
+ /** Timestamp when the file was last modified on disk (milliseconds since epoch). */
73
+ lastModified: number
74
+ /** Optional pre-computed checksum of the file (e.g. SHA-256 hex). */
75
+ checksum?: string | undefined
76
+ }
77
+
78
+ export namespace Errors {
79
+ /** Base schema for upload-related runtime errors. */
80
+ export type Base = {
81
+ /** Stable machine-readable error classification. */
82
+ code: string
83
+ /** Human-friendly message suitable for presentation in standard warning elements. */
84
+ message: string
85
+ /** Original thrown error or reason context if available. */
86
+ cause?: unknown
87
+ /** Flag specifying whether the error recovery system should schedule retries. */
88
+ retryable?: boolean
89
+ }
90
+
91
+ /** Discriminant union of built-in engine failure structures. */
92
+ export type BuiltIn =
93
+ | { code: 'validation_failed'; message: string; reason: Validation.Rejection; retryable?: false }
94
+ | { code: 'strategy_missing'; message: string; strategy: string; retryable?: false }
95
+ | { code: 'aborted'; message: string; reason: 'pause' | 'cancel' | 'unknown'; cause?: unknown; retryable?: false }
96
+ | { code: 'network'; message: string; cause?: unknown; retryable?: boolean }
97
+ | { code: 'http'; message: string; status: number; statusText?: string; cause?: unknown; retryable?: boolean }
98
+ | { code: 'timeout'; message: string; cause?: unknown; retryable?: boolean }
99
+ | { code: 'auth'; message: string; cause?: unknown; retryable?: false }
100
+ | { code: 'rate_limit'; message: string; retryAfterMs?: number; cause?: unknown; retryable?: boolean }
101
+ | { code: 'server'; message: string; serverCode?: string; cause?: unknown; retryable?: boolean }
102
+ | { code: 'unknown'; message: string; cause?: unknown; retryable?: boolean }
103
+
104
+ /** The canonical error shape handled by the engine, plugins, and handlers. */
105
+ export type Error = BuiltIn | (Base & Record<string, unknown>)
106
+ }
107
+
108
+ /** Validation rules applied to files on registration before intents are initiated. */
109
+ export type ValidationRules = {
110
+ /** Maximum number of files permitted in a single upload batch. */
111
+ maxFiles?: number
112
+ /** Maximum size in bytes allowed for a single file. */
113
+ maxSizeBytes?: number
114
+ /** Minimum size in bytes allowed for a single file. */
115
+ minSizeBytes?: number
116
+ /** White-list of allowed MIME types (e.g. `["image/png", "application/pdf"]`). */
117
+ allowedTypes?: string[]
118
+ /** White-list of allowed file extension strings (e.g. `["png", "pdf"]`). */
119
+ allowedExtensions?: string[]
120
+ }
121
+
122
+ /**
123
+ * Safe, cause-stripped error summary stored in step history and forwarded in API context.
124
+ * The raw `cause` field is omitted because it may contain sensitive server response bodies.
125
+ */
126
+ export type StepError = {
127
+ code: string
128
+ message: string
129
+ retryable?: boolean
130
+ }
131
+
132
+ /** A single recorded phase transition on an upload item. */
133
+ export type UploadStep = {
134
+ /** Phase name when this step was entered. */
135
+ phase: string
136
+ /** Epoch ms when this phase was entered. */
137
+ enteredAt: number
138
+ /** Epoch ms when this phase was left. Undefined while still in this phase. */
139
+ leftAt?: number
140
+ /** Attempt number at the time of entering this phase. */
141
+ attempt: number
142
+ /** Safe error summary if this phase ended in failure. Cause is stripped. */
143
+ error?: StepError | undefined
144
+ }
145
+
146
+ export namespace Api {
147
+ /** Context passed to every backend API method alongside the call-specific args. */
148
+ export type Context<P extends string = string, M extends Intent.Map = Intent.Map> = {
149
+ /** Client-side identifier for this file item. */
150
+ localId: string
151
+ /** Upload purpose. */
152
+ purpose: P
153
+ /** File identity: name, size, MIME type, checksum. Always present. */
154
+ fingerprint: FingerprintFile
155
+ /** Which attempt this is, starting at 1. Increments on retry. */
156
+ attempt: number
157
+ /** Epoch ms when this item was registered. */
158
+ createdAt: number
159
+ /** Phase history up to this call. Errors have cause stripped. */
160
+ steps: readonly UploadStep[]
161
+ /** Caller-supplied metadata from the addFiles command. Never persisted. */
162
+ meta: Record<string, unknown>
163
+ /** Safe error from the most recent failed step, if any. */
164
+ lastError?: StepError | undefined
165
+ /** Browser File object. Present in most phases; absent when file is not loaded. */
166
+ file?: File | undefined
167
+ /** Upload intent from the backend. Present after createIntent succeeds. */
168
+ intent?: Intent.Any<M> | undefined
169
+ /** Cancellation signal. */
170
+ signal?: AbortSignal | undefined
171
+ }
172
+
173
+ /** Context for API calls where intent is not yet created but file is loaded (e.g. createIntent) */
174
+ export type CreateIntentContext<P extends string, M extends Intent.Map> = Context<P, M> &
175
+ Required<Pick<Context<P, M>, 'file' | 'signal'>>
176
+
177
+ /** Context for API calls verifying file checksum (e.g. findByChecksum) */
178
+ export type FindByChecksumContext<P extends string, M extends Intent.Map> = Context<P, M> &
179
+ Required<Pick<Context<P, M>, 'file'>>
180
+
181
+ /** Context for API calls executing during/after upload where intent is active (e.g. complete, signPart, completeMultipart) */
182
+ export type CompleteContext<P extends string, M extends Intent.Map> = Context<P, M> &
183
+ Required<Pick<Context<P, M>, 'file' | 'intent' | 'signal'>>
184
+
185
+ /** Context for API abort calls where intent is active but file/signal might be absent (e.g. abort) */
186
+ export type AbortContext<P extends string, M extends Intent.Map> = Context<P, M> &
187
+ Required<Pick<Context<P, M>, 'intent'>>
188
+
189
+ /** Contract definition for your backend API endpoints. */
190
+ export type Me<M extends Intent.Map, P extends string, R extends Result.Base = Result.Base> = {
191
+ /** Requests an upload intent from the server. */
192
+ createIntent(
193
+ args: {
194
+ purpose: P
195
+ contentType: string
196
+ size: number
197
+ filename: string
198
+ checksum?: string
199
+ attempt: number
200
+ },
201
+ ctx: CreateIntentContext<P, M>,
202
+ ...extra: any[]
203
+ ): Promise<M[keyof M]>
204
+
205
+ /** Finalizes the upload on the server after bytes have been transferred. */
206
+ complete(
207
+ args: {
208
+ fileId: string
209
+ /** Sanitized filename. Do not use the raw File.name from the browser. */
210
+ filename: string
211
+ contentType: string
212
+ size: number
213
+ checksum?: string
214
+ attempt: number
215
+ },
216
+ ctx: CompleteContext<P, M>,
217
+ ...extra: any[]
218
+ ): Promise<R>
219
+
220
+ /** Optional endpoint to fetch a signed preview/access URL for completed resources. */
221
+ getSignedPreviewUrl?(
222
+ args: { fileId: string; key: string; purpose: P },
223
+ ctx: Context<P, M>,
224
+ ...extra: any[]
225
+ ): Promise<string>
226
+
227
+ /** Optional endpoint to verify if the file has already been uploaded previously. */
228
+ findByChecksum?(
229
+ args: { checksum: string; purpose: P },
230
+ ctx: FindByChecksumContext<P, M>,
231
+ ...extra: any[]
232
+ ): Promise<R | null>
233
+
234
+ /** Multipart-specific backend commands. Required if using the multipart strategy. */
235
+ multipart?: {
236
+ signPart(
237
+ args: {
238
+ fileId: string
239
+ uploadId: string
240
+ partNumber: number
241
+ checksum?: string
242
+ attempt: number
243
+ },
244
+ ctx: CompleteContext<P, M>,
245
+ ...extra: any[]
246
+ ): Promise<{
247
+ url: string
248
+ headers?: Record<string, string>
249
+ }>
250
+
251
+ completeMultipart(
252
+ args: {
253
+ fileId: string
254
+ uploadId: string
255
+ parts: Array<{ partNumber: number; etag: string }>
256
+ },
257
+ ctx: CompleteContext<P, M>,
258
+ ...extra: any[]
259
+ ): Promise<unknown>
260
+
261
+ listParts?(
262
+ args: { fileId: string; uploadId: string },
263
+ ctx: CompleteContext<P, M>,
264
+ ...extra: any[]
265
+ ): Promise<Array<{ partNumber: number; etag?: string; size?: number }>>
266
+
267
+ abort?(args: { fileId: string; uploadId: string }, ctx: AbortContext<P, M>, ...extra: any[]): Promise<void>
268
+ }
269
+
270
+ /** TUS resumable protocol operations. */
271
+ tus?: {
272
+ create(
273
+ args: { fileId: string; size: number; filename: string; contentType: string; attempt: number },
274
+ ctx: CompleteContext<P, M>,
275
+ ...extra: any[]
276
+ ): Promise<{ uploadUrl: string }>
277
+
278
+ getOffset(args: { uploadUrl: string }, ctx: CompleteContext<P, M>, ...extra: any[]): Promise<{ offset: number }>
279
+ }
280
+ }
281
+ }
282
+
283
+ export namespace Strategy {
284
+ /** Per-call context handed to a strategy's `start()` method. */
285
+ export type Ctx<
286
+ M extends Intent.Map,
287
+ C extends Cursor.Map<M>,
288
+ P extends string,
289
+ R extends Result.Base,
290
+ K extends keyof M & string,
291
+ > = {
292
+ localId: string
293
+ purpose: P
294
+ file: File
295
+ fingerprint: FingerprintFile
296
+ intent: M[K]
297
+ signal: AbortSignal
298
+ attempt: number
299
+ createdAt: number
300
+ steps: readonly UploadStep[]
301
+ lastError?: StepError | undefined
302
+ meta: Record<string, unknown>
303
+ transport: {
304
+ put(args: {
305
+ url: string
306
+ body: Blob
307
+ headers?: Record<string, string>
308
+ signal: AbortSignal
309
+ onProgress?: (u: number, t: number) => void
310
+ }): Promise<{ etag?: string | undefined; headers?: Record<string, string> }>
311
+
312
+ postForm(args: {
313
+ url: string
314
+ fields: Record<string, string>
315
+ file: File | Blob
316
+ filename?: string
317
+ signal: AbortSignal
318
+ onProgress?: (uploadedBytes: number, totalBytes: number) => void
319
+ }): Promise<{ etag?: string; headers?: Record<string, string> }>
320
+
321
+ patch(args: {
322
+ url: string
323
+ body: Blob | ArrayBuffer
324
+ headers?: Record<string, string>
325
+ signal: AbortSignal
326
+ onProgress?: (u: number, t: number) => void
327
+ }): Promise<{ headers?: Record<string, string> }>
328
+
329
+ get?(args: {
330
+ url: string
331
+ headers?: Record<string, string>
332
+ signal: AbortSignal
333
+ onProgress?: (loaded: number, total: number) => void
334
+ }): Promise<{ blob: Blob; status: number; headers?: Record<string, string> }>
335
+
336
+ head?(args: {
337
+ url: string
338
+ headers?: Record<string, string>
339
+ signal: AbortSignal
340
+ }): Promise<{ status: number; headers: Record<string, string> }>
341
+ }
342
+ api: Api.Me<M, P, R>
343
+ reportProgress: (p: { uploadedBytes: number; totalBytes: number }) => void
344
+ readCursor: () => C[K] | undefined
345
+ persistCursor: (cursor: C[K] | undefined) => void
346
+ }
347
+
348
+ /** A single upload strategy implementation. */
349
+ export type Me<
350
+ M extends Intent.Map,
351
+ C extends Cursor.Map<M>,
352
+ P extends string,
353
+ R extends Result.Base,
354
+ K extends keyof M & string,
355
+ > = {
356
+ id: K
357
+ resumable: boolean
358
+ start(ctx: Ctx<M, C, P, R, K>): Promise<void>
359
+ }
360
+
361
+ /** Registry of available upload strategies, keyed by strategy id. */
362
+ export type Registry<
363
+ M extends Intent.Map,
364
+ C extends Cursor.Map<M>,
365
+ P extends string = string,
366
+ R extends Result.Base = Result.Base,
367
+ > = {
368
+ get<K extends keyof M & string>(id: K): Me<M, C, P, R, K> | undefined
369
+ has(id: string): id is keyof M & string
370
+ set<K extends keyof M & string>(strategy: Me<M, C, P, R, K>): void
371
+ }
372
+ }
373
+ }
@@ -1,8 +1,3 @@
1
- export * from './backendApi'
2
- export * from './cursor.types'
3
- export * from './errors.types'
4
- export * from './fingerprint.types'
5
- export * from './intent.types'
6
- export * from './result.types'
7
- export * from './strategy.types'
8
- export * from './transport'
1
+ export type { Contracts } from './contracts.types'
2
+ export type { Transport } from './transport'
3
+ export { createFetchTransport, createXHRTransport } from './transport'
@@ -1,2 +1,3 @@
1
- export * from './transport'
2
- export * from './transport.types'
1
+ export { createXHRTransport } from './transport'
2
+ export { createFetchTransport } from './transport.fetch'
3
+ export type { Transport } from './transport.types'
@@ -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
+ }
@@ -1,44 +1,40 @@
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
- const v = r.reason
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
- const v = r.kind
25
+ const v = r['kind']
29
26
  if (v === 'pause' || v === 'cancel') return v
30
27
  }
31
28
 
32
29
  return 'unknown'
33
30
  }
34
31
 
35
- export class UploadAbortError extends Error {
36
- readonly code: 'aborted' = 'aborted'
37
- reason: 'pause' | 'cancel' | 'unknown'
32
+ import { UploadAbortError as CentralAbortError } from '../../errors'
38
33
 
34
+ export class UploadAbortError extends CentralAbortError {
39
35
  constructor(reason: unknown) {
40
- super('Upload aborted')
41
- this.reason = normalizeAbortReason(reason)
36
+ const normalized = normalizeAbortReason(reason)
37
+ super(normalized)
42
38
  }
43
39
  }
44
40
 
@@ -46,20 +42,9 @@ export function makeAbortError(reason: unknown): UploadAbortError {
46
42
  return new UploadAbortError(reason)
47
43
  }
48
44
 
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
- */
45
+ /** XHR network error with `status === 0` heuristic for CORS detection. */
61
46
  export function createNetworkError(xhr: XMLHttpRequest, defaultMessage: string): Error {
62
- // Status 0 typically indicates CORS failure or network error
47
+ // status 0 -> CORS failure or transport-level network error.
63
48
  if (xhr.status === 0) {
64
49
  return new Error(
65
50
  'CORS error: The upload server does not allow requests from this origin. Please check CORS configuration.',
@@ -68,18 +53,7 @@ export function createNetworkError(xhr: XMLHttpRequest, defaultMessage: string):
68
53
  return new Error(`${defaultMessage} (status: ${xhr.status})`)
69
54
  }
70
55
 
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
- */
56
+ /** Parse XHR headers into a lowercase-keyed record. */
83
57
  export function parseHeaders(xhr: XMLHttpRequest): Record<string, string> {
84
58
  const headers: Record<string, string> = {}
85
59
  const headerStr = xhr.getAllResponseHeaders()