@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.
Files changed (129) hide show
  1. package/README.md +297 -0
  2. package/dist/core/index.d.ts +2 -3
  3. package/dist/core/index.js +1 -1
  4. package/dist/core-BVgloTiP.js +10 -0
  5. package/dist/index-DCqyBVcx.d.ts +1441 -0
  6. package/dist/index-DsZHPmJm.d.ts +225 -0
  7. package/dist/index-KROXtLQh.d.ts +281 -0
  8. package/dist/index.d.ts +4 -5
  9. package/dist/index.js +1 -1
  10. package/dist/react/index.d.ts +2 -90
  11. package/dist/react/index.js +1 -1
  12. package/dist/react-euPrDv24.js +1 -0
  13. package/dist/strategies/index.d.ts +2 -56
  14. package/dist/strategies/index.js +1 -1
  15. package/dist/strategies-CUF0s68j.js +1 -0
  16. package/package.json +3 -2
  17. package/src/__tests__/emitter.test.ts +85 -0
  18. package/src/__tests__/fingerprint.test.ts +49 -0
  19. package/src/__tests__/guards.test.ts +40 -0
  20. package/src/__tests__/id.test.ts +67 -0
  21. package/src/__tests__/mime-sniff.test.ts +142 -0
  22. package/src/__tests__/persistence.test.ts +191 -0
  23. package/src/__tests__/sanitize-filename.test.ts +109 -0
  24. package/src/__tests__/sleep.test.ts +34 -0
  25. package/src/__tests__/static-analysis.test.ts +145 -0
  26. package/src/__tests__/store-libs.test.ts +192 -0
  27. package/src/__tests__/transport-fetch.test.ts +108 -0
  28. package/src/__tests__/transport-libs.test.ts +42 -0
  29. package/src/__tests__/transport.test.ts +182 -0
  30. package/src/core/__tests__/errors.test.ts +152 -0
  31. package/src/core/client.ts +3 -0
  32. package/src/core/contracts/contracts.types.ts +373 -0
  33. package/src/core/contracts/index.ts +3 -8
  34. package/src/core/contracts/transport/index.ts +3 -2
  35. package/src/core/contracts/transport/transport.fetch.ts +203 -0
  36. package/src/core/contracts/transport/transport.libs.ts +13 -39
  37. package/src/core/contracts/transport/transport.ts +96 -90
  38. package/src/core/contracts/transport/transport.types.ts +71 -35
  39. package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
  40. package/src/core/engine/engine.libs.ts +35 -0
  41. package/src/core/engine/engine.types.ts +490 -0
  42. package/src/core/engine/index.ts +3 -8
  43. package/src/core/engine/reducer.ts +93 -53
  44. package/src/core/engine/store/handlers/add-file.ts +43 -48
  45. package/src/core/engine/store/handlers/cancel.ts +13 -8
  46. package/src/core/engine/store/handlers/clean-up.ts +11 -8
  47. package/src/core/engine/store/handlers/create-intent.ts +82 -27
  48. package/src/core/engine/store/handlers/finalize.ts +30 -13
  49. package/src/core/engine/store/handlers/index.ts +7 -7
  50. package/src/core/engine/store/handlers/pause.ts +8 -6
  51. package/src/core/engine/store/handlers/run-upload.ts +54 -39
  52. package/src/core/engine/store/index.ts +27 -21
  53. package/src/core/engine/store/store.dispatch.ts +10 -8
  54. package/src/core/engine/store/store.libs.ts +197 -94
  55. package/src/core/engine/store/store.runtime.ts +52 -53
  56. package/src/core/engine/store/store.schedule.ts +27 -21
  57. package/src/core/engine/store/store.types.ts +177 -227
  58. package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
  59. package/src/core/engine/validation/file.ts +8 -4
  60. package/src/core/engine/validation/index.ts +1 -0
  61. package/src/core/engine/validation/intent.ts +49 -27
  62. package/src/core/engine/validation/list.ts +14 -15
  63. package/src/core/engine/validation/mime.ts +39 -0
  64. package/src/core/errors.ts +271 -0
  65. package/src/core/index.ts +28 -5
  66. package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
  67. package/src/core/persistence/__tests__/persistence.test.ts +85 -0
  68. package/src/core/persistence/adapters.indexeddb.ts +9 -3
  69. package/src/core/persistence/adapters.local.ts +9 -3
  70. package/src/core/persistence/adapters.memory.ts +35 -13
  71. package/src/core/persistence/index.ts +5 -5
  72. package/src/core/persistence/persistence.ts +73 -60
  73. package/src/core/persistence/persistence.types.ts +140 -60
  74. package/src/core/utils/__tests__/guards.test.ts +93 -0
  75. package/src/core/utils/__tests__/url-safety.test.ts +112 -0
  76. package/src/core/utils/async.ts +7 -2
  77. package/src/core/utils/constants.ts +3 -9
  78. package/src/core/utils/emitter/emitter.ts +126 -0
  79. package/src/core/utils/emitter/emitter.types.ts +33 -16
  80. package/src/core/utils/emitter/index.ts +2 -74
  81. package/src/core/utils/fingerprint.ts +16 -5
  82. package/src/core/utils/guards.ts +91 -1
  83. package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
  84. package/src/core/utils/hash/index.ts +126 -0
  85. package/src/core/utils/hash/sha256.ts +179 -0
  86. package/src/core/utils/id.ts +33 -1
  87. package/src/core/utils/index.ts +15 -6
  88. package/src/core/utils/mime-sniff.ts +119 -0
  89. package/src/core/utils/sanitize-filename.ts +85 -0
  90. package/src/core/utils/url-safety.ts +156 -0
  91. package/src/react/__tests__/use-dropzone.test.ts +64 -0
  92. package/src/react/index.ts +5 -2
  93. package/src/react/upload-provider.tsx +59 -95
  94. package/src/react/uploader.types.ts +80 -0
  95. package/src/react/use-dropzone.tsx +226 -0
  96. package/src/react/use-uploader.tsx +97 -93
  97. package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
  98. package/src/strategies/_shared/retry.ts +79 -0
  99. package/src/strategies/index.ts +9 -15
  100. package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
  101. package/src/strategies/multipart/index.ts +211 -116
  102. package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
  103. package/src/strategies/post/index.ts +113 -71
  104. package/src/strategies/put/__tests__/put.test.ts +132 -0
  105. package/src/strategies/put/index.ts +139 -0
  106. package/src/strategies/registry/index.ts +12 -9
  107. package/src/strategies/tus/__tests__/tus.test.ts +159 -0
  108. package/src/strategies/tus/index.ts +211 -0
  109. package/dist/async-DxRWu4le.js +0 -1
  110. package/dist/core-BNxznRif.js +0 -2
  111. package/dist/index-BXg9WIce.d.ts +0 -987
  112. package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
  113. package/src/core/client/config.types.ts +0 -77
  114. package/src/core/client/index.ts +0 -4
  115. package/src/core/client/plugins.types.ts +0 -46
  116. package/src/core/client/retry.types.ts +0 -6
  117. package/src/core/client/uploadClient.ts +0 -19
  118. package/src/core/contracts/backendApi.ts +0 -62
  119. package/src/core/contracts/cursor.types.ts +0 -23
  120. package/src/core/contracts/errors.types.ts +0 -67
  121. package/src/core/contracts/fingerprint.types.ts +0 -18
  122. package/src/core/contracts/intent.types.ts +0 -39
  123. package/src/core/contracts/result.types.ts +0 -11
  124. package/src/core/contracts/strategy.types.ts +0 -45
  125. package/src/core/engine/commands.types.ts +0 -17
  126. package/src/core/engine/event-map.types.ts +0 -45
  127. package/src/core/engine/internal-events.types.ts +0 -222
  128. package/src/core/engine/outcome.types.ts +0 -9
  129. package/src/core/engine/progress.types.ts +0 -15
@@ -0,0 +1,156 @@
1
+ /**
2
+ * SSRF guards for backend-supplied upload URLs.
3
+ *
4
+ * Every URL that arrives from a non-trusted backend (presigned POST,
5
+ * presigned PUT, multipart part URLs) must pass through
6
+ * `validateUploadUrl` before it reaches the transport. The guard
7
+ * blocks:
8
+ *
9
+ * - Empty / non-string / malformed URLs
10
+ * - `..` path-traversal segments (URL parser would normalize them away)
11
+ * - Non-http(s) protocols (`file:`, `javascript:`, `data:`)
12
+ * - IP literals pointing at loopback / RFC1918 / link-local /
13
+ * cloud-metadata / CGNAT / multicast / broadcast addresses
14
+ * - IPv6 embedded IPv4 (mapped, compat, 6to4, NAT64) carrying any of the above
15
+ *
16
+ * DNS rebinding is out of scope for a client-side check.
17
+ */
18
+
19
+ function hexTailToDottedQuad(tail: string): string | null {
20
+ const m = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(tail)
21
+ if (!m || m[1] === undefined || m[2] === undefined) return null
22
+ const hi = parseInt(m[1], 16)
23
+ const lo = parseInt(m[2], 16)
24
+ if (!Number.isFinite(hi) || !Number.isFinite(lo)) return null
25
+ if (hi < 0 || hi > 0xffff || lo < 0 || lo > 0xffff) return null
26
+ return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`
27
+ }
28
+
29
+ /**
30
+ * Returns true when `host` is a literal IP address in one of the
31
+ * private / loopback / cloud-metadata ranges. Hostnames that aren't IP
32
+ * literals return false.
33
+ */
34
+ export function isPrivateHost(host: string): boolean {
35
+ let h = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
36
+ if (h.endsWith('.')) h = h.slice(0, -1)
37
+
38
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h)
39
+ if (v4) {
40
+ const a = Number(v4[1])
41
+ const b = Number(v4[2])
42
+ const c = Number(v4[3])
43
+ const d = Number(v4[4])
44
+ if (a > 255 || b > 255 || c > 255 || d > 255) return false
45
+ if (a === 127) return true // 127.0.0.0/8 loopback
46
+ if (a === 10) return true // 10.0.0.0/8 RFC1918
47
+ if (a === 192 && b === 168) return true // 192.168.0.0/16 RFC1918
48
+ if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12 RFC1918
49
+ if (a === 169 && b === 254) return true // 169.254.0.0/16 link-local + AWS metadata
50
+ if (a === 0) return true // 0.0.0.0/8 this-network
51
+ if (a === 100 && b >= 64 && b <= 127) return true // 100.64.0.0/10 CGNAT
52
+ if (a >= 224 && a <= 239) return true // 224.0.0.0/4 multicast
53
+ if (a >= 240) return true // 240.0.0.0/4 reserved + broadcast
54
+ return false
55
+ }
56
+
57
+ if (h.includes(':')) {
58
+ const lower = h.toLowerCase()
59
+ if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') return true
60
+ if (lower === '::' || lower === '0:0:0:0:0:0:0:0') return true
61
+ if (/^f[cd][0-9a-f]{0,2}:/.test(lower)) return true // fc00::/7
62
+ if (/^fe[89ab][0-9a-f]?:/.test(lower)) return true // fe80::/10
63
+ if (/^ff[0-9a-f]{0,2}:/.test(lower)) return true // ff00::/8 multicast
64
+
65
+ let mappedTail: string | null = null
66
+ if (lower.startsWith('::ffff:')) mappedTail = lower.slice(7)
67
+ else if (lower.startsWith('0:0:0:0:0:ffff:')) mappedTail = lower.slice(15)
68
+ if (mappedTail !== null) {
69
+ if (mappedTail.includes('.')) return isPrivateHost(mappedTail)
70
+ const dotted = hexTailToDottedQuad(mappedTail)
71
+ if (dotted) return isPrivateHost(dotted)
72
+ return false
73
+ }
74
+
75
+ if (lower.startsWith('::') && lower.includes('.')) {
76
+ const tail = lower.slice(2)
77
+ if (/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(tail)) return isPrivateHost(tail)
78
+ }
79
+
80
+ if (lower.startsWith('2002:')) {
81
+ const m = /^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4})(?::|$)/.exec(lower)
82
+ if (m) {
83
+ const dotted = hexTailToDottedQuad(`${m[1]}:${m[2]}`)
84
+ if (dotted) return isPrivateHost(dotted)
85
+ }
86
+ }
87
+
88
+ if (lower.startsWith('64:ff9b:') || lower.startsWith('0064:ff9b:')) {
89
+ const tail = lower.startsWith('0064:') ? lower.slice(10) : lower.slice(8)
90
+ if (tail.includes('.')) {
91
+ const v4match = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(tail)
92
+ if (v4match?.[1] !== undefined) return isPrivateHost(v4match[1])
93
+ }
94
+ const groups = tail.split(':').filter((g) => g.length > 0)
95
+ if (groups.length >= 2) {
96
+ const dotted = hexTailToDottedQuad(`${groups[groups.length - 2]}:${groups[groups.length - 1]}`)
97
+ if (dotted) return isPrivateHost(dotted)
98
+ }
99
+ }
100
+
101
+ return false
102
+ }
103
+
104
+ return false
105
+ }
106
+
107
+ /**
108
+ * Throw-on-reject URL guard. Use from any strategy whose backend may
109
+ * return adversarial / compromised URLs. `caller` is a short label
110
+ * ('multipart.signPart', 'post.intent', etc.) included in error messages
111
+ * so logs name the source.
112
+ */
113
+ export function validateUploadUrl(
114
+ rawUrl: string,
115
+ caller: string,
116
+ opts: { allowedHosts?: string[]; allowPrivateHosts?: boolean } = {},
117
+ ): void {
118
+ if (typeof rawUrl !== 'string' || rawUrl.length === 0) {
119
+ throw new Error(`${caller} returned an invalid URL (empty or non-string)`)
120
+ }
121
+
122
+ // Cap raw URL length. Real presigned URLs are well under 4 KB; anything
123
+ // larger is either a server bug or an attacker probing the parser.
124
+ if (rawUrl.length > 8192) {
125
+ throw new Error(`${caller} URL exceeds 8192-byte length cap`)
126
+ }
127
+
128
+ if (rawUrl.includes('..')) {
129
+ throw new Error(`${caller} URL contains forbidden ".." segment`)
130
+ }
131
+
132
+ let parsed: URL
133
+ try {
134
+ parsed = new URL(rawUrl)
135
+ } catch {
136
+ throw new Error(`${caller} returned a malformed URL`)
137
+ }
138
+
139
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
140
+ throw new Error(`${caller} URL has forbidden protocol "${parsed.protocol}"`)
141
+ }
142
+
143
+ if (opts.allowedHosts && opts.allowedHosts.length > 0) {
144
+ const host = parsed.host.toLowerCase()
145
+ const list = opts.allowedHosts.map((h) => h.toLowerCase())
146
+ if (!list.includes(host)) {
147
+ throw new Error(`${caller} URL host is not in the configured allow-list`)
148
+ }
149
+ }
150
+
151
+ if (!opts.allowPrivateHosts) {
152
+ if (isPrivateHost(parsed.hostname)) {
153
+ throw new Error(`${caller} URL points to a private/loopback host`)
154
+ }
155
+ }
156
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { fileMatchesAccept, matchesAcceptToken, selectFiles } from '../use-dropzone'
3
+
4
+ function f(name: string, type = ''): File {
5
+ return new File(['x'], name, { type })
6
+ }
7
+
8
+ describe('matchesAcceptToken', () => {
9
+ test('extension token (case-insensitive)', () => {
10
+ expect(matchesAcceptToken(f('photo.PNG'), '.png')).toBe(true)
11
+ expect(matchesAcceptToken(f('doc.pdf'), '.png')).toBe(false)
12
+ })
13
+
14
+ test('exact MIME token', () => {
15
+ expect(matchesAcceptToken(f('a', 'image/png'), 'image/png')).toBe(true)
16
+ expect(matchesAcceptToken(f('a', 'image/jpeg'), 'image/png')).toBe(false)
17
+ })
18
+
19
+ test('MIME wildcard token', () => {
20
+ expect(matchesAcceptToken(f('a', 'image/jpeg'), 'image/*')).toBe(true)
21
+ expect(matchesAcceptToken(f('a', 'video/mp4'), 'image/*')).toBe(false)
22
+ })
23
+
24
+ test('empty/whitespace token never matches', () => {
25
+ expect(matchesAcceptToken(f('a', 'image/png'), ' ')).toBe(false)
26
+ })
27
+ })
28
+
29
+ describe('fileMatchesAccept', () => {
30
+ test('absent or empty accept passes everything', () => {
31
+ expect(fileMatchesAccept(f('a', 'application/zip'))).toBe(true)
32
+ expect(fileMatchesAccept(f('a', 'application/zip'), '')).toBe(true)
33
+ expect(fileMatchesAccept(f('a', 'application/zip'), ' , ')).toBe(true)
34
+ })
35
+
36
+ test('matches when any token matches', () => {
37
+ expect(fileMatchesAccept(f('a.pdf', 'application/pdf'), 'image/*,.pdf')).toBe(true)
38
+ expect(fileMatchesAccept(f('a.png', 'image/png'), 'image/*,.pdf')).toBe(true)
39
+ expect(fileMatchesAccept(f('a.txt', 'text/plain'), 'image/*,.pdf')).toBe(false)
40
+ })
41
+ })
42
+
43
+ describe('selectFiles', () => {
44
+ const files = [f('a.png', 'image/png'), f('b.txt', 'text/plain'), f('c.jpg', 'image/jpeg')]
45
+
46
+ test('filters by accept', () => {
47
+ const out = selectFiles(files, { accept: 'image/*' })
48
+ expect(out.map((x) => x.name)).toEqual(['a.png', 'c.jpg'])
49
+ })
50
+
51
+ test('collapses to one when multiple is false', () => {
52
+ const out = selectFiles(files, { accept: 'image/*', multiple: false })
53
+ expect(out.map((x) => x.name)).toEqual(['a.png'])
54
+ })
55
+
56
+ test('keeps all when multiple omitted (defaults to many)', () => {
57
+ const out = selectFiles(files, {})
58
+ expect(out).toHaveLength(3)
59
+ })
60
+
61
+ test('empty when nothing matches', () => {
62
+ expect(selectFiles(files, { accept: 'video/*' })).toEqual([])
63
+ })
64
+ })
@@ -1,2 +1,5 @@
1
- export * from './upload-provider'
2
- export * from './use-uploader'
1
+ export { isUploadStore, UploadProvider, useUploadStore } from './upload-provider'
2
+ export type { Uploader } from './uploader.types'
3
+ export type { UseDropzoneOptions, UseDropzoneReturn } from './use-dropzone'
4
+ export { fileMatchesAccept, matchesAcceptToken, selectFiles, useDropzone } from './use-dropzone'
5
+ export { createUploadFactory, useUploader, useUploaderActions } from './use-uploader'
@@ -1,121 +1,85 @@
1
- 'use client'
1
+ import { createContext, useContext, useEffect } from 'react'
2
+ import type { Contracts } from '../core'
3
+ import type { Store } from '../core/engine/store'
4
+ import type { Uploader } from './uploader.types'
2
5
 
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
- import { createContext, type ReactNode, useContext } from 'react'
13
- import { type CursorMap, type IntentMap, isRecord, type UploadResultBase } from '../core'
14
- import type { UploadStore } from '../core/engine/store'
15
-
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
- */
24
- const UploadContext = createContext<unknown | null>(null)
6
+ const UploadContext = createContext<Store.UploadStore<any, any, any, any> | null>(null)
25
7
 
26
8
  /**
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
9
+ * Context provider that distributes the upload store across the React component hierarchy.
36
10
  *
37
- * @returns {JSX.Element} Context provider wrapping children
11
+ * Wrap this provider around the top of your layout to enable child hooks like `useUploader`
12
+ * to access upload status.
38
13
  *
39
14
  * @example
40
15
  * ```tsx
41
- * <UploadProvider store={store}>
42
- * <UploadDropzone purpose="avatar" />
43
- * </UploadProvider>
16
+ * const store = createUploadStore({ ... });
17
+ *
18
+ * function App() {
19
+ * return (
20
+ * <UploadProvider store={store}>
21
+ * <UploadForm />
22
+ * </UploadProvider>
23
+ * );
24
+ * }
44
25
  * ```
26
+ * @author wildduck2 <https://github.com/wildduck2>
45
27
  */
46
28
  export function UploadProvider<
47
- M extends IntentMap,
48
- C extends CursorMap<M>,
29
+ M extends Contracts.Intent.Map,
30
+ C extends Contracts.Cursor.Map<M>,
49
31
  P extends string,
50
- R extends UploadResultBase = UploadResultBase,
51
- >({ store, children }: { store: UploadStore<M, C, P, R>; children: ReactNode }): React.JSX.Element {
32
+ R extends Contracts.Result.Base = Contracts.Result.Base,
33
+ >({ store, children }: Uploader.ProviderProps<M, C, P, R>) {
34
+ // Safe cleanup: destroy the store runner instance if the provider is unmounted.
35
+ useEffect(() => {
36
+ return () => {
37
+ if ('destroy' in store && typeof store.destroy === 'function') {
38
+ ;(store as unknown as { destroy: () => void }).destroy()
39
+ }
40
+ }
41
+ }, [store])
42
+
52
43
  return <UploadContext.Provider value={store}>{children}</UploadContext.Provider>
53
44
  }
54
45
 
55
46
  /**
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.
47
+ * Returns the active upload store instance from context.
48
+ * Throws a runtime exception if called outside an `<UploadProvider>`.
49
+ * @author wildduck2 <https://github.com/wildduck2>
61
50
  */
62
- export function useOptionalUploadStore<
63
- M extends IntentMap,
64
- C extends CursorMap<M>,
51
+ export function useUploadStore<
52
+ M extends Contracts.Intent.Map,
53
+ C extends Contracts.Cursor.Map<M>,
65
54
  P extends string,
66
- R extends UploadResultBase = UploadResultBase,
67
- >(): UploadStore<M, C, P, R> | null {
68
- const store = useContext(UploadContext)
69
- if (store === null) {
70
- return null
71
- }
72
-
73
- if (!isUploadStore<M, C, P, R>(store)) {
74
- throw new Error('UploadProvider received an invalid store value')
55
+ R extends Contracts.Result.Base = Contracts.Result.Base,
56
+ >(): Store.UploadStore<M, C, P, R> {
57
+ const context = useContext(UploadContext)
58
+ if (!context) {
59
+ throw new Error('useUploadStore must be used within an <UploadProvider>')
75
60
  }
76
-
77
- return store
61
+ return context as Store.UploadStore<M, C, P, R>
78
62
  }
79
63
 
80
64
  /**
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
- * ```
65
+ * Runtime type guard: returns `true` when `value` exposes the upload-store
66
+ * surface (`getSnapshot`, `subscribe`, `dispatch`, `on`, `off`, `waitFor`).
67
+ * @author wildduck2 <https://github.com/wildduck2>
95
68
  */
96
- export function useUploadStore<
97
- M extends IntentMap,
98
- C extends CursorMap<M>,
69
+ export function isUploadStore<
70
+ M extends Contracts.Intent.Map,
71
+ C extends Contracts.Cursor.Map<M>,
99
72
  P extends string,
100
- R extends UploadResultBase = UploadResultBase,
101
- >(): UploadStore<M, C, P, R> {
102
- const store = useOptionalUploadStore<M, C, P, R>()
103
- if (!store) {
104
- throw new Error('useUploadStore must be used within UploadProvider')
105
- }
106
- return store
107
- }
108
-
109
- function isUploadStore<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
110
- value: unknown,
111
- ): value is UploadStore<M, C, P, R> {
112
- if (!isRecord(value)) return false
73
+ R extends Contracts.Result.Base = Contracts.Result.Base,
74
+ >(value: unknown): value is Store.UploadStore<M, C, P, R> {
75
+ if (typeof value !== 'object' || value === null) return false
76
+ const val = value as Record<string, unknown>
113
77
  return (
114
- typeof value.getSnapshot === 'function' &&
115
- typeof value.subscribe === 'function' &&
116
- typeof value.dispatch === 'function' &&
117
- typeof value.on === 'function' &&
118
- typeof value.off === 'function' &&
119
- typeof value.waitFor === 'function'
78
+ typeof val['getSnapshot'] === 'function' &&
79
+ typeof val['subscribe'] === 'function' &&
80
+ typeof val['dispatch'] === 'function' &&
81
+ typeof val['on'] === 'function' &&
82
+ typeof val['off'] === 'function' &&
83
+ typeof val['waitFor'] === 'function'
120
84
  )
121
85
  }
@@ -0,0 +1,80 @@
1
+ import type React from 'react'
2
+ import type { Contracts, Engine } from '../core'
3
+ import type { Store } from '../core/engine/store'
4
+
5
+ /**
6
+ * Public typings and namespaces consumed by React hook components.
7
+ * @author wildduck2 <https://github.com/wildduck2>
8
+ */
9
+ export namespace Uploader {
10
+ /**
11
+ * Return state payload returned by the `useUploader` hook.
12
+ *
13
+ * @template M - Intent map type
14
+ * @template C - Cursor map type
15
+ * @template P - Purpose string union type
16
+ * @template R - Backend result shape
17
+ */
18
+ export type State<
19
+ M extends Contracts.Intent.Map,
20
+ C extends Contracts.Cursor.Map<M>,
21
+ P extends string,
22
+ R extends Contracts.Result.Base = Contracts.Result.Base,
23
+ > = Pick<Store.UploadStore<M, C, P, R>, 'dispatch' | 'on' | 'off'> & {
24
+ /** List of all registered upload items currently tracked. */
25
+ items: Array<Engine.Item<M, C, P, R>>
26
+
27
+ /** Upload items grouped by phase. */
28
+ byPhase: Record<Engine.Phase, Array<Engine.Item<M, C, P, R>>>
29
+
30
+ /** Subset of items currently active in the transfer phase. */
31
+ uploading: Array<Engine.Item<M, C, P, R>>
32
+
33
+ /** Subset of items currently paused. */
34
+ paused: Array<Engine.Item<M, C, P, R>>
35
+
36
+ /** Subset of items that successfully completed uploading and backend finalization. */
37
+ completed: Array<Engine.Item<M, C, P, R>>
38
+
39
+ /** Subset of items currently in the error state. */
40
+ failed: Array<Engine.Item<M, C, P, R>>
41
+
42
+ /** Subset of items validated and waiting to begin uploading. */
43
+ ready: Array<Engine.Item<M, C, P, R>>
44
+ }
45
+
46
+ /**
47
+ * Bound action callbacks returned by context hooks.
48
+ *
49
+ * Houses both the bound dispatchers and the raw underlying store reference.
50
+ *
51
+ * @template M - Intent map type
52
+ * @template C - Cursor map type
53
+ * @template P - Purpose string union type
54
+ * @template R - Backend result shape
55
+ */
56
+ export type IActions<
57
+ M extends Contracts.Intent.Map,
58
+ C extends Contracts.Cursor.Map<M>,
59
+ P extends string,
60
+ R extends Contracts.Result.Base = Contracts.Result.Base,
61
+ > = Pick<Store.UploadStore<M, C, P, R>, 'dispatch' | 'on'> & {
62
+ /** The raw upload store instance. */
63
+ store: Store.UploadStore<M, C, P, R>
64
+ }
65
+
66
+ /**
67
+ * Props for the `<UploadProvider>` component.
68
+ */
69
+ export interface ProviderProps<
70
+ M extends Contracts.Intent.Map,
71
+ C extends Contracts.Cursor.Map<M>,
72
+ P extends string,
73
+ R extends Contracts.Result.Base = Contracts.Result.Base,
74
+ > {
75
+ /** The upload store instance to distribute. */
76
+ store: Store.UploadStore<M, C, P, R>
77
+ /** React child elements. */
78
+ children: React.ReactNode
79
+ }
80
+ }