@gentleduck/upload 0.2.1
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/dist/index.d.ts +1417 -0
- package/dist/index.js +6 -0
- package/package.json +43 -0
- package/src/core/client/config.types.ts +77 -0
- package/src/core/client/index.ts +4 -0
- package/src/core/client/plugins.types.ts +46 -0
- package/src/core/client/retry.types.ts +6 -0
- package/src/core/client/uploadClient.ts +19 -0
- package/src/core/contracts/backendApi.ts +62 -0
- package/src/core/contracts/cursor.types.ts +23 -0
- package/src/core/contracts/errors.types.ts +67 -0
- package/src/core/contracts/fingerprint.types.ts +18 -0
- package/src/core/contracts/index.ts +8 -0
- package/src/core/contracts/intent.types.ts +39 -0
- package/src/core/contracts/result.types.ts +11 -0
- package/src/core/contracts/strategy.types.ts +45 -0
- package/src/core/contracts/transport/index.ts +2 -0
- package/src/core/contracts/transport/transport.libs.ts +100 -0
- package/src/core/contracts/transport/transport.ts +166 -0
- package/src/core/contracts/transport/transport.types.ts +44 -0
- package/src/core/engine/commands.types.ts +17 -0
- package/src/core/engine/event-map.types.ts +45 -0
- package/src/core/engine/index.ts +8 -0
- package/src/core/engine/internal-events.types.ts +222 -0
- package/src/core/engine/outcome.types.ts +9 -0
- package/src/core/engine/progress.types.ts +15 -0
- package/src/core/engine/reducer.ts +435 -0
- package/src/core/engine/store/handlers/add-file.ts +103 -0
- package/src/core/engine/store/handlers/cancel.ts +56 -0
- package/src/core/engine/store/handlers/clean-up.ts +66 -0
- package/src/core/engine/store/handlers/create-intent.ts +76 -0
- package/src/core/engine/store/handlers/finalize.ts +59 -0
- package/src/core/engine/store/handlers/index.ts +7 -0
- package/src/core/engine/store/handlers/pause.ts +16 -0
- package/src/core/engine/store/handlers/run-upload.ts +192 -0
- package/src/core/engine/store/index.ts +123 -0
- package/src/core/engine/store/store.dispatch.ts +78 -0
- package/src/core/engine/store/store.libs.ts +169 -0
- package/src/core/engine/store/store.runtime.ts +336 -0
- package/src/core/engine/store/store.schedule.ts +116 -0
- package/src/core/engine/store/store.types.ts +250 -0
- package/src/core/engine/validation/file.ts +77 -0
- package/src/core/engine/validation/index.ts +3 -0
- package/src/core/engine/validation/intent.ts +55 -0
- package/src/core/engine/validation/list.ts +50 -0
- package/src/core/index.ts +5 -0
- package/src/core/persistence/adapters.indexeddb.ts +92 -0
- package/src/core/persistence/adapters.local.ts +22 -0
- package/src/core/persistence/adapters.memory.ts +15 -0
- package/src/core/persistence/index.ts +5 -0
- package/src/core/persistence/persistence.ts +194 -0
- package/src/core/persistence/persistence.types.ts +66 -0
- package/src/core/utils/async.ts +3 -0
- package/src/core/utils/constants.ts +32 -0
- package/src/core/utils/emitter/emitter.types.ts +23 -0
- package/src/core/utils/emitter/index.ts +68 -0
- package/src/core/utils/fingerprint.ts +20 -0
- package/src/core/utils/guards.ts +3 -0
- package/src/core/utils/id.ts +3 -0
- package/src/core/utils/index.ts +6 -0
- package/src/index.ts +3 -0
- package/src/react/index.ts +2 -0
- package/src/react/upload-provider.tsx +96 -0
- package/src/react/use-uploader.tsx +115 -0
- package/src/strategies/index.ts +15 -0
- package/src/strategies/multipart/index.ts +243 -0
- package/src/strategies/post/index.ts +93 -0
- package/src/strategies/registry/index.ts +30 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multipart strategy implementation (S3/MinIO style).
|
|
3
|
+
* Signs each part on demand (signPart), uploads via PUT, then completes with ETags.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { CursorMap, IntentMap, UploadResultBase, UploadStrategy } from '../../core/contracts'
|
|
7
|
+
import { sleep } from '../../core/utils/async'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_MAX_PART_CONCURRENCY = 4
|
|
10
|
+
|
|
11
|
+
export type MultipartIntent = {
|
|
12
|
+
strategy: 'multipart'
|
|
13
|
+
fileId: string
|
|
14
|
+
uploadId: string
|
|
15
|
+
partSize: number
|
|
16
|
+
partCount: number
|
|
17
|
+
|
|
18
|
+
// Optional legacy mode: backend might provide all urls up front
|
|
19
|
+
parts?: Array<{
|
|
20
|
+
partNumber: number
|
|
21
|
+
url: string
|
|
22
|
+
headers?: Record<string, string>
|
|
23
|
+
}>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type MultipartCursor = {
|
|
27
|
+
done: Array<{
|
|
28
|
+
partNumber: number
|
|
29
|
+
etag: string
|
|
30
|
+
size: number
|
|
31
|
+
}>
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Marks that the multipart session was completed on the backend (parts assembled).
|
|
35
|
+
* This prevents re-sending completeMultipart on resume.
|
|
36
|
+
*/
|
|
37
|
+
completed?: true
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isAbort(err: unknown) {
|
|
41
|
+
return err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function multipartStrategy<
|
|
45
|
+
M extends IntentMap & { multipart: MultipartIntent },
|
|
46
|
+
C extends CursorMap<M> & { multipart?: MultipartCursor },
|
|
47
|
+
P extends string = string,
|
|
48
|
+
R extends UploadResultBase = UploadResultBase,
|
|
49
|
+
>(opts?: { maxPartConcurrency?: number }): UploadStrategy<M, C, P, R, 'multipart'> {
|
|
50
|
+
const maxPartConcurrency = Math.max(1, opts?.maxPartConcurrency ?? DEFAULT_MAX_PART_CONCURRENCY)
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
id: 'multipart',
|
|
54
|
+
resumable: true,
|
|
55
|
+
|
|
56
|
+
async start(ctx) {
|
|
57
|
+
const intent = ctx.intent
|
|
58
|
+
const totalBytes = ctx.file.size
|
|
59
|
+
const partSize = Math.max(1, intent.partSize)
|
|
60
|
+
|
|
61
|
+
// Trust backend partCount if provided (important for maxParts rules)
|
|
62
|
+
const totalParts = Math.max(1, intent.partCount ?? Math.ceil(totalBytes / partSize))
|
|
63
|
+
|
|
64
|
+
// Restore cursor
|
|
65
|
+
const cursor = ctx.readCursor()
|
|
66
|
+
const done = new Map<number, { etag: string; size: number }>()
|
|
67
|
+
if (cursor?.done) {
|
|
68
|
+
for (const p of cursor.done) done.set(p.partNumber, { etag: p.etag, size: p.size })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// If we already completed the multipart session in a previous run, do not re-complete.
|
|
72
|
+
// (The store will still run its generic finalization step.)
|
|
73
|
+
const alreadyCompleted = cursor?.completed === true
|
|
74
|
+
|
|
75
|
+
// Progress
|
|
76
|
+
const inflightBytes = new Map<number, number>()
|
|
77
|
+
let finishedBytes = 0
|
|
78
|
+
for (const v of done.values()) finishedBytes += v.size
|
|
79
|
+
|
|
80
|
+
const report = () => {
|
|
81
|
+
let inflight = 0
|
|
82
|
+
for (const b of inflightBytes.values()) inflight += b
|
|
83
|
+
ctx.reportProgress({ uploadedBytes: finishedBytes + inflight, totalBytes })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Build queue
|
|
87
|
+
const partsToUpload: Array<{ partNumber: number; start: number; end: number }> = []
|
|
88
|
+
for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
|
|
89
|
+
if (done.has(partNumber)) continue
|
|
90
|
+
const start = (partNumber - 1) * partSize
|
|
91
|
+
const end = Math.min(totalBytes, start + partSize)
|
|
92
|
+
partsToUpload.push({ partNumber, start, end })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (partsToUpload.length === 0) {
|
|
96
|
+
if (!alreadyCompleted) await completeMultipart()
|
|
97
|
+
report()
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const queue = partsToUpload.slice()
|
|
102
|
+
const running = new Set<Promise<void>>()
|
|
103
|
+
|
|
104
|
+
const getSignedPart = async (partNumber: number) => {
|
|
105
|
+
// Legacy fast path: intent.parts provided
|
|
106
|
+
const fromIntent = intent.parts?.find((x) => x.partNumber === partNumber)
|
|
107
|
+
if (fromIntent) return fromIntent
|
|
108
|
+
|
|
109
|
+
// Normal path: ask backend for this part url
|
|
110
|
+
if (!ctx.api.multipart?.signPart) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
'multipart.signPart is missing in UploadApi. Implement it to call your backend sign-part endpoint.',
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const out = await ctx.api.multipart.signPart(
|
|
117
|
+
{ fileId: intent.fileId, uploadId: intent.uploadId, partNumber },
|
|
118
|
+
{ signal: ctx.signal },
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return { partNumber, url: out.url, headers: out.headers }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const uploadOne = async (
|
|
125
|
+
p: { partNumber: number; start: number; end: number },
|
|
126
|
+
retryCount = 0,
|
|
127
|
+
): Promise<void> => {
|
|
128
|
+
const maxRetries = 3
|
|
129
|
+
try {
|
|
130
|
+
const blob = ctx.file.slice(p.start, p.end)
|
|
131
|
+
const size = blob.size
|
|
132
|
+
|
|
133
|
+
const signed = await getSignedPart(p.partNumber)
|
|
134
|
+
|
|
135
|
+
const res = await ctx.transport.put({
|
|
136
|
+
url: signed.url,
|
|
137
|
+
body: blob,
|
|
138
|
+
headers: signed.headers,
|
|
139
|
+
signal: ctx.signal,
|
|
140
|
+
onProgress: (loaded) => {
|
|
141
|
+
inflightBytes.set(p.partNumber, loaded)
|
|
142
|
+
report()
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
inflightBytes.delete(p.partNumber)
|
|
147
|
+
|
|
148
|
+
const etag = res.etag
|
|
149
|
+
if (!etag) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
'Missing ETag from upload part response. Ensure MinIO/S3 CORS exposes ETag (Access-Control-Expose-Headers: ETag).',
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
finishedBytes += size
|
|
156
|
+
done.set(p.partNumber, { etag, size })
|
|
157
|
+
|
|
158
|
+
const snapshot: MultipartCursor = {
|
|
159
|
+
done: Array.from(done.entries())
|
|
160
|
+
.map(([partNumber, v]) => ({ partNumber, etag: v.etag, size: v.size }))
|
|
161
|
+
.sort((a, b) => a.partNumber - b.partNumber),
|
|
162
|
+
}
|
|
163
|
+
ctx.persistCursor(snapshot)
|
|
164
|
+
report()
|
|
165
|
+
} catch (err) {
|
|
166
|
+
inflightBytes.delete(p.partNumber)
|
|
167
|
+
|
|
168
|
+
if (ctx.signal?.aborted || isAbort(err)) throw err
|
|
169
|
+
|
|
170
|
+
// basic retry for network-ish failures
|
|
171
|
+
if (retryCount < maxRetries) {
|
|
172
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
173
|
+
const retryable =
|
|
174
|
+
/network/i.test(msg) ||
|
|
175
|
+
/timeout/i.test(msg) ||
|
|
176
|
+
/5\d\d/.test(msg) ||
|
|
177
|
+
/ECONNRESET/i.test(msg) ||
|
|
178
|
+
/EHOSTUNREACH/i.test(msg)
|
|
179
|
+
|
|
180
|
+
if (retryable) {
|
|
181
|
+
await sleep(2 ** retryCount * 500)
|
|
182
|
+
return uploadOne(p, retryCount + 1)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
throw err
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
while (queue.length > 0 || running.size > 0) {
|
|
191
|
+
while (queue.length > 0 && running.size < maxPartConcurrency) {
|
|
192
|
+
const next = queue.shift()
|
|
193
|
+
if (!next) break
|
|
194
|
+
|
|
195
|
+
let task: Promise<void> | undefined
|
|
196
|
+
const wrapped = uploadOne(next).finally(() => {
|
|
197
|
+
if (task) running.delete(task)
|
|
198
|
+
})
|
|
199
|
+
task = wrapped
|
|
200
|
+
running.add(wrapped)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (running.size > 0) {
|
|
204
|
+
await Promise.race(running)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
await completeMultipart()
|
|
209
|
+
report()
|
|
210
|
+
|
|
211
|
+
async function completeMultipart() {
|
|
212
|
+
if (alreadyCompleted) return
|
|
213
|
+
if (!ctx.api.multipart?.completeMultipart) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
'multipart.completeMultipart is missing in UploadApi. Implement it to call your backend complete endpoint.',
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const parts = Array.from(done.entries())
|
|
220
|
+
.map(([partNumber, v]) => ({ partNumber, etag: v.etag }))
|
|
221
|
+
.sort((a, b) => a.partNumber - b.partNumber)
|
|
222
|
+
|
|
223
|
+
if (parts.length !== totalParts) {
|
|
224
|
+
throw new Error(`Cannot complete multipart: expected ${totalParts} parts, got ${parts.length}`)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
await ctx.api.multipart.completeMultipart(
|
|
228
|
+
{ fileId: intent.fileId, uploadId: intent.uploadId, parts },
|
|
229
|
+
{ signal: ctx.signal },
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
// Persist "completed" marker so resume doesn't re-complete.
|
|
233
|
+
const snapshot: MultipartCursor = {
|
|
234
|
+
done: Array.from(done.entries())
|
|
235
|
+
.map(([partNumber, v]) => ({ partNumber, etag: v.etag, size: v.size }))
|
|
236
|
+
.sort((a, b) => a.partNumber - b.partNumber),
|
|
237
|
+
completed: true,
|
|
238
|
+
}
|
|
239
|
+
ctx.persistCursor(snapshot)
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview POST strategy implementation for simple file uploads.
|
|
3
|
+
*
|
|
4
|
+
* Implements the presigned POST upload strategy, suitable for:
|
|
5
|
+
* - Small to medium files (< 100MB typically)
|
|
6
|
+
* - Simple uploads without resume capability
|
|
7
|
+
* - Direct form-based uploads to S3 or similar storage
|
|
8
|
+
*
|
|
9
|
+
* The strategy uses multipart/form-data POST requests with presigned
|
|
10
|
+
* form fields provided by the backend. This is the simplest upload
|
|
11
|
+
* method and doesn't support resumable uploads.
|
|
12
|
+
*
|
|
13
|
+
* @module upload-strategies/post
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { CursorMap, IntentMap, UploadResultBase, UploadStrategy } from '../../core/contracts'
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// POST INTENT TYPE
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
export type PostIntent = {
|
|
23
|
+
/** Strategy identifier - must be 'post' */
|
|
24
|
+
strategy: 'post'
|
|
25
|
+
/** Backend file identifier */
|
|
26
|
+
fileId: string
|
|
27
|
+
/** Presigned POST URL (form action) */
|
|
28
|
+
url: string
|
|
29
|
+
/** Form fields to include before the file */
|
|
30
|
+
fields: Record<string, string>
|
|
31
|
+
/** Optional expiration timestamp for the presigned URL */
|
|
32
|
+
expiresAt?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// POST CURSOR TYPE
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
export type PostCursor = {
|
|
40
|
+
/** Bytes uploaded (for progress display only) */
|
|
41
|
+
uploadedBytes: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// POST STRATEGY
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
export function PostStrategy<
|
|
49
|
+
M extends IntentMap & { post: PostIntent },
|
|
50
|
+
C extends CursorMap<M> & { post?: PostCursor },
|
|
51
|
+
P extends string = string,
|
|
52
|
+
R extends UploadResultBase = UploadResultBase,
|
|
53
|
+
>(): UploadStrategy<M, C, P, R, 'post'> {
|
|
54
|
+
return {
|
|
55
|
+
/** Strategy identifier */
|
|
56
|
+
id: 'post',
|
|
57
|
+
|
|
58
|
+
/** POST uploads are not resumable */
|
|
59
|
+
resumable: false,
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Execute the POST upload.
|
|
63
|
+
*
|
|
64
|
+
* @param ctx - Strategy context
|
|
65
|
+
* @throws Error if intent is missing URL
|
|
66
|
+
*/
|
|
67
|
+
async start(ctx) {
|
|
68
|
+
const intent = ctx.intent
|
|
69
|
+
|
|
70
|
+
// Validate intent has required URL
|
|
71
|
+
if (!intent.url) {
|
|
72
|
+
throw new Error('Direct strategy: intent missing url/uploadUrl')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Upload using form POST
|
|
76
|
+
await ctx.transport.postForm({
|
|
77
|
+
url: intent.url,
|
|
78
|
+
file: ctx.file,
|
|
79
|
+
fields: intent.fields,
|
|
80
|
+
filename: ctx.file.name,
|
|
81
|
+
signal: ctx.signal,
|
|
82
|
+
/**
|
|
83
|
+
* Report progress during upload.
|
|
84
|
+
* @param uploadedBytes - Bytes uploaded so far
|
|
85
|
+
* @param totalBytes - Total file size
|
|
86
|
+
*/
|
|
87
|
+
onProgress(uploadedBytes, totalBytes) {
|
|
88
|
+
ctx.reportProgress({ uploadedBytes, totalBytes })
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strategy registry helpers.
|
|
3
|
+
*
|
|
4
|
+
* Core strategy types live in `core/contracts/strategy.types.ts`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CursorMap, IntentMap, StrategyRegistry, UploadResultBase, UploadStrategy } from '../../core/contracts'
|
|
8
|
+
|
|
9
|
+
export type { StrategyCtx, StrategyRegistry, UploadStrategy } from '../../core/contracts'
|
|
10
|
+
|
|
11
|
+
export function createStrategyRegistry<
|
|
12
|
+
M extends IntentMap,
|
|
13
|
+
C extends CursorMap<M>,
|
|
14
|
+
P extends string = string,
|
|
15
|
+
R extends UploadResultBase = UploadResultBase,
|
|
16
|
+
>(): StrategyRegistry<M, C, P, R> {
|
|
17
|
+
const map: Partial<{ [K in keyof M & string]: UploadStrategy<M, C, P, R, K> }> = {}
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
get(id) {
|
|
21
|
+
return map[id]
|
|
22
|
+
},
|
|
23
|
+
has(id): id is keyof M & string {
|
|
24
|
+
return Object.hasOwn(map, id)
|
|
25
|
+
},
|
|
26
|
+
set(strategy) {
|
|
27
|
+
map[strategy.id] = strategy
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
}
|