@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,112 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { isPrivateHost, validateUploadUrl } from '../url-safety'
|
|
3
|
+
|
|
4
|
+
describe('isPrivateHost - IPv4', () => {
|
|
5
|
+
test('127.0.0.1 (loopback)', () => expect(isPrivateHost('127.0.0.1')).toBe(true))
|
|
6
|
+
test('10.0.0.1 (RFC1918)', () => expect(isPrivateHost('10.0.0.1')).toBe(true))
|
|
7
|
+
test('172.16.0.1 (RFC1918)', () => expect(isPrivateHost('172.16.0.1')).toBe(true))
|
|
8
|
+
test('172.31.255.255 (RFC1918 upper edge)', () => expect(isPrivateHost('172.31.255.255')).toBe(true))
|
|
9
|
+
test('172.32.0.1 (just outside RFC1918)', () => expect(isPrivateHost('172.32.0.1')).toBe(false))
|
|
10
|
+
test('192.168.1.1 (RFC1918)', () => expect(isPrivateHost('192.168.1.1')).toBe(true))
|
|
11
|
+
test('169.254.169.254 (AWS metadata)', () => expect(isPrivateHost('169.254.169.254')).toBe(true))
|
|
12
|
+
test('0.0.0.0 (this-network)', () => expect(isPrivateHost('0.0.0.0')).toBe(true))
|
|
13
|
+
test('100.64.0.1 (CGNAT)', () => expect(isPrivateHost('100.64.0.1')).toBe(true))
|
|
14
|
+
test('224.0.0.1 (multicast)', () => expect(isPrivateHost('224.0.0.1')).toBe(true))
|
|
15
|
+
test('255.255.255.255 (broadcast)', () => expect(isPrivateHost('255.255.255.255')).toBe(true))
|
|
16
|
+
test('8.8.8.8 (public)', () => expect(isPrivateHost('8.8.8.8')).toBe(false))
|
|
17
|
+
test('octet > 255 (malformed)', () => expect(isPrivateHost('256.0.0.0')).toBe(false))
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('isPrivateHost - IPv6', () => {
|
|
21
|
+
test('::1 (loopback)', () => expect(isPrivateHost('::1')).toBe(true))
|
|
22
|
+
test('[::1] bracketed', () => expect(isPrivateHost('[::1]')).toBe(true))
|
|
23
|
+
test(':: (unspecified)', () => expect(isPrivateHost('::')).toBe(true))
|
|
24
|
+
test('fc00::1 (ULA)', () => expect(isPrivateHost('fc00::1')).toBe(true))
|
|
25
|
+
test('fe80::1 (link-local)', () => expect(isPrivateHost('fe80::1')).toBe(true))
|
|
26
|
+
test('ff00::1 (multicast)', () => expect(isPrivateHost('ff00::1')).toBe(true))
|
|
27
|
+
test('2001:db8::1 (documentation)', () => expect(isPrivateHost('2001:db8::1')).toBe(false))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('isPrivateHost - IPv6 embedded IPv4', () => {
|
|
31
|
+
test('::ffff:127.0.0.1 (IPv4-mapped, dotted-quad tail)', () => {
|
|
32
|
+
expect(isPrivateHost('::ffff:127.0.0.1')).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
test('::ffff:7f00:1 (IPv4-mapped, hex tail)', () => {
|
|
35
|
+
expect(isPrivateHost('::ffff:7f00:1')).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
test('::127.0.0.1 (IPv4-compat)', () => expect(isPrivateHost('::127.0.0.1')).toBe(true))
|
|
38
|
+
test('2002:7f00:1:: (6to4 carrying loopback)', () => expect(isPrivateHost('2002:7f00:1::')).toBe(true))
|
|
39
|
+
test('64:ff9b::127.0.0.1 (NAT64 carrying loopback)', () => {
|
|
40
|
+
expect(isPrivateHost('64:ff9b::127.0.0.1')).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('isPrivateHost - hostnames are NOT IP literals', () => {
|
|
45
|
+
test('example.com (FQDN)', () => expect(isPrivateHost('example.com')).toBe(false))
|
|
46
|
+
test('localhost (resolves to loopback at runtime; not literal)', () => {
|
|
47
|
+
expect(isPrivateHost('localhost')).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('validateUploadUrl - rejected inputs', () => {
|
|
52
|
+
test('empty', () => expect(() => validateUploadUrl('', 'test')).toThrow(/empty or non-string/))
|
|
53
|
+
test('non-string', () => expect(() => validateUploadUrl(null as unknown as string, 'test')).toThrow())
|
|
54
|
+
test('path traversal', () => expect(() => validateUploadUrl('https://x.com/../etc', 'test')).toThrow(/forbidden/))
|
|
55
|
+
test('malformed', () => expect(() => validateUploadUrl('not a url', 'test')).toThrow(/malformed/))
|
|
56
|
+
test('file://', () => expect(() => validateUploadUrl('file:///etc/passwd', 'test')).toThrow(/forbidden protocol/))
|
|
57
|
+
test('javascript:', () =>
|
|
58
|
+
expect(() => validateUploadUrl('javascript:alert(1)', 'test')).toThrow(/forbidden protocol/))
|
|
59
|
+
test('data:', () => expect(() => validateUploadUrl('data:text/html,evil', 'test')).toThrow(/forbidden protocol/))
|
|
60
|
+
test('private host', () => expect(() => validateUploadUrl('https://127.0.0.1/x', 'test')).toThrow(/private/))
|
|
61
|
+
test('AWS metadata', () =>
|
|
62
|
+
expect(() => validateUploadUrl('https://169.254.169.254/latest/meta-data/', 'test')).toThrow(/private/))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('validateUploadUrl - allowedHosts', () => {
|
|
66
|
+
test('host in allowlist passes', () => {
|
|
67
|
+
expect(() =>
|
|
68
|
+
validateUploadUrl('https://up.example.com/x', 'test', { allowedHosts: ['up.example.com'] }),
|
|
69
|
+
).not.toThrow()
|
|
70
|
+
})
|
|
71
|
+
test('host outside allowlist rejected', () => {
|
|
72
|
+
expect(() => validateUploadUrl('https://evil.example.com/x', 'test', { allowedHosts: ['up.example.com'] })).toThrow(
|
|
73
|
+
/allow-list/,
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
test('case-insensitive match', () => {
|
|
77
|
+
expect(() =>
|
|
78
|
+
validateUploadUrl('https://UP.Example.COM/x', 'test', { allowedHosts: ['up.example.com'] }),
|
|
79
|
+
).not.toThrow()
|
|
80
|
+
})
|
|
81
|
+
test('host with port matches list with port', () => {
|
|
82
|
+
expect(() =>
|
|
83
|
+
validateUploadUrl('https://up.example.com:8443/x', 'test', { allowedHosts: ['up.example.com:8443'] }),
|
|
84
|
+
).not.toThrow()
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('validateUploadUrl - length cap', () => {
|
|
89
|
+
test('rejects URL > 8192 bytes', () => {
|
|
90
|
+
const huge = `https://x.com/${'a'.repeat(9000)}`
|
|
91
|
+
expect(() => validateUploadUrl(huge, 'test')).toThrow(/exceeds 8192-byte/)
|
|
92
|
+
})
|
|
93
|
+
test('accepts URL at the 8192 boundary', () => {
|
|
94
|
+
const base = 'https://x.example.com/'
|
|
95
|
+
const padded = base + 'a'.repeat(8192 - base.length)
|
|
96
|
+
expect(padded.length).toBe(8192)
|
|
97
|
+
expect(() => validateUploadUrl(padded, 'test')).not.toThrow()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('validateUploadUrl - allowPrivateHosts opt-in', () => {
|
|
102
|
+
test('127.0.0.1 passes when allowPrivateHosts true', () => {
|
|
103
|
+
expect(() => validateUploadUrl('https://127.0.0.1/x', 'test', { allowPrivateHosts: true })).not.toThrow()
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('validateUploadUrl - caller label in error messages', () => {
|
|
108
|
+
test('throws include the caller label', () => {
|
|
109
|
+
expect(() => validateUploadUrl('file:///x', 'multipart.signPart')).toThrow(/multipart\.signPart/)
|
|
110
|
+
expect(() => validateUploadUrl('https://127.0.0.1/x', 'post.intent')).toThrow(/post\.intent/)
|
|
111
|
+
})
|
|
112
|
+
})
|
package/src/core/utils/async.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Awaitable timer. Clamps `ms` to `[0, 86_400_000]` (24h) so a NaN /
|
|
3
|
+
* Infinity / negative argument can't wedge the scheduler.
|
|
4
|
+
*/
|
|
5
|
+
export function sleep(ms: number): Promise<void> {
|
|
6
|
+
const clamped = Number.isFinite(ms) && ms > 0 ? Math.min(ms, 86_400_000) : 0
|
|
7
|
+
return new Promise<void>((resolve) => setTimeout(resolve, clamped))
|
|
3
8
|
}
|
|
@@ -16,17 +16,11 @@ export const DEFAULT_RETRY_DELAY_MAX_MS = 10_000
|
|
|
16
16
|
*/
|
|
17
17
|
export const DEFAULT_PROGRESS_THROTTLE_MS = 100
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
* Default maximum number of retry attempts.
|
|
21
|
-
*/
|
|
19
|
+
/** Default maximum number of retry attempts. */
|
|
22
20
|
export const DEFAULT_MAX_ATTEMPTS = 3
|
|
23
21
|
|
|
24
|
-
/**
|
|
25
|
-
* Default maximum number of concurrent uploads.
|
|
26
|
-
*/
|
|
22
|
+
/** Default maximum number of concurrent uploads. */
|
|
27
23
|
export const DEFAULT_MAX_CONCURRENT_UPLOADS = 3
|
|
28
24
|
|
|
29
|
-
/**
|
|
30
|
-
* Default maximum number of items to keep in memory.
|
|
31
|
-
*/
|
|
25
|
+
/** Default maximum number of items to keep in memory. */
|
|
32
26
|
export const DEFAULT_MAX_ITEMS = 100
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { isDevEnv } from '../guards'
|
|
2
|
+
import type { Emitter } from './emitter.types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Soft cap on nested `emit` depth. A listener that synchronously dispatches a
|
|
6
|
+
* command which fires another emit is legal -- but unbounded re-entry usually
|
|
7
|
+
* means a feedback loop. The cap surfaces it in dev without aborting.
|
|
8
|
+
*/
|
|
9
|
+
const MAX_EMIT_DEPTH = 50
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a typed event emitter with listener-throw isolation.
|
|
13
|
+
*
|
|
14
|
+
* Listeners added or removed during an `emit` see a stable snapshot of the
|
|
15
|
+
* listener set; the mutation lands on the *next* emission.
|
|
16
|
+
*
|
|
17
|
+
* @param onError Optional error sink. Receives `(type, error, listener)` on
|
|
18
|
+
* every listener throw. Falls back to dev-only `console.error` when omitted.
|
|
19
|
+
* @returns Configured emitter exposing `on` / `off` / `emit`.
|
|
20
|
+
* @template E Event map (`Record<eventName, payloadType>`).
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* type Events = { 'file.added': { id: string }, 'upload.progress': { pct: number } }
|
|
24
|
+
* const emitter = createTypedEmitter<Events>()
|
|
25
|
+
* emitter.on('file.added', (p) => console.log(p.id))
|
|
26
|
+
* ```
|
|
27
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
28
|
+
*/
|
|
29
|
+
export function createTypedEmitter<E extends Record<string, unknown>>(
|
|
30
|
+
onError?: Emitter.ErrorHandler,
|
|
31
|
+
): Emitter.TypedEmitter<E> {
|
|
32
|
+
const listeners: Emitter.ListenerMap<E> = {}
|
|
33
|
+
// Memoized iteration snapshots per event type. Invalidated on `on`/`off`.
|
|
34
|
+
// Multi-listener `emit` would otherwise allocate a fresh array every call;
|
|
35
|
+
// for progress streams that adds up to one Set + spread per progress tick.
|
|
36
|
+
const snapshots = new Map<string, ReadonlyArray<(payload: unknown) => void>>()
|
|
37
|
+
let emitDepth = 0
|
|
38
|
+
let warnedDepth = false
|
|
39
|
+
|
|
40
|
+
const invalidate = (type: string) => {
|
|
41
|
+
if (snapshots.size > 0) snapshots.delete(type)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
on<K extends keyof E & string>(type: K, cb: (payload: E[K]) => void): () => void {
|
|
46
|
+
let set = listeners[type]
|
|
47
|
+
if (!set) {
|
|
48
|
+
set = new Set<(payload: E[K]) => void>()
|
|
49
|
+
listeners[type] = set
|
|
50
|
+
}
|
|
51
|
+
set.add(cb)
|
|
52
|
+
invalidate(type)
|
|
53
|
+
|
|
54
|
+
return () => {
|
|
55
|
+
const typeListeners = listeners[type]
|
|
56
|
+
if (!typeListeners) return
|
|
57
|
+
typeListeners.delete(cb)
|
|
58
|
+
invalidate(type)
|
|
59
|
+
if (typeListeners.size === 0) delete listeners[type]
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
off<K extends keyof E & string>(type: K, cb: (payload: E[K]) => void): void {
|
|
64
|
+
const typeListeners = listeners[type]
|
|
65
|
+
if (!typeListeners) return
|
|
66
|
+
typeListeners.delete(cb)
|
|
67
|
+
invalidate(type)
|
|
68
|
+
if (typeListeners.size === 0) delete listeners[type]
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
emit<K extends keyof E & string>(type: K, payload: E[K]): void {
|
|
72
|
+
const typeListeners = listeners[type]
|
|
73
|
+
if (!typeListeners || typeListeners.size === 0) return
|
|
74
|
+
|
|
75
|
+
const dispatch = (cb: (payload: E[K]) => void) => {
|
|
76
|
+
try {
|
|
77
|
+
cb(payload)
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (onError) {
|
|
80
|
+
try {
|
|
81
|
+
onError(type, err, cb as (...args: unknown[]) => void)
|
|
82
|
+
} catch {}
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if (isDevEnv()) {
|
|
86
|
+
console.error(`[UploadEngine] Error in event listener for "${type}":`, err)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
emitDepth++
|
|
92
|
+
try {
|
|
93
|
+
if (emitDepth > MAX_EMIT_DEPTH && !warnedDepth && isDevEnv()) {
|
|
94
|
+
warnedDepth = true
|
|
95
|
+
console.warn(
|
|
96
|
+
`[UploadEngine] emit depth exceeded ${MAX_EMIT_DEPTH} for "${type}". ` +
|
|
97
|
+
'A listener is likely dispatching a command that re-emits, forming a feedback loop. ' +
|
|
98
|
+
'Defer the dispatch with `queueMicrotask` to break the chain.',
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Size-1 fast path: skip the snapshot allocation when only one
|
|
103
|
+
// listener is attached. Hot path under busy progress streams where
|
|
104
|
+
// most subscribers attach exactly one listener per event type.
|
|
105
|
+
if (typeListeners.size === 1) {
|
|
106
|
+
const [only] = typeListeners
|
|
107
|
+
if (only) dispatch(only)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Multi-listener path: reuse the memoized snapshot so `on()`/`off()`
|
|
112
|
+
// from inside a listener doesn't reshape iteration mid-flight AND
|
|
113
|
+
// back-to-back emits don't re-allocate the array.
|
|
114
|
+
let snapshot = snapshots.get(type) as ReadonlyArray<(payload: E[K]) => void> | undefined
|
|
115
|
+
if (!snapshot) {
|
|
116
|
+
snapshot = Array.from(typeListeners) as ReadonlyArray<(payload: E[K]) => void>
|
|
117
|
+
snapshots.set(type, snapshot as ReadonlyArray<(payload: unknown) => void>)
|
|
118
|
+
}
|
|
119
|
+
for (const cb of snapshot) dispatch(cb)
|
|
120
|
+
} finally {
|
|
121
|
+
emitDepth--
|
|
122
|
+
if (emitDepth === 0) warnedDepth = false
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -1,23 +1,40 @@
|
|
|
1
|
-
|
|
2
|
-
* Minimal typed event emitter interface.
|
|
3
|
-
*
|
|
4
|
-
* Use this to expose strongly-typed events from your store.
|
|
5
|
-
*
|
|
6
|
-
* @typeParam E - Event map (`eventName -> payload`).
|
|
7
|
-
*/
|
|
8
|
-
export type TypedEmitter<E extends Record<string, unknown>> = {
|
|
1
|
+
export namespace Emitter {
|
|
9
2
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
3
|
+
* Minimal typed event emitter interface.
|
|
4
|
+
*
|
|
5
|
+
* Use this to expose strongly-typed events from your store.
|
|
6
|
+
*
|
|
7
|
+
* @typeParam E - Event map (`eventName -> payload`).
|
|
12
8
|
*/
|
|
13
|
-
|
|
9
|
+
export type TypedEmitter<E extends Record<string, unknown>> = {
|
|
10
|
+
/**
|
|
11
|
+
* Subscribe to an event.
|
|
12
|
+
* @returns Unsubscribe function.
|
|
13
|
+
*/
|
|
14
|
+
on<K extends keyof E & string>(type: K, cb: (payload: E[K]) => void): () => void
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Unsubscribe from an event.
|
|
18
|
+
*/
|
|
19
|
+
off<K extends keyof E & string>(type: K, cb: (payload: E[K]) => void): void
|
|
20
|
+
|
|
21
|
+
/** Emit an event to all listeners. */
|
|
22
|
+
emit<K extends keyof E & string>(type: K, payload: E[K]): void
|
|
23
|
+
}
|
|
14
24
|
|
|
15
25
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
26
|
+
* Callback invoked when a listener throws during `emit`, so one bad listener
|
|
27
|
+
* cannot break the dispatch loop. Receives the event type, the thrown error,
|
|
28
|
+
* and the listener that threw.
|
|
18
29
|
*/
|
|
19
|
-
|
|
30
|
+
export type ErrorHandler = (type: string, error: unknown, cb: (...args: any[]) => void) => void
|
|
20
31
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Listener registry, bucketed by event type.
|
|
34
|
+
*
|
|
35
|
+
* @template E Event map.
|
|
36
|
+
*/
|
|
37
|
+
export type ListenerMap<E extends Record<string, unknown>> = Partial<{
|
|
38
|
+
[K in keyof E & string]: Set<(payload: E[K]) => void>
|
|
39
|
+
}>
|
|
23
40
|
}
|
|
@@ -1,74 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
export type { TypedEmitter } from './emitter.types'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Creates a lightweight, typed event emitter.
|
|
7
|
-
* We use this instead of `EventEmitter` or `EventTarget` to ensure:
|
|
8
|
-
* 1. Strict typing of event payloads (via generic E)
|
|
9
|
-
* 2. No dependency on Node.js built-ins (browser compatible)
|
|
10
|
-
* 3. Exception safety (user listeners don't crash the loop)
|
|
11
|
-
*
|
|
12
|
-
* @template E - Event map type (Record<eventName, payloadType>)
|
|
13
|
-
*
|
|
14
|
-
* @returns {TypedEmitter<E>} Typed emitter instance
|
|
15
|
-
*
|
|
16
|
-
* @example
|
|
17
|
-
* ```ts
|
|
18
|
-
* type Events = { 'file.added': { id: string }, 'upload.progress': { pct: number } }
|
|
19
|
-
* const emitter = createTypedEmitter<Events>()
|
|
20
|
-
* emitter.on('file.added', (payload) => console.log(payload.id))
|
|
21
|
-
* ```
|
|
22
|
-
*/
|
|
23
|
-
type ListenerMap<E extends Record<string, unknown>> = Partial<{
|
|
24
|
-
[K in keyof E & string]: Set<(payload: E[K]) => void>
|
|
25
|
-
}>
|
|
26
|
-
|
|
27
|
-
export function createTypedEmitter<E extends Record<string, unknown>>(): TypedEmitter<E> {
|
|
28
|
-
const listeners: ListenerMap<E> = {}
|
|
29
|
-
|
|
30
|
-
return {
|
|
31
|
-
on<K extends keyof E & string>(type: K, cb: (payload: E[K]) => void): () => void {
|
|
32
|
-
// Create listener set for this event type if it doesn't exist
|
|
33
|
-
let set = listeners[type]
|
|
34
|
-
if (!set) {
|
|
35
|
-
set = new Set<(payload: E[K]) => void>()
|
|
36
|
-
listeners[type] = set
|
|
37
|
-
}
|
|
38
|
-
set.add(cb)
|
|
39
|
-
|
|
40
|
-
// Return unsubscribe function
|
|
41
|
-
return () => {
|
|
42
|
-
const typeListeners = listeners[type]
|
|
43
|
-
if (!typeListeners) return
|
|
44
|
-
typeListeners.delete(cb)
|
|
45
|
-
if (typeListeners.size === 0) delete listeners[type]
|
|
46
|
-
}
|
|
47
|
-
},
|
|
48
|
-
|
|
49
|
-
off<K extends keyof E & string>(type: K, cb: (payload: E[K]) => void): () => void {
|
|
50
|
-
const typeListeners = listeners[type]
|
|
51
|
-
if (!typeListeners) return () => {}
|
|
52
|
-
typeListeners.delete(cb)
|
|
53
|
-
if (typeListeners.size === 0) delete listeners[type]
|
|
54
|
-
return () => {}
|
|
55
|
-
},
|
|
56
|
-
|
|
57
|
-
emit<K extends keyof E & string>(type: K, payload: E[K]): void {
|
|
58
|
-
const typeListeners = listeners[type]
|
|
59
|
-
if (!typeListeners) return
|
|
60
|
-
|
|
61
|
-
typeListeners.forEach((cb) => {
|
|
62
|
-
try {
|
|
63
|
-
cb(payload)
|
|
64
|
-
} catch (err) {
|
|
65
|
-
// Do not crash upload engine due to user callbacks
|
|
66
|
-
// In production, this should be sent to error tracking service
|
|
67
|
-
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
|
68
|
-
console.error(`[UploadEngine] Error in event listener for "${type}":`, err)
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
})
|
|
72
|
-
},
|
|
73
|
-
}
|
|
74
|
-
}
|
|
1
|
+
export { createTypedEmitter } from './emitter'
|
|
2
|
+
export type { Emitter } from './emitter.types'
|
|
@@ -1,20 +1,31 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Contracts } from '../contracts'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Builds a lightweight fingerprint from a {@link File}.
|
|
5
|
+
*
|
|
6
|
+
* MIME is normalized: parameters after `;` are stripped (Bun's File
|
|
7
|
+
* constructor appends `;charset=utf-8` to text/* types; browsers send
|
|
8
|
+
* the bare MIME via FormData). Persisted fingerprints stay portable
|
|
9
|
+
* across runtimes.
|
|
5
10
|
*/
|
|
6
|
-
export function computeFingerprint(file: File):
|
|
11
|
+
export function computeFingerprint(file: File): Contracts.FingerprintFile {
|
|
7
12
|
return {
|
|
8
13
|
name: file.name,
|
|
9
14
|
size: file.size,
|
|
10
|
-
type: file.type,
|
|
15
|
+
type: normalizeMime(file.type),
|
|
11
16
|
lastModified: file.lastModified,
|
|
12
17
|
}
|
|
13
18
|
}
|
|
14
19
|
|
|
20
|
+
function normalizeMime(mime: string): string {
|
|
21
|
+
const semi = mime.indexOf(';')
|
|
22
|
+
return semi === -1 ? mime.trim() : mime.slice(0, semi).trim()
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
/**
|
|
16
|
-
*
|
|
26
|
+
* `type` is intentionally NOT compared: a re-picked file with the same
|
|
27
|
+
* bytes but a different runtime-inferred MIME is still the same file.
|
|
17
28
|
*/
|
|
18
|
-
export function fingerprintMatches(a:
|
|
29
|
+
export function fingerprintMatches(a: Contracts.FingerprintFile, b: Contracts.FingerprintFile): boolean {
|
|
19
30
|
return a.name === b.name && a.size === b.size && a.lastModified === b.lastModified
|
|
20
31
|
}
|
package/src/core/utils/guards.ts
CHANGED
|
@@ -1,3 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maximum recursion depth for {@link stripDangerousKeys}. Values nested deeper
|
|
3
|
+
* are left as-is, guarding against pathological or cyclic persistence payloads
|
|
4
|
+
* without aborting hydration.
|
|
5
|
+
*/
|
|
6
|
+
const MAX_STRIP_DEPTH = 16
|
|
7
|
+
|
|
8
|
+
const DANGEROUS_KEYS: ReadonlySet<string> = new Set(['__proto__', 'constructor', 'prototype'])
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Narrow guard for plain records (object literal or `Object.create(null)`).
|
|
12
|
+
* Rejects arrays, `Date`, `Map`, `Set`, and class instances — only
|
|
13
|
+
* `null`-prototype or `Object.prototype` objects pass. Keeps arrays and class
|
|
14
|
+
* instances out of runtime state during snapshot hydration.
|
|
15
|
+
*/
|
|
1
16
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
2
|
-
|
|
17
|
+
if (value === null || typeof value !== 'object') return false
|
|
18
|
+
const proto = Object.getPrototypeOf(value)
|
|
19
|
+
return proto === null || proto === Object.prototype
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Safe `NODE_ENV === 'development'` check for dev-only diagnostics (console
|
|
24
|
+
* warnings/errors that must never affect control flow).
|
|
25
|
+
*
|
|
26
|
+
* Bundlers that replace `process.env.NODE_ENV` (Vite, esbuild, webpack's
|
|
27
|
+
* DefinePlugin) only match that exact dotted-property expression - a bare
|
|
28
|
+
* `process` reference is never actually defined in the shipped browser
|
|
29
|
+
* bundle. Reading `process.env['NODE_ENV']` (or any other form the bundler
|
|
30
|
+
* doesn't statically match) throws `ReferenceError: process is not defined`
|
|
31
|
+
* at runtime instead of the constant-folded string, turning a should-be-inert
|
|
32
|
+
* dev warning into a hard crash. Guarding with `typeof process !== 'undefined'`
|
|
33
|
+
* avoids ever touching the global unless something has actually defined it.
|
|
34
|
+
*/
|
|
35
|
+
export function isDevEnv(): boolean {
|
|
36
|
+
return typeof window !== 'undefined' && typeof process !== 'undefined' && process.env?.['NODE_ENV'] === 'development'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Recursively strip `__proto__`, `constructor`, and `prototype` keys from a
|
|
41
|
+
* parsed value tree before it is spread into runtime state. Arrays are
|
|
42
|
+
* traversed; exotic objects (Date, Map, class instances) are returned
|
|
43
|
+
* untouched. Uses an explicit work stack so deeply nested payloads cannot
|
|
44
|
+
* overflow the call stack, and stops at {@link MAX_STRIP_DEPTH} to stay cycle-
|
|
45
|
+
* and depth-safe.
|
|
46
|
+
*/
|
|
47
|
+
export function stripDangerousKeys<T>(input: T): T {
|
|
48
|
+
if (input === null || typeof input !== 'object') return input
|
|
49
|
+
|
|
50
|
+
type Frame = { value: object; depth: number }
|
|
51
|
+
const stack: Frame[] = [{ value: input as unknown as object, depth: 0 }]
|
|
52
|
+
const seen = new WeakSet<object>()
|
|
53
|
+
|
|
54
|
+
while (stack.length > 0) {
|
|
55
|
+
const frame = stack.pop()
|
|
56
|
+
if (!frame) break
|
|
57
|
+
|
|
58
|
+
const { value, depth } = frame
|
|
59
|
+
if (seen.has(value)) continue
|
|
60
|
+
seen.add(value)
|
|
61
|
+
|
|
62
|
+
if (depth >= MAX_STRIP_DEPTH) continue
|
|
63
|
+
|
|
64
|
+
if (Array.isArray(value)) {
|
|
65
|
+
for (const child of value) {
|
|
66
|
+
if (child !== null && typeof child === 'object') {
|
|
67
|
+
stack.push({ value: child, depth: depth + 1 })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const proto = Object.getPrototypeOf(value)
|
|
74
|
+
if (proto !== null && proto !== Object.prototype) {
|
|
75
|
+
// Exotic object (Date, Map, class instance) — leave untouched.
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const obj = value as Record<string, unknown>
|
|
80
|
+
for (const key of Object.keys(obj)) {
|
|
81
|
+
if (DANGEROUS_KEYS.has(key)) {
|
|
82
|
+
delete obj[key]
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
const child = obj[key]
|
|
86
|
+
if (child !== null && typeof child === 'object') {
|
|
87
|
+
stack.push({ value: child, depth: depth + 1 })
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return input
|
|
3
93
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { hashBlob, sha256HexOfBlob } from '../index'
|
|
3
|
+
|
|
4
|
+
/** Reference digest via native Web Crypto. */
|
|
5
|
+
async function subtleHex(bytes: Uint8Array): Promise<string> {
|
|
6
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes as unknown as BufferSource)
|
|
7
|
+
return Array.from(new Uint8Array(digest))
|
|
8
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
9
|
+
.join('')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('sha256HexOfBlob', () => {
|
|
13
|
+
it('matches known vectors', async () => {
|
|
14
|
+
// FIPS 180-2 vectors.
|
|
15
|
+
expect(await sha256HexOfBlob(new Blob([]))).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
|
|
16
|
+
expect(await sha256HexOfBlob(new Blob(['abc']))).toBe(
|
|
17
|
+
'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
|
|
18
|
+
)
|
|
19
|
+
expect(await sha256HexOfBlob(new Blob(['abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq']))).toBe(
|
|
20
|
+
'248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1',
|
|
21
|
+
)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('is chunk-size invariant', async () => {
|
|
25
|
+
const bytes = new Uint8Array(4096)
|
|
26
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 131 + 7) & 0xff
|
|
27
|
+
const blob = new Blob([bytes])
|
|
28
|
+
const expected = await subtleHex(bytes)
|
|
29
|
+
for (const chunkSize of [1, 3, 55, 63, 64, 65, 128, 1000, 4096, 8192]) {
|
|
30
|
+
expect(await sha256HexOfBlob(blob, { chunkSize })).toBe(expected)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('hashes across the 55/56/64-byte padding boundaries', async () => {
|
|
35
|
+
for (const n of [0, 1, 55, 56, 57, 63, 64, 65, 119, 120, 128]) {
|
|
36
|
+
const bytes = new Uint8Array(n)
|
|
37
|
+
for (let i = 0; i < n; i++) bytes[i] = (i * 17) & 0xff
|
|
38
|
+
expect(await sha256HexOfBlob(new Blob([bytes]), { chunkSize: 7 })).toBe(await subtleHex(bytes))
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('aborts between slices', async () => {
|
|
43
|
+
const controller = new AbortController()
|
|
44
|
+
controller.abort()
|
|
45
|
+
await expect(
|
|
46
|
+
sha256HexOfBlob(new Blob([new Uint8Array(1000)]), { chunkSize: 10, signal: controller.signal }),
|
|
47
|
+
).rejects.toThrow(/abort/i)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('hashBlob', () => {
|
|
52
|
+
it('inline and incremental paths agree', async () => {
|
|
53
|
+
const bytes = new Uint8Array(2048)
|
|
54
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 7 + 3) & 0xff
|
|
55
|
+
const blob = new Blob([bytes])
|
|
56
|
+
const inline = await hashBlob(blob, { inlineMaxSize: 4096 }) // <= boundary -> subtle
|
|
57
|
+
const incremental = await hashBlob(blob, { inlineMaxSize: 16, chunkSize: 100 }) // > boundary -> incremental
|
|
58
|
+
expect(inline).toBe(incremental)
|
|
59
|
+
expect(inline).toBe(await subtleHex(bytes))
|
|
60
|
+
})
|
|
61
|
+
})
|