@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,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
+ }
@@ -0,0 +1,179 @@
1
+ // biome-ignore-all lint/style/noNonNullAssertion: fixed-bounds reads over Uint8/Uint32Array in the SHA-256 hot loop are always defined; asserting avoids per-read branches.
2
+
3
+ /**
4
+ * Pure-JS incremental SHA-256 over a `Blob`/`File`.
5
+ *
6
+ * Web Crypto (`crypto.subtle.digest`) has no streaming/incremental API, so it
7
+ * must buffer the whole file in memory. This implementation reads the blob in
8
+ * bounded slices and folds each into the digest state, so memory stays flat
9
+ * regardless of file size.
10
+ *
11
+ * The whole algorithm lives inside a single self-contained function with no
12
+ * external references (constants and helpers are declared locally). That lets
13
+ * {@link ./index} stringify it with `Function.prototype.toString` and run it
14
+ * verbatim inside a Web Worker, keeping large-file hashing off the main thread.
15
+ *
16
+ * @author wildduck2 <https://github.com/wildduck2>
17
+ */
18
+
19
+ /** Default slice size fed to the hasher: 8 MiB. */
20
+ export const DEFAULT_HASH_CHUNK_SIZE = 8 * 1024 * 1024
21
+
22
+ /**
23
+ * Compute the lowercase hex SHA-256 digest of `blob`, reading it in bounded
24
+ * slices so peak memory is `~chunkSize` rather than the whole file.
25
+ *
26
+ * @param blob - Source bytes (a `File` is a `Blob`).
27
+ * @param opts.chunkSize - Slice size in bytes. Defaults to {@link DEFAULT_HASH_CHUNK_SIZE}.
28
+ * @param opts.signal - Optional abort signal, checked between slices.
29
+ * @returns 64-char lowercase hex digest.
30
+ */
31
+ export async function sha256HexOfBlob(
32
+ blob: Blob,
33
+ opts?: { chunkSize?: number; signal?: AbortSignal },
34
+ ): Promise<string> {
35
+ const chunkSize = opts?.chunkSize && opts.chunkSize > 0 ? opts.chunkSize : 8 * 1024 * 1024
36
+ const signal = opts?.signal
37
+
38
+ // Round constants (first 32 bits of the fractional parts of the cube roots
39
+ // of the first 64 primes).
40
+ const K = new Uint32Array([
41
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98,
42
+ 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
43
+ 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8,
44
+ 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
45
+ 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819,
46
+ 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
47
+ 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
48
+ 0xc67178f2,
49
+ ])
50
+
51
+ let h0 = 0x6a09e667
52
+ let h1 = 0xbb67ae85
53
+ let h2 = 0x3c6ef372
54
+ let h3 = 0xa54ff53a
55
+ let h4 = 0x510e527f
56
+ let h5 = 0x9b05688c
57
+ let h6 = 0x1f83d9ab
58
+ let h7 = 0x5be0cd19
59
+
60
+ const w = new Uint32Array(64)
61
+ const block = new Uint8Array(64)
62
+ let blockLen = 0
63
+ let totalLen = 0
64
+
65
+ const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n))
66
+
67
+ const compress = (): void => {
68
+ for (let i = 0; i < 16; i++) {
69
+ const j = i * 4
70
+ w[i] = ((block[j]! << 24) | (block[j + 1]! << 16) | (block[j + 2]! << 8) | block[j + 3]!) >>> 0
71
+ }
72
+ for (let i = 16; i < 64; i++) {
73
+ const x15 = w[i - 15]!
74
+ const x2 = w[i - 2]!
75
+ const s0 = rotr(x15, 7) ^ rotr(x15, 18) ^ (x15 >>> 3)
76
+ const s1 = rotr(x2, 17) ^ rotr(x2, 19) ^ (x2 >>> 10)
77
+ w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0
78
+ }
79
+
80
+ let a = h0
81
+ let b = h1
82
+ let c = h2
83
+ let d = h3
84
+ let e = h4
85
+ let f = h5
86
+ let g = h6
87
+ let h = h7
88
+
89
+ for (let i = 0; i < 64; i++) {
90
+ const bigS1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)
91
+ const ch = (e & f) ^ (~e & g)
92
+ const t1 = (h + bigS1 + ch + K[i]! + w[i]!) >>> 0
93
+ const bigS0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)
94
+ const maj = (a & b) ^ (a & c) ^ (b & c)
95
+ const t2 = (bigS0 + maj) >>> 0
96
+ h = g
97
+ g = f
98
+ f = e
99
+ e = (d + t1) >>> 0
100
+ d = c
101
+ c = b
102
+ b = a
103
+ a = (t1 + t2) >>> 0
104
+ }
105
+
106
+ h0 = (h0 + a) >>> 0
107
+ h1 = (h1 + b) >>> 0
108
+ h2 = (h2 + c) >>> 0
109
+ h3 = (h3 + d) >>> 0
110
+ h4 = (h4 + e) >>> 0
111
+ h5 = (h5 + f) >>> 0
112
+ h6 = (h6 + g) >>> 0
113
+ h7 = (h7 + h) >>> 0
114
+ }
115
+
116
+ const update = (bytes: Uint8Array): void => {
117
+ totalLen += bytes.length
118
+ let off = 0
119
+
120
+ // Top up a partially filled block from a previous slice.
121
+ if (blockLen > 0) {
122
+ const take = Math.min(64 - blockLen, bytes.length)
123
+ block.set(bytes.subarray(0, take), blockLen)
124
+ blockLen += take
125
+ off = take
126
+ if (blockLen === 64) {
127
+ compress()
128
+ blockLen = 0
129
+ }
130
+ }
131
+
132
+ // Consume whole 64-byte blocks directly from the slice.
133
+ while (bytes.length - off >= 64) {
134
+ block.set(bytes.subarray(off, off + 64), 0)
135
+ compress()
136
+ off += 64
137
+ }
138
+
139
+ // Stash the trailing remainder for the next slice / finalization.
140
+ if (off < bytes.length) {
141
+ const rest = bytes.subarray(off)
142
+ block.set(rest, 0)
143
+ blockLen = rest.length
144
+ }
145
+ }
146
+
147
+ const size = blob.size
148
+ for (let pos = 0; pos < size; pos += chunkSize) {
149
+ if (signal?.aborted) throw new DOMException('The operation was aborted.', 'AbortError')
150
+ const end = Math.min(size, pos + chunkSize)
151
+ const buf = await blob.slice(pos, end).arrayBuffer()
152
+ update(new Uint8Array(buf))
153
+ }
154
+
155
+ // Finalize: append 0x80, pad with zeros, then the 64-bit big-endian bit length.
156
+ const lenBits = totalLen * 8
157
+ const hi = Math.floor(lenBits / 0x100000000) >>> 0
158
+ const lo = lenBits >>> 0
159
+
160
+ block[blockLen++] = 0x80
161
+ if (blockLen > 56) {
162
+ while (blockLen < 64) block[blockLen++] = 0
163
+ compress()
164
+ blockLen = 0
165
+ }
166
+ while (blockLen < 56) block[blockLen++] = 0
167
+ block[56] = (hi >>> 24) & 0xff
168
+ block[57] = (hi >>> 16) & 0xff
169
+ block[58] = (hi >>> 8) & 0xff
170
+ block[59] = hi & 0xff
171
+ block[60] = (lo >>> 24) & 0xff
172
+ block[61] = (lo >>> 16) & 0xff
173
+ block[62] = (lo >>> 8) & 0xff
174
+ block[63] = lo & 0xff
175
+ compress()
176
+
177
+ const toHex = (x: number): string => (x >>> 0).toString(16).padStart(8, '0')
178
+ return toHex(h0) + toHex(h1) + toHex(h2) + toHex(h3) + toHex(h4) + toHex(h5) + toHex(h6) + toHex(h7)
179
+ }
@@ -1,3 +1,35 @@
1
+ /**
2
+ * Generate a unique upload ID.
3
+ *
4
+ * Format: `upload_<unix-ms>_<uuid-v4>`. The UUID is produced by
5
+ * {@link Crypto.randomUUID} on platforms that expose it (Node >= 14.17,
6
+ * all modern browsers in secure contexts). On legacy runtimes we fall
7
+ * back to {@link Crypto.getRandomValues} + manual v4 formatting.
8
+ *
9
+ * Throws if no CSPRNG is reachable; `Math.random()` is deliberately not used,
10
+ * since predictable IDs can collide and leak creation ordering.
11
+ */
1
12
  export function generateId(): string {
2
- return `upload_${Date.now()}_${Math.random().toString(36).slice(2)}`
13
+ return `upload_${Date.now()}_${randomUUIDv4()}`
14
+ }
15
+
16
+ function randomUUIDv4(): string {
17
+ const c: Crypto | undefined = (globalThis as { crypto?: Crypto }).crypto
18
+ if (c && typeof c.randomUUID === 'function') {
19
+ return c.randomUUID()
20
+ }
21
+ if (c && typeof c.getRandomValues === 'function') {
22
+ const bytes = new Uint8Array(16)
23
+ c.getRandomValues(bytes)
24
+ // RFC 4122 §4.4 — set version (4) and variant (10xx). `bytes` is fixed at
25
+ // length 16, so every index is in bounds; `?? 0` satisfies the checker.
26
+ const b6 = bytes[6] ?? 0
27
+ const b8 = bytes[8] ?? 0
28
+ bytes[6] = (b6 & 0x0f) | 0x40
29
+ bytes[8] = (b8 & 0x3f) | 0x80
30
+ const hex: string[] = []
31
+ for (let i = 0; i < bytes.length; i++) hex.push((bytes[i] ?? 0).toString(16).padStart(2, '0'))
32
+ return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`
33
+ }
34
+ throw new Error('[duck-upload] No CSPRNG available: crypto.randomUUID and crypto.getRandomValues both missing')
3
35
  }
@@ -1,6 +1,15 @@
1
- export * from './async'
2
- export * from './constants'
3
- export * from './emitter'
4
- export * from './fingerprint'
5
- export * from './guards'
6
- export * from './id'
1
+ export { sleep } from './async'
2
+ export {
3
+ DEFAULT_MAX_ATTEMPTS,
4
+ DEFAULT_MAX_CONCURRENT_UPLOADS,
5
+ DEFAULT_MAX_ITEMS,
6
+ DEFAULT_PROGRESS_THROTTLE_MS,
7
+ DEFAULT_RETRY_DELAY_BASE_MS,
8
+ DEFAULT_RETRY_DELAY_MAX_MS,
9
+ } from './constants'
10
+ export type { Emitter } from './emitter'
11
+ export { createTypedEmitter } from './emitter'
12
+ export { computeFingerprint, fingerprintMatches } from './fingerprint'
13
+ export { isRecord, stripDangerousKeys } from './guards'
14
+ export { DEFAULT_HASH_CHUNK_SIZE, hashBlob, sha256HexOfBlob } from './hash'
15
+ export { generateId } from './id'
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Inspect the first bytes of `bytes` and return the canonical MIME type
3
+ * for the format if recognised, or `null` if unknown.
4
+ *
5
+ * 12 bytes is sufficient for every signature handled here; callers may
6
+ * pass a longer slice but anything beyond byte 11 is ignored.
7
+ */
8
+ export function sniffMime(bytes: Uint8Array): string | null {
9
+ const n = bytes.length
10
+
11
+ // JPEG: FF D8 FF
12
+ if (n >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
13
+ return 'image/jpeg'
14
+ }
15
+
16
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
17
+ if (
18
+ n >= 8 &&
19
+ bytes[0] === 0x89 &&
20
+ bytes[1] === 0x50 &&
21
+ bytes[2] === 0x4e &&
22
+ bytes[3] === 0x47 &&
23
+ bytes[4] === 0x0d &&
24
+ bytes[5] === 0x0a &&
25
+ bytes[6] === 0x1a &&
26
+ bytes[7] === 0x0a
27
+ ) {
28
+ return 'image/png'
29
+ }
30
+
31
+ // GIF: 47 49 46 38 ("GIF8")
32
+ if (n >= 4 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) {
33
+ return 'image/gif'
34
+ }
35
+
36
+ // PDF: 25 50 44 46 ("%PDF")
37
+ if (n >= 4 && bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) {
38
+ return 'application/pdf'
39
+ }
40
+
41
+ // WebP: bytes 0-3 = "RIFF" and bytes 8-11 = "WEBP"
42
+ if (
43
+ n >= 12 &&
44
+ bytes[0] === 0x52 &&
45
+ bytes[1] === 0x49 &&
46
+ bytes[2] === 0x46 &&
47
+ bytes[3] === 0x46 &&
48
+ bytes[8] === 0x57 &&
49
+ bytes[9] === 0x45 &&
50
+ bytes[10] === 0x42 &&
51
+ bytes[11] === 0x50
52
+ ) {
53
+ return 'image/webp'
54
+ }
55
+
56
+ // MP4 / ISO BMFF: bytes 4-7 = "ftyp"
57
+ if (n >= 8 && bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) {
58
+ return 'video/mp4'
59
+ }
60
+
61
+ // WebM / Matroska EBML header: 1A 45 DF A3
62
+ if (n >= 4 && bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3) {
63
+ return 'video/webm'
64
+ }
65
+
66
+ // ZIP-family (DOCX/XLSX/PPTX/APK/JAR/EPUB share this): 50 4B 03 04
67
+ if (n >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04) {
68
+ return 'application/zip'
69
+ }
70
+
71
+ // MP3: ID3v2 tag (49 44 33) or MPEG frame sync (FF FB / FF F3 / FF F2)
72
+ if (n >= 3 && bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) {
73
+ return 'audio/mpeg'
74
+ }
75
+ if (n >= 2 && bytes[0] === 0xff && (bytes[1] === 0xfb || bytes[1] === 0xf3 || bytes[1] === 0xf2)) {
76
+ return 'audio/mpeg'
77
+ }
78
+
79
+ return null
80
+ }
81
+
82
+ /**
83
+ * Compare a sniffed MIME type against a claimed one. Returns `true` when
84
+ * they refer to the same format, `false` on conflict. `null` sniff is
85
+ * always treated as a match (unknown formats are not a deny condition).
86
+ *
87
+ * Comparison is loose by design `image/jpg` vs `image/jpeg`, ZIP-based
88
+ * Office formats vs `application/zip`, etc. all need to be tolerated.
89
+ */
90
+ export function mimeMatches(claimed: string, sniffed: string | null): boolean {
91
+ if (!sniffed) return true
92
+ const c = claimed.toLowerCase().trim()
93
+ const s = sniffed.toLowerCase().trim()
94
+ if (c === s) return true
95
+ if (c === '' || c === 'application/octet-stream') return true
96
+
97
+ // Tolerate ZIP-based document formats the magic bytes only say "this
98
+ // is a ZIP container", which is the truthful low-level answer. Any
99
+ // claimed Office / OpenDocument / JAR / APK / EPUB type is consistent.
100
+ if (s === 'application/zip') {
101
+ if (
102
+ c.startsWith('application/vnd.openxmlformats-officedocument.') ||
103
+ c.startsWith('application/vnd.oasis.opendocument.') ||
104
+ c === 'application/epub+zip' ||
105
+ c === 'application/java-archive' ||
106
+ c === 'application/vnd.android.package-archive'
107
+ ) {
108
+ return true
109
+ }
110
+ }
111
+
112
+ // JPEG common aliases.
113
+ if (s === 'image/jpeg' && (c === 'image/jpg' || c === 'image/pjpeg')) return true
114
+
115
+ return false
116
+ }
117
+
118
+ /** Module-level dedup set for one-time-per-session warnings. @internal */
119
+ export const __mimeWarnings = new Set<string>()
@@ -0,0 +1,85 @@
1
+ export type FilenameRejectReason = 'reserved' | 'too-long' | 'empty' | 'path-sep'
2
+
3
+ export type SanitizeFilenameResult =
4
+ | { safe: true; normalised: string }
5
+ | { safe: false; normalised: string; reason: FilenameRejectReason }
6
+
7
+ // TODO: use regex
8
+ const RESERVED_WINDOWS_NAMES = new Set([
9
+ 'CON',
10
+ 'PRN',
11
+ 'AUX',
12
+ 'NUL',
13
+ 'COM1',
14
+ 'COM2',
15
+ 'COM3',
16
+ 'COM4',
17
+ 'COM5',
18
+ 'COM6',
19
+ 'COM7',
20
+ 'COM8',
21
+ 'COM9',
22
+ 'LPT1',
23
+ 'LPT2',
24
+ 'LPT3',
25
+ 'LPT4',
26
+ 'LPT5',
27
+ 'LPT6',
28
+ 'LPT7',
29
+ 'LPT8',
30
+ 'LPT9',
31
+ ])
32
+
33
+ function stripControlChars(s: string): string {
34
+ let out = ''
35
+ for (let i = 0; i < s.length; i++) {
36
+ const c = s.charCodeAt(i)
37
+ if (c >= 0x20 && c !== 0x7f) out += s[i]
38
+ }
39
+ return out
40
+ }
41
+
42
+ /**
43
+ * Normalise and validate an untrusted filename. Returns
44
+ * `{ safe: true, normalised }` when the name is safe to forward to a backend,
45
+ * or `{ safe: false, normalised, reason }` with the partial cleanup for
46
+ * diagnostics. Pure and synchronous.
47
+ */
48
+ export function sanitizeFilename(name: string): SanitizeFilenameResult {
49
+ // NFKC normalise.
50
+ let n = name.normalize('NFKC')
51
+
52
+ // Strip control chars and DEL.
53
+ n = stripControlChars(n)
54
+
55
+ // Strip leading dashes.
56
+ while (n.startsWith('-')) n = n.slice(1)
57
+
58
+ // Strip trailing dots and spaces (Windows behaviour).
59
+ while (n.length > 0 && (n.endsWith('.') || n.endsWith(' '))) n = n.slice(0, -1)
60
+
61
+ // Reject if nothing survived.
62
+ if (n.length === 0) {
63
+ return { safe: false, normalised: n, reason: 'empty' }
64
+ }
65
+
66
+ // Path separators must never reach here.
67
+ if (n.includes('/') || n.includes('\\')) {
68
+ return { safe: false, normalised: n, reason: 'path-sep' }
69
+ }
70
+
71
+ // Length cap.
72
+ if (n.length > 255) {
73
+ return { safe: false, normalised: n, reason: 'too-long' }
74
+ }
75
+
76
+ // Reserved Windows device names — match the base (before the first dot),
77
+ // case-insensitive. `CON.txt`, `nul`, `COM1.tar.gz` all reject.
78
+ const dot = n.indexOf('.')
79
+ const base = (dot === -1 ? n : n.slice(0, dot)).toUpperCase()
80
+ if (RESERVED_WINDOWS_NAMES.has(base)) {
81
+ return { safe: false, normalised: n, reason: 'reserved' }
82
+ }
83
+
84
+ return { safe: true, normalised: n }
85
+ }