@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.
Files changed (42) hide show
  1. package/README.md +320 -0
  2. package/dist/core/index.d.ts +1 -1
  3. package/dist/core/index.js +1 -1
  4. package/dist/{core-BNxznRif.js → core-CMkrP9DX.js} +2 -2
  5. package/dist/{index-BXg9WIce.d.ts → index-C38Jmizu.d.ts} +51 -33
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/react/index.d.ts +8 -47
  9. package/dist/react/index.js +1 -1
  10. package/dist/strategies/index.d.ts +5 -5
  11. package/package.json +1 -1
  12. package/src/__test__/effect-pool.test.ts +5 -0
  13. package/src/__test__/emitter.test.ts +85 -0
  14. package/src/__test__/fingerprint.test.ts +49 -0
  15. package/src/__test__/guards.test.ts +30 -0
  16. package/src/__test__/id.test.ts +14 -0
  17. package/src/__test__/persistence.test.ts +156 -0
  18. package/src/__test__/store-integration.test.ts +5 -0
  19. package/src/__test__/store-libs.test.ts +138 -0
  20. package/src/__test__/transport-libs.test.ts +42 -0
  21. package/src/__test__/transport.test.ts +182 -0
  22. package/src/core/client/client.ts +50 -0
  23. package/src/core/client/client.types.ts +184 -0
  24. package/src/core/contracts/contracts.types.ts +179 -0
  25. package/src/core/contracts/transport/transport.libs.ts +7 -32
  26. package/src/core/contracts/transport/transport.ts +5 -7
  27. package/src/core/engine/engine.types.ts +364 -0
  28. package/src/core/engine/internal-events.types.ts +3 -23
  29. package/src/core/engine/reducer.ts +5 -11
  30. package/src/core/engine/store/handlers/add-file.ts +6 -21
  31. package/src/core/engine/store/handlers/run-upload.ts +7 -7
  32. package/src/core/engine/store/handlers.ts +819 -0
  33. package/src/core/engine/store/store.libs.ts +17 -57
  34. package/src/core/engine/store/store.runtime.ts +10 -20
  35. package/src/core/engine/validation/file.ts +1 -1
  36. package/src/core/engine/validation.ts +216 -0
  37. package/src/core/utils/emitter/emitter.ts +143 -0
  38. package/src/react/upload-provider.tsx +7 -58
  39. package/src/react/uploader.types.ts +83 -0
  40. package/src/react/use-uploader.tsx +2 -5
  41. package/src/strategies/multipart/index.ts +9 -12
  42. 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
- * React context for the upload store.
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
- * Hook to access the upload store from React context when present.
57
- *
58
- * Some higher-level hooks support either an explicit store argument or context.
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
- * Stable action surface for React consumers that need imperative store access.
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
- * Multipart strategy implementation (S3/MinIO style).
3
- * Signs each part on demand (signPart), uploads via PUT, then completes with ETags.
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 if provided (important for maxParts rules)
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
- // 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.)
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 provided
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
- // basic retry for network-ish failures
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 "completed" marker so resume doesn't re-complete.
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
- * @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
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 expiration timestamp for the presigned URL */
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 (for progress display only) */
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
  },