@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.
- package/README.md +297 -0
- package/dist/core/index.d.ts +2 -3
- package/dist/core/index.js +1 -1
- package/dist/core-BVgloTiP.js +10 -0
- package/dist/index-DCqyBVcx.d.ts +1441 -0
- package/dist/index-DsZHPmJm.d.ts +225 -0
- package/dist/index-KROXtLQh.d.ts +281 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +2 -90
- package/dist/react/index.js +1 -1
- package/dist/react-euPrDv24.js +1 -0
- package/dist/strategies/index.d.ts +2 -56
- package/dist/strategies/index.js +1 -1
- package/dist/strategies-CUF0s68j.js +1 -0
- package/package.json +3 -2
- package/src/__tests__/emitter.test.ts +85 -0
- package/src/__tests__/fingerprint.test.ts +49 -0
- package/src/__tests__/guards.test.ts +40 -0
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/__tests__/persistence.test.ts +191 -0
- package/src/__tests__/sanitize-filename.test.ts +109 -0
- package/src/__tests__/sleep.test.ts +34 -0
- package/src/__tests__/static-analysis.test.ts +145 -0
- package/src/__tests__/store-libs.test.ts +192 -0
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/__tests__/transport-libs.test.ts +42 -0
- package/src/__tests__/transport.test.ts +182 -0
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +373 -0
- package/src/core/contracts/index.ts +3 -8
- package/src/core/contracts/transport/index.ts +3 -2
- package/src/core/contracts/transport/transport.fetch.ts +203 -0
- package/src/core/contracts/transport/transport.libs.ts +13 -39
- package/src/core/contracts/transport/transport.ts +96 -90
- package/src/core/contracts/transport/transport.types.ts +71 -35
- package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
- package/src/core/engine/engine.libs.ts +35 -0
- package/src/core/engine/engine.types.ts +490 -0
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +93 -53
- package/src/core/engine/store/handlers/add-file.ts +43 -48
- package/src/core/engine/store/handlers/cancel.ts +13 -8
- package/src/core/engine/store/handlers/clean-up.ts +11 -8
- package/src/core/engine/store/handlers/create-intent.ts +82 -27
- package/src/core/engine/store/handlers/finalize.ts +30 -13
- package/src/core/engine/store/handlers/index.ts +7 -7
- package/src/core/engine/store/handlers/pause.ts +8 -6
- package/src/core/engine/store/handlers/run-upload.ts +54 -39
- package/src/core/engine/store/index.ts +27 -21
- package/src/core/engine/store/store.dispatch.ts +10 -8
- package/src/core/engine/store/store.libs.ts +197 -94
- package/src/core/engine/store/store.runtime.ts +52 -53
- package/src/core/engine/store/store.schedule.ts +27 -21
- package/src/core/engine/store/store.types.ts +177 -227
- package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
- package/src/core/engine/validation/file.ts +8 -4
- package/src/core/engine/validation/index.ts +1 -0
- package/src/core/engine/validation/intent.ts +49 -27
- package/src/core/engine/validation/list.ts +14 -15
- package/src/core/engine/validation/mime.ts +39 -0
- package/src/core/errors.ts +271 -0
- package/src/core/index.ts +28 -5
- package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
- package/src/core/persistence/__tests__/persistence.test.ts +85 -0
- package/src/core/persistence/adapters.indexeddb.ts +9 -3
- package/src/core/persistence/adapters.local.ts +9 -3
- package/src/core/persistence/adapters.memory.ts +35 -13
- package/src/core/persistence/index.ts +5 -5
- package/src/core/persistence/persistence.ts +73 -60
- package/src/core/persistence/persistence.types.ts +140 -60
- package/src/core/utils/__tests__/guards.test.ts +93 -0
- package/src/core/utils/__tests__/url-safety.test.ts +112 -0
- package/src/core/utils/async.ts +7 -2
- package/src/core/utils/constants.ts +3 -9
- package/src/core/utils/emitter/emitter.ts +126 -0
- package/src/core/utils/emitter/emitter.types.ts +33 -16
- package/src/core/utils/emitter/index.ts +2 -74
- package/src/core/utils/fingerprint.ts +16 -5
- package/src/core/utils/guards.ts +91 -1
- package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
- package/src/core/utils/hash/index.ts +126 -0
- package/src/core/utils/hash/sha256.ts +179 -0
- package/src/core/utils/id.ts +33 -1
- package/src/core/utils/index.ts +15 -6
- package/src/core/utils/mime-sniff.ts +119 -0
- package/src/core/utils/sanitize-filename.ts +85 -0
- package/src/core/utils/url-safety.ts +156 -0
- package/src/react/__tests__/use-dropzone.test.ts +64 -0
- package/src/react/index.ts +5 -2
- package/src/react/upload-provider.tsx +59 -95
- package/src/react/uploader.types.ts +80 -0
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +97 -93
- package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
- package/src/strategies/_shared/retry.ts +79 -0
- package/src/strategies/index.ts +9 -15
- package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
- package/src/strategies/multipart/index.ts +211 -116
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +113 -71
- package/src/strategies/put/__tests__/put.test.ts +132 -0
- package/src/strategies/put/index.ts +139 -0
- package/src/strategies/registry/index.ts +12 -9
- package/src/strategies/tus/__tests__/tus.test.ts +159 -0
- package/src/strategies/tus/index.ts +211 -0
- package/dist/async-DxRWu4le.js +0 -1
- package/dist/core-BNxznRif.js +0 -2
- package/dist/index-BXg9WIce.d.ts +0 -987
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- package/src/core/client/config.types.ts +0 -77
- package/src/core/client/index.ts +0 -4
- package/src/core/client/plugins.types.ts +0 -46
- package/src/core/client/retry.types.ts +0 -6
- package/src/core/client/uploadClient.ts +0 -19
- package/src/core/contracts/backendApi.ts +0 -62
- package/src/core/contracts/cursor.types.ts +0 -23
- package/src/core/contracts/errors.types.ts +0 -67
- package/src/core/contracts/fingerprint.types.ts +0 -18
- package/src/core/contracts/intent.types.ts +0 -39
- package/src/core/contracts/result.types.ts +0 -11
- package/src/core/contracts/strategy.types.ts +0 -45
- package/src/core/engine/commands.types.ts +0 -17
- package/src/core/engine/event-map.types.ts +0 -45
- package/src/core/engine/internal-events.types.ts +0 -222
- package/src/core/engine/outcome.types.ts +0 -9
- 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
|
+
})
|
package/src/react/index.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
export
|
|
2
|
-
export
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
48
|
-
C extends
|
|
29
|
+
M extends Contracts.Intent.Map,
|
|
30
|
+
C extends Contracts.Cursor.Map<M>,
|
|
49
31
|
P extends string,
|
|
50
|
-
R extends
|
|
51
|
-
>({ store, children }:
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
63
|
-
M extends
|
|
64
|
-
C extends
|
|
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
|
|
67
|
-
>(): UploadStore<M, C, P, R>
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
70
|
-
|
|
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
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* @
|
|
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
|
|
97
|
-
M extends
|
|
98
|
-
C extends
|
|
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
|
|
101
|
-
>(): UploadStore<M, C, P, R> {
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
115
|
-
typeof
|
|
116
|
-
typeof
|
|
117
|
-
typeof
|
|
118
|
-
typeof
|
|
119
|
-
typeof
|
|
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
|
+
}
|