@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.
Files changed (68) hide show
  1. package/dist/index.d.ts +1417 -0
  2. package/dist/index.js +6 -0
  3. package/package.json +43 -0
  4. package/src/core/client/config.types.ts +77 -0
  5. package/src/core/client/index.ts +4 -0
  6. package/src/core/client/plugins.types.ts +46 -0
  7. package/src/core/client/retry.types.ts +6 -0
  8. package/src/core/client/uploadClient.ts +19 -0
  9. package/src/core/contracts/backendApi.ts +62 -0
  10. package/src/core/contracts/cursor.types.ts +23 -0
  11. package/src/core/contracts/errors.types.ts +67 -0
  12. package/src/core/contracts/fingerprint.types.ts +18 -0
  13. package/src/core/contracts/index.ts +8 -0
  14. package/src/core/contracts/intent.types.ts +39 -0
  15. package/src/core/contracts/result.types.ts +11 -0
  16. package/src/core/contracts/strategy.types.ts +45 -0
  17. package/src/core/contracts/transport/index.ts +2 -0
  18. package/src/core/contracts/transport/transport.libs.ts +100 -0
  19. package/src/core/contracts/transport/transport.ts +166 -0
  20. package/src/core/contracts/transport/transport.types.ts +44 -0
  21. package/src/core/engine/commands.types.ts +17 -0
  22. package/src/core/engine/event-map.types.ts +45 -0
  23. package/src/core/engine/index.ts +8 -0
  24. package/src/core/engine/internal-events.types.ts +222 -0
  25. package/src/core/engine/outcome.types.ts +9 -0
  26. package/src/core/engine/progress.types.ts +15 -0
  27. package/src/core/engine/reducer.ts +435 -0
  28. package/src/core/engine/store/handlers/add-file.ts +103 -0
  29. package/src/core/engine/store/handlers/cancel.ts +56 -0
  30. package/src/core/engine/store/handlers/clean-up.ts +66 -0
  31. package/src/core/engine/store/handlers/create-intent.ts +76 -0
  32. package/src/core/engine/store/handlers/finalize.ts +59 -0
  33. package/src/core/engine/store/handlers/index.ts +7 -0
  34. package/src/core/engine/store/handlers/pause.ts +16 -0
  35. package/src/core/engine/store/handlers/run-upload.ts +192 -0
  36. package/src/core/engine/store/index.ts +123 -0
  37. package/src/core/engine/store/store.dispatch.ts +78 -0
  38. package/src/core/engine/store/store.libs.ts +169 -0
  39. package/src/core/engine/store/store.runtime.ts +336 -0
  40. package/src/core/engine/store/store.schedule.ts +116 -0
  41. package/src/core/engine/store/store.types.ts +250 -0
  42. package/src/core/engine/validation/file.ts +77 -0
  43. package/src/core/engine/validation/index.ts +3 -0
  44. package/src/core/engine/validation/intent.ts +55 -0
  45. package/src/core/engine/validation/list.ts +50 -0
  46. package/src/core/index.ts +5 -0
  47. package/src/core/persistence/adapters.indexeddb.ts +92 -0
  48. package/src/core/persistence/adapters.local.ts +22 -0
  49. package/src/core/persistence/adapters.memory.ts +15 -0
  50. package/src/core/persistence/index.ts +5 -0
  51. package/src/core/persistence/persistence.ts +194 -0
  52. package/src/core/persistence/persistence.types.ts +66 -0
  53. package/src/core/utils/async.ts +3 -0
  54. package/src/core/utils/constants.ts +32 -0
  55. package/src/core/utils/emitter/emitter.types.ts +23 -0
  56. package/src/core/utils/emitter/index.ts +68 -0
  57. package/src/core/utils/fingerprint.ts +20 -0
  58. package/src/core/utils/guards.ts +3 -0
  59. package/src/core/utils/id.ts +3 -0
  60. package/src/core/utils/index.ts +6 -0
  61. package/src/index.ts +3 -0
  62. package/src/react/index.ts +2 -0
  63. package/src/react/upload-provider.tsx +96 -0
  64. package/src/react/use-uploader.tsx +115 -0
  65. package/src/strategies/index.ts +15 -0
  66. package/src/strategies/multipart/index.ts +243 -0
  67. package/src/strategies/post/index.ts +93 -0
  68. 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
+ }