@gentleduck/upload 0.2.5 → 0.2.6
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 +320 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/{core-BNxznRif.js → core-CMkrP9DX.js} +2 -2
- package/dist/{index-BXg9WIce.d.ts → index-C38Jmizu.d.ts} +51 -33
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +8 -47
- package/dist/react/index.js +1 -1
- package/dist/strategies/index.d.ts +5 -5
- package/package.json +1 -1
- package/src/__test__/effect-pool.test.ts +5 -0
- package/src/__test__/emitter.test.ts +85 -0
- package/src/__test__/fingerprint.test.ts +49 -0
- package/src/__test__/guards.test.ts +30 -0
- package/src/__test__/id.test.ts +14 -0
- package/src/__test__/persistence.test.ts +156 -0
- package/src/__test__/store-integration.test.ts +5 -0
- package/src/__test__/store-libs.test.ts +138 -0
- package/src/__test__/transport-libs.test.ts +42 -0
- package/src/__test__/transport.test.ts +182 -0
- package/src/core/client/client.ts +50 -0
- package/src/core/client/client.types.ts +184 -0
- package/src/core/contracts/contracts.types.ts +179 -0
- package/src/core/contracts/transport/transport.libs.ts +7 -32
- package/src/core/contracts/transport/transport.ts +5 -7
- package/src/core/engine/engine.types.ts +364 -0
- package/src/core/engine/internal-events.types.ts +3 -23
- package/src/core/engine/reducer.ts +5 -11
- package/src/core/engine/store/handlers/add-file.ts +6 -21
- package/src/core/engine/store/handlers/run-upload.ts +7 -7
- package/src/core/engine/store/handlers.ts +819 -0
- package/src/core/engine/store/store.libs.ts +17 -57
- package/src/core/engine/store/store.runtime.ts +10 -20
- package/src/core/engine/validation/file.ts +1 -1
- package/src/core/engine/validation.ts +216 -0
- package/src/core/utils/emitter/emitter.ts +143 -0
- package/src/react/upload-provider.tsx +7 -58
- package/src/react/uploader.types.ts +83 -0
- package/src/react/use-uploader.tsx +2 -5
- package/src/strategies/multipart/index.ts +9 -12
- package/src/strategies/post/index.ts +6 -46
|
@@ -1,48 +1,14 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* @fileoverview React context provider for the upload store.
|
|
5
|
-
*
|
|
6
|
-
* Provides the upload store instance to all child components via React Context.
|
|
7
|
-
* This allows components to access the store without prop drilling.
|
|
8
|
-
*
|
|
9
|
-
* @module upload-provider
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
3
|
import { createContext, type ReactNode, useContext } from 'react'
|
|
13
4
|
import { type CursorMap, type IntentMap, isRecord, type UploadResultBase } from '../core'
|
|
14
5
|
import type { UploadStore } from '../core/engine/store'
|
|
15
6
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
* Initialized as null and must be provided via UploadProvider.
|
|
19
|
-
*
|
|
20
|
-
* Note: Context cannot be generic at the type level in React, so we use a base type.
|
|
21
|
-
* The useUploadStore hook provides type safety through type assertion which is safe
|
|
22
|
-
* because the provider ensures the correct type is stored.
|
|
23
|
-
*/
|
|
7
|
+
// React Context can't carry generics, so the runtime value is `unknown`; the
|
|
8
|
+
// hooks below re-narrow with `isUploadStore`.
|
|
24
9
|
const UploadContext = createContext<unknown | null>(null)
|
|
25
10
|
|
|
26
|
-
/**
|
|
27
|
-
* Provider component that makes the upload store available to child components.
|
|
28
|
-
*
|
|
29
|
-
* @template M - Intent map type
|
|
30
|
-
* @template C - Cursor map type
|
|
31
|
-
* @template P - Purpose type
|
|
32
|
-
*
|
|
33
|
-
* @param props - Provider props
|
|
34
|
-
* @param props.store - Upload store instance to provide
|
|
35
|
-
* @param props.children - Child components that can access the store
|
|
36
|
-
*
|
|
37
|
-
* @returns {JSX.Element} Context provider wrapping children
|
|
38
|
-
*
|
|
39
|
-
* @example
|
|
40
|
-
* ```tsx
|
|
41
|
-
* <UploadProvider store={store}>
|
|
42
|
-
* <UploadDropzone purpose="avatar" />
|
|
43
|
-
* </UploadProvider>
|
|
44
|
-
* ```
|
|
45
|
-
*/
|
|
11
|
+
/** Provides an upload store to descendants. */
|
|
46
12
|
export function UploadProvider<
|
|
47
13
|
M extends IntentMap,
|
|
48
14
|
C extends CursorMap<M>,
|
|
@@ -53,11 +19,9 @@ export function UploadProvider<
|
|
|
53
19
|
}
|
|
54
20
|
|
|
55
21
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* They use this helper so the context read stays unconditional without throwing
|
|
60
|
-
* when a caller intentionally passes a store instance.
|
|
22
|
+
* Read the upload store from context if provided. Returns `null` outside a
|
|
23
|
+
* provider so higher-level hooks can keep their context read unconditional
|
|
24
|
+
* while still accepting an explicit store argument.
|
|
61
25
|
*/
|
|
62
26
|
export function useOptionalUploadStore<
|
|
63
27
|
M extends IntentMap,
|
|
@@ -77,22 +41,7 @@ export function useOptionalUploadStore<
|
|
|
77
41
|
return store
|
|
78
42
|
}
|
|
79
43
|
|
|
80
|
-
/**
|
|
81
|
-
* Hook to access the upload store from React context.
|
|
82
|
-
*
|
|
83
|
-
* @template M - Intent map type
|
|
84
|
-
* @template C - Cursor map type
|
|
85
|
-
* @template P - Purpose type
|
|
86
|
-
*
|
|
87
|
-
* @returns {UploadStore<M, C, P>} The upload store instance
|
|
88
|
-
* @throws {Error} If called outside of UploadProvider
|
|
89
|
-
*
|
|
90
|
-
* @example
|
|
91
|
-
* ```tsx
|
|
92
|
-
* const store = useUploadStore()
|
|
93
|
-
* const snapshot = store.getSnapshot()
|
|
94
|
-
* ```
|
|
95
|
-
*/
|
|
44
|
+
/** Read the upload store from context. Throws when called outside `UploadProvider`. */
|
|
96
45
|
export function useUploadStore<
|
|
97
46
|
M extends IntentMap,
|
|
98
47
|
C extends CursorMap<M>,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Contracts, Engine } from '../core'
|
|
2
|
+
import type { Store } from '../core/engine/store'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* React `useUploader` surface types.
|
|
6
|
+
*
|
|
7
|
+
* Houses the public typings consumed by the React hooks so generated
|
|
8
|
+
* `.d.ts` files do not leak internal generic names.
|
|
9
|
+
*
|
|
10
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
11
|
+
*/
|
|
12
|
+
export namespace Uploader {
|
|
13
|
+
/**
|
|
14
|
+
* Pick the variant of `Engine.Item` matching a phase.
|
|
15
|
+
*
|
|
16
|
+
* @template Phase Phase literal to filter on.
|
|
17
|
+
* @returns The item variant whose `phase` matches `Phase`.
|
|
18
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
19
|
+
*/
|
|
20
|
+
export type PickItemsByPhase<Phase extends Engine.Phase> = Extract<
|
|
21
|
+
Engine.Item<Contracts.IntentMap, Contracts.CursorMap<Contracts.IntentMap>, string, Contracts.IResultBase>,
|
|
22
|
+
{ phase: Phase }
|
|
23
|
+
>
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Object returned by `useUploader`.
|
|
27
|
+
*
|
|
28
|
+
* @template M Intent map.
|
|
29
|
+
* @template C Cursor map.
|
|
30
|
+
* @template P Purpose union.
|
|
31
|
+
* @template R Backend result shape.
|
|
32
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
33
|
+
*/
|
|
34
|
+
export interface IUploader<
|
|
35
|
+
M extends Contracts.IntentMap,
|
|
36
|
+
C extends Contracts.CursorMap<M>,
|
|
37
|
+
P extends string,
|
|
38
|
+
R extends Contracts.IResultBase,
|
|
39
|
+
> {
|
|
40
|
+
items: Engine.Item<M, C, P, R>[]
|
|
41
|
+
/**
|
|
42
|
+
* Items grouped by phase. `Partial<Record<Engine.Phase, ...>>` so consumers
|
|
43
|
+
* get autocomplete for the eleven known phases and a missing group reads
|
|
44
|
+
* as `undefined`.
|
|
45
|
+
*/
|
|
46
|
+
byPhase: Partial<Record<Engine.Phase, Engine.Item<M, C, P, R>[]>>
|
|
47
|
+
dispatch: (cmd: Engine.Command<P>) => void
|
|
48
|
+
on: <K extends keyof Engine.EventMap<M, C, P, R> & string>(
|
|
49
|
+
type: K,
|
|
50
|
+
cb: (payload: Engine.EventMap<M, C, P, R>[K]) => void,
|
|
51
|
+
) => () => void
|
|
52
|
+
off: <K extends keyof Engine.EventMap<M, C, P, R> & string>(
|
|
53
|
+
type: K,
|
|
54
|
+
cb: (payload: Engine.EventMap<M, C, P, R>[K]) => void,
|
|
55
|
+
) => void
|
|
56
|
+
uploading: Engine.Item<M, C, P, R>[]
|
|
57
|
+
paused: Engine.Item<M, C, P, R>[]
|
|
58
|
+
completed: Engine.Item<M, C, P, R>[]
|
|
59
|
+
failed: Engine.Item<M, C, P, R>[]
|
|
60
|
+
ready: Engine.Item<M, C, P, R>[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Imperative action surface returned by `useUploaderActions`.
|
|
65
|
+
*
|
|
66
|
+
* Declared explicitly so generated `.d.ts` files do not leak internal
|
|
67
|
+
* generic names.
|
|
68
|
+
*
|
|
69
|
+
* @template M Intent map.
|
|
70
|
+
* @template C Cursor map.
|
|
71
|
+
* @template P Purpose union.
|
|
72
|
+
* @template R Backend result shape; defaults to `Contracts.IResultBase`.
|
|
73
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
74
|
+
*/
|
|
75
|
+
export type IActions<
|
|
76
|
+
M extends Contracts.IntentMap,
|
|
77
|
+
C extends Contracts.CursorMap<M>,
|
|
78
|
+
P extends string,
|
|
79
|
+
R extends Contracts.IResultBase = Contracts.IResultBase,
|
|
80
|
+
> = Pick<Store.IUploadStore<M, C, P, R>, 'dispatch' | 'on'> & {
|
|
81
|
+
store: Store.IUploadStore<M, C, P, R>
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -47,11 +47,8 @@ type Uploader<M extends IntentMap, C extends CursorMap<M>, P extends string, R e
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* This is declared explicitly instead of relying on an inferred object return
|
|
53
|
-
* type so the generated package declarations do not leak the internal generic
|
|
54
|
-
* names created by `store.on.bind(store)`.
|
|
50
|
+
* Imperative action surface. Declared explicitly (not inferred) so generated
|
|
51
|
+
* .d.ts files do not leak internal generic names created by `store.on.bind(store)`.
|
|
55
52
|
*/
|
|
56
53
|
export type UploaderActions<
|
|
57
54
|
M extends IntentMap,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* S3/MinIO-style multipart strategy: sign each part on demand via `signPart`,
|
|
3
|
+
* PUT, then finalize with `completeMultipart` using collected ETags. Resumable —
|
|
4
|
+
* persists ETags in the cursor and skips already-completed sessions.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import type { CursorMap, IntentMap, UploadResultBase, UploadStrategy } from '../../core/contracts'
|
|
@@ -58,21 +59,19 @@ export function multipartStrategy<
|
|
|
58
59
|
const totalBytes = ctx.file.size
|
|
59
60
|
const partSize = Math.max(1, intent.partSize)
|
|
60
61
|
|
|
61
|
-
// Trust backend partCount
|
|
62
|
+
// Trust backend `partCount` when provided — S3-style backends enforce a maxParts rule.
|
|
62
63
|
const totalParts = Math.max(1, intent.partCount ?? Math.ceil(totalBytes / partSize))
|
|
63
64
|
|
|
64
|
-
// Restore cursor
|
|
65
65
|
const cursor = ctx.readCursor()
|
|
66
66
|
const done = new Map<number, { etag: string; size: number }>()
|
|
67
67
|
if (cursor?.done) {
|
|
68
68
|
for (const p of cursor.done) done.set(p.partNumber, { etag: p.etag, size: p.size })
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
//
|
|
72
|
-
//
|
|
71
|
+
// Skip re-completing if a previous run already finalized the multipart
|
|
72
|
+
// session. The store still runs its generic finalization step.
|
|
73
73
|
const alreadyCompleted = cursor?.completed === true
|
|
74
74
|
|
|
75
|
-
// Progress
|
|
76
75
|
const inflightBytes = new Map<number, number>()
|
|
77
76
|
let finishedBytes = 0
|
|
78
77
|
for (const v of done.values()) finishedBytes += v.size
|
|
@@ -83,7 +82,6 @@ export function multipartStrategy<
|
|
|
83
82
|
ctx.reportProgress({ uploadedBytes: finishedBytes + inflight, totalBytes })
|
|
84
83
|
}
|
|
85
84
|
|
|
86
|
-
// Build queue
|
|
87
85
|
const partsToUpload: Array<{ partNumber: number; start: number; end: number }> = []
|
|
88
86
|
for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
|
|
89
87
|
if (done.has(partNumber)) continue
|
|
@@ -102,11 +100,10 @@ export function multipartStrategy<
|
|
|
102
100
|
const running = new Set<Promise<void>>()
|
|
103
101
|
|
|
104
102
|
const getSignedPart = async (partNumber: number) => {
|
|
105
|
-
// Legacy fast path: intent.parts
|
|
103
|
+
// Legacy fast path: backend pre-signed all parts in `intent.parts`.
|
|
106
104
|
const fromIntent = intent.parts?.find((x) => x.partNumber === partNumber)
|
|
107
105
|
if (fromIntent) return fromIntent
|
|
108
106
|
|
|
109
|
-
// Normal path: ask backend for this part url
|
|
110
107
|
if (!ctx.api.multipart?.signPart) {
|
|
111
108
|
throw new Error(
|
|
112
109
|
'multipart.signPart is missing in UploadApi. Implement it to call your backend sign-part endpoint.',
|
|
@@ -167,7 +164,7 @@ export function multipartStrategy<
|
|
|
167
164
|
|
|
168
165
|
if (ctx.signal?.aborted || isAbort(err)) throw err
|
|
169
166
|
|
|
170
|
-
//
|
|
167
|
+
// Retry transient network errors with exponential backoff.
|
|
171
168
|
if (retryCount < maxRetries) {
|
|
172
169
|
const msg = err instanceof Error ? err.message : String(err)
|
|
173
170
|
const retryable =
|
|
@@ -229,7 +226,7 @@ export function multipartStrategy<
|
|
|
229
226
|
{ signal: ctx.signal },
|
|
230
227
|
)
|
|
231
228
|
|
|
232
|
-
// Persist
|
|
229
|
+
// Persist `completed: true` so resume after this point does not re-call `completeMultipart`.
|
|
233
230
|
const snapshot: MultipartCursor = {
|
|
234
231
|
done: Array.from(done.entries())
|
|
235
232
|
.map(([partNumber, v]) => ({ partNumber, etag: v.etag, size: v.size }))
|
|
@@ -1,50 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
|
2
|
+
* Presigned-POST strategy: a single `multipart/form-data` POST with
|
|
3
|
+
* server-provided fields. Non-resumable; suitable for small/medium files.
|
|
14
4
|
*/
|
|
15
5
|
|
|
16
6
|
import type { CursorMap, IntentMap, UploadResultBase, UploadStrategy } from '../../core/contracts'
|
|
17
7
|
|
|
18
|
-
// ============================================================================
|
|
19
|
-
// POST INTENT TYPE
|
|
20
|
-
// ============================================================================
|
|
21
|
-
|
|
22
8
|
export type PostIntent = {
|
|
23
|
-
/** Strategy identifier - must be 'post' */
|
|
24
9
|
strategy: 'post'
|
|
25
|
-
/** Backend file identifier */
|
|
26
10
|
fileId: string
|
|
27
|
-
/** Presigned POST URL (form action) */
|
|
11
|
+
/** Presigned POST URL (form action). */
|
|
28
12
|
url: string
|
|
29
|
-
/** Form fields to include before the file */
|
|
13
|
+
/** Form fields to include before the file. */
|
|
30
14
|
fields: Record<string, string>
|
|
31
|
-
/** Optional
|
|
15
|
+
/** Optional expiry of the presigned URL. */
|
|
32
16
|
expiresAt?: string
|
|
33
17
|
}
|
|
34
18
|
|
|
35
|
-
// ============================================================================
|
|
36
|
-
// POST CURSOR TYPE
|
|
37
|
-
// ============================================================================
|
|
38
|
-
|
|
39
19
|
export type PostCursor = {
|
|
40
|
-
/** Bytes uploaded
|
|
20
|
+
/** Bytes uploaded — progress display only; not used for resume. */
|
|
41
21
|
uploadedBytes: number
|
|
42
22
|
}
|
|
43
23
|
|
|
44
|
-
// ============================================================================
|
|
45
|
-
// POST STRATEGY
|
|
46
|
-
// ============================================================================
|
|
47
|
-
|
|
48
24
|
export function PostStrategy<
|
|
49
25
|
M extends IntentMap & { post: PostIntent },
|
|
50
26
|
C extends CursorMap<M> & { post?: PostCursor },
|
|
@@ -52,38 +28,22 @@ export function PostStrategy<
|
|
|
52
28
|
R extends UploadResultBase = UploadResultBase,
|
|
53
29
|
>(): UploadStrategy<M, C, P, R, 'post'> {
|
|
54
30
|
return {
|
|
55
|
-
/** Strategy identifier */
|
|
56
31
|
id: 'post',
|
|
57
|
-
|
|
58
|
-
/** POST uploads are not resumable */
|
|
59
32
|
resumable: false,
|
|
60
33
|
|
|
61
|
-
/**
|
|
62
|
-
* Execute the POST upload.
|
|
63
|
-
*
|
|
64
|
-
* @param ctx - Strategy context
|
|
65
|
-
* @throws Error if intent is missing URL
|
|
66
|
-
*/
|
|
67
34
|
async start(ctx) {
|
|
68
35
|
const intent = ctx.intent
|
|
69
36
|
|
|
70
|
-
// Validate intent has required URL
|
|
71
37
|
if (!intent.url) {
|
|
72
38
|
throw new Error('Direct strategy: intent missing url/uploadUrl')
|
|
73
39
|
}
|
|
74
40
|
|
|
75
|
-
// Upload using form POST
|
|
76
41
|
await ctx.transport.postForm({
|
|
77
42
|
url: intent.url,
|
|
78
43
|
file: ctx.file,
|
|
79
44
|
fields: intent.fields,
|
|
80
45
|
filename: ctx.file.name,
|
|
81
46
|
signal: ctx.signal,
|
|
82
|
-
/**
|
|
83
|
-
* Report progress during upload.
|
|
84
|
-
* @param uploadedBytes - Bytes uploaded so far
|
|
85
|
-
* @param totalBytes - Total file size
|
|
86
|
-
*/
|
|
87
47
|
onProgress(uploadedBytes, totalBytes) {
|
|
88
48
|
ctx.reportProgress({ uploadedBytes, totalBytes })
|
|
89
49
|
},
|