# Use upup with AI assistants https://useupup.com/docs/ai-assistants/ Coding agents choose a library by retrieving the docs page that answers the exact question a developer typed, then running its quickstart. This page makes upup easy for an assistant to discover and integrate correctly on the first try. ## Machine-readable context upup publishes machine-readable context files that a coding agent can fetch directly: - **`https://useupup.com/llms.txt`** — a concise index of upup, its packages, and the key docs pages. - **`https://useupup.com/llms-full.txt`** — the fuller context dump for agents that can take more input. Point your assistant at either URL when you want it to work with upup. ## Paste-ready context block Drop this into your project's assistant config — `CLAUDE.md`, `.cursorrules`, `AGENTS.md`, or similar — so your agent has an accurate, compact model of upup: ```text # upup — MIT self-hosted file uploader (docs: https://useupup.com/docs/) upup is one headless core plus native, DOM-identical UI packages for six frameworks. Nine published @upupjs/* packages: - @upupjs/core headless engine: file state, upload pipeline (compression, HEIC, web-worker), cloud-drive plugins, i18n, theme. Zero framework deps. - @upupjs/react canonical UI (React 19). @upupjs/vue, @upupjs/svelte, @upupjs/angular, @upupjs/vanilla, @upupjs/preact are native ports with the same DOM. - @upupjs/next Next.js client re-export + /server route handlers (App and Pages routers). - @upupjs/server server-mode endpoints: S3-compatible presign + proxy, cloud-drive token exchange, HMAC-signed upload-token trust model. Client mode (default): the browser uploads straight to your storage; your app returns presigned URLs at `uploadEndpoint`. No server package required. React example: import { UpupUploader } from '@upupjs/react'; import '@upupjs/react/styles' Server mode: point the uploader at @upupjs/server with mode="server" serverUrl="/api/upup". createUpupHandler({ storage: { type: 'aws', bucket, region }, uploadTokenSecret }) — uploadTokenSecret is REQUIRED and must be >= 16 chars, or it throws at construction. Sources: local drag-and-drop, URL/link import, camera, screen capture, and cloud drives (Google Drive, OneDrive, Dropbox, Box). Optional: image compression, HEIC conversion, resumable uploads (tus or S3 multipart), ICU i18n, theming. Image editor is React/Preact only. Quickstarts: https://useupup.com/docs/quickstarts/ ``` Every line above is verified against the packages as they ship — a snippet from it will run rather than throw. ## Example prompt Once the context is in place, a request like this is enough: > Add file uploads to my SvelteKit app with upup. Use client mode with a presigned > upload endpoint, enable the Google Drive and camera sources, and show me the > component plus the upload-token route. The assistant has what it needs: install `@upupjs/svelte`, import the component and its stylesheet, render ``, and add the `sources` and `cloudDrives` config. ## Next steps - Framework quickstarts: [React](/docs/quickstarts/react/), [Vue](/docs/quickstarts/vue/), [Svelte](/docs/quickstarts/svelte/), [Angular](/docs/quickstarts/angular/), [Vanilla JS](/docs/quickstarts/vanilla/), [Preact](/docs/quickstarts/preact/), [Next.js](/docs/quickstarts/next/). - [Server Mode — Setup](/docs/guides/server-mode-setup/) for the full server walkthrough. --- # Azure SAS Responses https://useupup.com/docs/api-reference/azure-generate-sas-url/ Client uploads can use any endpoint that returns upup's presign contract. ```ts type PresignedUrlResponse = { key: string uploadUrl: string uploadHeaders?: Record publicUrl?: string downloadUrl?: string expiresIn: number } ``` Return headers that the browser must send with the signed request: ```ts return Response.json({ key, uploadUrl: sasUrl, uploadHeaders: { 'x-ms-blob-type': 'BlockBlob', 'Content-Type': body.type || 'application/octet-stream', }, expiresIn: 3600, }) ``` --- # Error Codes https://useupup.com/docs/api-reference/error-codes/ Most failures upup raises are an `UpupError` (or one of its six subclasses) carrying a `code` from the `UpupErrorCode` enum. Codes are stable strings — safe to switch on, log, and tag in an error tracker. This page is the complete list; [Error Handling](/docs/error-handling/) covers the narrative, and [Error Monitoring](/docs/guides/error-monitoring/) covers wiring failures into Sentry or your own reporter. The one important exception is the rejection from `upload()` itself — see [Batch failures](/docs/api-reference/error-codes/#batch-failures-are-not-upuperrors) before you write a handler, because it is **not** an `UpupError` and carries no `code`. Every class that carries a code, and the one rejection that sits outside them: ## The complete code list `UpupErrorCode` has 24 members. `retryable` is the value the error object carries on the `retryable` field — see [Retryability](/docs/api-reference/error-codes/#retryability) below for what actually drives automatic retries. | Code | Meaning | Typical trigger | Retryable | | ------------------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | | `AUTH_REQUIRED` | Anonymous uploads are disabled | `@upupjs/server` `/presign` or `/multipart/init` reached with no `auth`, no `getUserId`, and no `allowAnonymousUploads` — returns 403 | no | | `AUTH_DENIED` | The upload token belongs to a different user | A multipart continuation route (sign-part / complete / abort / resume) called with a token whose `uid` is not the current user — returns 403 | no | | `AUTH_EXPIRED` | A stored drive credential is dead or revoked | The server's drive-token refresh call is rejected by the provider; the stored tokens are deleted and the user must re-authorize. Surfaces only through the server's `onError` reporter — it is never returned as a response-body code | no | | `AUTH_PROVIDER_ERROR` | A cloud-drive OAuth or provider call failed | OAuth code-for-token exchange returns non-2xx (server answers 502). Also the code every `UpupAuthError` carries by construction | no | | `FILE_TOO_LARGE` | A file is over the per-file size ceiling | The file's size exceeds `maxFileSize` | no | | `FILE_TOO_SMALL` | A file is under the per-file size floor | The file's size is below `minFileSize` | no | | `TYPE_MISMATCH` | A file's type is not accepted | Neither the MIME type nor the extension matches `accept` | no | | `LIMIT_EXCEEDED` | Too many files | The incoming batch would push the selection past `maxFiles` | no | | `TOTAL_SIZE_EXCEEDED` | The selection is too large in aggregate | Existing plus incoming bytes exceed `maxTotalFileSize` | no | | `DUPLICATE` | A content-identical file is already selected | Reserved. With `contentDeduplication` on, duplicates are dropped silently rather than raised — no code path emits this today | no | | `MIN_FILES_NOT_MET` | Fewer files selected than required | Reserved — no code path emits this today | no | | `UPLOAD_FAILED` | The upload could not be performed | Exactly one emitter: the resumable tus strategy is selected but the optional `tus-js-client` dependency is not installed. Despite the generic name, no other path raises it | no | | `UPLOAD_ABORTED` | The upload was cancelled | Reserved. Aborts currently surface as an `UpupNetworkError` ("Upload aborted") carrying `NETWORK_ERROR` | no | | `PRESIGN_FAILED` | Presigning the object URL failed | The server's `/presign` route threw — bad storage credentials, unreachable S3 endpoint, rejected key — returns 500 | no | | `CORS_ERROR` | The bucket's CORS configuration blocked the request | Reserved for bucket-CORS diagnosis; no code path emits it today, but the i18n map already routes it to a "CORS misconfigured" message | no | | `PIPELINE_STEP_FAILED` | A processing-pipeline step threw | Reserved — the HEIC step reports through `HEIC_CONVERSION_FAILED`, and no other step emits this today | no | | `HEIC_CONVERSION_FAILED` | A HEIC/HEIF image could not be decoded | The optional `libheif-js` dependency is not installed, or the file contains no decodable image | no | | `NETWORK_ERROR` | The transport itself failed | An XHR `error` or `abort` event during a direct PUT, or any `UpupNetworkError`. Note that the error thrown once retries are exhausted is the last _real_ failure, which is often an `UpupStorageError`, not this code | **yes** | | `TIMEOUT` | An operation ran past its deadline | Reserved — no code path emits this today | no | | `STORAGE_ERROR` | An S3 / S3-compatible storage operation failed | A direct PUT returning non-2xx, any multipart init / sign-part / complete / abort / resume failure, a failed drive list or transfer, or the server router's last-resort 500. Also reported (not returned) when a post-completion hook throws after a durably-completed upload the client already got a 200 for, and when the health probe finds storage unreachable | class-dependent | | `QUOTA_EXCEEDED` | A configured storage quota is exhausted | The code every `UpupQuotaError` carries by construction | no | | `NO_UPLOAD_TARGET` | No upload destination is configured | The default code for `UpupConfigError` — no `uploadEndpoint` / provider wiring to upload to | no | | `BAD_REQUEST` | The request or API call is malformed | Server-side: an unparseable JSON body or invalid file metadata (400). Client-side: calling `addFiles` / `setFiles` / `upload` / `resume` / `retry` after `destroy()`, or passing an unknown file ID to `replaceFile` / `reorderFiles` | no | | `NOT_FOUND` | The addressed resource is gone server-side | `@upupjs/server` `/multipart/resume` asked to re-attach to a multipart upload the storage provider no longer has — completed, aborted, or reaped by an `AbortIncompleteMultipartUpload` lifecycle rule — returns 404. Deliberately a 4xx: the client drops its saved session and starts a fresh upload rather than retrying something that cannot return | no | Codes marked _reserved_ are part of the published enum and safe to reference in your own `switch` statements — they simply have no emitter in upup today. ### Retryability `retryable` is a per-class constant, not a per-code lookup: `UpupNetworkError` sets it to `true` and every other class sets it to `false`. Crucially, **the code and the flag come from different places**, so you cannot infer one from the other. `uploadErrorFromResponse` picks the class from its `kind` argument — which fixes `retryable` — and then overwrites `code` with whatever machine code the response body supplied. A code can therefore ride on a class it does not "belong" to. The server-side drive transfer is a real example: it builds its error with `kind: 'network'` (so `retryable` is `true`), and the server answers a failed drive request with `STORAGE_ERROR` in the body, so the client genuinely sees `STORAGE_ERROR` with `retryable: true`. Never branch on `code` to decide whether something is retryable, or vice versa. Read `retryable` for the flag and `code` for the cause. Automatic retries do **not** consult that flag. The upload manager retries every failed attempt up to `maxRetries` times except three cases, which fail immediately: - the upload was aborted, - the failure is an `UpupNetworkError` with a 4xx status (a client error — a retry would fail identically), - the failure is an `UpupNetworkError` with status `0`, which is how a rejecting `isSuccessfulCall` is reported. Treat `retryable` as a hint for your own UI and reporting logic, and [`maxRetries`](/docs/api-reference/upupuploader/optional-props/#maxretries) as the control for upup's own retry loop. ## The error classes Seven classes, all exported from `@upupjs/core`. Each subclass fixes the `code` it carries (except `UpupConfigError`, whose code is overridable) and adds the fields you need to act on the failure. | Class | Code it carries | Thrown when | Extra fields | | --------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | | `UpupError` | whatever the caller passes | Base class. Raised directly for lifecycle and API misuse (`BAD_REQUEST`), HEIC decode failures, and a missing optional upload dependency | `code`, `retryable`, `status?` | | `UpupAuthError` | `AUTH_PROVIDER_ERROR` | A cloud-drive OAuth or provider call fails; also what `uploadErrorFromResponse` builds for `kind: 'auth'` | `provider` | | `UpupNetworkError` | `NETWORK_ERROR` | Transport failure or abort — the only class that is `retryable` | `status?` | | `UpupValidationError` | the restriction reason itself | A file fails a size, type, or count check | `reason`, `file` | | `UpupQuotaError` | `QUOTA_EXCEEDED` | A configured quota is exceeded | `limit`, `used` | | `UpupStorageError` | `STORAGE_ERROR` | An S3 / storage operation fails; also what `uploadErrorFromResponse` builds for `kind: 'storage'` | `provider`, `operation` | | `UpupConfigError` | `NO_UPLOAD_TARGET` by default, overridable via the constructor | Configuration is missing or invalid | — | `UpupStorageError.operation` is one of `'presign'`, `'upload'`, `'multipart-init'`, `'multipart-complete'`, `'multipart-sign-part'`, `'multipart-abort'`, or `'multipart-resume'` — enough to tell a failed presign apart from a failed part upload without parsing messages. ### Batch failures are not `UpupError`s When one or more files in a run fail, `upload()` rejects with an `UpupUploadBatchError`. It extends plain `Error`, **not** `UpupError` — it has no `code` and no `retryable`. This is the single most common rejection you will handle, so a handler that only checks `instanceof UpupError` will fall through to its "unknown error" branch on the ordinary failure path. The batch error is a wrapper, and the coded per-file errors are inside it: ```typescript readonly errors: { file: UploadFile; error: Error }[] ``` Each `error` in that array is the real, coded failure for that file — an `UpupStorageError`, `UpupNetworkError`, and so on. The same per-file errors also arrive individually on the `upload-error` event as they happen, which is usually the better place to react; the batch rejection is best treated as "the run did not fully succeed" rather than as a diagnosis. One caveat when narrowing: `UpupUploadBatchError` is **not exported** from `@upupjs/core` or `@upupjs/core/internal`, so you cannot `instanceof` it. Detect it by name or by shape: ```typescript function isBatchError( error: unknown, ): error is Error & { errors: { file: File; error: Error }[] } { return error instanceof Error && error.name === 'UpupUploadBatchError' } ``` ### Narrowing by class, then by code `instanceof` gets you the extra fields; the `code` gets you the specific cause. Reach for the class first, because that is what makes `error.file` or `error.operation` type-safe: ```typescript import { UpupError, UpupErrorCode, UpupNetworkError, UpupStorageError, UpupValidationError, } from '@upupjs/core' function report(error: unknown): string { // Check this FIRST — the rejection from upload() is a batch wrapper that is // not an UpupError, so every instanceof below would miss it. if (isBatchError(error)) { return error.errors.map(entry => report(entry.error)).join('; ') } if (error instanceof UpupValidationError) { // `reason` is narrowed to RestrictionFailedReason return `${error.file.name} rejected (${error.reason})` } if (error instanceof UpupStorageError) { return `S3 ${error.operation} failed on ${error.provider}` } if (error instanceof UpupNetworkError) { return error.status === undefined ? 'Connection dropped.' : `Transport failed with HTTP ${error.status}.` } if (error instanceof UpupError) { switch (error.code) { case UpupErrorCode.PRESIGN_FAILED: return 'Your token endpoint returned an error.' case UpupErrorCode.AUTH_REQUIRED: case UpupErrorCode.AUTH_DENIED: return 'Sign in again to upload.' case UpupErrorCode.HEIC_CONVERSION_FAILED: return 'This HEIC image could not be converted.' default: return error.retryable ? 'Transient failure — safe to retry.' : 'Permanent failure — needs attention.' } } return 'Unknown error.' } ``` ## Restriction reasons `UpupValidationError.reason` is typed as `RestrictionFailedReason` — the subset of codes a file-level check can produce, plus one string that is not part of the code enum: ```typescript type RestrictionFailedReason = | UpupErrorCode.TYPE_MISMATCH | UpupErrorCode.FILE_TOO_LARGE | UpupErrorCode.FILE_TOO_SMALL | UpupErrorCode.LIMIT_EXCEEDED | UpupErrorCode.TOTAL_SIZE_EXCEEDED | UpupErrorCode.DUPLICATE | 'BEFORE_FILE_ADDED_REJECTED' ``` The per-file checks run in a fixed order — type, then maximum size, then minimum size — so the first violation reported for a file is the most specific one. `LIMIT_EXCEEDED` and `TOTAL_SIZE_EXCEEDED` are batch-level instead: they are raised once for the whole incoming batch, with `file` set to the first file in it rather than to a single offender. `'BEFORE_FILE_ADDED_REJECTED'` is the reason reserved for a host application vetoing a file before it enters the selection. It is a bare string, not a `UpupErrorCode` member, so compare it literally; like `DUPLICATE`, no code path emits it today. ## Errors from your own endpoints When you host the presign or token endpoint yourself, `uploadErrorFromResponse` is what turns a failed HTTP response into a typed, code-carrying error. Four of the five upload strategies route their failures through it — direct PUT, multipart, server credentials, and server-side drive transfer — so a machine code your endpoint returns arrives intact on `error.code`. The exception is the tus strategy, which rejects with whatever error `tus-js-client` produced, unwrapped. Those errors are not `UpupError`s and carry no `code`, so a handler that reads `error.code` will come up empty on tus uploads. ```typescript import { uploadErrorFromResponse } from '@upupjs/core' const err = uploadErrorFromResponse({ status: response.status, statusText: response.statusText, body: await response.text(), kind: 'storage', // 'storage' | 'auth' | 'network' operation: 'presign', // required when kind is 'storage' provider: 'S3', // defaults to 'server' }) ``` `kind` picks the class: `'auth'` builds an `UpupAuthError`, `'storage'` an `UpupStorageError`, and anything else an `UpupNetworkError`. The originating HTTP status is always copied onto `error.status`. ### How the body is parsed `parseErrorBody` is the exported helper behind it, and it tries three shapes in order: 1. **JSON** — a `code` field and an `error` (or `message`) field. This is the convention `@upupjs/server` emits, so a server-mode failure reaches the client with the same machine code the server logged. 2. **S3-style XML** — `` and `` inside an `` envelope. This is how S3 and S3-compatible providers report a rejected direct PUT, so provider codes like `SignatureDoesNotMatch` or `EntityTooLarge` survive. 3. **Plain text** — the first 200 characters of the body become the message, with no code. If the body yields no code, `error.code` falls back to the class default and the message falls back to `" "`. To make your own endpoint's failures legible on the client, return JSON with a `code` field: ```typescript return Response.json( { error: 'Bucket is read-only', code: 'STORAGE_ERROR' }, { status: 500 }, ) ``` Anything you put in `code` is preserved verbatim — it does not have to be an `UpupErrorCode` member. ## Turning codes into user-facing text `errorCodeToMessageKey(code)` in `packages/core/src/i18n/error-code-map.ts` maps a machine code to a key in the localized `ErrorMessages` catalog, and it is the one place that mapping lives. Note that it returns a **message key**, not a code — the two namespaces look similar and are easy to conflate. Coverage is partial. Eight of the 24 `UpupErrorCode` members have a dedicated mapping: `PRESIGN_FAILED`, `STORAGE_ERROR`, `BAD_REQUEST`, `AUTH_PROVIDER_ERROR`, `AUTH_EXPIRED`, `AUTH_DENIED`, `CORS_ERROR`, and `QUOTA_EXCEEDED`. The map also handles values that are not enum members at all: `@upupjs/server`'s upload-token codes (`expired`, `bad_signature`, `malformed`), S3 provider codes (`AccessDenied`, `SignatureDoesNotMatch`, `EntityTooLarge`, `NoSuchBucket`, `InvalidBucketName`), and the drive controller's `UNAUTHENTICATED`. Everything else — the other 16 enum members included — falls through to the `uploadFailedWithCode` message, and **that message interpolates the raw code** (`Upload failed with error code: {code}`). So an unmapped code _is_ shown to the end user. Only a completely absent code is code-free, falling back to the plain `uploadFailed` message. If you surface codes that matter to your users, either map them to your own copy or override `uploadFailedWithCode` for your locale. See [Localization](/docs/localization/) for overriding those strings per locale. --- # Events https://useupup.com/docs/api-reference/events/ Every upload, file, drag, pipeline, and UI interaction in upup flows through one typed event bus on `UpupCore`. Each framework's callback props are a convenience layer over that bus, and a lossy one — the bus carries events no prop exposes, and a few props fire on paths that emit no event at all. When in doubt, listen to the event. ## Listening With the visual uploader, use the callback props: ```tsx console.log('started')} onFileUploadProgress={(file, p) => console.log(file.name, p.percentage)} onUploadComplete={files => console.log(files.map(f => f.key))} onError={message => console.error(message)} /> ``` Headless, subscribe to the bus directly. `on()` returns an unsubscribe function: ```ts import { UpupCore } from '@upupjs/core' const core = new UpupCore({ uploadEndpoint: '/api/upload-token' }) const off = core.on('upload-progress', ({ fileId, loaded, total }) => { console.log(fileId, Math.round((loaded / total) * 100)) }) core.on('upload-error', ({ error, file }) => { console.error(file?.name ?? 'run', error.message) }) // Later: off() core.destroy() ``` The React hook `useUpupUpload` exposes the same `on(event, handler)`, and the uploader ref exposes the underlying `core`. See [Headless Usage](/docs/guides/headless/) for both. Bare event names are the typed catalog below and nothing else — an unknown bare name is a compile error at the `on` / `emit` site. > **There is exactly one upload-failure event: `upload-error`.** A bare > `'error'` event does not exist. Failed `resume()` calls route through > `upload-error`, and so does every pipeline step failure except HEIC > conversion, which downgrades to a `pipeline-error` diagnostic. > Cloud-drive plugins emit **namespaced** `:` names > (e.g. `google-drive:files-loaded`, `one-drive:session-expired`) which pass > through the bus untyped and are not part of this catalog — see > [Credentials & Cloud Drives](/docs/credentials-configuration/). Events whose payload is listed as `{}` carry an empty object; the payload argument exists but has no fields. ## Upload lifecycle A run drives one status projection, and the events below are emitted as it moves between states: | Event | Payload | Fires when | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `state-change` | `{ status?: UploadStatus; error?: Error; files?: Map; progress?: { totalFiles: number; completedFiles: number; percentage: number } }` | Any observable state moves — the catch-all every framework binding re-renders from. Emitted alongside most other events, carrying only the slice that changed. | | `upload-start` | `{ retry?: boolean; fileId?: string }` | A run begins. `retry: true` (with the optional `fileId`) marks a retry run rather than a fresh one. | | `file-upload-start` | `{ file: UploadFile }` | An individual file starts transferring. | | `upload-progress` | `{ fileId: string; loaded: number; total: number }` | Byte progress advances for one file. | | `upload-success` | `{ file: UploadFile; result: UploadResult }` | One file finishes successfully; `result.key` is its storage key. | | `upload-error` | `{ error: Error; file?: UploadFile }` | A file fails (payload carries `file`) **or** the run ends terminally (no `file`). The only failure event — but see the double-emit warning below. | | `upload-all-complete` | `UploadFile[]` | Every file in the run has a storage key — the batch is done. | | `upload-pause` | `{}` | `core.pause()` pauses an in-flight run. | | `upload-resume` | `{}` | `core.resume()` restarts a paused run. | | `upload-cancel` | `{}` | `core.cancel()` aborts the run; pending files return to `IDLE`. | | `retry` | `{ fileId?: string }` | `core.retry(fileId?)` is called, before the retry run starts. Omitting `fileId` retries every unfinished or failed file. | | `destroyed` | `{}` | `core.destroy()` runs. Terminal — afterwards `upload`/`resume`/`retry`/`addFiles`/`setFiles` throw. | > **One failed file emits `upload-error` twice.** The per-file emission fires > first (with `file`), then the batch rejects and the terminal handler emits a > second time (without `file`, carrying an aggregate > `UpupUploadBatchError`). Anything bound to `upload-error` — including the > `onError` prop — therefore runs **twice** for a single failure. Deduplicate on > `file` being present if you only want the per-file signal, and treat the > `file`-less emission as "the run is over". > **Vanilla only:** `@upupjs/vanilla` also emits `upload-error` when a link > import fails, so a URL-fetch failure reaches the same channel as an upload > failure. React's URL path calls `onError` without emitting the event. Don't > assume `upload-error` implies a transfer was attempted. ## File operations | Event | Payload | Fires when | | -------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `files-added` | `UploadFile[]` | One or more files pass validation and enter core state (only fires when at least one was accepted). | | `file-removed` | `UploadFile` | A single file is removed via `removeFile(id)`. | | `file-rejected` | `{ count: number }` | Some of an `addFiles` batch were filtered out — `count` is how many. The accepted remainder still lands. | | `file-replaced` | `{ file: UploadFile }` | A file's content is swapped in place, keeping its id (the image editor's save path). | | `files-cleared` | `{}` | `removeAll()` empties the list and clears any crash-recovery snapshot. | | `files-set` | `{ count: number }` | `setFiles()` replaces the whole selection; `count` is the resulting size. | | `files-reordered` | `{ fileIds: string[] }` | `reorderFiles()` changes the display order; `fileIds` is the new order. | | `restriction-failed` | `{ error: unknown }` | `addFiles()` throws instead of filtering — a hard validation failure (type, size, count). The error is rethrown to the caller. Nothing in upup subscribes to this event; the built-in UI reacts by catching the rethrow instead. | ## Plugins and options | Event | Payload | Fires when | | ------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------- | | `plugin-registered` | `{ name: string }` | `core.use(plugin)` registers a plugin, after its `init(emitter)` hook runs. | | `options-updated` | `{ partial: Partial }` | `updateOptions()` merges new options — how framework bindings forward changed props to core. | ## Recovery | Event | Payload | Fires when | | ------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `snapshot-restored` | `{ count: number; status: UploadStatus }` | `core.restore(snapshot)` rehydrates files and status from a snapshot. | | `crash-recovery-restored` | `{}` | A persisted IndexedDB session is recovered after a refresh or crash. Files that were mid-flight come back as `PAUSED`. Emitted after the `snapshot-restored` it triggers. | ## UI flow | Event | Payload | Fires when | | -------------------- | ------------------- | ------------------------------------------------------------------------------------------------ | | `done` | `{}` | The user dismisses a finished run (the Done action), after `onDoneClicked` and before the reset. | | `state-reset` | `{}` | The uploader returns to its empty initial state. | | `auto-upload` | `{ count: number }` | `autoUpload` is on and newly added files trigger an upload automatically; `count` is how many. | | `connection-online` | `{}` | The browser regains network connectivity. | | `connection-offline` | `{}` | The browser loses network connectivity. | ## Image editor React and Preact only — the other frameworks intentionally stub the editor. | Event | Payload | Fires when | | --------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------- | | `image-editor-open` | `{ file: UploadFile }` | The editor opens for a file, whether by user action or `imageEditor.autoOpen`. | | `image-editor-cancel` | `{ file: UploadFile }` | The editor closes without saving. If more files are queued, the next one opens. | | `image-editor-save` | `{ file: UploadFile; original: UploadFile }` | An edit is saved. `file` is the new content, `original` is what it replaced (same id). | ## Drag, drop, and paste | Event | Payload | Fires when | | --------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `drag-over` | `{}` | A drag hovers the dropzone. Bound straight to the DOM `dragover` event with no enter-transition guard, so it fires **repeatedly** throughout the hover — debounce it, don't treat it as an enter signal. | | `drag-leave` | `{}` | A drag leaves the dropzone without dropping. Leaves into a child element are filtered out, so this fires once per real exit. | | `drop` | `{ files: File[] }` | Files are dropped and accepted. Not emitted when a folder drop is blocked and nothing else came with it. | | `folder-drop-blocked` | `{ acceptedFiles: number }` | A dropped folder was ignored because `folderUpload.allowDrop` is off. `acceptedFiles` counts the loose files that still went through. | | `paste` | `{ files: File[] }` | Files are pasted with `enablePaste` on. A pasted file is renamed to `pasted-.` only when the clipboard gave it no name or the literal name `image.png`; any other name is kept as-is. | ## Pipeline diagnostics The processing pipeline (hash, HEIC, EXIF, thumbnail, compress) reports through these as it works through each file. | Event | Payload | Fires when | | ------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pipeline-start` | `{ fileId: string; steps: string[] }` | Processing begins for a file. `steps` lists every **configured** step — a step whose `shouldProcess` returns false is still listed here but never runs, so it emits no `pipeline-step`. | | `pipeline-step` | `{ fileId: string; step: string }` | A step **finished**. It is emitted after the step resolves, not before it starts, so a step that never completes produces no event. | | `pipeline-complete` | `{ fileId: string }` | All steps finished for that file. | | `pipeline-error` | `{ scope: string; name: string; message: string }` | HEIC conversion failed. This is the only source — the pipeline engine has no error handling of its own. | > **`pipeline-error` is not the general step-failure event.** Only the HEIC step > catches its own errors and reports them here, downgrading a failed conversion > to a diagnostic while the run continues. Every **other** step failure > propagates out of the pipeline uncaught and fails the whole run through > `upload-error`. So the absence of `pipeline-error` says nothing about whether > processing succeeded. ## UI telemetry Emitted by every framework's UI layer with identical payloads (React is the payload canon). Useful for analytics on how people drive the picker. | Event | Payload | Fires when | | -------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `source-click` | `{ sourceId: FileSource }` | A source tile is clicked in the source selector. | | `source-view-cancel` | `{ sourceId: FileSource \| undefined }` | The user backs out of an open source view. | | `browse-files` | `{}` | The native file picker is opened for a normal file browse. | | `folder-select` | `{ count: number }` | A folder is chosen. `count` is the file count via the File System Access API, or `0` on the `webkitdirectory` input fallback where the count isn't known yet. | | `url-submit` | `{ url: string }` | The link-import form is submitted. | | `url-fetch` | `{ file: File }` | A link import finishes downloading and produced a file. | | `url-fetch-cancel` | `{ url: string }` | An in-flight link import is aborted. | | `camera-capture` | `{ dataUrl: string }` | A camera photo is captured, before the user confirms it. | | `camera-confirm` | `{ file: File }` | The captured photo is confirmed and added. | | `file-preview-open` | `{ fileId: string; fileName: string }` | A file preview overlay opens. | | `file-preview-close` | `{ fileId: string; fileName: string }` | A file preview overlay closes. | ## React callback props `` accepts these handlers. They come from the shared `UploaderBaseProps` type, so React, Vue, Svelte, Angular, and Preact all take the same set (each with its own binding syntax). Most are driven by a core event — subscribe to the event instead when you need the full payload, ordering guarantees, or a listener outside the component. > **Vanilla is a different surface.** `@upupjs/vanilla`'s `createUploader` > takes `CreateUploaderOptions`, not `UploaderBaseProps`, and accepts only > eleven callbacks: `onFileAdded`, `onFileRemoved`, `onUploadProgress`, > `onUploadComplete`, `onDoneClicked`, `onIntegrationClick`, `onFilesDragOver`, > `onFilesDragLeave`, `onFilesDrop`, `onWarn`, `onError`. Note `onFileAdded` > rather than `onFilesSelected`, and a single `onUploadProgress` rather than the > per-file/aggregate pair. `onStatusChange`, `onPrepareFiles`, > `onRestrictionFailed`, `onFileTypeMismatch`, and the per-file upload callbacks > have no vanilla equivalent — subscribe to the events directly there. | Prop | Signature | Driven by | | ----------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `onFilesSelected` | `(files: UploadFile[]) => void` | `files-added` | | `onDoneClicked` | `() => void` | `done` (called just before the event) | | `onPrepareFiles` | `(files: UploadFile[]) => Promise` | No event — an async gate awaited at the start of `startUpload()`; return the list to upload | | `onFileClick` | `(file: UploadFile) => void` | No event — a click on a file row in the list | | `onIntegrationClick` | `(integrationType: string) => void` | `source-click` (called just before the event) | | `onUploadStart` | `() => void` | `upload-start` | | `onFileUploadStart` | `(file: UploadFile) => void` | `file-upload-start` | | `onFileUploadProgress` | `(file: UploadFile, progress: { loaded: number; total: number; percentage: number }) => void` | `upload-progress` (per file; `percentage` is computed for you) | | `onFilesUploadProgress` | `(completedFiles: number, totalFiles: number) => void` | `upload-progress` (batch aggregate) | | `onFileUploadComplete` | `(file: UploadFile, key: string) => void` | `upload-success` | | `onFilesUploadComplete` | `(files: UploadFile[]) => void` | `upload-all-complete` | | `onUploadComplete` | `(files: UploadFile[]) => void` | `upload-all-complete` (same event as above; both fire) | | `onStatusChange` | `(status: string) => void` | `state-change` — the projected status, lowercased and deduplicated so it fires once per real transition | | `onFileRemoved` | `(file: UploadFile) => void` | `file-removed` | | `onFilesDragOver` | `(files: File[]) => void` | `drag-over` — fires repeatedly during the hover, and `files` is effectively always `[]` (see below) | | `onFilesDragLeave` | `(files: File[]) => void` | `drag-leave` — `files` is effectively always `[]` (see below) | | `onFilesDrop` | `(files: File[]) => void` | `drop` (called just before the event) | | `onFileTypeMismatch` | `(file: File, acceptedTypes: string) => void` | No event — the built-in UI's rejection path only (see below) | | `onRestrictionFailed` | `(file: File, reason: 'TYPE_MISMATCH' \| 'FILE_TOO_LARGE' \| 'FILE_TOO_SMALL' \| 'LIMIT_EXCEEDED') => void` | No event — the built-in UI's rejection path only (see below) | | `onBeforeFileAdded` | `(file: File) => boolean \| File \| undefined \| Promise` | No event — an async filter run per file before it is added. Return `false` to reject, a `File` to substitute, `true`/`undefined` to accept | | `onError` | `(errorMessage: string) => void` | `upload-error`, plus validation failures from `addFiles()`; delivers a message string | | `onWarn` | `(warningMessage: string) => void` | No event of its own — non-fatal notices such as an ignored folder drop (which also emits `folder-drop-blocked`) | | `onFileProcessed` | `(file: UploadFile, data: Record) => void` | No core event — the server's SSE message on `processingEndpoint` (opened after the whole batch, see below) | `onFilesUploadComplete` and `onUploadComplete` are both invoked from `upload-all-complete` — pick one. ### Caveats worth knowing **The rejection callbacks are UI-path only, and the reason is guessed from text.** `onFileTypeMismatch` and `onRestrictionFailed` are _not_ wired to `restriction-failed`. The built-in UI wraps its `addFiles` call in a try/catch and classifies the thrown error by substring-matching its **message** — `'type'`, `'limit'`, `'below'`, `'size'` — to pick the reason code. Two consequences: calling `core.addFiles()` yourself emits `restriction-failed` but invokes neither callback, and the reason code is a heuristic over human-readable text rather than a structured field. For robust handling, subscribe to `restriction-failed` and inspect the error yourself. Both callbacks fire for a type mismatch; `onRestrictionFailed` alone covers the size and count cases. **Drag callbacks never receive files.** `onFilesDragOver` and `onFilesDragLeave` are handed `e.dataTransfer.files`, which browsers keep empty during a drag (the DataTransfer is in protected mode until the drop completes). Expect `[]` and use these purely as hover signals — real files arrive only in `onFilesDrop`. **SSE processing is per batch, not per file.** Despite its name and per-file signature, `onFileProcessed` is driven by a connection opened once the _entire_ batch finishes: the handler folds into `onFilesUploadComplete` and then opens one `EventSource` per completed file. No connection opens after each individual upload, so with a long batch the first file's server-side processing isn't watched until the last file lands. ## Next steps - [Event Handlers](/docs/api-reference/upupuploader/event-handlers/) — the short version of the callback props. - [Headless Usage](/docs/guides/headless/) — drive `UpupCore` and its bus with no upup UI at all. - [Error Handling](/docs/error-handling/) — the `UpupError` taxonomy behind `upload-error`. --- # S3 Presign Responses https://useupup.com/docs/api-reference/s3-generate-presigned-url/ For client uploads, return a presigned object URL and any headers that were part of the signature. ```ts type PresignedUrlResponse = { key: string uploadUrl: string uploadHeaders?: Record publicUrl?: string downloadUrl?: string expiresIn: number } ``` Do not sign browser-forbidden headers such as `Content-Length` for direct browser `PUT` requests. If the content type is signed, return it in `uploadHeaders` so upup can send the exact same value. ```ts return Response.json({ key, uploadUrl, uploadHeaders: { 'Content-Type': body.type || 'application/octet-stream', }, downloadUrl, expiresIn: 3600, }) ``` Use `@upupjs/server` when you want the server package to host these routes. --- # Server HTTP API https://useupup.com/docs/api-reference/server-http/ `createUpupHandler(config)` returns one `(req: Request) => Promise` function that serves every route below. This page is the wire-level contract: what each route accepts, what it returns, and which status codes it can produce. Reach for it when you are writing a non-upup client, proxying the handler, or reading a failing request in your logs. For wiring the handler into a framework, see [Server Mode — Setup](/docs/guides/server-mode-setup/); for why the 403s exist, see [Server Auth & Trust Model](/docs/guides/server-auth/). ## Routing model Routes are matched on the **path suffix**, so the handler works at whatever prefix you mount it (`/api/upup`, `/upload`, the root). Every example below is written relative to that mount point. Trailing slashes are tolerated: the handler strips them before matching, so a framework that normalizes URLs (Next.js `trailingSlash: true` 308s a `POST /presign` to `/presign/` with method and body intact) still hits the route instead of falling through to 404. Two routes run **before** the global `auth` gate — `OPTIONS` preflight and `GET /health` — so browsers can preflight and uptime probes can poll without credentials. Everything else passes through `config.auth` first, when you have configured it. ## Route table | Method | Path | Auth | Success | | --------- | --------------------------- | ----------------------------------------------- | -------------------- | | `OPTIONS` | any path | none (before the gate) | `204`, no body | | `GET` | `/health` | none (before the gate) | `200` always | | `POST` | `/presign` | `auth` / `getUserId` / `allowAnonymousUploads` | `200` presigned PUT | | `POST` | `/multipart/init` | `auth` / `getUserId` / `allowAnonymousUploads` | `200` + upload token | | `POST` | `/multipart/sign-part` | upload token (owner-bound when `getUserId` set) | `200` presigned part | | `POST` | `/multipart/complete` | upload token (owner-bound when `getUserId` set) | `200` final object | | `POST` | `/multipart/abort` | upload token (owner-bound when `getUserId` set) | `200` `{ ok: true }` | | `POST` | `/multipart/resume` | upload token (owner-bound; expiry relaxed) | `200` parts + token | | `GET` | `/auth/:provider` | resolved user id | `302` to provider | | `GET` | `/auth/:provider/cb` | OAuth `state` (single-use, 10 min) | `200` HTML | | `GET` | `/files/:provider` | resolved user id + stored drive tokens | `200` file list | | `POST` | `/files/:provider/transfer` | resolved user id + stored drive tokens | `200` stored object | `:provider` is one of `google-drive`, `one-drive`, `dropbox`, `box` — the kebab-case wire form. Anything else returns `400`. Any method/path pair not in the table falls through to `404 {"error":"Not found"}` — including a `GET /presign` or a `POST /files/:provider` without the `/transfer` suffix. `POST /multipart/resume` is the one route you can switch off: setting `multipartResumeWindowSeconds: 0` stops it from being routed at all, so it 404s like any unknown path. That is deliberate — a server with resume disabled is indistinguishable from one too old to have the route, and upup clients treat both the same way. ## Response conventions Every response the handler produces — success, error, redirect, preflight — carries an **`x-upup-request-id`** header holding a fresh UUID for that request. The same id appears in the `onError` log record, so a client-side failure correlates to exactly one server log line. There is no route that skips it. CORS headers are added to every response too, when `config.cors` is set and the request's `Origin` matches `allowedOrigins` (or the list contains `*`). The matched origin is reflected rather than a literal `*`; a bare `*` goes out only to origin-less, non-browser callers. `Access-Control-Allow-Credentials: true` is sent **only** on a concrete origin match — a wildcard-only allowlist gets public, non-credentialed CORS. Failure bodies are uniform: ```json { "error": "human-readable message", "code": "MACHINE_CODE" } ``` The `code` field is present on coded failures (the `UpupErrorCode` values plus the upload-token codes); a few plain validation rejections carry `error` alone. The real cause — stack, provider response — goes to your [`onError` seam](/docs/guides/error-monitoring/), never to the client. An unhandled throw anywhere in routing is caught by a single transport safety net that returns a logged, CORS-headered `500 {"error":"Internal error","code":"STORAGE_ERROR"}`. Adapters do not need a catch of their own. ## `OPTIONS` — preflight Returns `204` with no body, the CORS headers, and `x-upup-request-id`. Matched before the auth gate and before any path matching, so it answers for every path the handler is mounted under. ## `GET /health` Answers "is this instance configured, and can it reach storage?" in one unauthenticated request, without performing an upload. It **always returns `200`** — a liveness probe should not restart a container because S3 blipped — so read the `status` field, not the HTTP code. ```json { "status": "ok", "checks": { "config": "ok", "storage": "ok" }, "summary": { "storageType": "aws", "anonymousUploads": false, "anonymousDrives": false, "driveProviders": 2, "uploadTokenTtlSeconds": 3600 } } ``` | Field | Values | | ------------------------------- | ------------------------------------------------------------------------ | | `status` | `ok` when both checks pass, otherwise `degraded` | | `checks.config` | `ok` / `incomplete` — bucket, region, and a ≥ 16-char secret are all set | | `checks.storage` | `ok` / `error` — a `HeadBucket` probe, cached for 30 s | | `summary.storageType` | the configured `storage.type` label | | `summary.anonymousUploads` | whether `allowAnonymousUploads` is on | | `summary.anonymousDrives` | whether `allowAnonymous` (drive/tokenStore scoping) is on | | `summary.driveProviders` | count of configured OAuth providers | | `summary.uploadTokenTtlSeconds` | lifetime of an issued multipart upload token | No secret **value** is ever returned. Setting `health: { exposeSecretFingerprint: true }` adds one extra field: ```json { "uploadTokenFingerprint": "9f2c41ab" } ``` That is the first 8 hex characters of `SHA-256(uploadTokenSecret)` — enough to confirm every instance in a fleet shares the same secret (a rolling redeploy that half-rotated it shows two different fingerprints), and not reversible to the secret itself. It is off by default. ## `POST /presign` Issues a presigned `PUT` so the browser uploads bytes straight to storage. The server chooses the object key; the client never proposes one. **Auth.** This is a capability-granting route, so it is closed by default: with none of `auth`, `getUserId`, or `allowAnonymousUploads` configured it returns `403` `AUTH_REQUIRED`. That is the intended posture — configure one of the three rather than working around it. **Request** ```ts type FileMetadata = { name: string // non-empty type: string // MIME type; may be empty, but then it must pass allowedTypes size: number // bytes, finite, >= 0 } ``` **Response `200`** — the `PresignedUrlResponse` shape from `@upupjs/core`: ```ts type PresignedUrlResponse = { key: string publicUrl?: string downloadUrl?: string uploadUrl: string uploadHeaders?: Record expiresIn: number } ``` This handler returns `key`, `uploadUrl`, `downloadUrl`, `uploadHeaders`, and `expiresIn: 3600`. The PUT signature covers both `content-type` and `content-length`, so a body larger than the approved size fails at S3 rather than silently landing. `downloadUrl` is a separately signed `GET` valid for three days. **Status codes** | Status | Body | Cause | | ------ | ------------------------------------------------------------- | --------------------------------------------- | | `400` | `{ "error": "Invalid JSON body", "code": "BAD_REQUEST" }` | unparseable body | | `400` | `{ "error": "Invalid file metadata", "code": "BAD_REQUEST" }` | missing/wrong-typed `name`, `type`, or `size` | | `401` | `{ "error": "Unauthorized" }` | your `auth` gate returned `false` | | `401` | `{ "error": "Unauthenticated" }` | `getUserId` returned `null` | | `403` | `{ "error": "…", "code": "AUTH_REQUIRED" }` | no auth path configured | | `403` | `{ "error": "Upload rejected" }` | your `hooks.onBeforeUpload` returned `false` | | `413` | `{ "error": "File too large" }` | `size` exceeds `maxFileSize` | | `415` | `{ "error": "File type not allowed" }` | `type` does not match `allowedTypes` | | `500` | `{ "error": "Presign failed", "code": "PRESIGN_FAILED" }` | the storage call threw | Checks run in that order: JSON parse, metadata shape, size, type, `onBeforeUpload`, then identity resolution. So an oversized file is rejected with `413` before `getUserId` is consulted. The `AUTH_REQUIRED` body carries the full remedy as its message: `Anonymous uploads are disabled. Set allowAnonymousUploads:true, or configure auth/getUserId.` ## `POST /multipart/init` Starts an S3 multipart session and returns the signed upload token that authorizes every later step. Same auth gate, same metadata validation, and the same `400` / `401` / `403` / `413` / `415` table as `/presign`. **Request** ```ts { name: string type: string size: number chunkSizeBytes?: number // requested part size; floors and bumps applied } ``` **Response `200`** — `MultipartInitResponse` plus the token: ```ts type MultipartInitResponse = { key: string uploadId: string partSize: number expiresIn: number /** Opaque server-issued token bound to {key, uploadId, size, expiry}. */ token?: string } ``` `token` is always present on responses from this handler. Treat it as opaque: it is a base64url payload plus an HMAC-SHA-256 signature, and it binds the object key, the S3 `uploadId`, the resolved user id (`null` when anonymous), the allowed total-size envelope, an expiry one hour out, and the issue time. Send it back verbatim on every continuation request. The issue time is what [`/multipart/resume`](#post-multipartresume) anchors its window on, and it is carried forward unchanged when that route re-issues a token. Tokens minted before resume shipped carry no issue time; the resume route derives it from the expiry, since `init` is the only issuer and has always used the same TTL. `partSize` is the server's decision, not your request — see [Policy and limits](#policy-and-limits). `500` here is `{ "error": "Multipart init failed", "code": "STORAGE_ERROR" }`. ## `POST /multipart/sign-part` Presigns one part's `PUT`. The token is the credential — the client never re-asserts the key or `uploadId`, because the server re-derives both from the verified token. **Request** ```ts { token: string partNumber: number } ``` **Response `200`** — `MultipartSignPartResponse`: ```ts type MultipartSignPartResponse = { uploadUrl: string uploadHeaders?: Record expiresIn: number } ``` This handler returns `uploadUrl` and `expiresIn: 3600`. **Token failures.** A missing, tampered, or stale token returns `403` with the specific reason, so you can tell a bug from an expiry: ```json { "error": "Invalid upload token", "code": "bad_signature" } ``` `code` is one of `malformed`, `bad_signature`, or `expired`. The signature is verified before any payload byte is trusted, and compared in constant time. `expired` is recoverable rather than fatal: present the expired token to [`/multipart/resume`](#post-multipartresume) and retry the call with the fresh one it returns. upup's own client does this automatically, once per expiry, shared across its concurrent part uploads. **Owner binding.** When `getUserId` is configured, the caller's current identity is re-checked against the `uid` the token was issued to. A token that leaked to a different authenticated user cannot be replayed: ```json { "error": "Upload token does not belong to the current user", "code": "AUTH_DENIED" } ``` Without `getUserId` there was no identity to bind at `init`, `uid` is `null`, and the check is skipped — possession of the token is then the model by design. `500` is `{ "error": "Multipart sign failed", "code": "STORAGE_ERROR" }`. ## `POST /multipart/complete` Finalizes the object. Verifies the token and the owner binding exactly as `sign-part` does, then enforces the signed size envelope before committing. **Request** ```ts { token: string parts: Array<{ partNumber: number; eTag: string }> } ``` **Response `200`** — `MultipartCompleteResponse`: ```ts type MultipartCompleteResponse = { key: string publicUrl?: string downloadUrl?: string etag?: string } ``` This handler returns `key`, a three-day signed `downloadUrl`, and `etag` when S3 supplied one. **The envelope check.** Because `sign-part` and the browser's direct PUTs never re-send a size, the real byte total is only knowable here. The handler sums what S3 actually received (`ListParts`) and compares it against the `[smin, smax]` range signed at `init`. Outside that range, the multipart upload is **aborted** — nothing partial is left in the bucket — and the request gets: ```json { "error": "Upload size outside signed envelope" } ``` with status `403`. This closes the "declare one byte at init, stream a gigabyte" path. After a successful commit, `hooks.onFileUploaded` and `hooks.onUploadComplete` run. A hook that throws is logged through `onError` and swallowed: the object is already durable, so the client still gets its `200` rather than a `500` telling it to retry an upload that succeeded. `500` is `{ "error": "Multipart complete failed", "code": "STORAGE_ERROR" }`. ## `POST /multipart/abort` Cancels the session and asks S3 to discard the uploaded parts. Token verification and owner binding are identical to `sign-part`. **Request** ```ts { token: string } ``` **Response `200`** — `MultipartAbortResponse`: ```ts type MultipartAbortResponse = { ok: true } ``` `500` is `{ "error": "Multipart abort failed", "code": "STORAGE_ERROR" }`. ## `POST /multipart/resume` Re-attaches to an upload a previous page load left in flight. Answers with the parts storage already holds — each with its byte size — and a freshly-signed token. This is what powers [cross-reload resume](/docs/resumable-uploads/#cross-reload-resume), and it is also how a client refreshes a token that expired mid-upload. **Request** ```ts { token: string } ``` The token is the only input. The client never sends the key or the `uploadId`, and neither comes back — the server re-derives both from the verified token, so the `uploadId` never leaves the signed payload. **Response `200`** — `MultipartResumeResponse`: ```ts type MultipartResumeResponse = { key: string /** Fresh token replacing the presented one — same key, uploadId, owner, * and size envelope; new expiry. */ token: string /** Parts the provider already holds; every entry carries `size`. */ parts: Array<{ partNumber: number; eTag: string; size?: number }> } ``` Send the returned `token` on every subsequent `sign-part` / `complete` / `abort` call — the presented one is not invalidated, but it is the stale one. **Expiry, relaxed and then re-bounded.** This is the only route that accepts a token whose `exp` has passed, because handing an expired token back as a fresh one is the whole point: an upload can easily outlive the one-hour TTL. A tighter bound replaces it — the **resume window**, measured from the original `/multipart/init` and carried forward unchanged on every re-issue, so a chain of resumes can never extend it. Signature and shape verification are identical to every other route. | Config | Default | Effect | | --------------------------------- | ------- | -------------------------------------------------------- | | `multipartResumeWindowSeconds` | `86400` | Seconds after the original `init` that resume is allowed | | `multipartResumeWindowSeconds: 0` | — | Route is not registered; requests 404 | A negative or fractional value throws `UpupConfigError` at construction rather than being coerced. **Owner binding** is enforced exactly as on `sign-part`: with `getUserId` configured the caller's identity is re-checked against the token's `uid`, and a mismatch returns `403 AUTH_DENIED`. Without `getUserId`, possession of the token is the model, as everywhere else. **Status codes** | Status | Body | Cause | | ------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `400` | `{ "error": "Invalid JSON body", "code": "BAD_REQUEST" }` | unparseable body | | `403` | `{ "error": "Invalid upload token", "code": "malformed" \| "bad_signature" }` | the token is not a token, or the signature does not verify | | `403` | `{ "error": "Upload resume window has expired", "code": "expired" }` | more than `multipartResumeWindowSeconds` since the original `init` | | `403` | `{ "error": "…", "code": "AUTH_DENIED" }` | owner mismatch | | `404` | `{ "error": "Multipart upload no longer exists", "code": "NOT_FOUND" }` | the provider has no such upload — completed, aborted, or reaped by a lifecycle rule | | `500` | `{ "error": "Multipart resume failed", "code": "STORAGE_ERROR" }` | the storage call threw for any other reason | A dead upload is deliberately a `404`, never a `500`: upup clients drop the session and start fresh on a `4xx`, whereas a `5xx` reads as "try again later" and would retry an upload that can never come back. The `NOT_FOUND` check matches the provider's `NoSuchUpload` error specifically, not a bare 404 status — a missing _bucket_ also 404s, and that is a misconfiguration, not a stale session. A leaked token stays usable for the resume window instead of one hour. What it can do with that window is unchanged and narrow: continue the **same** upload, to the **same** key, inside the **same** signed size envelope, still owner-bound whenever `getUserId` is configured. It cannot start an upload, retarget one, or widen the byte envelope. Shorten `multipartResumeWindowSeconds`, or set it to `0`, if that trade is not one you want. ## `GET /auth/:provider` Begins the cloud-drive OAuth flow. Requires a `tokenStore` and a resolvable user id — drive tokens are stored per user, which is why `providers` or `tokenStore` without `getUserId` is refused at construction unless you set `allowAnonymous: true`. **Query parameters** | Name | Required | Meaning | | ---------- | -------- | ------------------------------------------------------------------------ | | `returnTo` | no | Where to send the popup afterwards. Validated; see the callback section. | **Response `302`** to the provider's authorization URL, carrying a 32-byte random `state` and a `redirect_uri` derived from the request URL — always `/auth/:provider/cb`, so it is identical on the redirect and the exchange and providers do not reject it as a mismatch. The `state` is stored for **10 minutes** and is single-use. | Status | Body | Cause | | ------ | ------------------------------------------------------- | ---------------------------------------- | | `400` | `{ "error": "Unknown provider: x" }` | not one of the four wire slugs | | `400` | `{ "error": "Google Drive not configured" }` | that provider is absent from `providers` | | `401` | `{ "error": "Unauthenticated" }` | `getUserId` returned `null` | | `500` | `{ "error": "tokenStore is required for OAuth flows" }` | no `tokenStore` configured | | `500` | `{ "error": "No OAuth providers configured" }` | no `providers` block at all | Requested scopes are read-only per provider: Google Drive `drive.readonly`, OneDrive `Files.Read.All offline_access`, Dropbox `files.content.read files.metadata.read`, Box `root_readonly`. ## `GET /auth/:provider/cb` The provider's redirect target. Exchanges the authorization code for tokens, persists them against the user id captured in the `state`, and renders a small HTML page that closes the popup. **Query parameters:** `code`, `state`, or `error` from the provider. **Response `200`** is `text/html`, not JSON. The page posts `{ type: 'upup:oauth-success', provider }` to `window.opener`, targeted at each concrete origin in `cors.allowedOrigins` (falling back to `*` only when no concrete origin is configured — the payload carries no token), then closes itself. With no opener it redirects to a validated `returnTo`, or prints a "you may close this window" line. `returnTo` is validated fail-closed: it is accepted only when it resolves to the server's own origin or to a concrete (non-wildcard) entry in `cors.allowedOrigins`. A `*` in the allowlist does not authorize an arbitrary redirect target. Both `/auth` routes sit behind your global `auth` gate. The callback arrives as a top-level browser navigation from the provider, so it carries cookies but no `Authorization` header — an `auth` implementation that only reads a bearer token will reject the callback with `401`. Accept a session cookie there, or scope your gate to the upload routes. | Status | Body | Cause | | ------ | --------------------------------------------------------------------- | ------------------------------------------------- | | `400` | `{ "error": "OAuth error: access_denied" }` | the provider returned an error | | `400` | `{ "error": "Missing code or state" }` | truncated callback | | `400` | `{ "error": "Invalid or expired state" }` | replayed, unknown, or provider-mismatched `state` | | `400` | `{ "error": "Unknown provider: x" }` | bad slug | | `500` | `{ "error": "tokenStore is required" }` | no `tokenStore` | | `502` | `{ "error": "Token exchange failed", "code": "AUTH_PROVIDER_ERROR" }` | the provider rejected the code exchange | ## `GET /files/:provider` Lists one folder of the connected drive, or searches it. Requires a resolved user id and stored tokens for that user and provider. **Query parameters** | Name | Required | Meaning | | ---------- | -------- | ---------------------------------------------------------- | | `folderId` | no | Provider folder id; omitted means that provider's root. | | `search` | no | Free-text query; when present it replaces folder browsing. | **Response `200`** ```ts { provider: 'google-drive' | 'one-drive' | 'dropbox' | 'box' files: Array<{ id: string name: string size?: number mimeType?: string thumbnailUrl?: string isFolder: boolean modifiedAt?: string }> } ``` **Re-authentication.** When the caller has no stored tokens, when a proactive refresh fails, or when the provider answers `401`, the route returns **`401`** with a re-auth signal instead of a generic error: ```json { "reauth": true, "provider": "google-drive" } ``` In the last two cases the dead tokens are deleted from the store first, so the next request starts a clean OAuth flow. Send the user back through `GET /auth/:provider`. Tokens holding a refresh token are refreshed automatically 30 seconds before expiry, so a routine expiry never surfaces as `reauth`. | Status | Body | Cause | | ------ | -------------------------------------------------------------- | ----------------------------------- | | `400` | `{ "error": "Unknown provider: x" }` | bad slug | | `401` | `{ "error": "Unauthenticated" }` | `getUserId` returned `null` | | `401` | `{ "reauth": true, "provider": "…" }` | no / expired / revoked drive tokens | | `500` | `{ "error": "tokenStore is required" }` | no `tokenStore` | | `500` | `{ "error": "Drive request failed", "code": "STORAGE_ERROR" }` | the drive API failed | ## `POST /files/:provider/transfer` Streams one drive file **server-side** into your bucket, so the bytes never touch the browser. Same identity, token-refresh, and re-auth behavior as the list route. **Request** ```ts { fileId: string // required fileName?: string // overrides the provider's name size?: number // client-declared; used only for the fast reject mimeType?: string // checked against allowedTypes } ``` **Response `200`** ```ts { provider: string key: string name: string size: number // ACTUAL bytes transferred, not the declared size type: string url: string // three-day signed download URL } ``` `hooks.onFileUploaded` runs after the object is durable, with the same throw-is-logged-and-swallowed treatment as multipart complete. | Status | Body | Cause | | ------ | -------------------------------------------------------------- | ---------------------------------------------- | | `400` | `{ "error": "Invalid JSON body" }` | unparseable body | | `400` | `{ "error": "Missing fileId" }` | no `fileId` | | `400` | `{ "error": "Unknown provider: x" }` | bad slug | | `401` | `{ "error": "Unauthenticated" }` | `getUserId` returned `null` | | `401` | `{ "reauth": true, "provider": "…" }` | no / expired / revoked drive tokens | | `413` | `{ "error": "File too large" }` | declared `size` exceeds `maxFileSize` | | `415` | `{ "error": "File type not allowed" }` | `mimeType` fails `allowedTypes` | | `500` | `{ "error": "tokenStore is required" }` | no `tokenStore` | | `500` | `{ "error": "Drive request failed", "code": "STORAGE_ERROR" }` | drive fetch, size cap, or storage write failed | The `413` above is only a cheap early-out on the client-declared size. The authoritative cap is enforced against the bytes actually streamed — see below. ## Policy and limits **`maxFileSize`** (bytes) applies to `/presign`, `/multipart/init`, and `/files/:provider/transfer`. On the upload routes it rejects the declared size with `413`. On the transfer route it is additionally enforced against real egress: the transfer aborts the moment the streamed total crosses the cap, the S3 multipart upload is aborted, and **nothing is persisted**. Omit the option and no size limit applies. **`allowedTypes`** is a list of MIME patterns. An exact match passes; a trailing `/*` matches the whole type family (`image/*` matches `image/png`). An empty or absent type does **not** match a non-empty allowlist — a missing MIME type is rejected on both the upload and the transfer path, never waved through. Omit the option and every type passes. **Multipart part size.** The S3 floor is `MIN_PART_SIZE` = 5 MiB, and an upload may have at most 10,000 parts. `partSize` returned by `/multipart/init` is therefore `max(chunkSizeBytes ?? 5 MiB, 5 MiB, ceil(size / 10000))` — a large file automatically gets a larger part size instead of failing on the part-count ceiling. Requesting a chunk below 5 MiB silently floors to 5 MiB. **Drive-transfer buffering.** `SINGLE_PUT_MAX_BYTES` is 5 MiB. A drive file of known size at or under it is buffered once and written with a single `PUT`; anything larger — or of unknown size (a provider-reported size of `0`) — streams through bounded 5 MiB multipart parts, so peak server memory is one part regardless of file size. This bound is deliberately not configurable — memory safety is not a knob that can be raised away. **Upload-token TTL.** One hour from `init`, reported on `/health` as `summary.uploadTokenTtlSeconds`. A multipart session that outlives it refreshes its token through [`/multipart/resume`](#post-multipartresume) rather than restarting, up to `multipartResumeWindowSeconds` after the original `init`. ## Adapters Express, Fastify, Hono, `@upupjs/next` (App and Pages routers), and any custom Node adapter built on `@upupjs/server/node-bridge` all dispatch into this exact contract. The adapters only translate between the framework's request/response objects and the Web `Request`/`Response` the handler speaks — no route, status code, or body shape differs between them. See [Server Mode — Setup](/docs/guides/server-mode-setup/) for the per-framework mounting code, and [Storage Providers](/docs/guides/storage-providers/) for the `storage` block behind these routes. --- # Theme Slots https://useupup.com/docs/api-reference/upupuploader/classnames/ The stable styling API is `theme.slots`. It is keyed by component, then by the slot within it. ```tsx ``` Use `theme.mode` for color scheme: ```tsx ``` Slot contracts are exported from `@upupjs/core/theme`. ## Internals (what actually renders) Rendered styling comes from `theme.{mode,tokens,slots}` applied through inline `cn()` class strings inside each component (e.g. `UploaderPanel`). The `--upup-spacing-*` CSS-variable group is generated by the theme system but is **not** consumed by any component today — treat it as reserved, not a live styling knob. --- # Event Handlers https://useupup.com/docs/api-reference/upupuploader/event-handlers/ React events mirror core upload state. ```tsx console.log(files)} onUploadStart={() => console.log('started')} onFileUploadComplete={file => console.log(file)} onUploadComplete={files => console.log(files)} onError={message => console.error(message)} /> ``` Common handlers: | Handler | Called when | | ----------------------- | ------------------------------------------------------------------------------------- | | `onFilesSelected` | Files enter core state. | | `onFileRemoved` | A file is removed. | | `onUploadStart` | Upload begins. | | `onFileUploadProgress` | Byte progress changes for one file. | | `onFilesUploadProgress` | Aggregate byte progress across the batch. | | `onFileUploadComplete` | One file succeeds. | | `onUploadComplete` | All files succeed. | | `onError` | A validation, config, network, or provider failure occurs; delivers a message string. | File objects use the `UploadFile` contract from `@upupjs/core/contracts`. --- # Icon Prop https://useupup.com/docs/api-reference/upupuploader/icon-prop/ The icon prop is an object used to configure what icons are shown on the UpupUploader client component. It is optional. Each icon element must be a valid React element that accepts an optional `className` prop for styling. Recommended icon library suites include [React Icons](https://react-icons.github.io/react-icons) and [Lucide Icons](https://lucide.dev/). | Key | Example | Type | Default value | | --------------------------------------------- | -------------------------------------------- | ---------------------------- | ---------------- | | [CameraCaptureIcon](#cameracaptureicon) | `icons={{CameraCaptureIcon: FaCamera}}` | `FC<{ className?: string }>` | `TbCapture` | | [CameraDeleteIcon](#cameradeleteicon) | `icons={{CameraDeleteIcon: MdDelete}}` | `FC<{ className?: string }>` | `TbTrash` | | [CameraRotateIcon](#camerarotateicon) | `icons={{CameraRotateIcon: FaCameraRotate}}` | `FC<{ className?: string }>` | `TbCameraRotate` | | [ContainerAddMoreIcon](#containeraddmoreicon) | `icons={{ContainerAddMoreIcon: IoAdd}}` | `FC<{ className?: string }>` | `TbPlus` | | [FileDeleteIcon](#filedeleteicon) | `icons={{FileDeleteIcon: TiDelete}}` | `FC<{ className?: string }>` | `TbTrash` | | [LoaderIcon](#loadericon) | `icons={{LoaderIcon: TbLoader2}}` | `FC<{ className?: string }>` | `TbLoader` | ## `CameraCaptureIcon` Custom icon for the camera capture button. **Example override:** ```tsx import { FaCamera } from 'react-icons/fa' ; ``` ## `CameraDeleteIcon` Icon for removing captured camera images. ## `CameraRotateIcon` Button to switch between front/back camera. ## `ContainerAddMoreIcon` Icon shown in the "Add More Files" button. Appears when multiple uploads are allowed. ## `FileDeleteIcon` Icon for removing selected files from the list. ## `LoaderIcon` Animated icon shown during file processing. **Customization example:** ```tsx import { ImSpinner8 } from 'react-icons/im' ; ``` All icons inherit the component's dark mode styling automatically through the `className` prop when provided. --- # Image Editor https://useupup.com/docs/api-reference/upupuploader/image-editor/ The image editor is **on by default** in React and Preact. Pass `imageEditor={false}` to opt out, or an options object to configure it. The image editor ships in `@upupjs/react` and `@upupjs/preact` only (Preact loads the real React editor as a lazy island). The Vue, Svelte, Angular, and Vanilla packages intentionally stub it — a deliberate ruling, not a gap. ```tsx ``` Edited files flow back through core validation before upload. ## Options ```ts type ImageEditorOptions = { enabled?: boolean display?: 'inline' | 'modal' autoOpen?: 'never' | 'single' | 'always' output?: { mimeType?: string quality?: number fileName?: (original: File) => string } tabs?: ( 'Adjust' | 'Annotate' | 'Filters' | 'Finetune' | 'Resize' | 'Watermark' )[] tools?: ( | 'Crop' | 'Rotate' | 'Flip' | 'Brightness' | 'Contrast' | 'HSV' | 'Blur' | 'Text' | 'Line' | 'Rect' | 'Ellipse' | 'Polygon' | 'Pen' | 'Arrow' | 'Image' )[] onOpen?: (file: UploadFile) => void onCancel?: (file: UploadFile) => void onSave?: (editedFile: UploadFile, originalFile: UploadFile) => void } ``` - **`display`** (default `'inline'`) — `'inline'` swaps the editor into the uploader panel; `'modal'` opens it in a portal over the page. - **`autoOpen`** (default `'never'`) — `'single'` opens the editor automatically when exactly one image is selected; `'always'` opens it for every added image; `'never'` waits for the user to click the edit affordance. - **`output`** — controls the saved file: target `mimeType`, encode `quality`, and a `fileName` callback deriving the edited file's name from the original. - **`tabs`** / **`tools`** — restrict which editor tabs and annotation tools appear; omit either to get the full set. - **`onOpen` / `onCancel` / `onSave`** — lifecycle callbacks; the same moments are also emitted as `image-editor-open`, `image-editor-cancel`, and `image-editor-save` [events](/docs/api-reference/events/). --- # Optional Props https://useupup.com/docs/api-reference/upupuploader/optional-props/ Every prop on this page is optional. The three upload-target props (`uploadEndpoint`, `serverUrl`, `resumable.endpoint`) and `provider` are covered in [Required Props](/docs/api-reference/upupuploader/required-props/); the 34 `on*` callbacks are covered in [Events & Callbacks](/docs/api-reference/events/). Props are identical across React, Vue, Svelte, Angular, Vanilla, Preact, and Next — only `icons` and `style` differ in type per framework. Examples below use React syntax. ## Index - **Behavior** — [`mode`](#mode), [`autoUpload`](#autoupload), [`quietCompletion`](#quietcompletion), [`enablePaste`](#enablepaste), [`disableDragDrop`](#disabledragdrop), [`folderUpload`](#folderupload), [`onBeforeFileAdded`](#onbeforefileadded) - **Files & validation** — [`maxFiles`](#maxfiles), [`allowedFileTypes`](#allowedfiletypes), [`maxFileSize`](#maxfilesize), [`minFileSize`](#minfilesize), [`maxTotalFileSize`](#maxtotalfilesize), [`contentDeduplication`](#contentdeduplication) - **Processing pipeline** — [`imageCompression`](#imagecompression), [`heicConversion`](#heicconversion), [`stripExifData`](#stripexifdata), [`thumbnailGenerator`](#thumbnailgenerator), [`checksumVerification`](#checksumverification), [`webWorker`](#webworker), [`imageEditor`](#imageeditor), [`processingEndpoint`](#processingendpoint), [`processingTimeout`](#processingtimeout) - **Upload & reliability** — [`maxRetries`](#maxretries), [`maxConcurrentUploads`](#maxconcurrentuploads), [`resumable`](#resumable), [`crashRecovery`](#crashrecovery), [`networkAware`](#networkaware), [`metadata`](#metadata), [`cors`](#cors) - **Sources & drives** — [`sources`](#sources), [`cloudDrives`](#clouddrives) - **Appearance & UI** — [`theme`](#theme), [`animations`](#animations), [`mini`](#mini), [`className`](#classname), [`style`](#style), [`icons`](#icons), [`allowPreview`](#allowpreview), [`showBranding`](#showbranding), [`isProcessing`](#isprocessing) - **Localization** — [`i18n`](#i18n) - **Headless-only (not component props)** — [`plugins`](#plugins), [`pipeline`](#pipeline), [`workerTimeoutMs`](#workertimeoutms), [`isSuccessfulCall`](#issuccessfulcall) ## Behavior ### `mode` `mode?: 'client' | 'server'` — default `'client'`, or `'server'` when `serverUrl` is set and `uploadEndpoint` is not. In client mode the browser talks to storage directly and your server only signs URLs. In server mode the browser talks only to `serverUrl`, which proxies drive APIs and storage writes. Pick server mode when you cannot expose OAuth client secrets to the browser, or when uploads must pass through your own compliance or scanning layer. See [Client Mode vs Server Mode](/docs/guides/modes/). ```tsx ``` ### `autoUpload` `autoUpload?: boolean` — default `false`. Starts the upload immediately when files are selected, so the user never presses an upload button. Combine with [`quietCompletion`](#quietcompletion) when the uploader is embedded in a form your app controls. ```tsx ``` ### `quietCompletion` `quietCompletion?: boolean` — default `false`. When `true`, a successful run shows only a brief checkmark over the panel — no Done button, no summary, no follow-up calls to action. Use it when your app takes over after upload via the completion callbacks or events. The default `false` keeps the normal Done/continue-after-upload flow. ```tsx ``` ### `enablePaste` `enablePaste?: boolean` — default `false`. Accepts clipboard paste (Ctrl+V / Cmd+V) as a file source. Pasted files run through the same validation, `onBeforeFileAdded` filter, and pipeline as picked files. ```tsx ``` ### `disableDragDrop` `disableDragDrop?: boolean` — default `false`. Turns off drag-and-drop while keeping the browse/click path fully functional. Useful when the uploader sits inside a surface that owns its own drag behavior (a kanban card, a rich-text editor). ### `folderUpload` `folderUpload?: { allowDrop?: boolean; showSelectFolderButton?: boolean }` — both default `false`. `allowDrop` controls directory traversal when a user drops a folder onto the uploader. `showSelectFolderButton` controls whether the My Device source shows an explicit Select folder action. ```tsx ``` ### `onBeforeFileAdded` `onBeforeFileAdded?: (file: File) => boolean | File | undefined | Promise` An async filter called once per file before it enters the file list. Return `false` to reject the file, a `File` to substitute a different one (renamed, re-wrapped, pre-scrubbed), or `true`/`undefined` to accept it unchanged. It runs before size and type validation, so it is the right place for rules the built-in restrictions cannot express. ```tsx { if (file.name.startsWith('~$')) return false return new File([file], file.name.toLowerCase(), { type: file.type }) }} /> ``` This is the one file-gating callback documented here because it changes what gets added rather than reporting what happened. Every other `on*` prop lives in [Events & Callbacks](/docs/api-reference/events/). ## Files & validation ### `maxFiles` `maxFiles?: number` — default `10`. The maximum number of files that can be in the list at once. Values below `1` are clamped to `1`, and [`mini`](#mini) forces the limit to `1` regardless of what you pass. When the resolved limit is `1` the underlying file input drops its `multiple` attribute. Attempts to exceed the limit surface through `onRestrictionFailed` with reason `LIMIT_EXCEEDED`. ```tsx ``` ### `allowedFileTypes` `allowedFileTypes?: string | string[]` — default `'*'`. MIME patterns, file extensions, or preset names. Arrays are joined with commas; each comma-separated token is resolved against the preset table, and anything that is not a preset name passes through verbatim, so presets and raw patterns can be mixed freely. ```tsx ``` Preset names available from `@upupjs/core`'s `ACCEPT_PRESETS`: `images`, `video`, `audio`, `documents`, `spreadsheets`, `presentations`, `archives`, `code`, `fonts`, `3d`, `design`, `ebooks`, `photography`, `animation`, `ar`, `vector`, `cad`, `gis`, `data`, `markup`, `subtitles`, `email`, `calendar`, `contacts`, `disk`, `ml`, `database`, `certificates`, `firmware`, `executable`, `config`, `financial`, `scientific`, `medical`. A rejected file fires `onFileTypeMismatch` and `onRestrictionFailed` with reason `TYPE_MISMATCH`. ### `maxFileSize` `maxFileSize?: { size: number; unit: 'B' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB' | 'EB' | 'ZB' | 'YB' }` — default `{ size: 1, unit: 'GB' }`. Per-file ceiling. Oversized files are rejected before upload and reported through `onRestrictionFailed` with reason `FILE_TOO_LARGE`. ```tsx ``` ### `minFileSize` `minFileSize?: { size: number; unit: 'B' | 'KB' | 'MB' | 'GB' | ... }` — no default (no floor). Per-file floor, using the same `{ size, unit }` shape. Rejections report reason `FILE_TOO_SMALL`. Useful for catching truncated or zero-byte files before they reach storage. ### `maxTotalFileSize` `maxTotalFileSize?: { size: number; unit: 'B' | 'KB' | 'MB' | 'GB' | ... }` — no default. A ceiling on the combined size of everything currently in the list, checked as files are added. This is the right knob for per-submission quotas; `maxFileSize` alone cannot stop fifty acceptable files from adding up. ```tsx ``` ### `contentDeduplication` `contentDeduplication?: boolean` — default `false`. Hashes each incoming file's bytes (SHA-256 via WebCrypto, with a non-cryptographic fallback where `crypto.subtle` is unavailable) and drops any file whose content already exists in the list. Because it is content-based, it catches the same file added twice under different names — which name-based checks miss. ## Processing pipeline Pipeline steps run in a fixed order before upload: HEIC conversion, EXIF stripping, compression, thumbnail generation, then hashing. Each step's module is loaded lazily, so a step you do not enable costs nothing in your bundle. ### `imageCompression` `imageCompression?: boolean` — default `false`. Re-encodes images before upload. Defaults are quality `0.82` and a longest-edge cap of `1920` pixels. If a target byte size is configured, the step retries in `0.12` quality decrements down to a floor of `0.35` until the output fits. When no size target and no dimension cap are set and the re-encode came out larger than the original, the original file is uploaded untouched. ```tsx ``` The prop is typed as a boolean. To tune `quality`, `maxWidthOrHeight`, or `maxSizeMB`, pass the object form to the engine directly through the headless API — see [Headless Usage](/docs/guides/headless/). ```ts const core = new UpupCore({ imageCompression: { quality: 0.7, maxWidthOrHeight: 2560, maxSizeMB: 2 }, }) ``` ### `heicConversion` `heicConversion?: boolean` — default `false`. Converts HEIC/HEIF images (the default iPhone camera format) to JPEG so they render in every browser. The decoder (`libheif-js`) is an optional dependency loaded on demand, so enabling this adds nothing to the mandatory bundle. Decode failures surface as a `pipeline-error` diagnostic rather than failing the upload. ### `stripExifData` `stripExifData?: boolean` — default `false`. Removes EXIF metadata — including GPS coordinates, device model, and capture timestamps — from images before they leave the browser. Enable it for anything user-generated and publicly served. ### `thumbnailGenerator` `thumbnailGenerator?: boolean` — default `false`. Generates a thumbnail for each image and attaches it to the file's `metadata.thumbnailUrl` (plus a `thumbnail.file` blob). Useful when your backend wants a preview it did not have to render itself. ```tsx ``` Like `imageCompression`, the prop is a boolean; the engine option accepts `{ width, height, quality }` for tuning. ### `checksumVerification` `checksumVerification?: boolean` — default `false`. Computes a SHA-256 hash of each file and carries it through with the upload so your server can verify the bytes it received match the bytes the browser sent. Worth the cost on large or irreplaceable files; skip it for avatars. ### `webWorker` `webWorker?: boolean` — unset or `true` means auto, `false` forces the main thread. Controls whether the pipeline (hash, HEIC, EXIF, thumbnail, compress) runs off the main thread. Auto mode uses a worker where the runtime supports it and falls back to the main thread transparently otherwise, so the UI stays responsive while a 40 MB photo re-encodes. Set `false` only when a host environment forbids workers. ```tsx ``` ### `imageEditor` `imageEditor?: boolean | ImageEditorOptions` — enabled by default; `display` defaults to `'inline'` and `autoOpen` to `'never'`. Opens a crop/rotate/annotate editor on selected images. Omitting the prop leaves the editor **on** — the heavy editor bundle still loads lazily, only when a user actually opens it. Passing `false` (or `null`) disables it. The object form accepts `enabled`, `display` (`'inline' | 'modal'`), `autoOpen` (`'never' | 'single' | 'always'`), `output`, `tabs`, `tools`, and the `onOpen`/`onCancel`/`onSave` hooks. ```tsx ``` The editor ships in React and Preact only; Vue, Svelte, Angular, and Vanilla accept the prop but stub the UI. See [Image Editor](/docs/api-reference/upupuploader/image-editor/). ### `processingEndpoint` `processingEndpoint?: string` — no default. After each file finishes uploading, the uploader opens a Server-Sent Events connection to this URL with the storage key appended as `?key=...`. Use it to wait for server-side work — virus scanning, transcoding, thumbnail rendering — before treating the file as done. The completion event arrives through the `onFileProcessed` callback, documented in [Events & Callbacks](/docs/api-reference/events/). ```tsx ``` ### `processingTimeout` `processingTimeout?: number` — default `60000` (60 seconds). Maximum milliseconds to wait for the `processingEndpoint` SSE event before closing the connection. Raise it for slow pipelines like video transcoding; the file itself is already uploaded either way. ## Upload & reliability ### `maxRetries` `maxRetries?: number` — default `3`. Counts **retries, not attempts**: a file is tried once and then retried up to `maxRetries` times, so the default of `3` means four attempts in total before the file is marked failed. A file rejected by [`isSuccessfulCall`](#issuccessfulcall) is not retried, since a business-logic rejection is unlikely to resolve itself. ```tsx ``` See [Error Handling](/docs/error-handling/) for what reaches your code after retries are exhausted. ### `maxConcurrentUploads` `maxConcurrentUploads?: number` — default `3`. How many files upload in parallel. Raising it helps on fast connections with many small files; lowering it to `1` is the safest choice when your presign route is rate-limited or your storage layer meters concurrent writes. ### `resumable` `resumable?: ResumableUploadOptions` — no default (single-shot uploads). Multipart and tus are both explicit protocols; there is no automatic selection. ```tsx ``` ```tsx ``` The multipart form takes `thresholdBytes`, `chunkSizeBytes`, `persist`, `retryDelays`, `partTimeoutMs`, `maxConcurrentParts`, and `autoResume`. The tus form requires `endpoint` and takes `chunkSizeBytes`, `retryDelays`, `storeFingerprintForResuming`, `removeFingerprintOnSuccess`, `headers`, `metadata`, and `parallelUploads`; `tus-js-client` is an optional dependency loaded on demand. Full protocol details, including every multipart field, are in [Resumable Uploads](/docs/resumable-uploads/). ### `crashRecovery` `crashRecovery?: boolean` — default `false`. Persists upload state to IndexedDB so an interrupted session can resume after a page refresh or browser crash. State is saved on every state change while files are present and cleared once the run succeeds. Persistence is best-effort by design: a failed write warns in development and never breaks an upload. A normal unmount leaves the stored state intact, so the session stays recoverable. ```tsx ``` Pair it with `resumable` — recovering the file list is only useful if the transfer itself can pick up where it stopped. The prop is a boolean. Supplying your own persistence layer instead of IndexedDB is a headless-only option: the engine's `crashRecovery` also accepts an object carrying a custom storage implementation, reachable through `new UpupCore(...)` or `useUpupUpload` — see [Headless Usage](/docs/guides/headless/). ### `networkAware` `networkAware?: boolean` — default `true`. Reacts to browser connectivity: going offline mid-upload pauses the run — with multipart `persist` on, that keeps the server-side session alive instead of burning whole-run retries against a dead network — and coming back online resumes it. The resume fires **only** when the pause was offline-initiated; a pause the user chose is never overruled by a connectivity change. A no-op outside a browser. It is on by default, so the prop is only worth passing to opt out: ```tsx ``` `false` restores the plain fail-and-retry behavior, where a dropped connection burns retries and eventually fails the run. ### `metadata` `metadata?: Record` — no default. An arbitrary object sent with presign and multipart-init requests, so your server can attach ownership, tenancy, or routing information to the object it signs. It is the supported way to pass application context through the upload without inventing a side channel. ```tsx ``` ### `cors` `cors?: { dangerouslyAutoConfigure?: boolean; allowedOrigins: string[]; allowedMethods?: string[]; allowedHeaders?: string[]; maxAgeSeconds?: number }` `allowedOrigins` is required when the object is present. `dangerouslyAutoConfigure` is named for what it does: it can mutate your bucket's CORS policy, so it must be enabled explicitly and scoped to origins you control. Treat it as a local-development convenience and configure CORS on the bucket for production — see [Credentials And CORS](/docs/credentials-configuration/). ```tsx ``` ## Sources & drives ### `sources` `sources?: UploadSource[]` — default `['local', 'url', 'camera', 'microphone', 'screen']`. Controls which source panels are available and the order they appear in. Cloud drives are **not** in the default set — listing one here also requires configuring it in [`cloudDrives`](#clouddrives) (client mode) or on your `@upupjs/server` handler (server mode). `UploadSource` is the string form of the `FileSource` enum — import `FileSource` from `@upupjs/core` if you prefer named constants over string literals. Canonical IDs are: ```ts type UploadSource = | 'local' | 'url' | 'camera' | 'microphone' | 'screen' | 'googleDrive' | 'oneDrive' | 'dropbox' | 'box' ``` ```tsx ``` ### `cloudDrives` `cloudDrives?: { googleDrive?: …; oneDrive?: …; dropbox?: …; box?: … }` — no default. Browser-safe cloud provider configuration for client mode. Google Drive takes `clientId`, `apiKey`, and `appId`; OneDrive, Dropbox, and Box each take `clientId` plus an optional `redirectUri`. ```tsx ``` Only publishable values belong here. Use `serverUrl` for OAuth client secrets, token storage, server-side transfers, and compliance workflows — see [Server Mode — Setup](/docs/guides/server-mode-setup/). ## Appearance & UI ### `theme` `theme?: { mode?: 'light' | 'dark' | 'system'; tokens?: …; slots?: … }` `theme.mode` supports `light`, `dark`, and `system` (which follows `prefers-color-scheme` and updates live). `theme.tokens` overrides color, radius, shadow, and spacing values; `theme.slots` supplies per-component class overrides, keyed by component and then by the slot within it. ```tsx ``` Full token and slot tables are in [Theming](/docs/guides/theming/). ### `animations` `animations?: boolean` — default `true`. Decorative motion (entrance, hover, sheen, and success effects) is on by default. Set `animations={false}` to disable it. `prefers-reduced-motion` is honored automatically regardless of this prop, and essential motion — progress width, focus rings, the spinner — always runs. ```tsx ``` ### `mini` `mini?: boolean` — default `false`. Renders a compact square uploader (roughly 280 px) instead of the standard fixed-height panel. Mini mode forces a single-file limit: [`maxFiles`](#maxfiles) resolves to `1` and the file input drops `multiple`. ### `className` `className?: string` — no default. Additional CSS class applied to the uploader's root container, alongside the required `upup-scope` class. Use it for layout and positioning; use [`theme`](#theme) slots for anything inside the panel. ### `style` `style?: React.CSSProperties` (React/Preact/Next) or `Record` (Vue, Svelte, Angular, Vanilla) — no default. Inline styles on the root container. This is one of the two genuinely framework-specific props; the type differs but the DOM result does not. ### `icons` `icons?: UploaderIcons` — no default. Replaces individual glyphs with your own components. React accepts `ContainerAddMoreIcon`, `FileDeleteIcon`, `CameraDeleteIcon`, `CameraCaptureIcon`, `CameraRotateIcon`, and `LoaderIcon`, each a component taking `className`. The component type is framework-specific — see [Icon Prop](/docs/api-reference/upupuploader/icon-prop/). ```tsx ``` ### `allowPreview` `allowPreview?: boolean` — default `true`. Shows an expanded preview for files that can be rendered in the browser (images, video, audio). Set it to `false` for a filename-and-size list — a reasonable choice when users upload sensitive documents on shared screens. ### `showBranding` `showBranding?: boolean` — default `true`. Shows or hides the upup branding footer inside the panel. ### `isProcessing` `isProcessing?: boolean` — default `false`. A host-controlled busy flag. When `true`, the uploader shows a spinner in the panel corner and stops accepting new files: drag-over, drop, and paste handlers all short-circuit. Drive it from your own async work — form submission, server validation — when the uploader should look busy for a reason the uploader itself does not know about. ## Localization ### `i18n` `i18n?: { bundle?: LocaleBundle; locale?: LocaleBundle | string; fallbackLocale?: LocaleBundle | string; overrides?: PartialMessages }` Locale bundles are exported from `@upupjs/core/i18n`. Passing a bundle (via `bundle` or `locale`) enables ICU pluralization, namespaced key overrides, and the correct `lang`/`dir` on the root element; `bundle` takes precedence over `locale`. A bare BCP-47 string sets `lang`/`dir` only. `fallbackLocale` fills gaps when the active bundle is missing a key, and `overrides` merges per-key replacements on top of everything else. ```tsx import { frFR } from '@upupjs/core/i18n' ; ``` The full key list and RTL notes are in [Localization](/docs/localization/). ## Headless-only options (UpupCore / useUpupUpload) These four options are accepted by `UpupCore` but are **not** props on `` — the component's props-to-engine bridge does not forward them, so passing them to the component has no effect. Reach them by constructing the engine yourself with `new UpupCore(...)` or `useUpupUpload` — see [Headless Usage](/docs/guides/headless/). ### `plugins` `plugins?: UpupPlugin[]` — no default. Plugins registered at construction. A plugin is `{ name; init?(emitter) }`; `init` receives the core event bus, not the core itself. The built-in cloud-drive plugins are registered for you when you use the component. ### `pipeline` `pipeline?: PipelineStep[]` — no default. An explicit pipeline that replaces the automatic one. When you pass it, the boolean flags (`imageCompression`, `heicConversion`, and friends) no longer assemble the step list — you own the order and the contents. ```ts const core = new UpupCore({ uploadEndpoint: '/api/upload-token', pipeline: [myWatermarkStep(), compressStep({ quality: 0.7 })], }) ``` ### `workerTimeoutMs` `workerTimeoutMs?: number` — default `30000`. Per-task timeout for web-worker pipeline work. On timeout the task falls back to main-thread processing; it never fails the file. Raise it if you routinely process very large images on low-end devices. ### `isSuccessfulCall` `isSuccessfulCall?: (response: { status: number; headers: Record; body: unknown }) => boolean | Promise` A custom success predicate for the storage response. Use it when a `2xx` is not sufficient proof — for example a gateway that returns `200` with an error body. A file rejected by this predicate is marked failed and is **not** retried, on the assumption a business-logic rejection will not resolve on a second attempt. ## See also - [Required Props](/docs/api-reference/upupuploader/required-props/) — `uploadEndpoint`, `serverUrl`, `resumable.endpoint`, and `provider`. - [Events & Callbacks](/docs/api-reference/events/) — all 34 `on*` props. - [Ref API](/docs/api-reference/upupuploader/ref-api/) — imperative control from the host. - [Headless Usage](/docs/guides/headless/) — the engine behind the component. --- # Ref API https://useupup.com/docs/api-reference/upupuploader/ref-api/ `UpupUploader` exposes the same behavior surface as `useUpupUpload`. ```tsx const ref = useRef(null) ``` ```ts type UploaderRef = { useUpload(): { error?: string files: UploadFile[] loading: boolean progress: number upload(): Promise resetState(): void uploadFiles( files: File[] | UploadFile[], ): Promise setFiles(newFiles: File[]): void replaceFiles(files: File[] | UploadFile[]): void } } ``` `UploaderRef` is exported from `@upupjs/react`; `UploadFile` is exported from `@upupjs/core/contracts`. --- # Required Props https://useupup.com/docs/api-reference/upupuploader/required-props/ `UpupUploader` can run as a local file collector with no upload target. In that case users select files and you read the selected `File` objects from callbacks or hooks. For actual uploads, configure exactly one target: | Prop | Type | Use | | -------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- | | `uploadEndpoint` | `string` | Client-hosted presign route. The browser requests an upload URL, then uploads bytes directly to storage. | | `serverUrl` | `string` | Server-hosted upup route from `@upupjs/server`. Your server owns provider calls, storage credentials, and transfer policy. | | `resumable.endpoint` | `string` | External tus service endpoint when `resumable={{ protocol: 'tus', endpoint }}` is used. | `provider` is required only when the selected target or server handler needs to know which storage backend to use. ## Client upload target ```tsx import { UpupUploader } from '@upupjs/react' import '@upupjs/react/styles' export default function Uploader() { return } ``` The presign request body includes file details plus optional `metadata`: ```ts type PresignRequest = { name: string type: string size: number metadata?: Record } ``` The response must include the upload URL and may include signed upload headers: ```ts type PresignedUrlResponse = { key: string uploadUrl: string uploadHeaders?: Record publicUrl?: string downloadUrl?: string expiresIn: number } ``` ## Server upload target ```tsx ``` `serverUrl` should point to a handler created by `@upupjs/server`. --- # Code Examples https://useupup.com/docs/code-examples/ ## Local selection ```tsx console.log(files)} /> ``` ## Client upload ```tsx ``` Your token endpoint returns upup's presign contract (`key`, `uploadUrl`, `uploadHeaders`, `expiresIn`). Sign the URL however your storage prefers — here, AWS S3 via the AWS SDK: ```ts import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' const s3 = new S3Client({ region: process.env.S3_REGION! }) export async function POST(req: Request) { const body = await req.json() const key = `uploads/${crypto.randomUUID()}/${body.name}` const uploadUrl = await getSignedUrl( s3, new PutObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: key, ContentType: body.type, }), { expiresIn: 3600 }, ) return Response.json({ key, uploadUrl, uploadHeaders: { 'Content-Type': body.type }, expiresIn: 3600, }) } ``` ## Server upload ```tsx ``` ## Multipart ```tsx ``` ## tus ```tsx ``` --- # upup vs FilePond https://useupup.com/docs/comparisons/upup-vs-filepond/ FilePond (by PQINA) is a polished, highly accessible JavaScript uploader known for its silky image-optimization UX and a broad catalog of plugins, with first-party adapters for React, Vue, Angular, Svelte, and jQuery. It renders its own UI component and focuses on uploading local files and URLs to your own server endpoint. It does not include cloud-drive browsing, camera, or screen capture out of the box, and its full-featured image editing is a separate product (Pintura). upup is a headless core plus native UI that adds cloud drives, camera and screen capture, and an S3-compatible server with an HMAC-signed trust model. ## At a glance | Feature | FilePond | upup | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | | Native first-party UI | Vanilla JS core + adapters: React, Vue, Angular, Svelte, jQuery | React, Vue, Svelte, Angular, Vanilla JS, Preact | | Headless core | No — renders its own UI component | Yes (`@upupjs/core`) | | License / pricing | MIT, free & open source (the Pintura image editor is a separate commercial product) | MIT, free & open source | | Self-host incl. S3-compatible | Yes — uploads to your own server endpoint (process / revert / restore), with chunk uploads; no built-in S3 signing | Yes — `@upupjs/server` presigns and proxies to any S3-compatible storage | | Cloud-drive sources | No (local files, directories, blobs, local/remote URLs, Data URIs, paste) | Google Drive, OneDrive, Dropbox, Box | | Camera / screen capture | No | Yes (both) | | Image editor | Via plugins (crop / resize / transform); a full editor is Pintura (separate/commercial) | Yes — React/Preact only | | Resumable uploads | Chunked uploads (server-driven) | Yes — optional (tus or S3 multipart) | | i18n | Configurable label strings (no locale bundles) | Yes (ICU locale bundles) | ## Choose FilePond if - You want best-in-class image preview and optimization UX, smooth animations, and strong accessibility for a local-file upload flow. - You need a jQuery adapter, or you're happy uploading to your own server endpoint. - You want a mature, plugin-rich, single-purpose uploader and will add any cloud sources yourself. ## Choose upup if - You need cloud-drive sources (Google Drive, OneDrive, Dropbox, Box), camera, or screen capture built in. - You want a headless core you can drive from your own UI, plus native UI across six frameworks with a shared DOM contract. - You want an included server that presigns and proxies to any S3-compatible storage behind an HMAC-signed trust model. --- # upup vs react-dropzone https://useupup.com/docs/comparisons/upup-vs-react-dropzone/ react-dropzone is a small, focused React hook (`useDropzone`) — with a matching wrapper component — for building an HTML5-compliant drag-and-drop file-selection zone. It is a **primitive, not a full uploader**: it hands you the selected or dropped `File` objects and leaves the upload, UI, progress, previews, cloud sources, and everything else to you. That's exactly what you want when you only need drag-and-drop in React and will build the rest yourself. upup is a full uploader — a headless core, native UI for six frameworks, and an optional server — so the two really solve different problems. This page is here because teams often start with the primitive and later need the whole flow. ## At a glance | Feature | react-dropzone | upup | | ----------------------- | --------------------------------------------------------- | ------------------------------------------------------------------ | | Scope | Drag-and-drop file-selection primitive (hook + component) | Full uploader: selection, UI, upload pipeline, and server | | Native first-party UI | No — you render your own; React only | React, Vue, Svelte, Angular, Vanilla JS, Preact | | Headless core | Yes (a hook) — but file selection only, no uploading | Yes (`@upupjs/core`) — a full upload engine | | License / pricing | MIT, free & open source | MIT, free & open source | | Uploading / self-host | You implement uploads yourself, to any backend | `@upupjs/server` presigns and proxies to any S3-compatible storage | | Cloud-drive sources | No | Google Drive, OneDrive, Dropbox, Box | | Camera / screen capture | No | Yes (both) | | Image editor | No | Yes — React/Preact only | | Resumable uploads | No | Yes — optional (tus or S3 multipart) | | i18n | No | Yes (ICU locale bundles) | ## Choose react-dropzone if - You only need a drag-and-drop zone in React and will build the upload, UI, and progress yourself. - You want the smallest, most unopinionated primitive, with no upload logic baked in. - You already have an upload backend and UI and just need clean file selection. ## Choose upup if - You want the drag-and-drop **and** the progress bar, previews, cloud drives, camera, resumable uploads, and a server — without assembling them yourself. - You need more than React: Vue, Svelte, Angular, Vanilla JS, and Preact, all sharing one DOM contract. - You'd rather configure a complete uploader than build one from a primitive. --- # upup vs UploadThing https://useupup.com/docs/comparisons/upup-vs-uploadthing/ UploadThing (by Ping Labs) is a hosted file-upload **service** for full-stack TypeScript apps: you install its open-source SDK, drop in its `UploadButton` / `UploadDropzone` components, and files land in UploadThing's managed storage — no bucket or infrastructure to run. It's a fast, type-safe way to ship uploads, especially on Next.js. The core trade-off against upup is the model: UploadThing is a paid managed service that stores your files on its own infrastructure and is focused on the upload button/dropzone, while upup is an MIT, self-hosted library that writes to **your** S3-compatible storage and adds cloud drives, camera and screen capture, image editing, and native UI for six frameworks. ## At a glance | Feature | UploadThing | upup | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | | Model | Hosted SaaS (managed storage + CDN) | Self-hosted library (your storage) | | Native first-party UI | React components (`UploadButton` / `UploadDropzone`) + adapters for Next.js and other full-stack frameworks (Solid, Svelte, Vue, Nuxt, Expo, and more) | React, Vue, Svelte, Angular, Vanilla JS, Preact | | Headless core | React hooks (coupled to the service) | Yes (`@upupjs/core`, framework-agnostic) | | License / pricing | Open-source SDK (MIT); the service is a paid SaaS with a free tier and usage-based plans | MIT, free & open source (you pay only for your own storage) | | Storage / self-host | Managed — files are stored on UploadThing's infrastructure | Any S3-compatible storage you own (AWS, MinIO, R2, Spaces, Wasabi, Backblaze) via `@upupjs/server` | | Cloud-drive sources | No | Google Drive, OneDrive, Dropbox, Box | | Camera / screen capture | No | Yes (both) | | Image editor | No | Yes — React/Preact only | | Resumable uploads | Not a primary feature | Yes — optional (tus or S3 multipart) | | i18n | No | Yes (ICU locale bundles) | ## Choose UploadThing if - You want zero storage infrastructure — a managed service that stores files and serves them over a CDN for you. - You're building on Next.js (or another supported full-stack framework) and want the fastest, most type-safe path to a working upload button. - You're comfortable with a hosted, paid service owning the storage layer. ## Choose upup if - You want files kept in your own S3-compatible storage, avoiding a per-GB SaaS bill and third-party custody of your data. - You need cloud-drive sources, camera, screen capture, or image editing. - You want native UI across six frameworks and a fully MIT, self-hosted stack — including an HMAC-signed server trust model. --- # upup vs Uppy https://useupup.com/docs/comparisons/upup-vs-uppy/ Uppy (by Transloadit) is the most established open-source JavaScript uploader, with a large, battle-tested plugin ecosystem and Companion, its mature server for fetching files from remote sources. Both Uppy and upup are MIT-licensed and share the same shape — a headless core plus framework UI, cloud-drive sources, camera, image editing, and resumable uploads. The practical differences are in the UI model and the server: upup ships a **native, DOM-identical UI implemented in each framework's own idioms** across six frameworks (including Svelte 5, Angular standalone, a framework-free build, and Preact), and its `@upupjs/server` includes an HMAC-signed upload-token trust model out of the box. Uppy mounts one UI through framework wrappers and has the deeper, more widely deployed plugin catalog. ## At a glance | Feature | Uppy | upup | | ----------------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | Native first-party UI | Vanilla JS, React, Vue, Svelte, Angular | React, Vue, Svelte, Angular, Vanilla JS, Preact | | Headless core | Yes (`@uppy/core`; recent major versions add headless components and hooks) | Yes (`@upupjs/core`) | | License / pricing | MIT, free & open source (Transloadit is an optional paid hosted service) | MIT, free & open source | | Self-host incl. S3-compatible | Yes — your endpoint / tus server / S3 via the AWS S3 plugin; Companion (self-hostable) for remote sources | Yes — `@upupjs/server` presigns and proxies to any S3-compatible storage (AWS, MinIO, R2, Spaces, Wasabi, Backblaze) | | Cloud-drive sources | Google Drive, Dropbox, OneDrive, Box, Google Photos, Unsplash, and more (via Companion) | Google Drive, OneDrive, Dropbox, Box | | Camera / screen capture | Yes (Webcam, Screen Capture plugins) | Yes (both) | | Image editor | Yes (Image Editor plugin) | Yes — React/Preact only | | Resumable uploads | Yes (tus, S3 multipart) | Yes — optional (tus or S3 multipart) | | i18n | Yes (locale packs) | Yes (ICU locale bundles) | ## Choose Uppy if - You want the most mature, most widely deployed option, with the largest and most battle-tested plugin ecosystem. - You need Companion's server-side remote fetching for many providers (including Google Photos and Unsplash) or value its long production track record. - Your team has already standardized on Uppy's plugin model. ## Choose upup if - You want native UI implemented in each framework's idioms — including Svelte 5, Angular standalone, a framework-free build, and Preact — held DOM-identical by a parity harness, rather than one UI mounted through wrappers. - You want a server with an HMAC-signed upload-token trust model built in, so the client can never assert the object key or S3 `uploadId` it writes to. - You want cloud drives, camera, screen capture, image compression/HEIC, and resumable uploads from one MIT package set with a single DOM/parity contract. --- # Credentials and CORS https://useupup.com/docs/credentials-configuration/ Client mode should only expose browser-safe values such as OAuth client IDs or public Google Drive API keys. Storage credentials and OAuth client secrets belong in server mode. ## CORS For browser direct uploads, configure your bucket to allow the exact app origins that will upload files. ```tsx ``` `dangerouslyAutoConfigure` can mutate storage CORS policy. Keep it for local setup and controlled admin tooling, not broad production defaults. ## Server Credentials Use `@upupjs/server` for storage access keys, OAuth client secrets, provider token refresh, audit logging, and server-side transfers. --- # Error Handling https://useupup.com/docs/error-handling/ upup reports failures as a typed `UpupError` (or one of its subclasses). Every `UpupError` carries: - `message` — human-readable description - `code` — a stable string from the `UpupErrorCode` enum - `retryable` — whether the failure is transient and worth retrying - `status` — the originating HTTP status, when the error came from a response ## Error types Import the error classes and the code enum from `@upupjs/core`: ```typescript import { UpupError, UpupErrorCode, UpupAuthError, UpupNetworkError, UpupValidationError, UpupQuotaError, UpupStorageError, UpupConfigError, } from '@upupjs/core' ``` | Class | Raised when | Extra fields | | --------------------- | ----------------------------------------------------------- | ------------------------------ | | `UpupError` | Base class for every upup error | `code`, `retryable`, `status?` | | `UpupValidationError` | A file fails a size / type / count check | `reason`, `file` | | `UpupNetworkError` | A fetch / XHR fails (marked `retryable`) | `status?` | | `UpupStorageError` | An S3 / storage operation fails | `provider`, `operation` | | `UpupAuthError` | A cloud-drive OAuth / provider call fails | `provider` | | `UpupQuotaError` | A configured quota is exceeded | `limit`, `used` | | `UpupConfigError` | Configuration is missing or invalid (e.g. no upload target) | — | The `code` values come from the `UpupErrorCode` enum. See [Error Codes](/docs/api-reference/error-codes/) for the complete list with the trigger behind each one, or [Error Monitoring](/docs/guides/error-monitoring/) for the codes you'll tag most often. ## Inspecting an error Upload failures surface through the core `upload-error` event with the full `Error` object and the file that failed. Narrow with `instanceof`, or switch on `code`: ```typescript import { UpupError, UpupErrorCode, UpupValidationError } from '@upupjs/core' // error is the Error object from an upload-error event (see Error Monitoring). function describeUploadError(error: unknown): string { if (error instanceof UpupValidationError) { return `${error.file.name} rejected: ${error.reason}` } if (error instanceof UpupError) { if (error.code === UpupErrorCode.PRESIGN_FAILED) { return 'Your token endpoint returned an error.' } return error.retryable ? 'Transient failure — safe to retry.' : 'Permanent failure — needs attention.' } return 'Unknown error.' } ``` Both surfaces — the headless core's `upload-error` event (full `Error`) and the React `onError` prop (message string) — plus wiring them into an error tracker are covered in [Error Monitoring](/docs/guides/error-monitoring/). ## Retry behavior Configure automatic retries with the [`maxRetries`](/docs/api-reference/upupuploader/optional-props/#maxretries) prop. Each file upload is retried up to that many times before it counts as a failure. ```tsx ``` Once a file has exhausted its attempts and the run is marked failed, a manual **"Retry"** button appears in the UI. It appears whether or not `maxRetries` is set — that prop controls how many automatic attempts happen before the failure, not whether the manual button is offered. ### Resumable upload recovery When [resumable multipart uploads](/docs/resumable-uploads/) are enabled (`resumable={{ protocol: 'multipart' }}`), the failure UI changes and so does what recovery costs: - The button shown **after a failure** is labelled **"Resume"** instead of "Retry". - It invokes the same retry command as a non-resumable upload, but the file continues from its last completed part rather than restarting. The failed attempt left its server-side parts in place on purpose, and the retry re-attaches to them through `POST /multipart/resume`. Note that this is not the **"Resume"** button shown while a run is _paused_ — that one is a separate control on the pause/resume path. Only the failure-state button is described here. Part-level continuation survives a page refresh too, provided the file comes back: pair `resumable` with `crashRecovery` and the restored file re-attaches mid-transfer. It is on by default (`persist: true`) and degrades to a fresh upload — never to a failure — whenever the session cannot be trusted. What that costs your bucket, and the `AbortIncompleteMultipartUpload` lifecycle rule it obliges you to configure, are covered in [Cross-reload resume](/docs/resumable-uploads/#cross-reload-resume). In client mode (`uploadEndpoint`), where multipart cannot run, tus remains the resumable option: it delegates fingerprinting and offset recovery to `tus-js-client`. See [Resumable Uploads](/docs/resumable-uploads/). --- # Getting Started https://useupup.com/docs/getting-started/ upup is a file uploader with a native UI for **React, Vue, Svelte, Angular, Vanilla JS, and Preact**, built on a shared headless core (`@upupjs/core`), with an optional server mode for signed uploads and cloud-drive sources (Google Drive, OneDrive, Dropbox, Box). Every package renders the same UI. Here is the uploader you are about to install — try it: This guide uses React. To start from another framework, use its quickstart — each mounts the same uploader with the same options: - [React quickstart](/docs/quickstarts/react/) - [Vue quickstart](/docs/quickstarts/vue/) - [Svelte quickstart](/docs/quickstarts/svelte/) - [Angular quickstart](/docs/quickstarts/angular/) - [Vanilla JS quickstart](/docs/quickstarts/vanilla/) - [Preact quickstart](/docs/quickstarts/preact/) Install the React package and styles: ```bash npm i @upupjs/react ``` ```tsx import { UpupUploader } from '@upupjs/react' import '@upupjs/react/styles' ``` ## Pick your framework The same uploader mounts from every package with the same options. Pick your framework — the choice is remembered as you move through the docs, and you can deep-link a framework with `?fw=vue`. ## Local file collection With no upload target, upup lets users select files and gives you `File` objects through callbacks and hooks. ```tsx { console.log(files) }} /> ``` Calling `upload()` without a target returns a typed no-target error. ## What happens to your file Whichever target you pick, every file runs through the same client-side pipeline before it leaves the browser — validated, optionally compressed (HEIC images are converted), then uploaded, with the heavy work offloaded to a web worker. ## Client uploads Use `uploadEndpoint` when your app signs upload URLs and the browser uploads bytes directly to storage. ```tsx ``` ## Server uploads Use `@upupjs/server` when provider OAuth, token storage, storage credentials, or transfer policy should live server-side. ```bash npm i @upupjs/react @upupjs/server ``` ```tsx ``` ```ts import { createUpupHandler, InMemoryTokenStore } from '@upupjs/server' const handler = createUpupHandler({ storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, }, // Required, server-only: a stable, high-entropy secret (min 16 chars), // shared across every server instance. createUpupHandler throws without it. uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!, tokenStore: new InMemoryTokenStore(), getUserId: async () => 'user_123', }) export const GET = handler export const POST = handler ``` --- # Accessibility https://useupup.com/docs/guides/accessibility/ upup's accessibility semantics are not an opt-in layer. Roles, labels, live regions, focus handling, and the reduced-motion gate are part of the default render — there is no `a11y` prop to switch on, and nothing here requires extra markup from you. Because every framework package renders the same DOM (React is the source of truth and the cross-framework parity harness compares normalized DOM plus an accessibility snapshot for React, Vue, Svelte, Angular, Vanilla, and Preact), the behavior described on this page is identical in all six. A nightly CI job (`pnpm run e2e:a11y`) runs axe-core against every framework and fails the build on any new serious or critical violation. ## Keyboard interaction Every actionable element in the uploader is a real ` ))} {error &&

{error.message}

} ) } ``` ### What the hook returns `useUpupUpload(options)` returns a `UseUpupUploadReturn`: - **State** — `files: UploadFile[]`, `status: UploadStatus`, `progress: { totalFiles; completedFiles; percentage }`, `error: UpupError | null`. - **File commands** — `addFiles(files)`, `setFiles(files)`, `removeFile(id)`, `removeAll()`, `reorderFiles(fileIds)`. - **Upload commands** — `upload()`, `pause()`, `resume()`, `cancel()`, `retry(fileId?)`. `upload()` and `retry()` resolve to the resulting `UploadFile[]`. - **Events** — `on(event, handler)` returns an unsubscribe function. Bare names (e.g. `'state-change'`, `'upload-progress'`, `'upload-error'`) are typed; namespaced `':'` names pass through. - **Prop getters** — `getRootProps()`, `getInputProps()`, `getDropzoneProps()` spread onto your own elements to wire drag/drop, click-to-browse, and the hidden file input. - **Escape hatch** — `core: UpupCore`, the underlying engine instance, plus `ext` for plugin-contributed methods. A `UploadFile` extends the native `File`, so `file.name`, `file.size`, and `file.type` work alongside upup's additions: `file.id`, `file.status`, `file.source`, `file.key`, and `file.metadata`. `UploadStatus` is the enum `IDLE | PROCESSING | READY | UPLOADING | PAUSED | SUCCESSFUL | FAILED`. ### Options The hook's options are the engine's `CoreOptions` plus a few convenience callbacks (`onFileAdded`, `onFileRemoved`, `onUploadProgress`, `onUploadComplete`, `onWarn`) and drag/paste toggles (`enablePaste`, `disableDragDrop`). The upload target and pipeline options match the visual uploader: `uploadEndpoint`, `serverUrl`, `provider`, `mode`, `metadata`, `webWorker`, `heicConversion`, `resumable`, `locale`, `cloudDrives`. > **File limits at the engine level.** The visual `` > prop maps to the core option `limit`. In the headless hook you set `limit` > (a number), and `maxFileSize` / `minFileSize` as `{ size, unit }` objects > (e.g. `{ size: 25, unit: 'MB' }`), not a plain number. ## Driving `UpupCore` directly `UpupCore` and its `CoreOptions` type are part of the public `@upupjs/core` entry, so you can run the whole engine with no framework at all — a plain script, a Web Worker, or a framework upup has no dedicated hook for. This is the same class the React hook wraps. ```ts import { UpupCore } from '@upupjs/core' import type { CoreOptions } from '@upupjs/core' const options: CoreOptions = { uploadEndpoint: '/api/upload-token', provider: 'aws', limit: 10, } const core = new UpupCore(options) // Re-render / react to any state change. const unsubscribe = core.on('state-change', () => { console.log(core.status, core.progress.percentage) }) core.on('upload-all-complete', files => { console.log( 'done:', files.map(f => f.key), ) }) await core.addFiles([myFile]) await core.upload() // Later, on unmount: unsubscribe() core.destroy() ``` `UpupCore` exposes the commands the hook forwards — `addFiles`, `setFiles`, `removeFile`, `removeAll`, `reorderFiles`, `upload`, `pause`, `resume`, `cancel`, `retry` — plus the `files` map (`[...core.files.values()]`), the `status` / `progress` / `error` getters, and `on(event, handler)`. **`destroy()` is terminal.** After it, upload/resume/retry/`addFiles`/`setFiles` throw, and internal resources are released. Create a fresh core per mount — the framework packages already do this for you. Building your own UI but want upup's tokens and dark-mode? Wrap it in [`UpupThemeProvider`](/docs/guides/theming/#headless-upupthemeprovider). ### Headless in other frameworks `new UpupCore(...)` is the universal answer, but each native package also exposes framework-idiomatic accessors you can use to compose your own UI: - **Vanilla JS** — `createUploader(target, options)` returns an instance with `getState()`, `subscribe()`, `addFiles()`, `upload()`, `pause()`, `resume()`, `cancel()`, `retry()`, and `destroy()`. - **Vue / Svelte** — context hooks such as `useUploaderFiles` and `useUploaderUploadControls` read the uploader's state within its provider. - **Angular** — `UpupStore` plus the signal-store helpers. See each framework's [quickstart](/docs/quickstarts/vue/) for the exact imports. ## Pipeline opt-ins The heavy capabilities are off the mandatory path and enabled per option. They work the same whether you use `useUpupUpload`, `UpupCore`, or the visual ``. ### Web Worker offload `webWorker` moves the file pipeline (hash / HEIC / EXIF / thumbnail / compress) off the main thread. It defaults to **auto**: workers are used when the runtime supports them, with a transparent main-thread fallback otherwise. Set it to `false` to force the main thread, or tune the per-task fallback timeout with `workerTimeoutMs` (default `30000`). ```ts useUpupUpload({ uploadEndpoint: '/api/upload-token', webWorker: true, // auto (the default); `false` forces the main thread }) ``` ### HEIC → JPEG `heicConversion: true` converts HEIC/HEIF images to JPEG before upload. The decoder ships as an **optional dependency** (`libheif-js`) that the pipeline loads with a dynamic `import()` from `@upupjs/core/steps/heic` only when the option is on — so it never enters your base bundle. Install it alongside core: ```sh pnpm add libheif-js ``` ```ts useUpupUpload({ uploadEndpoint: '/api/upload-token', heicConversion: true }) ``` ### Resumable uploads (tus) `resumable` enables chunked, resumable transfers. For the [tus](https://tus.io) protocol, set `protocol: 'tus'` and an `endpoint`; the tus client is the **optional dependency** `tus-js-client`, lazily imported from `@upupjs/core/strategies/tus-upload` when configured. (The other protocol, `'multipart'`, resumes S3/server-mode multipart uploads and needs no extra dependency.) ```sh pnpm add tus-js-client ``` ```ts useUpupUpload({ provider: 'aws', resumable: { protocol: 'tus', endpoint: 'https://tus.example.com/files/', chunkSizeBytes: 5 * 1024 * 1024, }, }) ``` See [Resumable Uploads](/docs/resumable-uploads/) for the full protocol comparison and server requirements. ## Next steps - [Localization (i18n)](/docs/localization/) — the `locale` option and message overrides for your custom UI. - [Error Monitoring](/docs/guides/error-monitoring/) — handle the `error: UpupError` your hook exposes and wire it into your tracker. --- # Client Mode vs Server Mode https://useupup.com/docs/guides/modes/ `@upupjs/react` supports local-only collection plus two upload hosts. `mode` controls where upload/provider operations run, not whether users can select local files. ```tsx // local file collection only // client-hosted upload flow ``` ## Client mode **Browser talks directly to storage.** Your server's only job is to sign short-lived upload URLs. ```text browser ──POST /sign──> your server browser <──presigned URL─ your server browser ──PUT bytes────> S3 / R2 / etc. ``` Cloud drives (Google Drive, OneDrive, Dropbox, Box) use OAuth from the browser — tokens stay in browser memory, never touch your server. **Choose client mode when:** - You want the simplest setup. - You're OK exposing OAuth client IDs (not secrets) to the browser. - You don't need server-side virus scanning or compliance logging inside the upload path. - Latency matters — bytes go direct to storage, no relay hop. ## Server mode **Browser talks only to your server.** Your server proxies drive APIs, stores OAuth tokens, and writes bytes to storage. ```text browser ──GET /files/:provider ──> your server ──> Google / MS / ... browser ──POST /files/:provider/transfer ──> your server ──> S3 ``` The uploader hits one origin. You own the access tokens and the storage credentials. **Choose server mode when:** - Compliance or policy forbids shipping OAuth client secrets or storage credentials to the browser. - You want to scan, log, or transform files on the server before they reach storage. - Your end users can't reach the drive APIs directly (corporate firewall, region block). - Files may be larger than the browser can hold in memory. Server mode streams drive → S3 without buffering the whole file. ## What changes between modes? | Concern | Client mode | Server mode | | ----------------------- | ---------------------- | ----------------------- | | Upload target | `uploadEndpoint` | `serverUrl` | | OAuth client ID | Shipped to browser | Server-only | | OAuth client secret | Not used | Server-only, required | | Drive API calls | Browser → provider | Server → provider | | Storage credentials | Signed URLs only | Server holds real creds | | Multipart | Browser-coordinated | Server-coordinated | | Re-auth on token expiry | Same: user re-signs-in | Same: user re-signs-in | ## Feature parity Every `UpupUploader` event callback (`onFilesSelected`, `onUploadStart`, `onFileUploadComplete`, etc.) fires identically in both modes. Theme slots, i18n, file limits (`maxFiles`, `maxFileSize`, `allowedFileTypes`), image editor — all work the same. The differences are strictly on the wire, not on the API surface. ## Migration If neither `uploadEndpoint` nor `serverUrl` is configured, the uploader stays in local file collection mode and calling `upload()` returns a typed no-target error. --- # Plugins & Extensions https://useupup.com/docs/guides/plugins/ `@upupjs/core` has two extension points. A **plugin** is an object that subscribes to (and emits on) the engine's event bus. An **extension** is a bag of methods hung off the core so your own UI can call them. Cloud drives — Google Drive, OneDrive, Dropbox, Box — are plugins, and the same contract is open to anyone: see [Writing a Plugin](/docs/guides/writing-plugins/) if you want to build one. This page is about **using** plugins: what ships in the box, how to turn the cloud drives on, and how to register something a third party wrote. Everything here works identically under the visual ``, the [headless `useUpupUpload` hook](/docs/guides/headless/), and a bare `new UpupCore(...)`. ## What ships built-in Four cloud-drive plugins, all exported as classes from `@upupjs/core`: | Plugin | Plugin id | `cloudDrives` key | Auth model | | ------------------- | -------------- | ----------------- | ------------------------ | | `GoogleDrivePlugin` | `google-drive` | `googleDrive` | Google Identity Services | | `OneDrivePlugin` | `one-drive` | `oneDrive` | popup OAuth2 + PKCE | | `DropboxPlugin` | `dropbox` | `dropbox` | popup OAuth2 + PKCE | | `BoxPlugin` | `box` | `box` | popup OAuth2 + PKCE | The other upload sources — local files, camera, microphone, screen capture, URL import — are **not** plugins. They are built into the uploader and switched on through the `sources` prop; see [Sources](/docs/guides/sources/). Note the two spellings in that table, because both are load-bearing. The config key is camelCase (`googleDrive`, `oneDrive`) and the plugin id is kebab-case (`google-drive`, `one-drive`). `dropbox` and `box` are single words, so they look the same in either position. ## Enabling cloud drives In client mode you never construct a drive plugin yourself. Pass a `cloudDrives` config and the uploader instantiates, configures, and registers the matching plugin for every key you supply: ```tsx ``` A key you omit means that plugin is never registered, even if the source chip is listed. The config shapes differ slightly per provider: - `googleDrive` — `clientId`, `apiKey`, and `appId`, all required. - `oneDrive`, `dropbox`, `box` — a required `clientId` plus an optional `redirectUri`. `cloudDrives` is one camelCase shape end-to-end: the same object passes through the framework prop, the uploader options, and down to the plugin's `configure()` call unchanged. It is also part of `CoreOptions`, so the headless hook accepts it verbatim. Everything in `cloudDrives` ships to the browser, which is fine for OAuth client IDs and API keys but never acceptable for a client secret. If your provider's flow requires a secret, you need server mode. ## Client mode versus server mode The drive plugins are a **client-mode** mechanism. Which path the drive UI takes is decided by the uploader's mode, not by anything you configure on the plugin: - **Client mode.** The browser holds the OAuth tokens. The drive plugin runs in the page, talks to the provider's API directly, downloads the selected files as real `File` objects, and those flow through the normal pipeline and upload path. This is the path `cloudDrives` configures. - **Server mode.** The browser talks only to your server. The drive views route through `@upupjs/server`'s drive endpoints instead of a plugin — listing, auth, and the drive → S3 transfer all happen server-side, and the file bytes never pass through the browser. Credentials live in your server environment, so no client IDs go in `cloudDrives`. Because server mode bypasses the plugin path entirely, a plugin you register yourself only runs in client mode. Full comparison in [Client Mode vs Server Mode](/docs/guides/modes/); credential setup for the proxied flow is in [Server Mode Setup](/docs/guides/server-mode-setup/). ## Registering a plugin Two equivalent routes for anything not wired by `cloudDrives`. Pass it as an option: ```ts import { UpupCore } from '@upupjs/core' const core = new UpupCore({ uploadEndpoint: '/api/upload-token', plugins: [analyticsPlugin(track)], }) ``` …or register imperatively, which returns the core for chaining: ```ts core.use(analyticsPlugin(track)).use(anotherPlugin()) ``` The `plugins` option is part of `CoreOptions`, so the React hook takes it too: ```ts useUpupUpload({ uploadEndpoint: '/api/upload-token', plugins: [analyticsPlugin(track)], }) ``` Constructor-supplied plugins are registered in array order, before the first upload. Each registration emits the core event `plugin-registered` with the plugin's name — see the [event catalog](/docs/api-reference/events/) for everything else a plugin can listen to. Registration is keyed by the plugin's `name`, and registering two plugins with the same name throws a `UpupConfigError` with code `PLUGIN_ALREADY_REGISTERED`. Look one up with `core.getPlugin(name)`. ## Lifecycle, from the outside Three behaviors worth knowing before you drop someone else's plugin into your app: - **`init(emitter)` is the only lifecycle hook**, and it runs once at registration. What the plugin receives is core's event bus, not the core itself — so a plugin listens and emits, and nothing more. There is no `beforeUpload` interception point. - **`core.destroy()` clears the plugin and extension registries**, but it does not call anything on a plain plugin — the base contract has no teardown hook. (Drive plugins are the exception: `DrivePlugin` requires `destroy()`, and the uploader calls it on the drives it registered.) A plugin that owns a timer or a socket has to hand you its own cleanup handle. - **A throwing listener is contained.** The emitter isolates each handler, so a misbehaving plugin cannot abort sibling listeners or make `emit()` throw. In development the error is logged to the console; it is never re-surfaced as an upload failure. ## Extensions An extension attaches named methods to the core so your UI can reach behavior a plugin added. The shape is deliberately loose: ```ts type ExtensionMethods = Record unknown> ``` Register from your app code. A plugin cannot do this for itself — `init` never gets a core reference — so a plugin that offers methods will document them and leave the wiring to you: ```ts import { UpupCore } from '@upupjs/core' const core = new UpupCore({ uploadEndpoint: '/api/upload-token' }) core.use(analyticsPlugin(track)) core.registerExtension('analytics', { flush: () => track('flush'), identify: (...args: unknown[]) => track('identify', { id: args[0] }), }) ``` Read them back with `getExtension(name)`, or through `ext`, the map of every registered extension: ```ts core.getExtension('analytics')?.flush() core.ext.analytics?.flush() ``` The React hook re-exposes the same map, so a headless UI needs no extra wiring: ```tsx const { ext } = useUpupUpload({ uploadEndpoint: '/api/upload-token' }) ``` Because `ExtensionMethods` erases argument and return types, restore them at the call site with a typed accessor rather than casting inline everywhere: ```ts type AnalyticsExt = { flush: () => void identify: (id: string) => void } const analytics = core.getExtension('analytics') as AnalyticsExt | undefined analytics?.identify('user_42') ``` Names are unique per core: re-registering one throws a `UpupConfigError` with code `EXTENSION_ALREADY_REGISTERED`. ## Listening to a plugin Plugins emit on the same bus your app subscribes to, which is exactly how the drive plugins surface their state to the UI. Their events are namespaced by provider, and namespaced names pass through `core.on()` by design: ```ts core.on('one-drive:authenticated', ({ user }) => { console.log('signed in', user?.name) }) ``` The six drive events are `:state-change`, `:authenticated`, `:files-loaded`, `:session-expired`, `:error`, and `:signed-out`, where `` is the kebab-case plugin id from the table above. Payload shapes are documented in [Writing a Plugin](/docs/guides/writing-plugins/). A drive plugin emits `:error` — never a bare `error`. The engine's single upload-failure event is `upload-error`, and there is deliberately no second channel for it. See [Error Handling](/docs/error-handling/). The shipped uploader wires exactly the four known providers from the `cloudDrives` option, and `FileSource` is a closed set. A custom drive plugin registers and runs fine, and it is a first-class citizen in a headless UI you build yourself — but there is no registry today that adds a new provider to ``'s built-in source selector. ## Next steps - [Writing a Plugin](/docs/guides/writing-plugins/) — the `UpupPlugin` contract, the event rules, and building a custom cloud-drive provider. - [Sources](/docs/guides/sources/) — the non-plugin upload sources and the `sources` prop that decides which chips render. - [Client Mode vs Server Mode](/docs/guides/modes/) — which drive path your app is actually on. - [Events](/docs/api-reference/events/) — the full typed core-event catalog a plugin can subscribe to. - [Headless Usage](/docs/guides/headless/) — where `core`, `on`, and `ext` are handed to you directly. --- # Client-side image compression before upload https://useupup.com/docs/guides/processing/compression/ Large phone photos are the single biggest cause of slow uploads. `imageCompression` re-encodes each image through a canvas before the upload starts — downscaling it and lowering encoder quality — so what crosses the network is a fraction of what the user picked, and your storage bill shrinks with it. Turn it on with a boolean for sensible defaults: ```tsx import { UpupUploader } from '@upupjs/react' import '@upupjs/react/styles' export default function Uploader() { return } ``` That alone caps the longest edge at `1920` px and encodes at quality `0.82`. ## Options Pass an object (`ImageCompressionOptions`, exported from `@upupjs/core/steps/compress`) to tune all three knobs: ```ts import { UpupCore } from '@upupjs/core' const core = new UpupCore({ uploadEndpoint: '/api/upload-token', imageCompression: { maxWidthOrHeight: 1920, maxSizeMB: 1, quality: 0.82, }, }) ``` - **`quality`** — encoder quality, `0`–`1`. Default **`0.82`**. Values are clamped into `0.05`–`1`, and a non-numeric value falls back to the default, so a stray `0` will not produce an unusable file. - **`maxWidthOrHeight`** — cap on the **longest** edge, in pixels. Default **`1920`**, applied whether or not you pass the option. Aspect ratio is preserved and images are only ever scaled _down_; a smaller image is left at its natural size. - **`maxSizeMB`** — a target output size. No default: without it, the file is encoded exactly once at `quality`. The visual `` prop `imageCompression` is typed as a plain boolean toggle. To pass the object, configure it through `useUpupUpload` or `UpupCore` — see [Headless Usage](/docs/guides/headless/). ## The `maxSizeMB` backoff When you set `maxSizeMB` and the first encode lands over budget, upup retries at progressively lower quality: it subtracts **0.12** each pass and stops at a floor of **0.35**. Starting from the default, that is at most five encodes — `0.82 → 0.70 → 0.58 → 0.46 → 0.35`. Each pass re-encodes the _original_ image, so quality loss doesn't compound. `maxSizeMB` is a best-effort target, not a guarantee. If the file is still over budget at quality `0.35`, the loop stops and that result is what gets uploaded. For a hard ceiling, pair it with `maxFileSize` so oversized files are rejected outright rather than uploaded large. The trade-off to weigh: a tight `maxSizeMB` on a large photo can mean five full decode-and-encode cycles per file. Lowering `maxWidthOrHeight` usually reaches the same byte target in one pass, because pixels shed size faster than quality does. ## Output format The encoder preserves `image/png` and `image/webp`; **every other input type comes out as `image/jpeg`**. The filename is unchanged, so a `.gif` compressed to JPEG bytes keeps its original name — set the storage key or rename the file yourself if the extension matters to you. ## When compression does nothing Compression can decide to keep the original file. Two cases: - **No size benefit.** If the re-encoded result is not smaller than the original, upup keeps the original — but only when you passed neither `maxSizeMB` nor an explicit `maxWidthOrHeight`. Setting either of those is read as an instruction to normalize the image, so the re-encoded output is kept even if it grew. - **No canvas backend.** In an environment where the image cannot be decoded at all — server-side rendering, a runtime with no canvas — the step returns the file untouched. It is a silent no-op: no `pipeline-error` event is emitted for compression, unlike [HEIC conversion](/docs/guides/processing/heic-conversion/). ## Metadata a compressed file carries When a file is actually compressed, upup stamps its metadata with `originalSize`, `processedSize`, `width`, `height`, and `compressed: true`. `width` and `height` are the dimensions of the encoded output, so they are also the cheapest way to confirm a downscale happened: ```ts core.on('upload-all-complete', files => { for (const file of files) { const { originalSize, processedSize, width, height } = file.metadata ?? {} const size = `${originalSize} → ${processedSize}` console.log(file.name, size, `${width}×${height}`) } }) ``` ## How it interacts with the rest of the pipeline - **HEIC runs first.** A `.heic` photo is converted to JPEG before compression sees it, so compression works on decodable bytes. See [HEIC to JPEG](/docs/guides/processing/heic-conversion/). - **EXIF stripping runs before compression.** Enabling both means two encodes per image. Compression's own re-encode drops EXIF as a side effect, but only when it actually replaces the file — an image that lands in the "no size benefit" case above keeps its original bytes, metadata included. If GPS coordinates must never survive, enable `stripExifData` rather than relying on compression. - **Hashing runs after.** With `checksumVerification` on, the checksum describes the compressed bytes, not the original. - **Thumbnails are separate.** `thumbnailGenerator` annotates the file with a preview data URL and does not affect the uploaded bytes. Compression also runs on a Web Worker whenever one is available, and falls back to the main thread on timeout or failure without failing the file — see [Web Worker offload](/docs/guides/processing/custom-steps/#web-worker-offload). ## Observing it `pipeline-step` fires with `step: 'compress'` after the step runs on a file, and `pipeline-start` lists `compress` among the steps that will run: ```ts core.on('pipeline-start', ({ fileId, steps }) => { console.log(fileId, steps) // e.g. ['exif', 'compress', 'hash'] }) ``` A file skipped by the step's `shouldProcess` filter — anything whose MIME type isn't `image/*` — fires no `pipeline-step` at all. ## Related - [File Processing](/docs/guides/file-processing/) — the pipeline overview and the fixed step order. - [Optional props](/docs/api-reference/upupuploader/optional-props/) — every `` prop, including `maxFileSize` and the other processing toggles. - [Events](/docs/api-reference/events/) — `pipeline-start`, `pipeline-step`, `pipeline-complete`, and `pipeline-error` payloads. --- # Custom file processing pipeline steps https://useupup.com/docs/guides/processing/custom-steps/ upup's built-in steps cover the common cases — convert, strip, shrink, preview, hash. When you need something else (renaming for storage keys, watermarking, rejecting files by content, attaching your own metadata), the step contract is public and the pipeline is yours to assemble. ## The contract `PipelineStep` and `PipelineContext` are exported from `@upupjs/core`: ```ts interface PipelineStep { name: string process(file: UploadFile, context: PipelineContext): Promise shouldProcess?(file: UploadFile): boolean } interface PipelineContext { files: ReadonlyMap options: Record emit(event: string, data?: unknown): void t: (key: string, vars?: Record) => string worker?: { execute(task: { type: string data: ArrayBuffer params?: Record }): Promise } } ``` - **`name`** shows up in the `pipeline-start` step list and in every `pipeline-step` event, so make it descriptive. - **`shouldProcess`** is an optional synchronous filter — return `false` and the step is skipped for that file, with no cost and no `pipeline-step` event. - **`process`** receives the file as the previous step left it and returns the file the next step will see. - **`context.emit`** puts an event on core's bus — use `pipeline-error` to report a step that degraded rather than throwing. - **`context.t`** is the translator built from your `locale` option, so a step can produce localized text. - **`context.files`** is a read-only view of the whole selection, and `context.options` the resolved uploader options, if your step needs to reason about more than the file in hand. ## A complete step This one prefixes every filename with the upload date, so storage keys sort chronologically: ```ts import type { PipelineStep, PipelineContext, UploadFile } from '@upupjs/core' export function datePrefixStep(): PipelineStep { return { name: 'date-prefix', shouldProcess: file => !/^\d{4}-\d{2}-\d{2}--/.test(file.name), async process( file: UploadFile, context: PipelineContext, ): Promise { const date = new Date().toISOString().slice(0, 10) const renamed = new File([file], `${date}--${file.name}`, { type: file.type, lastModified: file.lastModified, }) context.emit('pipeline-step', { fileId: file.id, step: 'date-prefix', }) return Object.assign(renamed, { id: file.id, source: file.source, status: file.status, url: file.url, key: file.key, metadata: { ...file.metadata, renamed: true }, }) as UploadFile }, } } ``` Two rules that example is demonstrating, both of which are easy to get wrong: 1. **Never clone an `UploadFile` with an object spread.** `{ ...file }` produces a plain object and silently drops `File`'s blob slots — the result has no bytes. Build a real `new File([...])` and copy upup's own fields onto it with `Object.assign`, exactly as above. 2. **Carry the identity fields across.** `id` in particular: upup tracks files by it, and a step that returns a file with a fresh id detaches it from the entry in the list. A step that only annotates a file — attaching metadata, computing a value — does not need to clone at all. Mutate `file.metadata` and return the same file, the way the built-in `hash` and `thumbnail` steps do. ## Registering a pipeline Pass your steps as the `pipeline` option: ```ts import { UpupCore } from '@upupjs/core' const core = new UpupCore({ uploadEndpoint: '/api/upload-token', pipeline: [datePrefixStep()], }) ``` It does not append to it. Passing `pipeline` means the boolean options (`heicConversion`, `stripExifData`, `imageCompression`, `thumbnailGenerator`, `checksumVerification`) are ignored, so a custom pipeline that also wants compression must include a compress step itself. The option is read at construction time only — changing it later has no effect, while the boolean flags do rebuild the automatic pipeline when they change. Steps run in array order, one file at a time, sequentially within a file. If your step depends on decodable bytes, put it after whatever produces them. ## Web Worker offload Decoding and re-encoding images on the main thread janks the UI. `webWorker` moves the built-in pipeline — hash, HEIC, EXIF, thumbnail, compress — onto a Worker. It is **auto by default**: leave it unset (or pass `true`) and upup uses a Worker whenever it can. A Worker is used when all three hold: 1. `webWorker` is not `false`, 2. the runtime has a `Worker` global, 3. the pipeline has at least one step. The Worker is created when an upload run starts and terminated when processing finishes, so it doesn't sit around holding memory between uploads. ```ts const core = new UpupCore({ uploadEndpoint: '/api/upload-token', imageCompression: true, webWorker: true, // auto — the default workerTimeoutMs: 60_000, // engine-level option, not a UI prop }) ``` Force the main thread with `webWorker: false` when you are debugging a step, when a strict Content-Security-Policy blocks Worker creation, or in test environments with no Worker implementation. ### Fallback semantics **The Worker path can never fail a file.** Every built-in step tries the Worker first and falls back to the main thread on any failure — and it does so quietly: - **Per-task timeout.** `workerTimeoutMs` (default **`30000`**) bounds each task. On timeout the task rejects, the step catches it, and the same work runs on the main thread. - **Worker unavailable.** If the Worker can't be spun up at all, upup logs a console warning in development only and processes everything on the main thread. The consequence to plan for: a slow Worker doesn't shorten the upload, it lengthens it — you pay the timeout, then pay for the main-thread run. If you are processing very large images, raise `workerTimeoutMs` rather than leaving it at 30 s and eating both costs. ### What the Worker can run `context.worker` is handed to every step, but the Worker understands a **fixed set of task types** — `hash`, `heic`, `exif`, `compress`, and `thumbnail`. A custom step that calls `context.worker.execute` with any other `type` is rejected with an `unknown task type` error rather than running anything. So a custom step has two honest options: do its work on the main thread, or create and own a Worker of its own. If you reuse one of the built-in task types, mirror what the built-in steps do — try the Worker, catch, and fall back: ```ts export function myHashStep(): PipelineStep { return { name: 'my-hash', async process(file: UploadFile, context: PipelineContext) { if (context.worker) { try { const result = await context.worker.execute<{ checksum: string }>({ type: 'hash', data: await file.arrayBuffer(), }) file.metadata = { ...file.metadata, checksum: result.checksum, } return file } catch { // fall through to the main-thread path } } // ...main-thread implementation return file }, } } ``` Note that `execute` transfers the `ArrayBuffer` to the Worker, so the buffer you pass is not reusable afterwards — read a fresh one if you need the bytes again. ## Observing your steps Custom steps appear in the same events as the built-in ones: ```ts core.on('pipeline-start', ({ fileId, steps }) => { console.log(fileId, 'entering', steps.join(' → ')) // includes 'date-prefix' }) core.on('pipeline-step', ({ fileId, step }) => { console.log(fileId, 'finished', step) }) core.on('pipeline-error', ({ scope, name, message }) => { console.warn('pipeline problem in', scope, 'on', name, message) }) ``` If your step throws, the failure is **not** swallowed: it propagates out of the run, the uploader goes to `FAILED`, every file that hadn't succeeded is marked failed, `upload-error` is emitted, and the `upload()` promise rejects. The built-in steps deliberately catch their own failures and emit `pipeline-error` instead, so a degraded step never blocks the upload. Follow that pattern unless a failure genuinely should stop everything. ## Related - [File Processing](/docs/guides/file-processing/) — the pipeline overview and the fixed order of the built-in steps. - [Headless Usage](/docs/guides/headless/) — constructing `UpupCore` directly, which is where the `pipeline` option lives. - [Events](/docs/api-reference/events/) — full payload types for `pipeline-start`, `pipeline-step`, `pipeline-complete`, and `pipeline-error`. - [Optional props](/docs/api-reference/upupuploader/optional-props/) — the `webWorker` prop and the processing toggles it applies to. --- # Convert HEIC to JPEG in the browser https://useupup.com/docs/guides/processing/heic-conversion/ iPhones save photos as HEIC. Most browsers won't render them, most image pipelines won't read them, and a user who uploads one usually has no idea their file is unusable. `heicConversion` decodes HEIC/HEIF in the browser and uploads a JPEG instead: ```tsx import { UpupUploader } from '@upupjs/react' import '@upupjs/react/styles' export default function Uploader() { return } ``` Conversion runs **first** in the pipeline, so every later step — EXIF stripping, compression, thumbnails, hashing — sees a JPEG it can decode. ## Install the decoder The decoder is [libheif](https://github.com/strukturag/libheif) compiled to WebAssembly, and it is heavy. upup ships it as an **optional dependency** and imports it dynamically, from the `@upupjs/core/steps/heic` subpath, only when the option is on — so an app that doesn't convert HEIC never pays for it. Install it yourself: ```sh pnpm add libheif-js ``` ```sh npm install libheif-js ``` If `libheif-js` isn't installed, upup emits a `pipeline-error` event and uploads the original HEIC unchanged. The upload still succeeds, so a missing dependency looks like "conversion silently didn't happen" unless you are listening. Install the package, or handle the event. ## Which files are converted A file matches when **either** its MIME type is `image/heic` or `image/heif`, **or** its name ends in `.heic` / `.heif` — browsers frequently report an empty type for these files, so the extension check is what usually fires. Everything else is skipped by the step's `shouldProcess` filter at zero cost. ## What you get back - **Format** — JPEG, encoded at quality `0.92`. - **Name** — `photo.heic` becomes `photo.jpg`. A matching file whose name has neither extension (it matched on MIME type) gets `.jpg` appended instead. - **Metadata** — `heicConverted: true`, plus `originalSize` and `processedSize` so you can see what the conversion cost or saved. ```ts core.on('upload-all-complete', files => { for (const file of files) { if (file.metadata?.heicConverted) { console.log(file.name, file.metadata.processedSize) } } }) ``` Note that the converted JPEG is what later steps operate on: with `imageCompression` also enabled, the JPEG is re-encoded again at your compression quality, so the `0.92` above is an intermediate, not the final quality. See [Client-side image compression](/docs/guides/processing/compression/). ## Failure behavior Conversion **never fails the upload**. When decoding throws — the optional dependency isn't installed, or the file is corrupt — upup emits a `pipeline-error` event, logs to the console, and uploads the original HEIC: ```ts core.on('pipeline-error', ({ scope, name, message }) => { if (scope === 'heic') { console.warn('HEIC conversion degraded for', name, message) } }) ``` `pipeline-error` is a diagnostic, not an upload failure: it is a separate channel from `upload-error` and never aborts the run. If a degraded conversion should be fatal in your app, that decision belongs in this handler. The full payload shape is on [Events](/docs/api-reference/events/). There is one quieter case. In an environment with no canvas backend — SSR, a plain Node runtime — the step is a **silent no-op**: the WASM module is never loaded, no event is emitted, and the original file passes through. That is an environment signal rather than a failure, which is why it is not reported. ## Memory and repeat conversions The WASM module is loaded once per realm — one instance on the main thread, one inside the Web Worker — and reused across files, so converting twenty photos loads libheif once. Every decode frees its image handles and the decoder context afterwards, which is what keeps a long multi-file session from growing without bound. If the load failed because the package was missing, the memoized loader is cleared, so a later attempt picks the dependency up once it is installed — no page reload required. ## Worker offload HEIC decoding is one of the tasks upup offloads to a Web Worker when one is available, which keeps a slow decode from freezing the UI. On worker timeout or failure the same decode is retried on the main thread, so the outcome is unchanged — only where it ran. See [Web Worker offload](/docs/guides/processing/custom-steps/#web-worker-offload). ## Related - [File Processing](/docs/guides/file-processing/) — the pipeline overview and the fixed step order. - [Client-side image compression](/docs/guides/processing/compression/) — what happens to the converted JPEG next. - [Optional props](/docs/api-reference/upupuploader/optional-props/) — the `heicConversion` prop alongside every other uploader option. - [Events](/docs/api-reference/events/) — `pipeline-error` and the rest of the event surface. --- # Reliability https://useupup.com/docs/guides/reliability/ Uploads fail for boring reasons: a tunnel, a dropped VPN, a 502 from a storage edge node, a laptop lid closing mid-transfer. This guide covers what upup does about each on its own, and which knobs you get. Two things worth separating up front, because their names sound alike and their jobs are not: - **Retries** happen _within_ a page session — an attempt fails, upup waits and tries again. - **Crash recovery** happens _across_ page sessions — the tab dies, and the file selection comes back on reload. ## Retry policy Every file is uploaded through a retry loop of its own. A failure on one file never cancels the others; the run collects failures and reports them at the end. `maxRetries` is the number of **extra** attempts after the first one, so the total attempt count is `maxRetries + 1`. It defaults to **3** — four attempts in total. ```tsx ``` ### Backoff math Between attempts upup sleeps on a fixed exponential curve: `100ms * 2^attempt`, where `attempt` is the zero-based index of the attempt that just failed. There is no jitter and no configurable multiplier. | Attempt | Result | Wait before next | | ------- | ------ | ---------------- | | 1 | fails | 100 ms | | 2 | fails | 200 ms | | 3 | fails | 400 ms | | 4 | fails | — gives up | With the default `maxRetries={3}` the whole sequence adds about 700 ms of waiting before a file is declared failed. Raising `maxRetries` extends the curve (the fifth attempt waits 800 ms, the sixth 1600 ms, and so on). ### What is actually retried upup does not retry things that cannot plausibly succeed on a second try: | Situation | Retried? | Why | | ---------------------------------------------- | -------- | ------------------------------------------------------------ | | Network failure with no HTTP status | Yes | Connection reset, DNS, CORS-level failure | | HTTP 5xx from storage | Yes | Server-side transient | | HTTP 4xx from storage | **No** | Expired signature, denied, malformed — resending fails again | | Any non-network error thrown by the pipeline | Yes | Treated as potentially transient | | The upload was aborted (`cancel()`, `pause()`) | **No** | Aborting is intentional | | `isSuccessfulCall` returned `false` | **No** | Your predicate made a deliberate ruling | When every attempt is exhausted, the file's last error surfaces on the `upload-error` event, and the batch as a whole rejects with an `UpupUploadBatchError` carrying an `errors` array of `{ file, error }` pairs. ### Manual retry Automatic retries are not the only path. `core.retry(fileId?)` re-runs failed work on demand — pass a file id to retry one file, or omit it to retry every file that has not completed. It emits a `retry` event before the run starts. ```ts import { UpupCore } from '@upupjs/core' const core = new UpupCore({ uploadEndpoint: '/api/upload-token' }) core.on('upload-error', ({ file }) => { if (file) void core.retry(file.id) }) ``` `core.resume()` is the sibling for a paused run: it re-runs the upload for every file that has no storage `key` yet. Both throw if called after `destroy()`. The shipped UI already wires this. The button appears whenever the run reaches a failed state — it is **not** gated on `maxRetries`, which governs how many automatic attempts happen _before_ that state, not whether the manual button is offered. It invokes `retryUpload()` regardless of protocol; only its **label** changes, reading **Retry** normally and **Resume** when `resumable.protocol` is `'multipart'`. Both labels run the same code path — what differs is what that path does underneath: a multipart file with a live session continues from its last completed part, anything else starts over. Do not confuse that button with the **Resume** control shown while a run is _paused_ — a separate button on the genuine pause/resume path that calls `core.resume()`. The two share a label but not a behavior. ## Custom success predicates: `isSuccessfulCall` Some upload targets answer with something other than a plain 2xx, or return a 200 whose _body_ signals a failure. `isSuccessfulCall` lets you make the call: ```ts const core = new UpupCore({ uploadEndpoint: '/api/upload-token', isSuccessfulCall: response => { const body = response.body as { key?: string } return typeof body.key === 'string' }, }) ``` Return `false` and the attempt is treated as a failure. It is **not** retried — a predicate that rejects is read as a deliberate verdict, not a transient glitch. `isSuccessfulCall` does not receive the raw HTTP response. upup builds the argument from the completed `UploadResult`, so `status` is always `200` and `headers` is always an empty object — only `body` carries real information (the `UploadResult`, with fields such as `key`, `publicUrl`, and `etag`). Write your predicate against `body` alone; branching on `status` or `headers` will not do what it looks like it does. This also means the predicate runs only after a transfer _succeeds_ at the transport level. It is a post-check on the result, not a replacement for HTTP error handling. ## Concurrency: `maxConcurrentUploads` `maxConcurrentUploads` caps how many files transfer at the same time. It defaults to **3**. Remaining files wait in a queue and start as slots free up. ```tsx ``` Raising it helps when you are uploading many small files against a fast link; lowering it to `1` serializes the run, which is the friendlier choice for constrained mobile connections and for servers that rate-limit presign requests. This limit is about _files_. Within a single multipart file, parts have their own separate concurrency limit of 3. ## Offline and online upup listens for the browser's `online` and `offline` events and republishes them on the core bus as `connection-online` and `connection-offline`. Both carry an empty payload. The initial value is seeded from `navigator.onLine`, and the listeners are torn down when the uploader is destroyed. ```ts core.on('connection-offline', () => { console.warn('link down') }) core.on('connection-online', () => { console.info('link back') }) ``` In the shipped UI this state drives a banner across the top of the panel while the browser reports itself offline. Going offline does not pause, cancel, or defer an in-flight run, and coming back online does not restart one. The connectivity state is **observational** — it updates the banner and emits the events, and that is all. In practice an upload attempted while offline simply fails its network request and is picked up by the ordinary retry loop described above. If you want uploads to actually stop when the link drops, wire it yourself: call `core.pause()` on `connection-offline` and `core.resume()` on `connection-online`. ## Crash recovery vs. multipart session resume These two are genuinely different mechanisms with different storage and different scope. They are also complementary: together they are what makes a reload survivable, and neither one does the whole job alone. | | **Crash recovery** | **Multipart session resume** | | ----------------- | ----------------------------------- | ------------------------------------- | | What it restores | The file _selection_ and its status | Progress _within_ one large file | | Granularity | Whole files | Individual parts | | Storage | IndexedDB (`upup-crash-recovery`) | `localStorage` (`upup_mp_*` keys) | | Survives a reload | Yes | Yes | | Turned on by | `crashRecovery` | `resumable.persist` (on by default) | | Requires | Nothing | Server mode (`serverUrl`) + multipart | ### Crash recovery Crash recovery snapshots your file selection to IndexedDB so a refresh, a tab crash, or an accidental navigation does not lose the user's work. Enable it with a single prop: ```tsx ``` How it behaves: - **Saving** happens on every state change, as long as at least one file is selected. When a run completes successfully the snapshot is cleared — there is nothing left to recover. - **Restoring** is automatic in the shipped UI: when `crashRecovery` is on, the uploader attempts a restore as it initializes. Driving the core yourself, call `await core.restoreFromCrashRecovery()`, which resolves to `true` when a snapshot was found and applied. - **Files that were mid-flight come back as `PAUSED`**, not as uploading. The run is not resurrected; the user (or your code) decides whether to continue. What happens on continue depends on the upload path: a server-mode multipart file with a live session resumes at its last completed part, everything else uploads again from the beginning. - Two events fire on a successful restore: `snapshot-restored` (`{ count, status }`), then `crash-recovery-restored` (empty payload). `destroy()` deliberately leaves the stored snapshot in place, so a normal unmount stays recoverable. Call `core.clearCrashRecovery()` when you genuinely want it gone. In headless code the option also accepts an object, which is how you substitute your own persistence layer: ```ts const core = new UpupCore({ uploadEndpoint: '/api/upload-token', crashRecovery: { storage: myPersistentStorage }, }) ``` The storage object needs three async methods — `get(key)`, `set(key, value)`, and `delete(key)`. Omit it and upup uses IndexedDB. ### Degradation on SSR and in private mode Crash recovery is **best-effort by design**. IndexedDB is absent during server-side rendering and can be blocked or quota-limited in private-browsing modes. In every one of those cases upup degrades quietly instead of failing the upload: - A failed save or clear is swallowed; uploads proceed normally. - A failed read is treated as "no snapshot", so a restore just returns `false`. - Failures are logged with `console.warn` in development only, so a dead durability opt-in is visible while you are building and silent in production. The practical consequence: never treat a snapshot as guaranteed. Crash recovery is a nicety that improves the common case, not a durability contract. ### Multipart session resume In server mode with `resumable: { protocol: 'multipart' }`, upup checkpoints each file's upload to `localStorage` — fingerprinted by name, size, last-modified time, and type, holding the signed upload token, the object key, the part size, and the bytes uploaded so far, expiring after 24 hours, and treating corrupted JSON as "no session" rather than throwing. On the next attempt at a file whose fingerprint matches a live session, the client presents the stored token to `POST /multipart/resume`, gets back the parts storage already holds plus a fresh token, validates their sizes, skips them, and uploads only the remainder. The **Resume** button on a failed multipart upload is no longer cosmetic: it continues the file rather than restarting it. The same machinery makes in-session `pause()` / `resume()` and the automatic retry loop continue mid-file, and refreshes an upload token that expires during a long transfer. It is on by default (`persist: true`); `persist: false` restores the older behavior, where any failure aborts the server-side upload and the next attempt starts from byte zero. Keeping parts for a later resume is the point, and it has an operational cost: a failed or abandoned upload is no longer aborted server-side, and parts nobody resumes are never cleaned up by upup. S3 bills for them. Configure an `AbortIncompleteMultipartUpload` lifecycle rule on the bucket with a 1–7 day expiry. Every S3-compatible provider supports it, MinIO included. Resume is best-effort in the same way crash recovery is. It falls back to a fresh upload — never to a failure — when the fingerprint no longer matches (a pipeline-transformed file, a `Blob` with no name), when `localStorage` is evicted or unavailable, when the session belongs to a different `serverUrl`, when the server has resume disabled or is too old to have the route, or when the provider no longer holds the upload. The full list, and what each case costs you, is in [Cross-reload resume](/docs/resumable-uploads/#cross-reload-resume). For a client-mode (`uploadEndpoint`) deployment, where multipart cannot run at all, tus remains the resumable option. ## A note on `fastAbortThreshold` `fastAbortThreshold` is accepted by the core options and forwarded internally, but nothing reads it in v3.1.0 — setting it has no effect on behavior. It is documented here only so you do not spend an afternoon tuning a value that does nothing. Use `maxRetries` to control how long upup persists on a failing file. ## Recommended baseline For most applications the defaults are the right starting point, and the one line worth adding is crash recovery: ```tsx ``` Reach past that when you have a specific reason: lower `maxConcurrentUploads` for mobile-heavy traffic or a rate-limited presign endpoint, raise `maxRetries` for genuinely flaky networks, and add `isSuccessfulCall` only when your upload target reports failure in a response body rather than a status code. ## Next steps - [Resumable Uploads](/docs/resumable-uploads/) — multipart and tus protocol configuration, including the thresholds that decide which strategy a file uses. - [Events](/docs/api-reference/events/) — the full payload reference for `upload-error`, `retry`, `connection-online` / `connection-offline`, `snapshot-restored`, and `crash-recovery-restored`. - [Error Handling](/docs/error-handling/) — the `UpupError` taxonomy and the `retryable` flag behind the retry decisions above. - [Error Monitoring](/docs/guides/error-monitoring/) — routing these failures into Sentry or another tracker. --- # File upload server with Express and S3 https://useupup.com/docs/guides/server-adapters/express/ `@upupjs/server` ships an Express adapter: one middleware you mount wherever you like. It wraps the same `createUpupHandler` every other adapter wraps, so the config object is identical across frameworks — see [Server mode setup](/docs/guides/server-mode-setup/) for what each option does. ## 1. Install ```sh pnpm add @upupjs/server ``` The Express types are structural — the adapter declares the small slice of `req`/`res` it uses, so you don't need a particular `@types/express` version installed for it to typecheck. ## 2. Share one config Author the config once and import it wherever you mount: ```ts // lib/upup-config.ts import type { UpupServerConfig } from '@upupjs/server' export const upupConfig: UpupServerConfig = { storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, }, // Required, server-only: stable, high-entropy, min 16 chars, shared // across every instance. createUpupHandler throws without it. uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!, getUserId: async req => { const session = await getSessionFromCookie(req) return session?.userId ?? null }, } ``` ## 3. Mount the middleware ```ts import express from 'express' import { createUpupMiddleware } from '@upupjs/server/express' import { upupConfig } from './lib/upup-config' const app = express() // express.json() MUST run before the middleware — see below. app.use(express.json()) app.use('/api/upup', createUpupMiddleware(upupConfig)) app.listen(3000) ``` `createUpupMiddleware` returns a standard `(req, res, next)` middleware. It builds the request URL from `req.protocol`, `req.get('host')`, and `req.originalUrl` — `originalUrl` is the full path including the mount prefix, so mounting under `/api/upup`, `/uploads`, or the root all work unchanged. The handler matches routes on path **suffix** (`/presign`, `/multipart/init`, `/auth/:provider`, …), never on an absolute path it dictates. Point the uploader at the same prefix: ```tsx ``` ## Body parsing: the ordering trap The Express adapter reads `req.body` and re-serializes it with `JSON.stringify` before handing a Web `Request` to the core handler. It does **not** read the raw stream. So a JSON body parser has to have run first. Mount `express.json()` before `createUpupMiddleware`. Without it `req.body` is `undefined`, the adapter forwards no body at all, and the POST routes fail with `400 Invalid JSON body` (`code: BAD_REQUEST`) — a failure that looks like a broken client but is pure middleware ordering. Every route the browser POSTs to (`/presign`, `/multipart/init`, `/multipart/sign-part`, `/multipart/complete`, `/multipart/abort`, `/multipart/resume`, `/files/:provider/transfer`) sends `Content-Type: application/json`, which is exactly what `express.json()` matches by default. No multipart or `urlencoded` parser is involved — the file bytes never pass through Express on the presigned paths. If your app deliberately runs without a global body parser, scope one to the upup mount instead of dropping it: ```ts app.use('/api/upup', express.json(), createUpupMiddleware(upupConfig)) ``` ## Behind a proxy or load balancer The URL the adapter builds is load-bearing beyond routing: the OAuth `redirect_uri` for cloud drives is derived from the request's origin. Under a TLS-terminating proxy, Express reports `req.protocol` as `http` and `req.get('host')` as the internal host unless you opt in: ```ts app.set('trust proxy', true) ``` With that set, Express honours `X-Forwarded-Proto` and `X-Forwarded-Host`, and the callback URL resolves to your public origin — the one you registered in each provider's console. Leave it off and OAuth callbacks resolve to an internal address the provider will reject. ## CORS Same-origin mounts (the Express app also serves your frontend) need no CORS config at all. Cross-origin ones set it on the upup config, not on an Express CORS middleware — the handler attaches the headers itself, on every response including the preflight `OPTIONS` (`204`): ```ts export const upupConfig: UpupServerConfig = { // ...storage, uploadTokenSecret cors: { allowedOrigins: ['https://app.example.com'], allowedMethods: ['GET', 'POST', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], maxAgeSeconds: 600, }, } ``` Credentialed CORS is granted only on a concrete origin match. A config whose `allowedOrigins` is just `['*']` gets public, non-credentialed CORS — the server-mode drive client sends `credentials: 'include'`, so cloud drives will fail against a wildcard-only allowlist. Enumerate your real app origins. ## Verify the mount ```sh curl http://localhost:3000/api/upup/health ``` A JSON body with `"status": "ok"` and `checks.config` / `checks.storage` means the middleware is mounted and the storage credentials resolve. The route is unauthenticated by design and always answers `200` — read the `status` field, not the HTTP code. ## Related - [Server mode setup](/docs/guides/server-mode-setup/) — every `createUpupHandler` option, hooks, limits, and observability. - [Server auth & trust model](/docs/guides/server-auth/) — why `uploadTokenSecret` is mandatory and what forged requests get. - [Storage providers](/docs/guides/storage-providers/) — S3, R2, MinIO, B2, Spaces, Wasabi configs. --- # File upload server with Fastify and S3 https://useupup.com/docs/guides/server-adapters/fastify/ `@upupjs/server` ships a Fastify adapter: a plugin factory you register on your instance. It wraps the same `createUpupHandler` every other adapter wraps, so the config object is identical across frameworks — see [Server mode setup](/docs/guides/server-mode-setup/) for what each option does. ## 1. Install ```sh pnpm add @upupjs/server ``` The Fastify types are structural — the adapter declares the small slice of `request`/`reply` it uses, so no particular Fastify typings version is required for it to typecheck. ## 2. Share one config Author the config once and import it wherever you register: ```ts // lib/upup-config.ts import type { UpupServerConfig } from '@upupjs/server' export const upupConfig: UpupServerConfig = { storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, }, // Required, server-only: stable, high-entropy, min 16 chars, shared // across every instance. createUpupHandler throws without it. uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!, getUserId: async req => { const session = await getSessionFromCookie(req) return session?.userId ?? null }, } ``` ## 3. Register the plugin ```ts import Fastify from 'fastify' import { createUpupPlugin } from '@upupjs/server/fastify' import { upupConfig } from './lib/upup-config' const fastify = Fastify() await fastify.register(createUpupPlugin(upupConfig, { path: '/api/upup/*' })) await fastify.listen({ port: 3000 }) ``` Point the uploader at the same prefix (without the wildcard): ```tsx ``` ## The mount path `createUpupPlugin` is the one adapter that registers its own route, because `fastify.all(path, handler)` structurally requires a path at registration time. The optional second argument overrides it; the default is `/upup/*`. ```ts createUpupPlugin(upupConfig) // registers fastify.all('/upup/*', …) createUpupPlugin(upupConfig, { path: '/api/upup/*' }) ``` Keep the trailing `/*`. The plugin registers exactly the string you pass, and the handler dispatches on the path suffix beneath it — a bare `/api/upup` matches only that one path, so `/api/upup/presign` would 404 at the Fastify router before the handler ever sees it. ## Body parsing Nothing to wire. Fastify's built-in `application/json` content-type parser already populates `request.body`, and the adapter re-serializes that object into the Web `Request` it hands the core handler. Every route the browser POSTs to (`/presign`, `/multipart/init`, `/multipart/sign-part`, `/multipart/complete`, `/multipart/abort`, `/multipart/resume`, `/files/:provider/transfer`) sends JSON, so the default parser covers all of them. There is no multipart/form-data step to configure: on the presigned paths the file bytes go straight from the browser to storage and never traverse your Fastify process. If you removed Fastify's JSON parser or replaced it with one that leaves `request.body` empty, the adapter forwards no body and the POST routes fail with `400 Invalid JSON body` (`code: BAD_REQUEST`). ## Behind a proxy or load balancer The adapter builds the request URL from `request.protocol`, `request.hostname`, and `request.url`. That URL is load-bearing beyond routing: the OAuth `redirect_uri` for cloud drives is derived from the request's origin. Under a TLS-terminating proxy, construct Fastify with proxy trust enabled so those two fields reflect the public origin: ```ts const fastify = Fastify({ trustProxy: true }) ``` With that set, Fastify derives `protocol`/`hostname` from `X-Forwarded-Proto` / `X-Forwarded-Host` and the callback URL resolves to the public origin you registered in each provider's console. Leave it off and OAuth callbacks resolve to an internal address the provider rejects. ## CORS Same-origin deployments need no CORS config. For cross-origin ones, set it on the upup config rather than reaching for `@fastify/cors` — the handler attaches the headers itself, on every response including the preflight `OPTIONS` (`204`): ```ts export const upupConfig: UpupServerConfig = { // ...storage, uploadTokenSecret cors: { allowedOrigins: ['https://app.example.com'], allowedMethods: ['GET', 'POST', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], maxAgeSeconds: 600, }, } ``` Credentialed CORS is granted only on a concrete origin match. A config whose `allowedOrigins` is just `['*']` gets public, non-credentialed CORS — the server-mode drive client sends `credentials: 'include'`, so cloud drives will fail against a wildcard-only allowlist. Enumerate your real app origins. ## Verify the mount ```sh curl http://localhost:3000/api/upup/health ``` A JSON body with `"status": "ok"` and `checks.config` / `checks.storage` means the plugin is registered and the storage credentials resolve. The route is unauthenticated by design and always answers `200` — read the `status` field, not the HTTP code. ## Related - [Server mode setup](/docs/guides/server-mode-setup/) — every `createUpupHandler` option, hooks, limits, and observability. - [Server auth & trust model](/docs/guides/server-auth/) — why `uploadTokenSecret` is mandatory and what forged requests get. - [Storage providers](/docs/guides/storage-providers/) — S3, R2, MinIO, B2, Spaces, Wasabi configs. --- # File upload server with Hono and S3 https://useupup.com/docs/guides/server-adapters/hono/ Hono is web-native: its request and response objects _are_ the platform `Request` and `Response`. `createUpupHandler` has that exact signature already, so the Hono adapter is the thinnest of the four — it hands the handler back unwrapped. See [Server mode setup](/docs/guides/server-mode-setup/) for what each config option does. ## 1. Install ```sh pnpm add @upupjs/server ``` ## 2. Share one config Author the config once and import it wherever you mount: ```ts // lib/upup-config.ts import type { UpupServerConfig } from '@upupjs/server' export const upupConfig: UpupServerConfig = { storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, }, // Required, server-only: stable, high-entropy, min 16 chars, shared // across every instance. createUpupHandler throws without it. uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!, getUserId: async req => { const session = await getSessionFromCookie(req) return session?.userId ?? null }, } ``` ## 3. Mount the routes ```ts import { Hono } from 'hono' import { createUpupRoutes } from '@upupjs/server/hono' import { upupConfig } from './lib/upup-config' const routes = createUpupRoutes(upupConfig) const app = new Hono() app.all('/api/upup/*', c => routes(c.req.raw)) export default app ``` `createUpupRoutes(config)` returns a plain `(req: Request) => Promise`. Hand it the raw `Request` off the context (`c.req.raw`) and return its `Response` — no bridging, no serialization, nothing to convert. Use `app.all` and keep the trailing `/*`: the handler dispatches on the path suffix beneath your prefix (`/presign`, `/multipart/init`, `/auth/:provider`, …), and it needs `GET`, `POST`, and `OPTIONS` on all of them. Then point the uploader at the same prefix, minus the wildcard: ```tsx ``` ## Body parsing There is none to configure. The handler reads the request body itself off the Web `Request` — do not call `c.req.json()` in a middleware ahead of it, because a consumed body stream cannot be read twice and the POST routes would fail with `400 Invalid JSON body`. The file bytes never flow through your Hono app on the presigned paths: the browser gets a signed URL and PUTs straight to storage. ## Edge and non-Node runtimes Two things to check before deploying to Workers, Deno, or Bun. **Credentials arrive per request on some runtimes.** The snippet above builds the handler once at module scope, which is right on Node, Bun, and Deno where `process.env` is populated at startup. On Cloudflare Workers, secrets are bindings on `c.env` and only exist inside a request, so build the handler on first use and cache it: ```ts import { Hono } from 'hono' import { createUpupRoutes } from '@upupjs/server/hono' import type { UpupServerConfig } from '@upupjs/server' type Env = { Bindings: { R2_BUCKET: string R2_ENDPOINT: string R2_ACCESS_KEY_ID: string R2_SECRET_ACCESS_KEY: string UPUP_UPLOAD_TOKEN_SECRET: string } } const app = new Hono() let routes: ReturnType | undefined app.all('/api/upup/*', c => { routes ??= createUpupRoutes({ storage: { type: 'r2', bucket: c.env.R2_BUCKET, // R2 is account-scoped and its region is the literal 'auto'. region: 'auto', endpoint: c.env.R2_ENDPOINT, accessKeyId: c.env.R2_ACCESS_KEY_ID, secretAccessKey: c.env.R2_SECRET_ACCESS_KEY, }, uploadTokenSecret: c.env.UPUP_UPLOAD_TOKEN_SECRET, } satisfies UpupServerConfig) return routes(c.req.raw) }) export default app ``` Caching matters for more than speed: `createUpupRoutes` runs the construct-time validation (missing bucket, short `uploadTokenSecret`, a non-S3 `storage.type`) and throwing that per request would turn a config mistake into a stream of 500s instead of one loud boot failure. `@upupjs/server` signs and completes uploads through `@aws-sdk/client-s3`. A non-Node runtime therefore needs its Node-compatibility layer enabled — on Cloudflare Workers that is the `nodejs_compat` flag in `wrangler.toml`. Confirm it against your runtime before your first deploy. ## CORS Same-origin deployments need no CORS config. For cross-origin ones, set it on the upup config rather than adding Hono's `cors()` middleware in front — the handler attaches the headers itself, on every response including the preflight `OPTIONS` (`204`), and two layers of CORS on one route is a conflict waiting to happen: ```ts export const upupConfig: UpupServerConfig = { // ...storage, uploadTokenSecret cors: { allowedOrigins: ['https://app.example.com'], allowedMethods: ['GET', 'POST', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], maxAgeSeconds: 600, }, } ``` Credentialed CORS is granted only on a concrete origin match. A config whose `allowedOrigins` is just `['*']` gets public, non-credentialed CORS — the server-mode drive client sends `credentials: 'include'`, so cloud drives will fail against a wildcard-only allowlist. Enumerate your real app origins. ## Verify the mount ```sh curl http://localhost:8787/api/upup/health ``` A JSON body with `"status": "ok"` and `checks.config` / `checks.storage` means the routes are mounted and the storage credentials resolve. The route is unauthenticated by design and always answers `200` — read the `status` field, not the HTTP code. ## Related - [Server mode setup](/docs/guides/server-mode-setup/) — every `createUpupHandler` option, hooks, limits, and observability. - [Server auth & trust model](/docs/guides/server-auth/) — why `uploadTokenSecret` is mandatory and what forged requests get. - [Storage providers](/docs/guides/storage-providers/) — S3, R2, MinIO, B2, Spaces, Wasabi configs, and [Cloudflare R2](/docs/guides/storage/cloudflare-r2/) for the endpoint used above. --- # File upload server with Next.js and S3 https://useupup.com/docs/guides/server-adapters/nextjs/ Next.js gets the richest adapter of the four, because it has two router conventions and a proxy story of its own. Both entry points come from `@upupjs/next/server` and take the same config object — see [Server mode setup](/docs/guides/server-mode-setup/) for what each option does. ## 1. Install ```sh pnpm add @upupjs/next ``` `@upupjs/next` depends on `@upupjs/server` (and `@upupjs/react`), so this one package covers both the client component and the route handlers. It peers on Next 15+ and React 19. Server-only if you prefer: `createUpupNextHandler` is also exported from `@upupjs/server/next`, so an app that only needs the App Router route can install `@upupjs/server` alone. The Pages Router adapter and `defineUpupConfig` live in `@upupjs/next/server` only. ## 2. Share one config Author it once and import it from the route file: ```ts // lib/upup-config.ts import { defineUpupConfig } from '@upupjs/next/server' export const upupConfig = defineUpupConfig({ storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, }, // Required, server-only: stable, high-entropy, min 16 chars, shared // across every instance. createUpupHandler throws without it. uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!, getUserId: async req => { const session = await getSessionFromCookie(req) return session?.userId ?? null }, }) ``` `defineUpupConfig` is a typed pass-through — it returns the object unchanged and exists purely for editor autocomplete. Required-field validation happens inside `createUpupHandler` regardless, so calling the handler with a plain object literal is equally protected. ## 3a. App Router ```ts // app/api/upup/[...route]/route.ts import { createUpupNextHandler } from '@upupjs/next/server' import { upupConfig } from '@/lib/upup-config' export const { GET, POST, PUT, DELETE } = createUpupNextHandler(upupConfig) ``` The route segment must be a **catch-all** (`[...route]`): the handler dispatches on the path suffix beneath your mount (`/presign`, `/multipart/init`, `/auth/:provider`, `/files/:provider`, …), and a fixed segment would only ever match one of them. `createUpupNextHandler` returns all four methods rather than a single function. Exporting the bare handler works too, but you lose the origin correction described below. ## 3b. Pages Router ```ts // pages/api/upup/[...route].ts import { createUpupPagesHandler } from '@upupjs/next/server' import { upupConfig } from '@/lib/upup-config' export default createUpupPagesHandler(upupConfig) // Required: the adapter reads the raw request body itself. export const config = { api: { bodyParser: false }, } ``` The `bodyParser: false` export is not optional. With Next's parser left on, the adapter receives an already-consumed stream, every POST body arrives empty, and the upload routes fail with `400 Invalid JSON body` (`code: BAD_REQUEST`). The Pages adapter bridges Node's `req`/`res` to the Web `Request`/`Response` the core handler speaks, reading the raw body itself and streaming the response back through `res.status` / `res.setHeader` / `res.send`. It takes the same second-argument options as the App Router handler. ## 4. Point the uploader at it Both routers expose the same URL prefix, so the client code is identical: ```tsx 'use client' import { UpupUploader } from '@upupjs/next' export default function Uploader() { return ( ) } ``` ## Behind a proxy or CDN The OAuth `redirect_uri` for cloud drives is derived from the request's origin, so a proxy that rewrites the origin breaks the callback. Both handlers take an options object that corrects it: ```ts createUpupNextHandler(upupConfig, { baseUrl: 'https://app.example.com' }) createUpupNextHandler(upupConfig, { trustProxy: true }) ``` - `baseUrl` — an explicit public origin. Wins over everything; use it when you know the public URL at build time. - `trustProxy` — derive the origin from `x-forwarded-host` / `x-forwarded-proto`. **Off by default**, because those headers are spoofable by anything that can reach your server directly. Only enable it behind a proxy that overwrites them. Neither is needed on Vercel: `req.url` is already the public origin, so the correction is a no-op. The same options apply to `createUpupPagesHandler`. Whichever you use, register the resulting callback URL in each provider's console — for a mount at `/api/upup` that's `https://app.example.com/api/upup/auth/google-drive/cb`. ## `trailingSlash: true` Supported, nothing to configure. Next 308-redirects `POST /api/upup/presign` to `.../presign/` with method and body preserved, and the handler matches routes on the slash-stripped path — so the redirected request lands on the right route instead of 404ing. ## CORS A route mounted in the same Next app that serves your pages is same-origin and needs no CORS config. For a Next app acting as a standalone upload API for a separate frontend, set it on the upup config rather than in `next.config.js` headers — the handler attaches the headers itself, on every response including the preflight `OPTIONS` (`204`): ```ts export const upupConfig = defineUpupConfig({ // ...storage, uploadTokenSecret cors: { allowedOrigins: ['https://app.example.com'], allowedMethods: ['GET', 'POST', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], maxAgeSeconds: 600, }, }) ``` Credentialed CORS is granted only on a concrete origin match. A config whose `allowedOrigins` is just `['*']` gets public, non-credentialed CORS — the server-mode drive client sends `credentials: 'include'`, so cloud drives will fail against a wildcard-only allowlist. Enumerate your real app origins. ## Serverless caveats - **Multipart state is stateless by design.** The `/multipart/*` routes carry an HMAC-signed token instead of server-side session state, so a later part can land on a different lambda than the one that ran `/multipart/init`. That only holds while every instance shares the same `uploadTokenSecret` — set it from one environment variable, never generate it per boot. - **`InMemoryTokenStore` does not survive.** Drive OAuth tokens kept in process memory vanish between invocations and differ per instance. Use a Redis, KV, or database-backed `TokenStore` for any serverless deployment; the store contract is three methods (`get`, `set`, `delete`). - **Route timeouts bound the drive-transfer path.** `POST /files/:provider/transfer` streams a cloud-drive file into S3 inside one request; a large file on a short function timeout will be cut off. Raise the route's `maxDuration` if you enable cloud drives in server mode. ## Verify the mount ```sh curl http://localhost:3000/api/upup/health ``` A JSON body with `"status": "ok"` and `checks.config` / `checks.storage` means the route is mounted and the storage credentials resolve. The route is unauthenticated by design and always answers `200` — read the `status` field, not the HTTP code. ## Related - [Server mode setup](/docs/guides/server-mode-setup/) — every `createUpupHandler` option, hooks, limits, and observability. - [Server auth & trust model](/docs/guides/server-auth/) — why `uploadTokenSecret` is mandatory and what forged requests get. - [Storage providers](/docs/guides/storage-providers/) — S3, R2, MinIO, B2, Spaces, Wasabi configs. --- # Server Auth & Trust Model https://useupup.com/docs/guides/server-auth/ `@upupjs/server` is the trust boundary between the browser and your storage. In server mode the browser never holds storage credentials — it asks your handler to presign a PUT or to run a multipart upload, and the handler decides who is allowed and where the bytes land. This page explains that decision: the mandatory secret, the secure-by-default gate, per-user scoping, and the signed upload token that makes the multipart lifecycle tamper-resistant. Everything here is enforced by `createUpupHandler` — the single factory exported from `@upupjs/server`. ## The mandatory upload-token secret `createUpupHandler` **throws at construction** unless you pass a `uploadTokenSecret` of at least 16 characters. There is no way to run the handler without it, because the multipart routes are always live and every multipart session issues and verifies a signed token (see [The upload token](#the-upload-token)). ```ts import { createUpupHandler } from '@upupjs/server' const handler = createUpupHandler({ storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, }, // Required. >= 16 chars, stable, high-entropy, and identical across // every server instance / worker. Generate with `openssl rand -hex 32`. uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!, }) ``` Give it a stable, high-entropy value from your environment (`UPUP_UPLOAD_TOKEN_SECRET` is the conventional name) and share the **same** value across every instance and worker — a multipart session initialized on one node must verify on another. A missing or too-short secret raises a `UpupConfigError` at boot, not a confusing 500 at request time. ## Secure by default: anonymous uploads are off The two capability-granting routes — `POST /presign` and `POST /multipart/init` — refuse an unidentified caller. If you configure none of `auth`, `getUserId`, or `allowAnonymousUploads`, those routes return **403** with the code `AUTH_REQUIRED`: ```json { "error": "Anonymous uploads are disabled. Set allowAnonymousUploads:true, or configure auth/getUserId.", "code": "AUTH_REQUIRED" } ``` You unlock uploads by configuring exactly one of three things, depending on how much you want the handler to know about the user: ```ts // 1. Gate every request behind your own check (session cookie, bearer token…). // `auth` returns true to allow, false to reject with 401. createUpupHandler({ storage, uploadTokenSecret, auth: async req => Boolean(await getSessionFromCookie(req)), }) // 2. Resolve a stable per-user id. Uploads are namespaced by it, and the // multipart token is bound to it (cross-user replay → 403). Preferred // for multi-tenant apps. createUpupHandler({ storage, uploadTokenSecret, getUserId: async req => (await getSessionFromCookie(req))?.userId ?? null, }) // 3. Explicitly allow unauthenticated uploads under ONE shared namespace. // Demos, or deployments where auth already happened upstream. Never in // multi-tenant production — it prints a loud warning at boot. createUpupHandler({ storage, uploadTokenSecret, allowAnonymousUploads: true, maxFileSize: 25 * 1024 * 1024, // 25 MB, bytes allowedTypes: ['image/*', 'application/pdf'], }) ``` The signatures are exactly: | Option | Type | Effect | | ----------------------- | ------------------------------------------- | -------------------------------------------------------------------------- | | `auth` | `(req: Request) => Promise` | Global gate. `false` → `401 Unauthorized` before any route runs. | | `getUserId` | `(req: Request) => Promise` | Resolve the user. `null` → `401 Unauthenticated` inside the route. | | `allowAnonymousUploads` | `boolean` | Opt in to a shared anonymous namespace for `/presign` + `/multipart/init`. | `auth` and `getUserId` compose: use `auth` as a coarse allow/deny gate and `getUserId` to scope storage and bind the upload token. If you set neither and leave `allowAnonymousUploads` off, the upload routes stay closed. > `getUserId` also scopes cloud-drive OAuth tokens in the `tokenStore`. If you > configure `providers` or a `tokenStore` without `getUserId`, the handler > throws at construction unless you set `allowAnonymous: true` (the > drive-scoped sibling of `allowAnonymousUploads`). ## The upload token Multipart uploads run over five requests — `init`, `sign-part`, `complete`, `abort`, `resume`. The client cannot be trusted to re-assert which object key or S3 `uploadId` it is continuing, so the server issues a **stateless, HMAC-signed token** at `init` and re-derives everything from the verified token on every later step. The token binds: - **`k`** — the object key the upload targets (the server chose it, not the client). - **`u`** — the S3 multipart `uploadId`. - **`uid`** — the resolved user id (or `null` for an anonymous upload). - **`smin` / `smax`** — the allowed total-size envelope, in bytes. - **`exp`** — expiry, epoch seconds. Default TTL is one hour. - **`iat`** — the issue time of the **original** `init`, epoch seconds. Absent on tokens minted before cross-reload resume shipped; `resume` then derives it from `exp`, since `init` is the only issuer and has always used the same TTL. It is signed with HMAC-SHA-256 over the payload using your `uploadTokenSecret`, via Web Crypto (so it works on Node 18+, edge runtimes, and Cloudflare Workers). On every continuation request the handler verifies the signature **before** trusting any payload byte, compares it in constant time, and checks expiry — the one exception being `resume`, which applies the resume window described below instead. A tampered or forged token is rejected with **403** and a code that names the failure: ```json { "error": "Invalid upload token", "code": "bad_signature" } ``` The `code` is one of `malformed`, `bad_signature`, or `expired`. ### The resume window `POST /multipart/resume` is the one route that accepts a token whose `exp` has passed — handing an expired token back as a fresh one is precisely its job, and an upload can easily outlive a one-hour TTL. Signature and shape verification are unchanged; only the expiry check is relaxed, and a tighter bound replaces it: - The **resume window** is `multipartResumeWindowSeconds`, 24 hours by default, measured from the token's `iat` — the original `init`, not the most recent re-issue. Past that, `resume` answers `403` with code `expired`. - Re-issued tokens carry the original `iat` forward unchanged, so a chain of resumes cannot extend the window. It is a fixed-length extension of a token's life, never an open-ended renewal. - Set `multipartResumeWindowSeconds: 0` to remove the route entirely. **The trade, stated plainly:** a leaked token is usable for the window instead of an hour. What it can do in that time is unchanged and narrow — continue the same upload, to the same key, inside the same signed size envelope, still owner-bound when `getUserId` is configured. It cannot start an upload, retarget one, or widen the envelope. The other four routes keep rejecting expired tokens exactly as before. ### Owner binding When `getUserId` is configured, `complete` / `sign-part` / `abort` / `resume` also re-check the caller's current identity against the token's bound `uid`. A token that leaked to a different authenticated user cannot be replayed — the mismatch returns **403 `AUTH_DENIED`**: ```json { "error": "Upload token does not belong to the current user", "code": "AUTH_DENIED" } ``` Without `getUserId`, `uid` is always `null` (there was no identity to bind at `init`), so this check is skipped and **possession of the token is the model** — anyone holding a valid, unexpired token can continue the session. That is an intentional trade-off for token-possession deployments; set `getUserId` if you need per-user enforcement. ### Signed size envelope The declared file size at `init` is signed into `smax`. Because `sign-part` and the browser's direct PUTs never re-send the size, the handler enforces the envelope at `complete`: it sums the bytes S3 actually received (via `ListParts`) and, if the real total falls outside `[smin, smax]`, it **aborts the upload** and returns **403**: ```json { "error": "Upload size outside signed envelope" } ``` This stops a client from declaring a tiny size at `init` and then streaming an arbitrarily large object. Signing both ends (`smin` is `0` by default) means the accepted range is fixed by the server at init — a client can't widen it from either side. ## Server-chosen keys The client never chooses the storage key. By default the handler namespaces every object as `//`. Override with `keyStrategy` if you need your own layout — it receives the resolved user id (or `null`), file name, content type, and size: ```ts createUpupHandler({ storage, uploadTokenSecret, getUserId, keyStrategy: ({ userId, fileName }) => `tenants/${userId ?? 'anon'}/${Date.now()}-${fileName}`, }) ``` ## Metadata policy: size and type `maxFileSize` (bytes) and `allowedTypes` (an array of MIME patterns, with `image/*`-style wildcards) are enforced on both the presign and multipart paths before any storage call: - Over `maxFileSize` → **413** `File too large`. - Type not in `allowedTypes` → **415** `File type not allowed`. - Malformed metadata → **400** `BAD_REQUEST`. An `onBeforeUpload` hook — configured under `hooks`, i.e. `config.hooks.onBeforeUpload`, not top-level — can reject a specific upload (**403** `Upload rejected`) with your own logic. ## What forged and unsigned requests get - A `/presign` or `/multipart/init` with no configured auth path → **403 `AUTH_REQUIRED`**. - A continuation request with a forged, tampered, or expired token → **403** (`bad_signature` / `malformed` / `expired`). - A continuation request whose caller is not the bound `uid` (when `getUserId` is set) → **403 `AUTH_DENIED`**. - A `complete` whose real byte total is outside the signed envelope → **403**, and the S3 multipart upload is aborted so nothing partial is left behind. - A request rejected by your `auth` gate → **401 `Unauthorized`**. Every response — success or failure — carries an `x-upup-request-id` header so you can correlate a client error with a server log line. Failures are logged through the [`onError` seam](/docs/guides/error-monitoring/) with the route, method, status, and a redacted error; secrets, tokens, and request bodies are never logged. ## What this does _not_ protect The trust model secures the wire: it prevents tampered sizes and keys, cross-user continuation replay, and forged tokens. It does **not** validate the quality of your own `auth` / `getUserId` implementations — if your session check accepts a spoofable cookie, the handler faithfully trusts whatever user it returns. Treat `auth` and `getUserId` as security-critical code, and keep `uploadTokenSecret` out of source control and rotated like any other secret. ## Recipes **Session-cookie app, per-user storage.** The common case: authenticate with a cookie and scope every object to the user. ```ts import { createUpupHandler, InMemoryTokenStore } from '@upupjs/server' export const handler = createUpupHandler({ storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, }, uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!, tokenStore: new InMemoryTokenStore(), // swap for Redis/KV in production getUserId: async req => { const session = await getSessionFromCookie(req) return session?.userId ?? null // null → 401, upload refused }, }) ``` **Public drop-box with limits.** Accept uploads from anyone, but cap size and type. The shared anonymous namespace is explicit and logged at boot. ```ts export const handler = createUpupHandler({ storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, }, uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!, allowAnonymousUploads: true, maxFileSize: 10 * 1024 * 1024, // 10 MB allowedTypes: ['image/*'], }) ``` > `InMemoryTokenStore` is a reference implementation — fine for demos and > single-process dev, but it loses tokens on restart and is not shared across > workers. Implement the `TokenStore` interface (`get` / `set` / `delete`) > against Redis, Cloudflare KV, or your database for production. See [Client Mode vs Server Mode](/docs/guides/modes/) for when to reach for server mode, [Server Mode — Setup](/docs/guides/server-mode-setup/) for wiring the handler into Next.js, Express, Fastify, or Hono, and [Storage Providers](/docs/guides/storage-providers/) for the `storage` block of any S3-compatible backend. --- # Server Mode — Setup https://useupup.com/docs/guides/server-mode-setup/ End-to-end setup for `mode="server"`. In server mode the browser talks only to your server: it holds the storage credentials and the cloud-drive OAuth secrets, and it is the trust boundary every upload passes through. This page is the hub — the config object, the routes, the limits, the hooks, and the observability seam. The per-framework mount recipes live on their own pages, linked from [Framework adapters](#framework-adapters) below. Rough time budget: **15–30 minutes** including provider OAuth registration. ## 1. Install ```sh pnpm add @upupjs/react @upupjs/server ``` `@upupjs/server` is the Node-side handler. The React package has no dependency on it — your client bundle stays free of S3 SDKs. ## 2. Mount the handler The example below is the Next.js App Router; every other framework takes the same config object through a one-line adapter. ```ts // app/api/upup/[...route]/route.ts import { createUpupHandler, InMemoryTokenStore } from '@upupjs/server' const handler = createUpupHandler({ storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, }, // Required, server-only: a stable, high-entropy secret (min 16 chars), // shared across every server instance. createUpupHandler throws without it. uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!, providers: { googleDrive: { clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }, dropbox: { appKey: process.env.DROPBOX_APP_KEY!, appSecret: process.env.DROPBOX_APP_SECRET!, }, // oneDrive, box — same shape }, tokenStore: new InMemoryTokenStore(), // swap for Redis in prod getUserId: async req => { // Resolve the session user. Return null → OAuth 401s. const session = await getSessionFromCookie(req) return session?.userId ?? null }, }) export const GET = handler export const POST = handler ``` `getUserId` (or `auth`) is required once you set `providers` or `tokenStore` — it scopes tokens per user, and without it the upload routes return `403 AUTH_REQUIRED`. See [Server auth](/docs/guides/server-auth/). The handler routes on path suffix: `/presign`, `/multipart/*`, `/auth/:provider`, `/auth/:provider/cb`, `/files/:provider`, `/files/:provider/transfer`, `/health`. All paths are relative to the folder you mount it at. Running Express, Fastify, Hono, the Pages Router, or a bare Node server instead? See [Framework adapters](#framework-adapters) below — same config object, one adapter call each. ## 3. Point the uploader at it ```tsx ``` No cloud-drive `clientId` props needed on the client — the server holds them. ## 4. Register OAuth apps For each drive you enable: | Provider | Console | Callback URL | | ------------ | -------------------------------------------------------- | --------------------------------------------------- | | Google Drive | `console.cloud.google.com` → APIs → OAuth 2.0 Client IDs | `https://yourapp.com/api/upup/auth/google-drive/cb` | | OneDrive | `portal.azure.com` → App registrations | `https://yourapp.com/api/upup/auth/one-drive/cb` | | Dropbox | `www.dropbox.com/developers/apps` | `https://yourapp.com/api/upup/auth/dropbox/cb` | | Box | `app.box.com/developers/console` | `https://yourapp.com/api/upup/auth/box/cb` | Scopes required: - Google: `https://www.googleapis.com/auth/drive.readonly` - OneDrive: `Files.Read.All offline_access` (`offline_access` is what makes Microsoft return a refresh token — without it, sessions die when the access token expires) - Dropbox: `files.content.read files.metadata.read` - Box: `root_readonly` ## 5. Production token store `InMemoryTokenStore` is a reference implementation. Replace with any KV-shaped store for production: ```ts // Redis example import Redis from 'ioredis' const redis = new Redis(process.env.REDIS_URL!) const tokenStore = { async get(key) { return (await redis.get(key)) ?? null }, async set(key, value, ttlSeconds) { if (ttlSeconds) await redis.setex(key, ttlSeconds, value) else await redis.set(key, value) }, async delete(key) { await redis.del(key) }, } ``` The contract is three strings-in-strings-out methods. Cloudflare KV, DynamoDB, Postgres — anything shaped like this works. ## 6. Tuning ```ts createUpupHandler({ // ...storage, uploadTokenSecret, and providers from step 2 maxFileSize: 500 * 1024 * 1024, // 500 MB allowedTypes: ['image/*', 'video/*'], multipartResumeWindowSeconds: 86400, // 24h; 0 disables POST /multipart/resume hooks: { onBeforeUpload: async (file, req) => { // Return false to reject the upload return true }, onFileUploaded: async (file, req) => { // Persist a DB row pointing at file.key }, }, }) ``` The server→S3 multipart cutoff on the **cloud-drive transfer** path is fixed and not configurable: files up to 5 MiB stream through as a single PUT; larger files use S3 multipart with 5 MiB chunks. Server memory envelope is one chunk at a time regardless of file size — the old `multipartThreshold` knob was removed so the memory bound cannot be raised away by configuration. ## 7. Large files: the multipart flow By default a file is uploaded whole: `POST /presign` hands the browser one signed URL and the bytes go straight to storage. Turn on multipart (client-side `resumable: { protocol: 'multipart' }`, threshold 5 MiB by default — see [Resumable uploads](/docs/resumable-uploads/)) and the browser drives the lifecycle above against your server instead. The diagram shows the happy path; steps 4 and 5 below cover finishing and recovering: 1. **`POST /multipart/init`** — the server starts the S3 upload and returns the object key, the `uploadId`, the part size, and an **HMAC-signed token**. The token binds the key, the `uploadId`, the owning user, an expiry, and a size envelope. It is the only state the client carries; the server keeps no session. 2. **`POST /multipart/sign-part`** — one call per part, sending the token and a part number, answered with a presigned URL for exactly that part. 3. **The part `PUT`s go browser → storage directly.** Part bytes never traverse your server on this path, so a 5 GB upload costs it nothing but the signing calls. Parts are 5 MiB by default and floored at S3's 5 MiB minimum; the browser uploads several in parallel and collects each part's `ETag`. 4. **`POST /multipart/complete`** — the server sums the bytes S3 actually received, rejects and aborts the upload with `403` if the total falls outside the token's signed envelope, and only then finalizes the object. An explicit cancel calls `POST /multipart/abort`. 5. **`POST /multipart/resume`** — the recovery step. The browser presents the token it saved, and the server answers with the parts storage already holds (each with its byte size) plus a freshly-signed token. That is how an upload survives a reload or a tab close, and how a token that outlived its one-hour expiry is refreshed mid-upload. Its window is `multipartResumeWindowSeconds` (24 hours by default, measured from the original `init`); set it to `0` to switch the route off entirely. Because resume is on by default client-side, a failed or abandoned multipart upload is **not** aborted server-side — its parts are kept deliberately, so the next attempt continues from them. Parts nobody ever resumes are never cleaned up by upup and S3 bills for them. Configure an `AbortIncompleteMultipartUpload` lifecycle rule on the bucket with a 1–7 day expiry. Every S3-compatible provider supports it, MinIO included. See [Cross-reload resume](/docs/resumable-uploads/#cross-reload-resume). Two consequences worth knowing: - **Any instance can serve any step.** The signed token replaces server-side session state, so a load-balanced or serverless deployment works as long as every instance shares the same `uploadTokenSecret`. Different secrets across instances is the classic mid-upload failure — the `/health` fingerprint below catches it. - **The token's owner is enforced, not just its signature.** With `getUserId` configured, `sign-part`, `complete`, `abort`, and `resume` all check that the caller is the user the token was issued to and answer `403 AUTH_DENIED` otherwise. Without a `getUserId` resolver, possession of the token is the model. - **`resume` is the one route that tolerates an expired token**, since refreshing one is its job. It applies the resume window instead, anchored at the original `init` and carried forward on every re-issue, so the usable life of a leaked token is bounded even across a chain of resumes. See [Server Auth & Trust Model](/docs/guides/server-auth/#the-upload-token). This is a different path from the cloud-drive transfer described in [Limits](#the-drive-transfer-memory-bound), where the server itself pulls bytes from a drive and pushes them to S3. ## 8. Re-authentication When an OAuth access token expires, the server returns `401 { reauth: true }`. The React component catches this and surfaces the provider's "Sign in" button. One click re-auths and the user continues where they left off. Re-auth is the fallback, not the routine: when the provider issued a refresh token, the server stores it alongside the access token (as a no-expiry entry in `tokenStore`) and refreshes proactively before drive calls, so users rarely see the prompt. The `reauth: true` path fires when there is no refresh token or the refresh itself fails. ## Framework adapters `createUpupHandler` is a plain `(req: Request) => Promise` function. Every adapter wraps that same handler and takes the same config object — the one you built in step 2. | Framework | Entry point | Import from | | -------------------------------------------------------------- | ------------------------ | ------------------------ | | [Express](/docs/guides/server-adapters/express/) | `createUpupMiddleware` | `@upupjs/server/express` | | [Fastify](/docs/guides/server-adapters/fastify/) | `createUpupPlugin` | `@upupjs/server/fastify` | | [Hono](/docs/guides/server-adapters/hono/) | `createUpupRoutes` | `@upupjs/server/hono` | | [Next.js — App Router](/docs/guides/server-adapters/nextjs/) | `createUpupNextHandler` | `@upupjs/next/server` | | [Next.js — Pages Router](/docs/guides/server-adapters/nextjs/) | `createUpupPagesHandler` | `@upupjs/next/server` | Each page carries the complete mount recipe for that framework plus its own body-parsing, proxy-origin, and CORS notes — those genuinely differ, and getting them wrong is the most common first-run failure. ### One config, every adapter Define the config once and share it: ```ts // lib/upup-config.ts import type { UpupServerConfig } from '@upupjs/server' export const upupConfig: UpupServerConfig = { storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, }, uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!, // providers, tokenStore, getUserId… as in step 2 } ``` In a Next.js app you can author the same object through `defineUpupConfig` from `@upupjs/next/server` for editor autocomplete — it's a typed pass-through, not a second validation layer (required-field validation always happens inside `createUpupHandler`, so direct callers are protected too). ### Custom Node server Any Node framework without a dedicated adapter can reuse the same bridge the Express, Fastify, and Pages Router adapters are built on — `toWebRequest` and `writeWebResponse` from `@upupjs/server/node-bridge`. Don't hand-roll the conversion; the bridge already handles multi-value headers and skips `content-length` (Node recomputes it, and a copied value risks a mismatch). ```ts import { createServer } from 'node:http' import { createUpupHandler } from '@upupjs/server' import { toWebRequest, writeWebResponse } from '@upupjs/server/node-bridge' import { upupConfig } from './lib/upup-config' const handler = createUpupHandler(upupConfig) createServer(async (req, res) => { const chunks: Buffer[] = [] for await (const chunk of req) chunks.push(Buffer.from(chunk)) const webReq = toWebRequest({ url: new URL(req.url ?? '/', `http://${req.headers.host}`).toString(), method: req.method ?? 'GET', headers: req.headers, // toWebRequest drops the body for GET/HEAD on its own. body: chunks.length ? Buffer.concat(chunks) : undefined, }) await writeWebResponse( { status: code => { res.statusCode = code }, setHeader: (name, value) => { res.setHeader(name, value) }, send: body => { res.end(body) }, }, await handler(webReq), ) }).listen(3000) ``` The sink is three methods — `status`, `setHeader`, `send` — so Express's `res`, Fastify's `reply`, and `NextApiResponse` all satisfy it with a thin rename. ## Lifecycle hooks Three optional hooks let you gate uploads and react to completions: ```ts createUpupHandler({ // ...storage, uploadTokenSecret hooks: { onBeforeUpload: async (file, req) => true, // false rejects with 403 onFileUploaded: async (file, req) => { // one file finished — file.key, .name, .size, .type, .url }, onUploadComplete: async (files, req) => { // a request's file(s) finished }, }, }) ``` **Which hook fires on which path.** Read this before wiring alerting, billing, or webhooks on top of them — the gaps are structural, not bugs: | Route | `onBeforeUpload` | `onFileUploaded` | `onUploadComplete` | | -------------------------------- | ---------------- | ---------------- | ------------------ | | `POST /presign` | yes | no | no | | `POST /multipart/init` | yes | no | no | | `POST /multipart/complete` | no | yes | yes | | `POST /files/:provider/transfer` | no | yes | no | - **`onBeforeUpload` is an admission gate, not a completion signal.** It runs during metadata validation on `/presign` and `/multipart/init`, after the `maxFileSize` and `allowedTypes` checks. Returning `false` responds `403 Upload rejected` and nothing is presigned. - **`onFileUploaded` fires once per file on the two server-side-completion paths only:** `/multipart/complete` (the server just finished the S3 multipart upload) and `/files/:provider/transfer` (the server just finished streaming a cloud-drive file into S3). In both cases the server can actually see the finished object. - **`onUploadComplete` fires only on `/multipart/complete`, always with a single-element array.** The server completes one file per request and has no cross-file batching concept. For a true "the whole batch is done" signal, use the client-side `onUploadComplete` prop instead — that one sees the entire selection. - **Client-direct presigned PUTs fire no server hook at all.** `POST /presign` only hands the browser a URL; the bytes then go straight to S3 and the server never observes completion. If you need server visibility into that path, use the client-side `onUploadComplete` prop, or point `processingEndpoint` at an SSE route so the client tells your server when it's done. - On the multipart-complete path, `file.type` is always `''` — the declared MIME type isn't retained server-side once the upload completes. A hook that throws **after** a successful upload is reported through `onError` and swallowed, never re-coded as a 500. The object is already durably in S3, so a 500 would only tell the client to retry something that already succeeded. ## Observability ### The `onError` seam Every error path in the handler — 500s, invalid upload tokens, OAuth and token-exchange failures, failed drive-transfer aborts, health-check storage failures — routes through one logger: ```ts createUpupHandler({ // ...storage, uploadTokenSecret onError: event => { // event: route, method, status, code, message, requestId, // error: { name, message, stack } myLogger.error('upup-server', event) }, }) ``` If you don't supply `onError`, the default writes one structured line via `console.error('[upup:server]', JSON.stringify(event))` — error visibility is on out of the box, not something you wire up after your first incident. Pass a no-op to silence it. **Redaction guarantee.** An error event is only ever built from a static route string, the request method, the HTTP status, a machine `code`, a generic message, and the caught error's `name` / `message` / `stack`. Request bodies, drive tokens, `uploadTokenSecret`, S3 credentials, signatures, and `Authorization` headers are never put into an event. That contract is enforced structurally, not by convention: every event passes through a scrubber at the single reporting seam before it reaches your logger, so even an error whose message accidentally interpolated a credential is cleaned. It rewrites `Authorization` header dumps, bare `Bearer` tokens, the SigV4 `x-amz-signature` / `x-amz-credential` / `x-amz-security-token` params, and AWS access-key ids to `[REDACTED]`. It's deliberately conservative — ordinary stack frames, file paths, and function names survive intact. Treat it as defense in depth, not licence to build an event out of a secret. ### Request IDs Every response the handler produces carries an `x-upup-request-id` header, and the same value appears as `event.requestId` in your `onError` events. That's the join key between a client-reported failure and your server logs — health responses included; there are no exceptions to the contract. ### The `/health` endpoint ```sh curl https://yourapp.com/api/upup/health ``` ```json { "status": "ok", "checks": { "config": "ok", "storage": "ok" }, "summary": { "storageType": "aws", "anonymousUploads": false, "anonymousDrives": false, "driveProviders": 2, "uploadTokenTtlSeconds": 3600 } } ``` It's unauthenticated by design — the check runs _before_ `config.auth`, so uptime and deploy probes work without credentials — and **always responds 200**. The `status` field carries the real signal, so an orchestrator won't restart a container over a transient S3 blip. - `checks.config` is `ok` when `storage.bucket`, `storage.region`, and a valid-length `uploadTokenSecret` are all present. - `checks.storage` is a cheap bucket-head probe (no listing, no transfer), cached for 30 seconds so repeated polling doesn't hammer S3. A failed probe is also reported through `onError`. - `summary` is labels, flags, and counts only — never a secret value. To catch cross-instance secret drift after a rolling deploy, opt into a fingerprint: ```ts createUpupHandler({ // ...storage, uploadTokenSecret health: { exposeSecretFingerprint: true }, }) ``` That adds `uploadTokenFingerprint` — the first 8 hex characters of SHA-256(`uploadTokenSecret`). Two instances showing different fingerprints are running different secrets, which breaks multipart uploads that start on one and continue on another. It's a one-way hash, not the secret. Default is off. Full request/response shapes for every route: [Server HTTP API](/docs/api-reference/server-http/). For client-side error wiring, see [Error monitoring](/docs/guides/error-monitoring/). ## Limits ### `maxFileSize` Enforced on both upload paths, and on the drive path it's enforced twice: - `POST /presign` and `POST /multipart/init` reject a declared size over the limit with `413 File too large` before anything is signed. - `POST /files/:provider/transfer` fast-rejects the drive-declared size with `413`, then enforces the cap again against the bytes actually streamed. A file that lies about its size is aborted mid-transfer and leaves nothing behind in the bucket — the streamed-byte check is the authoritative one. ### `allowedTypes` An allowlist of MIME types; `415 File type not allowed` otherwise. One shared policy across the upload and drive-transfer paths: - Omitted or empty → every type passes. - An `image/*` entry honours the wildcard. - An absent or empty type does **not** match a non-empty allowlist. A file with no declared MIME type is rejected, never silently waved through. These are server-side policy. The client-side `maxFileSize` / file-type props give the user a fast local error; the server checks are what actually hold, since a client can be bypassed. ### The drive-transfer memory bound When the server pulls a file out of a cloud drive and pushes it to S3, its memory envelope is fixed at **5 MiB** regardless of file size. A file whose size the drive reports as 5 MiB or less goes through as a single PUT (one buffered body); everything else — including anything the drive reports no size for — streams through bounded 5 MiB multipart parts, one part in memory at a time. This cutoff is **not configurable, deliberately**. The old `multipartThreshold` knob was removed because raising it reintroduced unbounded buffering — a memory-safety bound must not be something an integrator can raise away. There is no replacement setting; a 5 GB drive file and a 5 MB one cost the server the same memory. --- # Upload Sources https://useupup.com/docs/guides/sources/ upup collects files from nine sources. One prop — `sources` — decides which of them appear in the panel, and each one is a self-contained view inside the same fixed-height uploader. ## The `sources` prop `sources` is an array of source ids. It drives the chip grid on the idle panel: every id you list gets a chip, in the order below (the registry order, not the order you pass), and nothing else is rendered. | Id | Chip label | What it opens | | ------------- | -------------- | ------------------------------------- | | `local` | My Device | The OS file picker (no in-panel view) | | `googleDrive` | Google Drive | Google Drive browser | | `oneDrive` | OneDrive | OneDrive browser | | `dropbox` | Dropbox | Dropbox browser | | `box` | Box | Box browser | | `url` | Link | URL import form | | `camera` | Camera | Live webcam capture | | `microphone` | Audio | Microphone recorder | | `screen` | Screen Capture | Screen recorder | When `sources` is omitted the default set is the five that need no OAuth credentials: `local`, `url`, `camera`, `microphone`, `screen`. Cloud drives are opt-in because they also need a `cloudDrives` config (client mode) or a configured server (server mode). ```tsx ``` Ids are normalized against the known set and anything unrecognized is dropped silently — a typo removes a chip rather than throwing. Clicking **My Device** opens the OS picker immediately. Every other chip swaps the panel to that source's view, which carries a header with the source name and a **Back** button that returns to the chip grid. ### Chip density The chip grid is capped at a fixed width, so it fits four regular chips per row. Up to **8** sources the chips render at the regular size (rows of 4). At **9** sources — the full set — they switch to the compact size, which fits five per row. Nothing else changes: same chips, same labels, same order. In `mini` mode the chip grid is not rendered at all; the panel shows a single browse button. ## Local files `local` covers four ways to get files off the machine: the browse button, the file picker behind the My Device chip, drag-and-drop, and clipboard paste. ### Browse The idle panel's **browse files** link and the My Device chip both open the same hidden file input. It carries your `allowedFileTypes` as its `accept` attribute and is `multiple` whenever `maxFiles` is greater than 1. ### Drag and drop Drag-and-drop is on by default; `disableDragDrop` turns it off while leaving browse and the chips working. Dropping is also suppressed automatically while a source view is open or while an upload is in flight. One special case: dropping files onto an **open cloud-drive picker** is rejected with a toast rather than silently ignored — that view browses a remote drive and cannot accept OS drops. ### Paste Clipboard paste is off by default. Set `enablePaste` to accept files pasted with Ctrl+V / Cmd+V: ```tsx ``` The listener sits on the uploader panel, so the paste has to land in the panel (it or something inside it must have focus) — there is no document-wide listener. Clipboard images usually arrive nameless or as `image.png`; those are renamed to `pasted-.` so a batch of screenshots doesn't collapse into one repeated filename. A file that already has a real name keeps it. Paste has its own switch: `disableDragDrop` does not disable it. ### Folders Folder upload is configured with `folderUpload`, and both of its flags default to `false`: ```tsx ``` `allowDrop` lets a dropped folder be traversed recursively — every file inside is collected, and each one is tagged with its `relativePath` inside the dropped folder. With `allowDrop` left off, a dropped folder is skipped: the uploader raises a warning through `onWarn` and emits `folder-drop-blocked` with the number of loose files it still accepted from the same drop. If the drop was folders only, nothing is added. `showSelectFolderButton` adds a **select a folder** action next to the browse link. It uses the browser's directory picker where available and falls back to a `webkitdirectory` file input elsewhere; either way the selected files carry their `relativePath`. ## Camera The `camera` view mounts a live webcam preview via `getUserMedia`. The browser owns the permission prompt — upup does not pre-check it. The flow is capture-then-confirm: **Capture** freezes a still from the stream and shows it as a preview with a small delete button to retake, and **Add image** turns that still into a `File` and returns you to the chip grid. Captured stills get a generated filename (the data URL carries no name), with the extension derived from the image MIME type. A **switch camera** button flips between the environment- and user-facing cameras; the view starts on the environment camera. Permission behavior, as implemented: the camera view has **no in-panel error state**. If the user denies access, the preview area simply stays empty and Capture produces nothing — no capture, no event, no message. If you need to surface a denial to the user, do it outside the uploader. ## Microphone The `microphone` view records audio with `getUserMedia` plus `MediaRecorder`. Idle shows a mic button; recording shows a live waveform rendered from the stream, a pulsing indicator, and an `m:ss` timer. **Stop Recording** ends the capture and gives you a standard audio player to review the take, plus **Discard** (throw it away, return to idle) and **Add Recording** (add it as `recording-.webm`, or `.ogg` where WebM is unavailable, and return to the chip grid). If permission is denied or no device is available, the view is replaced by the message _"Microphone access denied. Please allow microphone access and try again."_ There is no retry button here — leave the view with **Back** and re-enter it to try again. ## Screen capture The `screen` view records the screen with `getDisplayMedia`, requesting video and audio. The browser's own picker chooses which screen, window, or tab is shared — upup cannot preselect it. While recording, the panel shows a live preview of the shared surface with a **REC** chip and timer over it. Stopping works two ways: the **Stop Recording** button, or the browser's own "stop sharing" control — ending the track from outside stops the recording too, so the two never drift apart. Then it's the same review step as audio: play it back, **Discard**, or **Add Recording** to add `screen-recording-.webm`. If the user cancels the share dialog or denies the permission, the view is replaced by _"Screen sharing was cancelled or denied. Please try again."_ with a **Try Again** button that reopens the picker. Recorded video and the live preview are sized to fit the panel rather than stretch it — the uploader panel is a fixed-height container, so tall or wide media is letterboxed, never clipped. ## Import from a URL The `url` view takes a URL and fetches it **from the browser**, with the page's own origin. It is a plain `fetch()` — there is no server-side proxy, and this is identical in client mode and server mode. That makes CORS the deciding factor: **the remote server must allow the browser to read the response**. A URL that opens fine in a browser tab will still fail here unless its server sends permissive CORS headers. A cross-origin host that doesn't opt in is not importable this way; proxy it through your own backend and hand the uploader a same-origin URL instead. The filename is derived in order of preference: the response's `Content-Disposition` filename, then the last path segment of the URL, then a generated UUID with an extension inferred from the content type. A non-2xx response surfaces as an error message through `onError` (`Failed to fetch URL: `). ## Cloud drives Google Drive, OneDrive, Dropbox, and Box each render a file browser with sign-in, folder navigation, multi-select, and search where the provider supports it. Which credentials they need — and whether OAuth tokens live in the browser or on your server — depends entirely on the upload mode, so their setup lives with the mode docs rather than here: start with [Client Mode vs Server Mode](/docs/guides/modes/), then [Server Mode Setup](/docs/guides/server-mode-setup/) if you are proxying through `@upupjs/server`. ## Source events Beyond the callback props, each source emits events on the core event bus: | Event | Payload | Fired when | | --------------------- | ------------------- | ----------------------------------------------------- | | `source-click` | `{ sourceId }` | A chip is clicked | | `source-view-cancel` | `{ sourceId }` | A source view is closed with Back | | `browse-files` | — | The browse link opens the file picker | | `folder-select` | `{ count }` | A folder is picked (count is 0 on the input fallback) | | `drag-over` | — | A drag enters the panel | | `drag-leave` | — | A drag leaves the panel | | `drop` | `{ files }` | Files are dropped | | `folder-drop-blocked` | `{ acceptedFiles }` | A folder was dropped with `allowDrop` off | | `paste` | `{ files }` | Files are pasted | | `camera-capture` | `{ dataUrl }` | A still is captured | | `camera-confirm` | `{ file }` | The captured still is added | | `url-submit` | `{ url }` | The URL form is submitted | | `url-fetch` | `{ file }` | A URL fetch succeeds | | `url-fetch-cancel` | `{ url }` | An in-flight URL fetch is aborted | Two notes on the URL events. `url-fetch` also fires when a camera still is confirmed — the camera reuses the same fetch helper to turn its data URL into a `File`. And `url-fetch-cancel` fires on abort, which today happens when the view unmounts mid-fetch (leaving via Back), not from a user-facing cancel button. The microphone and screen-capture views emit no source-specific events; their recordings surface through the normal file-added path. These fire on the uploader's internal core, and the `UpupUploader` component does not currently expose it — the ref surface is `useUpload()` only. Subscribe with `core.on(...)` where you own the core: React's `useUpupUpload()` headless hook returns both `on` and `core`, and the Vanilla `createUploader()` handle does the same. See [Headless Usage](/docs/guides/headless/). ## Next steps - [Client Mode vs Server Mode](/docs/guides/modes/) — where uploads and drive API calls actually run. - [Theming](/docs/guides/theming/) — restyle the chip grid and each source view through tokens and slots. - [Headless Usage](/docs/guides/headless/) — drive the sources yourself and subscribe to the events above. --- # Storage Providers https://useupup.com/docs/guides/storage-providers/ upup uploads to any **S3-compatible** object store. One uploader UI, one config shape — the only thing that changes between AWS S3, Cloudflare R2, MinIO, Backblaze B2, and the rest is the `storage` block you hand the server (or the presign endpoint you point the client at). There are two ways to wire storage. In **client mode** the browser uploads bytes directly to storage using short-lived URLs your own presign endpoint signs — the storage credentials never leave your server, and upup never sees them. In **server mode** the browser talks only to your server and [`@upupjs/server`](/docs/guides/server-mode-setup/) holds the credentials and writes to storage for you. Which one to pick, and what changes on the wire, is covered in [Client Mode vs Server Mode](/docs/guides/modes/). This page focuses on server mode, where the storage provider is a `createUpupHandler` config value. Cloud-drive sources land in that same storage: after the user consents, the server exchanges the OAuth token and streams the picked file straight into your bucket — the bytes never pass through the browser. ## Pick your provider Each guide below carries the full setup for one provider: the `storage` block, where to create the bucket and keys in that provider's console, the CORS step, and the mistakes that provider specifically invites. - [Amazon S3](/docs/guides/storage/aws-s3/) — the native AWS path, including IAM roles instead of static keys. - [Cloudflare R2](/docs/guides/storage/cloudflare-r2/) — account-scoped endpoint, `region: 'auto'`, zero egress fees. - [Backblaze B2](/docs/guides/storage/backblaze-b2/) — application keys and the region baked into the endpoint host. - [DigitalOcean Spaces](/docs/guides/storage/digitalocean-spaces/) — Spaces keys and the datacenter-scoped endpoint. - [MinIO](/docs/guides/storage/minio/) — self-hosted S3, plus the local Docker setup upup's own e2e suite uses. - [Azure Blob Storage](/docs/guides/storage/azure-blob/) — **not** S3-compatible; `createUpupHandler` rejects it, so use the client-mode SAS path. - [Other S3-compatible services](/docs/guides/storage/s3-compatible/) — Wasabi, Google Cloud Storage, Supabase, Hetzner, Scaleway, Storj, and every other store that speaks the S3 API, including ones upup has never heard of. ## Supported providers `@upupjs/core` exports a `StorageProvider` enum with the values below. Every one except `azure` exposes an S3-compatible API and is served by `@upupjs/server`; `azure` is the sole exception. | `storage.type` | Service | S3 endpoint pattern | Notes | | -------------- | --------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | | `aws` | Amazon S3 | none — omit `endpoint` | The only provider that needs no endpoint. [Guide](/docs/guides/storage/aws-s3/) | | `r2` | Cloudflare R2 | `https://.r2.cloudflarestorage.com` | `region` is the literal `auto`. [Guide](/docs/guides/storage/cloudflare-r2/) | | `minio` | MinIO | your own server URL | Self-hosted; requires path-style. [Guide](/docs/guides/storage/minio/) | | `backblaze` | Backblaze B2 | `https://s3..backblazeb2.com` | Region looks like `us-west-004`. [Guide](/docs/guides/storage/backblaze-b2/) | | `digitalocean` | DigitalOcean Spaces | `https://.digitaloceanspaces.com` | Region is the datacenter, e.g. `nyc3`. [Guide](/docs/guides/storage/digitalocean-spaces/) | | `wasabi` | Wasabi | `https://s3..wasabisys.com` | [Guide](/docs/guides/storage/s3-compatible/) | | `gcs` | Google Cloud Storage (S3 interop) | `https://storage.googleapis.com` | One global host; needs HMAC keys. [Guide](/docs/guides/storage/s3-compatible/) | | `supabase` | Supabase Storage | `https://.storage.supabase.co/storage/v1/s3` | Path ends at `/s3`. [Guide](/docs/guides/storage/s3-compatible/) | | `hetzner` | Hetzner Object Storage | `https://.your-objectstorage.com` | Location is `fsn1`, `nbg1`, or `hel1`. [Guide](/docs/guides/storage/s3-compatible/) | | `scaleway` | Scaleway Object Storage | `https://s3..scw.cloud` | Regions incl. `fr-par`, `nl-ams`. [Guide](/docs/guides/storage/s3-compatible/) | | `linode` | Akamai (Linode) Object Storage | `https://.linodeobjects.com` | Host format changed in 2025 — copy it from the dashboard. | | `vultr` | Vultr Object Storage | `https://.vultrobjects.com` | Region is the datacenter, e.g. `ewr1`. | | `upcloud` | UpCloud Object Storage | `https://.upcloudobjects.com` | Endpoint is per-instance — copy it from the console. | | `ovhcloud` | OVHcloud Object Storage | `https://s3..io.cloud.ovh.net` | Regions incl. `gra`, `rbx`, `sgp`. | | `alibaba` | Alibaba Cloud OSS | `https://oss-.aliyuncs.com` | OSS host, no `s3.` prefix. Rejects path-style. | | `oracle` | Oracle Cloud Object Storage | `https://.compat.objectstorage..oci.customer-oci.com` | Sign with a Customer Secret Key, not your native OCI key. | | `contabo` | Contabo Object Storage | `https://.contabostorage.com` | Regions incl. `eu2`, `usc1`, `sin1`. Ceph-based, partial S3 coverage. | | `storj` | Storj | `https://gateway.storjshare.io` | One global gateway; `region` is `global-1` on new projects. | | `idrive` | IDrive e2 | `https://s3..idrivee2.com` | Host is provisioned per account — copy it from the dashboard. | | `ceph` | Ceph (RADOS Gateway) | your own RADOS Gateway URL | Self-hosted. | | `azure` | Azure Blob Storage | — (no S3 API) | Not servable — throws at construct time. [Guide](/docs/guides/storage/azure-blob/) | Placeholders in angle brackets come from your provider's console. Where a pattern is marked "copy it from the dashboard", the host is provisioned per account or per instance and cannot be derived from your region — read the exact value off the console rather than assembling it. The `type` value is a label. `@upupjs/server` builds the same AWS-SDK S3 client for every S3-compatible value and reaches your backend through `endpoint` — it does not branch on `type`. Because `storage.type` also accepts any string, a store that isn't in the enum works too, as long as it speaks the S3 API: use the [generic recipe](/docs/guides/storage/s3-compatible/). `azure` is the one value with no S3 surface. `createUpupHandler` throws an `UpupConfigError` at construct time if you pass it, rather than failing later at request time. See [Azure Blob Storage](/docs/guides/storage/azure-blob/). ## The `storage` config Every provider guide fills in the same object: | Field | Type | Required | Notes | | ----------------- | --------------------------- | --------------- | ----------------------------------------------------------------------------------------- | | `type` | `StorageProvider \| string` | Yes | Provider label from the table above. Also accepts any string for stores not listed. | | `bucket` | `string` | Yes | The bucket (or "Space" / "container") name. | | `region` | `string` | Yes | Must match the bucket's region. Use `auto` for Cloudflare R2. | | `accessKeyId` | `string` | Both or neither | Omit both to use the host's IAM role / instance profile (AWS). A half-set pair throws. | | `secretAccessKey` | `string` | Both or neither | Pairs with `accessKeyId`. | | `endpoint` | `string` | Non-AWS only | The provider's S3 endpoint URL. Omit for native AWS S3. | | `forcePathStyle` | `boolean` | Optional | Defaults to `true` when `endpoint` is set (MinIO requires it); ignored for native AWS S3. | `bucket`, `region`, and — when set — a complete `accessKeyId`/`secretAccessKey` pair are validated at construct time, so a forgotten env var fails loudly on boot instead of surfacing as a confusing 500 later. The [AWS S3 guide](/docs/guides/storage/aws-s3/) shows the complete `createUpupHandler` call; every other guide shows only the `storage` block that changes, since `uploadTokenSecret` and the rest of the handler are identical. See the [Server Mode setup guide](/docs/guides/server-mode-setup/) for the full walkthrough (`getUserId`, `providers`, `tokenStore`, and tuning). Placeholders in every guide are environment variables. Access keys and secrets belong in server-side environment variables, never in a file you commit and never in anything the browser can fetch. In client mode the browser sees only the short-lived signed URL your endpoint returns. ## Troubleshooting **`SignatureDoesNotMatch` / 403 on upload.** Almost always a wrong `region` or a skewed server clock. Make `region` match the bucket's actual region (use `auto` for R2), and confirm the server clock is accurate — S3 signatures are time-sensitive and tolerate only a few minutes of drift. A half-set credential pair (one of `accessKeyId`/`secretAccessKey` blank, the classic `process.env.X!` → `""` bug) throws at construct time; set both or neither. **Path-style vs virtual-hosted addressing.** upup enables path-style automatically whenever `endpoint` is set, which is what MinIO and most S3-compatible stores expect. If your provider only supports virtual-hosted-style and you see hostname or TLS errors, set `forcePathStyle: false`. This flag is ignored for native AWS S3. **Bucket or endpoint errors (`NoSuchBucket`, DNS/TLS failures).** Check that `bucket` exists in that `region`, and that the region segment inside `endpoint` matches `region`. **CORS (client mode only).** When the browser uploads directly to storage, the bucket must allow your app's origin for the signed `PUT` (and its headers). In server mode the browser only talks to your server, so bucket CORS isn't part of the upload path. See [Credentials And CORS](/docs/credentials-configuration/). ## Next steps - [Server Mode — Setup](/docs/guides/server-mode-setup/) — the full `createUpupHandler` walkthrough: OAuth, token store, tuning, and re-auth. - [Quickstarts](/docs/quickstarts/react/) — copy-paste starting points for React, Vue, Svelte, Angular, Vanilla JS, Preact, and Next.js. --- # Upload files to Amazon S3 https://useupup.com/docs/guides/storage/aws-s3/ Amazon S3 is the reference implementation of the API every other provider on this site imitates, and it is the only one upup needs no `endpoint` for. Point `storage.type` at `aws`, give it a bucket and a region, and the AWS SDK resolves the host itself. This page covers server mode, where [`@upupjs/server`](/docs/guides/server-mode-setup/) holds the credentials. In client mode your own endpoint signs the URLs and the browser `PUT`s straight to the bucket — see [Client Mode vs Server Mode](/docs/guides/modes/) for the split, and [S3 Presign Responses](/docs/api-reference/s3-generate-presigned-url/) for the response shape a client-mode endpoint must return. ## The config Native AWS needs no `endpoint`. This is the complete handler; every other provider guide changes only the `storage` block. ```ts // app/api/upup/[...route]/route.ts import { createUpupHandler } from '@upupjs/server' const handler = createUpupHandler({ storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, // e.g. 'us-east-1' accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, // Omit both keys to use the host's IAM role instead. }, // Required, server-only: a stable, high-entropy secret (min 16 chars), // shared across every server instance. createUpupHandler throws without it. uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!, // getUserId, providers, tokenStore — see the Server Mode setup guide. }) ``` `forcePathStyle` is ignored here: it only applies when `endpoint` is set, and native AWS S3 uses virtual-hosted-style addressing. ## Console setup 1. **Create the bucket.** S3 → Create bucket. Pick the region you will put in `region` and leave Block Public Access on — upup uploads through signed requests, so the bucket never needs to be public. 2. **Create credentials.** Either attach an IAM role to the host that runs your server (preferred on EC2, ECS, and Lambda) or create an IAM user with programmatic access and copy its access key ID and secret. 3. **Attach the policy** below to that role or user. 4. **Add CORS** if — and only if — you use client mode, where the browser talks to the bucket directly. ### IAM policy `@upupjs/server` issues exactly these S3 operations: `PutObject`, `GetObject`, `CreateMultipartUpload`, `UploadPart`, `CompleteMultipartUpload`, `AbortMultipartUpload`, `ListParts`, and `HeadBucket` for the health route. That maps to five IAM actions: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "s3:PutObject", "s3:GetObject", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts" ], "Resource": "arn:aws:s3:::YOUR_BUCKET/*" }, { "Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::YOUR_BUCKET" } ] } ``` The object statement targets `YOUR_BUCKET/*`; the `ListBucket` statement targets the bucket ARN itself with no `/*`. Mixing those two up is the most common reason a policy that "looks right" still returns `AccessDenied`. Drop `s3:GetObject` if your server never reads objects back, and drop the `ListBucket` statement if you do not enable the health route's storage check. ### Omitting the keys Leave both `accessKeyId` and `secretAccessKey` out and the AWS SDK falls back to its normal credential chain — the instance profile on EC2, the task role on ECS, the execution role on Lambda. That is the safer production setup: nothing to rotate, nothing to leak. upup validates the pair as both-or-neither, so a half-set pair fails at construct time rather than at the first upload. ### CORS (client mode only) ```json [ { "AllowedOrigins": ["https://your-app.com"], "AllowedMethods": ["PUT", "POST", "GET", "HEAD"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3000 } ] ``` `ExposeHeaders: ["ETag"]` is not optional for multipart uploads: the browser must read each part's `ETag` to complete the upload, and without it the final `CompleteMultipartUpload` fails with parts it cannot identify. In server mode none of this applies — the browser only ever talks to your origin. A `SignatureDoesNotMatch` or `PermanentRedirect` on the first upload almost always means `region` does not match the bucket's actual region. S3 signatures are region-scoped and time-scoped, so a wrong region and a clock more than a few minutes off produce the same 403. Check the bucket's region in the console rather than trusting a copied `.env`. ## Costs and lifecycle Abandoned multipart uploads keep their uploaded parts, and those parts are billed as storage even though the object never appears in the bucket. Add an `AbortIncompleteMultipartUpload` lifecycle rule that expires them after a day or two — one rule, and abandoned uploads stop accumulating charges. With [cross-reload resume](/docs/resumable-uploads/#cross-reload-resume) on — which it is by default for server-mode multipart — that rule stops being optional hygiene. Interrupted uploads deliberately keep their parts so a later attempt can continue from them, so nothing else will ever clean up the ones that are never resumed. ## Next steps - [Storage Providers](/docs/guides/storage-providers/) — the provider matrix and the shared `storage` config reference. - [Server Mode — Setup](/docs/guides/server-mode-setup/) — `getUserId`, `providers`, `tokenStore`, and tuning. - [Client Mode vs Server Mode](/docs/guides/modes/) — which one you want and what changes on the wire. - [Other S3-compatible services](/docs/guides/storage/s3-compatible/) — the same config against a non-AWS backend. --- # Upload files to Azure Blob Storage https://useupup.com/docs/guides/storage/azure-blob/ Azure Blob Storage is the one provider in upup's list that server mode cannot serve. It has no S3-compatible API, and `@upupjs/server` speaks only S3 — every upload it performs goes through an `@aws-sdk/client-s3` client. Azure still works with upup, through **client mode**: your endpoint returns a SAS URL shaped as upup's presign contract and the browser uploads straight to the blob. ## Why server mode rejects it `@upupjs/core` exports `azure` as a `StorageProvider` value and also lists it in `NON_S3_STORAGE_PROVIDERS`. `createUpupHandler` checks that set while it is being constructed, so passing `type: 'azure'` throws an `UpupConfigError` at boot — before a single request is served — rather than failing per-request later: ```text [@upupjs/server] storage.type "azure" has no S3-compatible API and cannot be served. upup uploads via the S3 API — use an S3-compatible provider (aws, minio, r2, wasabi, …) and set storage.endpoint for non-AWS backends. ``` This is deliberate. The alternative — accepting the value and failing on the first upload — hides a configuration mistake behind a runtime 500 that looks like a credentials problem. Server mode's guarantees (credentials never reach the browser, drive → storage transfers streamed server-side, one origin for the uploader) are tied to the S3 API. Reaching them with an Azure account means putting an S3-compatible store in front of the upload path and syncing to Azure afterwards, or moving the upload bucket to any provider on the [matrix](/docs/guides/storage-providers/). There is no Azure adapter to enable. ## Client mode with a SAS URL In client mode upup asks your endpoint for a signed URL and then `PUT`s the file to it. Nothing in that flow is S3-specific — the contract is a URL plus the headers to send with it — so an Azure SAS URL fits it exactly. Point the uploader at your endpoint: ```tsx ``` And return a service SAS for the blob, with the headers Azure requires: ```ts // app/api/upload-token/route.ts import { BlobSASPermissions, StorageSharedKeyCredential, generateBlobSASQueryParameters, } from '@azure/storage-blob' const account = process.env.AZURE_STORAGE_ACCOUNT! const container = process.env.AZURE_STORAGE_CONTAINER! const credential = new StorageSharedKeyCredential( account, process.env.AZURE_STORAGE_KEY!, ) export async function POST(req: Request) { const body = await req.json() const key = `uploads/${crypto.randomUUID()}-${body.name}` const expiresIn = 3600 const sas = generateBlobSASQueryParameters( { containerName: container, blobName: key, permissions: BlobSASPermissions.parse('cw'), // create + write expiresOn: new Date(Date.now() + expiresIn * 1000), }, credential, ).toString() return Response.json({ key, uploadUrl: `https://${account}.blob.core.windows.net/${container}/${key}?${sas}`, uploadHeaders: { 'x-ms-blob-type': 'BlockBlob', 'Content-Type': body.type || 'application/octet-stream', }, expiresIn, }) } ``` The exact response shape — every field and which are optional — is documented in [Azure SAS Responses](/docs/api-reference/azure-generate-sas-url/). The S3 equivalent, for comparison, is [S3 Presign Responses](/docs/api-reference/s3-generate-presigned-url/). Azure rejects a `PUT` to a blob URL that does not carry `x-ms-blob-type: BlockBlob`, with a 400 that names no missing header in a way the browser surfaces usefully. It must be returned in `uploadHeaders` so upup sends it — this is the single most common reason an otherwise correct SAS URL fails. ## Storage account setup 1. **Create the container.** Storage account → Containers → `+ Container`. Leave the public access level Private; a SAS grants access per request. 2. **Get a signing credential.** Access keys → key1, for the shared-key approach above. A user delegation key obtained through Entra ID is the stronger option and produces the same SAS URL shape. 3. **Configure CORS.** Storage account → Resource sharing (CORS) → Blob service. Allow your app's origin, the `PUT` method, the headers you send (`x-ms-blob-type`, `Content-Type`), and expose `ETag`. Unlike S3, CORS here is an account-level setting per service, not a per-container one. 4. **Keep the account key server-side.** It signs SAS tokens; the browser must only ever receive the finished URL. Because the client-mode contract is one signed `PUT` per file, uploads have to fit in a single Azure `Put Blob` request. Very large files need Azure's staged block flow (`Put Block` + `Put Block List`), which the presign contract does not model — cap file size with `maxFileSize` accordingly, and check the limit for your account's service version before choosing the number. ## Next steps - [Storage Providers](/docs/guides/storage-providers/) — the provider matrix and the shared `storage` config reference. - [Client Mode vs Server Mode](/docs/guides/modes/) — what changes on the wire. - [Other S3-compatible services](/docs/guides/storage/s3-compatible/) — if you would rather use a store server mode can serve. --- # Upload files to Backblaze B2 https://useupup.com/docs/guides/storage/backblaze-b2/ Backblaze B2 exposes an S3-compatible API alongside its own native one. upup uses the S3 one, so B2 behaves like any other endpoint-configured provider — with one detail that trips almost everybody once: the region is not a name you choose, it is a code like `us-west-004` that appears inside your bucket's endpoint. This page covers server mode, where [`@upupjs/server`](/docs/guides/server-mode-setup/) holds the credentials. See [Client Mode vs Server Mode](/docs/guides/modes/) if you would rather sign URLs yourself and have the browser upload directly. ## The config Only the `storage` block differs from the [AWS S3 example](/docs/guides/storage/aws-s3/) — keep `uploadTokenSecret` and the rest of the handler as they are there. ```ts const storage = { type: 'backblaze', bucket: process.env.B2_BUCKET!, region: process.env.B2_REGION!, // e.g. 'us-west-004' endpoint: `https://s3.${process.env.B2_REGION}.backblazeb2.com`, accessKeyId: process.env.B2_KEY_ID, secretAccessKey: process.env.B2_APPLICATION_KEY, } ``` The region appears twice — once as `region` and once inside the endpoint host — because both must agree for the signature to verify. Deriving the endpoint from the same env var, as above, keeps them from drifting apart. ## Console setup 1. **Create the bucket.** Backblaze console → Buckets → Create a Bucket. Leave it **Private**: upup uploads with signed requests and never needs public write. 2. **Read the endpoint off the bucket.** The bucket's details panel shows an `Endpoint` value such as `s3.us-west-004.backblazeb2.com`. The middle segment is your `region` — copy it rather than guessing from the bucket's advertised location. 3. **Create an application key.** App Keys → Add a New Application Key, scoped to that one bucket, with Read and Write access. Backblaze shows `keyID` and `applicationKey` once. `keyID` is `accessKeyId`; `applicationKey` is `secretAccessKey`. 4. **Add CORS rules** (client mode only) from the bucket's CORS Rules panel. Backblaze's account-level master key is rejected by the S3-compatible API — it works only with B2's native API. If uploads fail to authenticate with what looks like a perfectly good key pair, check that you created a separate application key rather than reusing the master one. Create the key after the bucket, too, so you can scope it to that bucket. ### CORS (client mode only) B2's CORS rules live on the bucket, configured in the console (or with the `b2` CLI). Allow your app's origin for `PUT`, `POST`, `GET`, and `HEAD`, and expose the `ETag` response header — the browser needs to read each part's `ETag` to complete a multipart upload. In server mode the browser only talks to your server, so no CORS rule is needed at all. See [Credentials And CORS](/docs/credentials-configuration/). ## Versions and abandoned uploads B2 keeps every version of a file. Uploading to a key that already exists does not replace the old object, it hides it behind a newer version, and both keep costing storage until a lifecycle rule removes the old ones. If your app lets users overwrite files, set a lifecycle rule on the bucket ("keep only the last version" is the usual choice) rather than assuming an overwrite frees space. Cancelled multipart uploads leave unfinished large files behind, and their parts are billed. B2 can clean those up on a schedule — worth enabling on any bucket that accepts uploads from a UI where users can close the tab mid-upload. ## Next steps - [Storage Providers](/docs/guides/storage-providers/) — the provider matrix and the shared `storage` config reference. - [Server Mode — Setup](/docs/guides/server-mode-setup/) — `getUserId`, `providers`, `tokenStore`, and tuning. - [Client Mode vs Server Mode](/docs/guides/modes/) — which one you want and what changes on the wire. --- # Upload files to Cloudflare R2 https://useupup.com/docs/guides/storage/cloudflare-r2/ Cloudflare R2 speaks the S3 API with zero egress fees, which makes it a common landing spot for user uploads that later get served back to those same users. For upup it is an ordinary S3-compatible backend with two quirks worth knowing up front: the endpoint is scoped to your account ID, and the region is the literal string `auto`. This page covers server mode, where [`@upupjs/server`](/docs/guides/server-mode-setup/) holds the credentials. In client mode your own endpoint signs the URLs and the browser `PUT`s straight to R2 — see [Client Mode vs Server Mode](/docs/guides/modes/) for the split. ## The config Only the `storage` block differs from the [AWS S3 example](/docs/guides/storage/aws-s3/) — keep `uploadTokenSecret` and the rest of the handler as they are there. ```ts const storage = { type: 'r2', bucket: process.env.R2_BUCKET!, region: 'auto', endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, accessKeyId: process.env.R2_ACCESS_KEY_ID, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY, } ``` `region: 'auto'` is not a placeholder to fill in. R2 has no per-region S3 hostnames — a bucket's location hint (Western North America, Europe, and so on) affects where data lives, not how you sign for it, and the S3 API expects `auto` regardless. Passing a real AWS region here produces a signature error. ## Console setup 1. **Create the bucket.** Cloudflare dashboard → R2 → Create bucket. A location hint is optional; it does not change the config above. 2. **Copy your account ID.** It is on the R2 overview page and is the first label in the endpoint hostname. 3. **Create an API token.** R2 → Manage R2 API Tokens → Create API token, with **Object Read & Write** permission. Scope it to the one bucket rather than the whole account. Cloudflare shows the access key ID, the secret access key, and the endpoint once — the secret is not retrievable later. 4. **Add a CORS policy** (client mode only) under the bucket's Settings tab. ### CORS (client mode only) ```json [ { "AllowedOrigins": ["https://your-app.com"], "AllowedMethods": ["PUT", "POST", "GET", "HEAD"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3000 } ] ``` In server mode the browser never contacts R2, so the bucket needs no CORS policy at all. See [Credentials And CORS](/docs/credentials-configuration/). R2 requires every part of a multipart upload except the last to be exactly the same size — S3 itself only enforces a 5 MiB minimum. upup's multipart strategy already slices files into uniform parts (5 MiB by default), so the default path is safe. If you override `resumable.chunkSizeBytes`, keep it a single fixed value rather than computing a per-file or per-part size. ## Serving the files back R2 buckets are private by default and the config above never makes them public. Two supported ways to serve an uploaded object: attach a custom domain to the bucket (the production answer, and it puts Cloudflare's cache in front of your files), or enable the `r2.dev` development subdomain, which is rate-limited and explicitly not meant for production traffic. Either is a Cloudflare-side setting and has no effect on the upload path. R2 does not implement object ACLs. Anything that would be a per-object `public-read` on S3 is a bucket-level decision here instead. ## Next steps - [Storage Providers](/docs/guides/storage-providers/) — the provider matrix and the shared `storage` config reference. - [Server Mode — Setup](/docs/guides/server-mode-setup/) — `getUserId`, `providers`, `tokenStore`, and tuning. - [Client Mode vs Server Mode](/docs/guides/modes/) — which one you want and what changes on the wire. --- # Upload files to DigitalOcean Spaces https://useupup.com/docs/guides/storage/digitalocean-spaces/ DigitalOcean Spaces is S3-compatible object storage where the datacenter region is the whole configuration story: `nyc3`, `sfo3`, `ams3`, `fra1`, `sgp1`, and friends are simultaneously the `region` value and the endpoint hostname. Get those two to agree and Spaces behaves exactly like S3. This page covers server mode, where [`@upupjs/server`](/docs/guides/server-mode-setup/) holds the credentials. See [Client Mode vs Server Mode](/docs/guides/modes/) if you would rather sign URLs yourself and have the browser upload directly. ## The config Only the `storage` block differs from the [AWS S3 example](/docs/guides/storage/aws-s3/) — keep `uploadTokenSecret` and the rest of the handler as they are there. ```ts const storage = { type: 'digitalocean', bucket: process.env.SPACES_BUCKET!, // the Space name region: process.env.SPACES_REGION!, // e.g. 'nyc3' endpoint: `https://${process.env.SPACES_REGION}.digitaloceanspaces.com`, accessKeyId: process.env.SPACES_KEY, secretAccessKey: process.env.SPACES_SECRET, } ``` `bucket` is the Space name — DigitalOcean's UI says "Space", the S3 API says "bucket", and they are the same thing. The endpoint host is the **region**, not the Space: `https://nyc3.digitaloceanspaces.com`, never `https://my-space.nyc3.digitaloceanspaces.com`. upup turns on path-style addressing whenever `endpoint` is set, so the Space name is added to the path for you; putting it in the hostname as well produces requests aimed at a Space inside a Space. ## Console setup 1. **Create the Space.** DigitalOcean control panel → Spaces Object Storage → Create a Space, and pick the datacenter region. Set File Listing to Restricted unless you specifically want the contents publicly enumerable. 2. **Generate Spaces keys.** API → Spaces Keys → Generate New Key. The secret is shown once. Newer accounts can scope a key to a single Space; scope it if you can. 3. **Add a CORS configuration** (client mode only) from the Space's Settings tab. Spaces access keys and DigitalOcean personal access tokens are different credentials issued from different pages. The API token that works with `doctl` and the DigitalOcean API will not authenticate an S3 request — if uploads come back as 403 with credentials you are sure are correct, check that they came from the Spaces Keys page. ### CORS (client mode only) Add an origin under the Space's Settings → CORS Configurations, allow `PUT`, `POST`, `GET`, and `HEAD`, and add `ETag` to the exposed headers so the browser can complete multipart uploads. In server mode the browser never contacts Spaces, so no CORS entry is needed. See [Credentials And CORS](/docs/credentials-configuration/). ## Serving files back through the CDN Each Space can enable a CDN endpoint on a hostname of the form `..cdn.digitaloceanspaces.com`. That is a read path only — keep uploading to the origin endpoint in the config above, and use the CDN hostname when you build URLs for users. Uploading through the CDN hostname is not supported. Objects are private unless you make them public, and the upload path above never changes an object's visibility. ## Next steps - [Storage Providers](/docs/guides/storage-providers/) — the provider matrix and the shared `storage` config reference. - [Server Mode — Setup](/docs/guides/server-mode-setup/) — `getUserId`, `providers`, `tokenStore`, and tuning. - [Client Mode vs Server Mode](/docs/guides/modes/) — which one you want and what changes on the wire. --- # Upload files to MinIO https://useupup.com/docs/guides/storage/minio/ MinIO is self-hosted S3-compatible storage, which makes it the fastest way to develop against real object storage without a cloud account — upup's own end-to-end suite uploads to a local MinIO container. Everything you configure here transfers to a hosted provider later: only `endpoint`, `region`, and the credentials change. This page covers server mode, where [`@upupjs/server`](/docs/guides/server-mode-setup/) holds the credentials. See [Client Mode vs Server Mode](/docs/guides/modes/) if you would rather sign URLs yourself and have the browser upload directly. ## The config Only the `storage` block differs from the [AWS S3 example](/docs/guides/storage/aws-s3/) — keep `uploadTokenSecret` and the rest of the handler as they are there. ```ts const storage = { type: 'minio', bucket: process.env.MINIO_BUCKET!, region: 'us-east-1', // MinIO's default region endpoint: 'http://localhost:9100', // your MinIO S3 endpoint forcePathStyle: true, // required by MinIO accessKeyId: process.env.MINIO_ACCESS_KEY, secretAccessKey: process.env.MINIO_SECRET_KEY, } ``` MinIO requires path-style addressing — requests go to `endpoint/bucket/key`, not `bucket.endpoint/key`. upup already defaults `forcePathStyle` to `true` whenever `endpoint` is set, so the line above is explicit rather than necessary. `region` is `us-east-1` unless you deliberately configured MinIO otherwise; it still has to match, because the signature covers it. ## Running MinIO locally ```bash docker run -p 9100:9000 -p 9101:9001 \ -e MINIO_ROOT_USER=upupadmin \ -e MINIO_ROOT_PASSWORD=upupadmin123 \ -e MINIO_API_CORS_ALLOW_ORIGIN='*' \ -v minio-data:/data \ minio/minio server /data --console-address ':9001' ``` MinIO listens on two ports and they are not interchangeable. The **S3 API** is on container port 9000 (published as 9100 above) and is what `endpoint` must point at; the **web console** is on 9001 (published as 9101) and is for humans. Aiming `endpoint` at the console port produces confusing HTML-instead-of-XML errors. Pick published ports deliberately. 9000 and 9001 are MinIO's defaults, so a container from another project may already own them — publishing on 9100/9101, as above, avoids uploading into somebody else's bucket. Create the bucket either from the console at `http://localhost:9101` or with the MinIO client: ```bash mc alias set local http://localhost:9100 upupadmin upupadmin123 mc mb local/upup-uploads ``` In client mode the presigned URL your server signs is opened by the **browser**, so `endpoint` has to be a host the browser can resolve. `http://localhost:9100` works on your laptop and fails from any other device, from a container on a different network, and from a phone on the same Wi-Fi. Use the machine's LAN address or a hostname both sides agree on. In server mode only your server connects to MinIO, so an internal Docker network name such as `http://minio:9000` is fine. ### CORS (client mode only) MinIO takes its CORS policy from the server process, not from per-bucket rules: set `MINIO_API_CORS_ALLOW_ORIGIN` to a comma-separated list of origins (`*` is fine locally, as in the container above; name your real origins in production). In server mode the browser only talks to your server, so CORS never enters the upload path. See [Credentials And CORS](/docs/credentials-configuration/). ## Going to production The root user is the equivalent of an account owner. For anything beyond local development, create a dedicated MinIO user with a policy limited to the upload bucket and use that key pair, terminate the endpoint over HTTPS, and keep the console port off the public internet. Because MinIO is the S3 API, moving to a hosted provider later is a change of three config values and nothing else — see [Other S3-compatible services](/docs/guides/storage/s3-compatible/). ## Next steps - [Storage Providers](/docs/guides/storage-providers/) — the provider matrix and the shared `storage` config reference. - [Server Mode — Setup](/docs/guides/server-mode-setup/) — `getUserId`, `providers`, `tokenStore`, and tuning. - [Client Mode vs Server Mode](/docs/guides/modes/) — which one you want and what changes on the wire. --- # Upload files to any S3-compatible storage https://useupup.com/docs/guides/storage/s3-compatible/ `@upupjs/server` builds the same AWS-SDK S3 client for every provider and reaches your backend through `endpoint`. The S3 client itself never reads `storage.type` — the only branch on it is a construct-time guard that rejects providers with no S3 surface (currently just `azure`, which has [its own path](/docs/guides/storage/azure-blob/)). Any other value passes through, so a store upup has never heard of works exactly as well as one in the enum, provided it speaks the S3 API. This page collects the providers that need no page of their own, plus the template for everything else. The dedicated guides are [AWS S3](/docs/guides/storage/aws-s3/), [Cloudflare R2](/docs/guides/storage/cloudflare-r2/), [Backblaze B2](/docs/guides/storage/backblaze-b2/), [DigitalOcean Spaces](/docs/guides/storage/digitalocean-spaces/), and [MinIO](/docs/guides/storage/minio/). Each block below is only the `storage` config — keep `uploadTokenSecret` and the rest of the handler from the [AWS S3 example](/docs/guides/storage/aws-s3/), and see [Server Mode — Setup](/docs/guides/server-mode-setup/) for the full walkthrough. Placeholders are environment variables: never hard-code an access key or a secret. ## The generic recipe Ask your provider for four things: - **Endpoint** — the S3 API URL (`https://…`). - **Region** — the region string. Some providers use `us-east-1` or `auto`. - **Path-style vs virtual-hosted** — whether the endpoint expects `endpoint/bucket/key` (path-style) or `bucket.endpoint/key` (virtual-hosted). - **Access key ID + secret access key.** ```ts const storage = { type: 'my-s3-store', // any StorageProvider value, or your own label string bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, // ask your provider endpoint: process.env.S3_ENDPOINT!, // the provider's S3 API URL // Defaults to true when `endpoint` is set. Set false only if your provider // requires virtual-hosted-style addressing. forcePathStyle: true, accessKeyId: process.env.S3_ACCESS_KEY_ID, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, } ``` That is the whole integration. If uploads authenticate, everything else upup does — multipart, retries, cloud-drive transfers into the same bucket — works without further configuration. ## Wasabi Wasabi's endpoint host is region-scoped (e.g. `s3.us-east-1.wasabisys.com`). Create the bucket in the console, then generate an access key under Access Keys; the secret is shown once. ```ts const storage = { type: 'wasabi', bucket: process.env.WASABI_BUCKET!, region: process.env.WASABI_REGION!, // e.g. 'us-east-1' endpoint: `https://s3.${process.env.WASABI_REGION}.wasabisys.com`, accessKeyId: process.env.WASABI_ACCESS_KEY, secretAccessKey: process.env.WASABI_SECRET_KEY, } ``` Wasabi bills stored objects for a minimum retention period, so a workload of short-lived uploads costs more than the per-GB price suggests. That is a pricing consideration, not an upload one. ## Google Cloud Storage GCS speaks S3 through its interoperability (XML) API on one global host. It needs **HMAC keys** — created under the project's Interoperability settings — not a service-account JSON key. GCS ignores `region`, so `auto` is a convention here rather than a requirement. ```ts const storage = { type: 'gcs', bucket: process.env.GCS_BUCKET!, region: 'auto', // ignored by GCS endpoint: 'https://storage.googleapis.com', accessKeyId: process.env.GCS_HMAC_ACCESS_KEY, // HMAC, not a JSON key secretAccessKey: process.env.GCS_HMAC_SECRET, } ``` S3 multipart uploads work, but GCS never expires an abandoned one on its own — add a lifecycle rule so cancelled uploads don't accumulate as billable fragments. ## Supabase Storage The endpoint is your project's S3 connection URL and ends at `/s3` — do not append the bucket to it. Region and credentials both come from the dashboard's Storage → S3 connection settings; the access keys are separate from your `anon`/`service_role` API keys. ```ts const storage = { type: 'supabase', bucket: process.env.SUPABASE_BUCKET!, region: process.env.SUPABASE_REGION!, // shown in the S3 connection settings endpoint: `https://${process.env.SUPABASE_PROJECT_REF}.storage.supabase.co/storage/v1/s3`, accessKeyId: process.env.SUPABASE_S3_ACCESS_KEY_ID, secretAccessKey: process.env.SUPABASE_S3_SECRET_ACCESS_KEY, } ``` Supabase Storage has no object versioning — an overwrite or delete is permanent. ## Hetzner Object Storage The location code (`fsn1`, `nbg1`, `hel1`) is both the endpoint host and the `region`. Hetzner addresses buckets as a subdomain, so turn path-style off. ```ts const storage = { type: 'hetzner', bucket: process.env.HETZNER_BUCKET!, region: process.env.HETZNER_LOCATION!, // 'fsn1' | 'nbg1' | 'hel1' endpoint: `https://${process.env.HETZNER_LOCATION}.your-objectstorage.com`, forcePathStyle: false, // Hetzner expects virtual-hosted-style accessKeyId: process.env.HETZNER_ACCESS_KEY, secretAccessKey: process.env.HETZNER_SECRET_KEY, } ``` ## Scaleway Object Storage The region (`fr-par`, `nl-ams`, `pl-waw`, `it-mil`) appears in the endpoint host and in `region`. Confirm the exact endpoint in your Scaleway console — it is printed on the bucket's page. ```ts const storage = { type: 'scaleway', bucket: process.env.SCW_BUCKET!, region: process.env.SCW_REGION!, // e.g. 'fr-par' endpoint: `https://s3.${process.env.SCW_REGION}.scw.cloud`, accessKeyId: process.env.SCW_ACCESS_KEY, secretAccessKey: process.env.SCW_SECRET_KEY, } ``` ## The rest of the enum These take the same four fields as the generic recipe; only `endpoint` differs. The full patterns are in the [provider matrix](/docs/guides/storage-providers/). - **Akamai (Linode) Object Storage** (`linode`) — `https://.linodeobjects.com`. The host format changed in 2025, so copy it from the dashboard rather than assembling it from an older tutorial. - **Vultr Object Storage** (`vultr`) — `https://.vultrobjects.com`, where the region is a datacenter code such as `ewr1`. - **UpCloud Object Storage** (`upcloud`) — `https://.upcloudobjects.com`. The endpoint is per-instance; read it off the console. - **OVHcloud Object Storage** (`ovhcloud`) — `https://s3..io.cloud.ovh.net`, regions including `gra`, `rbx`, `sgp`. - **Alibaba Cloud OSS** (`alibaba`) — `https://oss-.aliyuncs.com`. Note the OSS host has no `s3.` prefix. - **Oracle Cloud Object Storage** (`oracle`) — `https://.compat.objectstorage..oci.customer-oci.com`. - **Contabo Object Storage** (`contabo`) — `https://.contabostorage.com`, regions including `eu2`, `usc1`, `sin1`. Ceph-based, with partial S3 coverage. - **Storj** (`storj`) — `https://gateway.storjshare.io`, one global gateway; `region` is `global-1` on new projects. - **IDrive e2** (`idrive`) — `https://s3..idrivee2.com`. The host is provisioned per account — copy it from the dashboard. - **Ceph / RADOS Gateway** (`ceph`) — your own gateway URL, self-hosted. ## Quirks worth knowing before you debug them - **Alibaba Cloud OSS rejects path-style requests outright.** Since upup turns path-style on by default whenever `endpoint` is set, OSS needs an explicit `forcePathStyle: false`. Vultr and OVHcloud also document virtual-hosted-style addressing. - **Oracle Cloud signs with a Customer Secret Key**, generated separately from the API signing key you use for native OCI calls — the native key will not authenticate against the S3 compatibility endpoint. - **Coverage below the upload path varies.** Storj has no ACLs or lifecycle rules, Contabo (Ceph-based) omits access logging, and UpCloud has no object lock, replication, or website hosting. None of that affects uploading, but it does affect what you can do with the objects afterwards. Every store here implements the operations upup needs — `PutObject`, `GetObject`, the multipart family, and `HeadBucket` for the health route — but compatibility claims cover different amounts of the API beyond that. If a provider's own docs list an unsupported operation, check it against that set before assuming uploads are affected; most gaps are in lifecycle, versioning, and ACLs rather than in the upload path. ## Next steps - [Storage Providers](/docs/guides/storage-providers/) — the provider matrix, the shared `storage` config reference, and troubleshooting. - [Server Mode — Setup](/docs/guides/server-mode-setup/) — `getUserId`, `providers`, `tokenStore`, and tuning. - [Client Mode vs Server Mode](/docs/guides/modes/) — which one you want and what changes on the wire. --- # Theming https://useupup.com/docs/guides/theming/ upup gives you three layers of control, from coarse to surgical: 1. **`mode`** — switch the built-in light / dark / system palette. 2. **`tokens`** — override design tokens (colors, radius, shadow, spacing), surfaced as `--upup-*` CSS variables. 3. **`slots`** — inject your own class names into specific components. All three live on one prop — `theme` — which every framework's uploader accepts. React additionally exports a standalone `UpupThemeProvider` for headless compositions. ## The `theme` prop Pass a `UpupThemeConfig` to ``. In React the uploader wraps its subtree in a theme provider for you — you do not need `UpupThemeProvider` unless you are building your own UI (see [Headless](#headless-upupthemeprovider)). ```tsx import { UpupUploader } from '@upupjs/react' import '@upupjs/react/styles' export default function Uploader() { return ( ) } ``` `UpupThemeConfig` is: ```ts interface UpupThemeConfig { mode?: 'light' | 'dark' | 'system' tokens?: DeepPartial slots?: DeepPartial } ``` Every field is a deep-partial: override only what you want; the rest fall back to the preset for the active mode. ## Mode (light / dark / system) `mode` selects the base palette and is the switch for the built-in look: - `'light'` (default) and `'dark'` are fixed. - `'system'` follows the OS `prefers-color-scheme` and updates live when it changes. It is SSR-safe: it renders light on the server and first client paint (no hydration mismatch), then resolves the real preference after mount. ```tsx ``` ## Tokens Tokens are the design primitives. They are grouped into `color`, `radius`, `shadow`, and `spacing`: ```ts interface UpupThemeTokens { color: { surface: string surfaceAlt: string primary: string primaryHover: string text: string textMuted: string border: string borderActive: string danger: string success: string dragBg: string overlay: string } radius: { sm: string; md: string; lg: string; full: string } shadow: { sm: string; md: string; lg: string } spacing: { xs: string; sm: string; md: string; lg: string } } ``` Your `tokens` are merged onto the active mode preset and emitted as CSS custom properties on the uploader root, named `--upup--` (camelCase keys become kebab-case). For example `color.primaryHover` becomes `--upup-color-primary-hover`, and `radius.md` becomes `--upup-radius-md`. That means you can also read them from your own CSS: ```css .my-wrapper button.custom { background: var(--upup-color-primary); border-radius: var(--upup-radius-md); } ``` Set the light/dark base with `mode`; use `tokens` to tune the palette and to expose those variables to your slot classes and custom CSS. Tokens apply on top of whichever mode resolves — they don't select a mode. To vary a value per mode, drive `mode`/`tokens` from your own state, or write mode-scoped CSS that reads the `--upup-*` variables. ## Slots Slots override the class names on individual pieces of the UI without touching the token system. `theme.slots` is keyed by component, then by the slot within it: ```tsx ``` The class names you provide are appended to the component's own classes. The full set of component keys and their slots is the `UpupThemeSlots` type (exported from `@upupjs/core`); `UpupSlotPath` is the union of every valid `"component.slot"` string. Components include `uploader`, `sourceSelector`, `sourceView`, `fileList`, `filePreview`, `progressBar`, `driveBrowser`, `cameraUploader`, `audioUploader`, `screenCaptureUploader`, `urlUploader`, and `imageEditor`. ## CSS-level overrides via DOM hooks Every rendered piece carries a stable `data-upup-slot` attribute, and the key interactive elements carry a `data-testid`. These strings are part of upup's cross-framework DOM contract — identical across React, Vue, Svelte, Angular, Vanilla, and Preact — so CSS written against them survives framework swaps: ```css /* Target the panel and the source picker by their stable slot hooks. */ [data-upup-slot='uploader-panel'] { box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12); } [data-upup-slot='source-selector'] { gap: 1rem; } [data-upup-slot='progress-bar'] { --upup-color-primary: #16a34a; } ``` Common `data-upup-slot` values include `root`, `uploader-panel`, `header`, `source-selector`, `source-view`, `file-list`, `file-preview`, `progress-bar`, `url-uploader`, `camera-uploader`, and `drive-browser-item`. Provider-specific ones use the kebab wire form, e.g. `google-drive-uploader` and `one-drive-uploader`. Testids like `upup-root`, `upup-dropzone`, `upup-upload-btn`, and `upup-browse-files` are handy for both CSS and tests. > upup's own utility classes are emitted with an `upup-` prefix (its Tailwind > preset is prefixed to avoid colliding with your app's styles). Don't rely on > those internal utility class names — target `data-upup-slot` / > `data-testid` or use `theme.slots` instead. ### The `className` prop `` accepts a single `className` string applied to the root container — useful for outer layout (width, margins). There is **no** `classNames` map prop; per-element class overrides go through `theme.slots`, and `className` is only the root. ```tsx ``` ## Headless: `UpupThemeProvider` If you build your own UI with the headless hook (see [Headless](/docs/guides/headless/)) but still want upup's tokens and dark-mode resolution, wrap your tree in `UpupThemeProvider` (exported from `@upupjs/react`). It resolves the theme, emits the `--upup-*` variables, and sets `data-theme="light|dark"` on its root element: ```tsx import { UpupThemeProvider } from '@upupjs/react' function App() { return ( ) } ``` Its props are exactly `{ theme?: UpupThemeConfig; children }`. ## Framework support The `theme` prop is shared across all frameworks — it is part of the common `UploaderBaseProps`. How you pass it follows each framework's binding syntax: | Framework | How to pass the theme | Standalone provider | | ---------- | ------------------------------------------------- | ---------------------- | | React | `` | `UpupThemeProvider` | | Vue | `` | — (wrapped internally) | | Svelte | `` | — (wrapped internally) | | Angular | `` | — (wrapped internally) | | Vanilla JS | `createUploader('#el', { theme: { ... } })` | — (wrapped internally) | Only React exposes a separate `UpupThemeProvider`; the other frameworks apply the theme entirely through their uploader. The resolved `mode`, `isDark`, token, and slot state are the same in every framework because they come from the shared core theme store. See the per-framework [quickstarts](/docs/quickstarts/react/) for the full mount pattern. Preact re-exports `@upupjs/react`, so it behaves identically to the React row (including `UpupThemeProvider`). ## Next steps - [Headless Usage](/docs/guides/headless/) — build your own UI on the engine and wrap it in `UpupThemeProvider`. - [Localization (i18n)](/docs/localization/) — translate and override the copy in the components you are theming. --- # Write a custom upup plugin https://useupup.com/docs/guides/writing-plugins/ A plugin is the smallest way to teach `@upupjs/core` something new. Cloud drives — Google Drive, OneDrive, Dropbox, Box — are plugins, and the contract they satisfy is the same one open to you: subscribe to the engine's event bus, emit your own events on it, and optionally hand back real `File` objects for the pipeline to upload. If you only want to switch the built-in drives on, you want [Plugins & Extensions](/docs/guides/plugins/) instead. This page is for authors. ## The plugin contract A plugin is a name and one optional lifecycle hook: ```ts interface UpupPlugin { name: string init?(emitter: EventEmitter): void } ``` `name` is the identity used for deduplication; registering two plugins with the same name throws a `UpupConfigError` with code `PLUGIN_ALREADY_REGISTERED`. `init` is **the one lifecycle hook**. It is called once, at registration, and what it receives is core's **event bus** — not the core itself. That is the whole boundary: a plugin listens and emits. There is no `beforeUpload()`, and no teardown callback on `UpupPlugin`. Three consequences worth knowing before you write one: - **A plugin cannot register an extension from `init`.** It never gets a reference to the core, so `core.registerExtension()` is the path — your consumer calls it after `use()`, or you ship a factory that closes over the core. Document the methods you expect to be registered. - **A plugin has no `destroy` hook on the base contract.** `core.destroy()` clears the plugin registry, but it does not call anything on your plugin. (`DrivePlugin` adds a required `destroy()` because the drive controllers call it explicitly.) If you own a timer or a socket, hand your consumer a cleanup handle. - **Payloads arrive untyped inside `init`.** Core hands the plugin its bus through the untyped `EventEmitter` signature, so a handler's payload is `unknown` even for a well-known event. Narrow it yourself — see below. ## A complete minimal plugin An analytics listener, the canonical shape. Note the derived `Emitter` type: the `EventEmitter` class itself is not on the public entry, so deriving the parameter type from `UpupPlugin` keeps you on the public surface with no deep import. ```ts import type { UpupPlugin } from '@upupjs/core' type Emitter = Parameters>[0] type Track = (event: string, props?: Record) => void export function analyticsPlugin(track: Track): UpupPlugin { return { name: 'analytics', init(emitter: Emitter) { emitter.on('upload-start', () => track('upload_started')) emitter.on('upload-success', payload => { // Payloads are `unknown` on the plugin-facing bus — narrow here. const { file } = payload as { file: { name: string; size: number } } track('upload_succeeded', { name: file.name, size: file.size }) }) emitter.on('upload-error', payload => { const { error } = payload as { error: Error } track('upload_failed', { message: error.message }) }) }, } } ``` The full list of engine events you can subscribe to is the [event catalog](/docs/api-reference/events/). The emitter isolates each handler, so a throw inside your plugin cannot abort sibling listeners or escape `emit()`. In development it is logged to the console; it is never re-emitted as an upload failure. Handle your own errors — nobody downstream will see them. ## Registering Your consumer registers the plugin, either imperatively — `use()` returns the core, so it chains: ```ts core.use(analyticsPlugin(track)).use(anotherPlugin()) ``` …or through the `plugins` option, which is part of `CoreOptions` and therefore available on the framework components and the headless hook as well: ```ts const core = new UpupCore({ uploadEndpoint: '/api/upload-token', plugins: [analyticsPlugin(track)], }) ``` Constructor-supplied plugins register in array order, before the first upload. Each registration emits `plugin-registered` with your plugin's name. ## Naming rules Two naming rules the engine enforces socially rather than at the type level, so they are worth getting right the first time. **Emit namespaced event names.** Anything your plugin emits reaches every `core.on(...)` subscriber, so use a prefix you own (`'myplugin:ready'`). Bare event names are the engine's own typed catalog, and adding to it is not supported. In particular, a bare `error` is off limits: upup has exactly one upload-failure event, `upload-error`, and there is deliberately no second channel for it. A drive plugin's failures go out as `:error`. **Provider identity has exactly two forms.** If your plugin represents a provider, pick one word and spell it two ways, never three: | Form | Looks like | Used for | | ---------- | --------------------------- | ----------------------------------------------------- | | camelCase | `googleDrive`, `oneDrive` | config keys, `FileSource` values, i18n keys | | kebab-case | `google-drive`, `one-drive` | plugin ids, event prefixes, server slugs, DOM strings | Single-word providers (`dropbox`, `box`) are identical in both. A bare-concatenated slug like `googledrive` is a retired form in this codebase — do not introduce one. ## Building a cloud-drive provider Cloud drives are plugins that satisfy a richer contract, `DrivePlugin`, which extends `UpupPlugin` with the surface upup's drive controllers drive. Required members: `id`, `init(emitter)`, `destroy()`, `restoreSession()`, `isAuthenticated()`, `getAccessToken()`, `getUserInfo()`, `loadFiles(folderArg?)`, `downloadFiles(files)`, and `signOut()`. Optional, guarded at the call site: `authenticate()`, `authenticateViaPopup()`, `loadMoreFiles(cursor)`, `loadAllFilesInFolder(folderArg)`, and `getConfig()`. `downloadFiles` is where a drive meets the rest of the engine: it returns real `File` objects, which then flow through the normal pipeline and upload path. ### Subclassing `PopupOAuthPlugin` If your provider uses browser popup OAuth2 with PKCE, don't hand-roll it. `PopupOAuthPlugin` is an abstract base — exported as a value from `@upupjs/core` — that owns the whole auth skeleton: the PKCE challenge, opening and polling the popup, the authorization-code exchange, `sessionStorage` persistence, refresh-token exchange, proactive refresh when the access token is within 60 seconds of expiry, and an `apiRequest` helper that retries once on a 401 before declaring the session expired. You supply two things: a `PopupOAuthSpec` (pure data) and your provider's real API calls. ```ts import { PopupOAuthPlugin, type PopupOAuthSpec } from '@upupjs/core' import type { DriveFile, DriveUser } from '@upupjs/core' export class AcmeDrivePlugin extends PopupOAuthPlugin { readonly spec: PopupOAuthSpec = { id: 'acme-drive', displayName: 'Acme Drive', eventPrefix: 'acme-drive', popupName: 'UpupAcmeAuth', authUrl: 'https://acme.example.com/oauth2/authorize', tokenUrl: 'https://api.acme.example.com/oauth2/token', redirectPath: '/acme_redirect', storageKeys: { access: 'upup_acme_access_token', refresh: 'upup_acme_refresh_token', expiry: 'upup_acme_token_expiry', }, scopes: 'files.read files.write profile.read', authParams: { access_type: 'offline' }, } // ── List a folder. `apiRequest` adds auth and handles refresh for you. ── async loadFiles(folderId = 'root') { this.setState('browsing') const res = await this.apiRequest( `https://api.acme.example.com/folders/${folderId}/children`, { method: 'GET' }, ) const data = (await res.json()) as { items?: Record[] next?: string } const files = (data.items ?? []).map(item => this.mapEntry(item)) this.setState('authenticated') this.emitter?.emit('acme-drive:files-loaded', { files, path: folderId, hasMore: Boolean(data.next), cursor: data.next, }) return { files, hasMore: Boolean(data.next), cursor: data.next } } // ── Fetch bytes and hand back real Files. ── async downloadFiles(driveFiles: DriveFile[]): Promise { const out: File[] = [] for (const driveFile of driveFiles) { if (driveFile.isFolder) continue const res = await this.apiRequest( `https://api.acme.example.com/files/${driveFile.id}/content`, { method: 'GET' }, ) const blob = await res.blob() out.push( new File([blob], driveFile.name, { type: blob.type || driveFile.mimeType, }), ) } return out } // ── Provider hooks the base requires. ── protected mapEntry(entry: Record): DriveFile { const isFolder = entry.type === 'folder' return { id: String(entry.id ?? ''), name: String(entry.name ?? ''), path: String(entry.path ?? ''), size: isFolder ? 0 : Number(entry.size ?? 0), mimeType: isFolder ? 'folder' : String(entry.mime ?? ''), isFolder, } } protected async fetchUserProfile(): Promise { const res = await this.apiRequest('https://api.acme.example.com/me', { method: 'GET', }) const data = (await res.json()) as { name?: string; email?: string } return { name: data.name ?? '', email: data.email ?? '' } } } ``` Five abstract members are what the base demands: `spec`, `loadFiles`, `downloadFiles`, `mapEntry`, and `fetchUserProfile`. Everything else — auth state, tokens, popup lifecycle — is inherited. `id` and `name` are both derived from `spec.id`, so `spec.id` is what deduplicates the plugin and what `core.getPlugin(...)` takes. Add `loadMoreFiles(cursor)` if your API paginates. The `cursor` is opaque to upup: encode whatever your API needs (a raw token, an offset, a next-link URL) and it comes back to you verbatim. Register it like any plugin, after handing it its credentials: ```ts const acme = new AcmeDrivePlugin() acme.configure({ clientId: process.env.NEXT_PUBLIC_ACME_CLIENT_ID! }) core.use(acme) ``` It uses Google Identity Services, which issues access tokens with no PKCE popup and no refresh token, so `GoogleDrivePlugin` is a standalone `implements DrivePlugin` rather than a subclass. If your provider's auth doesn't fit the popup skeleton, implement `DrivePlugin` directly — the base class is a convenience, not a requirement. ### The namespaced event surface A drive plugin's events are namespaced by `spec.eventPrefix`. Six names, all prefixed: | Event | Payload | When | | -------------------------- | ----------------------------------------------------------------------- | ----------------------------------------- | | `:state-change` | `{ state }` — the new `DriveState` | any auth/browse state transition | | `:authenticated` | `{ user }` — the `DriveUser`, or `undefined` if the profile call failed | token exchange succeeded | | `:files-loaded` | files, path, `hasMore`, cursor | a folder listing landed | | `:session-expired` | empty | refresh failed or a 401 was unrecoverable | | `:error` | the `Error` plus the failing `action` | any operation failed | | `:signed-out` | empty | `signOut()` was called | `DriveState` is one of `idle`, `authenticating`, `authenticated`, `browsing`, or `session-expired`. `PopupOAuthPlugin` emits all six for you — state changes go out through the protected `setState()`, and auth, refresh, and sign-out are fully handled. Your domain methods only need to emit `files-loaded` on a successful listing and `error` on a failure, as the example above does. Anyone with a core reference subscribes the normal way: ```ts core.on('acme-drive:files-loaded', payload => { console.log(payload) }) ``` ## What's public today Worth stating plainly, because it decides your imports. All of these are named exports of the **public `@upupjs/core` entry** as of 3.1.0: - `UpupPlugin`, `ExtensionMethods` (types) - `DrivePlugin` (type) - `PopupOAuthPlugin` (class, exported as a value) and `PopupOAuthSpec` (type) - `DriveFile`, `DriveFolder`, `DriveUser`, `DriveState`, `DriveEventMap`, `DriveListPage`, `DriveBrowserError` (types) - The four built-in providers — `GoogleDrivePlugin`, `OneDrivePlugin`, `DropboxPlugin`, `BoxPlugin` — and their config types These are **not** on the public entry, which is what shaped the recipes above: - `EventEmitter` — the reason the plugin example derives its `Emitter` type from `UpupPlugin` instead of importing the class. - `PluginManager` — an implementation detail; you go through `core.use()` / `registerExtension()` / `getExtension()` / `getPlugin()`. - `DriveProviderDescriptor` and `DriveBrowserController` — the machinery behind the shipped drive-browser UI. The four descriptor **constants** (`GOOGLE_DRIVE_DESCRIPTOR` and friends) are exported, but the descriptor type is not. All three live behind `@upupjs/core/internal`, a deep-import-only subpath. It is importable, but it is explicitly not the stable surface — names there can change in a minor release, so build against the public entry wherever you can. The shipped uploader wires exactly the four known providers from the `cloudDrives` option, and `FileSource` is a closed set. Your plugin registers and runs fine, and it is a first-class citizen in a headless UI you build yourself — but there is no registry today that adds a new provider to ``'s built-in source selector. ## Next steps - [Plugins & Extensions](/docs/guides/plugins/) — the consumer side: enabling the built-in drives, and registering and calling what you wrote. - [Headless Usage](/docs/guides/headless/) — build the UI your custom provider will live in. - [Events](/docs/api-reference/events/) — the full typed core-event catalog your plugin subscribes to, and how namespaced events pass through. - [Error Handling](/docs/error-handling/) — the `UpupError` taxonomy your plugin should throw from, including `UpupAuthError` and `UpupNetworkError`. - [Client Mode vs Server Mode](/docs/guides/modes/) — plugins are a client-mode mechanism; here is what server mode does instead. --- # upup Documentation https://useupup.com/docs/ upup is a file uploader with a native UI for **React, Vue, Svelte, Angular, Vanilla JS, and Preact**, built on a shared headless core (`@upupjs/core`), with an optional server mode for signed uploads and cloud-drive sources (Google Drive, OneDrive, Dropbox, and Box). Every package renders the same UI. ## Start here - [Getting Started](/docs/getting-started/) — install upup and run your first upload. - [Quickstarts](/docs/quickstarts/react/) — copy-paste setup for every framework. - [Guides](/docs/guides/modes/) — server mode, storage providers, theming, and headless usage. - [API Reference](/docs/api-reference/s3-generate-presigned-url/) — every prop, event, and server utility. --- # Localization (i18n) https://useupup.com/docs/localization/ upup uses ICU locale bundles from `@upupjs/core/i18n`. Messages are grouped into namespaces, formatted with ICU MessageFormat (so plurals and interpolation work in every language), and Arabic switches the layout to right-to-left automatically. ## Built-in locales {/* Table generated from LOCALE_CODES (packages/core/src/i18n/locales/registry.ts); keep in sync. */} | Export | Locale | Direction | | ------ | ------- | --------- | | `enUS` | `en-US` | `ltr` | | `arSA` | `ar-SA` | `rtl` | | `deDE` | `de-DE` | `ltr` | | `esES` | `es-ES` | `ltr` | | `frFR` | `fr-FR` | `ltr` | | `jaJP` | `ja-JP` | `ltr` | | `koKR` | `ko-KR` | `ltr` | | `zhCN` | `zh-CN` | `ltr` | | `zhTW` | `zh-TW` | `ltr` | ## Using a locale Pass a bundle to `i18n.locale`: ```tsx import { UpupUploader } from '@upupjs/react' import '@upupjs/react/styles' import { jaJP } from '@upupjs/core/i18n' export default function Uploader() { return ( ) } ``` Arabic automatically sets right-to-left layout: ```tsx import { arSA } from '@upupjs/core/i18n' ; ``` ## The `i18n` prop The `i18n` object accepts four fields, all optional: ```ts i18n?: { /** A locale bundle. Takes precedence over `locale`. */ bundle?: LocaleBundle /** A locale bundle, or a BCP-47 code string (e.g. 'fr-FR') for lang/dir. */ locale?: LocaleBundle | string /** Bundle/code used when the active locale is missing a key. */ fallbackLocale?: LocaleBundle | string /** Per-key overrides merged on top of the locale. */ overrides?: PartialMessages } ``` - Pass a full bundle (like `jaJP`) to **`locale`** — that is the field to reach for. **`bundle`** exists for when you construct a bundle object yourself and want it to take precedence: if both are set, `bundle` wins. - A **string** code (`'fr-FR'`) is resolved from the registry; an unregistered code falls back to English for content but still sets the language/direction. - **`fallbackLocale`** supplies missing keys (defaults to English). ## Overrides Use `i18n.overrides` for small copy changes. Overrides are a `PartialMessages` object — **keyed by namespace**, then by the message key inside it. `browseFiles`, for example, lives under the `dropzone` namespace: ```tsx import { frFR } from '@upupjs/core/i18n' ; ``` ## Message structure Every message belongs to a namespace, and every value uses ICU MessageFormat. The namespaces are `common`, `sources`, `dropzone`, `header`, `fileList`, `filePreview`, `driveBrowser`, `url`, `camera`, `audio`, `screenCapture`, `branding`, and `errors`. A few real keys: ```ts { common: { cancel: 'Cancel', done: 'Done' }, sources: { myDevice: 'My Device', googleDrive: 'Google Drive', oneDrive: 'OneDrive' }, dropzone: { browseFiles: 'browse files', // ICU interpolation: addDocumentsHere: 'Add your documents here, you can upload up to {limit} files max', }, fileList: { // ICU pluralization: uploadFiles: 'Upload {count, plural, one {# file} other {# files}}', }, } ``` Your `overrides` follow this same namespaced shape, so you can retranslate or tweak any single key without shipping a whole bundle. ## Setting the locale per framework The `i18n` prop is shared across every framework — it is part of the common uploader props. Only the binding syntax differs: | Framework | How to pass it | | ---------- | --------------------------------------------------------- | | React | `` | | Vue | `` | | Svelte | `` | | Angular | `` | | Vanilla JS | `createUploader('#el', { i18n: { locale: jaJP } })` | Preact re-exports `@upupjs/react`, so its uploader takes the same `i18n` prop as the React row. In the [headless](/docs/guides/headless/) path — `useUpupUpload` or `UpupCore` — there is no `i18n` wrapper; set the engine's flat `locale` option to a bundle or code instead (`useUpupUpload({ locale: jaJP })`). It also drives the translator available to pipeline steps. ## Adding a locale Supported locales live behind one compiler-checked registry (`packages/core/src/i18n/locales/registry.ts`), so contributing a new one is a 2-file change: 1. Create `packages/core/src/i18n/locales/.ts` (e.g. `pt-BR.ts`) exporting a `LocaleBundle` — copy the closest existing bundle as a starting point and set its `code`, `language`, and `dir`. 2. In `registry.ts`, add the import, add the code to the `LOCALE_CODES` tuple, add the bundle to the `LOCALE_REGISTRY` map, and add the identifier to the re-export line. Everything else — `LOCALE_META`, the `UpupLocaleCode` type, both i18n barrel exports, and the locale test fixtures — derives from the registry automatically. The `Record` type annotation on `LOCALE_REGISTRY` makes the compiler reject a code that's missing its bundle or a bundle that's missing its code, and `locale-registry.test.ts` asserts every bundle file under `locales/` is actually registered (the one thing the compiler can't see on its own). ## Error messages User-facing error copy goes through the same system: every [`UpupErrorCode`](/docs/api-reference/error-codes/) maps to a key in the `errors` namespace (via `errorCodeToMessageKey` in `@upupjs/core/i18n`), so a localized bundle — or a targeted `overrides.errors` entry — changes what users see when an upload is rejected or fails. Override the `errors` namespace like any other; unknown codes fall back to a generic message. ## Next steps - [Theming](/docs/guides/theming/) — restyle the components whose copy you just localized. - [Headless Usage](/docs/guides/headless/) — set the `locale` option on the engine when you build your own UI. --- # Migrating from v1 to v3 https://useupup.com/docs/migration/v1-to-v3/ v1 shipped as a single React package, `upup-react-file-uploader`. v3 is a ground-up rewrite: a framework-agnostic headless core (`@upupjs/core`) with a native UI for **React, Vue, Svelte, Angular, Vanilla JS, and Preact**, an optional server package (`@upupjs/server`) for signed uploads and server-proxied cloud drives, and a Next.js package (`@upupjs/next`). The React component keeps the same name — `UpupUploader` — and the same idea, but **most props were renamed or restructured**. This is a major upgrade, not a drop-in bump; budget time to sweep your props. Rough time budget: **1–2 hours** for a typical single-`UpupUploader` app, longer if you customized styling via `classNames` or built programmatic upload control on the ref API. ## What changed at a glance - **Package rename.** `upup-react-file-uploader` → `@upupjs/react`. The `/styles` and `/server` subpaths move to `@upupjs/react/styles` and the standalone `@upupjs/server` package. - **Six frameworks.** The React UI is the canon; `@upupjs/vue`, `@upupjs/svelte`, `@upupjs/angular`, `@upupjs/vanilla`, and `@upupjs/preact` render the same DOM. - **Headless core.** `@upupjs/core` holds the engine, the error taxonomy, i18n bundles, and theme contracts. You can build a fully custom UI on it. - **Two upload modes.** v1's single `tokenEndpoint` becomes either `uploadEndpoint` (**client mode** — your route signs URLs, the browser uploads directly) or `mode="server"` + `serverUrl` (**server mode** — the browser talks only to `@upupjs/server`, which holds credentials and proxies drives). - **Prop renames.** `limit` → `maxFiles`, `accept` → `allowedFileTypes`, `dark` → `theme.mode`, `classNames` → `theme.slots`, `uploadAdapters` → `sources`, `driveConfigs` → `cloudDrives`, `customProps` → `metadata`, `enableAutoCorsConfig` → `cors`, `localePack`/`translations` → `i18n`. Full table below. - **Error taxonomy.** The `UploadError` / `UploadErrorType` pair becomes `UpupError` + the `UpupErrorCode` enum (with typed subclasses), exported from `@upupjs/core`. ## Install ```sh npm uninstall upup-react-file-uploader npm i @upupjs/react ``` Add `@upupjs/server` only if you adopt server mode: ```sh npm i @upupjs/server ``` If you catch upload errors by type (see [Error handling](#error-handling)), also add the core package so you can import the error classes directly: ```sh npm i @upupjs/core ``` Then update your imports: ```diff - import { UpupUploader, UpupProvider } from 'upup-react-file-uploader' - import 'upup-react-file-uploader/styles' + import { UpupUploader } from '@upupjs/react' + import '@upupjs/react/styles' ``` `UpupProvider` and `UploadAdapter` are **gone** as exports. `provider` is now a plain string (`"aws"`), and upload methods are configured with `sources` (string ids), not the `UploadAdapter` enum. If you need the storage-provider type, `@upupjs/react` re-exports `StorageProvider` from `@upupjs/core`. ## Props: v1 → v3 Every v1 `UpupUploader` prop, and where it went in v3. Exact v1 names are on the left; exact v3 names on the right. | v1 prop | v3 prop | Notes | | ---------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `provider={UpupProvider.AWS}` (required) | `provider="aws"` (optional) | Now a lowercase string, not the `UpupProvider` enum. Enum values are unchanged (`aws`, `azure`, `backblaze`, `digitalocean`) and v3 adds many more S3-compatible ids (`r2`, `wasabi`, `minio`, `gcs`, …). No longer required — omit it for local-only file selection. | | `tokenEndpoint="/api/upload-token"` (required) | `uploadEndpoint="/api/upload-token"` **or** `mode="server"` + `serverUrl="/api/upup"` | Renamed and split into client mode vs server mode. See [Storage & upload path](#storage--upload-path). | | `accept="image/png"` | `allowedFileTypes="image/png"` | Renamed. Accepts a string, a `string[]`, or preset names (e.g. `"images"`, `"documents"`). | | `dark={true}` | `theme={{ mode: 'dark' }}` | `theme.mode` accepts `'light'`, `'dark'`, or `'system'`. | | `classNames={{ … }}` (flat map) | `theme={{ slots: { … } }}` (nested) | The flat keys (`adapterButton`, `progressBarInner`, …) become nested slot paths — see the [theming guide](/docs/guides/theming/) for the full slot structure. | | `limit={5}` | `maxFiles={5}` | Renamed. **Default changed from `1` to `10`** — see [Gotchas](#gotchas). | | `mini={true}` | `mini={true}` | Unchanged. | | `maxFileSize={{ size: 20, unit: 'MB' }}` | `maxFileSize={{ size: 20, unit: 'MB' }}` | Unchanged shape. v3 adds `minFileSize` and `maxTotalFileSize` (same object shape). | | `maxRetries={3}` | `maxRetries={3}` | Unchanged. | | `resumable={{ mode: 'multipart' }}` | `resumable={{ protocol: 'multipart' }}` | The key `mode` was renamed to `protocol`. v3 also supports `{ protocol: 'tus', endpoint }`. | | `uploadAdapters={[UploadAdapter.INTERNAL, …]}` | `sources={['local', …]}` | Enum array → string-id array. Mapping below. | | `driveConfigs={{ … }}` | `cloudDrives={{ … }}` | Renamed, keys are camelCased. See [Sources & cloud drives](#sources--cloud-drives). | | `imageEditor={true}` | `imageEditor={true}` | Unchanged (`boolean \| ImageEditorOptions`). React/Preact only. | | `localePack={fr_FR}` | `i18n={{ locale: frFR }}` | Locale bundles now live under `i18n`. v1 exported snake_case bundles; v3's are camelCase, imported from `@upupjs/core` (`enUS`, `frFR`, `arSA`, `deDE`, `esES`, `jaJP`, `koKR`, `zhCN`, `zhTW`). | | `translations={{ browseFiles: '…' }}` | `i18n={{ overrides: { … } }}` | Per-key overrides move under `i18n.overrides`, and are **namespaced** (e.g. `{ fileList: { uploadFiles: '…' } }`) rather than flat. | | `customProps={{ … }}` | `metadata={{ … }}` | Renamed. Still forwarded to your upload route. | | `enableAutoCorsConfig={true}` | `cors={{ dangerouslyAutoConfigure: true, allowedOrigins: [...] }}` | Replaced by the `cors` object. Auto-configuration is now explicitly opt-in and named `dangerouslyAutoConfigure`. | | `shouldCompress={true}` | `imageCompression={true}` | Renamed (boolean → boolean). Same semantics. | | `showSelectFolderButton={true}` | `folderUpload={{ showSelectFolderButton: true }}` | Moved under the `folderUpload` object. | | `allowPreview={true}` | `allowPreview={true}` | Unchanged. | | `isProcessing={busy}` | `isProcessing={busy}` | Unchanged. | | `icons={{ … }}` | `icons={{ … }}` | Unchanged (the per-framework component types differ). | v3 also adds many new props with no v1 equivalent — among them `autoUpload`, `thumbnailGenerator`, `heicConversion`, `stripExifData`, `checksumVerification`, `contentDeduplication`, `crashRecovery`, `webWorker`, `maxConcurrentUploads`, `enablePaste`, and `processingEndpoint`. See the [React quickstart](/docs/quickstarts/react/) for the modern surface. ## Sources & cloud drives `uploadAdapters` (an `UploadAdapter` enum array) becomes `sources` (a string-id array). The order still controls tab order. ```diff - import { UpupUploader, UploadAdapter } from 'upup-react-file-uploader' - + import { UpupUploader } from '@upupjs/react' + ``` Adapter → source id mapping: | v1 `UploadAdapter` | v3 `sources` id | | ------------------ | ----------------------------------- | | `INTERNAL` | `'local'` | | `GOOGLE_DRIVE` | `'googleDrive'` | | `ONE_DRIVE` | `'oneDrive'` | | `DROPBOX` | `'dropbox'` | | `LINK` | `'url'` | | `CAMERA` | `'camera'` | | _(new)_ | `'box'`, `'screen'`, `'microphone'` | `driveConfigs` becomes `cloudDrives`, and the snake_case keys become camelCase: ```diff - driveConfigs={{ - googleDrive: { - google_client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!, - google_api_key: process.env.NEXT_PUBLIC_GOOGLE_API_KEY!, - google_app_id: process.env.NEXT_PUBLIC_GOOGLE_APP_ID!, - }, - oneDrive: { - onedrive_client_id: process.env.NEXT_PUBLIC_ONEDRIVE_CLIENT_ID!, - }, - }} + cloudDrives={{ + googleDrive: { + clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!, + apiKey: process.env.NEXT_PUBLIC_GOOGLE_API_KEY!, + appId: process.env.NEXT_PUBLIC_GOOGLE_APP_ID!, + }, + oneDrive: { + clientId: process.env.NEXT_PUBLIC_ONEDRIVE_CLIENT_ID!, + }, + }} ``` `oneDrive`, `dropbox`, and `box` each take `{ clientId, redirectUri? }`; `googleDrive` takes `{ clientId, apiKey, appId }`. ## Theming & i18n Two v1 props (`dark`, `classNames`) collapse into one `theme` object, and two i18n props (`localePack`, `translations`) collapse into one `i18n` object. ```diff ``` The flat `classNames` keys map one-to-one to nested slot paths; the [theming guide](/docs/guides/theming/) documents the full slot structure. For app-wide theming you can also wrap your tree in `UpupThemeProvider` (exported from `@upupjs/react`). ## Events Most handlers keep their names and shapes. Two are worth a closer look: | v1 event | v3 event | Change | | ----------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `onFileRemove` | `onFileRemoved` | Renamed to past tense — the old spelling is gone, not aliased. | | `onFilesUploadComplete` | `onFilesUploadComplete` | Name unchanged; item type changed. Its argument is a list of files in both versions; v1 typed it `FileWithParams[]` (and v1's own docs mislabeled the items as storage "keys"), v3 types it `UploadFile[]` — the same type swap every file handler gets. | Unchanged names: `onFilesSelected`, `onFileClick`, `onFileTypeMismatch`, `onFileUploadComplete`, `onFileUploadStart`, `onFileUploadProgress`, `onFilesDragOver`, `onFilesDragLeave`, `onFilesDrop`, `onFilesUploadProgress`, `onIntegrationClick`, `onPrepareFiles`, `onDoneClicked`, `onWarn`, and `onError`. New in v3: `onUploadStart`, `onUploadComplete`, `onStatusChange`, `onRestrictionFailed`, `onBeforeFileAdded`, and `onFileProcessed`. `onFilesSelected`, `onFileClick`, and the per-file complete/start handlers now receive v3 `UploadFile` objects (which carry `id`, `key`, `status`, and the underlying `File`) instead of v1's `FileWithParams`. Property access like `file.name` and `file.type` still works. ## Ref API (programmatic control) The ref pattern survives with a renamed type. `UpupUploaderRef` becomes `UploaderRef`, and its `useUpload()` method returns a **superset** of the v1 shape: ```diff - import { UpupUploader, UpupUploaderRef } from 'upup-react-file-uploader' - const ref = useRef(null) + import { UpupUploader, type UploaderRef } from '@upupjs/react' + const ref = useRef(null) // ref.current.useUpload() still returns { files, loading, progress, upload, error } // v3 adds: resetState, uploadFiles, setFiles, replaceFiles ``` v3 also introduces a first-class headless hook, `useUpupUpload`, which is the recommended way to drive uploads from your own UI. It returns reactive `files`, `status`, `progress`, and `error`, plus `addFiles`, `upload`, `pause`, `resume`, `cancel`, `retry`, and an `on(event, handler)` subscription — no ref polling required. ```tsx import { useUpupUpload } from '@upupjs/react' const { files, status, progress, error, addFiles, upload } = useUpupUpload({ provider: 'aws', uploadEndpoint: '/api/upload-token', }) ``` ## Error handling v1 threw `UploadError` carrying an `UploadErrorType` enum. v3 replaces it with `UpupError` (base class) and typed subclasses, keyed by the `UpupErrorCode` enum. Both are exported from `@upupjs/core`. ```diff - import { UploadError, UploadErrorType } from 'upup-react-file-uploader' + import { UpupError, UpupErrorCode } from '@upupjs/core' try { await doUpload() } catch (e) { - if (e instanceof UploadError && e.type === UploadErrorType.EXPIRED_URL) { + if (e instanceof UpupError && e.code === UpupErrorCode.PRESIGN_FAILED) { // handle re-signing } } ``` Key differences: - The discriminator moved from `error.type` (an `UploadErrorType` value) to `error.code` (an `UpupErrorCode` value, a string). `retryable` and `status` are still present. - v3 ships typed subclasses you can narrow on: `UpupAuthError`, `UpupNetworkError`, `UpupValidationError`, `UpupQuotaError`, `UpupStorageError`, and `UpupConfigError`. - `UpupErrorCode` is a broader, more specific set than v1's eight types — e.g. `FILE_TOO_LARGE`, `TYPE_MISMATCH`, `LIMIT_EXCEEDED`, `PRESIGN_FAILED`, `CORS_ERROR`, `AUTH_EXPIRED`, `AUTH_DENIED`, `AUTH_REQUIRED`, `NETWORK_ERROR`, `UPLOAD_FAILED`, `QUOTA_EXCEEDED`, `STORAGE_ERROR`. The simple `onError={(message) => …}` prop is unchanged (it still receives a string), and the reactive `error` from `useUpupUpload()` is now a typed `UpupError` rather than an opaque value. The ref's `useUpload().error` stays the plain message string it was in v1. ## Storage & upload path v1 had one path: a `tokenEndpoint` you implemented server-side with `s3GeneratePresignedUrl` (from `upup-react-file-uploader/server`). The browser received a presigned URL and uploaded bytes directly to storage. v3 keeps that model as **client mode** and adds a **server-mode** option that holds your credentials and proxies drive transfers. ### Client mode (closest to v1) Rename `tokenEndpoint` to `uploadEndpoint`. Your route still returns a presigned URL per file. ```diff - + ``` You can keep hand-rolling that route, or adopt `@upupjs/server`'s handler (below), which implements presign, multipart, and drive OAuth for you. The v1 server helpers (`s3GeneratePresignedUrl`, the `s3*MultipartUpload` family, `azureGenerateSasUrl`) are superseded by the handler's `/presign` and `/multipart/*` routes. ### Server mode (new) Point the uploader at a `@upupjs/server` route and let it hold the credentials: ```tsx ``` ```ts import { createUpupHandler, InMemoryTokenStore } from '@upupjs/server' const handler = createUpupHandler({ storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, }, // Required, server-only: a stable, high-entropy secret (min 16 chars), // shared across every server instance. createUpupHandler throws without it. uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!, // OAuth client SECRETS live here, never in the browser: providers: { googleDrive: { clientId: '…', clientSecret: '…' }, }, tokenStore: new InMemoryTokenStore(), getUserId: async req => resolveUser(req), }) export const GET = handler export const POST = handler ``` Server mode is **secure-by-default**: `/presign` and `/multipart/init` return `403 AUTH_REQUIRED` unless you configure `auth`, `getUserId`, or the explicit `allowAnonymousUploads: true`. Every upload is bound to an HMAC-signed token (key + uploadId + size), so a leaked presigned URL cannot be replayed for a different object or a larger body. - **v1's client-direct model:** cloud-drive integration was client-side only — the Google Drive `clientId`/`apiKey` and OneDrive `clientId` were public identifiers shipped to the browser (the normal client-side OAuth model; those are not secrets). - **What v1 couldn't do:** keep the OAuth **client secret** and the drive access tokens off the client. - **What v3 server mode adds:** the client talks to your `serverUrl` for OAuth and drive access, and `@upupjs/server` performs the OAuth exchange and stores tokens in your `TokenStore`. It also proxies cloud-drive transfers — the server fetches the drive file and writes it to storage itself, so drive access tokens never reach the browser. - **What stays the same for storage:** both modes still upload file bytes directly to storage via a presigned URL. In server mode, `@upupjs/server` issues that URL — holding the storage credentials server-side exactly as your v1 `tokenEndpoint` did — and binds it to an HMAC token (a key + uploadId + size envelope). Move to server mode when you need credential isolation, per-user token scoping, or server-side scanning/compliance. Client mode (`uploadEndpoint`) remains a valid, first-class choice. `@upupjs/server` speaks the S3 API only — set `storage.endpoint` for any non-AWS S3-compatible backend (MinIO, R2, DO Spaces, …). `StorageProvider.Azure` has no S3 surface, so `createUpupHandler` rejects it at construction time; use client mode with your own signing route for Azure. See [Server Mode — Setup](/docs/guides/server-mode-setup/) for the Next.js, Express, Fastify, and Hono adapters, and [Upload modes](/docs/guides/modes/) for choosing between them. ## Full example: before and after **v1** (`upup-react-file-uploader`): ```tsx 'use client' import { UpupUploader, UpupProvider, UploadAdapter, } from 'upup-react-file-uploader' import 'upup-react-file-uploader/styles' export default function Uploader() { return ( console.log('removed', file)} onFilesUploadComplete={files => console.log('done', files)} /> ) } ``` **v3** (`@upupjs/react`): ```tsx 'use client' import { UpupUploader } from '@upupjs/react' import '@upupjs/react/styles' export default function Uploader() { return ( console.log('removed', file)} onFilesUploadComplete={files => console.log('done', files)} /> ) } ``` ## Gotchas Behavioral differences to check after the mechanical rename: - **`maxFiles` defaults to 10, not 1.** v1's `limit` defaulted to `1`. If your app relied on single-file selection, set `maxFiles={1}` (or use `mini`, which forces a single file). - **`onFilesUploadComplete` always received file objects, not keys** — read `file.key` for the storage key. - **`resumable.mode` is now `resumable.protocol`.** A leftover `{ mode: 'multipart' }` will not enable resumable uploads. - **`onFileRemove` is now `onFileRemoved`.** The old spelling is gone (not aliased), so it silently stops firing if you miss it. - **The panel is fixed-height by design.** The full uploader is 480px tall (max-width 600px); `mini` is a compact square (max-width 280px). Media views (camera, screen capture, previews) adapt to that box — the panel does not grow to fit content. This is unchanged from v1's sizing model. - **`UpupProvider` and `UploadAdapter` no longer exist.** Replace enum usages with the string `provider` value and `sources` ids respectively. - **A fresh core per mount.** v3 creates and `destroy()`s its engine on mount/unmount; hold state in your own app or via `useUpupUpload`, not across a remount of ``. ## Next steps - [React quickstart](/docs/quickstarts/react/) — the modern v3 surface end to end. - [Getting started](/docs/getting-started/) — local collection, client uploads, and server uploads in one page. - [Server Mode — Setup](/docs/guides/server-mode-setup/) — adapters, auth, and production token stores. - [Upload modes](/docs/guides/modes/) — client vs server, and when to pick each. - Other frameworks: [Vue](/docs/quickstarts/vue/), [Svelte](/docs/quickstarts/svelte/), [Angular](/docs/quickstarts/angular/), [Vanilla JS](/docs/quickstarts/vanilla/), [Preact](/docs/quickstarts/preact/), [Next.js](/docs/quickstarts/next/). --- # Angular Quickstart https://useupup.com/docs/quickstarts/angular/ `@upupjs/angular` is a native Angular 19+ port of the canonical upup React UI — DOM-identical to it — with cloud-drive sources, resumable uploads, theming, and ICU i18n. This page gets you uploading in **client mode**, no server package required. Requires Angular 19+ (`@angular/core`, `@angular/common`, and `rxjs` are peer dependencies). ## Install ```sh npm i @upupjs/angular ``` ## Minimal example (client mode) `UpupUploaderComponent` is a **standalone** component (selector `upup-uploader`). Add it to a standalone component's `imports` (or an `NgModule`'s `imports`). It takes a single `config` input. In client mode the browser uploads directly to your storage; your app only issues short-lived upload credentials at `uploadEndpoint`. ```ts import { Component } from '@angular/core' import { UpupUploaderComponent } from '@upupjs/angular' @Component({ selector: 'app-root', standalone: true, imports: [UpupUploaderComponent], template: ``, }) export class AppComponent {} ``` Load the stylesheet once globally — add `@upupjs/angular/styles` to the `styles` array in `angular.json`, or `@import '@upupjs/angular/styles';` in your global `styles.css`. See [Code Examples](/docs/code-examples/) for a ready-to-copy presign handler. ## Choose sources and cloud drives Everything is passed through the single `config` input. Add `sources` to pick which tabs appear and `cloudDrives` to enable the cloud providers (client IDs come from each provider's developer console): ```ts @Component({ selector: 'app-root', standalone: true, imports: [UpupUploaderComponent], template: ``, }) export class AppComponent { config = { provider: 'aws', uploadEndpoint: '/api/upload-token', sources: [ 'local', 'camera', 'screen', 'url', 'googleDrive', 'oneDrive', ], cloudDrives: { googleDrive: { clientId: '...', apiKey: '...', appId: '...' }, oneDrive: { clientId: '...' }, }, } } ``` ## Add server mode For credential isolation and server-proxied cloud drives, add [`@upupjs/server`](https://www.npmjs.com/package/@upupjs/server) and set the mode: ```html ``` The handler requires an `uploadTokenSecret` of **at least 16 characters** — `createUpupHandler` throws at construction time if it is missing or too short: ```ts import { createUpupHandler } from '@upupjs/server' export const handler = createUpupHandler({ storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, }, uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET, // required, >= 16 chars }) ``` See [Server Mode — Setup](/docs/guides/server-mode-setup/) for the full walkthrough: adapters for Next.js, Express, Fastify, and Hono; auth and user binding; and production token stores. ## What you get - **Cloud drives** — Google Drive, OneDrive, Dropbox, and Box, browsed in-panel. - **Camera** and **screen capture** sources. - **Link import** — add files by URL. - **Resumable uploads** — optional tus or S3 multipart for large files. - **ICU i18n** — locale bundles with pluralization and per-key overrides. - **Theming** — design tokens, slots, and dark mode. The image editor is React/Preact-only and is intentionally stubbed in Angular; every other capability above is fully native. ## Also exported `UpupStore` and the `createUpupUpload` / `toSignalStore` helpers for driving the uploader from your own Angular state, plus the `FileSource`, `StorageProvider`, and `UploadStatus` enums. --- # Next.js Quickstart https://useupup.com/docs/quickstarts/next/ `@upupjs/next` is the Next.js integration for upup: one install gives you the client UI **and** the server handlers, split across two entry points so the AWS SDK never reaches your client bundle. The client entry re-exports the full [`@upupjs/react`](/docs/quickstarts/react/) UI; the `/server` entry provides App Router and Pages Router handlers. Requires Next.js 15+ and React 19 (`next`, `react`, and `react-dom` are peer dependencies). ## Install ```sh npm i @upupjs/next ``` ## Client component `@upupjs/next` re-exports `@upupjs/react`, so `UpupUploader` (and its hooks, icons, and theme provider) come straight from `@upupjs/next`. Render it in a client component: ```tsx 'use client' import { UpupUploader } from '@upupjs/next' import '@upupjs/next/styles' export default function Uploader() { return } ``` That's **client mode** — the browser uploads directly to your storage and needs no server package. To proxy uploads and cloud drives through your own server, point the same component at the handler below: ```tsx ``` ## Server — App Router Create a catch-all route at `app/api/upup/[...path]/route.ts`. `createUpupNextHandler` returns the HTTP method handlers; `defineUpupConfig` gives you typed config. ```ts import { createUpupNextHandler, defineUpupConfig } from '@upupjs/next/server' export const dynamic = 'force-dynamic' export const maxDuration = 60 export const { GET, POST, PUT, DELETE } = createUpupNextHandler( defineUpupConfig({ storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, }, // REQUIRED — HMAC-signs upload tokens. Must be >= 16 chars and identical // on every instance; the handler THROWS at construction time without it. uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET, }), ) ``` Add `serverExternalPackages: ['@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner']` to your `next.config` so the AWS SDK is bundled as a server external. ## Server — Pages Router A Pages Router adapter ships too: `createUpupPagesHandler`, used as the route file's default export at `pages/api/upup/[...path].ts`, with `export const config = { api: { bodyParser: false } }` so the handler can read the raw body. Same required `uploadTokenSecret` rule applies. See [Server Mode — Setup](/docs/guides/server-mode-setup/) for the full walkthrough — auth and user binding, production token stores, and serverless notes (persist the `TokenStore`, raise `maxDuration` for large transfers). ## What you get - **Cloud drives** — Google Drive, OneDrive, Dropbox, and Box, browsed in-panel. - **Camera** and **screen capture** sources. - **Link import** — add files by URL. - **Resumable uploads** — optional tus or S3 multipart for large files. - **Image editor** — crop, rotate, and annotate (React and Preact only). - **ICU i18n** — locale bundles with pluralization and per-key overrides. - **Theming** — design tokens, slots, and dark mode via `UpupThemeProvider`. ## Also exported From `@upupjs/next`: the full `@upupjs/react` surface (`UpupUploader`, `UpupThemeProvider`, the brand source icons, and the `use*` hooks). From `@upupjs/next/server`: `createUpupNextHandler`, `createUpupPagesHandler`, `defineUpupConfig`, and `InMemoryTokenStore` (dev-only — bring your own `TokenStore` in production). --- # Preact Quickstart https://useupup.com/docs/quickstarts/preact/ `@upupjs/preact` is a **`preact/compat` re-export of [`@upupjs/react`](/docs/quickstarts/react/)** — the same UI and API, resolved against Preact: cloud-drive sources, resumable uploads, theming, and ICU i18n. This page gets you uploading in **client mode**, no server package required. Requires Preact 10.13+ (`preact` is a peer dependency; the image editor has additional optional peers — see below). ## Install ```sh npm i @upupjs/preact ``` Use it in a Preact project with the standard `preact/compat` aliases (`react` / `react-dom` → `preact/compat`) configured in your bundler, as with any React-compatible library. ## Minimal example (client mode) In client mode the browser uploads directly to your storage; your app only issues short-lived upload credentials at `uploadEndpoint`. ```tsx import { UpupUploader } from '@upupjs/preact' import '@upupjs/preact/styles' export function App() { return } ``` The stylesheet is a separate import, so projects without Tailwind get the same look. `uploadEndpoint` is your route that returns a presigned upload URL per file; `provider` is your storage backend — `aws`, `minio`, `r2`, `digitalocean`, `wasabi`, `backblaze`, and other S3-compatible providers work. See [Code Examples](/docs/code-examples/) for a ready-to-copy presign handler. ## Choose sources and cloud drives Pass `sources` to pick which tabs appear and `cloudDrives` to enable the cloud providers (client IDs come from each provider's developer console): ```tsx ``` ## Image editor island The optional image editor runs as an **isolated real-React island**: it lazily loads actual `react` / `react-dom` on demand to render Filerobot, so React never enters your main Preact bundle. If you enable the editor, install its peers (`react`, `react-dom`, `react-filerobot-image-editor`, and the `konva` / `react-konva` / `styled-components` dependencies it needs — see this package's `peerDependencies`). ## Add server mode For credential isolation and server-proxied cloud drives, add [`@upupjs/server`](https://www.npmjs.com/package/@upupjs/server) and point the uploader at it: ```tsx ``` The handler requires an `uploadTokenSecret` of **at least 16 characters** — `createUpupHandler` throws at construction time if it is missing or too short: ```ts import { createUpupHandler } from '@upupjs/server' export const handler = createUpupHandler({ storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, }, uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET, // required, >= 16 chars }) ``` See [Server Mode — Setup](/docs/guides/server-mode-setup/) for the full walkthrough: adapters for Next.js, Express, Fastify, and Hono; auth and user binding; and production token stores. ## What you get - **Cloud drives** — Google Drive, OneDrive, Dropbox, and Box, browsed in-panel. - **Camera** and **screen capture** sources. - **Link import** — add files by URL. - **Resumable uploads** — optional tus or S3 multipart for large files. - **Image editor** — crop, rotate, and annotate (via the real-React island). - **ICU i18n** — locale bundles with pluralization and per-key overrides. - **Theming** — design tokens, slots, and dark mode via `UpupThemeProvider`. ## Also exported Because it re-exports `@upupjs/react`, the public surface matches `@upupjs/react` exactly: `UpupThemeProvider`, the brand source icons, and the `use*` hooks (`useUpupUpload`, `useUploaderFiles`, `useUploaderContext`, and more). --- # React Quickstart https://useupup.com/docs/quickstarts/react/ `@upupjs/react` is the canonical upup UI: a drag-and-drop uploader with file previews, a progress bar, cloud-drive sources, an image editor, theming, and ICU i18n. This page gets you uploading in **client mode** — no server package required. Requires React 19 (`react` and `react-dom` are peer dependencies). ## Install ```sh npm i @upupjs/react ``` ## Minimal example (client mode) In client mode the browser uploads directly to your storage; your app only issues short-lived upload credentials at `uploadEndpoint`. ```tsx 'use client' import { UpupUploader } from '@upupjs/react' import '@upupjs/react/styles' export default function Uploader() { return } ``` The stylesheet is a separate import, so projects without Tailwind get the same look. `uploadEndpoint` is your route that returns a presigned upload URL per file; `provider` is your storage backend — `aws`, `minio`, `r2`, `digitalocean`, `wasabi`, `backblaze`, and other S3-compatible providers work. See [Code Examples](/docs/code-examples/) for a ready-to-copy presign handler. ## Choose sources and cloud drives Pass `sources` to pick which tabs appear and `cloudDrives` to enable the cloud providers (client IDs come from each provider's developer console): ```tsx ``` ## Add server mode For credential isolation and server-proxied cloud drives, add [`@upupjs/server`](https://www.npmjs.com/package/@upupjs/server) and point the uploader at it: ```tsx ``` The handler requires an `uploadTokenSecret` of **at least 16 characters** — `createUpupHandler` throws at construction time if it is missing or too short: ```ts import { createUpupHandler } from '@upupjs/server' export const handler = createUpupHandler({ storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, }, uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET, // required, >= 16 chars }) ``` See [Server Mode — Setup](/docs/guides/server-mode-setup/) for the full walkthrough: adapters for Next.js, Express, Fastify, and Hono; auth and user binding; and production token stores. ## What you get - **Cloud drives** — Google Drive, OneDrive, Dropbox, and Box, browsed in-panel. - **Camera** and **screen capture** sources. - **Link import** — add files by URL. - **Resumable uploads** — optional tus or S3 multipart for large files. - **Image editor** — crop, rotate, and annotate (React and Preact only). - **ICU i18n** — locale bundles with pluralization and per-key overrides. - **Theming** — design tokens, slots, and dark mode via `UpupThemeProvider`. ## Also exported `UpupThemeProvider`, the brand source icons (`GoogleDriveIcon`, `OneDriveIcon`, `DropboxIcon`, `BoxIcon`, `CameraIcon`, and more), and the `use*` hooks (`useUpupUpload`, `useUploaderFiles`, `useUploaderContext`, and more) for building a custom UI on the same engine. --- # Svelte Quickstart https://useupup.com/docs/quickstarts/svelte/ `@upupjs/svelte` is a native Svelte 5 port of the canonical upup React UI — DOM-identical to it — with cloud-drive sources, resumable uploads, theming, and ICU i18n. This page gets you uploading in **client mode**, no server package required. Requires Svelte 5 (`svelte` is a peer dependency). ## Install ```sh npm i @upupjs/svelte ``` ## Minimal example (client mode) In client mode the browser uploads directly to your storage; your app only issues short-lived upload credentials at `uploadEndpoint`. ```svelte ``` The stylesheet is a separate import, so projects without Tailwind get the same look. `uploadEndpoint` is your route that returns a presigned upload URL per file; `provider` is your storage backend — `aws`, `minio`, `r2`, `digitalocean`, `wasabi`, `backblaze`, and other S3-compatible providers work. See [Code Examples](/docs/code-examples/) for a ready-to-copy presign handler. ## Choose sources and cloud drives Pass `sources` to pick which tabs appear and `cloudDrives` to enable the cloud providers (client IDs come from each provider's developer console): ```svelte ``` ## Add server mode For credential isolation and server-proxied cloud drives, add [`@upupjs/server`](https://www.npmjs.com/package/@upupjs/server) and point the uploader at it: ```svelte ``` The handler requires an `uploadTokenSecret` of **at least 16 characters** — `createUpupHandler` throws at construction time if it is missing or too short: ```ts import { createUpupHandler } from '@upupjs/server' export const handler = createUpupHandler({ storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, }, uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET, // required, >= 16 chars }) ``` See [Server Mode — Setup](/docs/guides/server-mode-setup/) for the full walkthrough: adapters for Next.js, Express, Fastify, and Hono; auth and user binding; and production token stores. ## What you get - **Cloud drives** — Google Drive, OneDrive, Dropbox, and Box, browsed in-panel. - **Camera** and **screen capture** sources. - **Link import** — add files by URL. - **Resumable uploads** — optional tus or S3 multipart for large files. - **ICU i18n** — locale bundles with pluralization and per-key overrides. - **Theming** — design tokens, slots, and dark mode. The image editor is React/Preact-only and is intentionally stubbed in Svelte; every other capability above is fully native. ## Also exported `toReadable` (adapts an uploader store to a Svelte readable), the `use*` uploader helpers, and the `FileSource`, `StorageProvider`, and `UploadStatus` enums for building a custom UI on the same engine. --- # Vanilla JS Quickstart https://useupup.com/docs/quickstarts/vanilla/ `@upupjs/vanilla` is the framework-free upup uploader (built on lit-html) — DOM-identical to the canonical React UI — with cloud-drive sources, resumable uploads, theming, and ICU i18n. Mount it into any DOM element; no framework required. This page gets you uploading in **client mode**, no server package required. ## Install ```sh npm i @upupjs/vanilla ``` ## Minimal example (client mode) `createUploader(target, options)` accepts a CSS selector string or an `HTMLElement`. In client mode the browser uploads directly to your storage; your app only issues short-lived upload credentials at `uploadEndpoint`. ```ts import { createUploader } from '@upupjs/vanilla' import '@upupjs/vanilla/styles' const uploader = createUploader('#uploader', { provider: 'aws', uploadEndpoint: '/api/upload-token', }) // Later, when you tear down the view: uploader.destroy() ``` The instance also exposes `getState()`, `subscribe()`, `addFiles()`, `upload()`, `pause()`, `resume()`, `cancel()`, and `retry()`. Call `destroy()` on unmount to stop the render loop and detach listeners. The stylesheet is a separate import, so pages without Tailwind get the same look. See [Code Examples](/docs/code-examples/) for a ready-to-copy presign handler. ## Custom element A `` custom element is also published. Importing `@upupjs/vanilla/element` registers it, after which you can use it declaratively: ```ts import '@upupjs/vanilla/element' ``` ```html ``` ## Choose sources and cloud drives Pass `sources` to pick which tabs appear and `cloudDrives` to enable the cloud providers (client IDs come from each provider's developer console): ```ts createUploader('#uploader', { provider: 'aws', uploadEndpoint: '/api/upload-token', sources: ['local', 'camera', 'screen', 'url', 'googleDrive', 'oneDrive'], cloudDrives: { googleDrive: { clientId: '...', apiKey: '...', appId: '...' }, oneDrive: { clientId: '...' }, }, }) ``` ## Add server mode For credential isolation and server-proxied cloud drives, add [`@upupjs/server`](https://www.npmjs.com/package/@upupjs/server) and set the mode: ```ts createUploader('#uploader', { mode: 'server', serverUrl: '/api/upup', provider: 'aws', }) ``` The handler requires an `uploadTokenSecret` of **at least 16 characters** — `createUpupHandler` throws at construction time if it is missing or too short: ```ts import { createUpupHandler } from '@upupjs/server' export const handler = createUpupHandler({ storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, }, uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET, // required, >= 16 chars }) ``` See [Server Mode — Setup](/docs/guides/server-mode-setup/) for the full walkthrough: adapters for Next.js, Express, Fastify, and Hono; auth and user binding; and production token stores. ## What you get - **Cloud drives** — Google Drive, OneDrive, Dropbox, and Box, browsed in-panel. - **Camera** and **screen capture** sources. - **Link import** — add files by URL. - **Resumable uploads** — optional tus or S3 multipart for large files. - **ICU i18n** — locale bundles with pluralization and per-key overrides. - **Theming** — design tokens, slots, and dark mode. The image editor is React/Preact-only and is intentionally stubbed in the framework-free build; every other capability above is fully native. ## Also exported `FileSource`, `StorageProvider`, and `UploadStatus`. --- # Vue Quickstart https://useupup.com/docs/quickstarts/vue/ `@upupjs/vue` is a native Vue 3 port of the canonical upup React UI — DOM-identical to it — with cloud-drive sources, resumable uploads, theming, and ICU i18n. This page gets you uploading in **client mode**, no server package required. Requires Vue 3.4+ (`vue` is a peer dependency). ## Install ```sh npm i @upupjs/vue ``` ## Minimal example (client mode) In client mode the browser uploads directly to your storage; your app only issues short-lived upload credentials at `uploadEndpoint`. ```vue ``` The stylesheet is a separate import, so projects without Tailwind get the same look. `upload-endpoint` is your route that returns a presigned upload URL per file; `provider` is your storage backend — `aws`, `minio`, `r2`, `digitalocean`, `wasabi`, `backblaze`, and other S3-compatible providers work. See [Code Examples](/docs/code-examples/) for a ready-to-copy presign handler. ## Choose sources and cloud drives Bind `sources` to pick which tabs appear and `cloud-drives` to enable the cloud providers (client IDs come from each provider's developer console): ```vue ``` ## Add server mode For credential isolation and server-proxied cloud drives, add [`@upupjs/server`](https://www.npmjs.com/package/@upupjs/server) and point the uploader at it: ```vue ``` The handler requires an `uploadTokenSecret` of **at least 16 characters** — `createUpupHandler` throws at construction time if it is missing or too short: ```ts import { createUpupHandler } from '@upupjs/server' export const handler = createUpupHandler({ storage: { type: 'aws', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION!, }, uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET, // required, >= 16 chars }) ``` See [Server Mode — Setup](/docs/guides/server-mode-setup/) for the full walkthrough: adapters for Next.js, Express, Fastify, and Hono; auth and user binding; and production token stores. ## What you get - **Cloud drives** — Google Drive, OneDrive, Dropbox, and Box, browsed in-panel. - **Camera** and **screen capture** sources. - **Link import** — add files by URL. - **Resumable uploads** — optional tus or S3 multipart for large files. - **ICU i18n** — locale bundles with pluralization and per-key overrides. - **Theming** — design tokens, slots, and dark mode. The image editor is React/Preact-only and is intentionally stubbed in Vue; every other capability above is fully native. ## Also exported The `use*` uploader composables (`useUpupUpload`, `useUploaderFiles`, `useUploaderContext`, and more), plus the `FileSource`, `StorageProvider`, and `UploadStatus` enums for building a custom UI on the same engine. --- # Resumable Uploads https://useupup.com/docs/resumable-uploads/ upup supports two resumable protocols. Both are opt-in through the single `resumable` prop, and they are mutually exclusive — a `resumable` value is either a multipart config or a tus config, never both. | | Multipart | tus | | ----------------------------- | ------------------------------ | ---------------------------------- | | Upload target | `serverUrl` (`@upupjs/server`) | tus `endpoint` | | Splits large files into parts | Yes | Yes (when `chunkSizeBytes` is set) | | Survives a page reload | Yes (`localStorage` session) | Yes (fingerprint URL storage) | | Extra dependency | None | `tus-js-client` (optional peer) | | Storage | Your S3-compatible bucket | Whatever the tus server writes to | Pick multipart if your bytes already go to S3 through `@upupjs/server`: large files are split into parallel, individually-retried parts, and an interrupted upload picks up at the last completed part after a reload, a tab close, or a crash — see [Cross-reload resume](#cross-reload-resume). Pick tus when server-mode multipart is not on the table: a client-mode `uploadEndpoint` deployment (multipart refuses to run there at all), a non-S3 destination, or an existing tus server you already operate. tus also resumes without `@upupjs/server` in the path, because the tus protocol — not upup — owns the offset bookkeeping. ## Multipart Multipart splits a file into parts, signs each part individually, uploads several parts at a time (three by default), and then asks the server to assemble them. Core routes a file to the multipart strategy when its size is at or above `thresholdBytes`; everything smaller takes the ordinary single-PUT presigned path. ```tsx ``` Multipart needs a credential source that can init, sign parts, complete, and abort. Only `serverUrl` (backed by `@upupjs/server`) provides those. A client-mode `uploadEndpoint` supplies presigned single-PUT URLs only, so pairing it with `protocol: 'multipart'` throws a `UpupConfigError` ("CredentialStrategy must implement multipart methods") the moment the uploader is constructed. ### Options Seven fields, all optional. | Option | Type | Default | What it does | | -------------------- | ---------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `thresholdBytes` | `number` | `5242880` (5 MiB) | Files at or above this size use multipart; smaller files use a single presigned PUT. | | `chunkSizeBytes` | `number` | `5242880` (5 MiB) | Requested part size. A fallback only — see [Part size](#part-size-the-5-mib-floor-and-the-10000-part-cap), the server decides the real value. | | `persist` | `boolean` | `true` | Keep a per-file session in `localStorage` so an interrupted upload resumes at the last completed part — see [Cross-reload resume](#cross-reload-resume). `false` restores the older behavior: any failure aborts the server-side upload and the next attempt starts from byte zero. | | `retryDelays` | `number[]` | `[0, 1000, 3000, 5000]` | Backoff schedule, in milliseconds, for retrying a transiently failed or stalled part — the same vocabulary tus uses. The array's length is the retry budget; `[]` disables part retries. See [Part retries](#part-retries-and-the-stall-watchdog). | | `partTimeoutMs` | `number` | `180000` (3 min) | Watchdog budget for each phase of a part attempt (sign, source read, PUT): a phase that neither succeeds nor fails within its window is aborted and retried instead of hanging the upload forever — see [Part retries](#part-retries-and-the-stall-watchdog). | | `maxConcurrentParts` | `number` | `3` | How many parts of a single file upload at once — see [Part concurrency](#part-concurrency). | | `autoResume` | `boolean` | `false` | Continue a crash-restored upload automatically instead of waiting for the Resume click — see [The full reload story](#the-full-reload-story-persist--crashrecovery). | ### Part concurrency `maxConcurrentParts` sets how many parts of one file are in flight at the same time. Three is a deliberate middle: raising it fills a high-bandwidth link faster, and costs memory and sockets, because every in-flight part holds its own chunk of the file in memory — parts up to 16 MiB are read into an `ArrayBuffer` before the PUT (so each in-flight part costs up to `min(partSize, 16 MiB)` of resident bytes, plus the copy XHR makes while sending), and larger parts stream from the file. Lower it when your users upload from constrained devices or you are being rate-limited; raise it when big files crawl on connections you know are fast. Values below `1` are clamped to `1`. This is parts within one file. `maxConcurrentUploads` is the other axis — how many files upload in parallel — and the two multiply: four files at three parts each is twelve requests in flight. ### Part retries and the stall watchdog Every part attempt runs under a watchdog, applied to each of its phases separately: the sign call, the source read (for parts small enough to be materialized), and the PUT. For the PUT it is an **inactivity** timer, not a deadline on the whole transfer: as long as bytes keep moving, a part is never timed out, no matter how slow the link or how large the part. The watchdog only fires after `partTimeoutMs` of **no upload progress at all** — a genuinely stalled or dead connection — at which point the attempt is aborted and retried rather than hanging for the lifetime of a half-open TCP socket. The sign call and the source read, which have no progress to measure, are each bounded by the same timeout as a plain deadline — so the worst case for one attempt is up to three windows, not one. The 3-minute default suits almost everyone; raise it only if your users sit behind proxies that buffer a whole part before forwarding it (so no progress is visible mid-part), and lower it if you want a dead connection declared failed sooner. A failed attempt is retried on the `retryDelays` schedule when the failure is transient: a network error, a watchdog timeout, an HTTP `429`, or any `5xx`. Definitive rejections — a `403` forged-token refusal, a `400` — are not retried and fail the file immediately. Retries wait out their delay, but a `pause()` or cancel cuts the wait short rather than blocking on it. Part retries sit **inside** one upload run; `maxRetries` governs whole-run retries around it. A part that recovers on retry never surfaces as a file failure at all. ### Server routes Multipart is a five-call lifecycle. All five are `POST`, all five are implemented by `@upupjs/server` under your `serverUrl` base path: ```txt POST /multipart/init POST /multipart/sign-part POST /multipart/complete POST /multipart/abort POST /multipart/resume ``` `/multipart/init` returns `{ key, uploadId, partSize, expiresIn, token }`. The `token` is an HMAC-signed capability bound to the key, upload id, and a byte envelope; the client replays it on every subsequent call, and an init response without one is rejected client-side as a misconfigured or outdated server. `/multipart/resume` takes that token — and nothing else — and answers with the parts storage already holds plus a freshly-signed replacement token. It is what makes a reload survivable, and it is also how the client refreshes a token that outlived its one-hour expiry mid-upload. `/multipart/abort` is issued on an explicit cancel. It is **not** issued on a plain failure while `persist` is on, because those parts are exactly what the next attempt resumes from — see [Cross-reload resume](#cross-reload-resume) for what that means for your bucket. Request and response shapes for all five routes are documented in the [server HTTP reference](/docs/api-reference/server-http). ### Part size, the 5 MiB floor, and the 10,000-part cap S3 imposes two hard limits that shape every multipart upload: a part must be at least **5 MiB** (except the last one), and a single upload may have at most **10,000 parts**. `@upupjs/server` reconciles both before answering `/multipart/init`: 1. Start from the requested chunk size, or 5 MiB if none was given. 2. Raise it to 5 MiB if it is below the floor. 3. Raise it again to `ceil(fileSize / 10000)` if the file would otherwise need more than 10,000 parts. The returned `partSize` wins — core uses the server's value and falls back to your `chunkSizeBytes` only if the server omits it. In practice that means the part size in server mode is decided by the server, not by the browser. The auto-bump in step 3 is why there is no hard file-size ceiling: a part size of `P` covers files up to `P × 10000`, and anything larger simply gets a bigger part size. | Part size | Files covered without an auto-bump | | ----------------- | ---------------------------------- | | 5 MiB (the floor) | up to ~48.8 GiB | | 8 MiB | up to ~78.1 GiB | | 16 MiB | up to ~156.3 GiB | | 64 MiB | up to ~625 GiB | Larger parts mean fewer signing round-trips but coarser progress and more bytes to re-send when one part fails. The 5 MiB default is the right choice unless you routinely move files in the tens of gigabytes. ### Cross-reload resume Multipart parallelizes and isolates failures — a failed part is retried on its own rather than taking the whole file down with it. On top of that, with `persist` on (the default) an interrupted upload continues from the last completed part instead of restarting, across all four ways an upload gets interrupted: | Interruption | What happens with `persist` on | | ---------------------------------- | ----------------------------------------------------------------------- | | A part's request fails | That part is retried; the rest of the file is untouched | | `pause()` then `resume()` | Continues mid-file — completed parts are not re-uploaded | | Automatic retry after a failed run | Re-enters the upload and resumes from the persisted session | | Page reload / tab close / crash | Resumes once the file is selected again, or restored by `crashRecovery` | The mechanism is a small session record per file, held in `localStorage` under the `upup_mp_` prefix, keyed by a fingerprint of `name:size:lastModified:type`, expiring after 24 hours. It holds the upload token, the object key, the part size, and the bytes uploaded so far — never the S3 `uploadId`, which stays sealed inside the signed token. On the next upload of a file whose fingerprint matches a live session, the client presents the stored token to `POST /multipart/resume`. The server hands back the parts storage already holds — each with its byte size — plus a freshly-signed token. The client then: 1. Validates every returned part's size: each part except the file's true final one must be exactly `partSize`, the final one must be the exact tail, and a part reported without a size is not trusted. Any mismatch discards the session and starts fresh. 2. Compares content hashes when both sides have one. With [`checksumVerification`](/docs/guides/file-processing/#checksumverification) on, the pipeline writes `metadata.originalContentHash` and the session records it — which catches a file whose bytes changed while its name, size, and timestamp did not. Without it, the fingerprint alone decides. 3. Skips the stored part numbers, pre-fills the progress bar at the resumed byte offset, and uploads only what is missing. If every part is already present it goes straight to `/multipart/complete`. Resume failures are never fatal: a `404`, a `403`, an old server without the route, or a plain network error all fall through to an ordinary `/multipart/init` and a fresh upload. A resume that cannot happen costs bandwidth, not the upload. Because `persist` defaults to on, a failed or abandoned upload no longer aborts itself server-side — its parts are deliberately kept so the next attempt can resume from them. Parts that are never resumed are never cleaned up by upup, and S3 bills for them. Set an `AbortIncompleteMultipartUpload` lifecycle rule on the bucket, with an expiry of 1–7 days. Every S3-compatible provider supports it, MinIO included. This is the one piece of operational setup cross-reload resume asks of you; without it, incomplete parts accrue storage cost silently. #### The full reload story: `persist` + `crashRecovery` The two features cover different halves of a reload and are designed to compose. Turn both on: ```tsx ``` After a reload, `crashRecovery` restores the file list from IndexedDB — blobs included, with the file's `name`, `size`, `lastModified`, and `type` preserved byte-exactly, which is what keeps the fingerprint valid — and marks the interrupted files `PAUSED`. Calling `resume()` (the shipped UI's resume button) then re-enters the upload, the persisted session is found, and the transfer picks up mid-file. The explicit Resume click is the industry default — closing a tab is as often "cancel" as "oops", and silently re-uploading a file the user meant to abandon is the worse failure. If your product knows better (a backup tool, a sync client), set `autoResume: true` in the multipart config and a crash-restored upload continues by itself, no click required. Without `crashRecovery` the session still works, but the user has to re-select the same file themselves; nothing on the page remembers it was there. #### What still starts over The fingerprint is a heuristic — the same model `tus-js-client` uses — and `localStorage` is not durable storage. These cases silently fall back to a fresh upload, which is the safe direction to fail in: - **A `Blob` rather than a `File`.** Blobs have no name or `lastModified`, so they cannot be fingerprinted and are never persisted. - **A file the pipeline transformed** (compression, HEIC conversion). The rewritten file carries a new `lastModified`, so it simply never matches an older session. - **A different server.** Sessions record the `serverUrl` they were created against; a session saved against one server is never resumed against another. - **Evicted or unavailable `localStorage`.** Safari's ITP evicts storage after a period of inactivity, and private-browsing modes may refuse writes outright. The store returns "no session" rather than throwing. - **A session older than 24 hours**, or an upload past the server's resume window (`multipartResumeWindowSeconds`, 24 hours by default — see the [server HTTP reference](/docs/api-reference/server-http/#post-multipartresume)). - **An upload the provider no longer has** — already completed, aborted, or reaped by your lifecycle rule. The server answers `404 NOT_FOUND` and the client drops the session. #### Uploads longer than the token's lifetime The upload token expires an hour after `init`. When a `sign-part` or `complete` call comes back `403` with code `expired`, the client refreshes through `/multipart/resume` once, swaps in the new token, and retries the call — the three concurrent part uploaders share one in-flight refresh rather than racing. A slow multi-hour upload therefore completes without the integrator configuring anything. #### Turning it off `persist: false` on the client restores the pre-3.2 behavior exactly: no session is written, and any failure or abort issues a best-effort `/multipart/abort` so no orphaned upload is left behind. This is the switch that turns off parts retention. Setting `multipartResumeWindowSeconds: 0` on the **server** is a different lever: it unregisters the resume route, so cross-reload resume stops working (clients see an old server and fall back to a fresh upload gracefully). It does **not** by itself stop clients from retaining parts on failure — a client still running the default `persist: true` keeps skipping the abort, because it cannot know the server disabled the route until it tries. To both disable resume and reclaim the old auto-abort, pair `multipartResumeWindowSeconds: 0` with `persist: false` on the client — or, as always, rely on the `AbortIncompleteMultipartUpload` lifecycle rule, which is the durable backstop either way. Explicit cancellation always cleans up regardless of `persist`: `cancel()`, `removeFile()`, and `removeAll()` abort the server-side upload and clear the persisted session on a best-effort basis. A user who cancels does not leave parts behind. ## tus tus talks directly to an external tus-compatible service — your own [tusd](https://github.com/tus/tusd), Transloadit, or any other implementation of the protocol. `@upupjs/server` is not involved. `tus-js-client` is an optional peer dependency, kept out of core's mandatory path so projects that never use tus don't pay for it. Install it yourself: ```bash npm i tus-js-client ``` The import is dynamic, so a missing `tus-js-client` fails at upload time rather than at construction time: the first tus upload rejects with a `UpupError` carrying code `UPLOAD_FAILED` and the message _"Resumable (tus) uploads require the optional dependency 'tus-js-client'. Install it: npm i tus-js-client"_. ```tsx ``` ### Options Nine fields. `endpoint` is required; every other field is passed through to `tus-js-client` only when you set it, so an omitted field keeps that library's own default. | Option | Type | Default | What it does | | ----------------------------- | ------------------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | | `endpoint` | `string` | — | **Required.** The tus creation endpoint. Also counts as your upload target. | | `chunkSizeBytes` | `number` | `Infinity` (one request) | Bytes per PATCH request. Set it to get real chunking — and resumability that survives a mid-upload failure. | | `chunkSize` | `number` | — | **Deprecated** alias of `chunkSizeBytes`, still honored. `chunkSizeBytes` wins when both are set. | | `retryDelays` | `number[]` | `[0, 1000, 3000, 5000]` | Backoff schedule, in milliseconds, for automatic retries. Pass `[]` to disable retrying. | | `storeFingerprintForResuming` | `boolean` | `true` | Store the upload URL in `localStorage`, keyed by a file fingerprint, so a later upload of the same file resumes instead of restarting. | | `removeFingerprintOnSuccess` | `boolean` | `false` | Delete that stored entry once the upload succeeds. Turn it on if users re-upload the same filename often. | | `headers` | `Record` | — | Extra headers on every tus request — the usual place for an auth token. | | `metadata` | `Record` | — | Upload-Metadata entries. Merged **over** the `filename` and `filetype` upup sets, so a key of either name overrides upup's value. | | `parallelUploads` | `number` | `1` | Split the file into N partial uploads sent concurrently, then concatenate server-side. Requires the tus **Concatenation** extension. | A completed tus upload reports the tus upload URL as the file's storage key. tus-js-client stores fingerprints for whole uploads, not for the partial uploads created by `parallelUploads`. With `parallelUploads` above 1 the partial uploads are not fingerprinted, so a fresh page load restarts them. Leave it at 1 if resume-after-reload matters more than raw throughput. ## Choosing exactly one upload target `uploadEndpoint`, `serverUrl`, and a tus `endpoint` are three upload targets and you may configure exactly one. Two or more throws a `UpupConfigError` with code `AMBIGUOUS_UPLOAD_TARGET` at construction time. When a tus config is present it takes precedence for every file regardless of size — `thresholdBytes` is a multipart-only concept and has no tus equivalent. ## Crash recovery is a different feature Do not confuse resumable uploads with crash recovery. They solve neighboring problems, are enabled independently, and — for multipart — are two halves of the same reload story: - **Resumable uploads** are about the bytes of one in-flight transfer — parts, retries, and (for tus) reattaching to a partly-uploaded object on the server. - **Crash recovery** (`crashRecovery`) is about the uploader's own state. It writes a snapshot to **IndexedDB** holding the file list — each file's blob plus its id, name, type, source, status, metadata, and storage key if it got one — along with the overall uploader status, and restores that after an unexpected unmount or reload. The snapshot carries **no partial upload progress**: no byte offsets, no part list, no multipart upload id. Crash recovery gives the user their file list back; it does not pick a transfer up mid-stream. Conversely, a persisted multipart session does not remember which files were selected — it only knows how to continue one, once that file is in front of it again. Turned on together in server mode they cover the whole reload: crash recovery restores the file, the multipart session restores the transfer. See [Cross-reload resume](#cross-reload-resume) above, and the [reliability guide](/docs/guides/reliability) for the side-by-side comparison.