@gentleduck/upload 0.2.6 → 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 (136) hide show
  1. package/README.md +170 -193
  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 -51
  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/{__test__ → __tests__}/emitter.test.ts +4 -4
  18. package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
  19. package/src/{__test__ → __tests__}/guards.test.ts +15 -5
  20. package/src/__tests__/id.test.ts +67 -0
  21. package/src/__tests__/mime-sniff.test.ts +142 -0
  22. package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
  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/{__test__ → __tests__}/store-libs.test.ts +57 -3
  27. package/src/__tests__/transport-fetch.test.ts +108 -0
  28. package/src/{__test__ → __tests__}/transport.test.ts +5 -5
  29. package/src/core/__tests__/errors.test.ts +152 -0
  30. package/src/core/client.ts +3 -0
  31. package/src/core/contracts/contracts.types.ts +335 -141
  32. package/src/core/contracts/index.ts +3 -8
  33. package/src/core/contracts/transport/index.ts +3 -2
  34. package/src/core/contracts/transport/transport.fetch.ts +203 -0
  35. package/src/core/contracts/transport/transport.libs.ts +7 -8
  36. package/src/core/contracts/transport/transport.ts +91 -83
  37. package/src/core/contracts/transport/transport.types.ts +71 -35
  38. package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
  39. package/src/core/engine/engine.libs.ts +35 -0
  40. package/src/core/engine/engine.types.ts +367 -241
  41. package/src/core/engine/index.ts +3 -8
  42. package/src/core/engine/reducer.ts +89 -43
  43. package/src/core/engine/store/handlers/add-file.ts +39 -29
  44. package/src/core/engine/store/handlers/cancel.ts +13 -8
  45. package/src/core/engine/store/handlers/clean-up.ts +11 -8
  46. package/src/core/engine/store/handlers/create-intent.ts +82 -27
  47. package/src/core/engine/store/handlers/finalize.ts +30 -13
  48. package/src/core/engine/store/handlers/index.ts +7 -7
  49. package/src/core/engine/store/handlers/pause.ts +8 -6
  50. package/src/core/engine/store/handlers/run-upload.ts +49 -34
  51. package/src/core/engine/store/index.ts +27 -21
  52. package/src/core/engine/store/store.dispatch.ts +10 -8
  53. package/src/core/engine/store/store.libs.ts +184 -41
  54. package/src/core/engine/store/store.runtime.ts +45 -36
  55. package/src/core/engine/store/store.schedule.ts +27 -21
  56. package/src/core/engine/store/store.types.ts +177 -227
  57. package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
  58. package/src/core/engine/validation/file.ts +7 -3
  59. package/src/core/engine/validation/index.ts +1 -0
  60. package/src/core/engine/validation/intent.ts +49 -27
  61. package/src/core/engine/validation/list.ts +14 -15
  62. package/src/core/engine/validation/mime.ts +39 -0
  63. package/src/core/errors.ts +271 -0
  64. package/src/core/index.ts +28 -5
  65. package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
  66. package/src/core/persistence/__tests__/persistence.test.ts +85 -0
  67. package/src/core/persistence/adapters.indexeddb.ts +9 -3
  68. package/src/core/persistence/adapters.local.ts +9 -3
  69. package/src/core/persistence/adapters.memory.ts +35 -13
  70. package/src/core/persistence/index.ts +5 -5
  71. package/src/core/persistence/persistence.ts +73 -60
  72. package/src/core/persistence/persistence.types.ts +140 -60
  73. package/src/core/utils/__tests__/guards.test.ts +93 -0
  74. package/src/core/utils/__tests__/url-safety.test.ts +112 -0
  75. package/src/core/utils/async.ts +7 -2
  76. package/src/core/utils/constants.ts +3 -9
  77. package/src/core/utils/emitter/emitter.ts +7 -24
  78. package/src/core/utils/emitter/emitter.types.ts +33 -16
  79. package/src/core/utils/emitter/index.ts +2 -74
  80. package/src/core/utils/fingerprint.ts +16 -5
  81. package/src/core/utils/guards.ts +91 -1
  82. package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
  83. package/src/core/utils/hash/index.ts +126 -0
  84. package/src/core/utils/hash/sha256.ts +179 -0
  85. package/src/core/utils/id.ts +33 -1
  86. package/src/core/utils/index.ts +15 -6
  87. package/src/core/utils/mime-sniff.ts +119 -0
  88. package/src/core/utils/sanitize-filename.ts +85 -0
  89. package/src/core/utils/url-safety.ts +156 -0
  90. package/src/react/__tests__/use-dropzone.test.ts +64 -0
  91. package/src/react/index.ts +5 -2
  92. package/src/react/upload-provider.tsx +68 -53
  93. package/src/react/uploader.types.ts +58 -61
  94. package/src/react/use-dropzone.tsx +226 -0
  95. package/src/react/use-uploader.tsx +98 -91
  96. package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
  97. package/src/strategies/_shared/retry.ts +79 -0
  98. package/src/strategies/index.ts +9 -15
  99. package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
  100. package/src/strategies/multipart/index.ts +206 -108
  101. package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
  102. package/src/strategies/post/index.ts +115 -33
  103. package/src/strategies/put/__tests__/put.test.ts +132 -0
  104. package/src/strategies/put/index.ts +139 -0
  105. package/src/strategies/registry/index.ts +12 -9
  106. package/src/strategies/tus/__tests__/tus.test.ts +159 -0
  107. package/src/strategies/tus/index.ts +211 -0
  108. package/dist/async-DxRWu4le.js +0 -1
  109. package/dist/core-CMkrP9DX.js +0 -2
  110. package/dist/index-C38Jmizu.d.ts +0 -1005
  111. package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
  112. package/src/__test__/effect-pool.test.ts +0 -5
  113. package/src/__test__/id.test.ts +0 -14
  114. package/src/__test__/store-integration.test.ts +0 -5
  115. package/src/core/client/client.ts +0 -50
  116. package/src/core/client/client.types.ts +0 -184
  117. package/src/core/client/config.types.ts +0 -77
  118. package/src/core/client/index.ts +0 -4
  119. package/src/core/client/plugins.types.ts +0 -46
  120. package/src/core/client/retry.types.ts +0 -6
  121. package/src/core/client/uploadClient.ts +0 -19
  122. package/src/core/contracts/backendApi.ts +0 -62
  123. package/src/core/contracts/cursor.types.ts +0 -23
  124. package/src/core/contracts/errors.types.ts +0 -67
  125. package/src/core/contracts/fingerprint.types.ts +0 -18
  126. package/src/core/contracts/intent.types.ts +0 -39
  127. package/src/core/contracts/result.types.ts +0 -11
  128. package/src/core/contracts/strategy.types.ts +0 -45
  129. package/src/core/engine/commands.types.ts +0 -17
  130. package/src/core/engine/event-map.types.ts +0 -45
  131. package/src/core/engine/internal-events.types.ts +0 -202
  132. package/src/core/engine/outcome.types.ts +0 -9
  133. package/src/core/engine/progress.types.ts +0 -15
  134. package/src/core/engine/store/handlers.ts +0 -819
  135. package/src/core/engine/validation.ts +0 -216
  136. /package/src/{__test__ → __tests__}/transport-libs.test.ts +0 -0
@@ -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
@@ -1,23 +1,6 @@
1
+ import { isDevEnv } from '../guards'
1
2
  import type { Emitter } from './emitter.types'
2
3
 
3
- /**
4
- * Listener-throw callback. Re-exported alias for the canonical
5
- * {@link Emitter.ErrorHandler} type. Kept for backwards compatibility
6
- * with R30 import paths; prefer `Emitter.ErrorHandler` in new code.
7
- *
8
- * @author wildduck2 <https://github.com/wildduck2>
9
- */
10
- export type EmitterErrorHandler = Emitter.ErrorHandler
11
-
12
- /**
13
- * Listener registry, bucketed by event type.
14
- *
15
- * @template E Event map.
16
- */
17
- type ListenerMap<E extends Record<string, unknown>> = Partial<{
18
- [K in keyof E & string]: Set<(payload: E[K]) => void>
19
- }>
20
-
21
4
  /**
22
5
  * Soft cap on nested `emit` depth. A listener that synchronously dispatches a
23
6
  * command which fires another emit is legal -- but unbounded re-entry usually
@@ -44,9 +27,9 @@ const MAX_EMIT_DEPTH = 50
44
27
  * @author wildduck2 <https://github.com/wildduck2>
45
28
  */
46
29
  export function createTypedEmitter<E extends Record<string, unknown>>(
47
- onError?: EmitterErrorHandler,
48
- ): Emitter.ITypedEmitter<E> {
49
- const listeners: ListenerMap<E> = {}
30
+ onError?: Emitter.ErrorHandler,
31
+ ): Emitter.TypedEmitter<E> {
32
+ const listeners: Emitter.ListenerMap<E> = {}
50
33
  // Memoized iteration snapshots per event type. Invalidated on `on`/`off`.
51
34
  // Multi-listener `emit` would otherwise allocate a fresh array every call;
52
35
  // for progress streams that adds up to one Set + spread per progress tick.
@@ -99,7 +82,7 @@ export function createTypedEmitter<E extends Record<string, unknown>>(
99
82
  } catch {}
100
83
  return
101
84
  }
102
- if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
85
+ if (isDevEnv()) {
103
86
  console.error(`[UploadEngine] Error in event listener for "${type}":`, err)
104
87
  }
105
88
  }
@@ -107,7 +90,7 @@ export function createTypedEmitter<E extends Record<string, unknown>>(
107
90
 
108
91
  emitDepth++
109
92
  try {
110
- if (emitDepth > MAX_EMIT_DEPTH && !warnedDepth && process.env.NODE_ENV !== 'production') {
93
+ if (emitDepth > MAX_EMIT_DEPTH && !warnedDepth && isDevEnv()) {
111
94
  warnedDepth = true
112
95
  console.warn(
113
96
  `[UploadEngine] emit depth exceeded ${MAX_EMIT_DEPTH} for "${type}". ` +
@@ -121,7 +104,7 @@ export function createTypedEmitter<E extends Record<string, unknown>>(
121
104
  // most subscribers attach exactly one listener per event type.
122
105
  if (typeListeners.size === 1) {
123
106
  const [only] = typeListeners
124
- dispatch(only)
107
+ if (only) dispatch(only)
125
108
  return
126
109
  }
127
110
 
@@ -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
+ })
@@ -0,0 +1,126 @@
1
+ /**
2
+ * File hashing entry point.
3
+ *
4
+ * Small files use `crypto.subtle.digest` (native, fastest). Large files are
5
+ * hashed incrementally so memory stays flat, off the main thread via a Web
6
+ * Worker when the environment supports one, or inline on the current thread as
7
+ * a fallback (Node/SSR/tests, or browsers without Worker/Blob URL support).
8
+ *
9
+ * @author wildduck2 <https://github.com/wildduck2>
10
+ */
11
+
12
+ import { DEFAULT_HASH_CHUNK_SIZE, sha256HexOfBlob } from './sha256'
13
+
14
+ export { DEFAULT_HASH_CHUNK_SIZE, sha256HexOfBlob }
15
+
16
+ /** Native Web Crypto digest of a fully-buffered blob. Lowercase hex. */
17
+ async function sha256HexViaSubtle(blob: Blob): Promise<string> {
18
+ const buf = await blob.arrayBuffer()
19
+ const digest = await crypto.subtle.digest('SHA-256', buf)
20
+ const bytes = new Uint8Array(digest)
21
+ let out = ''
22
+ for (const b of bytes) out += b.toString(16).padStart(2, '0')
23
+ return out
24
+ }
25
+
26
+ /** True when this realm can spin up a Blob-URL Web Worker. */
27
+ function canUseWorker(): boolean {
28
+ return (
29
+ typeof Worker !== 'undefined' &&
30
+ typeof Blob !== 'undefined' &&
31
+ typeof URL !== 'undefined' &&
32
+ typeof URL.createObjectURL === 'function'
33
+ )
34
+ }
35
+
36
+ let workerScriptUrl: string | null = null
37
+
38
+ /**
39
+ * Build (once) an object-URL for a worker that runs {@link sha256HexOfBlob}.
40
+ * The function is self-contained, so stringifying it yields valid worker source.
41
+ */
42
+ function getWorkerScriptUrl(): string {
43
+ if (workerScriptUrl) return workerScriptUrl
44
+ const source = `const sha256HexOfBlob = ${sha256HexOfBlob.toString()};
45
+ self.onmessage = async (e) => {
46
+ try {
47
+ const hex = await sha256HexOfBlob(e.data.blob, { chunkSize: e.data.chunkSize });
48
+ self.postMessage({ ok: true, hex });
49
+ } catch (err) {
50
+ self.postMessage({ ok: false, error: String((err && err.message) || err) });
51
+ }
52
+ };`
53
+ const blob = new Blob([source], { type: 'application/javascript' })
54
+ workerScriptUrl = URL.createObjectURL(blob)
55
+ return workerScriptUrl
56
+ }
57
+
58
+ /** Hash `blob` in a Web Worker. Rejects if the worker errors. */
59
+ function sha256HexInWorker(blob: Blob, chunkSize: number): Promise<string> {
60
+ return new Promise<string>((resolve, reject) => {
61
+ let worker: Worker
62
+ try {
63
+ worker = new Worker(getWorkerScriptUrl())
64
+ } catch (err) {
65
+ reject(err instanceof Error ? err : new Error(String(err)))
66
+ return
67
+ }
68
+
69
+ const done = (fn: () => void) => {
70
+ worker.onmessage = null
71
+ worker.onerror = null
72
+ worker.terminate()
73
+ fn()
74
+ }
75
+
76
+ worker.onmessage = (e: MessageEvent<{ ok: boolean; hex?: string; error?: string }>) => {
77
+ const data = e.data
78
+ if (data.ok && typeof data.hex === 'string') {
79
+ done(() => resolve(data.hex as string))
80
+ } else {
81
+ done(() => reject(new Error(data.error ?? 'hash worker failed')))
82
+ }
83
+ }
84
+ worker.onerror = (e) => {
85
+ done(() => reject(new Error(e.message || 'hash worker error')))
86
+ }
87
+
88
+ worker.postMessage({ blob, chunkSize })
89
+ })
90
+ }
91
+
92
+ /**
93
+ * Compute the lowercase hex SHA-256 of `blob`.
94
+ *
95
+ * @param blob - Source bytes.
96
+ * @param opts.inlineMaxSize - At or below this size, hash inline with Web Crypto
97
+ * (fast, buffers the whole blob). Above it, hash incrementally — off-thread
98
+ * when a Worker is available, otherwise inline-incremental. Defaults to
99
+ * {@link DEFAULT_HASH_CHUNK_SIZE}.
100
+ * @param opts.chunkSize - Slice size for incremental hashing.
101
+ * @param opts.signal - Abort signal (honored on the inline-incremental path).
102
+ */
103
+ export async function hashBlob(
104
+ blob: Blob,
105
+ opts?: { inlineMaxSize?: number; chunkSize?: number; signal?: AbortSignal },
106
+ ): Promise<string> {
107
+ const inlineMax =
108
+ typeof opts?.inlineMaxSize === 'number' && Number.isFinite(opts.inlineMaxSize) && opts.inlineMaxSize > 0
109
+ ? opts.inlineMaxSize
110
+ : DEFAULT_HASH_CHUNK_SIZE
111
+ const chunkSize = opts?.chunkSize ?? DEFAULT_HASH_CHUNK_SIZE
112
+
113
+ if (blob.size <= inlineMax && typeof crypto !== 'undefined' && crypto.subtle) {
114
+ return sha256HexViaSubtle(blob)
115
+ }
116
+
117
+ if (canUseWorker() && !opts?.signal) {
118
+ try {
119
+ return await sha256HexInWorker(blob, chunkSize)
120
+ } catch {
121
+ // Fall through to inline-incremental on any worker failure.
122
+ }
123
+ }
124
+
125
+ return opts?.signal ? sha256HexOfBlob(blob, { chunkSize, signal: opts.signal }) : sha256HexOfBlob(blob, { chunkSize })
126
+ }