@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,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
+ })
@@ -1,3 +1,8 @@
1
- export function sleep(ms: number) {
2
- return new Promise<void>((resolve) => setTimeout(resolve, ms))
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
- * Subscribe to an event.
11
- * @returns Unsubscribe function.
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
- on<K extends keyof E & string>(type: K, cb: (payload: E[K]) => void): () => void
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
- * Unsubscribe from an event.
17
- * @returns Unsubscribe function.
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
- off<K extends keyof E & string>(type: K, cb: (payload: E[K]) => void): () => void
30
+ export type ErrorHandler = (type: string, error: unknown, cb: (...args: any[]) => void) => void
20
31
 
21
- /** Emit an event to all listeners. */
22
- emit<K extends keyof E & string>(type: K, payload: E[K]): void
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
- import type { TypedEmitter } from './emitter.types'
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 { FileFingerprint } from '../contracts'
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): FileFingerprint {
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
- * Compares two file fingerprints.
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: FileFingerprint, b: FileFingerprint): boolean {
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
  }
@@ -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
- return !!value && typeof value === 'object'
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
+ })