uploaderkit 1.0.0 → 2.0.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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +16 -0
  3. package/README.es.md +1140 -0
  4. package/README.md +1135 -0
  5. package/dist/FileViewer-ChTf7-uY.d.cts +49 -0
  6. package/dist/FileViewer-nuD-GEdJ.d.ts +49 -0
  7. package/dist/adapters/gcs.cjs +83 -0
  8. package/dist/adapters/gcs.cjs.map +1 -0
  9. package/dist/adapters/gcs.d.cts +31 -0
  10. package/dist/adapters/gcs.d.ts +31 -0
  11. package/dist/adapters/gcs.js +81 -0
  12. package/dist/adapters/gcs.js.map +1 -0
  13. package/dist/adapters/memory.cjs +27 -0
  14. package/dist/adapters/memory.cjs.map +1 -0
  15. package/dist/adapters/memory.d.cts +18 -0
  16. package/dist/adapters/memory.d.ts +18 -0
  17. package/dist/adapters/memory.js +25 -0
  18. package/dist/adapters/memory.js.map +1 -0
  19. package/dist/adapters/s3.cjs +75 -0
  20. package/dist/adapters/s3.cjs.map +1 -0
  21. package/dist/adapters/s3.d.cts +27 -0
  22. package/dist/adapters/s3.d.ts +27 -0
  23. package/dist/adapters/s3.js +73 -0
  24. package/dist/adapters/s3.js.map +1 -0
  25. package/dist/chunk-3FI44IOW.js +150 -0
  26. package/dist/chunk-3FI44IOW.js.map +1 -0
  27. package/dist/chunk-H7BRW5IY.js +308 -0
  28. package/dist/chunk-H7BRW5IY.js.map +1 -0
  29. package/dist/chunk-PDKAF4GX.js +707 -0
  30. package/dist/chunk-PDKAF4GX.js.map +1 -0
  31. package/dist/chunk-PTEX7F4R.js +309 -0
  32. package/dist/chunk-PTEX7F4R.js.map +1 -0
  33. package/dist/chunk-T5YZG6JW.js +511 -0
  34. package/dist/chunk-T5YZG6JW.js.map +1 -0
  35. package/dist/index.cjs +220 -27
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.d.cts +57 -170
  38. package/dist/index.d.ts +57 -170
  39. package/dist/index.js +1 -324
  40. package/dist/index.js.map +1 -1
  41. package/dist/presets.cjs +1665 -0
  42. package/dist/presets.cjs.map +1 -0
  43. package/dist/presets.d.cts +100 -0
  44. package/dist/presets.d.ts +100 -0
  45. package/dist/presets.js +405 -0
  46. package/dist/presets.js.map +1 -0
  47. package/dist/react.cjs +981 -0
  48. package/dist/react.cjs.map +1 -0
  49. package/dist/react.d.cts +106 -0
  50. package/dist/react.d.ts +106 -0
  51. package/dist/react.js +5 -0
  52. package/dist/react.js.map +1 -0
  53. package/dist/server/express.cjs +179 -0
  54. package/dist/server/express.cjs.map +1 -0
  55. package/dist/server/express.d.cts +64 -0
  56. package/dist/server/express.d.ts +64 -0
  57. package/dist/server/express.js +111 -0
  58. package/dist/server/express.js.map +1 -0
  59. package/dist/server/next.cjs +165 -0
  60. package/dist/server/next.cjs.map +1 -0
  61. package/dist/server/next.d.cts +38 -0
  62. package/dist/server/next.d.ts +38 -0
  63. package/dist/server/next.js +107 -0
  64. package/dist/server/next.js.map +1 -0
  65. package/dist/server.cjs +550 -0
  66. package/dist/server.cjs.map +1 -0
  67. package/dist/server.d.cts +19 -0
  68. package/dist/server.d.ts +19 -0
  69. package/dist/server.js +80 -0
  70. package/dist/server.js.map +1 -0
  71. package/dist/storage-CYkSHWZX.d.cts +133 -0
  72. package/dist/storage-Qc9epG0G.d.ts +133 -0
  73. package/dist/types-BSlJJwti.d.cts +341 -0
  74. package/dist/types-BSlJJwti.d.ts +341 -0
  75. package/dist/ui.cjs +2325 -0
  76. package/dist/ui.cjs.map +1 -0
  77. package/dist/ui.d.cts +331 -0
  78. package/dist/ui.d.ts +331 -0
  79. package/dist/ui.js +749 -0
  80. package/dist/ui.js.map +1 -0
  81. package/dist/useSlottedUploader-BzT5jV8c.d.cts +139 -0
  82. package/dist/useSlottedUploader-IkG5RhEO.d.ts +139 -0
  83. package/dist/useUploader-BiBdS-7y.d.cts +117 -0
  84. package/dist/useUploader-CQHpj_oI.d.ts +117 -0
  85. package/package.json +189 -3
  86. package/tailwind.css +80 -0
package/README.md ADDED
@@ -0,0 +1,1135 @@
1
+ <div align="center">
2
+
3
+ # uploaderkit
4
+
5
+ **File uploads for React and Node, from one shared contract.**
6
+ Scopes both sides validate against, a headless uploader with progress / abort / compression, and a server storage service with pluggable providers.
7
+
8
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
9
+ [![Node](https://img.shields.io/badge/node-%3E%3D18-339933.svg)](https://nodejs.org/)
10
+ [![React](https://img.shields.io/badge/react-%5E18%20%7C%7C%20%5E19-61dafb.svg)](https://react.dev/)
11
+ [![Tailwind](https://img.shields.io/badge/tailwindcss-v4-38bdf8.svg)](https://tailwindcss.com/)
12
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178c6.svg)](https://www.typescriptlang.org/)
13
+
14
+ 🌐 **English** | [🇲🇽 Español](./README.es.md)
15
+
16
+ </div>
17
+
18
+ ---
19
+
20
+ ## Table of Contents
21
+
22
+ - [Features](#features)
23
+ - [Quick Start](#quick-start)
24
+ - [Installation](#installation)
25
+ - [Tailwind v4 Setup](#tailwind-v4-setup)
26
+ - [Scopes — the contract](#scopes--the-contract)
27
+ - [Defining scopes](#defining-scopes)
28
+ - [Replace — never leave a dead file](#replace--never-leave-a-dead-file)
29
+ - [Client](#client)
30
+ - [`useUploader`](#useuploader)
31
+ - [Upload trigger — `select` vs `manual`](#upload-trigger--select-vs-manual)
32
+ - [Retry and concurrency](#retry-and-concurrency)
33
+ - [Renaming on the way in](#renaming-on-the-way-in)
34
+ - [Safe file names](#safe-file-names)
35
+ - [Upload strategies](#upload-strategies)
36
+ - [Validation](#validation)
37
+ - [Image compression](#image-compression)
38
+ - [Named slots (`useSlottedUploader`)](#named-slots-useslotteduploader)
39
+ - [UI components](#ui-components)
40
+ - [`Uploader`](#uploader)
41
+ - [`SlottedUploader`](#slotteduploader)
42
+ - [Confirmations](#confirmations)
43
+ - [File preview (`FileViewer`)](#file-preview-fileviewer)
44
+ - [Reading a stored file](#reading-a-stored-file)
45
+ - [Drop to replace](#drop-to-replace)
46
+ - [Labels — every string is replaceable](#labels--every-string-is-replaceable)
47
+ - [Theming](#theming)
48
+ - [Going fully headless](#going-fully-headless)
49
+ - [Presets](#presets)
50
+ - [Server](#server)
51
+ - [`createStorage`](#createstorage)
52
+ - [Express](#express)
53
+ - [Next.js App Router](#nextjs-app-router)
54
+ - [Encryption](#encryption)
55
+ - [Storage providers](#storage-providers)
56
+ - [Developer feedback](#developer-feedback)
57
+ - [Subpath Exports](#subpath-exports)
58
+ - [License](#license)
59
+
60
+ ---
61
+
62
+ ## Features
63
+
64
+ - **One contract, both sides** — a scope registry declares where a file lands, who may read it, what is accepted and how big it may be. The browser and the server validate against the same object, so a rejection is never a surprise at the end of a 40 MB upload.
65
+ - **Headless first** — `useUploader` owns selection, validation, compression, per-file progress, abort and error state; it renders nothing. `/ui` is an opt-in skin over it, so an app with its own design system loses no behavior by skipping it.
66
+ - **Client-side validation before the first byte** — extension, size and magic numbers, so an `.exe` renamed to `.pdf` never leaves the machine.
67
+ - **Real upload progress** — the default strategy is XHR because `fetch` still has no usable upload progress in browsers. Every in-flight upload is abortable, per file or all at once.
68
+ - **Image compression in the scope** — declare `compress` and images are downscaled and re-encoded before travelling. EXIF (GPS, camera) drops in the process.
69
+ - **Pluggable transport** — the `UploadStrategy` is a single function `(file, scope, entityId, { onProgress, signal })`. Swap the endpoint, the auth header or the whole protocol without touching hook state.
70
+ - **Server storage service** — `createStorage` re-runs the same validation, encrypts the scopes that ask for it, and answers signed expiring URLs for private objects.
71
+ - **Boot-time guards** — a private scope on a provider that cannot sign, or an encrypted scope without an injected cipher, throws at construction. Deploys fail loudly instead of 500ing on the first upload.
72
+ - **Framework adapters** — structural handlers for Express (the app owns multer) and for the Next.js App Router (native `Request`/`Response`). No framework dependency is pulled in.
73
+ - **Provider adapters** — Google Cloud Storage (two-bucket layout), any S3-compatible backend (AWS, Cloudflare R2, Backblaze B2, MinIO, Wasabi) and an in-memory provider for tests. All optional peers: choosing GCS never installs the AWS SDK.
74
+ - **Named slots** — `SlottedUploader` fills one file per named position (letterhead, ID, tax certificate); a bulk drop routes each file to its slot and renames it so re-uploads overwrite in place.
75
+ - **No cipher shipped by default** — private scopes declare `encrypt: true` and the app injects `CryptoHooks`. `createAesGcmCrypto` is available as a reference implementation.
76
+ - **You choose when the upload fires** — `uploadOn: 'select'` sends as soon as a valid file lands; `'manual'` holds files until `upload()` — the form-submit flow, reachable from the styled components through `controllerRef`. `onUploadStart` announces the moment a batch leaves.
77
+ - **Retry with backoff + concurrency cap** — opt-in resilience for flaky networks: transient strategy failures retry behind exponential backoff, and large batches queue behind a concurrency limit.
78
+ - **Confirmation dialogs built in** — `confirmRemove` / `confirmReplace` gate destructive file actions behind an accessible dialog (focus lands on cancel), and `ConfirmDialog` is exported for app-level use.
79
+ - **Paste and camera capture** — a focused dropzone accepts a pasted screenshot, and `capture` opens the mobile camera directly.
80
+ - **Translatable copy** — every user-facing string flows through a labels object, on the client AND on the server. English by default, `ES_LABELS` included, any language via a partial override.
81
+ - **Rebrandable theme** — the styled layer reads `--color-ui-*` CSS variables so one `:root` override rebrands the whole styled layer.
82
+ - **Sizes, icons and motion** — `size='sm' | 'md'` compacts every row and zone, `icon` swaps (or removes) the dropzone glyph, and the whole surface animates: rows fade in, the drag state scales the zone, overlays enter and exit with a transition, the uploading indicator pulses.
83
+ - **Touch-first by default** — on coarse pointers the zone reads as a tap target ("Toca para elegir un archivo") with press feedback instead of advertising a drag nobody can do; `capture` opens the camera directly.
84
+
85
+ ---
86
+
87
+ ## Quick Start
88
+
89
+ ```bash
90
+ pnpm add uploaderkit react react-dom
91
+ ```
92
+
93
+ ```ts
94
+ // scopes.ts — imported by the client AND the server
95
+ import { defineScopes, MB } from 'uploaderkit'
96
+
97
+ export const scopes = defineScopes({
98
+ 'invoice-evidence': {
99
+ path: (invoiceId, file) => `Invoices/${invoiceId}/evidence/${file.name}`,
100
+ visibility: 'private',
101
+ accept: ['pdf', 'png', 'jpg'],
102
+ maxBytes: 8 * MB,
103
+ encrypt: true,
104
+ },
105
+ })
106
+ ```
107
+
108
+ ```tsx
109
+ // client
110
+ import { Uploader } from 'uploaderkit/ui'
111
+ import { createXhrUploadStrategy } from 'uploaderkit/react'
112
+
113
+ const strategy = createXhrUploadStrategy({ endpoint: `${apiUrl}/storage` })
114
+
115
+ ;<Uploader
116
+ scopes={scopes}
117
+ scope='invoice-evidence'
118
+ entityId={invoiceId}
119
+ strategy={strategy}
120
+ onUploaded={stored => saveToDb(stored)}
121
+ />
122
+ ```
123
+
124
+ ```ts
125
+ // server
126
+ import { createStorage } from 'uploaderkit/server'
127
+ import { createMemoryProvider } from 'uploaderkit/adapters/memory'
128
+
129
+ export const storage = createStorage({
130
+ scopes,
131
+ provider: createMemoryProvider(),
132
+ crypto: { encrypt, decrypt },
133
+ })
134
+ ```
135
+
136
+ ---
137
+
138
+ ## Installation
139
+
140
+ ```bash
141
+ pnpm add uploaderkit
142
+ # peer, only if you use /react or /ui
143
+ pnpm add react react-dom
144
+ ```
145
+
146
+ Optional peers — install only the provider you use:
147
+
148
+ ```bash
149
+ pnpm add @google-cloud/storage # /adapters/gcs
150
+ pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner # /adapters/s3
151
+ ```
152
+
153
+ The server entry points depend on nothing but this package: the Express adapter
154
+ matches Express 4 and 5 structurally and the Next.js one uses the Fetch API.
155
+
156
+ ---
157
+
158
+ ## Tailwind v4 Setup
159
+
160
+ Only needed for `/ui`. Register the package's classes once so Tailwind
161
+ generates them:
162
+
163
+ ```css
164
+ /* app/globals.css */
165
+ @import 'tailwindcss';
166
+ @import 'uploaderkit/tailwind.css';
167
+ ```
168
+
169
+ The hooks in `/react` carry no styles, so a headless app skips this entirely.
170
+
171
+ ---
172
+
173
+ ## Scopes — the contract
174
+
175
+ A **scope** is a named destination: the single source of truth for where a file
176
+ lands, who may read it, and what is accepted there. The registry is a plain
177
+ object the client and the server both import, which is what makes the two
178
+ validations agree by construction.
179
+
180
+ ### Defining scopes
181
+
182
+ ```ts
183
+ import { defineScopes, MB } from 'uploaderkit'
184
+
185
+ export const scopes = defineScopes({
186
+ 'user-avatar': {
187
+ path: userId => `Users/${userId}/avatar`,
188
+ visibility: 'public',
189
+ accept: ['png', 'jpg', 'jpeg', 'webp'],
190
+ maxBytes: 5 * MB,
191
+ category: 'image',
192
+ compress: { maxWidth: 512, quality: 0.8, stripExif: true },
193
+ },
194
+ })
195
+ ```
196
+
197
+ | Field | Meaning |
198
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
199
+ | `path` | `(entityId, file) => string` — the storage key. You own collisions and folder shape. |
200
+ | `visibility` | `'public'` (direct URL) or `'private'` (signed expiring URL only). |
201
+ | `accept` | Extensions allowed here. Narrower than the category preset, never wider. |
202
+ | `maxBytes` | Hard ceiling. `KB` / `MB` / `GB` helpers are exported. |
203
+ | `category` | `'image' \| 'pdf' \| 'document' \| 'data' \| 'video' \| 'audio' \| 'certificate' \| 'key' \| 'any'`. Picks the preset that decides whether magic numbers are read. |
204
+ | `encrypt` | Hand the bytes to the app's cipher before they leave the server. |
205
+ | `compress` | Client-side image pipeline: `maxWidth`, `maxHeight`, `quality`, `stripExif` (default `true`). |
206
+ | `maxFiles` | How many files one entity may hold here. Default `1`. The uploader derives `multiple` from it. |
207
+ | `replace` | What an upload removes. Derived by default — see [Replace](#replace--never-leave-a-dead-file). |
208
+ | `prefix` | `(entityId) => string` — objects an `'entity'` replace may delete. Defaults to the folder of the resolved key. |
209
+ | `metadata` | Free-form tags forwarded to the provider when it supports them. |
210
+
211
+ ### Replace — never leave a dead file
212
+
213
+ Object storage does not clean up after itself. A scope whose key carries the
214
+ file name writes a NEW object every time, so re-uploading a logo leaves the
215
+ previous one paying rent forever. `replace` is what decides that, and its
216
+ default is derived so there is no prop to forget:
217
+
218
+ | The scope | Derived `replace` | Why |
219
+ | ------------------------------------------------ | ----------------- | --------------------------------------------------------------- |
220
+ | `maxFiles: 1` (default), key carries `file.name` | `'entity'` | Every upload lands on a new key — the old object must be swept. |
221
+ | `maxFiles: 1`, key ignores `file.name` | `'key'` | The key is stable, so the provider overwrites in place already. |
222
+ | `maxFiles > 1` | `'key'` | A collection: siblings are the point. |
223
+
224
+ Declare it explicitly only to opt out — `replace: false` keeps every version.
225
+
226
+ The `'entity'` sweep runs **after** a successful put and deletes everything
227
+ under the entity's prefix that is not the new key. Two guards keep it from
228
+ reaching too far, both at `defineScopes` time:
229
+
230
+ - `replace: 'entity'` together with `maxFiles > 1` throws. A scope cannot hold
231
+ a collection and erase it on every upload.
232
+ - Two scopes whose folders overlap throw when either sweeps, so an avatar
233
+ upload can never delete the same user's documents. Give each its own folder,
234
+ or narrow one with `prefix`.
235
+
236
+ ```ts
237
+ defineScopes({
238
+ // One file, swept: uploading `new.png` deletes `old.png`.
239
+ logo: { path: (id, file) => `Companies/${id}/logo/${file.name}` /* … */ },
240
+
241
+ // A collection: `maxFiles` alone switches the semantics.
242
+ expediente: {
243
+ path: (id, file) => `Companies/${id}/docs/${file.name}`,
244
+ maxFiles: 10 /* … */,
245
+ },
246
+
247
+ // Keeps every version on purpose.
248
+ audit: {
249
+ path: (id, file) => `Companies/${id}/audit/${file.name}`,
250
+ replace: false /* … */,
251
+ },
252
+ })
253
+ ```
254
+
255
+ `defineScopes` returns a `ScopeRegistry`: `names`, `get(name)`, `has(name)` and
256
+ `accept(name)` — the last one being the ready-made string for `<input accept>`.
257
+
258
+ ---
259
+
260
+ ## Client
261
+
262
+ ### `useUploader`
263
+
264
+ The headless state machine: selection → validation → (compression) → upload
265
+ with progress and abort. It renders nothing; you render `files` however the
266
+ screen needs.
267
+
268
+ ```tsx
269
+ import { useUploader } from 'uploaderkit/react'
270
+
271
+ const { files, accept, addFiles, upload, abort, isUploading, hasPending } =
272
+ useUploader({
273
+ scopes,
274
+ scope: 'invoice-evidence',
275
+ entityId: invoiceId,
276
+ strategy,
277
+ multiple: true,
278
+ maxFiles: 3,
279
+ uploadOn: 'manual', // the default — see "Upload trigger" below
280
+ onUploadStart: files => setSending(true),
281
+ onUploaded: stored => saveToDb(stored),
282
+ onError: message => toast.error(message),
283
+ retry: { attempts: 3, backoffMs: 500 },
284
+ concurrency: 3,
285
+ })
286
+
287
+ <input type='file' accept={accept} onChange={e => addFiles(e.target.files!)} />
288
+ ```
289
+
290
+ Each entry of `files` is an `UploaderFile`:
291
+
292
+ | Field | Meaning |
293
+ | ---------- | --------------------------------------------------------------------- |
294
+ | `id` | Stable id for the row; also the argument to `abort` and `removeFile`. |
295
+ | `file` | The `File` as selected. |
296
+ | `status` | `'idle' \| 'uploading' \| 'success' \| 'error'`. |
297
+ | `progress` | 0–100 while uploading, 100 on success. |
298
+ | `error` | Human message, from validation or the strategy's failure. |
299
+ | `preview` | Object URL for images — a local thumbnail before uploading. |
300
+ | `stored` | The `StoredFile` the server confirmed. |
301
+
302
+ `upload()` sends every file still `idle` and resolves with the confirmed ones.
303
+ `abort(id)` cancels one upload, `abort()` cancels all — an aborted file returns
304
+ to `idle`, not `error`, so the user can retry without clearing anything.
305
+ `hasPending` is `true` while any file waits in `idle` — the flag a submit
306
+ button reads.
307
+
308
+ Omit `strategy` for local-only selection plus validation.
309
+
310
+ ### Upload trigger — `select` vs `manual`
311
+
312
+ Not every screen wants the same moment. The contract makes it explicit instead
313
+ of fixing one behavior:
314
+
315
+ - **`uploadOn: 'select'`** — the file travels the moment it validates. The
316
+ drag-and-drop / attach-and-go screens: evidence, avatars, galleries. The
317
+ styled `Uploader` defaults to this.
318
+ - **`uploadOn: 'manual'`** (hook default) — files wait in `idle` until the app
319
+ calls `upload()`. The form flow: every field plus the document commit as one
320
+ action on submit.
321
+
322
+ ```tsx
323
+ const uploader = useUploader({ scopes, scope, entityId, strategy }) // manual
324
+
325
+ const onSubmit = async (event: FormEvent) => {
326
+ event.preventDefault()
327
+ if (!form.valid || !uploader.hasPending) return
328
+ const stored = await uploader.upload() // fires here, with the submit
329
+ await saveRecord({ ...form.values, file: stored[0] })
330
+ }
331
+ ```
332
+
333
+ `onUploadStart(files)` fires when a batch actually leaves — from either
334
+ trigger — so a form can flip into its "sending" state at the true moment, not
335
+ at selection.
336
+
337
+ At the styled-component level the choice is a three-way contract —
338
+ `uploadOn: 'select' | 'submit' | 'manual'` — one mode per kind of screen:
339
+
340
+ | Mode | Who sends | Use it for |
341
+ | ---------- | --------------------------------- | -------------------------------------------------------------------------------- |
342
+ | `'select'` | The zone, the moment a file lands | Avatars, quick replacements — the file IS the action |
343
+ | `'submit'` | The form, via `controllerRef` | Any file that depends on the rest of a form to make sense (documents, catalogs) |
344
+ | `'manual'` | The zone's own upload button | Evidence and punctual flows with no form around them — drop now, send when ready |
345
+
346
+ Prefer `'submit'` whenever the file belongs to a form the user can abandon.
347
+ A scope with a stable key overwrites on every put, so an upload that fires on
348
+ selection has **already** changed what the entity serves — a customer's logo, a
349
+ product photo — even if the operator then hits Cancel. Deferring is what makes
350
+ "cancel" mean cancel. (`SlottedUploader` offers `'select'` and `'submit'` only:
351
+ it has no button surface, so `'manual'` staged slots could never leave.)
352
+
353
+ The `'submit'` wiring:
354
+
355
+ ```tsx
356
+ const uploaderRef = useRef<UploaderController | null>(null)
357
+ const [staged, setStaged] = useState(false)
358
+
359
+ const onSubmit = async () => {
360
+ if (uploaderRef.current?.hasPending) await uploaderRef.current.upload()
361
+ await handleSubmit(save)() // reads the values the upload just wrote
362
+ }
363
+
364
+ <Uploader
365
+ {...props}
366
+ uploadOn='submit'
367
+ controllerRef={uploaderRef}
368
+ onPendingChange={setStaged}
369
+ />
370
+ <button disabled={!isDirty && !staged}>Guardar</button>
371
+ ```
372
+
373
+ Two details that are easy to get wrong:
374
+
375
+ - Flush **before** `handleSubmit(...)()`, not inside the submit callback. An
376
+ upload settles into the form through `setValue`, and a callback that already
377
+ received its `data` argument would read the values from before it.
378
+ - `onPendingChange` is what tells the form it has unsent work. A staged file
379
+ never touches the fields, so a save button gated on `isDirty` alone stays
380
+ disabled on a pristine form the user just dropped a file into.
381
+
382
+ Under `'submit'` the zone renders no upload button of its own: two ways to
383
+ send the same batch is one too many, and the form's is the one that knows
384
+ whether the rest of the fields are valid.
385
+
386
+ ### Retry and concurrency
387
+
388
+ Both opt-in, both living entirely inside the hook:
389
+
390
+ ```ts
391
+ retry: { attempts: 3, backoffMs: 500 }, // or shorthand: retry: 3
392
+ concurrency: 3,
393
+ ```
394
+
395
+ - **`retry`** re-runs a failed strategy call before surfacing the error, with
396
+ exponential backoff (`backoffMs`, then ×2 each further attempt). Aborts never
397
+ retry, and validation failures never reach the strategy at all. The row's
398
+ progress resets between attempts; the user only sees an error when the last
399
+ attempt fails.
400
+ - **`concurrency`** caps how many files upload at once; the rest queue. Thirty
401
+ photos on mobile no longer means thirty simultaneous XHRs.
402
+
403
+ ### Renaming on the way in
404
+
405
+ `rename` rewrites each file's name before it enters the machine — a folio, a
406
+ client-side input, a slug. It runs **before validation** (a rename that breaks
407
+ the extension is rejected like any invalid file), and the scope's `path`
408
+ reads the new name when it builds the storage key:
409
+
410
+ ```ts
411
+ useUploader({
412
+ scopes,
413
+ scope: 'invoice-evidence',
414
+ entityId,
415
+ strategy,
416
+ rename: file => `${folio}-${file.name}`,
417
+ })
418
+ ```
419
+
420
+ Named slots already rename to `{slot}.{ext}` — that contract stays theirs.
421
+
422
+ ### Safe file names
423
+
424
+ `sanitizeFileName` turns a user's file name into a safe key segment — ASCII,
425
+ lower case, one extension, no path syntax. Call it **inside** your scope's
426
+ `path()`, so client and server derive the same key:
427
+
428
+ ```ts
429
+ path: (id, file) => `Docs/${id}/${sanitizeFileName(file.name)}`
430
+ ```
431
+
432
+ The traversal guard behind `resolveKey` judges by path segment, not by
433
+ substring: `Screenshot … 4.18.54 p.m..png` carries `..` without ever being
434
+ traversal, and a macOS screenshot is the common case, not a corner one. The
435
+ guard is the backstop; the sanitizer is the fix.
436
+
437
+ ### Upload strategies
438
+
439
+ A strategy is the physical transport for one file. The hook owns state, the
440
+ strategy owns bytes:
441
+
442
+ ```ts
443
+ type UploadStrategy = (
444
+ file: File,
445
+ scope: string,
446
+ entityId: string,
447
+ options: { onProgress: (percent: number) => void; signal: AbortSignal }
448
+ ) => Promise<StoredFile>
449
+ ```
450
+
451
+ The default one posts multipart to `POST {endpoint}/{scope}/{entityId}/upload`,
452
+ which is exactly what the framework adapters below expose:
453
+
454
+ ```ts
455
+ import { createXhrUploadStrategy } from 'uploaderkit/react'
456
+
457
+ const strategy = createXhrUploadStrategy({
458
+ endpoint: `${apiUrl}/storage`,
459
+ // Evaluated per upload, so a rotating JWT is read at send time.
460
+ headers: () => ({ Authorization: `Bearer ${getToken()}` }),
461
+ fieldName: 'file',
462
+ // Cookie sessions: the api answers on another origin, so the browser drops
463
+ // the session cookie unless the request asks for it.
464
+ credentials: 'include',
465
+ })
466
+ ```
467
+
468
+ Writing your own is one function — direct-to-bucket signed PUT, a resumable
469
+ protocol, a queue. Aborting must reject with an error named `AbortError`; the
470
+ hook maps that to `idle` instead of `error`.
471
+
472
+ ### Validation
473
+
474
+ Runs on the client for feedback and again on the server for safety. Three
475
+ checks, in order: **extension** against the scope's `accept`, **size** against
476
+ `maxBytes`, and **magic numbers** — the leading bytes of the file, so an `.exe`
477
+ renamed to `.pdf` is rejected before it travels.
478
+
479
+ Messages are Spanish and user-safe by design; the failure lands in
480
+ `files[i].error` and in `onError`.
481
+
482
+ ### Image compression
483
+
484
+ When the scope declares `compress`, images are downscaled and re-encoded on a
485
+ canvas before the strategy sees them:
486
+
487
+ ```ts
488
+ compress: { maxWidth: 512, maxHeight: 512, quality: 0.8, stripExif: true }
489
+ ```
490
+
491
+ EXIF is dropped as an inherent side effect of the re-encode — camera photos
492
+ carry GPS coordinates, and a public bucket is the wrong place for them. The
493
+ pipeline falls back to the original file whenever it cannot help, so it never
494
+ fails an upload. `compressImage(file, options)` is exported for one-off use.
495
+
496
+ ### Named slots (`useSlottedUploader`)
497
+
498
+ For forms where each position takes exactly one document. This hook wraps
499
+ `useUploader` verbatim: it only decides **which slot** a file fills and renames
500
+ it to `{slot}.{ext}`, so the scope's `path` yields a stable key and a re-upload
501
+ overwrites in place.
502
+
503
+ ```tsx
504
+ const { slots, accept, addFiles, addToSlot, removeSlot, abort, isUploading } =
505
+ useSlottedUploader({
506
+ scopes,
507
+ scope: 'company-identity',
508
+ entityId: companyId,
509
+ strategy,
510
+ slots: [
511
+ { id: 'letterhead', label: 'Letterhead', extensions: ['pdf'] },
512
+ { id: 'logo', label: 'Logo', extensions: ['png', 'svg'] },
513
+ ],
514
+ value: slotFiles, // SlottedFile[] — you own persistence
515
+ onChange: setSlotFiles,
516
+ matchBy: 'extension', // or 'name', or a custom `match` matcher
517
+ })
518
+ ```
519
+
520
+ Uploads are **controlled**: `value`/`onChange` keep persistence in the caller,
521
+ and the hook merges uploads the parent has not absorbed yet, so two quick drops
522
+ cannot race the controlled state into losing one.
523
+
524
+ `matchBy: 'extension'` (the default) prefers an empty slot, so dropping three
525
+ files fills three positions; `'name'` matches a file named after its slot
526
+ (`letterhead-a4.pdf` → slot `letterhead-a4`). `slotOfStored` recovers the slot
527
+ of a persisted file when you rehydrate from the database.
528
+
529
+ ---
530
+
531
+ ## UI components
532
+
533
+ Both components are skins over the hooks — same validation, compression,
534
+ progress and abort. Import `uploaderkit/tailwind.css` once (see
535
+ [Tailwind v4 Setup](#tailwind-v4-setup)).
536
+
537
+ ### `Uploader`
538
+
539
+ One dropzone, one or many files in it.
540
+
541
+ ```tsx
542
+ import { Uploader } from 'uploaderkit/ui'
543
+
544
+ ;<Uploader
545
+ scopes={scopes}
546
+ scope='invoice-evidence'
547
+ entityId={invoiceId}
548
+ strategy={strategy}
549
+ multiple
550
+ maxFiles={3}
551
+ label='Evidence'
552
+ description='PDF or photo, up to 8 MB'
553
+ stored={saved} // already persisted, rendered on the filesPosition side
554
+ filesPosition='below' // keep the drop target from sliding down the page
555
+ onRemoveStored={forget} // delete from storage here — see Removal below
556
+ confirmRemove // gate it behind a dialog; or { title, message }
557
+ onUploaded={persist}
558
+ resolveViewUrl={file => api.signedUrl(file.key)}
559
+ capture='environment' // mobile: open the rear camera directly
560
+ />
561
+ ```
562
+
563
+ It accepts every `useUploader` option plus the presentation props above, and
564
+ defaults `uploadOn` to `'select'`. `'manual'` gives the zone an upload button
565
+ for the files still waiting; `'submit'` hands the send to your form through
566
+ `controllerRef` (see [Upload trigger](#upload-trigger--select-vs-manual)). `resolveViewUrl` re-signs a
567
+ private object right before previewing it, for the case where the stored URL
568
+ has expired.
569
+
570
+ `filesPosition` decides which side of the dropzone the file lists sit on. It
571
+ defaults to `'above'`, the historical layout; `'below'` keeps the zone anchored,
572
+ which matters when files are added one at a time — otherwise every addition
573
+ pushes the target the user is aiming at further down.
574
+
575
+ `renderFiles` replaces the rows themselves. It receives the persisted files, the
576
+ staged ones with their live status, the default rows already built, and the
577
+ view/remove callbacks — so a screen can render a thumbnail grid, a single
578
+ summary line, or a count folded into its own card:
579
+
580
+ ```tsx
581
+ <Uploader
582
+ {...props}
583
+ filesPosition='below'
584
+ renderFiles={({ staged, stored, isEmpty, remove }) =>
585
+ isEmpty ? null : (
586
+ <ul className='grid grid-cols-3 gap-2'>
587
+ {stored.map(file => (
588
+ <li key={file.key}>{file.fileName}</li>
589
+ ))}
590
+ {staged.map(file => (
591
+ <li key={file.id} onClick={() => remove(file.id)}>
592
+ {file.file.name} · {file.status}
593
+ </li>
594
+ ))}
595
+ </ul>
596
+ )
597
+ }
598
+ />
599
+ ```
600
+
601
+ It changes the rows, not their place: the result still renders on the
602
+ `filesPosition` side. For a layout the zone itself has to be part of — files
603
+ BESIDE the dropzone, everything inside your own frame — skip this skin and
604
+ compose `useUploader` with the exported `Dropzone`, `FileItem` and
605
+ `StoredFileItem`. Nothing here is unavailable there.
606
+
607
+ The dropzone also accepts a **pasted** file while focused (screenshots land as
608
+ uploads), and `capture` makes a touch device offer its camera instead of the
609
+ picker. On a coarse pointer the prompt switches to the tap-first copy
610
+ (`labels.tapPrompt`) with press feedback — a phone user never reads about
611
+ dragging.
612
+
613
+ Presentation knobs: `size='sm'` compacts the zone and every row; `icon`
614
+ replaces the dropzone glyph with any node (`icon={null}` removes it). Colors
615
+ and radii come from the theme variables — see [Theming](#theming).
616
+
617
+ `shortcut='mod+u'` binds a global key (⌘U / Ctrl+U) that opens the picker and
618
+ renders a small `kbd` hint inside the zone, so users discover it. It never
619
+ fires while typing in a field, and two zones claiming the same combo warn in
620
+ development — with several uploaders on screen, give each its own.
621
+
622
+ ### `SlottedUploader`
623
+
624
+ A status row per slot plus one bulk dropzone whose matcher routes each file:
625
+
626
+ ```tsx
627
+ import { SlottedUploader } from 'uploaderkit/ui'
628
+
629
+ ;<SlottedUploader
630
+ scopes={scopes}
631
+ scope='company-identity'
632
+ entityId={companyId}
633
+ strategy={strategy}
634
+ title='Company documents'
635
+ slots={[
636
+ { id: 'letterhead', label: 'Letterhead', extensions: ['pdf'] },
637
+ {
638
+ id: 'logo',
639
+ label: 'Logo',
640
+ extensions: ['png', 'svg'],
641
+ hint: 'Transparent background',
642
+ },
643
+ ]}
644
+ value={slotFiles}
645
+ onChange={setSlotFiles}
646
+ confirmRemove // dialog before forgetting a filled slot
647
+ confirmReplace // dialog naming both files before overwriting
648
+ hideDropzone={false}
649
+ />
650
+ ```
651
+
652
+ `confirmReplace` intercepts the picked file _after_ the pick — the dialog can
653
+ then name what is about to be lost and what replaces it. Both props take
654
+ `true` for the default copy or `{ title, message }` to override it.
655
+
656
+ **Staged rows.** Under `uploadOn: 'submit'` a pick does not travel: it rests on
657
+ its own row with a thumbnail, its name and _ready to upload_, plus Replace and
658
+ Remove, until the form calls `controllerRef.current.upload()`. The status dot
659
+ turns amber to say so. The name shown is the storage one — the file is renamed
660
+ to `{slot}.{ext}` before it enters the machine, which is what the entity will
661
+ actually serve.
662
+
663
+ **Removal deletes, history keeps.** Pass a `removeStrategy` — `createRemoveStrategy({ endpoint, headers, credentials })`, the DELETE mirror of the upload transport (`DELETE {endpoint}/{scope}/{entityId}` with `{ key }`) — and a confirmed removal deletes the object from storage **by itself**, on `Uploader` and `SlottedUploader` alike. `onRemoveStored(stored)` still fires for the app's bookkeeping (clearing the DB reference), delivered BEFORE `onChange`. The reference is forgotten even when the delete fails — a dangling pointer is worse than an orphan — and a refused delete surfaces through `onError`. The opt-out is a scope contract, not a client choice: mark the scope `keepOnRemove: true` and both the components skip the delete and the server's `storage.remove` answers `false` — history enforced where no client can bypass it.
664
+
665
+ **Language.** English is the default across the kit. A Spanish app opts in once at the root — `<UploaderProvider language='es'>` (exported from `/react` and `/ui`) — and every component and hook under it, including validation messages like `maxFilesReached`, speaks Spanish; a per-component `labels` prop still wins for one-off rewording.
666
+
667
+ ### Confirmations
668
+
669
+ Destructive file actions get a second step: an accessible dialog (portal,
670
+ focus trapped, **focus lands on cancel** so a stray Enter never destroys
671
+ anything, Escape and the backdrop cancel). `ConfirmDialog` is exported for
672
+ wrapping your own actions in the same UX:
673
+
674
+ ```tsx
675
+ import { ConfirmDialog } from 'uploaderkit/ui'
676
+
677
+ ;<ConfirmDialog
678
+ open={confirming}
679
+ title='Eliminar expediente'
680
+ message='Se borrarán también sus documentos.'
681
+ variant='danger'
682
+ onConfirm={destroy}
683
+ onCancel={() => setConfirming(false)}
684
+ />
685
+ ```
686
+
687
+ ### File preview (`FileViewer`)
688
+
689
+ Both uploaders embed the full-screen viewer; it is exported standalone for any
690
+ screen that persists a `StoredFile`. It portals to `<body>` (no ancestor
691
+ stacking context can trap it), traps focus while open and restores it on
692
+ close, and locks the page scroll behind it. When `resolveUrl` fails — an
693
+ expired signature, a dropped connection — the viewer shows a retryable error
694
+ instead of loading forever. On narrow viewports a PDF renders as a download
695
+ card instead of an embedded frame (iOS Safari freezes embedded PDFs).
696
+
697
+ Pass `files` (the collection) alongside `file` (the one clicked) and the
698
+ viewer becomes a gallery: side arrows, `←`/`→` on the keyboard, a `2 / 5`
699
+ counter, and — on fine pointers — a footer hinting the shortcuts (`Esc`,
700
+ `←` `→`), so nobody has to guess them:
701
+
702
+ ```tsx
703
+ <FileViewer file={viewing} files={storedImages} onClose={close} />
704
+ ```
705
+
706
+ Keyboard: `Esc` closes, `←`/`→` walk the gallery, `D` downloads and `O` opens
707
+ the file in a tab — each hinted in the footer on fine pointers. A modifier is
708
+ never claimed, so `⌘D` still bookmarks.
709
+
710
+ `renderError` replaces the built-in "could not load" panel. It receives the
711
+ file that failed plus a `retry` that re-resolves it, so a custom panel keeps
712
+ the recovery the default one offers:
713
+
714
+ ```tsx
715
+ <FileViewer
716
+ file={viewing}
717
+ onClose={close}
718
+ renderError={({ file, retry }) => (
719
+ <MyErrorState name={file.fileName} onRetry={retry} />
720
+ )}
721
+ />
722
+ ```
723
+
724
+ `useFileViewer` owns the open/close state every screen would otherwise repeat:
725
+
726
+ ```tsx
727
+ const viewer = useFileViewer({ resolveUrl })
728
+
729
+ <button onClick={() => viewer.open(stored)}>Ver</button>
730
+ <FileViewer {...viewer.viewerProps} />
731
+ ```
732
+
733
+ ### Reading a stored file
734
+
735
+ An `<img>` or an `<iframe>` cannot send an `Authorization` header, so a
736
+ private or encrypted object never renders from its raw url. Two helpers do
737
+ the authenticated read for you — same rule, two output shapes:
738
+
739
+ ```tsx
740
+ import {
741
+ createBlobUrlResolver,
742
+ createBytesResolver,
743
+ } from 'uploaderkit/react'
744
+
745
+ // For the viewer: fetches with the app's headers, hands back an object URL.
746
+ const resolveViewUrl = createBlobUrlResolver({
747
+ baseUrl: apiUrl,
748
+ headers: () => ({ Authorization: `Bearer ${getToken()}` }),
749
+ credentials: 'include',
750
+ })
751
+
752
+ <Uploader {...props} resolveViewUrl={resolveViewUrl} />
753
+
754
+ // For code that processes the file instead of showing it.
755
+ const readBytes = createBytesResolver({ baseUrl: apiUrl, headers })
756
+ const pdf = await PDFDocument.load(await readBytes(stored.url))
757
+ ```
758
+
759
+ The rule both share is about **origin, not shape**: a url `baseUrl` serves —
760
+ app-relative, or absolute on the same origin — is yours and travels with your
761
+ headers and `credentials`; anything on a **foreign** origin is already
762
+ reachable and is fetched bare, because the token must never go to a
763
+ third-party host. Your server's `encryptedUrl` usually persists an absolute
764
+ url pointing back at your own `/view` route: that one counts as yours. Reading bytes through your own
765
+ endpoint is also what spares a public bucket its own CORS policy: an `<img>`
766
+ is exempt from CORS, a `fetch` for bytes is not.
767
+
768
+ `viewUrlFileName(url)` recovers the display name from a `/view?key=…` url.
769
+
770
+ ### Drop to replace
771
+
772
+ A filled `SlottedUploader` row is itself a drop target: dragging a file over
773
+ it lights the row up with a "Drop to replace" pill, and the drop runs through
774
+ the same `confirmReplace` dialog as the button path. An empty row accepts the
775
+ drop as a direct fill — no trip through the bulk zone's matcher.
776
+
777
+ ### Labels — every string is replaceable
778
+
779
+ All user-facing copy flows through one object, `UploaderLabels`. English is the
780
+ default; Spanish ships as `ES_LABELS`. Select a language once at the root:
781
+
782
+ ```tsx
783
+ import { UploaderProvider } from 'uploaderkit/react'
784
+
785
+ ;<UploaderProvider language='es'>
786
+ <App />
787
+ </UploaderProvider>
788
+ ```
789
+
790
+ Any partial override wins over that base, per component or per hook:
791
+
792
+ ```tsx
793
+ <Uploader {...props} labels={{ dropPrompt: 'Drop your invoice here' }} />
794
+ ```
795
+
796
+ #### The copy reaches further than the components
797
+
798
+ The object is not only for markup — it words the validation messages and the
799
+ server's HTTP responses too, which is what keeps one rejection from arriving in
800
+ two languages:
801
+
802
+ ```ts
803
+ import { ES_LABELS, validateForScope } from 'uploaderkit'
804
+
805
+ const result = await validateForScope(scopes, 'invoices', file, ES_LABELS)
806
+ // result.message is Spanish
807
+ ```
808
+
809
+ | Surface | How it takes the copy |
810
+ | --------------------------------------------- | ---------------------------------- |
811
+ | `Uploader`, `SlottedUploader`, presets | `labels` prop, over the provider |
812
+ | `useUploader`, `useSlottedUploader` | `labels` option, over the provider |
813
+ | `validateFile`, `validateFiles` | `labels` in `ValidationOptions` |
814
+ | `validateForScope` | 4th argument |
815
+ | `createXhrUploadStrategy`, the view resolvers | `labels` option |
816
+ | `createStorage` — and both framework adapters | `labels` option, once |
817
+
818
+ On the server, one option covers the whole round trip: `createStorage` resolves
819
+ the copy and republishes it as `storage.labels`, which is exactly where
820
+ `createExpressStorageHandlers` and `createNextStorageHandlers` read from, so a
821
+ route can never answer in a different language than the service behind it.
822
+
823
+ ```ts
824
+ import { ES_LABELS } from 'uploaderkit'
825
+
826
+ const storage = createStorage({ scopes, provider, labels: ES_LABELS })
827
+ // 401 → "No autorizado"; a rejected upload → the same 422 text the browser showed
828
+ ```
829
+
830
+ `ScopeError` is the one exception, deliberately: it flags a wiring bug, stays
831
+ English, and is never serialized to a client.
832
+
833
+ See `UploaderLabels` for the full key list.
834
+
835
+ ### Theming
836
+
837
+ The styled layer reads `--color-ui-*` / `--radius-ui*` CSS variables, declared
838
+ with defaults in `tailwind.css`. An app rebrands the whole styled layer with
839
+ a single override:
840
+
841
+ ```css
842
+ :root {
843
+ --color-ui-primary: #c41e3a;
844
+ --color-ui-primary-hover: #8b1529;
845
+ --radius-ui: 0.25rem;
846
+ }
847
+ ```
848
+
849
+ The override scopes like any CSS variable: put it on a wrapper `div` to
850
+ re-brand a single uploader instead of the whole app.
851
+
852
+ Motion ships with the components: rows animate in
853
+ (`--animate-ui-fade-in`), the viewer and the confirm dialog fade/scale on
854
+ enter and exit, the drag state lifts the zone and a press compresses it, and
855
+ the slot indicator pulses while uploading. All CSS — nothing to configure,
856
+ `prefers-reduced-motion` friendly to override from the app.
857
+
858
+ `Dropzone`, `FileItem` and `FileViewer` are exported separately for building a
859
+ different arrangement out of the same pieces.
860
+
861
+ ### Going fully headless
862
+
863
+ An app with its own design system uses `/react` directly and loses nothing —
864
+ validation, compression, progress, abort and slot routing all live in the
865
+ hooks. `/ui` exists so a screen that does not need custom markup does not have
866
+ to write any.
867
+
868
+ ---
869
+
870
+ ## Presets
871
+
872
+ The recipes the playground kept demonstrating are components now, under `uploaderkit/presets`. Each composes `./react` and `./ui`, reads the same `--color-ui-*` tokens and the same labels, and stays optional: when one does not fit, `useUploader` + `Dropzone` + `FileItem` are still the floor.
873
+
874
+ | Preset | What it is |
875
+ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
876
+ | `useDropAnywhere` + `DropAnywhereOverlay` | The whole window as a drop target. The hook counts `dragenter`/`dragleave` depth at window level and is the **only** `drop` handler, so a file never arrives twice; the overlay is purely visual (`pointer-events-none`). `accept` uses `<input>` syntax. |
877
+ | `AvatarUploader` | A picture that is its own control: click or drop on it, a progress ring closes around it, `onUploaded` hands back the `StoredFile`. Uploads on select on purpose. |
878
+ | `GalleryUploader` | A thumbnail grid: persisted `stored` tiles first, in-flight files with a progress wash, hover view/remove, the add tile as the dropzone, and `FileViewer` over the whole set. |
879
+
880
+ ```tsx
881
+ const uploader = useUploader({ scopes, scope: 'attachments', entityId, strategy, multiple: true })
882
+ const { dragging } = useDropAnywhere({ onFiles: files => uploader.addFiles(files), accept: uploader.accept })
883
+
884
+ <DropAnywhereOverlay open={dragging} />
885
+ ```
886
+
887
+ ## Server
888
+
889
+ ### `createStorage`
890
+
891
+ The server side of the contract: it re-runs the same validation the browser
892
+ ran, encrypts what the scope declares, and talks to a `StorageProvider`.
893
+
894
+ ```ts
895
+ import { createStorage } from 'uploaderkit/server'
896
+ import { createGcsProvider } from 'uploaderkit/adapters/gcs'
897
+
898
+ const storage = createStorage({
899
+ scopes,
900
+ provider: createGcsProvider({ publicBucket, privateBucket }),
901
+ crypto: { encrypt, decrypt }, // required when any scope declares `encrypt`
902
+ signedUrlTtl: 300, // seconds
903
+ })
904
+ ```
905
+
906
+ | Method | Answers |
907
+ | ------------------------------------------------ | ---------------------------------------------------------------- |
908
+ | `upload({ scope, entityId, file, uploadedBy })` | An `UploadResult`: the `StoredFile` to persist, plus `replaced`. |
909
+ | `read({ scope, key })` | Raw bytes, decrypted when the scope is encrypted. |
910
+ | `remove({ scope, key })` | `true` when the object existed. |
911
+ | `signedUrl({ scope, key, download, expiresIn })` | A fresh expiring URL. Throws on public scopes. |
912
+ | `list(prefix)` | `{ key, size }[]`. |
913
+
914
+ Construction is defensive: a private scope riding a provider that cannot sign,
915
+ or an encrypted scope without `crypto`, throws a `ScopeError` **before the
916
+ first request** — while a deploy can still fail loudly.
917
+
918
+ #### What an upload replaced
919
+
920
+ `upload()` answers an `UploadResult` — a `StoredFile` plus the keys the sweep
921
+ removed:
922
+
923
+ ```ts
924
+ const { key, url, replaced } = await storage.upload({ scope, entityId, file })
925
+
926
+ // The bucket no longer has these. Whatever you persisted must forget them too,
927
+ // or your UI keeps rendering objects that are gone.
928
+ await db.files.deleteMany({ key: { $in: replaced } })
929
+ ```
930
+
931
+ `replaced` is empty unless the scope resolves to an `'entity'` replace, and it
932
+ only lists what the provider confirmed deleted. The sweep runs **after** a
933
+ successful put — a failure between the two would otherwise leave the entity
934
+ with nothing — and a delete that fails is swallowed: the upload the caller
935
+ asked for did happen, and a stale object is not worth failing it over.
936
+
937
+ A public object's `url` carries a short `?v=` fingerprint of its content, so a
938
+ stable-key scope (an avatar) does not keep serving the previous image from a
939
+ CDN or the browser cache after an overwrite.
940
+
941
+ #### Streaming reads
942
+
943
+ `storage.readStream({ scope, key })` serves a file without holding it in
944
+ memory — through `get` a 20MB document costs its full size in RAM per
945
+ concurrent reader. The Express `view` handler pipes it. It degrades honestly:
946
+ a provider without `getStream`, or an encrypted scope whose crypto lacks
947
+ `decryptStream`, falls back to a buffered read wrapped in a one-chunk stream,
948
+ so callers always get one shape.
949
+
950
+ The trade of streaming decryption, stated where you decide: plaintext reaches
951
+ the consumer **before** the GCM tag is verified, so tampering surfaces as a
952
+ stream that breaks at the end — `read()` verifies before returning a single
953
+ byte.
954
+
955
+ ### Express
956
+
957
+ Structural request/response shapes instead of Express types, so the package
958
+ stays dependency-free and any Express 4/5 app satisfies them. The app keeps
959
+ ownership of multer:
960
+
961
+ ```ts
962
+ import { createExpressStorageHandlers } from 'uploaderkit/server/express'
963
+
964
+ const handlers = createExpressStorageHandlers(storage, {
965
+ authorize: async req => (req.user ? { userId: req.user.id } : null),
966
+ })
967
+
968
+ const upload = multer({ storage: multer.memoryStorage() })
969
+ router.post(
970
+ '/:scope/:entityId/upload',
971
+ useAuth,
972
+ upload.single('file'),
973
+ handlers.upload
974
+ )
975
+ router.delete('/:scope/:entityId', useAuth, handlers.remove)
976
+ router.get('/:scope/:entityId/signed-url', useAuth, handlers.signedUrl)
977
+ ```
978
+
979
+ `authorize` returns the acting user (or `{}` for "allowed") to proceed, or
980
+ `null` to answer 401.
981
+
982
+ ### Next.js App Router
983
+
984
+ Same surface over the Fetch API:
985
+
986
+ ```ts
987
+ // app/api/storage/[scope]/[entityId]/upload/route.ts
988
+ import { createNextStorageHandlers } from 'uploaderkit/server/next'
989
+
990
+ const handlers = createNextStorageHandlers(storage, {
991
+ authorize: async request => {
992
+ const session = await auth(request)
993
+ return session ? { userId: session.userId } : null
994
+ },
995
+ })
996
+
997
+ export const POST = handlers.upload
998
+ ```
999
+
1000
+ Omitting `authorize` leaves the router **open** — only acceptable behind an
1001
+ authenticated proxy.
1002
+
1003
+ ### Encryption
1004
+
1005
+ An encrypted scope needs **two** things wired, and `createStorage` throws at
1006
+ boot without either: the cipher, and `encryptedUrl`.
1007
+
1008
+ ```ts
1009
+ const storage = createStorage({
1010
+ scopes,
1011
+ provider,
1012
+ crypto,
1013
+ // Where a client can READ an encrypted object. The bucket holds
1014
+ // ciphertext, so a signed URL would serve garbage — this must point at
1015
+ // your authenticated view route, which decrypts on the way out.
1016
+ encryptedUrl: ({ scope, entityId, key }) =>
1017
+ `/api/storage/${scope}/${entityId}/view?key=${encodeURIComponent(key)}`,
1018
+ })
1019
+ ```
1020
+
1021
+ `StoredFile.url` for those scopes is that route, so an `<img>` or the
1022
+ `FileViewer` renders the real file. Both framework adapters expose the route
1023
+ as `handlers.view`, answering the decrypted bytes with
1024
+ `Cache-Control: private, no-store` — decrypted content must never land in a
1025
+ shared cache:
1026
+
1027
+ ```ts
1028
+ // Express
1029
+ router.get('/:scope/:entityId/view', useAuth, handlers.view)
1030
+
1031
+ // Next App Router — app/api/storage/[scope]/[entityId]/view/route.ts
1032
+ export const GET = handlers.view
1033
+ ```
1034
+
1035
+ No cipher is imposed: a scope declares `encrypt: true` and the app injects the
1036
+ `CryptoHooks`. `createAesGcmCrypto` is the reference implementation
1037
+ (AES-256-GCM, layout `[iv 12][tag 16][ciphertext]`) so you do not hand-roll it:
1038
+
1039
+ ```ts
1040
+ import { createAesGcmCrypto } from 'uploaderkit/server'
1041
+
1042
+ const crypto = createAesGcmCrypto(process.env.STORAGE_KEY!) // openssl rand -hex 32
1043
+ ```
1044
+
1045
+ The key must be exactly 64 hex characters (32 bytes) — no passphrase
1046
+ derivation on purpose, since deriving would let two instances run "almost the
1047
+ same" secret and silently produce mutually unreadable files.
1048
+
1049
+ Encrypted objects are stored as `application/octet-stream`, so nothing ever
1050
+ tries to render ciphertext; `read()` decrypts on the way out.
1051
+
1052
+ ---
1053
+
1054
+ ## Storage providers
1055
+
1056
+ A provider is the thin translation to a bucket. All three implement the same
1057
+ contract, so swapping one never touches the scopes or the service.
1058
+
1059
+ ```ts
1060
+ // Google Cloud Storage — two buckets: public assets, private documents
1061
+ import { createGcsProvider } from 'uploaderkit/adapters/gcs'
1062
+
1063
+ const provider = createGcsProvider({
1064
+ publicBucket,
1065
+ privateBucket,
1066
+ publicUrl: (bucket, key) => `https://cdn.example.com/${key}`,
1067
+ })
1068
+ ```
1069
+
1070
+ ```ts
1071
+ // S3-compatible — AWS, Cloudflare R2, Backblaze B2, MinIO, Wasabi
1072
+ import { createS3Provider } from 'uploaderkit/adapters/s3'
1073
+
1074
+ const provider = createS3Provider({
1075
+ client, // an @aws-sdk/client-s3 S3Client; only endpoint/credentials differ per backend
1076
+ bucket: 'uploads',
1077
+ publicUrl: key => `https://cdn.example.com/${key}`,
1078
+ })
1079
+ ```
1080
+
1081
+ ```ts
1082
+ // Tests and local development
1083
+ import { createMemoryProvider } from 'uploaderkit/adapters/memory'
1084
+
1085
+ const provider = createMemoryProvider() // signed URLs are fake but carry the expiry
1086
+ ```
1087
+
1088
+ The S3 bucket is treated as private and `publicUrl` maps the keys a CDN or
1089
+ public domain exposes — modern buckets block per-object ACLs, so the adapter
1090
+ cannot invent a stable public URL on its own. Both SDKs are optional peers:
1091
+ importing an adapter without its SDK installed fails only for the app that
1092
+ chose that backend.
1093
+
1094
+ ---
1095
+
1096
+ ## Developer feedback
1097
+
1098
+ Two tiers, so a broken config surfaces where it can still be fixed:
1099
+
1100
+ - **`ScopeError` — thrown, eagerly.** Configs that can never work: a malformed
1101
+ scope, an unknown scope name, duplicate slot ids, a private scope on a
1102
+ non-signing provider, an encrypted scope without `CryptoHooks`. They fail at
1103
+ import or boot, never in front of a user. The framework adapters rethrow it
1104
+ instead of serializing it to the client.
1105
+ - **Development warnings — once per issue.** Configs that run but probably are
1106
+ not what you meant: calling `upload()` without a strategy, dropping several
1107
+ files in single mode, a slot accepting extensions its scope rejects. Prefixed
1108
+ `[uploaderkit]`, silent in production, and never load-bearing for behavior.
1109
+
1110
+ Request-level failures are a third, separate family: `StorageRequestError`
1111
+ carries an HTTP status and a Spanish user-safe message, which the framework
1112
+ adapters map to JSON.
1113
+
1114
+ ---
1115
+
1116
+ ## Subpath Exports
1117
+
1118
+ | Import path | Contents |
1119
+ | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
1120
+ | `uploaderkit` | `defineScopes`, `validateForScope`, `resolveKey`, `toAcceptAttribute`, `formatFileSize`, `KB`/`MB`/`GB`, `ScopeError`, `DEFAULT_LABELS`/`ES_LABELS`, types |
1121
+ | `uploaderkit/react` | `useUploader`, `useSlottedUploader`, `createXhrUploadStrategy`, `compressImage`, slot matchers, types |
1122
+ | `uploaderkit/ui` | `Uploader`, `SlottedUploader`, `Dropzone`, `FileItem`, `FileViewer`, `ConfirmDialog`, `cn` |
1123
+ | `uploaderkit/server` | `createStorage`, `createAesGcmCrypto`, `StorageRequestError`, types |
1124
+ | `uploaderkit/server/express` | `createExpressStorageHandlers` — structural handlers for an Express app that owns multer |
1125
+ | `uploaderkit/server/next` | `createNextStorageHandlers` — App Router handlers over the Fetch API |
1126
+ | `uploaderkit/adapters/gcs` | `createGcsProvider` — two-bucket Google Cloud Storage (optional peer) |
1127
+ | `uploaderkit/adapters/s3` | `createS3Provider` — AWS, R2, B2, MinIO, Wasabi (optional peers) |
1128
+ | `uploaderkit/adapters/memory` | `createMemoryProvider` — tests and local development |
1129
+ | `uploaderkit/tailwind.css` | Tailwind v4 source registration for the `/ui` classes |
1130
+
1131
+ ---
1132
+
1133
+ ## License
1134
+
1135
+ MIT © Ricardo Tapia