@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.
- package/README.md +297 -0
- 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 -90
- 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/__tests__/emitter.test.ts +85 -0
- package/src/__tests__/fingerprint.test.ts +49 -0
- package/src/__tests__/guards.test.ts +40 -0
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/__tests__/persistence.test.ts +191 -0
- 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/__tests__/store-libs.test.ts +192 -0
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/__tests__/transport-libs.test.ts +42 -0
- package/src/__tests__/transport.test.ts +182 -0
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +373 -0
- 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 +13 -39
- package/src/core/contracts/transport/transport.ts +96 -90
- 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 +490 -0
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +93 -53
- package/src/core/engine/store/handlers/add-file.ts +43 -48
- 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 +54 -39
- 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 +197 -94
- package/src/core/engine/store/store.runtime.ts +52 -53
- 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 +8 -4
- 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 +126 -0
- 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 +59 -95
- package/src/react/uploader.types.ts +80 -0
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +97 -93
- 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 +211 -116
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +113 -71
- 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-BNxznRif.js +0 -2
- package/dist/index-BXg9WIce.d.ts +0 -987
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- 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 -222
- package/src/core/engine/outcome.types.ts +0 -9
- 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
|
|
2
|
-
export
|
|
3
|
-
export
|
|
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
|
|
2
|
-
export
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
//
|
|
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()
|