From 8e0f44be5e4062cfdda130312608f6b691b71e7c Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Tue, 1 Sep 2026 19:53:30 +0200 Subject: [PATCH 01/41] docs(blob): add Upstash Blob documentation --- blob/browser/abandoned-uploads.mdx | 296 +++++++++++++ blob/browser/constraints.mdx | 244 +++++++++++ blob/browser/large-files.mdx | 325 +++++++++++++++ blob/browser/upload-handler.mdx | 642 +++++++++++++++++++++++++++++ blob/bucket/caching.mdx | 231 +++++++++++ blob/bucket/deleting.mdx | 271 ++++++++++++ blob/bucket/errors.mdx | 377 +++++++++++++++++ blob/bucket/reading.mdx | 326 +++++++++++++++ blob/bucket/writing.mdx | 456 ++++++++++++++++++++ blob/formulas/overview.mdx | 251 +++++++++++ blob/overall/pricing.mdx | 6 + blob/overall/quickstart.mdx | 206 +++++++++ blob/overall/signing.mdx | 340 +++++++++++++++ docs.json | 38 ++ 14 files changed, 4009 insertions(+) create mode 100644 blob/browser/abandoned-uploads.mdx create mode 100644 blob/browser/constraints.mdx create mode 100644 blob/browser/large-files.mdx create mode 100644 blob/browser/upload-handler.mdx create mode 100644 blob/bucket/caching.mdx create mode 100644 blob/bucket/deleting.mdx create mode 100644 blob/bucket/errors.mdx create mode 100644 blob/bucket/reading.mdx create mode 100644 blob/bucket/writing.mdx create mode 100644 blob/formulas/overview.mdx create mode 100644 blob/overall/pricing.mdx create mode 100644 blob/overall/quickstart.mdx create mode 100644 blob/overall/signing.mdx diff --git a/blob/browser/abandoned-uploads.mdx b/blob/browser/abandoned-uploads.mdx new file mode 100644 index 00000000..d32aa3e5 --- /dev/null +++ b/blob/browser/abandoned-uploads.mdx @@ -0,0 +1,296 @@ +--- +title: "Abandoned Uploads" +--- + +A user picks a file, the upload starts, and the tab closes halfway through. Nothing else happens: no callback runs, no row is written, and your server never hears about it again. + +What that leaves in the bucket depends on which side of the multipart threshold the file was on. Over the threshold the SDK can sweep it up for you. Under the threshold it cannot, and this page is mostly about that half: why the SDK cannot tell an abandoned object from an accepted one, and the one pattern that can. + +See [Upload handler](/blob/browser/upload-handler) for the callbacks, and [Large files](/blob/browser/large-files) for the threshold itself. + +--- + +## The two kinds + +The default threshold is 16 MB. Under it a file goes up as one presigned PUT; over it the file is cut into parts. The two halves fail differently. + +| | Under the threshold, one PUT | Over the threshold, multipart | +| --- | --- | --- | +| What is left behind | a whole stored object | the parts that landed, and nothing else | +| Visible to `list()` | yes, an ordinary object | no | +| Billed | yes | yes | +| Blocks deleting the bucket | no | yes | +| Readable on a public bucket | yes, from the moment the last byte landed | no, no object exists yet | +| Who cleans it up | you | `bucket.abortStaleMultipartUploads()` | + +**Over the threshold**, the object does not exist until phase `end` completes the multipart upload. A browser that dies mid-upload leaves an incomplete multipart upload: parts that are billed, that `list()` cannot see, and that stop the bucket from being deleted while they exist. The SDK can sweep those, and the last section shows the cron. + +**Under the threshold**, the presigned PUT is the object write. Storage has the whole object the moment the last byte lands, before your route has been told anything. The completion request that runs `onUploadComplete` is a separate call, made by the browser, after the PUT. A browser that dies between the two leaves an ordinary object: `list()`-visible, billed, already served by the public host if the bucket is public, and accepted by no callback of yours. + +That is the price the threshold bought. One round trip instead of three for the files most apps upload most often, and this window in exchange. + +--- + +## Why the SDK cannot tell the difference + +Every single-PUT upload carries a marker. The SDK mints a random id at phase `begin` and writes it into the presigned URL as `x-amz-meta-upstash-upload`, as a signed header. Signed matters: the browser has to send the header verbatim or storage answers 403, so it cannot be forged, dropped or aimed somewhere else. `metadata.upstash-upload` is reserved, and returning it from `onBeforeUpload` is refused with `invalid_input`. + +At phase `end` the route reads the object back and compares: + +```ts +if (head.metadata[UPLOAD_MARKER] !== t.id) throw new BlobError('not_found', { message: 'the upload never landed' }); +``` + +That answers the one question a multipart upload answers by construction: are the bytes at this path the ones **this** upload put there? It is what lets a refusal in `onUploadComplete` delete only what this upload wrote, instead of deleting whatever happens to be standing at the path. + +What it does not answer is whether anything ever accepted the object. The marker stays on the stored object after completion. Phase `end` deletes it from the record it hands your callbacks, so `metadata` in `onUploadComplete` never contains it, but nothing rewrites the object, so `bucket.info(path).metadata['upstash-upload']` is still set on a finished upload. A marker match proves "same upload". It never proves "never accepted". + +So the SDK, looking at a bucket, cannot separate an abandoned object from a finished one. Only your own rows can. + +See [Signed URLs](/blob/overall/signing) for how a header gets pinned into a signature. + +--- + +## The fix: one pending row + +Write the row before the bytes, flip it after them, and sweep what never flipped. + + + + It runs once per upload, before anything is signed, and it already knows the path. The browser never retries phase `begin`, so one upload is one row. + + + Everything else the callback does happens before the flip. + + + An indexed query on your own table, not a bucket scan. Each row names the exact path to check. + + + +The order in step 2 is the whole pattern. Because the marker survives on accepted objects, the sweep's only safe premise is **row still pending implies the callback never finished**. Work done after the flip breaks that premise: the row says ready, the work never happened, and nothing will ever come back for it. Do the work first, flip last. + +### The route + +```ts lib/uploads.ts +import 'server-only'; +import { BlobError, uniquePath, uploadHandler, uploadRoute } from '@upstash/blob'; +import { sql } from '@/lib/db'; + +const attachment = uploadRoute()({ + constraints: { maxBytes: '20mb', contentTypes: ['image/*', 'application/pdf'] }, + + onBeforeUpload: async ({ request, file }) => { + const user = await getUser(request); + if (!user) throw new BlobError('unauthorized'); + + const rowId = crypto.randomUUID(); + const path = uniquePath`uploads/${user.id}/${file.name}`; + await sql`insert into uploads (id, owner, path, status, created_at) + values (${rowId}, ${user.id}, ${path}, 'pending', now())`; + + // metadata is written onto the object and signed into the PUT, so the cron can read it back. + // state only crosses in the completion token, so the callback can read it without a lookup. + return { path, metadata: { rowid: rowId }, state: { rowId } }; + }, + + onUploadComplete: async ({ path, size, contentType, url, state }) => { + await indexForSearch(path, contentType); + await notifyOwner(state.rowId); + + // Last. Everything above has to be done before the row stops looking abandoned. + await sql`update uploads + set status = 'ready', size = ${size}, url = ${url ?? null} + where id = ${state.rowId}`; + + return { rowId: state.rowId }; + }, +}); + +export const uploads = uploadHandler({ routes: { attachment } }); +``` + +Metadata keys come back from storage lowercased, so `{ rowid: ... }` is written the way it will be read. Values are printable ASCII; anything else is refused with `invalid_input`. + +### The cron + +```ts app/api/cron/sweep-uploads/route.ts +import { BlobError, Bucket } from '@upstash/blob'; +import { sql } from '@/lib/db'; + +const bucket = Bucket.fromEnv(); + +export const GET = async () => { + const rows = await sql`select id, path from uploads + where status = 'pending' and created_at < now() - interval '2 hours' + limit 500`; + + let deleted = 0; + for (const row of rows) { + try { + const info = await bucket.info(row.path); + // info() returns metadata unstripped, so this says the object standing at the path is the + // one this row reserved, and not a later upload's that happens to share it. + if (info.metadata.rowid !== row.id) continue; + await bucket.del(row.path); + deleted++; + } catch (e) { + // Nothing was ever stored: the browser died before the PUT finished. The row is the leftover. + if (!(BlobError.is(e) && e.code === 'not_found')) throw e; + } + await sql`delete from uploads where id = ${row.id}`; + } + + return Response.json({ swept: rows.length, deleted }); +}; +``` + +```json vercel.json +{ "crons": [{ "path": "/api/cron/sweep-uploads", "schedule": "0 * * * *" }] } +``` + +Two constraints come with this example. + +**Verify before deleting.** `bucket.info(path)` is the check, and it is not optional. A row's path can be occupied by a different upload's object by the time the cron runs, and deleting on the row alone would destroy a file somebody's callback accepted. `info()` throws `not_found` rather than returning `undefined`, which is why the example catches `BlobError.is(e) && e.code === 'not_found'` instead of testing for a missing value. + +**Pick a grace window longer than your longest upload.** A row is only evidence once the upload has had time to finish. Completion tokens live seven days and a paused multipart upload can be resumed long after it started, so a window measured in minutes will delete objects out from under uploads that are still running. + + + `uploadId` is not handed to `onBeforeUpload` today. It is minted after the callback returns and + first reaches your code in `onUploadComplete`, which is why the example mints its own row id and + carries it through `metadata` and `state`. `state` reaches `onUploadComplete` typed, and `metadata` + is what `info()` reads back in the cron, so the same id covers both ends. + + +--- + +## The weaker fallback + +An app that will not take a database write on the upload path can list the prefix instead and diff it against whatever rows it does have: + +```ts +const page = await bucket.list({ prefix: 'uploads/', limit: 1000 }); +const known = new Set(await recordedPaths()); +const cutoff = Date.now() - 2 * 60 * 60 * 1000; + +const orphans = page.blobs.filter((b) => !known.has(b.path) && b.uploadedAt.getTime() < cutoff); +if (orphans.length) await bucket.del(orphans.map((b) => b.path)); +``` + +This is strictly weaker. `list()` returns `BlobObject`, which carries the path, size, etag and `uploadedAt` and **no metadata**, so all it can compare is keys. It cannot tell which upload wrote the object, it scans the bucket instead of an index, and it pages through every object under the prefix to find the few that do not belong. Use it when a pending row is genuinely not on the table. + +--- + +## Sweeping incomplete multipart uploads + +Over the threshold, the SDK does this half for you. Put it on a cron: + +```ts app/api/cron/abort-stale-uploads/route.ts +import { Bucket } from '@upstash/blob'; + +const bucket = Bucket.fromEnv(); + +export const GET = async () => { + const aborted = await bucket.abortStaleMultipartUploads({ olderThan: '1d', prefix: 'uploads/' }); + return Response.json({ aborted: aborted.length, paths: aborted.map((u) => u.path) }); +}; +``` + +```json vercel.json +{ "crons": [{ "path": "/api/cron/abort-stale-uploads", "schedule": "0 4 * * *" }] } +``` + +`abortStaleMultipartUploads` lists the bucket's incomplete uploads, keeps the ones started longer ago than `olderThan`, aborts each one along with every part that landed for it, and **returns what it aborted**. That return is the log line: an empty array means there was nothing to reap. + +The two halves are also available on their own: + +```ts +const uploads = await bucket.listMultipartUploads({ prefix: 'uploads/' }); +// [{ path, uploadId, initiatedAt }] + +for (const upload of uploads) { + if (isStale(upload.initiatedAt)) await bucket.abortMultipartUpload(upload); +} +``` + +`abortMultipartUpload` takes the record `listMultipartUploads()` returned rather than two strings, and that is deliberate. Aborting something that is not there is success as far as storage is concerned, so a swapped `(path, uploadId)` pair would abort nothing and report that it worked. Passing the record back is the shape that cannot be swapped. + +`olderThan` is a duration: `'1d'`, `'2h'`, `'30m'`, or a bare number of seconds. Pick one comfortably longer than your slowest upload, for the same reason as the pending row's grace window. + +The one sentence version: **over the threshold the SDK sweeps it, under the threshold you do.** + +See [Deleting](/blob/bucket/deleting) for `del` and the rest of the removal API. + +--- + +## The escape hatch + +`multipart: true` on the handler or on a route pins parts at every size, whatever the file weighs: + +```ts lib/uploads.ts +export const uploads = uploadHandler({ + multipart: true, + onBeforeUpload: ({ file }) => ({ path: uniquePath`uploads/${file.name}` }), + onUploadComplete: async ({ path }) => recordFile(path), +}); +``` + +Now nothing is stored until your handler completes the upload at phase `end`. A closed tab leaves an incomplete multipart upload, which `abortStaleMultipartUploads()` reaps, and the single-PUT orphan class disappears. Parts also buy pause, resume and per-chunk retry for files that would not have had them. + +Be honest about what is left. If phase `end` is retried after the object already completed, `completeMultipart` throws `NoSuchUpload`, the route confirms the object landed and carries on, but `completedEtag` stays undefined. A refusal from `onUploadComplete` at that point deliberately leaves the object stored rather than delete one it cannot identify, and logs that it did. That leftover is a completed object, so `abortStaleMultipartUploads()` cannot reap it either. + +The cost is not extra browser requests. The browser makes `begin`, the PUT, `end` in both modes. It is two extra server to storage round trips, `createMultipart` inside `begin` and `completeMultipart` inside `end`, landing as latency on those two calls. + +For an app that will not run a cron, this is one option value that removes the common case. + +--- + +## Do not let onUploadComplete throw on a database error + + + Any throw out of `onUploadComplete` runs `discard`, which deletes the object. That is the intended + behavior for a refusal, and a disaster for a transient failure: the bytes uploaded fine, your + database blinked for ten seconds, and the object is gone. The browser retries `end` on a + retryable status, the retry finds nothing at the path, and the user is shown a 404 reading + `the upload never landed`. A database blip costs the upload and then reports it as a phantom. + + +Catch your own storage errors and decide deliberately instead of letting a driver error escape the callback. A retryable `BlobError` is not an escape either: any throw deletes the object first, so the retry it asks for arrives at an empty path. Retry the write in place, hand it to a queue, or simply leave the row pending and let the sweep decide later. Throw out of `onUploadComplete` only when you mean to refuse the file, because that throw is what deletes it. + +On a public bucket the delete is also less than it looks. The object has been readable since it was stored, through the whole of your callback, so deleting bounds the exposure to those few round trips rather than undoing it, and an edge that cached the object inside the window keeps serving it for its `Cache-Control`. That is why the type check runs at `begin`, where refusing costs nothing. + +--- + +## Use unique paths unless overwriting is the intent + + + Two single-PUT uploads to the same path do not queue. The second overwrites the first, and the + first upload's `end` then fails its marker check with `not_found`, `the upload never landed`, even + though its bytes did land. The user who uploaded first sees a phantom failure, and the file they + uploaded is gone. + + +The marker is what stops a refusal from deleting somebody else's file. It does not stop a lost update, and it does not stop the spurious 404 the losing upload gets. + +`uniquePath` is the fix. It is a tagged template whose trust boundary is the interpolation: slashes in the literal chunks are structure, slashes and directory components inside `${}` are stripped, and the basename gets eight random base58 characters before its extension. + +```ts +uniquePath`uploads/${user.id}/${file.name}`; +// uploads/u128/holiday-pic-k9cECNWP.png +``` + +Stable paths are a legitimate choice when overwriting is exactly what you want, an avatar at `users/7/avatar.png` for instance. Then use `cache: 'revalidate'` and `versionedUrl`, and know that a concurrent upload to that path can hand the loser a 404. + +--- + +## What cancel() already handles + +An explicit cancel is covered. `upload.cancel()` in the browser posts phase `cancel` with the completion token, and the route acts on which kind of upload it is: + +- **Multipart**: the upload is aborted, along with every part that landed. +- **Single PUT**: the route reads the object at the path and deletes it only if the marker matches this upload's id. The request body names no object, so the marker is the whole check, and a cancel cannot be pointed at an object this upload did not write. + +One case is deliberately not covered. A cancel from `finishing`, once phase `end` is already running, does not post at all. The route has been asked to record the object and the answer is its to give, so racing it would ask the route to delete an object `onUploadComplete` may have just accepted and written a row for. The local task is canceled either way. + +The gap is everything that is not an explicit cancel. There is no `beforeunload` handler and no `sendBeacon` anywhere in the SDK, so a crash, a closed tab or a lost network posts nothing at all. Nothing tells your server, and nothing can. + +That is exactly the gap the pending row closes. diff --git a/blob/browser/constraints.mdx b/blob/browser/constraints.mdx new file mode 100644 index 00000000..0d0d570b --- /dev/null +++ b/blob/browser/constraints.mdx @@ -0,0 +1,244 @@ +--- +title: "Constraints" +--- + +Constraints are the two limits an upload route enforces before it signs anything: how big a file may be, and what type it may claim to be. They are the only place a direct upload can be refused for free, because past `begin` the bytes go straight to storage and never touch your server. + +See [Upload handler](/blob/browser/upload-handler) for the handler shape, its callbacks, and the hooks. + +--- + +## The two constraints + +`constraints` takes `maxBytes`, `contentTypes`, or both. Write it on the handler, on a route, or on both. + +```ts lib/uploads.ts +import { uploadHandler, uniquePath } from '@upstash/blob'; + +export const uploads = uploadHandler({ + constraints: { maxBytes: '20mb', contentTypes: ['image/*', 'application/pdf'] }, + onBeforeUpload: ({ file }) => ({ path: uniquePath`uploads/${file.name}` }), +}); +``` + +Both are enforced at phase `begin`, from the name, type and size the browser declared, before anything is signed and before `onBeforeUpload` runs. Nothing has been written down when a file is refused: no presigned URL exists, no row was inserted, no multipart upload was created. + +Omitting `constraints` entirely accepts any type at any size. + +--- + +## maxBytes + +`maxBytes` takes a `Size`: a number of bytes, or a string like `'20mb'`, `'500kb'`, `'5gb'`. + +Sizes are **decimal**, matching how storage is billed. `'2mb'` is 2,000,000 bytes, not 2,097,152. The units are `b`, `kb`, `mb`, `gb` and `tb`, and binary spellings are not part of the vocabulary: `'5mib'` throws. The only binary math in the SDK is multipart part sizing, because R2's part floor is 5 MiB. + +```ts +constraints: { maxBytes: '2mb' } // 2,000,000 +constraints: { maxBytes: 4096 } // a bare number is bytes +``` + +`formatBytes` is exported from `@upstash/blob`, `@upstash/blob/browser` and `@upstash/blob/react`, and it formats sizes the same decimal way they are parsed, so a refusal reads back in the units the limit was written in: + +``` +cat.png is 2.4 MB, over the 2 MB limit +``` + +An unparseable size throws a `TypeError` naming the option, where the option is written, not once per request. A typo in `maxBytes` fails at startup like any other bad option. + +--- + +## contentTypes + +`contentTypes` is a list. Each entry is either an exact `type/subtype`, or one of exactly three wildcards: `image/*`, `video/*` and `audio/*`. + +Anything else throws `invalid_content_type_pattern`. That includes `*/*`, `text/*`, and strings that are not a media type at all (`png`, `image/`, `/png`). An empty list throws too: omit the option to accept anything, rather than write a list that reads enforced and is not. + +Entries are lowercased, deduplicated, and keep the order you wrote them in. + +### What the wildcards expand to + +A wildcard is the media family, not the subset the byte sniffer happens to recognise, so `audio/*` includes `audio/mp4` rather than refusing every voice memo. + +| Wildcard | Expands to | +| --------- | ---------- | +| `image/*` | `image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/bmp`, `image/tiff`, `image/avif`, `image/heic`, `image/heif`, `image/x-icon` | +| `video/*` | `video/mp4`, `video/quicktime`, `video/webm`, `video/x-matroska`, `video/x-msvideo`, `video/mpeg`, `video/ogg`, `video/3gpp` | +| `audio/*` | `audio/mpeg`, `audio/wav`, `audio/ogg`, `audio/opus`, `audio/flac`, `audio/aac`, `audio/mp4`, `audio/webm` | + + + `image/*` deliberately does not include `image/svg+xml`. An SVG is script, so consenting to it has + to be explicit: list `'image/svg+xml'` yourself if you want it. + + +--- + +## Aliases + +Browsers and operating systems send several spellings for types that have one canonical name. Those spellings are canonicalized on both sides: on the type the browser declared, and on the list you wrote. `contentTypes: ['image/jpg']` and a file declared `image/jpeg` agree, and so do the reverse. + +| Written | Canonicalizes to | +| ------- | ---------------- | +| `image/jpg` | `image/jpeg` | +| `image/pjpeg` | `image/jpeg` | +| `image/vnd.microsoft.icon` | `image/x-icon` | +| `audio/x-wav` | `audio/wav` | +| `audio/wave` | `audio/wav` | +| `audio/vnd.wave` | `audio/wav` | +| `audio/mp3` | `audio/mpeg` | +| `audio/x-flac` | `audio/flac` | +| `audio/x-aac` | `audio/aac` | +| `video/avi` | `video/x-msvideo` | +| `video/msvideo` | `video/x-msvideo` | +| `application/x-gzip` | `application/gzip` | +| `application/x-zip-compressed` | `application/zip` | +| `application/vnd.rar` | `application/x-rar-compressed` | + +Parameters are stripped before the comparison, so `image/png; charset=binary` is `image/png`. + +--- + +## Byte sniffing + +A route with `contentTypes` gets more than the declared type. The browser slices the file's first 4100 bytes (`SNIFF_BYTES`), base64-encodes them, and sends them as `head` with phase `begin`. A mislabelled file is then refused before the upload rather than after it. + +The check runs in two steps: + +1. **The declared type against the allow list.** `report.exe` renamed to `report.png` but declared `application/x-msdownload` is refused here, with the allowed list as the hint. This step runs whether or not the bytes arrived. +2. **The bytes against the declaration, on a proven conflict only.** The leading bytes are sniffed. If they prove nothing, the file passes. If they prove something, it is only a refusal when the declared type is in a small closed set and the bytes name a different type in that set. + +That second condition is what keeps real files from being refused. Bytes that prove a container the declaration sits on top of pass: a `.docx` really is a zip, an `.epub` and a `.jar` and an `.apk` are too, and a `.svgz` really is a gzip. `application/octet-stream` is a shrug, not a claim, so bytes never contradict it. + +The closed set, the types a signature proves outright with no sibling format sharing it: + +`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/bmp`, `audio/wav`, `video/x-msvideo`, `application/pdf`, `application/zip`, `application/gzip`, `application/x-7z-compressed`, `application/x-rar-compressed`, `application/x-bzip2` + +Some formats are deliberately left unnamed by the sniffer, because their signature proves a container and not the type above it: + +| Format | Why | +| ------ | --- | +| ISO-BMFF | `ftyp` is mp4, m4a, heic, avif and quicktime alike | +| EBML | webm and mkv share it | +| Ogg | vorbis, opus and theora share it | +| TIFF | also every raw camera format | +| sfnt fonts | ttf, otf and ttc share it | +| MPEG audio | frame sync varies by version and layer | +| tar | its marker sits at offset 257, behind an attacker-controlled filename | + + + This is ergonomics, not a control. The part bodies never reach your server, so a client is free to + send an honest head and then upload something else entirely. It is not malware scanning, and it is + not a substitute for treating stored objects as untrusted. + + +--- + +## Per-route constraints + +A route's `constraints` **replace** the handler's key by key. A key the route does not mention is inherited. `null` clears a key the handler set. + +```ts lib/uploads.ts +export const uploads = uploadHandler({ + constraints: { maxBytes: '20mb', contentTypes: ['image/png'] }, + routes: { + attachment: { + onBeforeUpload: () => ({ path: 'attachment/1.png' }), + }, + avatar: { + constraints: { maxBytes: '2mb' }, + onBeforeUpload: () => ({ path: 'avatar/demo' }), + }, + large: { + constraints: { maxBytes: '2gb', contentTypes: null }, + onBeforeUpload: () => ({ path: 'large/1.bin' }), + }, + }, +}); +``` + +| Route | `maxBytes` | `contentTypes` | +| ----- | ---------- | -------------- | +| `attachment` | 20,000,000, inherited | `['image/png']`, inherited | +| `avatar` | 2,000,000, replaced | `['image/png']`, inherited | +| `large` | 2,000,000,000, replaced | none, cleared by `null` | + +--- + +## Narrowing per user + +`onBeforeUpload` may return `constraints` to narrow the route's further, once it knows who is uploading. + +```ts lib/uploads.ts +export const uploads = uploadHandler({ + constraints: { maxBytes: '1gb', contentTypes: ['image/*', 'video/*'] }, + + onBeforeUpload: async ({ request, file }) => { + const user = await getUser(request); + return { + path: uniquePath`${user.id}/${file.name}`, + constraints: user.plan === 'free' ? { maxBytes: '25mb', contentTypes: ['image/*'] } : undefined, + }; + }, +}); +``` + +The narrowed constraints are checked against the same file, with the same head bytes, right after `onBeforeUpload` returns. + +Widening throws a `TypeError`: `onBeforeUpload widened maxBytes` for a larger cap, `onBeforeUpload widened contentTypes` for a type the route does not already allow, naming the types that were added. The route's own limits are always the ceiling, so reading the code of a route tells you the most it can ever accept. + +--- + +## In the browser + +`GET` on the upload route serves the constraints it enforces: + +```json +{ "constraints": { "contentTypes": ["image/png"], "maxBytes": 2000000 } } +``` + +It carries an ETag and `Cache-Control: public, max-age=60`, and the hook caches it for the same 60 seconds (`CONSTRAINTS_TTL_MS`). Short and revalidated, not immutable: the constraints are your route's code and change with a deploy, and a client that cached them forever would refuse files the route now accepts. + +`useUpload` exposes two things from it. `accept` is `contentTypes` joined with commas, ready for an ``, and empty until the GET lands or when the route serves no type list. `constraints` is the served document itself, so a page can state the cap it enforces. + +```tsx components/upload-button.tsx +'use client'; +import { formatBytes } from '@upstash/blob/react'; +import { useUpload } from '@/lib/upload-hooks'; + +export function UploadButton() { + const { start, upload, accept, constraints } = useUpload(); + + return ( + <> + start({ file: e.target.files?.[0] })} /> + {constraints?.maxBytes !== undefined &&

Up to {formatBytes(constraints.maxBytes)}

} + {upload?.error &&

{upload.error.message}

} + + ); +} +``` + +A file over `maxBytes` is refused in the browser before any request is made. It still becomes a record, with `status: 'error'` and a real `BlobError` whose code is `too_large`, so one error path renders both the client-side refusal and the server's. + +The size check is the only one that runs in the browser. Type validation stays on the server, which canonicalizes aliases and sniffs the leading bytes, neither of which the served `accept` list can express. **The server is authoritative.** Constraints that have not arrived yet are not an answer either: the file is sent and the route decides. + +A route that serves no `contentTypes` has nothing to check leading bytes against, so the hook does not read them off the file and does not send them. + +--- + +## Error codes + +| Code | Status | When | +| ---- | ------ | ---- | +| `too_large` | 413 | The file is over `maxBytes`, from the browser or from `begin` | +| `content_type_not_allowed` | 400 | The declared type is not in the list, or the bytes contradict it | +| `invalid_content_type_pattern` | 500 | `contentTypes` is not a valid list: a bad wildcard, a malformed type, or empty | +| `empty_body` | 400 | A zero-byte file, refused at `begin` | + +Every refusal reaches the browser as a `BlobError` with its code intact, so switch on `error.code` rather than on status numbers. See [Errors](/blob/bucket/errors) for the full list. + +--- + +## Server-side writes + +`bucket.put()` takes the same `contentTypes` and `maxBytes` options, with the same grammar, the same aliases and the same byte check, for bytes that pass through your own route. See [Writing](/blob/bucket/writing). diff --git a/blob/browser/large-files.mdx b/blob/browser/large-files.mdx new file mode 100644 index 00000000..2a2177f6 --- /dev/null +++ b/blob/browser/large-files.mdx @@ -0,0 +1,325 @@ +--- +title: "Large Files" +--- + +Every upload crosses one line. Under it a file goes up as a single presigned PUT. Over it the file is cut into real multipart parts, and parts are what buy pause, resume and per-part retry. This page is about where that line sits, what changes on each side of it, and how the browser runs a parted transfer. + +--- + +## The threshold + +The default is **16 MB decimal**, 16,000,000 bytes. Sizes in the SDK are decimal everywhere, the way storage is billed, so `'16mb'` means 16,000,000 and not 16,777,216. + +The comparison is strict. A 16,000,000 byte body is a single PUT. 16,000,001 is multipart. + +The same line governs both halves of the SDK: `bucket.put()` on your server and a direct browser upload split at the same size, so a file does not behave differently depending on which door it came in through. + +Parts are not free. A single PUT is one round trip; a multipart upload is three plus one per chunk, and an upload that is begun and never finished lingers in storage until something aborts it. Parts are what a big file needs, past the single-PUT ceiling and for a chunk that can be retried or resumed on its own. They are not the right shape for every upload. + +--- + +## What changes at the line + +| | Under the threshold | Over the threshold | +| -------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------- | +| Transport | one presigned object PUT | one presigned PUT per part | +| Browser round trips | `begin`, the PUT, `end` | `begin`, one PUT per part, one `parts` call per 16 parts after the first, `end` | +| Extra server-to-storage calls | none | `createMultipart` inside `begin`, `completeMultipart` inside `end` | +| When the object exists | the moment the last byte lands | when phase `end` completes the upload | +| `canPause` | `false` | `true` while uploading | +| A failed chunk | the whole PUT is sent again | only that part is sent again | +| Ceiling | ~5 GiB (5,368,709,120 bytes) | no practical limit | +| A tab that dies mid-upload | a whole stored object no callback accepted | parts, invisible to `list()`, reaped by `abortStaleMultipartUploads()` | + +The row that matters most is when the object comes into existence. Under the threshold the presigned PUT stores the object itself, so by the time phase `end` runs there is already a real, billed, listable object at that path, and a throw out of `onUploadComplete` has to delete it again. Over the threshold nothing exists at the path until `end` calls `completeMultipart`, so an upload that never reaches `end` leaves parts rather than a file. + +That is the whole of what an abandoned upload costs on each side of the line. See [Abandoned uploads](/blob/browser/abandoned-uploads) for the sweep, and for why `multipart: true` is the escape hatch an app that will not run a cron reaches for. + +Storage refuses a single PUT larger than 5,368,709,120 bytes, so past that size parts are used whatever `multipart` says. + +--- + +## Moving the line + +`multipart` takes a size, `true` or `false`. A size becomes the threshold for that handler, route or write; `true` always parts; `false` never does. + +On a handler it is the default for every route: + +```ts lib/uploads.ts +import { uploadHandler, uniquePath } from "@upstash/blob" + +export const uploads = uploadHandler({ + // Everything up to 100 MB goes up as one PUT. + multipart: "100mb", + onBeforeUpload: ({ file }) => ({ path: uniquePath`uploads/${file.name}` }), +}) +``` + +A route replaces that value rather than merging with it: + +```ts lib/uploads.ts +import { uploadHandler, uniquePath } from "@upstash/blob" + +export const uploads = uploadHandler({ + multipart: "100mb", + routes: { + avatar: { + multipart: false, + onBeforeUpload: ({ file }) => ({ path: uniquePath`avatars/${file.name}` }), + }, + video: { + multipart: true, + onBeforeUpload: ({ file }) => ({ path: uniquePath`videos/${file.name}` }), + }, + }, +}) +``` + +And `bucket.put()` takes the same option per write: + +```ts lib/videos.ts +import { Bucket } from "@upstash/blob" + +const bucket = Bucket.fromEnv() + +await bucket.put("videos/clip.mp4", file, { multipart: true }) +await bucket.put("logs/today.ndjson", stream, { size, multipart: "50mb" }) +``` + +A few edges: + +- `multipart: false` on a body over the single-PUT ceiling throws `too_large`, with the hint `multipart: false forbids the parts this body needs`. It is a refusal rather than a silent override, because the option said something the request cannot honour. +- On `bucket.put()`, `overwrite: false` and `ifUnchanged` are single-PUT only, since the conditional header rides on the object write. Passing either turns multipart off. Passing `multipart: true` together with either throws `invalid_input`. +- An unparseable size (`'100 megs'`) throws where the option is written, not per request. A handler resolves it once at construction, so a typo is a startup error and never a 500 raised after `onBeforeUpload` has already inserted your row. + + +The cost of `multipart: true` is not extra browser requests. The browser makes `begin`, the PUTs and `end` either way. It is two extra server-to-storage round trips, `createMultipart` inside `begin` and `completeMultipart` inside `end`, landing as latency inside those two calls. + + +--- + +## Part sizing + +The part size is derived from the file size, never configured: + +```text +partSize = max(5 MiB, ceil(size / 250) rounded up to a whole MiB) +partCount = ceil(size / partSize) +``` + +Two constants shape it, and part math is the only binary math in the SDK: + +- **5 MiB** is the floor storage enforces on every part but the last. It is checked when the upload is completed, so a part below it fails the whole upload at the very end. The SDK never goes under it. +- **250** is the part count the SDK aims at. Targeting a count rather than a size keeps the number of requests roughly flat as files grow, and stays two orders of magnitude below the 10,000 part ceiling. + +| File size | Part size | Parts | +| --------- | --------- | ----- | +| 20 MB | 5 MiB | 4 | +| 200 MB | 5 MiB | 39 | +| 2 GB | 8 MiB | 239 | +| 20 GB | 77 MiB | 248 | + +The last part is the short one. The part size is also the blast radius of one crashed part: on a 5 GB file, 20 MiB parts mean a failure costs at most 20 MiB of re-sent bytes. + +--- + +## The transfer + +Phase `begin` answers with a plan: `partSize`, the list of `parts` to send, and `multipart`. Part URLs are presigned in batches of 16, so a 239 part upload does not sign 239 URLs before the first byte moves. The client asks for the next batch through phase `parts` when it reaches a part it has no URL for. + +```text +begin -> route: onBeforeUpload, createMultipart, presign parts 1..16 + | + v + +--------- 4 parts in flight per upload ----------+ + | PUT part 1 PUT part 2 PUT part 3 PUT 4 | + +-------------------------------------------------+ + | every request in the page shares one cap of 6 + v +parts -> route: presign 17..32, report the parts that landed + | + v +end -> route: completeMultipart, onUploadComplete +``` + +The numbers: + +| Constant | Value | Scope | +| -------------------- | ----- | ------------------------------------------------------------ | +| `PARTS_PER_BATCH` | 16 | part URLs presigned per `parts` call | +| `PARTS_IN_FLIGHT` | 4 | parts uploading at once, per upload | +| `GLOBAL_REQUEST_CAP` | 6 | requests in flight in the whole page, files and parts alike | + +The global cap is shared on purpose. The browser does not care which of our requests a connection belongs to, so three files uploading four parts each would otherwise queue against each other inside the browser, where the SDK cannot see it. One pool means the queueing is ours and the progress numbers stay honest. + +Uploads go out over `XMLHttpRequest` rather than `fetch`, for one reason: `fetch` has no upload progress event. + +A single PUT is expressed as one part covering the whole file, with `partSize` equal to the file size. The browser runs the same loop over one URL, and only the server knows that URL is an object PUT and not a part. + +--- + +## Progress and status + +Every record carries the same fields whichever side of the line it is on: + +| Field | Meaning | +| --------- | ----------------------------------------------------------------------------- | +| `loaded` | bytes of parts that landed, plus bytes on the wire right now | +| `total` | the file's size | +| `percent` | `floor(loaded / total * 100)`, capped at 99 until the status is `done` | +| `pending` | not settled: `queued`, `uploading`, `finishing` or `paused` | +| `stalled` | every in-flight part is waiting on a backoff | + +`percent` is capped at 99 because 100 has to mean stored, not sent. The bar sits there through status `finishing`, which is the stretch where every byte has landed and phase `end` is completing the upload and running `onUploadComplete`. That can take as long as your callback does. Naming the state is the difference between a bar that is working and one that looks stuck. + +In-flight bytes are counted, and a failed part's bytes retreat rather than lying: a part that got 3 MiB out and then took a 500 drops back to zero, because those bytes were never stored. `loaded` never counts a part twice, and a landed part is banked before it leaves the in-flight map, so the bar does not dip between the two. + +```tsx app/upload.tsx +{upload.pending && } +{upload.status === "finishing" &&

Finishing up...

} +{upload.stalled &&

Connection is struggling, retrying...

} +``` + +--- + +## Pause and resume + +`canPause` answers whether `pause()` would do anything: + +| Situation | `canPause` | +| -------------------------- | ---------- | +| single PUT | `false` | +| `queued` | `false` | +| `uploading`, multipart | `true` | +| `paused` | `true` | +| `finishing` | `false` | +| `done`, `error`, `canceled`| `false` | + +A single PUT is one request that is either on the wire or not. Stopping it throws its bytes away rather than parking them, so it is not offered as a pause. While `queued` the answer is also `false`, because which of the two an upload is only becomes known when the route answers `begin`. And from `finishing` there is nothing left to hold back: every part has landed and `end` is already running. + +**Pause stops the queue, not the transfer.** Bytes on the wire are already paid for, so a part that has sent anything finishes and keeps its etag. Only a part that has sent nothing, parked on a backoff or waiting for a pool slot, is dropped and handed back to the queue. Aborting all four threw away up to a part each and snapped the bar back. + +`resume()` restarts the workers. Nothing that landed is sent again. + +```tsx app/upload.tsx +{upload.canPause && upload.status === "uploading" && ( + +)} +{upload.status === "paused" && ( + +)} +``` + +--- + +## Resuming after a reload + +A closed tab is not a canceled upload. At phase `begin` the client writes the upload's completion token to `localStorage`, keyed by a fingerprint of the route, the file name, the size and `lastModified`: + +```text +upstash-blob:v1:/api/upload|movie.mp4|4823110458|1700000000000 +``` + +**The user picking the same file again is the resume gesture.** There is no API to call. A fingerprint match makes the SDK send phase `parts` with the stored token instead of `begin`, the server asks storage for the parts that actually landed (`ListParts`), and only the missing parts are sent. The row `onBeforeUpload` inserted is not written twice, because `onBeforeUpload` never runs again. + +Three properties worth stating: + +- **Nothing about what landed is trusted from `localStorage`.** The token is the only thing kept. What landed is the server's answer, read from storage. +- **A mismatch is a fresh upload, never an error.** A different file, a different size, an expired token, a route that has forgotten the upload: any of these fall back to `begin`. +- **A single PUT has nothing to resume.** There are no parts on the other side, so the file is simply sent again under the same token and to the same path. + +It is best effort. Quota, private mode or a browser with no `localStorage` at all just means no resume, never a failed upload. + +--- + +## Retries + +Every response to a part PUT is classified: + +| Response | Verdict | +| ------------------------------------- | ------------ | +| network failure (no status) | retry | +| 408, 429, 500, 502, 503, 504 | retry | +| 401, 403 | re-presign | +| anything else | fail | + +A 401 or 403 is treated as a clock problem first, not a body problem: an expired presign looks exactly like a tampered request. The SDK drops the batch, asks the route for fresh URLs and sends the part again. Only a URL minted moments ago and refused a second time is reported as `signature_mismatch`. + +The budgets: + +| Constant | Value | Why | +| --------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------- | +| `MAX_ATTEMPTS` | 8 | attempts against a response the server actually wrote | +| `MAX_NETWORK_ATTEMPTS` | 20 | a dropped link is not a refusal: 8 attempts give up ~25s in, which a phone changing cell outlives | +| `NO_BYTES_NETWORK_ATTEMPTS` | 3 | a request that failed without putting a byte on the wire is almost always CORS, which retrying does not fix | +| `STALL_TIMEOUT_MS` | 60,000 ms | silence, not duration: a 5 MiB part on a slow link takes minutes but is never quiet for a minute | +| backoff | 500 ms to 15 s | full jitter, uniform in `[0, min(15s, 500ms * 2^attempt)]` | +| `Retry-After` | honoured to 60s | a seconds count or an HTTP date, clamped | + +The CORS case is the one worth knowing about. A browser blocks a request that fails preflight before a single byte goes out, and script never sees why. Backing off for four and a half minutes only delays the same answer, so the SDK gives up after three attempts and says so in the error's hint. If the browser reports it is offline, the larger network budget applies instead, because that does come back. + +The stall watchdog measures silence rather than total time. `xhr.timeout` is a deadline for the whole request, which a large part on a slow link outruns honestly. Sixty seconds with no upload progress event and no response is the failure, and it covers the wait for the response too, so a connection that dies after the last byte does not hang until the tab closes. + +Calls to your own route follow a smaller policy: `end` and `parts` are retried up to three times on a network failure or a retryable status, and `begin` is never retried, because it runs `onBeforeUpload`. + +When the budget runs out the record settles as `error`, carrying a `BlobError` with the code the failure earned. The codes are listed in [Errors](/blob/bucket/errors). + +`retry()` on a failed record runs the same upload again from the parts that landed. The upload the route began still exists, every landed part is still landed, no new `begin` is sent, and `done` is replaced with a fresh promise, since the old one already rejected. + +```tsx app/upload.tsx +{upload.status === "error" && ( + +)} +``` + +--- + +## Cancel + +`cancel()` aborts the parts in flight and posts phase `cancel`, which aborts the multipart upload server-side so the parts stop costing storage. For a single PUT, the same call deletes the object if the bytes already landed and the upload marker proves they are this upload's. + +From status `finishing` the server call is dropped and only the local task is canceled. The route has already been asked to record the object, and the answer is its to give: a cancel that raced `end` and won would ask the route to delete an object `onUploadComplete` may have just accepted and written a row for. The worst case that way round is an object your app has no row for, rather than a row your app has no object for. + +--- + +## Large writes from the server + +`bucket.put()` crosses the same line, with the same options. Over the threshold it streams the body one part at a time: a part is buffered whole so it can be retried, and holding several would multiply that by the concurrency. + +Any failure aborts the upload rather than leaving parts behind, because nothing lists an incomplete upload and an invisible one is invisible billing. + +```ts lib/backup.ts +import { Bucket } from "@upstash/blob" + +const bucket = Bucket.fromEnv() + +export async function archive(stream: ReadableStream, size: number) { + // Pass size for a stream whose length is not otherwise known, or put() buffers + // it to find the content length. + return await bucket.put("backups/2026-01.tar", stream, { size }) +} +``` + +Without `size` and without `maxBytes`, an unknown-length stream throws `length_required`. The rest of the write API is in [Writing](/blob/bucket/writing). + +--- + +## Next steps + + + + What a closed tab leaves behind on each side of the threshold, and how to sweep it. + + + + Routes, context, and the callbacks that run around a transfer. + + + + Size limits and content types, refused before a byte is signed. + + + + `put`, metadata, conditional writes and multipart from the server. + + diff --git a/blob/browser/upload-handler.mdx b/blob/browser/upload-handler.mdx new file mode 100644 index 00000000..e3f1c275 --- /dev/null +++ b/blob/browser/upload-handler.mdx @@ -0,0 +1,642 @@ +--- +title: "Upload Handler" +--- + +`uploadHandler` is one upload endpoint. The bytes go straight from the browser to storage: your server only authorizes the upload, signs it, and records what landed. The file never passes through your app, so nothing is bound by your platform's request body limit and nothing streams through your function's memory. + +Three requests reach your route per upload. `begin` runs your authorization and hands the browser presigned URLs, the browser PUTs the bytes to storage, and `end` records the object and runs your completion callback. + +--- + +## A minimal handler + +Four files. The handler, the route it is mounted at, the bound hooks, and the component. + +```ts lib/uploads.ts +import "server-only" +import { BlobError, uniquePath, uploadHandler } from "@upstash/blob" + +export const uploads = uploadHandler({ + constraints: { maxBytes: "20mb", contentTypes: ["image/*", "application/pdf"] }, + + onBeforeUpload: async ({ request, file }) => { + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") // the 401; nothing is signed + return { path: uniquePath`${user.id}/${file.name}`, metadata: { owner: user.id } } + }, + + onUploadComplete: async ({ uploadId, metadata, path, url }) => { + await sql`insert into files (upload_id, owner, path, url) + values (${uploadId}, ${metadata.owner}, ${path}, ${url}) + on conflict (upload_id) do nothing` + return { path } + }, +}) +``` + +```ts app/api/upload/route.ts +import { uploads } from "@/lib/uploads" + +export const { GET, POST } = uploads +``` + +`uploadHooks` binds the client to the handler's type, so route names and completion data are checked at compile time: + +```ts lib/upload-hooks.ts +"use client" +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "./uploads" + +export const { useUpload } = uploadHooks() +``` + +```tsx app/page.tsx +"use client" +import { useUpload } from "@/lib/upload-hooks" + +export function Uploader() { + const { start, upload, accept } = useUpload() + + return ( + <> + start({ file: e.target.files?.[0] })} /> + {upload?.pending && } + {upload?.status === "error" &&

{upload.error.message}

} + {upload?.status === "done" &&

Saved as {upload.blob.data.path}

} + + ) +} +``` + +The client assumes the handler is mounted at `/api/upload`. That is the only default; `endpoint` on `uploadHooks` or on `useUpload` moves it. + + + New to Upstash Blob? Start at the [quickstart](/blob/overall/quickstart). If the bytes have to pass + through your app instead, write an ordinary route that calls `bucket.put` ([writing](/blob/bucket/writing)) + and drive it with [`useServerUpload`](#useserverupload). + + +--- + +## Handler options + +```ts lib/uploads.ts +export const uploads = uploadHandler({ + bucket, + constraints: { maxBytes: "20mb", contentTypes: ["image/*"] }, + multipart: "100mb", + endpoint: "/api/upload", + context: (request) => requireUser(request), + input: schema, + onBeforeUpload, + onUploadComplete, + onError, + routes: { avatar, attachment }, +}) +``` + +| Option | Type | What it does | +| --- | --- | --- | +| `bucket` | `Bucket` | The bucket every route writes to. Defaults to `UPSTASH_BLOB_TOKEN`. | +| `constraints` | `{ contentTypes?, maxBytes? }` | What the route accepts. Served by `GET` and enforced at `begin`. | +| `multipart` | `boolean \| Size` | Where an upload stops being one PUT and starts going up in parts. 16 MB by default. | +| `endpoint` | `string` | Where the handler is mounted. Only needed to separate two handlers on one bucket. | +| `context` | `(request: Request) => TCtx` | Runs once per POST. Its value is `ctx` in every callback. | +| `input` | Standard Schema | Validates what the browser sends as `input` before `onBeforeUpload` runs. | +| `onBeforeUpload` | `(args) => { path, ... }` | Authorizes the upload and names the path. Required. | +| `onUploadComplete` | `(args) => TData` | Records the object. What it returns becomes `upload.blob.data`. | +| `onError` | `(args) => BlobError \| Response \| void` | Sees every refusal. The one place to log. | +| `routes` | `Record` | Mounts several routes at this one endpoint. | + +Everything except `routes`, `endpoint` and `context` is a **default**. A route replaces the ones it names and inherits the rest, key by key, so a handler with five routes states the shared policy once. `constraints` merges one level deeper: a route's `constraints` replaces `maxBytes` and `contentTypes` individually, and `null` clears a key the handler set. See [constraints](/blob/browser/constraints) for the grammar and what a wildcard expands to. + +`onBeforeUpload` is the one callback that has to exist. A route with none of its own, mounted in a handler with none either, is a build error naming the route. + +### The bucket + +With no `bucket` written anywhere, the handler reads `UPSTASH_BLOB_TOKEN` once and builds one bucket for every route, the way `Bucket.fromEnv()` does. Pass `bucket:` when + +- the token lives under another variable, +- the bucket needs `cache` or `visibility`, +- or you are on Cloudflare Workers, where the token only exists on the request's `env` and there is no `process.env` to read. + +```ts lib/uploads.ts +import { Bucket, uploadHandler } from "@upstash/blob" + +const bucket = new Bucket({ token: process.env.MEDIA_BLOB_TOKEN!, cache: "immutable" }) + +export const uploads = uploadHandler({ bucket, onBeforeUpload }) +``` + +Options that cannot be parsed are build errors, not 500s at request time: an unparseable `multipart` size, a route name a URL query cannot carry, an empty `routes` map, and a missing token all throw where they were written. + +--- + +## onBeforeUpload + +Runs on the **first request of an upload only**, before anything is signed and before any bytes exist. It decides whether the upload happens and where the object goes. + +```ts lib/uploads.ts +onBeforeUpload: async ({ ctx, route, request, file, input }) => { + return { path: uniquePath`${ctx.userId}/${file.name}`, metadata: { owner: ctx.userId } } +} +``` + +| Argument | Type | | +| --- | --- | --- | +| `ctx` | `TCtx` | Whatever `context` returned for this request. | +| `route` | `string` | The route this file was sent to. `''` when the handler mounts no named routes. | +| `request` | `Request` | The `begin` request, headers and cookies intact. | +| `file` | `{ name, type, size }` | What the browser declared, before a byte was sent. | +| `input` | `TInput` | The validated `input`, when the route declares a schema. | + +What it returns: + +| Field | Type | | +| --- | --- | --- | +| `path` | `string` | Required. Where the object is stored. | +| `cache` | `CacheOption` | The `Cache-Control` this object is stored with, over the bucket default. See [caching](/blob/bucket/caching). | +| `metadata` | `Record` | Signed into the upload and handed back to `onUploadComplete`. | +| `constraints` | `{ contentTypes?, maxBytes? }` | Narrows this one upload's limits. | +| `state` | `TState` | Carried to `onUploadComplete` and `onError`. Typed only under `uploadRoute()`. | + +`file` is the browser's own claim, so `file.type` is the type the object is stored and served as. The type the bytes really are is checked at `begin` too, against the file's first bytes; that check is described in [constraints](/blob/browser/constraints). + +### Paths + +`path` is required, and a return without one is a `TypeError`. Paths may not contain `.` or `..` segments. + +The `uniquePath` template tag builds one that is safe to hand a browser filename: + +```ts lib/uploads.ts +import { uniquePath } from "@upstash/blob" + +uniquePath`chat/${threadId}/${file.name}` +// chat/42/holiday-pic-7Kd2mQ9x.png +``` + +Slashes in the literal chunks are structure. Everything inside `${...}` is a value, and a value cannot contribute structure: + +- directory components are dropped, so `../admin/x.png` contributes `x.png` +- control and format characters are stripped +- the stem is slugged (letters and digits survive, including non-Latin ones; everything else becomes `-`), lowercased, and capped at 64 characters +- the final extension is preserved and lowercased; a stem that slugs to nothing becomes `file` +- a `-` and 8 base58 characters are appended, so two uploads of the same filename never collide + +Without the suffix, a stable path is an overwrite: the second upload replaces the first, and the first upload's `end` then answers 404 even though its bytes landed. Use a stable path only when overwriting is the intent. + +### Metadata + +`metadata` is signed into the presigned PUT, so the browser can neither add to it nor change it, and it comes back on `onUploadComplete` as `metadata`. It is stored on the object and readable later with `bucket.info(path)`. + +Values are printable ASCII; anything else is refused with `invalid_input` rather than silently re-encoded by storage. Keys must be valid header names and are lowercased on the way in, so read them back lowercased. Percent-encode anything else with `encodeURIComponent`. + +`metadata["upstash-upload"]` is reserved: the SDK writes its own marker under that key to prove which upload wrote the object at a path, and setting it throws `invalid_input`. + +### Narrowing per user + +`constraints` returned here applies to this upload alone, and may only make the route stricter: + +```ts lib/uploads.ts +onBeforeUpload: async ({ ctx, file }) => ({ + path: uniquePath`${ctx.userId}/${file.name}`, + constraints: ctx.plan === "free" ? { maxBytes: "5mb" } : undefined, +}) +``` + +Widening throws a `TypeError` naming what was widened, for `maxBytes` and for a content type that is not on the route's list. That is a bug in the handler, not a refusal of the file, so it surfaces as a server error rather than a `BlobError`. + +### Refusing + +Throw. A `BlobError("unauthorized")` is the 401, and nothing is signed, no multipart is created, and no URL is handed out: + +```ts lib/uploads.ts +onBeforeUpload: async ({ request, file }) => { + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") + if (await overQuota(user)) throw new BlobError("too_large", { message: "you are out of space" }) + return { path: uniquePath`${user.id}/${file.name}` } +} +``` + +Every `BlobError` reaches the browser with its `code` intact, so a hook can switch on `error.code` instead of reading status numbers. The codes are listed in [errors](/blob/bucket/errors). + +The browser never retries `begin`: it runs your callback, and a callback that writes a row must not be run twice for one file. + +--- + +## onUploadComplete + +Runs on the last request of an upload, once the object exists. It gets the completed object flattened into its arguments, plus everything this route knew about the upload. + +```ts lib/uploads.ts +onUploadComplete: async ({ uploadId, path, url, size, contentType, metadata, state, ctx }) => { + await sql`insert into files (upload_id, owner, path, url, size, content_type) + values (${uploadId}, ${ctx.userId}, ${path}, ${url}, ${size}, ${contentType}) + on conflict (upload_id) do nothing` + return { path } +} +``` + +| Argument | Type | | +| --- | --- | --- | +| `path` | `string` | Where the object is stored. | +| `url` | `string \| undefined` | The public URL. Undefined on a private bucket; use [signed URLs](/blob/overall/signing). | +| `versionedUrl` | `string \| undefined` | `${url}?v=${etag}`, for a stable path that gets overwritten. | +| `size` | `number` | Bytes actually stored, verified against what the browser declared. | +| `etag` | `string` | The stored object's etag. | +| `uploadedAt` | `Date` | When storage wrote it. | +| `contentType` | `string` | What the object is stored as. This is the one to record. | +| `ctx` | `TCtx` | What `context` returned for this request. | +| `route` | `string` | The route name, `''` for a sole route. | +| `request` | `Request` | The `end` request. | +| `file` | `{ name, type, size }` | What the browser declared at `begin`. The original filename survives only here. | +| `uploadId` | `string` | Identifies this upload. Stable across retries: the idempotency key. | +| `multipartUploadId` | `string \| undefined` | R2's own multipart id, for `bucket.abortMultipartUpload()`. Undefined for a single PUT. | +| `metadata` | `Record` | What `onBeforeUpload` returned, minus the SDK's marker. | +| `state` | `TState` | What `onBeforeUpload` returned as `state`. | + +What it returns is handed to the browser as `upload.blob.data`, fully typed through `uploadHooks`: + +```tsx app/page.tsx +const { upload } = useUpload() +if (upload?.status === "done") upload.blob.data.path // string, inferred from onUploadComplete +``` + + + **It is at-least-once.** The browser retries `end` up to three times, on a network failure and on + 408, 429 and any 5xx. `uploadId` is stable across those retries and is the key to write against: + `on conflict (upload_id) do nothing`, or the equivalent upsert for your database. + + **Any throw out of it deletes the completed object.** That is the intent for a refusal, and it is a + trap for a database error: a ten-second outage destroys bytes that uploaded fine, the browser + retries `end`, and the user is shown a 404 reading "the upload never landed". Catch your own + storage errors and decide deliberately instead of letting a driver error escape the callback. + + +```ts lib/uploads.ts +onUploadComplete: async ({ uploadId, path, url, metadata }) => { + try { + await sql`insert into files (upload_id, owner, path, url) + values (${uploadId}, ${metadata.owner}, ${path}, ${url}) + on conflict (upload_id) do nothing` + } catch (e) { + console.error("[uploads] could not record", path, e) + // A throw is a refusal: this deletes the object and the retried end answers 404. Throw when + // losing the file is better than keeping an unrecorded one; otherwise return and reconcile. + throw new BlobError("not_ready", { message: "could not record the upload, try again" }) + } + return { path } +} +``` + +Writing the row in `onBeforeUpload` as pending and flipping it to ready here, last, is the pattern that survives a browser that dies mid-upload. [Abandoned uploads](/blob/browser/abandoned-uploads) covers it, and the cron that sweeps the rest. + +--- + +## onError + +Sees every refusal this endpoint produces, including the handler's own and a request for a route nobody mounted. + +```ts lib/uploads.ts +onError: ({ ctx, route, request, error, file, path, metadata, state }) => { + logger.warn({ route, path, user: ctx?.userId, error }) + if (error instanceof PaymentRequired) return new BlobError("forbidden", { message: "plan expired" }) +} +``` + +| Argument | | | +| --- | --- | --- | +| `ctx` | `TCtx \| undefined` | Undefined when `context` itself threw, or when no route matched. | +| `route` | `string` | The name from the query, even when nothing mounts it. | +| `request` | `Request` | | +| `error` | `unknown` | Whatever was thrown. | +| `file`, `path`, `metadata`, `state` | optional | As much as the request had reached before it failed. | + +Return a `BlobError` or a `Response` to answer with it. Return nothing and the answer is left alone. It is the one place to log: the callbacks themselves stay about the happy path. + +--- + +## context + +`context` runs once per POST, before the route is picked and before any body is read. Its resolved value, awaited, is `ctx` in every callback and is typed there. It does not run for `GET`, which serves a public, cacheable constraints document and reads nothing. + +```ts lib/uploads.ts +export const uploads = uploadHandler({ + context: (request) => requireUser(request), // throws BlobError('unauthorized') on a dead session + + routes: { + avatar: { + onBeforeUpload: ({ ctx, file }) => ({ path: uniquePath`avatars/${ctx.id}/${file.name}` }), + onUploadComplete: ({ ctx, url }) => db.users.update(ctx.id, { avatarUrl: url }), + }, + attachment: { + onBeforeUpload: ({ ctx, file }) => ({ path: uniquePath`files/${ctx.id}/${file.name}` }), + }, + }, +}) +``` + +Two things make it worth reaching for. `onBeforeUpload` only runs on the first request of an upload, so `context` is how an authenticated value reaches `onUploadComplete` and `onError` as well. And several routes share one auth check instead of repeating it. + +With a single route, authorizing inside `onBeforeUpload` and carrying an id in `metadata` is shorter, and `metadata` comes back on completion anyway. + +### The ordering rule + +Write `context` **above** `routes` and the callbacks that read `ctx`, or annotate its parameter. Straight from the SDK's own source: + +> One rule about `context`: write it above the callbacks that read `ctx`. `(request) =>` with no annotation is fine there. Written below `routes`, TypeScript has already typed the routes with `ctx: undefined` by the time it reads what `context` returns, and the error lands on `context` itself: `Promise is not assignable to undefined`. Annotating the parameter, `(request: Request) =>`, lifts the order rule, because TypeScript reads an annotated function's return before it types anything else in the literal. + +```ts lib/uploads.ts +// Fine: context first. +uploadHandler({ context: (request) => requireUser(request), routes: { a: routeA } }) + +// Fine: annotated, so the order stops mattering. +uploadHandler({ routes: { a: routeA }, context: (request: Request) => requireUser(request) }) +``` + +For a callback written in another file, annotate the argument with `UploadContext`, which takes the handler and hands back the ctx type: + +```ts lib/callbacks.ts +import { uniquePath } from "@upstash/blob" +import type { BeforeUploadArgs, UploadCompleteArgs, UploadContext } from "@upstash/blob" +import type { uploads } from "./uploads" + +export function shared({ ctx, file }: BeforeUploadArgs) { + return { path: uniquePath`${ctx.id}/${file.name}` } +} + +export async function record({ ctx, url }: UploadCompleteArgs) { + await db.files.insert({ owner: ctx.id, url }) +} + +type Session = UploadContext +``` + +--- + +## Multiple routes + +`routes` mounts several routes at one endpoint. The name travels in the query, `?route=avatar`, and the client names it instead of spelling out a URL: + +```ts lib/uploads.ts +export const uploads = uploadHandler({ + constraints: { maxBytes: "20mb", contentTypes: ["image/*"] }, + context: (request) => requireUser(request), + + routes: { + avatar: { + constraints: { maxBytes: "2mb" }, + onBeforeUpload: ({ ctx }) => ({ path: `avatars/${ctx.id}`, cache: "revalidate" }), + }, + attachment: { + constraints: { contentTypes: null }, // clears the handler's list for this route + multipart: "50mb", + onBeforeUpload: ({ ctx, file }) => ({ path: uniquePath`files/${ctx.id}/${file.name}` }), + onUploadComplete: ({ ctx, path, size }) => db.files.insert({ owner: ctx.id, path, size }), + }, + }, +}) +``` + +```tsx app/page.tsx +const avatar = useUpload("avatar") +const attachment = useUpload("attachment") +``` + +- Route names must match `/^[A-Za-z_][\w-]*$/`, checked when the handler is built rather than per request. +- An unknown name is an ordinary 404 that never names the routes the handler does mount. It still reaches `onError`. +- A name is part of the completion token's identity, so a token minted by one route is not spendable at another. + +A handler with **no** `routes` is itself the route. It is reached with no `?route=` at all, and the bound `useUpload()` takes no argument. A name on the query is then a client bound to some other handler, and it gets a 404 rather than this route by accident. + +Two handlers on the same bucket that mount the same route names derive the same token identity. `endpoint: "/api/one"` tells them apart. + +--- + +## uploadRoute() + +A plain object cannot express two things: a Standard Schema for `input`, and a `state` typed from that route's own `onBeforeUpload`. `uploadRoute()` is the builder that can. It is curried because the ctx has to be named, and it is written outside the `routes` map: + +```ts lib/uploads.ts +import { uploadRoute, uniquePath } from "@upstash/blob" +import * as z from "zod" + +const thread = uploadRoute()({ + input: z.object({ threadId: z.string().uuid() }), + + onBeforeUpload: ({ ctx, input, file }) => ({ + path: uniquePath`chat/${input.threadId}/${file.name}`, + state: { threadId: input.threadId, name: file.name }, + }), + + onUploadComplete: ({ ctx, state, url, uploadId }) => + db.messages.insert({ uploadId, threadId: state.threadId, name: state.name, owner: ctx.id, url }), +}) + +export const uploads = uploadHandler({ + context: (request) => requireUser(request), + routes: { thread }, +}) +``` + +```tsx app/page.tsx +const { start } = useUpload("thread") +start({ file, input: { threadId } }) // input is required here, and its shape is checked +``` + +`input` is validated before `onBeforeUpload` runs, and only the parsed value reaches it. A route with **no** schema refuses any `input` the browser sends, with `invalid_input` rather than dropping it silently. Validation failures come back as `invalid_input` too, with the issues joined into one message as `path: message`, so a bad `threadId` reads `threadId: Invalid uuid`. + +`state` is for what the callback already computed and does not want to look up again. It rides in the completion token, which is signed but readable in devtools, so put a row id there, never a secret. + +Everything else on the route works as it does on a plain object: `bucket`, `constraints`, `multipart`, `onError`, and the same inheritance from the handler. + +--- + +## The client + +`uploadHooks(defaults)` binds `useUpload` to one handler. The bound hook knows the route names, so a typo does not compile, and it knows each route's `input` and completion data. + +```ts lib/upload-hooks.ts +"use client" +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "./uploads" + +export const { useUpload } = uploadHooks({ + headers: async () => ({ authorization: `Bearer ${await getToken()}` }), + concurrency: 3, + endpoint: "/api/upload", + onError: ({ file, error }) => toast.error(`${file.name}: ${error.message}`), +}) +``` + +`headers`, `concurrency`, `endpoint` and `onError` are the defaults `uploadHooks` takes. A call-site option wins over the default; the configured `onError` runs first, then the one passed at the call site, and a throw from either stops neither the other nor the queue. + +Called with no type parameter, `uploadHooks()` returns the unbound `useUpload`, which takes a URL. + +### useUpload + +```tsx app/page.tsx +const { start, uploads, upload, clear, accept, constraints } = useUpload("attachment", { + concurrency: 2, + onDone: (record) => console.log(record.blob.data), + onError: (record) => console.log(record.error.code), +}) +``` + +| | | +| --- | --- | +| `start` | Begins one upload or several. Returns the record(s). | +| `uploads` | Every record, in the order they were started. | +| `upload` | The newest record, or `null`. | +| `clear(id?)` | Removes one record, or all of them. | +| `accept` | The route's `contentTypes`, joined, for an ``. | +| `constraints` | What the route's `GET` served, its own numbers. Undefined until it answers. | + +`start({ file })` returns one record, or `null` when the file is nullish, so an empty file picker is not an error. `start({ files })` takes a `File[]` or a `FileList` and returns an array. + +```tsx app/page.tsx + start({ files: e.target.files })} +/> +``` + +### The record + +| Field | Type | | +| --- | --- | --- | +| `id` | `string` | Stable for the life of the record. The key to render lists with. | +| `file` | `File` | The file this record uploads. | +| `status` | `'queued' \| 'uploading' \| 'finishing' \| 'paused' \| 'done' \| 'canceled' \| 'error'` | | +| `loaded` | `number` | Bytes that have landed. | +| `total` | `number` | The file's size. | +| `percent` | `number` | 0 to 99 while running, 100 only once `done`. | +| `pending` | `boolean` | Not settled: queued, uploading, finishing or paused. | +| `stalled` | `boolean` | Every request in flight is waiting on a backoff. | +| `canPause` | `boolean` | Whether `pause()` would do anything. | +| `blob` | `CompletedBlob & { data }` | On `done` only. | +| `error` | `BlobError` | On `error` only. | +| `pause()` `resume()` `cancel()` `retry()` | `() => boolean` | Each answers whether it did anything. | + +`pending` is the field to drive UI off. Hand-rolling it from `status` is where the off-by-one-state bugs live: an input re-enabled during `finishing`, a progress bar still drawn under an error line. + +`percent` sits at 99 through `finishing`, which is the stretch after the last byte is sent while `end` records the object and runs `onUploadComplete`. Naming that state is the difference between a bar that is working and one that looks stuck. + +`blob.data` is typed from that route's `onUploadComplete`. The payload a state does not carry is declared as `undefined` rather than left out, so `upload?.blob?.url` and `upload?.error?.message` read straight off the record with no narrowing. + +`canPause` is false for a single PUT, which is every file under the route's `multipart` threshold: one request is either on the wire or not, and stopping it throws its bytes away rather than parking them. `retry()` works only from `error`, and resumes from the parts that already landed. [Large files](/blob/browser/large-files) has the whole of pause, resume and multipart. + +Three files are in flight by default and the rest queue. `clear(id?)` removes records from the list; a cleared upload that is still running keeps its place in the queue and finishes, it is just no longer rendered. Unmounting the component does not cancel anything either. + +### headers + +`headers` is a function, not an object, and it is re-read for every request the SDK makes to your route: the constraints `GET`, `begin`, `parts` and `end`. A JWT that rotated between the first byte and the last still ends the upload. + +```tsx app/page.tsx +const { start } = useUpload("attachment", { + headers: async () => { + const token = await auth.getToken() // throwing here refuses the upload + return { authorization: `Bearer ${token}` } + }, +}) +``` + +A throw from it ends the upload carrying that error, with no retry and no rewording as a network fault. That is how an app refuses its own upload: a token it could not refresh, a precondition that failed. + +--- + +## The GET endpoint + +`GET` on the route serves its constraints as JSON, with an ETag and `Cache-Control: public, max-age=60`: + +```json +{ "constraints": { "contentTypes": ["image/png", "image/jpeg"], "maxBytes": 20000000 } } +``` + +That is what fills `accept` and `constraints` on the hook, and it lets the hook refuse an oversized file locally, as an error record, before any request leaves the browser. It is short-lived and revalidated rather than immutable, because the constraints are your route's own code and change with a deploy. + +The check in the browser is a courtesy: the server is authoritative and enforces the same limits at `begin`. See [constraints](/blob/browser/constraints). + +--- + +## Without React + +The same upload, with no hooks: + +```ts app/uploader.ts +import { upload } from "@upstash/blob/browser" + +const task = upload(file, { + route: "/api/upload?route=attachment", + headers: async () => ({ authorization: `Bearer ${await getToken()}` }), + input: { threadId }, +}) + +const stop = task.subscribe(() => { + const { status, percent, stalled } = task.snapshot() + render(status, percent, stalled) +}) + +const blob = await task.done // CompletedBlob & { data } +stop() +``` + +`upload()` starts immediately and returns an `UploadTask`: `snapshot()` for the current state, `subscribe()` for changes, `done` as a promise, and `pause()`, `resume()`, `cancel()` and `retry()`. The snapshot carries the same fields the React record does, since the record is that snapshot plus `id`, `file` and the four methods. + +--- + +## useServerUpload + +For bytes that must pass through your app, do not use an upload handler. Write an ordinary route that calls `bucket.put`: + +```ts app/api/avatar/route.ts +import { Bucket } from "@upstash/blob" + +const bucket = Bucket.fromEnv() + +export async function POST(request: Request) { + const file = (await request.formData()).get("file") + if (!(file instanceof File)) return Response.json({ error: "file field required" }, { status: 400 }) + + const blob = await bucket.put(`avatars/${userId}`, file, { contentTypes: ["image/png"], maxBytes: "2mb" }) + return Response.json({ url: blob.versionedUrl }) +} +``` + +`useServerUpload` drives it as one POST, with upload progress, cancellation and `BlobError` decoding, and hands the route's JSON back exactly as it arrived: + +```tsx app/avatar.tsx +"use client" +import { useServerUpload } from "@upstash/blob/react" + +const { start, upload } = useServerUpload<{ url: string }>("/api/avatar", { field: "file" }) + +start({ file }) +upload?.percent +upload?.status === "done" && upload.response.url // typed from the generic +``` + +Its options are `headers`, `concurrency` and `field`, the multipart field name `start({ file })` sends the file under, `'file'` by default and it has to match what your route reads. `start({ body })` sends a `File`, `Blob` or `FormData` as the raw body instead. The record has `cancel()` only, and statuses `queued`, `uploading`, `finishing`, `done`, `canceled` and `error`: there is no `begin` or `end` to pause between. + + + A proxied upload is capped by your platform's request body limit, not by `maxBytes`: Vercel caps a + serverless request body at 4.5 MB, AWS Lambda at 6 MB, and Cloudflare at 100 MB on the free plan. + The body is rejected before your route runs, so the 413 carries no code of its own; the SDK + surfaces it as `too_large` with those numbers as the hint. Anything larger belongs on the direct + path above. + + +--- + +## CORS + +The signed PUT is a cross-origin request from your page to storage, and it sends `Content-Type`, `Cache-Control` and the object's `x-amz-meta-*` as real headers, because they are pinned into the signature and storage refuses the PUT if they are changed. The bucket's CORS configuration therefore has to + +- allow `PUT` from your origin, +- allow those request headers, and +- expose `ETag` in `Access-Control-Expose-Headers`, which is how the browser reads back what it stored. + +A request that fails with no status and no bytes sent is almost always CORS: the preflight is what failed, and the reason is never visible to script. The SDK says so after three attempts rather than backing off for minutes, with a hint naming CORS as the likely cause. A failure after bytes were sent is treated as a dropped link instead, and retried far longer. diff --git a/blob/bucket/caching.mdx b/blob/bucket/caching.mdx new file mode 100644 index 00000000..27d7e0ce --- /dev/null +++ b/blob/bucket/caching.mdx @@ -0,0 +1,231 @@ +--- +title: "Caching" +--- + +The `Cache-Control` an object is served with is written once, at upload. It is stored with the object and handed back by the CDN and the browser on every read, so changing it later means writing the object again. There is no per-request override: a read cannot ask for a different `Cache-Control` than the one the object carries. + +That is the whole shape of the feature. Everything below is about choosing the right value at write time, and about the one pattern that makes a stable path and a long cache life work together. + +--- + +## The `cache` option + +`cache` takes a `CacheOption`: one of three words, a duration, or a `Cache-Control` header written out. + +| Value | Stored header | +| ----- | ------------- | +| `'immutable'` | `public, max-age=31536000, immutable` | +| `'revalidate'` | `public, max-age=0, must-revalidate` | +| `'no-store'` | `no-store` | +| a duration (`'15m'`, `3600`) | `public, max-age=` | +| unset | `public, max-age=3600` | +| anything containing `=` or `,` | stored exactly as written | + +### The duration grammar + +A bare number is **seconds**, the unit every TTL option on the web already uses. A string takes a unit: `ms`, `s`, `m`, `h`, `d`, and their long forms (`sec`, `second`, `seconds`, `min`, `minute`, `minutes`, `hr`, `hour`, `hours`, `day`, `days`). A string with no unit at all is read as seconds too. + +```ts +cache: '1h' // public, max-age=3600 +cache: 3600 // public, max-age=3600 +cache: '15 min' // public, max-age=900 +cache: '7d' // public, max-age=604800 +``` + +Durations are converted to whole seconds, so `'1500ms'` stores `max-age=1`. An unparseable duration throws a `TypeError` naming the option. + +### The raw header + +Anything containing `=` or `,` is a header, stored exactly as written, trimmed. A directive separator is what tells the two apart: a header always has one, a duration never does. + +```ts +cache: 'public, max-age=60, s-maxage=31536000' +cache: 'max-age=0, stale-while-revalidate=86400' +``` + +That is the escape hatch, and it is why `cache` is three words and a duration rather than an object of flags. `s-maxage`, `stale-while-revalidate`, `no-transform` and whatever the spec adds next are all sayable without the option growing a camelCase word for each of them. + +--- + +## `revalidate` versus a short max-age + +`'revalidate'` stores `public, max-age=0, must-revalidate`, and it is the answer for a stable path that gets overwritten. The copy is kept and checked with `If-None-Match`, so an unchanged object costs a 304 with no body instead of the whole object once every max-age. + +A short max-age is the wrong tool there. It is stale until it expires, and then it re-downloads: + +| | `cache: 'revalidate'` | `cache: '60s'` | +| --- | --- | --- | +| Unchanged object | 304, no body | full object, once a minute | +| Object just overwritten | next read sees it | up to 60 s of the old bytes | + +`'revalidate'` costs a round trip per read. It never costs the bytes twice, and it is never stale. + +--- + +## Where you can set it + +Four places, innermost wins. A per-call `cache` overrides the bucket default. + +### On the bucket + +The default for every object this bucket stores. + +```ts lib/blob.ts +import { Bucket } from '@upstash/blob'; + +export const bucket = new Bucket({ + token: process.env.UPSTASH_BLOB_TOKEN!, + cache: 'immutable', +}); +``` + +### On a put + +```ts +await bucket.put('avatars/7.png', file, { + contentType: 'image/png', + cache: 'revalidate', +}); +``` + +`updateJson` takes it too, for the object it rewrites. See [Writing](/blob/bucket/writing) for the rest of `put`. + +### On a signed upload URL + +The `Cache-Control` is pinned into the signature and handed back in `headers`, so the uploader has to send it verbatim. + +```ts +const upload = await bucket.signedUploadUrl('u/7/report.pdf', { + contentType: 'application/pdf', + cache: 'immutable', +}); + +await fetch(upload.url, { method: 'PUT', headers: upload.headers, body }); +``` + +### On a direct browser upload + +`onBeforeUpload` returns the path, and may return the `cache` alongside it. It is decided per upload, on your server, and signed into the presigned PUT. + +```ts lib/uploads.ts +import { uniquePath, uploadHandler } from '@upstash/blob'; + +export const uploads = uploadHandler({ + onBeforeUpload: ({ file }) => ({ + path: uniquePath`uploads/${file.name}`, + cache: 'immutable', + }), +}); +``` + +See [Upload handler](/blob/browser/upload-handler) for the rest of the callback. + +--- + +## Private buckets + +On a private bucket, `private` replaces `public` in the stored directive. A shared cache must not keep a copy of an object only a signed request may read, and `public` on such an object invites every shared cache between storage and the reader to keep one and hand it to the next reader. + +| `cache` | Public bucket | Private bucket | +| ------- | ------------- | -------------- | +| unset | `public, max-age=3600` | `private, max-age=3600` | +| `'1m'` | `public, max-age=60` | `private, max-age=60` | +| `'immutable'` | `public, max-age=31536000, immutable` | `private, max-age=31536000, immutable` | +| `'revalidate'` | `public, max-age=0, must-revalidate` | `private, max-age=0, must-revalidate` | +| `'no-store'` | `no-store` | `no-store` | + +This follows the bucket's real visibility, taken from the credentials response, not just what you declared in `new Bucket({ visibility })`. A bucket that never declared anything still stores the right directive. + +A raw header string is passed through as written, so `cache: 'public, max-age=60'` on a private bucket stores `public, max-age=60`. Once you write the header out, the visibility is yours to state too. + +Reads on a private bucket go through `signedReadUrl()`. See [Reading](/blob/bucket/reading). + +--- + +## Immutable plus a versioned URL + +This is the pattern worth learning, because it is the one that gets a year of caching out of a path that changes. + +Every record carries `versionedUrl`, which is `${url}?v=${etag}`. The etag changes whenever the content does, so the URL changes whenever the content does. A stable path stored with `cache: 'immutable'` and served through `versionedUrl` is cached for a year by URL, and an overwrite mints a new URL that no cache has ever seen. + +```ts app/api/avatar/route.ts +const blob = await bucket.put(`avatars/${user.id}.png`, file, { + contentType: 'image/png', + cache: 'immutable', +}); + +await db.users.update(user.id, { avatar: blob.versionedUrl }); +``` + +```tsx + +``` + +The path never moves, so nothing has to be deleted and the old object is not left behind. The URL in your row moves on every write, so the next render points at bytes no cache holds. + + + `url` and `versionedUrl` are both undefined on a private bucket, since no public host serves it. + + +### The three shapes + +| | Path | `cache` | Invalidation | +| --- | --- | --- | --- | +| Content-addressed or unique path | new path per upload (`uniquePath`) | `'immutable'` | none needed, the path never repeats | +| Stable path, versioned URL | stable | `'immutable'` | `versionedUrl` changes with the etag | +| Stable path, plain URL | stable | `'revalidate'` | a 304 per read | + +A unique path per upload is the simplest of the three: nothing ever overwrites anything, so `immutable` is unconditionally correct and there is no URL to update. The cost is that old objects accumulate and are yours to delete. + +`'revalidate'` is the fallback for a stable URL you do not control, such as one already printed somewhere or served to a client that will not carry a query string. + +### Choosing + +| Object | Use | +| ------ | --- | +| Content-addressed, or a `uniquePath` per upload | `cache: 'immutable'` | +| Stable path that changes rarely | `cache: 'immutable'` plus `versionedUrl` | +| User-editable, read through a fixed URL | `cache: 'revalidate'` | +| Private or sensitive | `cache: 'no-store'`, or a short duration on a private bucket | + +--- + +## `no-store` and signed reads + +For anything served through `signedReadUrl()`, two separate mechanisms are in play and both matter. + +The link expires. `signedReadUrl()` defaults to 5 minutes and is capped by the credential that signed it, so `expiresAt` on the result is the answer per link rather than a number you assume. + +```ts +const { url, expiresAt } = await bucket.signedReadUrl('private/report.pdf'); +``` + +The stored `Cache-Control` is a different thing entirely, and it outlives the link. A long max-age on a private object still lets the requester's own browser keep the bytes after the link stops working, because the browser is caching a response it was allowed to fetch. If a reader must not keep the bytes, say so on the object: + +```ts +await bucket.put('private/report.pdf', body, { + contentType: 'application/pdf', + cache: 'no-store', +}); +``` + +`no-store` is the one value that drops the visibility scope entirely: it stores `no-store` on a public and a private bucket alike, because nothing is to be kept either way. + +See [Signed URLs](/blob/overall/signing) for how link lifetimes are capped. + +--- + +## What the upload route itself caches + +An upload route's `GET` serves its constraints, and it has its own caching, unrelated to the objects the route stores. + +```http +cache-control: public, max-age=60 +etag: "1qk8ru" +``` + +A request carrying a matching `If-None-Match` gets a 304 with no body. On the client, the React hooks keep the answer in memory for 60 seconds, so a page with several pickers on it asks once. + +Short and revalidated, not immutable: the constraints are the route's own code and change with a deploy, and a client that cached them forever would refuse files the route now accepts. The server stays authoritative either way, since every upload is checked again at `begin`. + +See [Upload handler](/blob/browser/upload-handler). diff --git a/blob/bucket/deleting.mdx b/blob/bucket/deleting.mdx new file mode 100644 index 00000000..b160ca6b --- /dev/null +++ b/blob/bucket/deleting.mdx @@ -0,0 +1,271 @@ +--- +title: "Deleting" +--- + +`bucket.del()` is the only delete verb, and it takes three shapes: one path, an array of paths, or a prefix. All three resolve to `Promise`, and all three treat "already gone" as success. What differs is how many requests they make and what they throw when storage refuses part of the work. + +This page also covers the deletes that are not `del()`: the copy `move` leaves behind when its delete fails, the incomplete multipart uploads that `list()` cannot see, and the objects the upload handler removes on your behalf. + +--- + +## The three shapes + +```ts delete.ts +import { Bucket } from '@upstash/blob'; + +const bucket = Bucket.fromEnv(); + +await bucket.del('avatars/me.png'); // one path +await bucket.del(['a.png', 'b.png', 'c.png']); // an array of paths +await bucket.del({ prefix: 'tmp/' }); // everything under a prefix +``` + +The type is `DeleteTarget`: + +```ts type +type DeleteTarget = string | string[] | { prefix: string; all?: boolean }; +``` + +Anything else is refused with `invalid_input`: `del: expected a path, an array of paths, or { prefix }`. There is no `del()` with no argument. + +--- + +## Deleting one path + +One `DELETE` request. A 404 counts as success, so deleting something that is not there does not throw: + +```ts idempotent.ts +await bucket.del('drafts/9f3c.txt'); +await bucket.del('drafts/9f3c.txt'); // fine, still no throw +``` + +That is what makes a delete safe to run from a retried job or a queue consumer with at-least-once delivery. Any other failure is a real error: a 403 surfaces as `signature_mismatch`, a 429 as `rate_limited`, a 5xx as `request_failed`. + + +`del` never tells you whether anything was there. If you need to know, ask first with `bucket.exists(path)`, which answers `false` instead of throwing. See [reading](/blob/bucket/reading). + + +--- + +## Deleting an array + +An array is sent as S3 batch deletes, in chunks of 1000 paths. A 5000-path array is five `POST` requests, run one after another, not 5000 round trips. + +Two details are worth knowing before you read the error handling. + +First, every path in a chunk is validated before that chunk's request goes out, so a bad path fails the chunk it is in rather than being silently skipped. Chunks before it have already run. + +Second, and this is the one that shapes the API: S3 answers a batch delete with **200 and per-key errors inside the body**. A key that failed is reported in an `` block in an otherwise successful response. The SDK does not take that list at face value. For each key S3 named, it re-checks with `exists()` and keeps only the ones that are still there: + +```ts src/server/bucket.ts +// S3 answers 200 with errors inside; a survivor is the truth, so ask again rather than trust the list. +const survivors: string[] = []; +for (const p of failed) if (await this.exists(p)) survivors.push(p); +``` + +A survivor is the truth and the list is not. An error block for an object that is in fact gone would otherwise be reported to you as a failure you cannot act on, and the whole point of `failed` is that you can act on it. + +If anything survives, `del` throws `partial_delete`, status 500, whose `failed` array names exactly which paths are still there: + +```ts partial.ts +import { BlobError } from '@upstash/blob'; + +try { + await bucket.del(paths); +} catch (e) { + if (BlobError.is(e) && e.code === 'partial_delete') { + // e.failed is string[]: the paths that are still in the bucket, verified one by one + console.error(`${e.failed?.length} objects survived`, e.failed); + await requeue(e.failed ?? []); + return; + } + throw e; +} +``` + +Everything not in `failed` was deleted. `partial_delete` is a report, not a rollback: retrying with `e.failed` is the whole recovery, and it is safe because a delete of something already gone is success. + + +Use `BlobError.is(e)`, never `instanceof`. An ESM copy and a CJS copy of the class are two different classes. See [errors](/blob/bucket/errors). + + +A batch delete is a `POST`, and the SDK only retries idempotent verbs, so a 5xx on a batch surfaces as `request_failed` on the first try rather than being sent twice. + +--- + +## Deleting by prefix + +`del({ prefix })` pages through `list()` at 1000 objects per page and batch-deletes each page as it goes: + +```ts prefix.ts +await bucket.del({ prefix: 'users/7/tmp/' }); +``` + +So the cost scales with the number of objects under the prefix, not with the one call you wrote. A prefix over 100,000 objects is 100 list requests and 100 batch deletes, run sequentially. It is not atomic: objects written under the prefix while it runs may or may not be caught, depending on which page they land on. + +Failures work exactly as they do for an array. Survivors from every page are collected, and if any remain the call throws `partial_delete` with them in `failed`. + + +`del({ prefix: '' })` matches every object in the bucket, so an empty prefix from an unset variable or an empty form field would wipe the bucket. It is refused with `invalid_input` before a single request is sent, and the hint tells you the deliberate form: `pass { prefix: '', all: true } if that is what you mean`. + + +```ts whole-bucket.ts +// Refused: invalid_input, status 400, nothing sent +await bucket.del({ prefix: userFolder }); + +// Deliberate: this is how you say "yes, the whole bucket" +await bucket.del({ prefix: '', all: true }); +``` + +`all` is only consulted for the empty prefix. `del({ prefix: 'tmp/' })` needs nothing extra. + +--- + +## Paths are validated, never normalized + +Every path reaching storage goes through `encodeKey`, which percent-encodes each segment and refuses outright any path containing a `.` or `..` segment: + +```ts traversal.ts +await bucket.del('users/7/../8/private.pdf'); +// TypeError: path may not contain "." or ".." segments: users/7/../8/private.pdf +``` + +The reason is the trust model rather than tidiness. Your server holds a temporary credential that authorizes the whole bucket, and the URL parser resolves `..` before the request is signed. A traversing key would sign a delete against a different object than the one your code named, and the credential would happily allow it. Normalizing the path would hide that; rejecting it does not. + +This applies to `del` in all three shapes, and to `put`, `copy`, `move`, `signedUploadUrl` and `abortMultipartUpload` alike. If you build paths from user input, build them with `uniquePath`, which strips directory components out of every interpolated value. See [writing](/blob/bucket/writing). + +--- + +## `move` leaves a copy on failure + +`move(from, to)` is not a primitive. It is a copy followed by a delete: + +```ts move.ts +const blob = await bucket.move('tmp/9f3c', 'avatars/7.png'); +``` + +If the copy fails, nothing has changed and you get the copy's error. If the copy succeeds and the delete fails, the SDK throws `move_left_a_copy`, status 500, and **keeps the destination**. You are left with two objects rather than zero, which is the failure mode that loses no data: + +```ts move-catch.ts +import { BlobError } from '@upstash/blob'; + +try { + await bucket.move('tmp/9f3c', 'avatars/7.png'); +} catch (e) { + if (BlobError.is(e) && e.code === 'move_left_a_copy') { + // avatars/7.png exists and is correct. tmp/9f3c is also still there. + // The recovery is to retry the source delete, not the move. + await bucket.del('tmp/9f3c'); + return; + } + throw e; +} +``` + +The original error is attached as `cause`, so you can see whether the delete was refused, rate limited, or something else. + +--- + +## Incomplete multipart uploads + +These are the deletes people forget, because nothing in the ordinary API shows them. + +A multipart upload is created, parts land against it, and it becomes an object only when it is completed. Between those two moments the parts are real, billed storage that `list()` cannot see, and a bucket cannot be deleted while one exists. A browser tab closed mid-upload leaves exactly this behind. + +`bucket.put()` cleans up after itself: any failure inside its multipart path aborts the upload before rethrowing. So does a browser upload that calls `cancel()`. What is left over is the case nobody handled, and it needs a sweep. + +### Listing them + +```ts list-uploads.ts +const uploads = await bucket.listMultipartUploads({ prefix: 'uploads/' }); +// [{ path: 'uploads/big.mp4', uploadId: 'ABC...', initiatedAt: Date }, ...] +``` + +`listMultipartUploads` returns every upload started and neither completed nor aborted, paging internally until it has them all. `prefix` is optional; without one you get the whole bucket. + +| Field | Meaning | +| --- | --- | +| `path` | The object key the upload was started for. Nothing is stored there yet. | +| `uploadId` | R2's own id for the upload, needed to abort it. | +| `initiatedAt` | When it was started. This is what "stale" is measured against. | + +### Aborting one + +```ts abort-one.ts +await bucket.abortMultipartUpload({ path: 'uploads/big.mp4', uploadId: 'ABC...' }); +``` + +This throws the upload away along with every part that landed for it. Missing is success, exactly like `del` on a path that is not there. + +That is also why it takes the record `listMultipartUploads()` returned rather than two positional strings. If the wire treats "not there" as success, then `abortMultipartUpload(uploadId, path)` with the arguments swapped would abort nothing, answer 204, and report that it worked. A named `{ path, uploadId }` pair cannot be swapped by accident, and an empty `uploadId` is refused with `invalid_input` before anything is sent. + + +`onUploadComplete` receives `multipartUploadId` for exactly this pair. Store it alongside your row and you can abort a specific upload later without listing the bucket. It is `undefined` when the file went up as a single PUT. See [upload handler](/blob/browser/upload-handler). + + +### Sweeping the stale ones + +`abortStaleMultipartUploads` is list plus abort in one call, meant for a cron. It returns what it aborted: + +```ts app/api/cron/sweep-uploads/route.ts +import { Bucket } from '@upstash/blob'; + +export async function GET(request: Request) { + if (request.headers.get('authorization') !== `Bearer ${process.env.CRON_SECRET}`) { + return new Response('unauthorized', { status: 401 }); + } + + const bucket = Bucket.fromEnv(); + const aborted = await bucket.abortStaleMultipartUploads({ + olderThan: '1d', + prefix: 'uploads/', + }); + + for (const upload of aborted) { + console.info(`[sweep] aborted ${upload.path}, started ${upload.initiatedAt.toISOString()}`); + } + + return Response.json({ aborted: aborted.length }); +} +``` + +`olderThan` is required, and it is a `Duration`: a bare number is seconds, or write a string like `'15m'`, `'2h'`, `'7d'`. Only uploads started longer ago than that are touched, which is what keeps the sweep from aborting an upload that is still running. Pick a window comfortably longer than your slowest legitimate upload; a day is a reasonable default. + +`prefix` narrows the sweep the same way it narrows `listMultipartUploads`. + + +An abandoned upload **under** the multipart threshold is not a multipart upload at all. The browser's presigned PUT stored the object the moment its last byte landed, so what it leaves behind is a whole, ordinary, `list()`-visible, billed object, and none of the calls on this page can find it. That needs a different sweep: see [abandoned uploads](/blob/browser/abandoned-uploads). + + +--- + +## When the SDK deletes for you + +Two paths in the upload handler delete objects without you asking. + +**A rejected direct upload.** When `onUploadComplete` throws, the handler discards the object the upload created. The intent is that the object exists only if your callback returned. + +Because R2 has no conditional delete, `discard` re-reads the object's etag first and only deletes when it still matches the one this upload produced. On a stable path, a later upload may already have replaced those bytes, and deleting then would destroy a file that was accepted. When the etag has moved, the object is left alone and a warning is logged instead. When the upload cannot be identified at all, the object is left stored and an error is logged: an orphan costs storage and a log line, a blind delete costs somebody else's accepted file, and the cheaper mistake is the one to make. + +This is also why a database error inside `onUploadComplete` is expensive. Any throw runs the discard, so a ten second outage deletes bytes that uploaded perfectly well. Catch your own storage errors and answer with a retryable error rather than letting them escape the callback. + +**A `cancel()` from the browser.** The browser posts a cancel phase with its completion token. For a multipart upload the handler aborts it, parts and all. For a single PUT there is no upload to abort, so the handler reads the object and deletes it only when its `upstash-upload` marker matches this upload's id. That marker is signed into the presigned PUT, so the browser cannot forge it, and it is the whole check: a cancel cannot be aimed at an object this upload did not write. + +A cancel that arrives once the upload has reached its finishing phase is dropped rather than raced against a callback that may already have written a row. + +See [how signing works](/blob/overall/signing) for the marker and the completion token, and [abandoned uploads](/blob/browser/abandoned-uploads) for what happens when no cancel is ever sent. + +--- + +## Error codes + +| Code | Status | When | +| --- | --- | --- | +| `partial_delete` | 500 | An array or prefix delete left objects behind. `e.failed` names exactly which paths are still there, each one verified with `exists()`. | +| `move_left_a_copy` | 500 | `move` copied the object but could not delete the source. The destination is kept, so both exist. The delete's own error is on `cause`. | +| `invalid_input` | 400 | `del({ prefix: '' })` without `all: true`, a target that is not a path, array or `{ prefix }`, or an `abortMultipartUpload` with no `uploadId`. | +| `not_found` | 404 | Never raised by `del`, which treats a missing object as success. You will see it from `get` and `info` on the same path. | + +A path containing a `.` or `..` segment throws a `TypeError` rather than a `BlobError`, because it is a programming mistake rather than a runtime condition. + +The full list of codes, statuses and the fields each one carries is on [errors](/blob/bucket/errors). diff --git a/blob/bucket/errors.mdx b/blob/bucket/errors.mdx new file mode 100644 index 00000000..1783049e --- /dev/null +++ b/blob/bucket/errors.mdx @@ -0,0 +1,377 @@ +--- +title: "Errors" +--- + +Everything the SDK throws is a `BlobError`. It carries a `code` from a closed list of eighteen, a `status`, and a `message` written to be printed. That holds on the server, in the browser, and inside the React hooks: one class, one list of codes, one shape to handle. + +```ts lib/avatar.ts +import { BlobError, Bucket } from '@upstash/blob'; + +const bucket = Bucket.fromEnv(); + +export async function avatar(path: string) { + try { + return await bucket.info(path); + } catch (e) { + if (BlobError.is(e) && e.code === 'not_found') return null; + throw e; + } +} +``` + +`BlobError` is exported from all three entrypoints: `@upstash/blob`, `@upstash/blob/browser` and `@upstash/blob/react`. + +--- + +## Use `BlobError.is()`, not `instanceof` + + + An ESM copy and a CJS copy of the class are two different classes, so `instanceof` can return + false for an error that genuinely is one. `BlobError.is()` checks a `Symbol.for` marker instead, + which is shared across every copy of the package in the process. + + +```ts +if (BlobError.is(e)) { + e.code; // BlobErrorCode + e.status; // number + e.message; // string +} +``` + +`is()` is a type guard, so the fields are typed after it. It is the only supported check, and it is what the SDK itself uses internally at every boundary. + +--- + +## The codes + +`e.status` is what a route answers with, so the table is API surface rather than a detail. + +| Code | Status | Default message | When you see it | +| ---- | ------ | --------------- | --------------- | +| `not_found` | 404 | `not found` | `get`, `info` or `copy` on a path that is not there. Storage answered `NoSuchKey` or `NoSuchUpload`. An unknown upload route. A completion whose object never landed. | +| `already_exists` | 409 | `already exists` | `put` with `overwrite: false` against a path that already has an object. Carries `etag` and `size`. | +| `conflict` | 409 | `the object changed since it was read` | `ifUnchanged` did not match, or storage answered `PreconditionFailed`. Also `updateJson` giving up after six attempts. | +| `content_type_not_allowed` | 400 | `content type not allowed` | The declared type is not in `contentTypes`, or the file's leading bytes contradict the declaration. | +| `invalid_input` | 400 | `invalid input` | Arguments the SDK will not accept: metadata outside printable ASCII, a malformed upload request body, `input` that fails the route's schema, a `del` target that is none of the three shapes. | +| `too_large` | 413 | `too large` | Over `maxBytes`, over the route's `constraints`, or over what a single PUT can carry when `multipart: false` forbids the parts the body needs. Also a 413 from storage or from your platform. | +| `empty_body` | 400 | `empty body` | A zero-byte file at `begin`, or a `put` from a `Request` with no body left to read. | +| `length_required` | 411 | `length required` | `put` of an unknown-length stream with neither `size` nor `maxBytes`, so nothing knows how long the body is. | +| `signature_mismatch` | 403 | `signature mismatch` | A 403 from storage that is not a credential problem: the body length or type does not match what was signed. Also a completion where the stored size is not the declared size. | +| `unauthorized` | 401 | `unauthorized` | The bucket token was rejected, or your own auth check refused the upload. | +| `forbidden` | 403 | `forbidden` | A completion token that is not valid for this route, or has expired. | +| `rate_limited` | 429 | `rate limited` | Storage answered `SlowDown` or `TooManyRequests`, or credential requests are being rate limited. | +| `mint_backoff` | 429 | `the credential service asked for a backoff longer than a request can wait` | The credential service asked for more than 10 seconds of backoff. `retryAfter` says how long. | +| `not_ready` | 503 | `bucket is not ready` | The bucket is not ready to serve requests yet. | +| `partial_delete` | 500 | `some paths were not deleted` | An array or prefix `del` where some objects survived. `failed` lists them. | +| `move_left_a_copy` | 500 | `move left a copy at the source` | `move` copied the object but could not delete the source. The destination is kept. | +| `invalid_content_type_pattern` | 500 | `invalid content type pattern` | A `contentTypes` entry that is not a `type/subtype` or one of `image/*`, `video/*`, `audio/*`. An empty list throws this too. | +| `request_failed` | 500 | `request failed` | Everything else. This is the one code whose `status` the thrower sets, so it also carries 502 and 503. | + +Bad option values are not in this list. An unparseable `'5mib'`, a missing `token`, a route with no `onBeforeUpload`: those throw a `TypeError` where the option is written, not a `BlobError` per request. + +See [Writing](/blob/bucket/writing), [Reading](/blob/bucket/reading) and [Deleting](/blob/bucket/deleting) for which calls raise which. + +--- + +## Extra fields + +Beyond `code`, `status` and `message`, an error carries whatever the code has to say. + +| Field | Type | Set by | +| ----- | ---- | ------ | +| `hint` | `string \| undefined` | Any code. `signature_mismatch` and `length_required` have a built-in one, and many call sites add their own. | +| `failed` | `string[] \| undefined` | `partial_delete`: the paths that survived the delete. | +| `etag` | `string \| undefined` | `already_exists`: the etag of what is already there. | +| `size` | `number \| undefined` | `already_exists`: the size of what is already there. | +| `retryAfter` | `number \| undefined` | `mint_backoff` and `rate_limited`: seconds the service asked the caller to wait. | +| `cause` | `unknown` | The underlying error, when there was one. Standard `Error.cause`. | + +```ts +try { + await bucket.del(['a.png', 'b.png', 'c.png']); +} catch (e) { + if (!BlobError.is(e)) throw e; + if (e.code === 'partial_delete') await queueForRetry(e.failed ?? []); + if (e.code === 'rate_limited') await sleep((e.retryAfter ?? 1) * 1000); +} +``` + +`already_exists` hands back what blocked the write, so a conditional put does not need a second round trip to find out: + +```ts +try { + await bucket.put('avatars/7.png', file, { overwrite: false }); +} catch (e) { + if (BlobError.is(e) && e.code === 'already_exists') { + console.log('kept', e.etag, e.size); + } +} +``` + +--- + +## Messages are written to be shown + +Messages are lowercase in the source and sentence-cased when the error is built, so an app can print `e.message` straight into its error line without writing its own `capitalize()`. Every app that printed these messages ended up writing one. + +A message that opens with an identifier keeps its case. A MIME type, a file name or a metadata key is not a word to raise: "Image/png is not allowed" names a type that does not exist, and "Cat.png" is not the file the user picked. + +```ts +new BlobError('not_found').message; // 'Not found' +new BlobError('forbidden', 'not your thread').message; // 'Not your thread' +new BlobError('too_large', 'cat.png is 3.1 MB, over the 2 MB limit').message; +// 'cat.png is 3.1 MB, over the 2 MB limit' +``` + +`e.message` never carries a credential, a token, or an internal path. Every message is assembled from a code, a caller-supplied string, or an HTTP status. + +### Hints fold into the message + +A hint is appended to the message in parentheses, so printing `message` alone is enough. `e.hint` is still there separately if you want to lay it out yourself. + +| Code | Built-in hint | +| ---- | ------------- | +| `signature_mismatch` | a 403 from R2 usually means the body length or type differs from the signature | +| `length_required` | pass `{ size }` or `{ maxBytes }` so the length is known before the first byte | + +```ts +new BlobError('length_required').message; +// 'Length required (pass { size } or { maxBytes } so the length is known before the first byte)' +``` + +A message that already contains its hint is not doubled. + +--- + +## Errors across the wire + +This is what makes the browser half usable. An upload route answers every refusal with `BlobError.toJSON()` at the error's own status, and the browser rebuilds it with `BlobError.fromJSON()`. So `error.code` inside a hook is the code your server raised, not a status number you have to decode back into a meaning. + +```tsx app/picker.tsx +'use client'; +import { uploadHooks, type BlobError } from '@upstash/blob/react'; +import type { uploads } from '@/lib/uploads'; + +const { useUpload } = uploadHooks(); + +export function Picker() { + const { start, upload, accept } = useUpload(); + + return ( + <> + start({ file: e.target.files?.[0] })} + /> + {upload?.status === 'error' &&

{describe(upload.error)}

} + + ); +} + +function describe(error: BlobError): string { + switch (error.code) { + case 'unauthorized': + return 'Your session expired. Sign in and try again.'; + case 'rate_limited': + return `Too many uploads. Try again in ${error.retryAfter ?? 30}s.`; + case 'not_ready': + return 'Storage is warming up. Try again in a moment.'; + default: + // too_large, content_type_not_allowed and the rest already read as a sentence. + return error.message; + } +} +``` + +### What reaches the browser, in order + +A route runs `onError` first. If it returns a `Response`, that is the answer; if it returns a `BlobError`, the answer is that error's JSON at its own status. Otherwise the throw falls through three cases: + +1. **A `BlobError` is answered as itself.** `toJSON()` at `e.status`, with `hint`, `failed`, `etag`, `size` and `retryAfter` when they are set. +2. **An app error carrying an integer `status` between 400 and 599 is mapped through the status table below.** This is how an auth check that throws its own 401 reaches the browser as `unauthorized`, so a caller can tell a dead session from a rejected file without reading status numbers. +3. **Anything else is treated as your bug and rethrown**, so the framework logs it with its stack rather than masking it as a generic 500. + +| Status | Code | +| ------ | ---- | +| 401 | `unauthorized` | +| 403 | `forbidden` | +| 404 | `not_found` | +| 409 | `conflict` | +| 411 | `length_required` | +| 413 | `too_large` | +| 429 | `rate_limited` | + +Any other status becomes `request_failed`, keeping the status it arrived with. + +--- + +## `onError` + +`onError` is the one place to log. It sees every refusal, the SDK's own included, and it runs before the answer is written. + +```ts lib/uploads.ts +import { BlobError, uniquePath, uploadHandler } from '@upstash/blob'; + +export const uploads = uploadHandler({ + constraints: { maxBytes: '20mb', contentTypes: ['image/*'] }, + + onBeforeUpload: ({ file }) => ({ path: uniquePath`uploads/${file.name}` }), + + onError: ({ route, error, path, file, metadata }) => { + logger.error('upload refused', { + route, + path, + file: file?.name, + owner: metadata?.owner, + code: BlobError.is(error) ? error.code : 'unknown', + message: error instanceof Error ? error.message : String(error), + }); + // Returning nothing leaves the answer alone. + }, +}); +``` + +It is handed `{ ctx, route, request, error, file?, path?, metadata?, state? }`, with as much as the request had reached before it failed. A file refused at `begin` has `file` and no `path`; one refused after `onBeforeUpload` has both. + +Return a `BlobError` or a `Response` to answer with it instead: + +```ts +onError: ({ error }) => { + if (!BlobError.is(error)) return new BlobError('request_failed', 'could not record the upload'); +}, +``` + +Written on the handler it is the default for every route, and a route with its own `onError` replaces it. See [Upload handler](/blob/browser/upload-handler). + +--- + +## The `onUploadComplete` footgun + + + Any throw out of `onUploadComplete` deletes the completed object. A plain database error destroys + bytes that uploaded fine, and the browser is told 404 "the upload never landed". A ten second + database blip costs the upload and reports it as a phantom. + + +Catch your own storage errors instead of letting them escape: + +```ts lib/uploads.ts +onUploadComplete: async ({ uploadId, url, metadata }) => { + try { + // uploadId is stable across retries, so the same completion twice writes one row. + await sql`insert into files (upload_id, owner, url) + values (${uploadId}, ${metadata.owner}, ${url}) + on conflict (upload_id) do nothing`; + } catch (e) { + logger.error('could not record upload', { uploadId, error: e }); + // Deliberately not rethrown: the object is stored and the row can be reconciled later. + } +}, +``` + +The delete is the intended behaviour for a genuine refusal, where the object should not survive a callback that rejected it. It is the wrong outcome for an error that has nothing to do with the file. See [Abandoned uploads](/blob/browser/abandoned-uploads) for the pending-row pattern that reconciles the rest. + +--- + +## What storage errors map to + +Errors from R2 are normalised before they leave the SDK, first matching wins. + +| Storage answered | Becomes | +| ---------------- | ------- | +| 404, or `NoSuchKey` / `NoSuchUpload` | `not_found` | +| 412, or `PreconditionFailed` | `conflict` | +| 401 | `unauthorized` | +| 403 with `ExpiredToken`, `InvalidAccessKeyId` or `TokenRefreshRequired` | `unauthorized`, "storage refused the temporary credential", hinting that it expired mid-request and the SDK re-mints and retries once | +| any other 403 | `signature_mismatch` | +| 429, or `SlowDown` / `TooManyRequests` | `rate_limited`, "R2 rate limited the request" | +| 503 | `not_ready` | +| 413, or `EntityTooLarge` | `too_large` | +| anything else | `request_failed`, message `R2 responded : ` | + +For that last row the status is passed through, except that a 5xx is normalised to 502: the failure is upstream of your app, not in it. + +--- + +## Errors the browser raises on its own + +Some failures never reach your route, so the browser names them itself. + +**A PUT that fails with no status and no bytes sent** is not a dropped link. The browser refused it before it went out, and the reason is never visible to script because it is the preflight that failed. That gets three attempts rather than the twenty a real network failure gets, and the hint says so: + +``` +the browser blocked the request before sending any bytes, which is almost always CORS: +the bucket has to allow PUT and the signed headers from this origin +``` + +The signed PUT sends `Content-Type`, `Cache-Control` and `x-amz-meta-*` as real headers, so bucket CORS has to allow them from your origin. See [Upload handler](/blob/browser/upload-handler). + +**A 403 on a freshly minted presign** becomes `signature_mismatch`. A 401 or 403 on an older URL is read as an expired signature and the browser asks the route for a new one; only a URL minted moments ago and refused again is the body. + +**Exhausted retries** become `request_failed`, carrying the attempt count and the last status, hinted with what to do next: + +``` +Upload failed after 8 attempts (last status 500) (the parts that landed are kept: +task.retry(), or pick the same file again) +``` + +`retry()` runs the same upload again from the parts that landed, so nothing already uploaded is re-sent. See [Large files](/blob/browser/large-files). + +**A canceled upload rejects with an `AbortError`, not a `BlobError`.** The record's status is `canceled` and it carries no `error` at all, so a cancel never renders as a failure. + +```ts +const record = start({ file }); +record?.cancel(); // status becomes 'canceled', error stays undefined +``` + +--- + +## Platform body limits + +This applies to `useServerUpload` and to any route of your own that the bytes pass through. It does not apply to direct browser uploads, where the bytes go straight to storage and never touch your server. + +A 413 from the platform never reached your route, so it carries no code of its own. The SDK turns it into `too_large` and attaches the limits as a hint: + +``` +Too large (Vercel caps a serverless request body at 4.5MB, AWS Lambda at 6MB, +Cloudflare at 100MB on the free plan) +``` + +```tsx +const { start, upload } = useServerUpload('/api/avatar'); + +if (upload?.status === 'error' && upload.error.code === 'too_large') { + // Either your own maxBytes or the platform's body cap. e.hint says which. +} +``` + +Keep a proxied route's own `maxBytes` under the platform's cap, so the refusal comes from your code with your wording rather than from the platform with none. A file that has to be bigger than the cap wants a direct browser upload instead. + +--- + +## Credential errors + +Three codes come from the credential service rather than from storage or from your code. + +| Code | Status | Meaning | What to do | +| ---- | ------ | ------- | ---------- | +| `unauthorized` | 401 | The bucket token was rejected. | Check `UPSTASH_BLOB_TOKEN`. Nothing retries this. | +| `not_ready` | 503 | The bucket is not ready yet. | Retry the request. | +| `mint_backoff` | 429 | The service asked for a backoff longer than a request can wait, over 10 seconds. `retryAfter` says how long. | Retry the request later rather than blocking on it. | + +The SDK already waits out short backoffs itself, up to three times. `mint_backoff` is what is left over: a pause no single request can sit through, so it is handed back to the caller instead of holding a serverless invocation open for it. + +```ts +try { + await bucket.put('u/7/report.pdf', body); +} catch (e) { + if (BlobError.is(e) && e.code === 'mint_backoff') { + return retryAfterSeconds(e.retryAfter ?? 10); + } + throw e; +} +``` + +Credentials are short-lived, cached per token, and re-minted just before they expire. A credential that expires mid-request is caught inside the SDK: it re-mints once and asks again, and only a second refusal surfaces. See [Signed URLs](/blob/overall/signing) for how that lifetime caps a signed link, and [Quickstart](/blob/overall/quickstart) for where the token comes from. diff --git a/blob/bucket/reading.mdx b/blob/bucket/reading.mdx new file mode 100644 index 00000000..663e1ee9 --- /dev/null +++ b/blob/bucket/reading.mdx @@ -0,0 +1,326 @@ +--- +title: "Reading" +--- + +Reading covers everything that gets bytes or facts back out of a bucket: `get` for the bytes, `info` for the facts, `exists` for the question, `list` for a page of keys, and a URL, public or signed, for everything that reads the object without going through your server at all. + +Every example below starts from a bucket: + +```ts lib/bucket.ts +import { Bucket } from '@upstash/blob'; + +export const bucket = Bucket.fromEnv(); // reads UPSTASH_BLOB_TOKEN +``` + +See [Quickstart](/blob/overall/quickstart) for the token, and [Writing](/blob/bucket/writing) for the other half of the API. + +--- + +## The record types + +Four record shapes come back from the SDK. They nest, so the rest of this page names them rather than repeating their fields. + +| Type | Is | Comes back from | +| --- | --- | --- | +| `BlobObject` | `path`, `url?`, `versionedUrl?`, `size`, `etag`, `uploadedAt` | `list()`, `copy()`, `move()`, `updateJson()` | +| `CompletedBlob` | `BlobObject` plus `contentType` | `put()`, and `onUploadComplete` | +| `BlobInfo` | `BlobObject` plus `contentType` and `metadata` | `info()` | +| `BlobDownload` | `BlobInfo` plus `body: ReadableStream` | `get()` | + +`BlobObject` is the base, and it is what a bucket listing can carry: + +| Field | Type | | +| --- | --- | --- | +| `path` | `string` | The object's key. | +| `url` | `string \| undefined` | The public object URL. Undefined on a private bucket. | +| `versionedUrl` | `string \| undefined` | `url` with the etag on the query. Undefined when `url` is. | +| `size` | `number` | Bytes. | +| `etag` | `string` | Storage's etag, quoted as it arrives: `"9f3c..."`. | +| `uploadedAt` | `Date` | Last modified. | + +### `blob` is a record, never bytes + +In this SDK `blob` always names a record, and never the bytes of one. The DOM already has a `Blob` and it is bytes, so the two must never swap places: nothing in the API takes a parameter named `blob`, and bytes go in as `body`. That is why `put(path, body)` reads the way it does, and why the bytes on a download sit under `body` on a record rather than being the return value. + +--- + +## `get(path)` + +`get` returns the whole record plus the response body as a stream. Nothing is buffered for you, so a large object costs whatever you do with the stream and no more. + +```ts +const res = await bucket.get('reports/2026-01.pdf'); + +res.contentType; // 'application/pdf' +res.size; // 184_302 +res.etag; // '"9f3c..."' +res.metadata; // { owner: 'u7' } +res.body; // ReadableStream +``` + +Wrap the body in a `Response` to get the usual conversions: + +```ts +const text = await new Response((await bucket.get('notes/1.md')).body).text(); +const buffer = await new Response((await bucket.get('img/1.png')).body).arrayBuffer(); +``` + +A missing object is a throw, not an `undefined` return: `get` raises a `BlobError` with code `not_found` and status 404. See [Errors](/blob/bucket/errors) for the code list and for `BlobError.is`. + +There is no range option. Reading part of an object is what [the S3 escape hatch](#the-s3-escape-hatch) is for. + +--- + +## `info(path)` + +`info` is the same record with no bytes: one HEAD request, so it costs nothing to read a 2 GB object's facts. + +```ts +const info = await bucket.info('reports/2026-01.pdf'); + +info.size; // 184_302 +info.etag; // '"9f3c..."' +info.contentType; // 'application/pdf' +info.metadata; // { owner: 'u7' } +info.uploadedAt; // Date +``` + +Like `get`, a missing object throws `not_found` rather than answering `undefined`. + + + Metadata keys come back lowercased. They cross the wire as `x-amz-meta-*` headers, and header names are case-insensitive, so `{ uploadedBy: 'u1' }` written at upload reads back as `metadata.uploadedby`. Values are printable ASCII, because storage does not carry anything else back unchanged. + + +`metadata` is the reason this call exists next to `exists()`. It comes back from `get` and from `info`, and from nothing else: a listing does not carry it. So `info` is the call a cleanup cron makes before it deletes anything, to confirm the object at that path is the one its row reserved rather than a later upload that reused the path. + +```ts scripts/sweep.ts +import { BlobError } from '@upstash/blob'; +import { bucket } from '@/lib/bucket'; + +for (const row of await db.uploads.pendingOlderThan('1d')) { + try { + const info = await bucket.info(row.path); + if (info.metadata.rowid !== row.id) continue; // somebody else's object + await bucket.del(row.path); + } catch (e) { + if (!BlobError.is(e) || e.code !== 'not_found') throw e; + } + await db.uploads.markSwept(row.id); +} +``` + +The pattern that sweep belongs to, and why the row is the only thing that can tell an abandoned upload from a finished one, is in [Abandoned uploads](/blob/browser/abandoned-uploads). + +--- + +## `exists(path)` + +`exists` answers `false` instead of throwing. It is the same HEAD request as `info`, with the record thrown away. + +```ts +if (await bucket.exists('avatars/u7.png')) { + // ... +} +``` + +Prefer `info()` whenever you are going to want the etag, the size or the metadata anyway: `exists()` then `info()` is two round trips for one answer, and the `not_found` catch you would write around `info` is the same branch as the `false`. + +--- + +## `list(options)` + +`list` returns one page of objects. + +| Option | Type | | +| --- | --- | --- | +| `prefix` | `string` | Only keys starting with this. | +| `limit` | `number` | Page size, clamped to 1 to 1000. Omit it and storage picks. | +| `cursor` | `string` | The `cursor` from the previous page. | + +```ts +const page = await bucket.list({ prefix: 'avatars/', limit: 100 }); + +page.blobs; // BlobObject[] +page.cursor; // string | undefined +``` + +`cursor` is set only while more remains, so a full walk is a `do ... while` and never needs a separate "is there more" check: + +```ts +let cursor: string | undefined; +const paths: string[] = []; + +do { + const page = await bucket.list({ prefix: 'avatars/', limit: 1000, cursor }); + for (const blob of page.blobs) paths.push(blob.path); + cursor = page.cursor; +} while (cursor); +``` + +A listing carries `BlobObject`, which means it has the path, size, etag, timestamp and URLs, and it does not have `contentType` or `metadata`. Storage does not return those in a listing, and fetching them would be one HEAD per key. + +`prefix` is also the only filter there is. There is no query by owner, by type, by date or by anything else, and the only way to find "this user's files" is a prefix you chose at upload time. An app that has to ask real questions about its files should keep its own table, write the row when the upload is authorized, and treat the bucket as the bytes rather than the index. That table is also what makes deleting and re-rendering cheap, since it holds the metadata a listing cannot. + +--- + +## Public URLs + +On a public bucket every record already carries `url`, and you can compute one for any path without a record: + +```ts +bucket.publicUrl('avatars/u7.png'); +// 'https://b0f3a91c24d.blob.upstash.io/avatars/u7.png' +``` + +There is no network call. The bucket's public DNS label is carried in the token itself, so `publicUrl` is string work against `.blob.upstash.io` and the path, percent-encoded. It returns `undefined` on a private bucket. It throws a `TypeError` for a path that is empty or contains a `.` or `..` segment, the same check every other path takes. + +### `versionedUrl` + +`versionedUrl` is `${url}?v=${etag}`, with the etag percent-encoded because storage returns it quoted. + +It exists for the stable path. If `avatars/u7.png` is overwritten every time the user picks a new picture, the URL never changes, so every cache between your object and the reader is free to keep serving the old bytes. `versionedUrl` changes whenever the content changes, because the etag does, which turns "the URL is stale" into "the URL is different". + +```tsx +const avatar = await bucket.info(`avatars/${user.id}.png`); +; +``` + +Pair it with `cache: 'immutable'` at upload: the bytes at any one versioned URL genuinely never change, so a year-long `max-age` is honest and the new picture is a new URL rather than a revalidation. See [Caching](/blob/bucket/caching) for the other cache options and when `'revalidate'` is the better trade. + +--- + +## Private buckets + +A private bucket has no public host, so a URL on one of its records would be a link that 404s. Declare it and `url` and `versionedUrl` are dropped from every record the SDK builds: + +```ts +const bucket = new Bucket({ token, visibility: 'private' }); + +const blob = await bucket.put('reports/2026-01.pdf', pdf); +blob.url; // undefined +blob.versionedUrl; // undefined +bucket.publicUrl('reports/2026-01.pdf'); // undefined +``` + +A `visibility` in the credentials response wins over what you declared, so a bucket that is private in the console stays private here even if the code says otherwise. Reads on a private bucket go through `signedReadUrl()`. + +--- + +## `signedReadUrl(path, options)` + +A time-limited URL anyone can GET, for a private bucket or for an object you do not want linked from a public page. + +```ts +const { url, expiresAt } = await bucket.signedReadUrl('reports/2026-01.pdf', { + expiresIn: '2m', + downloadAs: 'Report Q3.pdf', +}); +``` + +| Option | Type | | +| --- | --- | --- | +| `expiresIn` | `Duration` | How long to ask for. `'15m'`, `'2h'`, or a bare number of seconds. Default 5 minutes. | +| `downloadAs` | `string` | Save as this filename instead of displaying inline. | +| `contentType` | `string` | What storage answers with as `Content-Type`, overriding what was stored. | + +The return is `{ url, expiresAt }`. + +### The lifetime is answered, not chosen + +Links are signed with the bucket's short-lived credential, and a signature cannot outlive the credential that made it. So `expiresIn` is what you ask for, and `expiresAt` is what you got: it is never later than the signing credential's own expiry, and it is the value to cache the link against rather than a duration you compute yourself. + +```ts +const cached = await cache.get(key); +if (!cached || cached.expiresAt < new Date()) { + const link = await bucket.signedReadUrl(path, { expiresIn: '5m' }); + await cache.set(key, link); +} +``` + +The default with no `expiresIn` is 5 minutes, shortened if the credential has less than that left. Asking for more than the credential can cover re-mints where that helps, and is capped where it does not. [Signed URLs](/blob/overall/signing) covers the mechanism, and `signedUploadUrl` for the write direction. + +### `downloadAs` + +`downloadAs` sets a `Content-Disposition: attachment` on the response, so the browser saves the file under that name rather than rendering it. + +The name is carried as an RFC 6266 `filename*` ext-value, percent-encoded, with an ASCII `filename` fallback cut back to characters that cannot end the quoted string. A name with a quote, a semicolon or a CRLF in it cannot add a parameter or a second header, and a Unicode name arrives intact: + +```ts +await bucket.signedReadUrl(path, { downloadAs: 'café ☕.pdf' }); +// content-disposition: attachment; filename="caf_ _.pdf"; filename*=UTF-8''caf%C3%A9%20%E2%98%95.pdf +``` + +The disposition is signed into the URL along with everything else, so it cannot be edited off the query string by whoever holds the link. + +### `contentType` + +`contentType` overrides what storage answers with, without rewriting the object: + +```ts +await bucket.signedReadUrl('exports/rows.bin', { contentType: 'text/csv' }); +``` + +It is validated as a media type and throws `invalid_input` if it is not one, for the same reason `downloadAs` is encoded: this value becomes a response header. + +--- + +## Incomplete uploads + +`listMultipartUploads()` is the one read that does not answer with objects. + +```ts +const uploads = await bucket.listMultipartUploads({ prefix: 'uploads/' }); +// [{ path: 'uploads/big.bin', uploadId: 'mp-1', initiatedAt: Date }] +``` + +A multipart upload that was started and never completed or aborted is billed storage that `list()` cannot see, and a bucket cannot be deleted while one exists. That makes this the only call that can find them. Finding them is not the job though: sweeping them is, and `abortStaleMultipartUploads()` is in [Deleting](/blob/bucket/deleting), with the reason a browser leaves one behind in [Abandoned uploads](/blob/browser/abandoned-uploads). + +--- + +## The S3 escape hatch + +The bucket is S3-compatible, and `bucket.s3()` hands the aws-sdk what it needs for anything the SDK does not model: byte ranges, conditional GETs, delimiters and common prefixes, object tagging. + +```ts +import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { bucket } from '@/lib/bucket'; + +const { endpoint, region, bucket: name, credentials } = bucket.s3(); +const s3 = new S3Client({ endpoint, region, credentials }); + +const res = await s3.send( + new GetObjectCommand({ Bucket: name, Key: 'reports/2026-01.pdf', Range: 'bytes=0-1023' }), +); +``` + +`endpoint` and `credentials` are async providers rather than values. The bucket's credential is short-lived and the endpoint is only known from a credentials response, so handing over providers is what lets the aws-sdk re-read both when the current one expires, instead of holding a credential that dies a few minutes in. + +--- + +## Next steps + + + + `put`, metadata, conditional writes and multipart from the server. + + + + What `Cache-Control` an object is stored with, and pairing it with `versionedUrl`. + + + + One path, a list, a prefix, and sweeping incomplete uploads. + + + + How links are signed, and the upload direction. + + + + `BlobError`, the code list, and `BlobError.is`. + + + + Direct browser uploads, and what `onUploadComplete` is handed. + + diff --git a/blob/bucket/writing.mdx b/blob/bucket/writing.mdx new file mode 100644 index 00000000..9eff788c --- /dev/null +++ b/blob/bucket/writing.mdx @@ -0,0 +1,456 @@ +--- +title: "Writing" +--- + +Everything on this page runs on your server, with the bucket token. `put` writes bytes, `copy` and `move` rearrange them, `updateJson` reads and writes a document under a compare-and-set loop, and `signedUploadUrl` hands the write to somebody else. + +If you have not installed the SDK or created a bucket yet, start at the [Quickstart](/blob/overall/quickstart). For bytes that live in a browser, do not proxy them through your app: see [Upload handler](/blob/browser/upload-handler). + +--- + +## The bucket client + +`Bucket.fromEnv()` reads `UPSTASH_BLOB_TOKEN`. + +```ts lib/blob.ts +import { Bucket } from '@upstash/blob'; + +export const bucket = Bucket.fromEnv(); +``` + +The constructor takes the token directly, plus three options that apply to every write this client makes. + +```ts +const bucket = new Bucket({ + token: process.env.UPSTASH_BLOB_TOKEN!, + visibility: 'private', // drops url and versionedUrl everywhere + cache: 'immutable', // the default Cache-Control for objects this client stores + enableTelemetry: false, +}); +``` + +| Option | Type | What it does | +| --- | --- | --- | +| `token` | `string` | Required. The bucket token. | +| `visibility` | `'public' \| 'private'` | `'private'` drops `url` and `versionedUrl` from every record, since nothing serves a private bucket over the public host. A visibility in the credentials response wins over this. | +| `cache` | `CacheOption` | The `Cache-Control` written on every object this bucket stores. A per-call `cache` overrides it. See [Caching](/blob/bucket/caching). | +| `enableTelemetry` | `boolean` | Default `true`. See [Telemetry](#telemetry). | + +On Cloudflare Workers there is no `process`, so the token only exists on the request's `env`. `Bucket.fromEnv()` throws there and says so; pass the token instead. + +```ts src/index.ts +import { Bucket } from '@upstash/blob'; + +export default { + async fetch(request: Request, env: { UPSTASH_BLOB_TOKEN: string }) { + const bucket = new Bucket({ token: env.UPSTASH_BLOB_TOKEN }); + await bucket.put('hits.txt', '1'); + return new Response('ok'); + }, +}; +``` + + +The credential cache is keyed by token, not held per instance. Constructing a `Bucket` per request on a serverless platform is the intended shape and does not mint a credential each time: two clients built from the same token share one. + + +--- + +## put + +```ts +const blob = await bucket.put('reports/q3.pdf', pdf, { contentType: 'application/pdf' }); +``` + +### Options + +| Option | Type | What it does | +| --- | --- | --- | +| `contentType` | `string` | What the object is stored as. Falls back to what the body carries, then to `application/octet-stream`. | +| `contentTypes` | `readonly string[]` | An allow list. The declared type is checked against it, and the body's first 4100 bytes are checked against the declared type. A refusal is `content_type_not_allowed`, before anything is written. | +| `maxBytes` | `Size` | Refuses a body over this with `too_large`. Also the buffer cap for a body whose length is not known. | +| `cache` | `CacheOption` | The `Cache-Control` this object is stored with, overriding the bucket default. See [Caching](/blob/bucket/caching). | +| `metadata` | `Record` | Stored as `x-amz-meta-*`. See [Metadata](#metadata). | +| `size` | `number` | The declared length of a body whose size is not otherwise known. | +| `overwrite` | `boolean` | `false` refuses the write if something is already at the path. | +| `ifUnchanged` | `string` | An etag. The write fails if the object changed. | +| `multipart` | `boolean \| Size` | Where the multipart path starts. Default 16 MB. | + +Sizes are decimal, so `'2mb'` is 2,000,000 bytes. `maxBytes` accepts a number of bytes or a string like `'20mb'`; `size` is a number of bytes. + +### What it returns + +A `CompletedBlob`, which is the record a listing carries plus the type the object was stored as. + +| Field | Type | | +| --- | --- | --- | +| `path` | `string` | The path you wrote to. | +| `url` | `string \| undefined` | The public object URL. `undefined` on a private bucket. | +| `versionedUrl` | `string \| undefined` | `${url}?v=${etag}`, for a stable path that gets overwritten. `undefined` whenever `url` is. | +| `size` | `number` | Bytes stored. | +| `etag` | `string` | Quoted, and what `ifUnchanged` takes. | +| `uploadedAt` | `Date` | | +| `contentType` | `string` | What the object was stored as. | + +```ts +const blob = await bucket.put('avatars/7.png', file, { contentType: 'image/png' }); + +blob.url; // https://b3f9a2c7d1e4.blob.upstash.io/avatars/7.png +blob.versionedUrl; // ...?v=%22d41d8...%22 +blob.etag; +``` + +On a private bucket `url` and `versionedUrl` are `undefined` and reads go through a signed link instead. See [Signed URLs](/blob/overall/signing). + +### Bodies + +`put` takes a `PutBody`. Some of these already know how long they are and what they contain, which is what decides whether `put` has to buffer anything and what the object is stored as. + +| Body | Carries its length | Carries a content type | +| --- | --- | --- | +| `Request` | From its `content-length` header | From its `content-type` header | +| `Blob` / `File` | Yes | Yes, when `type` is set | +| `ArrayBuffer` | Yes | No | +| Typed array | Yes | No | +| `string` | Yes, once UTF-8 encoded | No | +| `ReadableStream` | No | No | + +The default content type is `application/octet-stream`, so a string or a buffer with no `contentType` is stored as that. An explicit `contentType` always wins over what the body carries. + +```ts app/api/avatar/route.ts +import { bucket } from '@/lib/blob'; + +export async function POST(request: Request) { + // A Request carries both, so nothing has to be declared. + const blob = await bucket.put('avatars/me.png', request, { + contentTypes: ['image/*'], + maxBytes: '5mb', + }); + return Response.json({ url: blob.url }); +} +``` + +A `Request` with no body, or one that has already been read, throws `empty_body`. Anything that is not one of the types above is a `TypeError`. + +--- + +## Streams and unknown lengths + +Storage needs a content length before the first byte goes out, and a `ReadableStream` has no length. Without `size` or `maxBytes` there is nothing `put` can do with one, so it refuses up front: + +```ts +await bucket.put('export.csv', stream); +// BlobError: Length required (pass { size } or { maxBytes } so the length is known +// before the first byte) -- code 'length_required', status 411 +``` + +There are two ways through. + +**Pass `maxBytes`.** The stream is read into memory up to that many bytes, which is what makes the length knowable, and a stream that runs past the cap is cancelled with `too_large`. Keep the cap somewhere your process can hold. + +```ts +const blob = await bucket.put('export.csv', stream, { maxBytes: '10mb' }); +``` + +**Pass `size`.** Nothing is buffered and the bytes go straight through. + +```ts +const blob = await bucket.put('export.csv', stream, { size: 5000 }); +``` + +A declared `size` that does not match what arrives is caught rather than stored wrong. Too many bytes throws `invalid_input` with `Body is longer than the declared 5000 bytes`, and too few throws `invalid_input` with `Body was 4000 bytes, 5000 were declared`. + +The same applies to a `Request` that arrived chunked: delete or ignore its `content-length` and it is an unknown length like any other stream. + + +When bytes are being proxied through a route, keep `maxBytes` under the platform's own request body cap. Vercel refuses a serverless body at 4.5 MB, AWS Lambda at 6 MB, Cloudflare at 100 MB on the free plan, and that refusal happens before your route runs. + + +--- + +## Paths + +A path is any non-empty string, with `/` as structure. It is percent-encoded for you, so spaces and unicode are fine. + +`.` and `..` segments are rejected outright rather than normalised: + +```ts +await bucket.put('uploads/../secrets/key.pem', body); +// TypeError: path may not contain "." or ".." segments +``` + +Normalising would be the wrong answer here. The temporary credential the SDK signs with authorizes the whole bucket, and a URL parser resolves `..` on its own, so a traversing key would quietly touch a different object than the one it names. Refusing is the only outcome that cannot surprise you. + +### uniquePath + +`uniquePath` is a template tag for building a path out of values you do not control, like a filename a browser handed you. + +```ts +import { uniquePath } from '@upstash/blob'; + +const path = uniquePath`${user.id}/${file.name}`; +// 'u7/holiday-pic-3xK9mBqR.png' +``` + +The trust boundary is the interpolation. Slashes in the literal chunks are structure; slashes inside `${}` are stripped along with the rest of the directory component, so an interpolated value can never contribute a directory of its own. + +```ts +uniquePath`chat/${'../admin/x.png'}`; // 'chat/x-9fQ2mAe7.png' +uniquePath`a/${'b/c'}`; // 'a/c-Kd3xR8wP' +``` + +Each interpolated value is reduced to its basename, stripped of control and format characters, NFC-normalized, lowercased, and slugged: runs of anything that is not a letter or a number become `-`. Letters and digits from any script survive, so `café.pdf` stays `café`. The stem is capped at 64 characters. The extension, up to 8 characters, is kept and lowercased. + +An 8 character base58 suffix is then appended to the final basename, before the extension. The alphabet leaves out `0`, `O`, `I` and `l`, so a path read aloud or retyped stays the same path. + +```ts +uniquePath`${'Q3 Report (final).pdf'}`; // 'q3-report-final-7hTbN2xY.pdf' +uniquePath`${'!!! ***'}`; // 'file-Wm4pQ8dK' +``` + +Use it whenever two people picking `photo.png` must not land on the same object. When overwriting is the intent, write the path yourself. + +--- + +## Metadata + +`metadata` is a flat `Record` stored alongside the object as `x-amz-meta-*` headers. + +```ts +await bucket.put('invoices/7.pdf', pdf, { + contentType: 'application/pdf', + metadata: { owner: 'u7', invoiceId: '2026-0042' }, +}); + +const info = await bucket.info('invoices/7.pdf'); +info.metadata; // { owner: 'u7', invoiceid: '2026-0042' } +``` + +Header names are case-insensitive, so keys come back lowercased. Write them lowercase and there is no surprise. + +Keys must be valid header names, and values must be printable ASCII. Anything else is refused before the request goes out: + +```ts +await bucket.put('a.txt', 'x', { metadata: { note: 'café' } }); +// BlobError: metadata.note has characters storage does not carry back unchanged +// (metadata is printable ASCII; percent-encode anything else with encodeURIComponent) +// -- code 'invalid_input', status 400 +``` + +This is stricter than it looks, and the reason is measurable. R2 does not store a non-ASCII value verbatim: `{ note: 'café' }` comes back as `=?utf-8?Q?caf=C3=A9?=`. Accepting it would mean handing you back a different string than the one you wrote, and finding out about it on the read. Percent-encode instead, and it round trips exactly: + +```ts +await bucket.put('a.txt', 'x', { metadata: { note: encodeURIComponent('café') } }); +decodeURIComponent((await bucket.info('a.txt')).metadata.note!); // 'café' +``` + + +Metadata comes back from `info()` and `get()`, but not from `list()`. A listing carries paths, sizes, etags and urls only, so a sweep that has to read metadata is one `info()` per object. See [Reading](/blob/bucket/reading). + + +--- + +## Conditional writes + +Two options turn `put` into a conditional write. Both are enforced by storage, not by a read-then-write in the SDK, so neither has a race window. + +**`overwrite: false`** sends `If-None-Match: *`. If something is already at the path the write is a real 412, and the SDK raises `already_exists` carrying what is there: + +```ts +try { + await bucket.put('u/7/profile.json', body, { overwrite: false }); +} catch (e) { + if (BlobError.is(e) && e.code === 'already_exists') { + e.etag; // the etag of the object that is already there + e.size; // and its size + } +} +``` + +**`ifUnchanged: etag`** sends `If-Match`. If the object changed since you read that etag, the write throws `conflict`: + +```ts +const current = await bucket.info('u/7/profile.json'); +await bucket.put('u/7/profile.json', next, { ifUnchanged: current.etag }); +// throws BlobError 'conflict' if somebody else wrote first +``` + +Both are single-PUT only, because a multipart upload has no conditional complete. They turn multipart off, which is why a conditional write of a large body still goes up as one request. Asking for both at once is a build-time mistake rather than a silent downgrade: + +```ts +await bucket.put('big.bin', data, { multipart: true, overwrite: false }); +// BlobError: Multipart: overwrite:false and ifUnchanged are single-PUT only +// -- code 'invalid_input' +``` + +--- + +## updateJson + +`updateJson` is the compare-and-set loop those two options are for, written once. It reads the document, calls your function with the parsed value, and writes the result back with `If-Match`, or with `If-None-Match: *` when there was nothing there. A conflict means somebody wrote in between, so it reads again and re-runs your function against what actually landed. + +```ts +interface Settings { + theme: string; +} + +await bucket.updateJson('u/7.json', (prev) => ({ + ...(prev ?? {}), + theme: 'dark', +})); +``` + +Your function is handed `null` when there is nothing to read. An object that exists but is empty also reads as `null`: there is no JSON document either way, so the callback sees the same "nothing here yet" both times. + +The object is written as `application/json`. Existing metadata is carried over unless you pass `metadata` of your own, and `cache` is available the same way: + +```ts +await bucket.updateJson( + 'u/7.json', + (prev) => ({ ...(prev ?? {}), theme: 'dark' }), + { metadata: { owner: 'u7' }, cache: 'no-store' }, +); +``` + +Your function may be async, and it is re-run on every attempt, so keep it a pure transform rather than somewhere to do work with side effects. + +There are six attempts in total. A document that keeps changing under all six throws: + +```ts +// BlobError: u/7.json kept changing across 6 attempts -- code 'conflict', status 409 +``` + +--- + +## copy and move + +`copy(from, to)` is a server-side copy: the bytes never travel through your app. It returns the destination's record. + +```ts +const archived = await bucket.copy('tmp/9f3c', 'archive/2026/report.pdf'); +archived.size; +``` + +`move(from, to)` is a copy followed by a delete of the source. + +```ts +const moved = await bucket.move('tmp/9f3c', 'reports/q3.pdf'); +``` + + +A move is not atomic. If the copy lands and the delete fails, `move` throws `move_left_a_copy` and keeps the destination, so you have **two** objects rather than none. The original error is on `cause`. Retry the delete, or delete the source yourself, but do not treat the throw as "nothing happened". + + +```ts +try { + await bucket.move('tmp/9f3c', 'reports/q3.pdf'); +} catch (e) { + if (BlobError.is(e) && e.code === 'move_left_a_copy') { + await bucket.del('tmp/9f3c'); // the destination is already correct + } +} +``` + +Copying a path that does not exist throws `not_found`. + +--- + +## Large bodies + +A body over 16 MB, decimal, goes up as a multipart upload instead of one PUT. `multipart` moves that line: a size is the threshold to use instead (`'100mb'`), `true` always uses parts, `false` never does. + +```ts +await bucket.put('video.mp4', data, { multipart: '100mb' }); +``` + +R2 refuses a single PUT larger than about 5 GiB, so past that there is no choice. `multipart: false` on a body that big is refused rather than attempted: + +```ts +// BlobError: 6 GB is over the 5.4 GB a single PUT can carry +// (multipart: false forbids the parts this body needs) -- code 'too_large' +``` + +Server-side, parts are sent one at a time. A part is buffered whole so it can be retried, and holding several would multiply that memory by the concurrency. If anything fails, the SDK aborts the whole upload before throwing, because an incomplete multipart upload is billed storage that `list()` cannot see. + +Parts, pause, resume and per-part retry are covered in full in [Large files](/blob/browser/large-files), including the cron for upload parts a closed browser tab left behind. + +--- + +## Signed upload URLs + +`signedUploadUrl` produces a URL somebody else can PUT exactly one object to. It is the write-side counterpart of a signed read link, for a CLI, a build step, or a server-to-server job that has bytes you do not want to relay. + +```ts +const upload = await bucket.signedUploadUrl('u/7/report.pdf', { + contentType: 'application/pdf', + size: pdf.size, + expiresIn: '15m', +}); + +await fetch(upload.url, { method: 'PUT', headers: upload.headers, body: pdf }); +``` + +| Option | Type | What it does | +| --- | --- | --- | +| `expiresIn` | `Duration` | How long the link should live. Default one hour. | +| `contentType` | `string` | The `Content-Type` the upload must send, and what the object is stored as. Defaults to `application/octet-stream`. | +| `cache` | `CacheOption` | The `Cache-Control` the object is stored with. | +| `metadata` | `Record` | Written as `x-amz-meta-*`, under the same rules as `put`. | +| `size` | `Size` | Pins the body's exact length, so a URL handed out for one file cannot upload another size. | +| `overwrite` | `boolean` | `false` refuses the upload if something is already at the path. | + +It returns `{ url, headers, expiresAt }`. + + +`headers` are pinned into the signature and must be sent verbatim. Drop one, change one, or add one, and storage answers **403** rather than letting the caller choose what the object is stored as. That is also what makes `metadata` yours and not the uploader's. + + +A link can never outlive the credential that signed it, so `expiresAt` is the answer rather than what you asked for. The SDK re-mints to cover a longer ask where it can, and `expiresAt` reports what actually came out. + +For a browser upload, use the upload handler instead. It also handles multipart, resume, and the completion callback this cannot: a signed URL is one PUT, and nothing tells your server it happened. See [Upload handler](/blob/browser/upload-handler) and [Signed URLs](/blob/overall/signing). + +--- + +## The S3 escape hatch + +`bucket.s3()` hands back a config for `@aws-sdk/client-s3`, for the S3 operations this SDK does not wrap. + +```ts +import { S3Client, ListObjectsV2Command } from '@aws-sdk/client-s3'; +import { bucket } from '@/lib/blob'; + +const { endpoint, region, bucket: name, credentials } = bucket.s3(); +const s3 = new S3Client({ endpoint, region, credentials }); + +await s3.send(new ListObjectsV2Command({ Bucket: name, Prefix: 'reports/' })); +``` + +`endpoint` and `credentials` are async providers rather than values. The endpoint is only known from a credentials response, and the credential itself is short-lived, so handing the aws-sdk providers is what lets it re-read a fresh one on expiry instead of failing an hour in. + +--- + +## Telemetry + +The SDK sends its version, the runtime it is on, and the platform as headers on credential requests to Upstash. Those are one request per credential lifetime, not per object, so nothing on the hot path carries them. Turn it off with `UPSTASH_DISABLE_TELEMETRY` in the environment, or with `enableTelemetry: false` on the `Bucket`. Setting the variable to `false`, `0`, `no` or `off` does not opt out: an environment variable that says false and means true is a trap. + +--- + +## Next steps + + + + `get`, `info`, `exists` and paging through `list`. + + + + One path, a list, a prefix, and what a partial delete reports. + + + + What `cache` accepts and why it is written once, at upload. + + + + Every code, what raises it, and how to test for one. + + diff --git a/blob/formulas/overview.mdx b/blob/formulas/overview.mdx new file mode 100644 index 00000000..565abf72 --- /dev/null +++ b/blob/formulas/overview.mdx @@ -0,0 +1,251 @@ +--- +title: "Formulas" +--- + +The reference pages describe one option at a time: what `cache` accepts, what `constraints` refuse, what `onUploadComplete` is handed. A formula is the other direction. It is one real feature wired end to end, with every file it takes, and with the choices already made and explained. Copy one, rename the paths, and it works. + +--- + +## Pick your shape + +Almost every decision in an upload feature is the same five: where the object goes, what it is cached as, what the route accepts, whether it goes up in parts, and who cleans up after a browser that never came back. These are the answers for the cases that come up most. + +| Feature | Path | Cache | Constraints | Multipart | Notes | +| ------- | ---- | ----- | ----------- | --------- | ----- | +| Avatar | `avatars/${user.id}.png`, stable | [`'immutable'`](/blob/bucket/caching) and serve `versionedUrl`, or `'revalidate'` if you link the bare `url` | [`image/*`](/blob/browser/constraints), `maxBytes: '5mb'` | default, a 5 MB file is always one PUT | Overwriting is the intent, so there is no orphan and nothing to sweep | +| Chat or issue attachment | [`uniquePath`](/blob/browser/upload-handler) under `threads/${threadId}/` | default, `public, max-age=3600` | `maxBytes: '25mb'`, no type list | default, or `true` to skip the sweep | [Pending row plus a cron](/blob/browser/abandoned-uploads); `uploadId` is the idempotency key | +| User document library | `uniquePath` under `docs/${user.id}/` | `'immutable'`, the path is already unique | `['application/pdf']`, `maxBytes: '100mb'` | default | [`bucket.list({ prefix })`](/blob/bucket/reading) is the listing, your rows are the metadata | +| Large video upload | `uniquePath` under `videos/${user.id}/` | `'immutable'` | [`video/*`](/blob/browser/constraints), `maxBytes: '5gb'` | [`multipart: true`](/blob/browser/large-files) | Only parts can pause, resume and retry; a closed tab leaves parts for `abortStaleMultipartUploads` | +| Private report or invoice | `invoices/${invoice.id}.pdf`, stable | `'no-store'` | written by your server, so [`bucket.put`](/blob/bucket/writing) rather than a route | default | `visibility: 'private'` drops `url`, and reads go through [`signedReadUrl`](/blob/overall/signing) | + +Two rules run underneath the whole table. Use `uniquePath` unless overwriting is the intent, because two uploads racing to one path lose an update and make the loser's completion fail with `not_found`. And write the row that says an upload is in flight before the bytes are, because under the multipart threshold the presigned PUT stores the object the moment the last byte lands, whether or not any callback ever accepted it. + +--- + +## Avatar upload + +One object per user, at a path derived from the user id, served through a URL that changes with the bytes. + +```ts lib/uploads.ts +import "server-only" +import { BlobError, uploadHandler } from "@upstash/blob" +import { getUser } from "./auth" +import { db } from "./db" + +export const uploads = uploadHandler({ + constraints: { contentTypes: ["image/*"], maxBytes: "5mb" }, + + onBeforeUpload: async ({ request, file }) => { + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") // the 401, and nothing is signed + + return { + path: `avatars/${user.id}.png`, + cache: "immutable", + metadata: { owner: user.id }, + } + }, + + onUploadComplete: async ({ metadata, path, versionedUrl }) => { + try { + await db.users.update({ id: metadata.owner, avatarUrl: versionedUrl }) + } catch (e) { + // A throw here deletes the object that just landed. The bytes are fine and the path is + // derivable, so a stale row is the cheaper failure. + console.error("[avatar] could not record", path, e) + } + return { avatarUrl: versionedUrl } + }, +}) +``` + +The `.png` in the path is cosmetic. The object is stored and served as the `Content-Type` the browser declared, and `contentTypes: ['image/*']` is what checks that. + +```ts app/api/upload/route.ts +import { uploads } from "@/lib/uploads" + +export const { GET, POST } = uploads +``` + +```tsx components/avatar-picker.tsx +"use client" + +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "@/lib/uploads" + +const { useUpload } = uploadHooks() + +export function AvatarPicker({ src }: { src: string }) { + const { start, upload, accept } = useUpload() + const current = upload?.status === "done" ? (upload.blob.data.avatarUrl ?? src) : src + + return ( + + ) +} +``` + +`upload.blob.data` is typed from what `onUploadComplete` returned, so `avatarUrl` is checked at compile time rather than hoped for. + +### Why this shape + +A stable path is the one case where overwriting is the intent. There is exactly one object per user, the second upload replaces the first, and that removes both jobs the unique-path shape has to do: no pending row to reconcile, because a completion that never arrives leaves the previous avatar in place rather than an orphan, and no old avatars to sweep, because there are never two. + +`cache: 'immutable'` on a path that gets overwritten would normally be the wrong answer, and it is safe here only because nothing links the bare `url`. `versionedUrl` is `url` with `?v=` appended, so new bytes are a new URL and the old one is never asked for again. The alternative is `'revalidate'`, which keeps one URL and pays a 304 per read. + +The `try`/`catch` is not decoration. Any throw out of `onUploadComplete` deletes the object that just landed, the browser retries the completion, and the user is shown a 404 for an upload whose bytes were fine. Swallowing the database error costs a stale `avatarUrl` instead, and that is recoverable: the path is `avatars/${user.id}.png`, so `bucket.info(path)` gives the etag the URL is built from. See [Errors](/blob/bucket/errors). + +--- + +## Chat attachments + +Many files per thread, none of them overwriting each other, with a row written before the upload and cleared after it. + +```ts lib/uploads.ts +import "server-only" +import * as z from "zod" +import { BlobError, uniquePath, uploadHandler, uploadRoute } from "@upstash/blob" +import { requireUser, type Session } from "./auth" +import { sql } from "./db" + +export const uploads = uploadHandler({ + // Written above `routes`: it runs once per POST, before any body is read, and its value is `ctx`. + context: (request: Request) => requireUser(request), + + routes: { + attachment: uploadRoute()({ + constraints: { maxBytes: "25mb" }, + input: z.object({ threadId: z.string().uuid() }), + + onBeforeUpload: async ({ ctx, input, file }) => { + const thread = await sql`select id from threads + where id = ${input.threadId} and member_id = ${ctx.id}` + if (thread.length === 0) throw new BlobError("forbidden") + + const path = uniquePath`threads/${input.threadId}/${file.name}` + const [row] = await sql`insert into pending_uploads (thread_id, user_id, path) + values (${input.threadId}, ${ctx.id}, ${path}) + returning id` + + return { + path, + metadata: { row: row.id }, + state: { rowId: row.id, threadId: input.threadId }, + } + }, + + onUploadComplete: async ({ state, uploadId, path, url, size, contentType, file }) => { + // uploadId is stable across the browser's retries of the completion request, so + // at-least-once delivery writes one row. + await sql`insert into attachments (upload_id, thread_id, path, url, size, content_type, name) + values (${uploadId}, ${state.threadId}, ${path}, ${url ?? null}, + ${size}, ${contentType}, ${file.name}) + on conflict (upload_id) do nothing` + + // Last, always. "Row still pending" is what the sweep below reads as "never accepted". + await sql`delete from pending_uploads where id = ${state.rowId}` + + return { attachmentId: uploadId } + }, + }), + }, +}) +``` + +The schema is validated before `onBeforeUpload` runs, so a thread id that is not a UUID is a `400` and nothing is signed, no row is inserted, and no presigned URL exists. + +```ts app/api/upload/route.ts +import { uploads } from "@/lib/uploads" + +export const { GET, POST } = uploads +``` + +```ts app/api/cron/sweep-uploads/route.ts +import { BlobError, Bucket } from "@upstash/blob" +import { sql } from "@/lib/db" + +const bucket = Bucket.fromEnv() + +export async function GET() { + const stale = await sql`select id, path from pending_uploads + where created_at < now() - interval '1 hour' + limit 500` + + for (const row of stale) { + try { + // metadata comes back unstripped, so this confirms the object is the one the row reserved. + const info = await bucket.info(row.path) + if (info.metadata.row === row.id) await bucket.del(row.path) + } catch (e) { + // info() throws rather than returning undefined. Already gone is the good case. + if (!BlobError.is(e) || e.code !== "not_found") throw e + } + await sql`delete from pending_uploads where id = ${row.id}` + } + + // Parts from a tab that closed mid-upload: invisible to list(), billed, and they block a + // bucket delete until something aborts them. + const aborted = await bucket.abortStaleMultipartUploads({ olderThan: "1d", prefix: "threads/" }) + + return Response.json({ swept: stale.length, aborted: aborted.length }) +} +``` + +```tsx components/attachment-input.tsx +"use client" + +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "@/lib/uploads" + +const { useUpload } = uploadHooks() + +export function AttachmentInput({ threadId }: { threadId: string }) { + const { start, uploads: files } = useUpload("attachment") + + return ( + <> + start({ files: e.target.files, input: { threadId } })} + /> + +
    + {files.map((file) => ( +
  • + {file.file.name} + {file.pending && } + {file.status === "done" && attached} + {file.status === "error" && {file.error.message}} +
  • + ))} +
+ + ) +} +``` + +`input` is required by the hook because the route declared a schema, and its shape is the schema's, so a missing or misspelled `threadId` fails to compile rather than at `begin`. + +### Why this shape + +`uniquePath` sanitizes what you interpolate and appends a random suffix, so two people sending `photo.png` to one thread get two objects. Without it the second upload silently replaces the first, and the first upload's completion then fails with `not_found` even though its bytes landed. + +The pending row is what makes a closed tab recoverable. Under the multipart threshold a direct upload is one presigned PUT, so the object exists the moment the last byte lands, and the completion request that would have recorded it is a separate call the browser may never make. Nothing on the object distinguishes that from a finished upload, so only your own rows can: write the row in `onBeforeUpload`, clear it last in `onUploadComplete`, and sweep what is still pending past the grace window. The sweep is an indexed query over your rows, not a scan of the bucket, and the row names the exact path. See [Abandoned uploads](/blob/browser/abandoned-uploads). + +Because the sweep exists, a database error is allowed to escape `onUploadComplete` here, unlike in the avatar formula. The throw deletes the object, the pending row survives, the cron's `not_found` branch clears it, and the user gets an error for an upload that genuinely did not land. + +--- + +## More formulas coming + +Planned next: user document library, large video upload with pause and resume, private invoices behind signed URLs, and image processing on completion. Each will land here as a full set of files, in the same shape as the two above. diff --git a/blob/overall/pricing.mdx b/blob/overall/pricing.mdx new file mode 100644 index 00000000..e5bd681a --- /dev/null +++ b/blob/overall/pricing.mdx @@ -0,0 +1,6 @@ +--- +title: Pricing & Limits +url: https://upstash.com/pricing/blob +--- + +Please check our [pricing page](https://upstash.com/pricing/blob) for the most up-to-date information on pricing and limits. diff --git a/blob/overall/quickstart.mdx b/blob/overall/quickstart.mdx new file mode 100644 index 00000000..21360f57 --- /dev/null +++ b/blob/overall/quickstart.mdx @@ -0,0 +1,206 @@ +--- +title: "Quickstart" +--- + +Upstash Blob is S3-compatible object storage with an SDK for three jobs: writing objects from your server with `Bucket`, letting a browser upload straight to storage without the bytes ever passing through your app, and driving that upload from React with hooks. This page takes the direct browser upload path from nothing to a working file picker on Next.js App Router. + +--- + +## Install + + +```bash npm +npm install @upstash/blob +``` + +```bash pnpm +pnpm add @upstash/blob +``` + +```bash yarn +yarn add @upstash/blob +``` + +```bash bun +bun add @upstash/blob +``` + + +The package has three entrypoints: `@upstash/blob` for the server, `@upstash/blob/browser` for a plain browser client, and `@upstash/blob/react` for the hooks. + +--- + +## Get a bucket token + +Create a bucket in the [Upstash Console](https://console.upstash.com) and copy its token. + +```bash .env.local +UPSTASH_BLOB_TOKEN=... +``` + +Everything below reads this variable: `Bucket.fromEnv()` reads it, and an `uploadHandler` with no `bucket` of its own builds one from it, once. + +--- + +## Upload from the browser + + + + + +The handler decides who may upload, where the object goes, and what happens once it lands. It runs on your server and signs the upload. It never sees the bytes. + +```ts lib/uploads.ts +import "server-only" +import { BlobError, uniquePath, uploadHandler } from "@upstash/blob" +import { getUser } from "./auth" +import { db } from "./db" + +export const uploads = uploadHandler({ + constraints: { maxBytes: "20mb", contentTypes: ["image/*", "application/pdf"] }, + + onBeforeUpload: async ({ request, file }) => { + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") // the 401, and nothing is signed + return { path: uniquePath`${user.id}/${file.name}`, metadata: { owner: user.id } } + }, + + onUploadComplete: async ({ url, metadata, uploadId }) => { + // uploadId is stable across retries, so the same completion twice writes one row + await db.files.upsert({ uploadId, owner: metadata.owner, url }) + }, +}) +``` + +`uniquePath` sanitizes what you interpolate and adds a random suffix, so two people picking `photo.png` do not land on the same object. Sizes are decimal, so `'20mb'` is 20,000,000 bytes. The grammar behind `constraints` is covered in [Constraints](/blob/browser/constraints). + + +A throw out of `onUploadComplete` deletes the object that just landed. Catch your own database errors rather than letting them escape. See [Abandoned uploads](/blob/browser/abandoned-uploads). + + + + + + +The handler is already a pair of route handlers. `POST` runs the upload, `GET` serves the route's constraints. + +```ts app/api/upload/route.ts +import { uploads } from "@/lib/uploads" + +export const { GET, POST } = uploads +``` + +`/api/upload` is where the hooks look by default, so nothing else has to name a URL. + + + + + +`uploadHooks()` reads the handler's type. That is how `upload.blob.data` on the client is typed from what `onUploadComplete` returned, and how a route name that does not exist fails to compile. + +```ts lib/upload-client.ts +"use client" + +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "./uploads" + +export const { useUpload } = uploadHooks() +``` + +The import is `import type`, so the `server-only` module is erased and never reaches the browser bundle. + + + + + +```tsx app/page.tsx +"use client" + +import { useUpload } from "@/lib/upload-client" + +export default function Page() { + const { start, upload, accept } = useUpload() + + return ( +
+ start({ file: e.target.files?.[0] })} + /> + + {upload &&

{upload.status}

} + {upload?.pending && } + {upload?.status === "done" && {upload.blob.path}} + {upload?.status === "error" &&

{upload.error.message}

} +
+ ) +} +``` + +`accept` comes from the route's own `GET`, so the file picker is filled from the same list that does the refusing. `status` is one of `queued`, `uploading`, `finishing`, `paused`, `done`, `canceled` or `error`, and `pending` is true for the first four. `upload.error` is a `BlobError` with the `code` your server raised. + +
+ +
+ +A file under 16 MB goes up as one presigned PUT. Anything larger is cut into parts, which is what buys pause, resume and per-part retry. See [Large files](/blob/browser/large-files). + +--- + +## Server uploads + +For bytes that are already on your server, skip the handler and write the object directly. + +```ts lib/reports.ts +import { Bucket } from "@upstash/blob" + +const bucket = Bucket.fromEnv() + +export async function saveReport(pdf: Blob) { + const blob = await bucket.put("reports/2026-01.pdf", pdf, { contentType: "application/pdf" }) + return blob.url +} +``` + +`blob.url` is the public object URL, and `undefined` on a private bucket. The rest of the write API, including metadata, caching and conditional writes, is in [Writing](/blob/bucket/writing). + +--- + +## CORS + +The browser PUTs to storage, not to your app, so the bucket has to allow it. The presigned PUT sends `Content-Type`, `Cache-Control` and the object's `x-amz-meta-*` as real headers, and they are pinned into the signature: allow `PUT` and those headers from your origin in the bucket's CORS configuration. Add `ETag` to the exposed headers too, since a multipart upload reads each part's ETag back from the response. + + +A browser blocks a request that fails CORS before a single byte goes out, and script never sees why. The SDK gives up after three such attempts and says so, rather than backing off for minutes over an answer that will not change. + + +--- + +## Next steps + + + + Routes, context, input schemas and the completion callback in full. + + + + Size limits, content types and what the byte check does and does not prove. + + + + Parts, pause, resume and retry, and where the threshold sits. + + + + `put`, metadata, conditional writes and multipart from the server. + + + + Read and upload links for anything outside the browser flow. + + + + What storage, requests and egress cost. + + diff --git a/blob/overall/signing.mdx b/blob/overall/signing.mdx new file mode 100644 index 00000000..c2fa4a3b --- /dev/null +++ b/blob/overall/signing.mdx @@ -0,0 +1,340 @@ +--- +title: "How Signing Works" +--- + +Your server holds a bucket token. It exchanges that token for short-lived S3 credentials from Upstash, signs individual URLs with those credentials, and hands only the URLs to the browser. The token, the credentials and the bucket password never leave your server. Every URL a browser holds is scoped to one object, one method, one set of headers, and a few minutes. + +This page follows that chain from the token in your environment down to the bytes landing in storage, so you can reason about what a browser holding one of these URLs can and cannot do. If you only want to upload a file, start with the [quickstart](/blob/overall/quickstart) instead. + +--- + +## The bucket token + +`UPSTASH_BLOB_TOKEN` is a base64url string. It is not an opaque handle: the SDK decodes it locally with `decodeToken`, and no network call is involved. + +| Offset | Bytes | Meaning | +| --- | --- | --- | +| 0 | 1 | version, `0x02` | +| 1 | 1 | flags | +| 2 | 1 | length of `bucketId` | +| 3 | 2 | length of `password`, big endian | +| 5 | 1 | length of `hashForDomain` | +| 6 | rest | the three fields, in that order, UTF-8 | + +Decoding is strict. A version byte other than `0x02` is `token: unsupported format`. A zero-length field is `token: malformed`. The total length has to be exactly `6 + idLen + pwLen + hLen`, so a trailing byte is tamper rather than padding and is refused. Surrounding whitespace is trimmed, and anything that is not base64url throws `token is not base64url`. + +The three fields do different jobs. + +| Field | What it is for | +| --- | --- | +| `bucketId` | Names the bucket. It goes into the object URL path and into every completion token, which is what stops a token minted for one bucket from being spent against another. | +| `hashForDomain` | The bucket's public DNS label. Objects are served from `.blob.upstash.io`, so `bucket.publicUrl(path)` is pure string work with no request. On a private bucket it returns `undefined`, because nothing serves that host. | +| `password` | The HMAC key for [completion tokens](#the-completion-token). It is never sent anywhere, not to Upstash and not to storage. It only ever keys a MAC inside your process. | + + +The token is a bearer secret. Anything holding it can mint credentials for the whole bucket. Keep it server side, never in `NEXT_PUBLIC_`, `VITE_`, or any other variable your bundler inlines into client code. + + +--- + +## Minting temporary credentials + +The token is not an S3 credential. To touch storage, the SDK exchanges it: + +```http credential request +POST https://blob.upstash.io/v1/credentials +Authorization: Bearer +``` + +The request carries no body and times out after 10 seconds. Telemetry headers ride along unless you set `UPSTASH_DISABLE_TELEMETRY` or pass `enableTelemetry: false`. + +The response is the whole picture of where this bucket lives: + +| Field | Meaning | +| --- | --- | +| `accessKeyId`, `secretAccessKey`, `sessionToken` | The temporary S3 credential everything else is signed with. | +| `endpoint` | The R2 endpoint every object request goes to. | +| `bucket` | The bucket name inside that endpoint. | +| `region` | The region the SigV4 scope is built from. | +| `expiresAt` | Unix seconds. The hard ceiling on anything signed with this credential. | +| `visibility` | `'public'` or `'private'`. Private drops `url` and `versionedUrl` from every record. | +| `signing` | Optional, and longer lived. A read-only credential used only for presigning reads. | + +Because the `endpoint` decides where every subsequent request goes, the SDK refuses to take it on faith. It must parse as a URL, its protocol must be `https:`, and its hostname must end in `.r2.cloudflarestorage.com`. Anything else is `request_failed` with the message `credentials response named an unexpected endpoint`, before a single byte is signed. + +### Caching + +The credential cache is keyed by the token, not held on the `Bucket` instance. `Bucket.fromEnv()` inside a request handler is the documented shape on every serverless platform, and an instance-held cache would mint a fresh credential for each request. Constructing a bucket per request is free. + +Two constants shape when a re-mint happens: + +- `REFRESH_MARGIN_MS` is 30 seconds. A cached credential is considered usable until 30 seconds before it expires. +- `NO_BETTER_MS` is 30 seconds. The Upstash agent hands back its own cached credential until roughly 60 seconds of life remain on it, so asking for a longer one often returns exactly what you already had. When a re-mint comes back no later than the credential it replaced, the SDK stops asking for 30 seconds. Mints are an account-wide budget, and a loop that refetches on every operation is how you reach the rate limiter. + +Concurrent callers share one in-flight mint rather than racing. + +### When minting fails + +| Response | What you get | +| --- | --- | +| 401 | `unauthorized`, `the bucket token was rejected` | +| 429 or 503 with `Retry-After` under 10s | Waited out and retried, up to 3 retries. A missing or unparseable `Retry-After` becomes 2 seconds. | +| 429 or 503 with `Retry-After` over 10s | `mint_backoff` immediately, carrying `retryAfter`. No request can usefully block that long, so the caller is told to come back. | +| 429 after the retries | `rate_limited` | +| 503 after the retries | `not_ready` | +| anything else | `request_failed` with status 502 | + +See [errors](/blob/bucket/errors) for the full code list. + +--- + +## Signing a request + +Signing is AWS Signature Version 4 over Web Crypto, service `s3`, region taken from the credential. The payload hash is `UNSIGNED-PAYLOAD` everywhere, which is what lets a body stream through without being buffered and hashed first. + +There are two modes, and which one is used tells you who is making the request. + +| Mode | Where the signature lives | Used for | +| --- | --- | --- | +| `signHeaders` | An `Authorization` header, plus `x-amz-date`, `x-amz-content-sha256` and `x-amz-security-token` | Every request your server makes to storage: `put`, `get`, `list`, `del`, `CreateMultipartUpload`, `CompleteMultipartUpload`. | +| `presign` | Query string: `X-Amz-Algorithm`, `X-Amz-Credential`, `X-Amz-Date`, `X-Amz-Expires`, `X-Amz-SignedHeaders`, `X-Amz-Security-Token`, `X-Amz-Signature` | Every URL handed to a browser, and every URL you make with `signedReadUrl()` or `signedUploadUrl()`. | + +### Signed headers + +A presigned URL can pin headers. Each pinned header name and value is folded into the canonical request, lowercased, trimmed, with runs of inner whitespace collapsed, and the sorted list of names is published in `X-Amz-SignedHeaders`. + +The consequence is the important part: the client has to send those headers back byte for byte. Change a value, or omit a header the URL declared, and storage answers 403. That is not a rule the SDK enforces on the client, it is arithmetic inside the signature. A header pinned into a URL is not the client's to choose. + +Query parameters work the same way. `response-content-disposition` on a signed read URL rides inside the signature, so a link whose filename was edited afterwards is refused rather than honoured. + +### Path encoding + +S3 wants every character outside `A-Za-z0-9-_.~` percent-encoded, including the ones `encodeURIComponent` leaves alone, and encoded as uppercase hex UTF-8 bytes. `uriEncode` does that; `encodeKey` applies it per path segment so slashes stay structural. + +`encodeKey` also refuses any path containing a `.` or `..` segment outright, rather than normalising it. The reason is the trust model: a temporary credential authorizes the whole bucket, and the URL parser resolves `..` before the request is signed, so a traversing key would sign a request against a different object than the one your code named. Rejecting is the only safe answer. `uniquePath` guards the same boundary from the other side: slashes in the literal chunks of the template are structure, and slashes inside an interpolated value are stripped along with the rest of the directory component. + +--- + +## How long a presigned URL lives + +A presigned URL cannot outlive the credential that signed it. R2 checks the credential at the start of the request, so a URL with `X-Amz-Expires=3600` on a credential with 200 seconds left stops working in 200 seconds. + +The SDK works with that instead of around it. `R2.presign` signs for `Math.min(expiresIn, credential remaining)`, so the `X-Amz-Expires` in the URL is never a promise the credential cannot keep. + +Being born stale is the other half of the problem. `minRemainingSeconds` is passed down to the credential cache: it means "re-mint if less than this is left", so a URL is signed against a credential that can actually carry it. The direct upload path asks for 3600 seconds with `minRemainingSeconds` of 120, which is why a fresh part URL always has a usable window even when the cached credential was nearly done. + +The defaults and the cap: + +- `DEFAULT_READ_SECONDS` is 300. A read link with no `expiresIn` asks for 5 minutes, or the cap if that is lower. +- `DEFAULT_WRITE_SECONDS` is 3600. A write link with no `expiresIn` asks for an hour, and because writes must use the object credential, `presignWrite` re-mints rather than hand back a link that dies early. +- `capOf(credential)` is the seconds left on whichever credential will actually sign: the read-only `signing` credential when the backend supplied one, otherwise the object credential. +- `worthReminting` decides whether a read that asked for longer than the cap is worth a round trip. It is true only when there is no `signing` credential and the current one has lost more than `WORTH_REMINTING_S`, 30 seconds, of its original lifetime. Below that, a fresh mint would come back with the same expiry, so asking is wasted and the link is simply capped. + +Reads are signed with the `signing` credential when one is present, which is why a read link can outlive the object credential. Writes cannot use it, since it is read-only. + +All of which is why `signedReadUrl()` returns `expiresAt` rather than making you compute it: + +```ts read link +const { url, expiresAt } = await bucket.signedReadUrl('private/report.pdf'); +// expiresAt is the real answer for this link: min(what you asked for, what the signer had left) +``` + +Cache the link until `expiresAt` and re-sign after. Do not assume five minutes. + +--- + +## The direct upload handshake + +A direct browser upload is four phases against your own route. Your route is the only thing that ever sees the token or the credentials. + +```text upload handshake +browser your route blob.upstash.io R2 + | | | | + | phase 'begin' | | | + |------------------> | | + | | POST /v1/credentials| | + | |----------------------> | + | | temp credentials | | + | <----------------------| | + | | | | + | | CreateMultipartUpload (multipart only) | + | |---------------------------------------------> + | | uploadId | + | <---------------------------------------------| + | completion token + presigned URLs | | + <------------------| | | + | | | | + | PUT the bytes: presigned URL + pinned headers | + |----------------------------------------------------------------> + | 200 + etag | | + <----------------------------------------------------------------| + | | | | + | phase 'parts': next URL batch, ListParts | + |------------------> | | + | | | | + | phase 'end': part etags | | + |------------------> | | + | | CompleteMultipartUpload, then HEAD | + | |---------------------------------------------> + | blob record + onUploadComplete data | | + <------------------| | | +``` + +| Phase | What your route does | What it signs | What it returns | +| --- | --- | --- | --- | +| `begin` | Enforces [constraints](/blob/browser/constraints), runs `onBeforeUpload`, and for a large file creates the multipart upload | The first PUT URL, or the first batch of part URLs | `WireBeginResponse`: `completionToken`, `path`, and an upload plan carrying `partSize`, `multipart` and `parts` | +| `parts` | Verifies the completion token, asks R2 `ListParts` for what already landed | The next batch of part URLs, 16 at a time | `WirePartsResponse`: `partSize`, `size`, `multipart`, `parts`, `landed` | +| `end` | Verifies the token, completes the multipart or checks the marker, reads the object back, runs `onUploadComplete` | Nothing new | `WireEndResponse`: the blob record plus whatever `onUploadComplete` returned | +| `cancel` | Verifies the token, aborts the multipart or deletes a matching single-PUT object | Nothing | `{ ok: true }` | + +Said plainly: the browser never sees the bucket token and never sees an S3 credential. It sees per-object presigned URLs, the headers those URLs pin, and a completion token. Nothing it holds can list the bucket, read another object, or write to a path your `onBeforeUpload` did not choose. + +`GET` on the same route serves the constraints document, with an ETag and `max-age=60`, so a file picker can be filled from the same list that does the refusing. See [upload handler](/blob/browser/upload-handler) for the callbacks and [large files](/blob/browser/large-files) for the multipart path. + +--- + +## The completion token + +The completion token is what carries an upload's identity between phases without keeping server state. It is a base64url JSON payload and an HMAC-SHA256 over that payload, joined by a dot: + +```text shape +. +``` + +The key is `upstash-blob-completion:`, so it is derived from the token you already hold and never from anything the request supplies. Comparison is timing safe. + + +The token is signed, not encrypted. Anyone can open devtools, base64-decode the first half, and read the whole payload including `ctx`. Whatever `onBeforeUpload` returns as `state` must be a row id or something equally boring. Never a secret, never a signed URL, never an internal flag you would not print on the page. + + +The payload: + +| Field | What it locks down | +| --- | --- | +| `v` | Payload version. Anything but `1` is refused even with a valid MAC. | +| `b` | Bucket id, checked against this bucket. | +| `r` | Route id, checked against this route. | +| `id` | The upload id. It is the idempotency key `onUploadComplete` receives as `uploadId`, and on a single PUT it is also the marker value. | +| `path` | The object key `onBeforeUpload` chose. The browser cannot move an upload to another path by asking. | +| `n` | The file name the browser gave, which is the one thing the stored object does not keep. | +| `type` | The declared content type. | +| `size` | The declared byte length. `end` compares it against the stored object and refuses a mismatch. | +| `headers` | The headers pinned into the signature, so a re-presign at `parts` reproduces the same ones. | +| `ctx` | Whatever `onBeforeUpload` returned as `state`. | +| `exp` | Unix ms. Seven days out. | +| `uploadId` | R2's own multipart id, so `end` can complete it and `cancel` can abort it. Absent on a single PUT, where there is nothing to complete. | +| `partSize` | The part size for a multipart, or the whole file size for a single PUT, so one part covers it. | + +Verification is three checks past the MAC: the bucket id must match, the route id must match, and `exp` must be in the future. A failure of any of them is `forbidden`, not a 500. A token minted at one route is not spendable at another. + +### Route ids + +The route id comes from `deriveRouteId`, an FNV-1a hash over the route name, its resolved constraints (`contentTypes` and `maxBytes`), and whether the route takes `input`. When a handler declares an `endpoint`, the name is prefixed with it, which is what separates two handlers that mount the same route names on one bucket. All routes on a bucket sign with the same key, so without this a completion token from a 2 MB avatar route would be spendable at a 2 GB video route. + + +FNV-1a is not the security boundary here. It is a short, stable label for "which route is this". The MAC is what makes the payload unforgeable, and it covers the route id like every other field. Changing a route's constraints changes its id, which invalidates completion tokens issued under the old shape. That is intentional: the grant no longer describes what the route enforces. + + +--- + +## Pinned headers, and why the browser cannot forge metadata + +For a file under the multipart threshold, the browser writes the object itself with a single PUT. Everything the object should carry has to be decided on your server and pinned into that URL's signature: + +- `content-type`, from the file the browser declared +- `cache-control`, resolved from the route's or bucket's [cache option](/blob/bucket/caching) and the bucket visibility +- every `x-amz-meta-*` derived from the `metadata` your `onBeforeUpload` returned +- `content-length`, the exact declared size +- `x-amz-meta-upstash-upload`, the marker + +Signed, not merely sent. An unsigned header would be the browser's to choose, and then metadata your app reads back in `onUploadComplete` would be the client's to write. Because they are signed, the browser must echo them exactly and cannot substitute an `owner` that is not theirs. + +For a multipart upload the same headers are pinned earlier and elsewhere: they are sent with `CreateMultipartUpload`, signed by your server with an `Authorization` header, and the object inherits them at completion. Each part URL then signs only `content-length`. Part URLs carry no headers at all in the wire response, which is why the browser sets none of ours on a part PUT. + + +Because a single PUT sends `Content-Type`, `Cache-Control` and `x-amz-meta-*` as real headers on a cross-origin request, the bucket's CORS configuration has to allow those request headers from your origin, allow `PUT`, and expose `ETag` on the response so the client can read it. A PUT that fails with no status and no bytes on the wire is almost always this: the preflight failed, and the reason is never visible to script. + + +--- + +## The `upstash-upload` marker + +On a single PUT, `begin` mints a UUID, writes it as `x-amz-meta-upstash-upload`, and signs it into the URL. The browser cannot set it, cannot change it, and `metadata.upstash-upload` from your own `onBeforeUpload` is refused as reserved. + +It answers exactly one question: did the bytes at this path come from THIS upload? A multipart upload answers that by construction, because the object does not exist until `end` completes it, so an object that exists is one this token created. A single PUT has no such guarantee. The presigned PUT stores the object the moment the last byte lands, so by the time `end` runs, the object at that path could be a stale token's, a concurrent upload's, or something that was there all along. + +So `end` requires a marker match on the single-PUT path. No match is `not_found`, `the upload never landed`. `cancel` uses the same check, and it is the whole check there, because the request body says nothing about which object to [delete](/blob/bucket/deleting). That is what stops a cancel from deleting someone else's file at the same path. + +The marker is deleted from the record handed to `onUploadComplete` and `onError`, but not from the stored object. Nothing on the completion path rewrites metadata. + + +A marker match proves "same upload". It never proves "no callback accepted it". An object carrying the marker may be an abandoned upload or a perfectly finished one, the SDK cannot tell them apart, and both are [billed](/blob/overall/pricing) the same. Track that in your own rows: pending in `onBeforeUpload`, ready last in `onUploadComplete`, sweep the rest. See [abandoned uploads](/blob/browser/abandoned-uploads) for what that costs and the cron that fixes it. + + +--- + +## Retries and 403 + +A 403 from storage is ambiguous by design. An expired presigned URL and a tampered request produce the same status, and the browser cannot tell which it is looking at. So the client's `classify` treats 401 and 403 as `represign` rather than `fail`: + +| Status | Verdict | +| --- | --- | +| 0 (network), 408, 429, 500, 502, 503, 504 | `retry` with jittered backoff | +| 401, 403 | `represign`: throw the batch of URLs away and ask the route for fresh ones | +| anything else | `fail` | + +Re-presigning forever would hide a real signature problem, so there is a clock on it. `PRESIGN_STALE_MS` is 60 seconds. A 403 on a URL minted more than a minute ago is read as the clock however often it happens, because a 5 MiB part on a slow link genuinely outruns a presign more than once. A 403 on a freshly minted URL, for a part that has already been re-presigned once, is a real `signature_mismatch` and ends the upload. So does exhausting the 8-attempt budget. + +Two more bounds sit around that loop. `MAX_URL_BATCHES` is 4: a part that waits through four batches without the route ever signing it fails rather than spinning with no backoff. And a re-presign always throws away the whole batch, not just the one URL, because every URL in a batch was signed against the same credential and expires with it. + +Your server has the same ambiguity and resolves it by reading the body. `R2.fetch` re-mints once, and only once per request, when a 403 body matches `ExpiredToken`, `InvalidAccessKeyId` or `TokenRefreshRequired`, or when the cached credential has visibly expired (a `HEAD` carries no body to name a reason). Any other 403 is returned as-is and surfaces as `signature_mismatch`, usually meaning the body length or type differs from what was signed. + +--- + +## What the browser stores + +One thing, in `localStorage`, under a key built from the route, the file name, the file size and its `lastModified`: + +```text localStorage +key upstash-blob:v1:/api/upload|holiday.png|4211|1756732800000 +value {"completionToken":"eyJ2IjoxLCJiIjoiYWM2M..."} +``` + +That is the entire record. It is a bearer capability for one upload and nothing else is worth the exposure. Notably it does not record what landed: picking the same file again sends phase `parts`, and the server asks R2 `ListParts` for the truth. A single PUT has nothing to resume, so the same file goes up again under the same token and the same path. Writing the record is best effort, since quota limits and private browsing modes both make `localStorage` throw, and a failed write just means no resume. + +--- + +## Signed URLs you make yourself + +The same machinery is available directly, for a CLI, a server-to-server job, or a link in an email. + +```ts signed links +const { url, expiresAt } = await bucket.signedReadUrl('private/report.pdf', { + downloadAs: 'Q3 Report.pdf', + expiresIn: '15m', +}); + +const upload = await bucket.signedUploadUrl('u/7/report.pdf', { + contentType: 'application/pdf', + size: bytes.byteLength, +}); +await fetch(upload.url, { method: 'PUT', headers: upload.headers, body: bytes }); +``` + +`signedReadUrl` puts `downloadAs` into `response-content-disposition` as an RFC 6266 header, carrying the real name in `filename*` as an RFC 8187 ext-value, with an ASCII fallback cut back to characters that cannot end the quoted string. The name reaches storage as a query parameter and comes back as a header value, so a quote or a CRLF in it must not be able to add a header. A `contentType` override is validated as a media type for the same reason. + +`signedUploadUrl` pins every header it returns into the signature: `content-type`, `cache-control`, your `x-amz-meta-*`, `content-length` when you pass `size`, and `if-none-match: *` when you pass `overwrite: false`. Send the `headers` object verbatim. Anything changed, dropped or added is a 403, not a header the client got to choose. + +For an existing S3 client, `bucket.s3()` hands back the endpoint and the credentials as async providers, so the aws-sdk re-reads the short-lived credential when it expires rather than holding a snapshot that dies mid-session. + +Full options are on [reading](/blob/bucket/reading) and [writing](/blob/bucket/writing). + +--- + +## What never reaches the browser + +- `UPSTASH_BLOB_TOKEN`, in any form. +- The bucket password. It only ever keys an HMAC inside your process. +- `accessKeyId`, `secretAccessKey` or `sessionToken`. They are only ever folded into a signature. +- Any ability to list, read, overwrite or delete outside the one object a single presigned URL names. +- Anything `context` or `onBeforeUpload` computed, except what you explicitly return as `metadata` (visible on the object) or `state` (visible in the completion token). diff --git a/docs.json b/docs.json index 01b5be37..545469ca 100644 --- a/docs.json +++ b/docs.json @@ -2091,6 +2091,44 @@ } ] }, + { + "tab": "Blob", + "groups": [ + { + "group": "Introduction", + "pages": [ + "blob/overall/quickstart", + "blob/overall/pricing", + "blob/overall/signing" + ] + }, + { + "group": "Browser Usage", + "pages": [ + "blob/browser/upload-handler", + "blob/browser/constraints", + "blob/browser/large-files", + "blob/browser/abandoned-uploads" + ] + }, + { + "group": "Bucket", + "pages": [ + "blob/bucket/writing", + "blob/bucket/reading", + "blob/bucket/deleting", + "blob/bucket/caching", + "blob/bucket/errors" + ] + }, + { + "group": "Formulas", + "pages": [ + "blob/formulas/overview" + ] + } + ] + }, { "tab": "Realtime", "groups": [ From 90df0d5713d1a359aa33c133d26bf239c8a2082a Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Tue, 1 Sep 2026 21:05:57 +0200 Subject: [PATCH 02/41] docs(blob): apply review fixes --- blob/browser/abandoned-uploads.mdx | 132 +++++++++------------- blob/browser/constraints.mdx | 55 ++++----- blob/browser/large-files.mdx | 2 +- blob/browser/upload-handler.mdx | 83 ++++++-------- blob/bucket/caching.mdx | 75 ++++++------- blob/bucket/deleting.mdx | 149 +++++++++++-------------- blob/bucket/errors.mdx | 128 +++++++++------------ blob/bucket/reading.mdx | 132 ++++++++++------------ blob/bucket/writing.mdx | 172 ++++++++++++++--------------- blob/formulas/overview.mdx | 34 +++--- blob/overall/quickstart.mdx | 78 ++++++++----- blob/overall/signing.mdx | 63 ++++------- 12 files changed, 487 insertions(+), 616 deletions(-) diff --git a/blob/browser/abandoned-uploads.mdx b/blob/browser/abandoned-uploads.mdx index d32aa3e5..4bd2a818 100644 --- a/blob/browser/abandoned-uploads.mdx +++ b/blob/browser/abandoned-uploads.mdx @@ -12,22 +12,19 @@ See [Upload handler](/blob/browser/upload-handler) for the callbacks, and [Large ## The two kinds -The default threshold is 16 MB. Under it a file goes up as one presigned PUT; over it the file is cut into parts. The two halves fail differently. +The default threshold is 16 MB. [Large files](/blob/browser/large-files#what-changes-at-the-line) compares the two transports in full; these are the rows that decide who cleans up. | | Under the threshold, one PUT | Over the threshold, multipart | | --- | --- | --- | -| What is left behind | a whole stored object | the parts that landed, and nothing else | -| Visible to `list()` | yes, an ordinary object | no | -| Billed | yes | yes | -| Blocks deleting the bucket | no | yes | -| Readable on a public bucket | yes, from the moment the last byte landed | no, no object exists yet | +| When the object exists | the moment the last byte lands | when phase `end` completes the upload | +| What a dead tab leaves | a whole stored object | the parts that landed, and nothing else | | Who cleans it up | you | `bucket.abortStaleMultipartUploads()` | **Over the threshold**, the object does not exist until phase `end` completes the multipart upload. A browser that dies mid-upload leaves an incomplete multipart upload: parts that are billed, that `list()` cannot see, and that stop the bucket from being deleted while they exist. The SDK can sweep those, and the last section shows the cron. **Under the threshold**, the presigned PUT is the object write. Storage has the whole object the moment the last byte lands, before your route has been told anything. The completion request that runs `onUploadComplete` is a separate call, made by the browser, after the PUT. A browser that dies between the two leaves an ordinary object: `list()`-visible, billed, already served by the public host if the bucket is public, and accepted by no callback of yours. -That is the price the threshold bought. One round trip instead of three for the files most apps upload most often, and this window in exchange. +That is what one round trip instead of three costs for the files most apps upload most often. --- @@ -38,7 +35,7 @@ Every single-PUT upload carries a marker. The SDK mints a random id at phase `be At phase `end` the route reads the object back and compares: ```ts -if (head.metadata[UPLOAD_MARKER] !== t.id) throw new BlobError('not_found', { message: 'the upload never landed' }); +if (head.metadata[UPLOAD_MARKER] !== t.id) throw new BlobError("not_found", { message: "the upload never landed" }) ``` That answers the one question a multipart upload answers by construction: are the bytes at this path the ones **this** upload put there? It is what lets a refusal in `onUploadComplete` delete only what this upload wrote, instead of deleting whatever happens to be standing at the path. @@ -47,7 +44,7 @@ What it does not answer is whether anything ever accepted the object. The marker So the SDK, looking at a bucket, cannot separate an abandoned object from a finished one. Only your own rows can. -See [Signed URLs](/blob/overall/signing) for how a header gets pinned into a signature. +See [How signing works](/blob/overall/signing) for how a header gets pinned into a signature. --- @@ -72,76 +69,76 @@ The order in step 2 is the whole pattern. Because the marker survives on accepte ### The route ```ts lib/uploads.ts -import 'server-only'; -import { BlobError, uniquePath, uploadHandler, uploadRoute } from '@upstash/blob'; -import { sql } from '@/lib/db'; +import "server-only" +import { BlobError, uniquePath, uploadHandler, uploadRoute } from "@upstash/blob" +import { sql } from "@/lib/db" const attachment = uploadRoute()({ - constraints: { maxBytes: '20mb', contentTypes: ['image/*', 'application/pdf'] }, + constraints: { maxBytes: "20mb", contentTypes: ["image/*", "application/pdf"] }, onBeforeUpload: async ({ request, file }) => { - const user = await getUser(request); - if (!user) throw new BlobError('unauthorized'); + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") - const rowId = crypto.randomUUID(); - const path = uniquePath`uploads/${user.id}/${file.name}`; + const rowId = crypto.randomUUID() + const path = uniquePath`uploads/${user.id}/${file.name}` await sql`insert into uploads (id, owner, path, status, created_at) - values (${rowId}, ${user.id}, ${path}, 'pending', now())`; + values (${rowId}, ${user.id}, ${path}, 'pending', now())` // metadata is written onto the object and signed into the PUT, so the cron can read it back. // state only crosses in the completion token, so the callback can read it without a lookup. - return { path, metadata: { rowid: rowId }, state: { rowId } }; + return { path, metadata: { rowid: rowId }, state: { rowId } } }, onUploadComplete: async ({ path, size, contentType, url, state }) => { - await indexForSearch(path, contentType); - await notifyOwner(state.rowId); + await indexForSearch(path, contentType) + await notifyOwner(state.rowId) // Last. Everything above has to be done before the row stops looking abandoned. await sql`update uploads set status = 'ready', size = ${size}, url = ${url ?? null} - where id = ${state.rowId}`; + where id = ${state.rowId}` - return { rowId: state.rowId }; + return { rowId: state.rowId } }, -}); +}) -export const uploads = uploadHandler({ routes: { attachment } }); +export const uploads = uploadHandler({ routes: { attachment } }) ``` -Metadata keys come back from storage lowercased, so `{ rowid: ... }` is written the way it will be read. Values are printable ASCII; anything else is refused with `invalid_input`. +Metadata keys come back from storage lowercased, so `{ rowid: ... }` is written the way it will be read. The rest of the [metadata rules](/blob/bucket/writing#metadata) apply here too. ### The cron ```ts app/api/cron/sweep-uploads/route.ts -import { BlobError, Bucket } from '@upstash/blob'; -import { sql } from '@/lib/db'; +import { BlobError, Bucket } from "@upstash/blob" +import { sql } from "@/lib/db" -const bucket = Bucket.fromEnv(); +const bucket = Bucket.fromEnv() export const GET = async () => { const rows = await sql`select id, path from uploads where status = 'pending' and created_at < now() - interval '2 hours' - limit 500`; + limit 500` - let deleted = 0; + let deleted = 0 for (const row of rows) { try { - const info = await bucket.info(row.path); + const info = await bucket.info(row.path) // info() returns metadata unstripped, so this says the object standing at the path is the // one this row reserved, and not a later upload's that happens to share it. - if (info.metadata.rowid !== row.id) continue; - await bucket.del(row.path); - deleted++; + if (info.metadata.rowid !== row.id) continue + await bucket.del(row.path) + deleted++ } catch (e) { // Nothing was ever stored: the browser died before the PUT finished. The row is the leftover. - if (!(BlobError.is(e) && e.code === 'not_found')) throw e; + if (!(BlobError.is(e) && e.code === "not_found")) throw e } - await sql`delete from uploads where id = ${row.id}`; + await sql`delete from uploads where id = ${row.id}` } - return Response.json({ swept: rows.length, deleted }); -}; + return Response.json({ swept: rows.length, deleted }) +} ``` ```json vercel.json @@ -168,12 +165,12 @@ Two constraints come with this example. An app that will not take a database write on the upload path can list the prefix instead and diff it against whatever rows it does have: ```ts -const page = await bucket.list({ prefix: 'uploads/', limit: 1000 }); -const known = new Set(await recordedPaths()); -const cutoff = Date.now() - 2 * 60 * 60 * 1000; +const page = await bucket.list({ prefix: "uploads/", limit: 1000 }) +const known = new Set(await recordedPaths()) +const cutoff = Date.now() - 2 * 60 * 60 * 1000 -const orphans = page.blobs.filter((b) => !known.has(b.path) && b.uploadedAt.getTime() < cutoff); -if (orphans.length) await bucket.del(orphans.map((b) => b.path)); +const orphans = page.blobs.filter((b) => !known.has(b.path) && b.uploadedAt.getTime() < cutoff) +if (orphans.length) await bucket.del(orphans.map((b) => b.path)) ``` This is strictly weaker. `list()` returns `BlobObject`, which carries the path, size, etag and `uploadedAt` and **no metadata**, so all it can compare is keys. It cannot tell which upload wrote the object, it scans the bucket instead of an index, and it pages through every object under the prefix to find the few that do not belong. Use it when a pending row is genuinely not on the table. @@ -185,40 +182,23 @@ This is strictly weaker. `list()` returns `BlobObject`, which carries the path, Over the threshold, the SDK does this half for you. Put it on a cron: ```ts app/api/cron/abort-stale-uploads/route.ts -import { Bucket } from '@upstash/blob'; +import { Bucket } from "@upstash/blob" -const bucket = Bucket.fromEnv(); +const bucket = Bucket.fromEnv() export const GET = async () => { - const aborted = await bucket.abortStaleMultipartUploads({ olderThan: '1d', prefix: 'uploads/' }); - return Response.json({ aborted: aborted.length, paths: aborted.map((u) => u.path) }); -}; + const aborted = await bucket.abortStaleMultipartUploads({ olderThan: "1d", prefix: "uploads/" }) + return Response.json({ aborted: aborted.length, paths: aborted.map((u) => u.path) }) +} ``` ```json vercel.json { "crons": [{ "path": "/api/cron/abort-stale-uploads", "schedule": "0 4 * * *" }] } ``` -`abortStaleMultipartUploads` lists the bucket's incomplete uploads, keeps the ones started longer ago than `olderThan`, aborts each one along with every part that landed for it, and **returns what it aborted**. That return is the log line: an empty array means there was nothing to reap. - -The two halves are also available on their own: - -```ts -const uploads = await bucket.listMultipartUploads({ prefix: 'uploads/' }); -// [{ path, uploadId, initiatedAt }] - -for (const upload of uploads) { - if (isStale(upload.initiatedAt)) await bucket.abortMultipartUpload(upload); -} -``` - -`abortMultipartUpload` takes the record `listMultipartUploads()` returned rather than two strings, and that is deliberate. Aborting something that is not there is success as far as storage is concerned, so a swapped `(path, uploadId)` pair would abort nothing and report that it worked. Passing the record back is the shape that cannot be swapped. +`abortStaleMultipartUploads` lists the bucket's incomplete uploads, keeps the ones started longer ago than `olderThan`, aborts each one along with every part that landed for it, and returns what it aborted, so an empty array is the log line saying there was nothing to reap. Pick an `olderThan` comfortably longer than your slowest upload, for the same reason as the pending row's grace window. -`olderThan` is a duration: `'1d'`, `'2h'`, `'30m'`, or a bare number of seconds. Pick one comfortably longer than your slowest upload, for the same reason as the pending row's grace window. - -The one sentence version: **over the threshold the SDK sweeps it, under the threshold you do.** - -See [Deleting](/blob/bucket/deleting) for `del` and the rest of the removal API. +[Deleting](/blob/bucket/deleting#incomplete-multipart-uploads) has the rest: the two halves on their own, and what each field of an upload record means. **Over the threshold the SDK sweeps it. Under the threshold you do.** --- @@ -231,16 +211,14 @@ export const uploads = uploadHandler({ multipart: true, onBeforeUpload: ({ file }) => ({ path: uniquePath`uploads/${file.name}` }), onUploadComplete: async ({ path }) => recordFile(path), -}); +}) ``` Now nothing is stored until your handler completes the upload at phase `end`. A closed tab leaves an incomplete multipart upload, which `abortStaleMultipartUploads()` reaps, and the single-PUT orphan class disappears. Parts also buy pause, resume and per-chunk retry for files that would not have had them. -Be honest about what is left. If phase `end` is retried after the object already completed, `completeMultipart` throws `NoSuchUpload`, the route confirms the object landed and carries on, but `completedEtag` stays undefined. A refusal from `onUploadComplete` at that point deliberately leaves the object stored rather than delete one it cannot identify, and logs that it did. That leftover is a completed object, so `abortStaleMultipartUploads()` cannot reap it either. - -The cost is not extra browser requests. The browser makes `begin`, the PUT, `end` in both modes. It is two extra server to storage round trips, `createMultipart` inside `begin` and `completeMultipart` inside `end`, landing as latency on those two calls. +One case survives it. If phase `end` is retried after the object already completed, `completeMultipart` throws `NoSuchUpload`, the route confirms the object landed and carries on, but `completedEtag` stays undefined. A refusal from `onUploadComplete` at that point deliberately leaves the object stored rather than delete one it cannot identify, and logs that it did. That leftover is a completed object, so `abortStaleMultipartUploads()` cannot reap it either. -For an app that will not run a cron, this is one option value that removes the common case. +The cost is two extra server-to-storage round trips rather than extra browser requests. For an app that will not run a cron, this is one option value that removes the common case. --- @@ -254,9 +232,9 @@ For an app that will not run a cron, this is one option value that removes the c `the upload never landed`. A database blip costs the upload and then reports it as a phantom. -Catch your own storage errors and decide deliberately instead of letting a driver error escape the callback. A retryable `BlobError` is not an escape either: any throw deletes the object first, so the retry it asks for arrives at an empty path. Retry the write in place, hand it to a queue, or simply leave the row pending and let the sweep decide later. Throw out of `onUploadComplete` only when you mean to refuse the file, because that throw is what deletes it. +A retryable `BlobError` is not an escape either: any throw deletes the object first, so the retry it asks for arrives at an empty path. Retry the write in place, hand it to a queue, or simply leave the row pending and let the sweep decide later. Throw out of `onUploadComplete` only when you mean to refuse the file, because that throw is what deletes it. [Upload handler](/blob/browser/upload-handler#onuploadcomplete) has the callback in full. -On a public bucket the delete is also less than it looks. The object has been readable since it was stored, through the whole of your callback, so deleting bounds the exposure to those few round trips rather than undoing it, and an edge that cached the object inside the window keeps serving it for its `Cache-Control`. That is why the type check runs at `begin`, where refusing costs nothing. +On a public bucket the delete is also less than it looks. The object has been readable since it was stored, through the whole of your callback, so deleting bounds the exposure to those few round trips rather than undoing it, and an edge that cached the object inside the window keeps serving it for its `Cache-Control`. --- @@ -271,10 +249,10 @@ On a public bucket the delete is also less than it looks. The object has been re The marker is what stops a refusal from deleting somebody else's file. It does not stop a lost update, and it does not stop the spurious 404 the losing upload gets. -`uniquePath` is the fix. It is a tagged template whose trust boundary is the interpolation: slashes in the literal chunks are structure, slashes and directory components inside `${}` are stripped, and the basename gets eight random base58 characters before its extension. +`uniquePath` is the fix: a tagged template that sanitizes every interpolated value and appends a random suffix to the basename, so two uploads of the same filename never collide. Its rules are in [Writing](/blob/bucket/writing#uniquepath). ```ts -uniquePath`uploads/${user.id}/${file.name}`; +uniquePath`uploads/${user.id}/${file.name}` // uploads/u128/holiday-pic-k9cECNWP.png ``` diff --git a/blob/browser/constraints.mdx b/blob/browser/constraints.mdx index 0d0d570b..4e358169 100644 --- a/blob/browser/constraints.mdx +++ b/blob/browser/constraints.mdx @@ -13,12 +13,12 @@ See [Upload handler](/blob/browser/upload-handler) for the handler shape, its ca `constraints` takes `maxBytes`, `contentTypes`, or both. Write it on the handler, on a route, or on both. ```ts lib/uploads.ts -import { uploadHandler, uniquePath } from '@upstash/blob'; +import { uploadHandler, uniquePath } from "@upstash/blob" export const uploads = uploadHandler({ - constraints: { maxBytes: '20mb', contentTypes: ['image/*', 'application/pdf'] }, + constraints: { maxBytes: "20mb", contentTypes: ["image/*", "application/pdf"] }, onBeforeUpload: ({ file }) => ({ path: uniquePath`uploads/${file.name}` }), -}); +}) ``` Both are enforced at phase `begin`, from the name, type and size the browser declared, before anything is signed and before `onBeforeUpload` runs. Nothing has been written down when a file is refused: no presigned URL exists, no row was inserted, no multipart upload was created. @@ -34,7 +34,7 @@ Omitting `constraints` entirely accepts any type at any size. Sizes are **decimal**, matching how storage is billed. `'2mb'` is 2,000,000 bytes, not 2,097,152. The units are `b`, `kb`, `mb`, `gb` and `tb`, and binary spellings are not part of the vocabulary: `'5mib'` throws. The only binary math in the SDK is multipart part sizing, because R2's part floor is 5 MiB. ```ts -constraints: { maxBytes: '2mb' } // 2,000,000 +constraints: { maxBytes: "2mb" } // 2,000,000 constraints: { maxBytes: 4096 } // a bare number is bytes ``` @@ -125,11 +125,11 @@ Some formats are deliberately left unnamed by the sniffer, because their signatu | MPEG audio | frame sync varies by version and layer | | tar | its marker sits at offset 257, behind an attacker-controlled filename | - + This is ergonomics, not a control. The part bodies never reach your server, so a client is free to send an honest head and then upload something else entirely. It is not malware scanning, and it is not a substitute for treating stored objects as untrusted. - + --- @@ -139,21 +139,21 @@ A route's `constraints` **replace** the handler's key by key. A key the route do ```ts lib/uploads.ts export const uploads = uploadHandler({ - constraints: { maxBytes: '20mb', contentTypes: ['image/png'] }, + constraints: { maxBytes: "20mb", contentTypes: ["image/png"] }, routes: { attachment: { - onBeforeUpload: () => ({ path: 'attachment/1.png' }), + onBeforeUpload: () => ({ path: "attachment/1.png" }), }, avatar: { - constraints: { maxBytes: '2mb' }, - onBeforeUpload: () => ({ path: 'avatar/demo' }), + constraints: { maxBytes: "2mb" }, + onBeforeUpload: () => ({ path: "avatar/demo" }), }, large: { - constraints: { maxBytes: '2gb', contentTypes: null }, - onBeforeUpload: () => ({ path: 'large/1.bin' }), + constraints: { maxBytes: "2gb", contentTypes: null }, + onBeforeUpload: () => ({ path: "large/1.bin" }), }, }, -}); +}) ``` | Route | `maxBytes` | `contentTypes` | @@ -170,16 +170,16 @@ export const uploads = uploadHandler({ ```ts lib/uploads.ts export const uploads = uploadHandler({ - constraints: { maxBytes: '1gb', contentTypes: ['image/*', 'video/*'] }, + constraints: { maxBytes: "1gb", contentTypes: ["image/*", "video/*"] }, onBeforeUpload: async ({ request, file }) => { - const user = await getUser(request); + const user = await getUser(request) return { path: uniquePath`${user.id}/${file.name}`, - constraints: user.plan === 'free' ? { maxBytes: '25mb', contentTypes: ['image/*'] } : undefined, - }; + constraints: user.plan === "free" ? { maxBytes: "25mb", contentTypes: ["image/*"] } : undefined, + } }, -}); +}) ``` The narrowed constraints are checked against the same file, with the same head bytes, right after `onBeforeUpload` returns. @@ -201,12 +201,12 @@ It carries an ETag and `Cache-Control: public, max-age=60`, and the hook caches `useUpload` exposes two things from it. `accept` is `contentTypes` joined with commas, ready for an ``, and empty until the GET lands or when the route serves no type list. `constraints` is the served document itself, so a page can state the cap it enforces. ```tsx components/upload-button.tsx -'use client'; -import { formatBytes } from '@upstash/blob/react'; -import { useUpload } from '@/lib/upload-hooks'; +"use client" +import { formatBytes } from "@upstash/blob/react" +import { useUpload } from "@/lib/upload-hooks" export function UploadButton() { - const { start, upload, accept, constraints } = useUpload(); + const { start, upload, accept, constraints } = useUpload() return ( <> @@ -214,7 +214,7 @@ export function UploadButton() { {constraints?.maxBytes !== undefined &&

Up to {formatBytes(constraints.maxBytes)}

} {upload?.error &&

{upload.error.message}

} - ); + ) } ``` @@ -228,14 +228,7 @@ A route that serves no `contentTypes` has nothing to check leading bytes against ## Error codes -| Code | Status | When | -| ---- | ------ | ---- | -| `too_large` | 413 | The file is over `maxBytes`, from the browser or from `begin` | -| `content_type_not_allowed` | 400 | The declared type is not in the list, or the bytes contradict it | -| `invalid_content_type_pattern` | 500 | `contentTypes` is not a valid list: a bad wildcard, a malformed type, or empty | -| `empty_body` | 400 | A zero-byte file, refused at `begin` | - -Every refusal reaches the browser as a `BlobError` with its code intact, so switch on `error.code` rather than on status numbers. See [Errors](/blob/bucket/errors) for the full list. +A refusal here is `too_large`, `content_type_not_allowed`, `invalid_content_type_pattern` or `empty_body`, and it reaches the browser as a `BlobError` with that code intact, so switch on `error.code` rather than on status numbers. See [Errors](/blob/bucket/errors) for what each one means. --- diff --git a/blob/browser/large-files.mdx b/blob/browser/large-files.mdx index 2a2177f6..62f27d1b 100644 --- a/blob/browser/large-files.mdx +++ b/blob/browser/large-files.mdx @@ -260,7 +260,7 @@ The CORS case is the one worth knowing about. A browser blocks a request that fa The stall watchdog measures silence rather than total time. `xhr.timeout` is a deadline for the whole request, which a large part on a slow link outruns honestly. Sixty seconds with no upload progress event and no response is the failure, and it covers the wait for the response too, so a connection that dies after the last byte does not hang until the tab closes. -Calls to your own route follow a smaller policy: `end` and `parts` are retried up to three times on a network failure or a retryable status, and `begin` is never retried, because it runs `onBeforeUpload`. +Calls to your own route follow a smaller policy: `end` and `parts` get three attempts, so two retries, on a network failure or one of the retryable statuses above. `begin` is never retried, because it runs `onBeforeUpload`. When the budget runs out the record settles as `error`, carrying a `BlobError` with the code the failure earned. The codes are listed in [Errors](/blob/bucket/errors). diff --git a/blob/browser/upload-handler.mdx b/blob/browser/upload-handler.mdx index e3f1c275..f73119d7 100644 --- a/blob/browser/upload-handler.mdx +++ b/blob/browser/upload-handler.mdx @@ -4,7 +4,7 @@ title: "Upload Handler" `uploadHandler` is one upload endpoint. The bytes go straight from the browser to storage: your server only authorizes the upload, signs it, and records what landed. The file never passes through your app, so nothing is bound by your platform's request body limit and nothing streams through your function's memory. -Three requests reach your route per upload. `begin` runs your authorization and hands the browser presigned URLs, the browser PUTs the bytes to storage, and `end` records the object and runs your completion callback. +Two requests reach your route for an ordinary upload. `begin` runs your authorization and hands the browser presigned URLs, and `end` records the object and runs your completion callback. The PUTs in between go to storage, not to you. A third phase, `parts`, is only asked for when an upload needs more than the first 16 part URLs, or when it resumes after a reload. --- @@ -15,6 +15,8 @@ Four files. The handler, the route it is mounted at, the bound hooks, and the co ```ts lib/uploads.ts import "server-only" import { BlobError, uniquePath, uploadHandler } from "@upstash/blob" +import { getUser } from "./auth" +import { sql } from "./db" export const uploads = uploadHandler({ constraints: { maxBytes: "20mb", contentTypes: ["image/*", "application/pdf"] }, @@ -71,8 +73,8 @@ export function Uploader() { The client assumes the handler is mounted at `/api/upload`. That is the only default; `endpoint` on `uploadHooks` or on `useUpload` moves it. - New to Upstash Blob? Start at the [quickstart](/blob/overall/quickstart). If the bytes have to pass - through your app instead, write an ordinary route that calls `bucket.put` ([writing](/blob/bucket/writing)) + New to Upstash Blob? Start at the [Quickstart](/blob/overall/quickstart). If the bytes have to pass + through your app instead, write an ordinary route that calls `bucket.put` ([Writing](/blob/bucket/writing)) and drive it with [`useServerUpload`](#useserverupload). @@ -108,7 +110,7 @@ export const uploads = uploadHandler({ | `onError` | `(args) => BlobError \| Response \| void` | Sees every refusal. The one place to log. | | `routes` | `Record` | Mounts several routes at this one endpoint. | -Everything except `routes`, `endpoint` and `context` is a **default**. A route replaces the ones it names and inherits the rest, key by key, so a handler with five routes states the shared policy once. `constraints` merges one level deeper: a route's `constraints` replaces `maxBytes` and `contentTypes` individually, and `null` clears a key the handler set. See [constraints](/blob/browser/constraints) for the grammar and what a wildcard expands to. +Everything except `routes`, `endpoint` and `context` is a **default**. A route replaces the ones it names and inherits the rest, key by key, so a handler with five routes states the shared policy once. `constraints` merges one level deeper: a route's `constraints` replaces `maxBytes` and `contentTypes` individually, and `null` clears a key the handler set. See [Constraints](/blob/browser/constraints) for the grammar and what a wildcard expands to. `onBeforeUpload` is the one callback that has to exist. A route with none of its own, mounted in a handler with none either, is a build error naming the route. @@ -155,12 +157,12 @@ What it returns: | Field | Type | | | --- | --- | --- | | `path` | `string` | Required. Where the object is stored. | -| `cache` | `CacheOption` | The `Cache-Control` this object is stored with, over the bucket default. See [caching](/blob/bucket/caching). | +| `cache` | `CacheOption` | The `Cache-Control` this object is stored with, over the bucket default. See [Caching](/blob/bucket/caching). | | `metadata` | `Record` | Signed into the upload and handed back to `onUploadComplete`. | | `constraints` | `{ contentTypes?, maxBytes? }` | Narrows this one upload's limits. | -| `state` | `TState` | Carried to `onUploadComplete` and `onError`. Typed only under `uploadRoute()`. | +| `state` | `TState` | Carried to `onUploadComplete` and `onError`. Only `uploadRoute()` can carry one: on a plain-object route the return type is pinned to `state: undefined`, so returning anything else does not compile. | -`file` is the browser's own claim, so `file.type` is the type the object is stored and served as. The type the bytes really are is checked at `begin` too, against the file's first bytes; that check is described in [constraints](/blob/browser/constraints). +`file` is the browser's own claim, so `file.type` is the type the object is stored and served as. The type the bytes really are is checked at `begin` too, against the file's first bytes; that check is described in [Constraints](/blob/browser/constraints). ### Paths @@ -175,21 +177,15 @@ uniquePath`chat/${threadId}/${file.name}` // chat/42/holiday-pic-7Kd2mQ9x.png ``` -Slashes in the literal chunks are structure. Everything inside `${...}` is a value, and a value cannot contribute structure: +Slashes in the literal chunks are structure. Everything inside `${...}` is a value, sanitized down to a slugged basename with a random suffix, so it can never contribute a directory of its own. The full rules are in [Writing](/blob/bucket/writing#uniquepath). -- directory components are dropped, so `../admin/x.png` contributes `x.png` -- control and format characters are stripped -- the stem is slugged (letters and digits survive, including non-Latin ones; everything else becomes `-`), lowercased, and capped at 64 characters -- the final extension is preserved and lowercased; a stem that slugs to nothing becomes `file` -- a `-` and 8 base58 characters are appended, so two uploads of the same filename never collide - -Without the suffix, a stable path is an overwrite: the second upload replaces the first, and the first upload's `end` then answers 404 even though its bytes landed. Use a stable path only when overwriting is the intent. +Without the suffix, a stable path is an overwrite: the second upload replaces the first, and a single-PUT upload that lost the race then gets 404 from its own `end` even though its bytes landed. Use a stable path only when overwriting is the intent. ### Metadata `metadata` is signed into the presigned PUT, so the browser can neither add to it nor change it, and it comes back on `onUploadComplete` as `metadata`. It is stored on the object and readable later with `bucket.info(path)`. -Values are printable ASCII; anything else is refused with `invalid_input` rather than silently re-encoded by storage. Keys must be valid header names and are lowercased on the way in, so read them back lowercased. Percent-encode anything else with `encodeURIComponent`. +Values are printable ASCII and keys come back lowercased, under the same rules as a server-side write: see [Writing](/blob/bucket/writing#metadata). `metadata["upstash-upload"]` is reserved: the SDK writes its own marker under that key to prove which upload wrote the object at a path, and setting it throws `invalid_input`. @@ -219,7 +215,7 @@ onBeforeUpload: async ({ request, file }) => { } ``` -Every `BlobError` reaches the browser with its `code` intact, so a hook can switch on `error.code` instead of reading status numbers. The codes are listed in [errors](/blob/bucket/errors). +Every `BlobError` reaches the browser with its `code` intact, so a hook can switch on `error.code` instead of reading status numbers. The codes are listed in [Errors](/blob/bucket/errors). The browser never retries `begin`: it runs your callback, and a callback that writes a row must not be run twice for one file. @@ -241,8 +237,8 @@ onUploadComplete: async ({ uploadId, path, url, size, contentType, metadata, sta | Argument | Type | | | --- | --- | --- | | `path` | `string` | Where the object is stored. | -| `url` | `string \| undefined` | The public URL. Undefined on a private bucket; use [signed URLs](/blob/overall/signing). | -| `versionedUrl` | `string \| undefined` | `${url}?v=${etag}`, for a stable path that gets overwritten. | +| `url` | `string \| undefined` | The public URL. Undefined on a private bucket; see [How signing works](/blob/overall/signing). | +| `versionedUrl` | `string \| undefined` | `${url}?v=${etag}` with the etag percent-encoded, since storage returns it quoted, so it reads `?v=%22...%22`. For a stable path that gets overwritten. | | `size` | `number` | Bytes actually stored, verified against what the browser declared. | | `etag` | `string` | The stored object's etag. | | `uploadedAt` | `Date` | When storage wrote it. | @@ -264,14 +260,17 @@ if (upload?.status === "done") upload.blob.data.path // string, inferred from on ``` - **It is at-least-once.** The browser retries `end` up to three times, on a network failure and on - 408, 429 and any 5xx. `uploadId` is stable across those retries and is the key to write against: - `on conflict (upload_id) do nothing`, or the equivalent upsert for your database. + **It is at-least-once.** `end` gets three attempts, so two retries, on a network failure and on + 408, 429, 500, 502, 503 or 504. Any other status fails outright. `uploadId` is stable across those + retries and is the key to write against: `on conflict (upload_id) do nothing`, or the equivalent + upsert for your database. **Any throw out of it deletes the completed object.** That is the intent for a refusal, and it is a trap for a database error: a ten-second outage destroys bytes that uploaded fine, the browser - retries `end`, and the user is shown a 404 reading "the upload never landed". Catch your own - storage errors and decide deliberately instead of letting a driver error escape the callback. + retries `end`, and a single-PUT upload then answers 404 reading "the upload never landed". A + retryable `BlobError` is not an escape either: the delete happens first, so the retry it asks for + arrives at an empty path. Catch your own storage errors and decide deliberately instead of letting + a driver error escape the callback. ```ts lib/uploads.ts @@ -343,9 +342,7 @@ With a single route, authorizing inside `onBeforeUpload` and carrying an id in ` ### The ordering rule -Write `context` **above** `routes` and the callbacks that read `ctx`, or annotate its parameter. Straight from the SDK's own source: - -> One rule about `context`: write it above the callbacks that read `ctx`. `(request) =>` with no annotation is fine there. Written below `routes`, TypeScript has already typed the routes with `ctx: undefined` by the time it reads what `context` returns, and the error lands on `context` itself: `Promise is not assignable to undefined`. Annotating the parameter, `(request: Request) =>`, lifts the order rule, because TypeScript reads an annotated function's return before it types anything else in the literal. +Write `context` **above** `routes` and the callbacks that read `ctx`, or annotate its parameter. An unannotated `(request) =>` is fine in the first position. Written below `routes`, TypeScript has already typed the routes with `ctx: undefined` by the time it reads what `context` returns, and the error lands on `context` itself: `Promise is not assignable to undefined`. Annotating the parameter as `(request: Request) =>` lifts the ordering rule, because TypeScript reads an annotated function's return type before it types anything else in the object literal. ```ts lib/uploads.ts // Fine: context first. @@ -470,7 +467,9 @@ export const { useUpload } = uploadHooks({ }) ``` -`headers`, `concurrency`, `endpoint` and `onError` are the defaults `uploadHooks` takes. A call-site option wins over the default; the configured `onError` runs first, then the one passed at the call site, and a throw from either stops neither the other nor the queue. +`headers`, `concurrency`, `endpoint` and `onError` are the defaults `uploadHooks` takes. A call-site option wins over the default, except `onError`, where the configured handler runs first and the call-site one after it. + +Only the configured handler is wrapped: a throw from it is caught and logged as `[upstash-blob] uploadHooks onError threw`, and the call-site handler still runs. The call-site handler is not wrapped. A throw there escapes the store's settle loop before it reaches the step that starts the next queued upload, so the rest of the queue never starts. That handler must not throw. Called with no type parameter, `uploadHooks()` returns the unbound `useUpload`, which takes a URL. @@ -523,7 +522,7 @@ const { start, uploads, upload, clear, accept, constraints } = useUpload("attach `pending` is the field to drive UI off. Hand-rolling it from `status` is where the off-by-one-state bugs live: an input re-enabled during `finishing`, a progress bar still drawn under an error line. -`percent` sits at 99 through `finishing`, which is the stretch after the last byte is sent while `end` records the object and runs `onUploadComplete`. Naming that state is the difference between a bar that is working and one that looks stuck. +`percent` sits at 99 through `finishing`, because 100 has to mean stored rather than sent. [Large files](/blob/browser/large-files#progress-and-status) has the rest of the progress fields. `blob.data` is typed from that route's `onUploadComplete`. The payload a state does not carry is declared as `undefined` rather than left out, so `upload?.blob?.url` and `upload?.error?.message` read straight off the record with no narrowing. @@ -550,15 +549,7 @@ A throw from it ends the upload carrying that error, with no retry and no reword ## The GET endpoint -`GET` on the route serves its constraints as JSON, with an ETag and `Cache-Control: public, max-age=60`: - -```json -{ "constraints": { "contentTypes": ["image/png", "image/jpeg"], "maxBytes": 20000000 } } -``` - -That is what fills `accept` and `constraints` on the hook, and it lets the hook refuse an oversized file locally, as an error record, before any request leaves the browser. It is short-lived and revalidated rather than immutable, because the constraints are your route's own code and change with a deploy. - -The check in the browser is a courtesy: the server is authoritative and enforces the same limits at `begin`. See [constraints](/blob/browser/constraints). +`GET` on the route serves its constraints as JSON. That is what fills `accept` and `constraints` on the hook, and it lets the hook refuse an oversized file locally, as an error record, before any request leaves the browser. The check in the browser is a courtesy: the server is authoritative and enforces the same limits at `begin`. The document, its caching and what the hook does with it are in [Constraints](/blob/browser/constraints#in-the-browser). --- @@ -621,22 +612,10 @@ upload?.status === "done" && upload.response.url // typed from the generic Its options are `headers`, `concurrency` and `field`, the multipart field name `start({ file })` sends the file under, `'file'` by default and it has to match what your route reads. `start({ body })` sends a `File`, `Blob` or `FormData` as the raw body instead. The record has `cancel()` only, and statuses `queued`, `uploading`, `finishing`, `done`, `canceled` and `error`: there is no `begin` or `end` to pause between. - - A proxied upload is capped by your platform's request body limit, not by `maxBytes`: Vercel caps a - serverless request body at 4.5 MB, AWS Lambda at 6 MB, and Cloudflare at 100 MB on the free plan. - The body is rejected before your route runs, so the 413 carries no code of its own; the SDK - surfaces it as `too_large` with those numbers as the hint. Anything larger belongs on the direct - path above. - +A proxied upload is capped by your platform's request body limit rather than by `maxBytes`, and the refusal happens before your route runs. The SDK surfaces it as `too_large` with the platform numbers as the hint; they are listed in [Errors](/blob/bucket/errors#platform-body-limits). Anything larger belongs on the direct path above. --- ## CORS -The signed PUT is a cross-origin request from your page to storage, and it sends `Content-Type`, `Cache-Control` and the object's `x-amz-meta-*` as real headers, because they are pinned into the signature and storage refuses the PUT if they are changed. The bucket's CORS configuration therefore has to - -- allow `PUT` from your origin, -- allow those request headers, and -- expose `ETag` in `Access-Control-Expose-Headers`, which is how the browser reads back what it stored. - -A request that fails with no status and no bytes sent is almost always CORS: the preflight is what failed, and the reason is never visible to script. The SDK says so after three attempts rather than backing off for minutes, with a hint naming CORS as the likely cause. A failure after bytes were sent is treated as a dropped link instead, and retried far longer. +The signed PUT is a cross-origin request from your page to storage, so the bucket's CORS policy has to allow it: the required shape is in [CORS](/blob/overall/quickstart#cors). A PUT that fails with no status and no bytes sent is almost always this, and the SDK says so after three attempts rather than backing off for minutes. diff --git a/blob/bucket/caching.mdx b/blob/bucket/caching.mdx index 27d7e0ce..6c9ecc03 100644 --- a/blob/bucket/caching.mdx +++ b/blob/bucket/caching.mdx @@ -26,10 +26,10 @@ That is the whole shape of the feature. Everything below is about choosing the r A bare number is **seconds**, the unit every TTL option on the web already uses. A string takes a unit: `ms`, `s`, `m`, `h`, `d`, and their long forms (`sec`, `second`, `seconds`, `min`, `minute`, `minutes`, `hr`, `hour`, `hours`, `day`, `days`). A string with no unit at all is read as seconds too. ```ts -cache: '1h' // public, max-age=3600 +cache: "1h" // public, max-age=3600 cache: 3600 // public, max-age=3600 -cache: '15 min' // public, max-age=900 -cache: '7d' // public, max-age=604800 +cache: "15 min" // public, max-age=900 +cache: "7d" // public, max-age=604800 ``` Durations are converted to whole seconds, so `'1500ms'` stores `max-age=1`. An unparseable duration throws a `TypeError` naming the option. @@ -39,8 +39,8 @@ Durations are converted to whole seconds, so `'1500ms'` stores `max-age=1`. An u Anything containing `=` or `,` is a header, stored exactly as written, trimmed. A directive separator is what tells the two apart: a header always has one, a duration never does. ```ts -cache: 'public, max-age=60, s-maxage=31536000' -cache: 'max-age=0, stale-while-revalidate=86400' +cache: "public, max-age=60, s-maxage=31536000" +cache: "max-age=0, stale-while-revalidate=86400" ``` That is the escape hatch, and it is why `cache` is three words and a duration rather than an object of flags. `s-maxage`, `stale-while-revalidate`, `no-transform` and whatever the spec adds next are all sayable without the option growing a camelCase word for each of them. @@ -71,21 +71,21 @@ Four places, innermost wins. A per-call `cache` overrides the bucket default. The default for every object this bucket stores. ```ts lib/blob.ts -import { Bucket } from '@upstash/blob'; +import { Bucket } from "@upstash/blob" export const bucket = new Bucket({ token: process.env.UPSTASH_BLOB_TOKEN!, - cache: 'immutable', -}); + cache: "immutable", +}) ``` ### On a put ```ts -await bucket.put('avatars/7.png', file, { - contentType: 'image/png', - cache: 'revalidate', -}); +await bucket.put("avatars/7.png", file, { + contentType: "image/png", + cache: "revalidate", +}) ``` `updateJson` takes it too, for the object it rewrites. See [Writing](/blob/bucket/writing) for the rest of `put`. @@ -95,12 +95,12 @@ await bucket.put('avatars/7.png', file, { The `Cache-Control` is pinned into the signature and handed back in `headers`, so the uploader has to send it verbatim. ```ts -const upload = await bucket.signedUploadUrl('u/7/report.pdf', { - contentType: 'application/pdf', - cache: 'immutable', -}); +const upload = await bucket.signedUploadUrl("u/7/report.pdf", { + contentType: "application/pdf", + cache: "immutable", +}) -await fetch(upload.url, { method: 'PUT', headers: upload.headers, body }); +await fetch(upload.url, { method: "PUT", headers: upload.headers, body }) ``` ### On a direct browser upload @@ -108,14 +108,14 @@ await fetch(upload.url, { method: 'PUT', headers: upload.headers, body }); `onBeforeUpload` returns the path, and may return the `cache` alongside it. It is decided per upload, on your server, and signed into the presigned PUT. ```ts lib/uploads.ts -import { uniquePath, uploadHandler } from '@upstash/blob'; +import { uniquePath, uploadHandler } from "@upstash/blob" export const uploads = uploadHandler({ onBeforeUpload: ({ file }) => ({ path: uniquePath`uploads/${file.name}`, - cache: 'immutable', + cache: "immutable", }), -}); +}) ``` See [Upload handler](/blob/browser/upload-handler) for the rest of the callback. @@ -146,15 +146,15 @@ Reads on a private bucket go through `signedReadUrl()`. See [Reading](/blob/buck This is the pattern worth learning, because it is the one that gets a year of caching out of a path that changes. -Every record carries `versionedUrl`, which is `${url}?v=${etag}`. The etag changes whenever the content does, so the URL changes whenever the content does. A stable path stored with `cache: 'immutable'` and served through `versionedUrl` is cached for a year by URL, and an overwrite mints a new URL that no cache has ever seen. +Every record carries `versionedUrl`, which is `${url}?v=${etag}` with the etag percent-encoded, since storage returns it quoted: the query reads `?v=%22...%22`. The etag changes whenever the content does, so the URL changes whenever the content does. A stable path stored with `cache: 'immutable'` and served through `versionedUrl` is cached for a year by URL, and an overwrite mints a new URL that no cache has ever seen. ```ts app/api/avatar/route.ts const blob = await bucket.put(`avatars/${user.id}.png`, file, { - contentType: 'image/png', - cache: 'immutable', -}); + contentType: "image/png", + cache: "immutable", +}) -await db.users.update(user.id, { avatar: blob.versionedUrl }); +await db.users.update(user.id, { avatar: blob.versionedUrl }) ``` ```tsx @@ -197,35 +197,24 @@ For anything served through `signedReadUrl()`, two separate mechanisms are in pl The link expires. `signedReadUrl()` defaults to 5 minutes and is capped by the credential that signed it, so `expiresAt` on the result is the answer per link rather than a number you assume. ```ts -const { url, expiresAt } = await bucket.signedReadUrl('private/report.pdf'); +const { url, expiresAt } = await bucket.signedReadUrl("private/report.pdf") ``` The stored `Cache-Control` is a different thing entirely, and it outlives the link. A long max-age on a private object still lets the requester's own browser keep the bytes after the link stops working, because the browser is caching a response it was allowed to fetch. If a reader must not keep the bytes, say so on the object: ```ts -await bucket.put('private/report.pdf', body, { - contentType: 'application/pdf', - cache: 'no-store', -}); +await bucket.put("private/report.pdf", body, { + contentType: "application/pdf", + cache: "no-store", +}) ``` `no-store` is the one value that drops the visibility scope entirely: it stores `no-store` on a public and a private bucket alike, because nothing is to be kept either way. -See [Signed URLs](/blob/overall/signing) for how link lifetimes are capped. +See [How signing works](/blob/overall/signing) for how link lifetimes are capped. --- ## What the upload route itself caches -An upload route's `GET` serves its constraints, and it has its own caching, unrelated to the objects the route stores. - -```http -cache-control: public, max-age=60 -etag: "1qk8ru" -``` - -A request carrying a matching `If-None-Match` gets a 304 with no body. On the client, the React hooks keep the answer in memory for 60 seconds, so a page with several pickers on it asks once. - -Short and revalidated, not immutable: the constraints are the route's own code and change with a deploy, and a client that cached them forever would refuse files the route now accepts. The server stays authoritative either way, since every upload is checked again at `begin`. - -See [Upload handler](/blob/browser/upload-handler). +An upload route's `GET` serves its constraints with a short, revalidated `Cache-Control` of its own, unrelated to the objects the route stores. That document and its caching are covered in [Constraints](/blob/browser/constraints#in-the-browser). diff --git a/blob/bucket/deleting.mdx b/blob/bucket/deleting.mdx index b160ca6b..aa4bf6dd 100644 --- a/blob/bucket/deleting.mdx +++ b/blob/bucket/deleting.mdx @@ -10,23 +10,23 @@ This page also covers the deletes that are not `del()`: the copy `move` leaves b ## The three shapes -```ts delete.ts -import { Bucket } from '@upstash/blob'; +```ts +import { Bucket } from "@upstash/blob" -const bucket = Bucket.fromEnv(); +const bucket = Bucket.fromEnv() -await bucket.del('avatars/me.png'); // one path -await bucket.del(['a.png', 'b.png', 'c.png']); // an array of paths -await bucket.del({ prefix: 'tmp/' }); // everything under a prefix +await bucket.del("avatars/me.png") // one path +await bucket.del(["a.png", "b.png", "c.png"]) // an array of paths +await bucket.del({ prefix: "tmp/" }) // everything under a prefix ``` The type is `DeleteTarget`: -```ts type -type DeleteTarget = string | string[] | { prefix: string; all?: boolean }; +```ts +type DeleteTarget = string | string[] | { prefix: string; all?: boolean } ``` -Anything else is refused with `invalid_input`: `del: expected a path, an array of paths, or { prefix }`. There is no `del()` with no argument. +Anything else is refused with `invalid_input`: `Del: expected a path, an array of paths, or { prefix }`. There is no `del()` with no argument. --- @@ -34,15 +34,15 @@ Anything else is refused with `invalid_input`: `del: expected a path, an array o One `DELETE` request. A 404 counts as success, so deleting something that is not there does not throw: -```ts idempotent.ts -await bucket.del('drafts/9f3c.txt'); -await bucket.del('drafts/9f3c.txt'); // fine, still no throw +```ts +await bucket.del("drafts/9f3c.txt") +await bucket.del("drafts/9f3c.txt") // fine, still no throw ``` -That is what makes a delete safe to run from a retried job or a queue consumer with at-least-once delivery. Any other failure is a real error: a 403 surfaces as `signature_mismatch`, a 429 as `rate_limited`, a 5xx as `request_failed`. +That is what makes a delete safe to run from a retried job or a queue consumer with at-least-once delivery. Any other failure is a real error: a 403 surfaces as `signature_mismatch`, a 429 as `rate_limited`, a 503 as `not_ready`, and any other 5xx as `request_failed` carrying status 502. See [Errors](/blob/bucket/errors#what-storage-errors-map-to). -`del` never tells you whether anything was there. If you need to know, ask first with `bucket.exists(path)`, which answers `false` instead of throwing. See [reading](/blob/bucket/reading). +`del` never tells you whether anything was there. If you need to know, ask first with `bucket.exists(path)`, which answers `false` instead of throwing. See [Reading](/blob/bucket/reading). --- @@ -55,41 +55,35 @@ Two details are worth knowing before you read the error handling. First, every path in a chunk is validated before that chunk's request goes out, so a bad path fails the chunk it is in rather than being silently skipped. Chunks before it have already run. -Second, and this is the one that shapes the API: S3 answers a batch delete with **200 and per-key errors inside the body**. A key that failed is reported in an `` block in an otherwise successful response. The SDK does not take that list at face value. For each key S3 named, it re-checks with `exists()` and keeps only the ones that are still there: - -```ts src/server/bucket.ts -// S3 answers 200 with errors inside; a survivor is the truth, so ask again rather than trust the list. -const survivors: string[] = []; -for (const p of failed) if (await this.exists(p)) survivors.push(p); -``` +Second, and this is the one that shapes the API: S3 answers a batch delete with **200 and per-key errors inside the body**. A key that failed is reported in an `` block in an otherwise successful response. The SDK does not take that list at face value: for each key S3 named it makes one more `exists()` call and keeps only the paths that are still there. A survivor is the truth and the list is not. An error block for an object that is in fact gone would otherwise be reported to you as a failure you cannot act on, and the whole point of `failed` is that you can act on it. If anything survives, `del` throws `partial_delete`, status 500, whose `failed` array names exactly which paths are still there: -```ts partial.ts -import { BlobError } from '@upstash/blob'; +```ts +import { BlobError } from "@upstash/blob" try { - await bucket.del(paths); + await bucket.del(paths) } catch (e) { - if (BlobError.is(e) && e.code === 'partial_delete') { + if (BlobError.is(e) && e.code === "partial_delete") { // e.failed is string[]: the paths that are still in the bucket, verified one by one - console.error(`${e.failed?.length} objects survived`, e.failed); - await requeue(e.failed ?? []); - return; + console.error(`${e.failed?.length} objects survived`, e.failed) + await requeue(e.failed ?? []) + return } - throw e; + throw e } ``` Everything not in `failed` was deleted. `partial_delete` is a report, not a rollback: retrying with `e.failed` is the whole recovery, and it is safe because a delete of something already gone is success. -Use `BlobError.is(e)`, never `instanceof`. An ESM copy and a CJS copy of the class are two different classes. See [errors](/blob/bucket/errors). +Use `BlobError.is(e)`, never `instanceof`. An ESM copy and a CJS copy of the class are two different classes. See [Errors](/blob/bucket/errors). -A batch delete is a `POST`, and the SDK only retries idempotent verbs, so a 5xx on a batch surfaces as `request_failed` on the first try rather than being sent twice. +A batch delete is a `POST`, and the SDK only retries idempotent verbs, so a failure on a batch surfaces on the first try rather than being sent twice: a 503 as `not_ready`, any other 5xx as `request_failed` at status 502. --- @@ -97,8 +91,8 @@ A batch delete is a `POST`, and the SDK only retries idempotent verbs, so a 5xx `del({ prefix })` pages through `list()` at 1000 objects per page and batch-deletes each page as it goes: -```ts prefix.ts -await bucket.del({ prefix: 'users/7/tmp/' }); +```ts +await bucket.del({ prefix: "users/7/tmp/" }) ``` So the cost scales with the number of objects under the prefix, not with the one call you wrote. A prefix over 100,000 objects is 100 list requests and 100 batch deletes, run sequentially. It is not atomic: objects written under the prefix while it runs may or may not be caught, depending on which page they land on. @@ -109,12 +103,12 @@ Failures work exactly as they do for an array. Survivors from every page are col `del({ prefix: '' })` matches every object in the bucket, so an empty prefix from an unset variable or an empty form field would wipe the bucket. It is refused with `invalid_input` before a single request is sent, and the hint tells you the deliberate form: `pass { prefix: '', all: true } if that is what you mean`. -```ts whole-bucket.ts +```ts // Refused: invalid_input, status 400, nothing sent -await bucket.del({ prefix: userFolder }); +await bucket.del({ prefix: userFolder }) // Deliberate: this is how you say "yes, the whole bucket" -await bucket.del({ prefix: '', all: true }); +await bucket.del({ prefix: "", all: true }) ``` `all` is only consulted for the empty prefix. `del({ prefix: 'tmp/' })` needs nothing extra. @@ -125,14 +119,14 @@ await bucket.del({ prefix: '', all: true }); Every path reaching storage goes through `encodeKey`, which percent-encodes each segment and refuses outright any path containing a `.` or `..` segment: -```ts traversal.ts -await bucket.del('users/7/../8/private.pdf'); +```ts +await bucket.del("users/7/../8/private.pdf") // TypeError: path may not contain "." or ".." segments: users/7/../8/private.pdf ``` The reason is the trust model rather than tidiness. Your server holds a temporary credential that authorizes the whole bucket, and the URL parser resolves `..` before the request is signed. A traversing key would sign a delete against a different object than the one your code named, and the credential would happily allow it. Normalizing the path would hide that; rejecting it does not. -This applies to `del` in all three shapes, and to `put`, `copy`, `move`, `signedUploadUrl` and `abortMultipartUpload` alike. If you build paths from user input, build them with `uniquePath`, which strips directory components out of every interpolated value. See [writing](/blob/bucket/writing). +This applies to `del` in all three shapes, and to `put`, `copy`, `move`, `signedUploadUrl` and `abortMultipartUpload` alike. If you build paths from user input, build them with `uniquePath`, which strips directory components out of every interpolated value. See [Writing](/blob/bucket/writing). --- @@ -140,25 +134,25 @@ This applies to `del` in all three shapes, and to `put`, `copy`, `move`, `signed `move(from, to)` is not a primitive. It is a copy followed by a delete: -```ts move.ts -const blob = await bucket.move('tmp/9f3c', 'avatars/7.png'); +```ts +const blob = await bucket.move("tmp/9f3c", "avatars/7.png") ``` If the copy fails, nothing has changed and you get the copy's error. If the copy succeeds and the delete fails, the SDK throws `move_left_a_copy`, status 500, and **keeps the destination**. You are left with two objects rather than zero, which is the failure mode that loses no data: -```ts move-catch.ts -import { BlobError } from '@upstash/blob'; +```ts +import { BlobError } from "@upstash/blob" try { - await bucket.move('tmp/9f3c', 'avatars/7.png'); + await bucket.move("tmp/9f3c", "avatars/7.png") } catch (e) { - if (BlobError.is(e) && e.code === 'move_left_a_copy') { + if (BlobError.is(e) && e.code === "move_left_a_copy") { // avatars/7.png exists and is correct. tmp/9f3c is also still there. // The recovery is to retry the source delete, not the move. - await bucket.del('tmp/9f3c'); - return; + await bucket.del("tmp/9f3c") + return } - throw e; + throw e } ``` @@ -176,8 +170,8 @@ A multipart upload is created, parts land against it, and it becomes an object o ### Listing them -```ts list-uploads.ts -const uploads = await bucket.listMultipartUploads({ prefix: 'uploads/' }); +```ts +const uploads = await bucket.listMultipartUploads({ prefix: "uploads/" }) // [{ path: 'uploads/big.mp4', uploadId: 'ABC...', initiatedAt: Date }, ...] ``` @@ -191,8 +185,8 @@ const uploads = await bucket.listMultipartUploads({ prefix: 'uploads/' }); ### Aborting one -```ts abort-one.ts -await bucket.abortMultipartUpload({ path: 'uploads/big.mp4', uploadId: 'ABC...' }); +```ts +await bucket.abortMultipartUpload({ path: "uploads/big.mp4", uploadId: "ABC..." }) ``` This throws the upload away along with every part that landed for it. Missing is success, exactly like `del` on a path that is not there. @@ -200,7 +194,7 @@ This throws the upload away along with every part that landed for it. Missing is That is also why it takes the record `listMultipartUploads()` returned rather than two positional strings. If the wire treats "not there" as success, then `abortMultipartUpload(uploadId, path)` with the arguments swapped would abort nothing, answer 204, and report that it worked. A named `{ path, uploadId }` pair cannot be swapped by accident, and an empty `uploadId` is refused with `invalid_input` before anything is sent. -`onUploadComplete` receives `multipartUploadId` for exactly this pair. Store it alongside your row and you can abort a specific upload later without listing the bucket. It is `undefined` when the file went up as a single PUT. See [upload handler](/blob/browser/upload-handler). +`onUploadComplete` receives `multipartUploadId` for exactly this pair. Store it alongside your row and you can abort a specific upload later without listing the bucket. It is `undefined` when the file went up as a single PUT. See [Upload handler](/blob/browser/upload-handler). ### Sweeping the stale ones @@ -208,24 +202,24 @@ That is also why it takes the record `listMultipartUploads()` returned rather th `abortStaleMultipartUploads` is list plus abort in one call, meant for a cron. It returns what it aborted: ```ts app/api/cron/sweep-uploads/route.ts -import { Bucket } from '@upstash/blob'; +import { Bucket } from "@upstash/blob" export async function GET(request: Request) { - if (request.headers.get('authorization') !== `Bearer ${process.env.CRON_SECRET}`) { - return new Response('unauthorized', { status: 401 }); + if (request.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) { + return new Response("unauthorized", { status: 401 }) } - const bucket = Bucket.fromEnv(); + const bucket = Bucket.fromEnv() const aborted = await bucket.abortStaleMultipartUploads({ - olderThan: '1d', - prefix: 'uploads/', - }); + olderThan: "1d", + prefix: "uploads/", + }) for (const upload of aborted) { - console.info(`[sweep] aborted ${upload.path}, started ${upload.initiatedAt.toISOString()}`); + console.info(`[sweep] aborted ${upload.path}, started ${upload.initiatedAt.toISOString()}`) } - return Response.json({ aborted: aborted.length }); + return Response.json({ aborted: aborted.length }) } ``` @@ -233,39 +227,20 @@ export async function GET(request: Request) { `prefix` narrows the sweep the same way it narrows `listMultipartUploads`. - -An abandoned upload **under** the multipart threshold is not a multipart upload at all. The browser's presigned PUT stored the object the moment its last byte landed, so what it leaves behind is a whole, ordinary, `list()`-visible, billed object, and none of the calls on this page can find it. That needs a different sweep: see [abandoned uploads](/blob/browser/abandoned-uploads). - + +An abandoned upload **under** the multipart threshold is not a multipart upload at all. The browser's presigned PUT stored the object the moment its last byte landed, so what it leaves behind is a whole, ordinary, `list()`-visible, billed object, and none of the calls on this page can find it. That needs a different sweep: see [Abandoned uploads](/blob/browser/abandoned-uploads). + --- ## When the SDK deletes for you -Two paths in the upload handler delete objects without you asking. - -**A rejected direct upload.** When `onUploadComplete` throws, the handler discards the object the upload created. The intent is that the object exists only if your callback returned. +Two paths in the upload handler delete objects without you asking: a throw out of `onUploadComplete`, and a `cancel()` from the browser. A cancel on a multipart upload aborts it, parts and all. Everything else deletes a stored object, and that goes through one guard, because R2 has no conditional delete. The object's etag is re-read first and the delete only happens when it still matches the one this upload produced, so a later upload to the same path is left alone with a warning; an upload the handler cannot identify at all is left stored with an error logged, because an orphan costs storage and a log line while a blind delete costs somebody else's accepted file. On a single PUT, the `upstash-upload` marker signed into the presigned URL is what says the object is this upload's at all. -Because R2 has no conditional delete, `discard` re-reads the object's etag first and only deletes when it still matches the one this upload produced. On a stable path, a later upload may already have replaced those bytes, and deleting then would destroy a file that was accepted. When the etag has moved, the object is left alone and a warning is logged instead. When the upload cannot be identified at all, the object is left stored and an error is logged: an orphan costs storage and a log line, a blind delete costs somebody else's accepted file, and the cheaper mistake is the one to make. - -This is also why a database error inside `onUploadComplete` is expensive. Any throw runs the discard, so a ten second outage deletes bytes that uploaded perfectly well. Catch your own storage errors and answer with a retryable error rather than letting them escape the callback. - -**A `cancel()` from the browser.** The browser posts a cancel phase with its completion token. For a multipart upload the handler aborts it, parts and all. For a single PUT there is no upload to abort, so the handler reads the object and deletes it only when its `upstash-upload` marker matches this upload's id. That marker is signed into the presigned PUT, so the browser cannot forge it, and it is the whole check: a cancel cannot be aimed at an object this upload did not write. - -A cancel that arrives once the upload has reached its finishing phase is dropped rather than raced against a callback that may already have written a row. - -See [how signing works](/blob/overall/signing) for the marker and the completion token, and [abandoned uploads](/blob/browser/abandoned-uploads) for what happens when no cancel is ever sent. +Any throw out of `onUploadComplete` runs that discard, including a retryable `BlobError`: the object is deleted first, so the retry the error asks for arrives at an empty path. Catch your own storage errors rather than letting them escape the callback. See [Upload handler](/blob/browser/upload-handler#onuploadcomplete) for the callback and [Abandoned uploads](/blob/browser/abandoned-uploads) for what happens when nothing is ever posted at all. --- ## Error codes -| Code | Status | When | -| --- | --- | --- | -| `partial_delete` | 500 | An array or prefix delete left objects behind. `e.failed` names exactly which paths are still there, each one verified with `exists()`. | -| `move_left_a_copy` | 500 | `move` copied the object but could not delete the source. The destination is kept, so both exist. The delete's own error is on `cause`. | -| `invalid_input` | 400 | `del({ prefix: '' })` without `all: true`, a target that is not a path, array or `{ prefix }`, or an `abortMultipartUpload` with no `uploadId`. | -| `not_found` | 404 | Never raised by `del`, which treats a missing object as success. You will see it from `get` and `info` on the same path. | - -A path containing a `.` or `..` segment throws a `TypeError` rather than a `BlobError`, because it is a programming mistake rather than a runtime condition. - -The full list of codes, statuses and the fields each one carries is on [errors](/blob/bucket/errors). +Deleting raises `partial_delete`, `move_left_a_copy` and `invalid_input`; each is described where it is raised above, and the statuses and extra fields are on [Errors](/blob/bucket/errors#the-codes). Two things are specific to this page. `not_found` is never raised by `del`, which treats a missing object as success. And a path containing a `.` or `..` segment throws a `TypeError` rather than a `BlobError`, because it is a programming mistake rather than a runtime condition. diff --git a/blob/bucket/errors.mdx b/blob/bucket/errors.mdx index 1783049e..2310a1ec 100644 --- a/blob/bucket/errors.mdx +++ b/blob/bucket/errors.mdx @@ -5,16 +5,16 @@ title: "Errors" Everything the SDK throws is a `BlobError`. It carries a `code` from a closed list of eighteen, a `status`, and a `message` written to be printed. That holds on the server, in the browser, and inside the React hooks: one class, one list of codes, one shape to handle. ```ts lib/avatar.ts -import { BlobError, Bucket } from '@upstash/blob'; +import { BlobError, Bucket } from "@upstash/blob" -const bucket = Bucket.fromEnv(); +const bucket = Bucket.fromEnv() export async function avatar(path: string) { try { - return await bucket.info(path); + return await bucket.info(path) } catch (e) { - if (BlobError.is(e) && e.code === 'not_found') return null; - throw e; + if (BlobError.is(e) && e.code === "not_found") return null + throw e } } ``` @@ -33,9 +33,9 @@ export async function avatar(path: string) { ```ts if (BlobError.is(e)) { - e.code; // BlobErrorCode - e.status; // number - e.message; // string + e.code // BlobErrorCode + e.status // number + e.message // string } ``` @@ -89,11 +89,11 @@ Beyond `code`, `status` and `message`, an error carries whatever the code has to ```ts try { - await bucket.del(['a.png', 'b.png', 'c.png']); + await bucket.del(["a.png", "b.png", "c.png"]) } catch (e) { - if (!BlobError.is(e)) throw e; - if (e.code === 'partial_delete') await queueForRetry(e.failed ?? []); - if (e.code === 'rate_limited') await sleep((e.retryAfter ?? 1) * 1000); + if (!BlobError.is(e)) throw e + if (e.code === "partial_delete") await queueForRetry(e.failed ?? []) + if (e.code === "rate_limited") await sleep((e.retryAfter ?? 1) * 1000) } ``` @@ -101,10 +101,10 @@ try { ```ts try { - await bucket.put('avatars/7.png', file, { overwrite: false }); + await bucket.put("avatars/7.png", file, { overwrite: false }) } catch (e) { - if (BlobError.is(e) && e.code === 'already_exists') { - console.log('kept', e.etag, e.size); + if (BlobError.is(e) && e.code === "already_exists") { + console.log("kept", e.etag, e.size) } } ``` @@ -113,14 +113,14 @@ try { ## Messages are written to be shown -Messages are lowercase in the source and sentence-cased when the error is built, so an app can print `e.message` straight into its error line without writing its own `capitalize()`. Every app that printed these messages ended up writing one. +Messages are lowercase in the source and sentence-cased when the error is built, so an app can print `e.message` straight into its error line without writing its own `capitalize()`. A message that opens with an identifier keeps its case. A MIME type, a file name or a metadata key is not a word to raise: "Image/png is not allowed" names a type that does not exist, and "Cat.png" is not the file the user picked. ```ts -new BlobError('not_found').message; // 'Not found' -new BlobError('forbidden', 'not your thread').message; // 'Not your thread' -new BlobError('too_large', 'cat.png is 3.1 MB, over the 2 MB limit').message; +new BlobError("not_found").message // 'Not found' +new BlobError("forbidden", "not your thread").message // 'Not your thread' +new BlobError("too_large", "cat.png is 3.1 MB, over the 2 MB limit").message // 'cat.png is 3.1 MB, over the 2 MB limit' ``` @@ -136,7 +136,7 @@ A hint is appended to the message in parentheses, so printing `message` alone is | `length_required` | pass `{ size }` or `{ maxBytes }` so the length is known before the first byte | ```ts -new BlobError('length_required').message; +new BlobError("length_required").message // 'Length required (pass { size } or { maxBytes } so the length is known before the first byte)' ``` @@ -149,14 +149,12 @@ A message that already contains its hint is not doubled. This is what makes the browser half usable. An upload route answers every refusal with `BlobError.toJSON()` at the error's own status, and the browser rebuilds it with `BlobError.fromJSON()`. So `error.code` inside a hook is the code your server raised, not a status number you have to decode back into a meaning. ```tsx app/picker.tsx -'use client'; -import { uploadHooks, type BlobError } from '@upstash/blob/react'; -import type { uploads } from '@/lib/uploads'; - -const { useUpload } = uploadHooks(); +"use client" +import type { BlobError } from "@upstash/blob/react" +import { useUpload } from "@/lib/upload-hooks" export function Picker() { - const { start, upload, accept } = useUpload(); + const { start, upload, accept } = useUpload() return ( <> @@ -165,22 +163,22 @@ export function Picker() { accept={accept} onChange={(e) => start({ file: e.target.files?.[0] })} /> - {upload?.status === 'error' &&

{describe(upload.error)}

} + {upload?.status === "error" &&

{describe(upload.error)}

} - ); + ) } function describe(error: BlobError): string { switch (error.code) { - case 'unauthorized': - return 'Your session expired. Sign in and try again.'; - case 'rate_limited': - return `Too many uploads. Try again in ${error.retryAfter ?? 30}s.`; - case 'not_ready': - return 'Storage is warming up. Try again in a moment.'; + case "unauthorized": + return "Your session expired. Sign in and try again." + case "rate_limited": + return `Too many uploads. Try again in ${error.retryAfter ?? 30}s.` + case "not_ready": + return "Storage is warming up. Try again in a moment." default: // too_large, content_type_not_allowed and the rest already read as a sentence. - return error.message; + return error.message } } ``` @@ -212,25 +210,25 @@ Any other status becomes `request_failed`, keeping the status it arrived with. `onError` is the one place to log. It sees every refusal, the SDK's own included, and it runs before the answer is written. ```ts lib/uploads.ts -import { BlobError, uniquePath, uploadHandler } from '@upstash/blob'; +import { BlobError, uniquePath, uploadHandler } from "@upstash/blob" export const uploads = uploadHandler({ - constraints: { maxBytes: '20mb', contentTypes: ['image/*'] }, + constraints: { maxBytes: "20mb", contentTypes: ["image/*"] }, onBeforeUpload: ({ file }) => ({ path: uniquePath`uploads/${file.name}` }), onError: ({ route, error, path, file, metadata }) => { - logger.error('upload refused', { + logger.error("upload refused", { route, path, file: file?.name, owner: metadata?.owner, - code: BlobError.is(error) ? error.code : 'unknown', + code: BlobError.is(error) ? error.code : "unknown", message: error instanceof Error ? error.message : String(error), - }); + }) // Returning nothing leaves the answer alone. }, -}); +}) ``` It is handed `{ ctx, route, request, error, file?, path?, metadata?, state? }`, with as much as the request had reached before it failed. A file refused at `begin` has `file` and no `path`; one refused after `onBeforeUpload` has both. @@ -239,7 +237,7 @@ Return a `BlobError` or a `Response` to answer with it instead: ```ts onError: ({ error }) => { - if (!BlobError.is(error)) return new BlobError('request_failed', 'could not record the upload'); + if (!BlobError.is(error)) return new BlobError("request_failed", "could not record the upload") }, ``` @@ -249,29 +247,7 @@ Written on the handler it is the default for every route, and a route with its o ## The `onUploadComplete` footgun - - Any throw out of `onUploadComplete` deletes the completed object. A plain database error destroys - bytes that uploaded fine, and the browser is told 404 "the upload never landed". A ten second - database blip costs the upload and reports it as a phantom. - - -Catch your own storage errors instead of letting them escape: - -```ts lib/uploads.ts -onUploadComplete: async ({ uploadId, url, metadata }) => { - try { - // uploadId is stable across retries, so the same completion twice writes one row. - await sql`insert into files (upload_id, owner, url) - values (${uploadId}, ${metadata.owner}, ${url}) - on conflict (upload_id) do nothing`; - } catch (e) { - logger.error('could not record upload', { uploadId, error: e }); - // Deliberately not rethrown: the object is stored and the row can be reconciled later. - } -}, -``` - -The delete is the intended behaviour for a genuine refusal, where the object should not survive a callback that rejected it. It is the wrong outcome for an error that has nothing to do with the file. See [Abandoned uploads](/blob/browser/abandoned-uploads) for the pending-row pattern that reconciles the rest. +Any throw out of `onUploadComplete` deletes the completed object, which is right for a refusal and wrong for a database error that has nothing to do with the file. Catch your own storage errors instead of letting them escape, and see [Upload handler](/blob/browser/upload-handler#onuploadcomplete) for the callback and [Abandoned uploads](/blob/browser/abandoned-uploads) for the pending-row pattern that reconciles the rest. --- @@ -306,9 +282,9 @@ the browser blocked the request before sending any bytes, which is almost always the bucket has to allow PUT and the signed headers from this origin ``` -The signed PUT sends `Content-Type`, `Cache-Control` and `x-amz-meta-*` as real headers, so bucket CORS has to allow them from your origin. See [Upload handler](/blob/browser/upload-handler). +The policy that fixes it is in [CORS](/blob/overall/quickstart#cors). -**A 403 on a freshly minted presign** becomes `signature_mismatch`. A 401 or 403 on an older URL is read as an expired signature and the browser asks the route for a new one; only a URL minted moments ago and refused again is the body. +**A 403 on a freshly minted presign** becomes `signature_mismatch`. A 401 or 403 on an older URL is read as an expired signature and the browser asks the route for a new one instead. The whole classification is in [Large files](/blob/browser/large-files#retries). **Exhausted retries** become `request_failed`, carrying the attempt count and the last status, hinted with what to do next: @@ -322,8 +298,8 @@ task.retry(), or pick the same file again) **A canceled upload rejects with an `AbortError`, not a `BlobError`.** The record's status is `canceled` and it carries no `error` at all, so a cancel never renders as a failure. ```ts -const record = start({ file }); -record?.cancel(); // status becomes 'canceled', error stays undefined +const record = start({ file }) +record?.cancel() // status becomes 'canceled', error stays undefined ``` --- @@ -340,9 +316,9 @@ Cloudflare at 100MB on the free plan) ``` ```tsx -const { start, upload } = useServerUpload('/api/avatar'); +const { start, upload } = useServerUpload("/api/avatar") -if (upload?.status === 'error' && upload.error.code === 'too_large') { +if (upload?.status === "error" && upload.error.code === "too_large") { // Either your own maxBytes or the platform's body cap. e.hint says which. } ``` @@ -365,13 +341,13 @@ The SDK already waits out short backoffs itself, up to three times. `mint_backof ```ts try { - await bucket.put('u/7/report.pdf', body); + await bucket.put("u/7/report.pdf", body) } catch (e) { - if (BlobError.is(e) && e.code === 'mint_backoff') { - return retryAfterSeconds(e.retryAfter ?? 10); + if (BlobError.is(e) && e.code === "mint_backoff") { + return retryAfterSeconds(e.retryAfter ?? 10) } - throw e; + throw e } ``` -Credentials are short-lived, cached per token, and re-minted just before they expire. A credential that expires mid-request is caught inside the SDK: it re-mints once and asks again, and only a second refusal surfaces. See [Signed URLs](/blob/overall/signing) for how that lifetime caps a signed link, and [Quickstart](/blob/overall/quickstart) for where the token comes from. +Credentials are short-lived, cached per token, and re-minted just before they expire. A credential that expires mid-request is caught inside the SDK: it re-mints once and asks again, and only a second refusal surfaces. See [How signing works](/blob/overall/signing) for how that lifetime caps a signed link, and [Quickstart](/blob/overall/quickstart) for where the token comes from. diff --git a/blob/bucket/reading.mdx b/blob/bucket/reading.mdx index 663e1ee9..878a5e6b 100644 --- a/blob/bucket/reading.mdx +++ b/blob/bucket/reading.mdx @@ -7,9 +7,9 @@ Reading covers everything that gets bytes or facts back out of a bucket: `get` f Every example below starts from a bucket: ```ts lib/bucket.ts -import { Bucket } from '@upstash/blob'; +import { Bucket } from "@upstash/blob" -export const bucket = Bucket.fromEnv(); // reads UPSTASH_BLOB_TOKEN +export const bucket = Bucket.fromEnv() // reads UPSTASH_BLOB_TOKEN ``` See [Quickstart](/blob/overall/quickstart) for the token, and [Writing](/blob/bucket/writing) for the other half of the API. @@ -49,20 +49,20 @@ In this SDK `blob` always names a record, and never the bytes of one. The DOM al `get` returns the whole record plus the response body as a stream. Nothing is buffered for you, so a large object costs whatever you do with the stream and no more. ```ts -const res = await bucket.get('reports/2026-01.pdf'); +const res = await bucket.get("reports/2026-01.pdf") -res.contentType; // 'application/pdf' -res.size; // 184_302 -res.etag; // '"9f3c..."' -res.metadata; // { owner: 'u7' } -res.body; // ReadableStream +res.contentType // 'application/pdf' +res.size // 184_302 +res.etag // '"9f3c..."' +res.metadata // { owner: 'u7' } +res.body // ReadableStream ``` Wrap the body in a `Response` to get the usual conversions: ```ts -const text = await new Response((await bucket.get('notes/1.md')).body).text(); -const buffer = await new Response((await bucket.get('img/1.png')).body).arrayBuffer(); +const text = await new Response((await bucket.get("notes/1.md")).body).text() +const buffer = await new Response((await bucket.get("img/1.png")).body).arrayBuffer() ``` A missing object is a throw, not an `undefined` return: `get` raises a `BlobError` with code `not_found` and status 404. See [Errors](/blob/bucket/errors) for the code list and for `BlobError.is`. @@ -76,40 +76,20 @@ There is no range option. Reading part of an object is what [the S3 escape hatch `info` is the same record with no bytes: one HEAD request, so it costs nothing to read a 2 GB object's facts. ```ts -const info = await bucket.info('reports/2026-01.pdf'); +const info = await bucket.info("reports/2026-01.pdf") -info.size; // 184_302 -info.etag; // '"9f3c..."' -info.contentType; // 'application/pdf' -info.metadata; // { owner: 'u7' } -info.uploadedAt; // Date +info.size // 184_302 +info.etag // '"9f3c..."' +info.contentType // 'application/pdf' +info.metadata // { owner: 'u7' } +info.uploadedAt // Date ``` Like `get`, a missing object throws `not_found` rather than answering `undefined`. - - Metadata keys come back lowercased. They cross the wire as `x-amz-meta-*` headers, and header names are case-insensitive, so `{ uploadedBy: 'u1' }` written at upload reads back as `metadata.uploadedby`. Values are printable ASCII, because storage does not carry anything else back unchanged. - - -`metadata` is the reason this call exists next to `exists()`. It comes back from `get` and from `info`, and from nothing else: a listing does not carry it. So `info` is the call a cleanup cron makes before it deletes anything, to confirm the object at that path is the one its row reserved rather than a later upload that reused the path. - -```ts scripts/sweep.ts -import { BlobError } from '@upstash/blob'; -import { bucket } from '@/lib/bucket'; - -for (const row of await db.uploads.pendingOlderThan('1d')) { - try { - const info = await bucket.info(row.path); - if (info.metadata.rowid !== row.id) continue; // somebody else's object - await bucket.del(row.path); - } catch (e) { - if (!BlobError.is(e) || e.code !== 'not_found') throw e; - } - await db.uploads.markSwept(row.id); -} -``` +Metadata keys come back lowercased, since they cross the wire as `x-amz-meta-*` headers: `{ uploadedBy: 'u1' }` written at upload reads back as `metadata.uploadedby`. The rules that govern what can be written are in [Writing](/blob/bucket/writing#metadata). -The pattern that sweep belongs to, and why the row is the only thing that can tell an abandoned upload from a finished one, is in [Abandoned uploads](/blob/browser/abandoned-uploads). +`metadata` is the reason this call exists next to `exists()`. It comes back from `get` and from `info`, and from nothing else: a listing does not carry it. So `info` is the call a cleanup cron makes before it deletes anything, to confirm the object at that path is the one its row reserved rather than a later upload that reused the path. That sweep, and why a row is the only thing that can tell an abandoned upload from a finished one, is in [Abandoned uploads](/blob/browser/abandoned-uploads). --- @@ -118,7 +98,7 @@ The pattern that sweep belongs to, and why the row is the only thing that can te `exists` answers `false` instead of throwing. It is the same HEAD request as `info`, with the record thrown away. ```ts -if (await bucket.exists('avatars/u7.png')) { +if (await bucket.exists("avatars/u7.png")) { // ... } ``` @@ -138,23 +118,23 @@ Prefer `info()` whenever you are going to want the etag, the size or the metadat | `cursor` | `string` | The `cursor` from the previous page. | ```ts -const page = await bucket.list({ prefix: 'avatars/', limit: 100 }); +const page = await bucket.list({ prefix: "avatars/", limit: 100 }) -page.blobs; // BlobObject[] -page.cursor; // string | undefined +page.blobs // BlobObject[] +page.cursor // string | undefined ``` `cursor` is set only while more remains, so a full walk is a `do ... while` and never needs a separate "is there more" check: ```ts -let cursor: string | undefined; -const paths: string[] = []; +let cursor: string | undefined +const paths: string[] = [] do { - const page = await bucket.list({ prefix: 'avatars/', limit: 1000, cursor }); - for (const blob of page.blobs) paths.push(blob.path); - cursor = page.cursor; -} while (cursor); + const page = await bucket.list({ prefix: "avatars/", limit: 1000, cursor }) + for (const blob of page.blobs) paths.push(blob.path) + cursor = page.cursor +} while (cursor) ``` A listing carries `BlobObject`, which means it has the path, size, etag, timestamp and URLs, and it does not have `contentType` or `metadata`. Storage does not return those in a listing, and fetching them would be one HEAD per key. @@ -168,7 +148,7 @@ A listing carries `BlobObject`, which means it has the path, size, etag, timesta On a public bucket every record already carries `url`, and you can compute one for any path without a record: ```ts -bucket.publicUrl('avatars/u7.png'); +bucket.publicUrl("avatars/u7.png") // 'https://b0f3a91c24d.blob.upstash.io/avatars/u7.png' ``` @@ -181,8 +161,8 @@ There is no network call. The bucket's public DNS label is carried in the token It exists for the stable path. If `avatars/u7.png` is overwritten every time the user picks a new picture, the URL never changes, so every cache between your object and the reader is free to keep serving the old bytes. `versionedUrl` changes whenever the content changes, because the etag does, which turns "the URL is stale" into "the URL is different". ```tsx -const avatar = await bucket.info(`avatars/${user.id}.png`); -; +const avatar = await bucket.info(`avatars/${user.id}.png`) + ``` Pair it with `cache: 'immutable'` at upload: the bytes at any one versioned URL genuinely never change, so a year-long `max-age` is honest and the new picture is a new URL rather than a revalidation. See [Caching](/blob/bucket/caching) for the other cache options and when `'revalidate'` is the better trade. @@ -194,12 +174,12 @@ Pair it with `cache: 'immutable'` at upload: the bytes at any one versioned URL A private bucket has no public host, so a URL on one of its records would be a link that 404s. Declare it and `url` and `versionedUrl` are dropped from every record the SDK builds: ```ts -const bucket = new Bucket({ token, visibility: 'private' }); +const bucket = new Bucket({ token, visibility: "private" }) -const blob = await bucket.put('reports/2026-01.pdf', pdf); -blob.url; // undefined -blob.versionedUrl; // undefined -bucket.publicUrl('reports/2026-01.pdf'); // undefined +const blob = await bucket.put("reports/2026-01.pdf", pdf) +blob.url // undefined +blob.versionedUrl // undefined +bucket.publicUrl("reports/2026-01.pdf") // undefined ``` A `visibility` in the credentials response wins over what you declared, so a bucket that is private in the console stays private here even if the code says otherwise. Reads on a private bucket go through `signedReadUrl()`. @@ -211,10 +191,10 @@ A `visibility` in the credentials response wins over what you declared, so a buc A time-limited URL anyone can GET, for a private bucket or for an object you do not want linked from a public page. ```ts -const { url, expiresAt } = await bucket.signedReadUrl('reports/2026-01.pdf', { - expiresIn: '2m', - downloadAs: 'Report Q3.pdf', -}); +const { url, expiresAt } = await bucket.signedReadUrl("reports/2026-01.pdf", { + expiresIn: "2m", + downloadAs: "Report Q3.pdf", +}) ``` | Option | Type | | @@ -230,14 +210,14 @@ The return is `{ url, expiresAt }`. Links are signed with the bucket's short-lived credential, and a signature cannot outlive the credential that made it. So `expiresIn` is what you ask for, and `expiresAt` is what you got: it is never later than the signing credential's own expiry, and it is the value to cache the link against rather than a duration you compute yourself. ```ts -const cached = await cache.get(key); +const cached = await cache.get(key) if (!cached || cached.expiresAt < new Date()) { - const link = await bucket.signedReadUrl(path, { expiresIn: '5m' }); - await cache.set(key, link); + const link = await bucket.signedReadUrl(path, { expiresIn: "5m" }) + await cache.set(key, link) } ``` -The default with no `expiresIn` is 5 minutes, shortened if the credential has less than that left. Asking for more than the credential can cover re-mints where that helps, and is capped where it does not. [Signed URLs](/blob/overall/signing) covers the mechanism, and `signedUploadUrl` for the write direction. +The default with no `expiresIn` is 5 minutes, shortened if the credential has less than that left. Asking for more than the credential can cover re-mints where that helps, and is capped where it does not. [How signing works](/blob/overall/signing) covers the mechanism, and `signedUploadUrl` for the write direction. ### `downloadAs` @@ -246,7 +226,7 @@ The default with no `expiresIn` is 5 minutes, shortened if the credential has le The name is carried as an RFC 6266 `filename*` ext-value, percent-encoded, with an ASCII `filename` fallback cut back to characters that cannot end the quoted string. A name with a quote, a semicolon or a CRLF in it cannot add a parameter or a second header, and a Unicode name arrives intact: ```ts -await bucket.signedReadUrl(path, { downloadAs: 'café ☕.pdf' }); +await bucket.signedReadUrl(path, { downloadAs: "café ☕.pdf" }) // content-disposition: attachment; filename="caf_ _.pdf"; filename*=UTF-8''caf%C3%A9%20%E2%98%95.pdf ``` @@ -257,7 +237,7 @@ The disposition is signed into the URL along with everything else, so it cannot `contentType` overrides what storage answers with, without rewriting the object: ```ts -await bucket.signedReadUrl('exports/rows.bin', { contentType: 'text/csv' }); +await bucket.signedReadUrl("exports/rows.bin", { contentType: "text/csv" }) ``` It is validated as a media type and throws `invalid_input` if it is not one, for the same reason `downloadAs` is encoded: this value becomes a response header. @@ -269,11 +249,11 @@ It is validated as a media type and throws `invalid_input` if it is not one, for `listMultipartUploads()` is the one read that does not answer with objects. ```ts -const uploads = await bucket.listMultipartUploads({ prefix: 'uploads/' }); +const uploads = await bucket.listMultipartUploads({ prefix: "uploads/" }) // [{ path: 'uploads/big.bin', uploadId: 'mp-1', initiatedAt: Date }] ``` -A multipart upload that was started and never completed or aborted is billed storage that `list()` cannot see, and a bucket cannot be deleted while one exists. That makes this the only call that can find them. Finding them is not the job though: sweeping them is, and `abortStaleMultipartUploads()` is in [Deleting](/blob/bucket/deleting), with the reason a browser leaves one behind in [Abandoned uploads](/blob/browser/abandoned-uploads). +A multipart upload that was started and never completed or aborted is billed storage that `list()` cannot see, which makes this the only call that can find them. Finding them is not the job though: sweeping them is, and `abortStaleMultipartUploads()` is in [Deleting](/blob/bucket/deleting#incomplete-multipart-uploads). --- @@ -282,18 +262,18 @@ A multipart upload that was started and never completed or aborted is billed sto The bucket is S3-compatible, and `bucket.s3()` hands the aws-sdk what it needs for anything the SDK does not model: byte ranges, conditional GETs, delimiters and common prefixes, object tagging. ```ts -import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; -import { bucket } from '@/lib/bucket'; +import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3" +import { bucket } from "@/lib/bucket" -const { endpoint, region, bucket: name, credentials } = bucket.s3(); -const s3 = new S3Client({ endpoint, region, credentials }); +const { endpoint, region, bucket: name, credentials } = bucket.s3() +const s3 = new S3Client({ endpoint, region, credentials }) const res = await s3.send( - new GetObjectCommand({ Bucket: name, Key: 'reports/2026-01.pdf', Range: 'bytes=0-1023' }), -); + new GetObjectCommand({ Bucket: name, Key: "reports/2026-01.pdf", Range: "bytes=0-1023" }), +) ``` -`endpoint` and `credentials` are async providers rather than values. The bucket's credential is short-lived and the endpoint is only known from a credentials response, so handing over providers is what lets the aws-sdk re-read both when the current one expires, instead of holding a credential that dies a few minutes in. +`endpoint` and `credentials` are async providers rather than values, for the reason given in [Writing](/blob/bucket/writing#the-s3-escape-hatch). --- @@ -312,7 +292,7 @@ const res = await s3.send( One path, a list, a prefix, and sweeping incomplete uploads. - + How links are signed, and the upload direction. diff --git a/blob/bucket/writing.mdx b/blob/bucket/writing.mdx index 9eff788c..a8b261cd 100644 --- a/blob/bucket/writing.mdx +++ b/blob/bucket/writing.mdx @@ -13,9 +13,9 @@ If you have not installed the SDK or created a bucket yet, start at the [Quickst `Bucket.fromEnv()` reads `UPSTASH_BLOB_TOKEN`. ```ts lib/blob.ts -import { Bucket } from '@upstash/blob'; +import { Bucket } from "@upstash/blob" -export const bucket = Bucket.fromEnv(); +export const bucket = Bucket.fromEnv() ``` The constructor takes the token directly, plus three options that apply to every write this client makes. @@ -23,10 +23,10 @@ The constructor takes the token directly, plus three options that apply to every ```ts const bucket = new Bucket({ token: process.env.UPSTASH_BLOB_TOKEN!, - visibility: 'private', // drops url and versionedUrl everywhere - cache: 'immutable', // the default Cache-Control for objects this client stores + visibility: "private", // drops url and versionedUrl everywhere + cache: "immutable", // the default Cache-Control for objects this client stores enableTelemetry: false, -}); +}) ``` | Option | Type | What it does | @@ -39,15 +39,15 @@ const bucket = new Bucket({ On Cloudflare Workers there is no `process`, so the token only exists on the request's `env`. `Bucket.fromEnv()` throws there and says so; pass the token instead. ```ts src/index.ts -import { Bucket } from '@upstash/blob'; +import { Bucket } from "@upstash/blob" export default { async fetch(request: Request, env: { UPSTASH_BLOB_TOKEN: string }) { - const bucket = new Bucket({ token: env.UPSTASH_BLOB_TOKEN }); - await bucket.put('hits.txt', '1'); - return new Response('ok'); + const bucket = new Bucket({ token: env.UPSTASH_BLOB_TOKEN }) + await bucket.put("hits.txt", "1") + return new Response("ok") }, -}; +} ``` @@ -59,7 +59,7 @@ The credential cache is keyed by token, not held per instance. Constructing a `B ## put ```ts -const blob = await bucket.put('reports/q3.pdf', pdf, { contentType: 'application/pdf' }); +const blob = await bucket.put("reports/q3.pdf", pdf, { contentType: "application/pdf" }) ``` ### Options @@ -93,14 +93,14 @@ A `CompletedBlob`, which is the record a listing carries plus the type the objec | `contentType` | `string` | What the object was stored as. | ```ts -const blob = await bucket.put('avatars/7.png', file, { contentType: 'image/png' }); +const blob = await bucket.put("avatars/7.png", file, { contentType: "image/png" }) -blob.url; // https://b3f9a2c7d1e4.blob.upstash.io/avatars/7.png -blob.versionedUrl; // ...?v=%22d41d8...%22 -blob.etag; +blob.url // https://b3f9a2c7d1e4.blob.upstash.io/avatars/7.png +blob.versionedUrl // ...?v=%22d41d8...%22 +blob.etag ``` -On a private bucket `url` and `versionedUrl` are `undefined` and reads go through a signed link instead. See [Signed URLs](/blob/overall/signing). +On a private bucket `url` and `versionedUrl` are `undefined` and reads go through a signed link instead. See [How signing works](/blob/overall/signing). ### Bodies @@ -118,15 +118,15 @@ On a private bucket `url` and `versionedUrl` are `undefined` and reads go throug The default content type is `application/octet-stream`, so a string or a buffer with no `contentType` is stored as that. An explicit `contentType` always wins over what the body carries. ```ts app/api/avatar/route.ts -import { bucket } from '@/lib/blob'; +import { bucket } from "@/lib/blob" export async function POST(request: Request) { // A Request carries both, so nothing has to be declared. - const blob = await bucket.put('avatars/me.png', request, { - contentTypes: ['image/*'], - maxBytes: '5mb', - }); - return Response.json({ url: blob.url }); + const blob = await bucket.put("avatars/me.png", request, { + contentTypes: ["image/*"], + maxBytes: "5mb", + }) + return Response.json({ url: blob.url }) } ``` @@ -139,7 +139,7 @@ A `Request` with no body, or one that has already been read, throws `empty_body` Storage needs a content length before the first byte goes out, and a `ReadableStream` has no length. Without `size` or `maxBytes` there is nothing `put` can do with one, so it refuses up front: ```ts -await bucket.put('export.csv', stream); +await bucket.put("export.csv", stream) // BlobError: Length required (pass { size } or { maxBytes } so the length is known // before the first byte) -- code 'length_required', status 411 ``` @@ -149,22 +149,20 @@ There are two ways through. **Pass `maxBytes`.** The stream is read into memory up to that many bytes, which is what makes the length knowable, and a stream that runs past the cap is cancelled with `too_large`. Keep the cap somewhere your process can hold. ```ts -const blob = await bucket.put('export.csv', stream, { maxBytes: '10mb' }); +const blob = await bucket.put("export.csv", stream, { maxBytes: "10mb" }) ``` **Pass `size`.** Nothing is buffered and the bytes go straight through. ```ts -const blob = await bucket.put('export.csv', stream, { size: 5000 }); +const blob = await bucket.put("export.csv", stream, { size: 5000 }) ``` -A declared `size` that does not match what arrives is caught rather than stored wrong. Too many bytes throws `invalid_input` with `Body is longer than the declared 5000 bytes`, and too few throws `invalid_input` with `Body was 4000 bytes, 5000 were declared`. +A declared `size` is what the request is sent with: it becomes the `Content-Length`, and it is signed, so a body that does not match it fails the request rather than being stored at the wrong length. A body large enough to take the multipart path is counted as it streams, and the mismatch is named there instead: see [Large bodies](#large-bodies). The same applies to a `Request` that arrived chunked: delete or ignore its `content-length` and it is an unknown length like any other stream. - -When bytes are being proxied through a route, keep `maxBytes` under the platform's own request body cap. Vercel refuses a serverless body at 4.5 MB, AWS Lambda at 6 MB, Cloudflare at 100 MB on the free plan, and that refusal happens before your route runs. - +When bytes are being proxied through a route, keep `maxBytes` under the platform's own request body cap, since that refusal happens before your route runs. The numbers are in [Errors](/blob/bucket/errors#platform-body-limits). --- @@ -175,7 +173,7 @@ A path is any non-empty string, with `/` as structure. It is percent-encoded for `.` and `..` segments are rejected outright rather than normalised: ```ts -await bucket.put('uploads/../secrets/key.pem', body); +await bucket.put("uploads/../secrets/key.pem", body) // TypeError: path may not contain "." or ".." segments ``` @@ -186,17 +184,17 @@ Normalising would be the wrong answer here. The temporary credential the SDK sig `uniquePath` is a template tag for building a path out of values you do not control, like a filename a browser handed you. ```ts -import { uniquePath } from '@upstash/blob'; +import { uniquePath } from "@upstash/blob" -const path = uniquePath`${user.id}/${file.name}`; +const path = uniquePath`${user.id}/${file.name}` // 'u7/holiday-pic-3xK9mBqR.png' ``` The trust boundary is the interpolation. Slashes in the literal chunks are structure; slashes inside `${}` are stripped along with the rest of the directory component, so an interpolated value can never contribute a directory of its own. ```ts -uniquePath`chat/${'../admin/x.png'}`; // 'chat/x-9fQ2mAe7.png' -uniquePath`a/${'b/c'}`; // 'a/c-Kd3xR8wP' +uniquePath`chat/${"../admin/x.png"}` // 'chat/x-9fQ2mAe7.png' +uniquePath`a/${"b/c"}` // 'a/c-Kd3xR8wP' ``` Each interpolated value is reduced to its basename, stripped of control and format characters, NFC-normalized, lowercased, and slugged: runs of anything that is not a letter or a number become `-`. Letters and digits from any script survive, so `café.pdf` stays `café`. The stem is capped at 64 characters. The extension, up to 8 characters, is kept and lowercased. @@ -204,8 +202,8 @@ Each interpolated value is reduced to its basename, stripped of control and form An 8 character base58 suffix is then appended to the final basename, before the extension. The alphabet leaves out `0`, `O`, `I` and `l`, so a path read aloud or retyped stays the same path. ```ts -uniquePath`${'Q3 Report (final).pdf'}`; // 'q3-report-final-7hTbN2xY.pdf' -uniquePath`${'!!! ***'}`; // 'file-Wm4pQ8dK' +uniquePath`${"Q3 Report (final).pdf"}` // 'q3-report-final-7hTbN2xY.pdf' +uniquePath`${"!!! ***"}` // 'file-Wm4pQ8dK' ``` Use it whenever two people picking `photo.png` must not land on the same object. When overwriting is the intent, write the path yourself. @@ -217,13 +215,13 @@ Use it whenever two people picking `photo.png` must not land on the same object. `metadata` is a flat `Record` stored alongside the object as `x-amz-meta-*` headers. ```ts -await bucket.put('invoices/7.pdf', pdf, { - contentType: 'application/pdf', - metadata: { owner: 'u7', invoiceId: '2026-0042' }, -}); +await bucket.put("invoices/7.pdf", pdf, { + contentType: "application/pdf", + metadata: { owner: "u7", invoiceId: "2026-0042" }, +}) -const info = await bucket.info('invoices/7.pdf'); -info.metadata; // { owner: 'u7', invoiceid: '2026-0042' } +const info = await bucket.info("invoices/7.pdf") +info.metadata // { owner: 'u7', invoiceid: '2026-0042' } ``` Header names are case-insensitive, so keys come back lowercased. Write them lowercase and there is no surprise. @@ -231,7 +229,7 @@ Header names are case-insensitive, so keys come back lowercased. Write them lowe Keys must be valid header names, and values must be printable ASCII. Anything else is refused before the request goes out: ```ts -await bucket.put('a.txt', 'x', { metadata: { note: 'café' } }); +await bucket.put("a.txt", "x", { metadata: { note: "café" } }) // BlobError: metadata.note has characters storage does not carry back unchanged // (metadata is printable ASCII; percent-encode anything else with encodeURIComponent) // -- code 'invalid_input', status 400 @@ -240,13 +238,13 @@ await bucket.put('a.txt', 'x', { metadata: { note: 'café' } }); This is stricter than it looks, and the reason is measurable. R2 does not store a non-ASCII value verbatim: `{ note: 'café' }` comes back as `=?utf-8?Q?caf=C3=A9?=`. Accepting it would mean handing you back a different string than the one you wrote, and finding out about it on the read. Percent-encode instead, and it round trips exactly: ```ts -await bucket.put('a.txt', 'x', { metadata: { note: encodeURIComponent('café') } }); -decodeURIComponent((await bucket.info('a.txt')).metadata.note!); // 'café' +await bucket.put("a.txt", "x", { metadata: { note: encodeURIComponent("café") } }) +decodeURIComponent((await bucket.info("a.txt")).metadata.note!) // 'café' ``` - + Metadata comes back from `info()` and `get()`, but not from `list()`. A listing carries paths, sizes, etags and urls only, so a sweep that has to read metadata is one `info()` per object. See [Reading](/blob/bucket/reading). - + --- @@ -258,11 +256,11 @@ Two options turn `put` into a conditional write. Both are enforced by storage, n ```ts try { - await bucket.put('u/7/profile.json', body, { overwrite: false }); + await bucket.put("u/7/profile.json", body, { overwrite: false }) } catch (e) { - if (BlobError.is(e) && e.code === 'already_exists') { - e.etag; // the etag of the object that is already there - e.size; // and its size + if (BlobError.is(e) && e.code === "already_exists") { + e.etag // the etag of the object that is already there + e.size // and its size } } ``` @@ -270,15 +268,15 @@ try { **`ifUnchanged: etag`** sends `If-Match`. If the object changed since you read that etag, the write throws `conflict`: ```ts -const current = await bucket.info('u/7/profile.json'); -await bucket.put('u/7/profile.json', next, { ifUnchanged: current.etag }); +const current = await bucket.info("u/7/profile.json") +await bucket.put("u/7/profile.json", next, { ifUnchanged: current.etag }) // throws BlobError 'conflict' if somebody else wrote first ``` Both are single-PUT only, because a multipart upload has no conditional complete. They turn multipart off, which is why a conditional write of a large body still goes up as one request. Asking for both at once is a build-time mistake rather than a silent downgrade: ```ts -await bucket.put('big.bin', data, { multipart: true, overwrite: false }); +await bucket.put("big.bin", data, { multipart: true, overwrite: false }) // BlobError: Multipart: overwrite:false and ifUnchanged are single-PUT only // -- code 'invalid_input' ``` @@ -291,13 +289,13 @@ await bucket.put('big.bin', data, { multipart: true, overwrite: false }); ```ts interface Settings { - theme: string; + theme: string } -await bucket.updateJson('u/7.json', (prev) => ({ +await bucket.updateJson("u/7.json", (prev) => ({ ...(prev ?? {}), - theme: 'dark', -})); + theme: "dark", +})) ``` Your function is handed `null` when there is nothing to read. An object that exists but is empty also reads as `null`: there is no JSON document either way, so the callback sees the same "nothing here yet" both times. @@ -306,10 +304,10 @@ The object is written as `application/json`. Existing metadata is carried over u ```ts await bucket.updateJson( - 'u/7.json', - (prev) => ({ ...(prev ?? {}), theme: 'dark' }), - { metadata: { owner: 'u7' }, cache: 'no-store' }, -); + "u/7.json", + (prev) => ({ ...(prev ?? {}), theme: "dark" }), + { metadata: { owner: "u7" }, cache: "no-store" }, +) ``` Your function may be async, and it is re-run on every attempt, so keep it a pure transform rather than somewhere to do work with side effects. @@ -327,29 +325,17 @@ There are six attempts in total. A document that keeps changing under all six th `copy(from, to)` is a server-side copy: the bytes never travel through your app. It returns the destination's record. ```ts -const archived = await bucket.copy('tmp/9f3c', 'archive/2026/report.pdf'); -archived.size; +const archived = await bucket.copy("tmp/9f3c", "archive/2026/report.pdf") +archived.size ``` `move(from, to)` is a copy followed by a delete of the source. ```ts -const moved = await bucket.move('tmp/9f3c', 'reports/q3.pdf'); +const moved = await bucket.move("tmp/9f3c", "reports/q3.pdf") ``` - -A move is not atomic. If the copy lands and the delete fails, `move` throws `move_left_a_copy` and keeps the destination, so you have **two** objects rather than none. The original error is on `cause`. Retry the delete, or delete the source yourself, but do not treat the throw as "nothing happened". - - -```ts -try { - await bucket.move('tmp/9f3c', 'reports/q3.pdf'); -} catch (e) { - if (BlobError.is(e) && e.code === 'move_left_a_copy') { - await bucket.del('tmp/9f3c'); // the destination is already correct - } -} -``` +A move is not atomic. If the copy lands and the delete fails, `move` throws `move_left_a_copy` and keeps the destination, so you are left with two objects rather than none. Do not read that throw as "nothing happened": the recovery is to retry the source delete, and it is written out in [Deleting](/blob/bucket/deleting#move-leaves-a-copy-on-failure). Copying a path that does not exist throws `not_found`. @@ -360,7 +346,7 @@ Copying a path that does not exist throws `not_found`. A body over 16 MB, decimal, goes up as a multipart upload instead of one PUT. `multipart` moves that line: a size is the threshold to use instead (`'100mb'`), `true` always uses parts, `false` never does. ```ts -await bucket.put('video.mp4', data, { multipart: '100mb' }); +await bucket.put("video.mp4", data, { multipart: "100mb" }) ``` R2 refuses a single PUT larger than about 5 GiB, so past that there is no choice. `multipart: false` on a body that big is refused rather than attempted: @@ -372,6 +358,8 @@ R2 refuses a single PUT larger than about 5 GiB, so past that there is no choice Server-side, parts are sent one at a time. A part is buffered whole so it can be retried, and holding several would multiply that memory by the concurrency. If anything fails, the SDK aborts the whole upload before throwing, because an incomplete multipart upload is billed storage that `list()` cannot see. +This is also the path that counts the body against the `size` you declared, since it is already reading the stream part by part. Too many bytes throws `invalid_input` with `Body is longer than the declared 5000 bytes`, and too few throws `invalid_input` with `Body was 4000 bytes, 5000 were declared`. + Parts, pause, resume and per-part retry are covered in full in [Large files](/blob/browser/large-files), including the cron for upload parts a closed browser tab left behind. --- @@ -381,13 +369,13 @@ Parts, pause, resume and per-part retry are covered in full in [Large files](/bl `signedUploadUrl` produces a URL somebody else can PUT exactly one object to. It is the write-side counterpart of a signed read link, for a CLI, a build step, or a server-to-server job that has bytes you do not want to relay. ```ts -const upload = await bucket.signedUploadUrl('u/7/report.pdf', { - contentType: 'application/pdf', +const upload = await bucket.signedUploadUrl("u/7/report.pdf", { + contentType: "application/pdf", size: pdf.size, - expiresIn: '15m', -}); + expiresIn: "15m", +}) -await fetch(upload.url, { method: 'PUT', headers: upload.headers, body: pdf }); +await fetch(upload.url, { method: "PUT", headers: upload.headers, body: pdf }) ``` | Option | Type | What it does | @@ -401,13 +389,13 @@ await fetch(upload.url, { method: 'PUT', headers: upload.headers, body: pdf }); It returns `{ url, headers, expiresAt }`. - + `headers` are pinned into the signature and must be sent verbatim. Drop one, change one, or add one, and storage answers **403** rather than letting the caller choose what the object is stored as. That is also what makes `metadata` yours and not the uploader's. - + A link can never outlive the credential that signed it, so `expiresAt` is the answer rather than what you asked for. The SDK re-mints to cover a longer ask where it can, and `expiresAt` reports what actually came out. -For a browser upload, use the upload handler instead. It also handles multipart, resume, and the completion callback this cannot: a signed URL is one PUT, and nothing tells your server it happened. See [Upload handler](/blob/browser/upload-handler) and [Signed URLs](/blob/overall/signing). +For a browser upload, use the upload handler instead. It also handles multipart, resume, and the completion callback this cannot: a signed URL is one PUT, and nothing tells your server it happened. See [Upload handler](/blob/browser/upload-handler) and [How signing works](/blob/overall/signing). --- @@ -416,13 +404,13 @@ For a browser upload, use the upload handler instead. It also handles multipart, `bucket.s3()` hands back a config for `@aws-sdk/client-s3`, for the S3 operations this SDK does not wrap. ```ts -import { S3Client, ListObjectsV2Command } from '@aws-sdk/client-s3'; -import { bucket } from '@/lib/blob'; +import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3" +import { bucket } from "@/lib/blob" -const { endpoint, region, bucket: name, credentials } = bucket.s3(); -const s3 = new S3Client({ endpoint, region, credentials }); +const { endpoint, region, bucket: name, credentials } = bucket.s3() +const s3 = new S3Client({ endpoint, region, credentials }) -await s3.send(new ListObjectsV2Command({ Bucket: name, Prefix: 'reports/' })); +await s3.send(new ListObjectsV2Command({ Bucket: name, Prefix: "reports/" })) ``` `endpoint` and `credentials` are async providers rather than values. The endpoint is only known from a credentials response, and the credential itself is short-lived, so handing the aws-sdk providers is what lets it re-read a fresh one on expiry instead of failing an hour in. diff --git a/blob/formulas/overview.mdx b/blob/formulas/overview.mdx index 565abf72..53ef1b9c 100644 --- a/blob/formulas/overview.mdx +++ b/blob/formulas/overview.mdx @@ -13,12 +13,12 @@ Almost every decision in an upload feature is the same five: where the object go | Feature | Path | Cache | Constraints | Multipart | Notes | | ------- | ---- | ----- | ----------- | --------- | ----- | | Avatar | `avatars/${user.id}.png`, stable | [`'immutable'`](/blob/bucket/caching) and serve `versionedUrl`, or `'revalidate'` if you link the bare `url` | [`image/*`](/blob/browser/constraints), `maxBytes: '5mb'` | default, a 5 MB file is always one PUT | Overwriting is the intent, so there is no orphan and nothing to sweep | -| Chat or issue attachment | [`uniquePath`](/blob/browser/upload-handler) under `threads/${threadId}/` | default, `public, max-age=3600` | `maxBytes: '25mb'`, no type list | default, or `true` to skip the sweep | [Pending row plus a cron](/blob/browser/abandoned-uploads); `uploadId` is the idempotency key | +| Chat or issue attachment | [`uniquePath`](/blob/bucket/writing#uniquepath) under `threads/${threadId}/` | default, `public, max-age=3600` | `maxBytes: '25mb'`, no type list | default, or `true` to skip the sweep | [Pending row plus a cron](/blob/browser/abandoned-uploads); `uploadId` is the idempotency key | | User document library | `uniquePath` under `docs/${user.id}/` | `'immutable'`, the path is already unique | `['application/pdf']`, `maxBytes: '100mb'` | default | [`bucket.list({ prefix })`](/blob/bucket/reading) is the listing, your rows are the metadata | | Large video upload | `uniquePath` under `videos/${user.id}/` | `'immutable'` | [`video/*`](/blob/browser/constraints), `maxBytes: '5gb'` | [`multipart: true`](/blob/browser/large-files) | Only parts can pause, resume and retry; a closed tab leaves parts for `abortStaleMultipartUploads` | -| Private report or invoice | `invoices/${invoice.id}.pdf`, stable | `'no-store'` | written by your server, so [`bucket.put`](/blob/bucket/writing) rather than a route | default | `visibility: 'private'` drops `url`, and reads go through [`signedReadUrl`](/blob/overall/signing) | +| Private report or invoice | `invoices/${invoice.id}.pdf`, stable | `'no-store'` | written by your server, so [`bucket.put`](/blob/bucket/writing) rather than a route | default | `visibility: 'private'` drops `url`, and reads go through [`signedReadUrl`](/blob/bucket/reading) | -Two rules run underneath the whole table. Use `uniquePath` unless overwriting is the intent, because two uploads racing to one path lose an update and make the loser's completion fail with `not_found`. And write the row that says an upload is in flight before the bytes are, because under the multipart threshold the presigned PUT stores the object the moment the last byte lands, whether or not any callback ever accepted it. +Two rules run underneath the whole table. Use `uniquePath` unless overwriting is the intent. And write the row that says an upload is in flight before the bytes are: under the multipart threshold the object exists the moment the last byte lands, whether or not any callback ever accepted it. Both are argued out in [Abandoned uploads](/blob/browser/abandoned-uploads). --- @@ -67,13 +67,19 @@ import { uploads } from "@/lib/uploads" export const { GET, POST } = uploads ``` -```tsx components/avatar-picker.tsx +```ts lib/upload-hooks.ts "use client" import { uploadHooks } from "@upstash/blob/react" -import type { uploads } from "@/lib/uploads" +import type { uploads } from "./uploads" + +export const { useUpload } = uploadHooks() +``` + +```tsx components/avatar-picker.tsx +"use client" -const { useUpload } = uploadHooks() +import { useUpload } from "@/lib/upload-hooks" export function AvatarPicker({ src }: { src: string }) { const { start, upload, accept } = useUpload() @@ -102,7 +108,7 @@ A stable path is the one case where overwriting is the intent. There is exactly `cache: 'immutable'` on a path that gets overwritten would normally be the wrong answer, and it is safe here only because nothing links the bare `url`. `versionedUrl` is `url` with `?v=` appended, so new bytes are a new URL and the old one is never asked for again. The alternative is `'revalidate'`, which keeps one URL and pays a 304 per read. -The `try`/`catch` is not decoration. Any throw out of `onUploadComplete` deletes the object that just landed, the browser retries the completion, and the user is shown a 404 for an upload whose bytes were fine. Swallowing the database error costs a stale `avatarUrl` instead, and that is recoverable: the path is `avatars/${user.id}.png`, so `bucket.info(path)` gives the etag the URL is built from. See [Errors](/blob/bucket/errors). +The `try`/`catch` is not decoration: any throw out of `onUploadComplete` [deletes the object that just landed](/blob/browser/upload-handler#onuploadcomplete). Swallowing the database error costs a stale `avatarUrl` instead, and that is recoverable, because the path is `avatars/${user.id}.png` and `bucket.info(path)` gives the etag the URL is built from. --- @@ -192,8 +198,7 @@ export async function GET() { await sql`delete from pending_uploads where id = ${row.id}` } - // Parts from a tab that closed mid-upload: invisible to list(), billed, and they block a - // bucket delete until something aborts them. + // Parts a tab left behind over the multipart threshold, which list() cannot see. const aborted = await bucket.abortStaleMultipartUploads({ olderThan: "1d", prefix: "threads/" }) return Response.json({ swept: stale.length, aborted: aborted.length }) @@ -203,10 +208,7 @@ export async function GET() { ```tsx components/attachment-input.tsx "use client" -import { uploadHooks } from "@upstash/blob/react" -import type { uploads } from "@/lib/uploads" - -const { useUpload } = uploadHooks() +import { useUpload } from "@/lib/upload-hooks" export function AttachmentInput({ threadId }: { threadId: string }) { const { start, uploads: files } = useUpload("attachment") @@ -238,9 +240,9 @@ export function AttachmentInput({ threadId }: { threadId: string }) { ### Why this shape -`uniquePath` sanitizes what you interpolate and appends a random suffix, so two people sending `photo.png` to one thread get two objects. Without it the second upload silently replaces the first, and the first upload's completion then fails with `not_found` even though its bytes landed. +`uniquePath` sanitizes what you interpolate and appends a random suffix, so two people sending `photo.png` to one thread get two objects rather than a lost update. -The pending row is what makes a closed tab recoverable. Under the multipart threshold a direct upload is one presigned PUT, so the object exists the moment the last byte lands, and the completion request that would have recorded it is a separate call the browser may never make. Nothing on the object distinguishes that from a finished upload, so only your own rows can: write the row in `onBeforeUpload`, clear it last in `onUploadComplete`, and sweep what is still pending past the grace window. The sweep is an indexed query over your rows, not a scan of the bucket, and the row names the exact path. See [Abandoned uploads](/blob/browser/abandoned-uploads). +The pending row is what makes a closed tab recoverable: write it in `onBeforeUpload`, clear it last in `onUploadComplete`, and sweep what is still pending past a grace window. Why the row is the only thing that can tell an abandoned upload from a finished one is in [Abandoned uploads](/blob/browser/abandoned-uploads). Because the sweep exists, a database error is allowed to escape `onUploadComplete` here, unlike in the avatar formula. The throw deletes the object, the pending row survives, the cron's `not_found` branch clears it, and the user gets an error for an upload that genuinely did not land. @@ -248,4 +250,4 @@ Because the sweep exists, a database error is allowed to escape `onUploadComplet ## More formulas coming -Planned next: user document library, large video upload with pause and resume, private invoices behind signed URLs, and image processing on completion. Each will land here as a full set of files, in the same shape as the two above. +The other rows of the table above land here next, each as a full set of files. diff --git a/blob/overall/quickstart.mdx b/blob/overall/quickstart.mdx index 21360f57..13813aa6 100644 --- a/blob/overall/quickstart.mdx +++ b/blob/overall/quickstart.mdx @@ -38,7 +38,7 @@ Create a bucket in the [Upstash Console](https://console.upstash.com) and copy i UPSTASH_BLOB_TOKEN=... ``` -Everything below reads this variable: `Bucket.fromEnv()` reads it, and an `uploadHandler` with no `bucket` of its own builds one from it, once. +Everything below reads this variable, and `Bucket.fromEnv()` is the call that reads it. --- @@ -48,36 +48,23 @@ Everything below reads this variable: `Bucket.fromEnv()` reads it, and an `uploa -The handler decides who may upload, where the object goes, and what happens once it lands. It runs on your server and signs the upload. It never sees the bytes. +The handler decides who may upload, where the object goes, and what happens once it lands. It runs on your server and signs the upload. It never sees the bytes. With no `bucket` of its own it builds one from `UPSTASH_BLOB_TOKEN`, once. + +The smallest one that works states what it accepts and where the object goes: ```ts lib/uploads.ts import "server-only" -import { BlobError, uniquePath, uploadHandler } from "@upstash/blob" -import { getUser } from "./auth" -import { db } from "./db" +import { uniquePath, uploadHandler } from "@upstash/blob" export const uploads = uploadHandler({ constraints: { maxBytes: "20mb", contentTypes: ["image/*", "application/pdf"] }, - onBeforeUpload: async ({ request, file }) => { - const user = await getUser(request) - if (!user) throw new BlobError("unauthorized") // the 401, and nothing is signed - return { path: uniquePath`${user.id}/${file.name}`, metadata: { owner: user.id } } - }, - - onUploadComplete: async ({ url, metadata, uploadId }) => { - // uploadId is stable across retries, so the same completion twice writes one row - await db.files.upsert({ uploadId, owner: metadata.owner, url }) - }, + onBeforeUpload: ({ file }) => ({ path: uniquePath`uploads/${file.name}` }), }) ``` `uniquePath` sanitizes what you interpolate and adds a random suffix, so two people picking `photo.png` do not land on the same object. Sizes are decimal, so `'20mb'` is 20,000,000 bytes. The grammar behind `constraints` is covered in [Constraints](/blob/browser/constraints). - -A throw out of `onUploadComplete` deletes the object that just landed. Catch your own database errors rather than letting them escape. See [Abandoned uploads](/blob/browser/abandoned-uploads). - - @@ -98,7 +85,7 @@ export const { GET, POST } = uploads `uploadHooks()` reads the handler's type. That is how `upload.blob.data` on the client is typed from what `onUploadComplete` returned, and how a route name that does not exist fails to compile. -```ts lib/upload-client.ts +```ts lib/upload-hooks.ts "use client" import { uploadHooks } from "@upstash/blob/react" @@ -116,7 +103,7 @@ The import is `import type`, so the `server-only` module is erased and never rea ```tsx app/page.tsx "use client" -import { useUpload } from "@/lib/upload-client" +import { useUpload } from "@/lib/upload-hooks" export default function Page() { const { start, upload, accept } = useUpload() @@ -142,6 +129,36 @@ export default function Page() { + + +Uploads work at this point. What the handler above does not do is check who is asking or write anything down, and both go in the same two callbacks. `onBeforeUpload` runs before a byte is signed, so a throw there costs nothing; `onUploadComplete` runs once the object exists. + +```ts lib/uploads.ts +import "server-only" +import { BlobError, uniquePath, uploadHandler } from "@upstash/blob" +import { getUser } from "./auth" +import { db } from "./db" + +export const uploads = uploadHandler({ + constraints: { maxBytes: "20mb", contentTypes: ["image/*", "application/pdf"] }, + + onBeforeUpload: async ({ request, file }) => { + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") // the 401, and nothing is signed + return { path: uniquePath`${user.id}/${file.name}`, metadata: { owner: user.id } } + }, + + onUploadComplete: async ({ url, metadata, uploadId }) => { + // uploadId is stable across retries, so the same completion twice writes one row + await db.files.upsert({ uploadId, owner: metadata.owner, url }) + }, +}) +``` + +A throw out of `onUploadComplete` deletes the object that just landed, so catch your own database errors rather than letting them escape. [Upload handler](/blob/browser/upload-handler#onuploadcomplete) has the whole callback. + + + A file under 16 MB goes up as one presigned PUT. Anything larger is cut into parts, which is what buys pause, resume and per-part retry. See [Large files](/blob/browser/large-files). @@ -169,11 +186,18 @@ export async function saveReport(pdf: Blob) { ## CORS -The browser PUTs to storage, not to your app, so the bucket has to allow it. The presigned PUT sends `Content-Type`, `Cache-Control` and the object's `x-amz-meta-*` as real headers, and they are pinned into the signature: allow `PUT` and those headers from your origin in the bucket's CORS configuration. Add `ETag` to the exposed headers too, since a multipart upload reads each part's ETag back from the response. +A direct upload PUTs to storage, not to your app, so the bucket's CORS configuration is what decides whether the browser is allowed to send it at all. Four things have to be true, and they come straight from what the SDK signs: + +| The policy must allow | Because | +| --- | --- | +| the origin your page is served from | that is who sends the PUT | +| the `PUT` method | a presigned upload is one `PUT` per object or per part | +| the request headers `content-type`, `cache-control`, and every `x-amz-meta-*` the route writes | a single PUT carries them as real headers, pinned into the signature, so the browser must send them verbatim | +| `ETag` in the exposed response headers | the browser reads each part's etag off a cross-origin response to complete the upload | + +The metadata header names are yours: a route returning `metadata: { owner }` sends `x-amz-meta-owner`. A multipart part PUT pins only `content-length`, so it is the single-PUT path that needs the header list. Exposing `Retry-After` too is worth doing but not required; without it the client backs off on its own schedule rather than the one storage asked for. - A browser blocks a request that fails CORS before a single byte goes out, and script never sees why. The SDK gives up after three such attempts and says so, rather than backing off for minutes over an answer that will not change. - --- @@ -196,10 +220,14 @@ A browser blocks a request that fails CORS before a single byte goes out, and sc `put`, metadata, conditional writes and multipart from the server. - + Read and upload links for anything outside the browser flow. + + Whole features wired end to end: avatars, chat attachments, and the files they take. + + What storage, requests and egress cost. diff --git a/blob/overall/signing.mdx b/blob/overall/signing.mdx index c2fa4a3b..62f0a1e7 100644 --- a/blob/overall/signing.mdx +++ b/blob/overall/signing.mdx @@ -4,7 +4,7 @@ title: "How Signing Works" Your server holds a bucket token. It exchanges that token for short-lived S3 credentials from Upstash, signs individual URLs with those credentials, and hands only the URLs to the browser. The token, the credentials and the bucket password never leave your server. Every URL a browser holds is scoped to one object, one method, one set of headers, and a few minutes. -This page follows that chain from the token in your environment down to the bytes landing in storage, so you can reason about what a browser holding one of these URLs can and cannot do. If you only want to upload a file, start with the [quickstart](/blob/overall/quickstart) instead. +This page follows that chain from the token in your environment down to the bytes landing in storage, so you can reason about what a browser holding one of these URLs can and cannot do. If you only want to upload a file, start with the [Quickstart](/blob/overall/quickstart) instead. --- @@ -27,13 +27,13 @@ The three fields do different jobs. | Field | What it is for | | --- | --- | -| `bucketId` | Names the bucket. It goes into the object URL path and into every completion token, which is what stops a token minted for one bucket from being spent against another. | +| `bucketId` | Names the bucket inside your account. It is the `b` claim in every completion token, checked when one is spent, which is what stops a token minted for one bucket from being spent against another. It is also the bucket name `bucket.s3()` hands the aws-sdk. It is not in the public object URL: that is built from `hashForDomain`, and storage request paths use the `bucket` the credentials response names. | | `hashForDomain` | The bucket's public DNS label. Objects are served from `.blob.upstash.io`, so `bucket.publicUrl(path)` is pure string work with no request. On a private bucket it returns `undefined`, because nothing serves that host. | | `password` | The HMAC key for [completion tokens](#the-completion-token). It is never sent anywhere, not to Upstash and not to storage. It only ever keys a MAC inside your process. | - + The token is a bearer secret. Anything holding it can mint credentials for the whole bucket. Keep it server side, never in `NEXT_PUBLIC_`, `VITE_`, or any other variable your bundler inlines into client code. - + --- @@ -41,7 +41,7 @@ The token is a bearer secret. Anything holding it can mint credentials for the w The token is not an S3 credential. To touch storage, the SDK exchanges it: -```http credential request +```http POST https://blob.upstash.io/v1/credentials Authorization: Bearer ``` @@ -60,7 +60,7 @@ The response is the whole picture of where this bucket lives: | `visibility` | `'public'` or `'private'`. Private drops `url` and `versionedUrl` from every record. | | `signing` | Optional, and longer lived. A read-only credential used only for presigning reads. | -Because the `endpoint` decides where every subsequent request goes, the SDK refuses to take it on faith. It must parse as a URL, its protocol must be `https:`, and its hostname must end in `.r2.cloudflarestorage.com`. Anything else is `request_failed` with the message `credentials response named an unexpected endpoint`, before a single byte is signed. +Because the `endpoint` decides where every subsequent request goes, the SDK refuses to take it on faith. It must parse as a URL, its protocol must be `https:`, and its hostname must end in `.r2.cloudflarestorage.com`. Anything else is `request_failed` with the message `Credentials response named an unexpected endpoint`, before a single byte is signed. ### Caching @@ -84,7 +84,7 @@ Concurrent callers share one in-flight mint rather than racing. | 503 after the retries | `not_ready` | | anything else | `request_failed` with status 502 | -See [errors](/blob/bucket/errors) for the full code list. +See [Errors](/blob/bucket/errors) for the full code list. --- @@ -111,7 +111,7 @@ Query parameters work the same way. `response-content-disposition` on a signed r S3 wants every character outside `A-Za-z0-9-_.~` percent-encoded, including the ones `encodeURIComponent` leaves alone, and encoded as uppercase hex UTF-8 bytes. `uriEncode` does that; `encodeKey` applies it per path segment so slashes stay structural. -`encodeKey` also refuses any path containing a `.` or `..` segment outright, rather than normalising it. The reason is the trust model: a temporary credential authorizes the whole bucket, and the URL parser resolves `..` before the request is signed, so a traversing key would sign a request against a different object than the one your code named. Rejecting is the only safe answer. `uniquePath` guards the same boundary from the other side: slashes in the literal chunks of the template are structure, and slashes inside an interpolated value are stripped along with the rest of the directory component. +`encodeKey` also refuses any path containing a `.` or `..` segment outright, rather than normalising it. The reason is the trust model: a temporary credential authorizes the whole bucket, and the URL parser resolves `..` before the request is signed, so a traversing key would sign a request against a different object than the one your code named. Rejecting is the only safe answer. `uniquePath` guards the same boundary from the other side, by stripping directory components out of every interpolated value. Its rules are on [Writing](/blob/bucket/writing#uniquepath). --- @@ -134,7 +134,7 @@ Reads are signed with the `signing` credential when one is present, which is why All of which is why `signedReadUrl()` returns `expiresAt` rather than making you compute it: -```ts read link +```ts const { url, expiresAt } = await bucket.signedReadUrl('private/report.pdf'); // expiresAt is the real answer for this link: min(what you asked for, what the signer had left) ``` @@ -147,7 +147,7 @@ Cache the link until `expiresAt` and re-sign after. Do not assume five minutes. A direct browser upload is four phases against your own route. Your route is the only thing that ever sees the token or the credentials. -```text upload handshake +```text browser your route blob.upstash.io R2 | | | | | phase 'begin' | | | @@ -182,14 +182,14 @@ browser your route blob.upstash.io R2 | Phase | What your route does | What it signs | What it returns | | --- | --- | --- | --- | -| `begin` | Enforces [constraints](/blob/browser/constraints), runs `onBeforeUpload`, and for a large file creates the multipart upload | The first PUT URL, or the first batch of part URLs | `WireBeginResponse`: `completionToken`, `path`, and an upload plan carrying `partSize`, `multipart` and `parts` | +| `begin` | Enforces [Constraints](/blob/browser/constraints), runs `onBeforeUpload`, and for a large file creates the multipart upload | The first PUT URL, or the first batch of part URLs | `WireBeginResponse`: `completionToken`, `path`, and an upload plan carrying `partSize`, `multipart` and `parts` | | `parts` | Verifies the completion token, asks R2 `ListParts` for what already landed | The next batch of part URLs, 16 at a time | `WirePartsResponse`: `partSize`, `size`, `multipart`, `parts`, `landed` | | `end` | Verifies the token, completes the multipart or checks the marker, reads the object back, runs `onUploadComplete` | Nothing new | `WireEndResponse`: the blob record plus whatever `onUploadComplete` returned | | `cancel` | Verifies the token, aborts the multipart or deletes a matching single-PUT object | Nothing | `{ ok: true }` | -Said plainly: the browser never sees the bucket token and never sees an S3 credential. It sees per-object presigned URLs, the headers those URLs pin, and a completion token. Nothing it holds can list the bucket, read another object, or write to a path your `onBeforeUpload` did not choose. +The browser never sees the bucket token and never sees an S3 credential. It sees per-object presigned URLs, the headers those URLs pin, and a completion token. Nothing it holds can list the bucket, read another object, or write to a path your `onBeforeUpload` did not choose. -`GET` on the same route serves the constraints document, with an ETag and `max-age=60`, so a file picker can be filled from the same list that does the refusing. See [upload handler](/blob/browser/upload-handler) for the callbacks and [large files](/blob/browser/large-files) for the multipart path. +`GET` on the same route serves the constraints document, with an ETag and `max-age=60`, so a file picker can be filled from the same list that does the refusing. See [Upload handler](/blob/browser/upload-handler) for the callbacks and [Large files](/blob/browser/large-files) for the multipart path. --- @@ -197,15 +197,15 @@ Said plainly: the browser never sees the bucket token and never sees an S3 crede The completion token is what carries an upload's identity between phases without keeping server state. It is a base64url JSON payload and an HMAC-SHA256 over that payload, joined by a dot: -```text shape +```text . ``` The key is `upstash-blob-completion:`, so it is derived from the token you already hold and never from anything the request supplies. Comparison is timing safe. - + The token is signed, not encrypted. Anyone can open devtools, base64-decode the first half, and read the whole payload including `ctx`. Whatever `onBeforeUpload` returns as `state` must be a row id or something equally boring. Never a secret, never a signed URL, never an internal flag you would not print on the page. - + The payload: @@ -251,9 +251,7 @@ Signed, not merely sent. An unsigned header would be the browser's to choose, an For a multipart upload the same headers are pinned earlier and elsewhere: they are sent with `CreateMultipartUpload`, signed by your server with an `Authorization` header, and the object inherits them at completion. Each part URL then signs only `content-length`. Part URLs carry no headers at all in the wire response, which is why the browser sets none of ours on a part PUT. - -Because a single PUT sends `Content-Type`, `Cache-Control` and `x-amz-meta-*` as real headers on a cross-origin request, the bucket's CORS configuration has to allow those request headers from your origin, allow `PUT`, and expose `ETag` on the response so the client can read it. A PUT that fails with no status and no bytes on the wire is almost always this: the preflight failed, and the reason is never visible to script. - +Because those headers ride on a cross-origin request, the bucket's CORS policy has to allow them. The exact shape is in [CORS](/blob/overall/quickstart#cors). --- @@ -267,21 +265,13 @@ So `end` requires a marker match on the single-PUT path. No match is `not_found` The marker is deleted from the record handed to `onUploadComplete` and `onError`, but not from the stored object. Nothing on the completion path rewrites metadata. - -A marker match proves "same upload". It never proves "no callback accepted it". An object carrying the marker may be an abandoned upload or a perfectly finished one, the SDK cannot tell them apart, and both are [billed](/blob/overall/pricing) the same. Track that in your own rows: pending in `onBeforeUpload`, ready last in `onUploadComplete`, sweep the rest. See [abandoned uploads](/blob/browser/abandoned-uploads) for what that costs and the cron that fixes it. - +A marker match proves "same upload", and never "no callback accepted it". What that costs, and the pending row that closes it, is on [Abandoned uploads](/blob/browser/abandoned-uploads). --- ## Retries and 403 -A 403 from storage is ambiguous by design. An expired presigned URL and a tampered request produce the same status, and the browser cannot tell which it is looking at. So the client's `classify` treats 401 and 403 as `represign` rather than `fail`: - -| Status | Verdict | -| --- | --- | -| 0 (network), 408, 429, 500, 502, 503, 504 | `retry` with jittered backoff | -| 401, 403 | `represign`: throw the batch of URLs away and ask the route for fresh ones | -| anything else | `fail` | +A 403 from storage is ambiguous by design. An expired presigned URL and a tampered request produce the same status, and the browser cannot tell which it is looking at. So the client's `classify` treats 401 and 403 as `represign` rather than `fail`, throwing the batch of URLs away and asking the route for fresh ones. The rest of the classification, and the retry budgets, are in [Large files](/blob/browser/large-files#retries). Re-presigning forever would hide a real signature problem, so there is a clock on it. `PRESIGN_STALE_MS` is 60 seconds. A 403 on a URL minted more than a minute ago is read as the clock however often it happens, because a 5 MiB part on a slow link genuinely outruns a presign more than once. A 403 on a freshly minted URL, for a part that has already been re-presigned once, is a real `signature_mismatch` and ends the upload. So does exhausting the 8-attempt budget. @@ -293,14 +283,7 @@ Your server has the same ambiguity and resolves it by reading the body. `R2.fetc ## What the browser stores -One thing, in `localStorage`, under a key built from the route, the file name, the file size and its `lastModified`: - -```text localStorage -key upstash-blob:v1:/api/upload|holiday.png|4211|1756732800000 -value {"completionToken":"eyJ2IjoxLCJiIjoiYWM2M..."} -``` - -That is the entire record. It is a bearer capability for one upload and nothing else is worth the exposure. Notably it does not record what landed: picking the same file again sends phase `parts`, and the server asks R2 `ListParts` for the truth. A single PUT has nothing to resume, so the same file goes up again under the same token and the same path. Writing the record is best effort, since quota limits and private browsing modes both make `localStorage` throw, and a failed write just means no resume. +One thing: the completion token, in `localStorage`, under a key built from the route, the file name, the file size and its `lastModified`. Nothing else is worth the exposure, and in particular nothing about what landed is stored, since the server can ask R2 for that. [Large files](/blob/browser/large-files#resuming-after-a-reload) covers the key, the resume gesture and what happens when `localStorage` is unavailable. --- @@ -308,7 +291,7 @@ That is the entire record. It is a bearer capability for one upload and nothing The same machinery is available directly, for a CLI, a server-to-server job, or a link in an email. -```ts signed links +```ts const { url, expiresAt } = await bucket.signedReadUrl('private/report.pdf', { downloadAs: 'Q3 Report.pdf', expiresIn: '15m', @@ -325,9 +308,9 @@ await fetch(upload.url, { method: 'PUT', headers: upload.headers, body: bytes }) `signedUploadUrl` pins every header it returns into the signature: `content-type`, `cache-control`, your `x-amz-meta-*`, `content-length` when you pass `size`, and `if-none-match: *` when you pass `overwrite: false`. Send the `headers` object verbatim. Anything changed, dropped or added is a 403, not a header the client got to choose. -For an existing S3 client, `bucket.s3()` hands back the endpoint and the credentials as async providers, so the aws-sdk re-reads the short-lived credential when it expires rather than holding a snapshot that dies mid-session. +For an existing S3 client, `bucket.s3()` hands back the endpoint and the credentials as async providers rather than values. See [Writing](/blob/bucket/writing#the-s3-escape-hatch). -Full options are on [reading](/blob/bucket/reading) and [writing](/blob/bucket/writing). +Full options are on [Reading](/blob/bucket/reading) and [Writing](/blob/bucket/writing). --- From 87fc2242d26ac5df4bfdc9c7aa4b6551d6b24932 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:06:29 +0000 Subject: [PATCH 03/41] chore(llms): regenerate llms.txt and llms-full.txt --- llms-full.txt | 3841 +++++++++++++++++++++++++++++++++++++++++++++++++ llms.txt | 13 + 2 files changed, 3854 insertions(+) diff --git a/llms-full.txt b/llms-full.txt index 80aefa19..4646e658 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -861,6 +861,3847 @@ Source: https://upstash.com/docs/api-reference/vector/get-vector-stats /devops/developer-api/openapi.yaml get /vector/index/stats Get vector statistics for all the vector indices associated with the authenticated user +# Abandoned Uploads +Source: https://upstash.com/docs/blob/browser/abandoned-uploads + +A user picks a file, the upload starts, and the tab closes halfway through. Nothing else happens: no callback runs, no row is written, and your server never hears about it again. + +What that leaves in the bucket depends on which side of the multipart threshold the file was on. Over the threshold the SDK can sweep it up for you. Under the threshold it cannot, and this page is mostly about that half: why the SDK cannot tell an abandoned object from an accepted one, and the one pattern that can. + +See [Upload handler](/docs/blob/browser/upload-handler) for the callbacks, and [Large files](/docs/blob/browser/large-files) for the threshold itself. + +*** + +## The two kinds + +The default threshold is 16 MB. [Large files](/docs/blob/browser/large-files#what-changes-at-the-line) compares the two transports in full; these are the rows that decide who cleans up. + +| | Under the threshold, one PUT | Over the threshold, multipart | +| --- | --- | --- | +| When the object exists | the moment the last byte lands | when phase `end` completes the upload | +| What a dead tab leaves | a whole stored object | the parts that landed, and nothing else | +| Who cleans it up | you | `bucket.abortStaleMultipartUploads()` | + +**Over the threshold**, the object does not exist until phase `end` completes the multipart upload. A browser that dies mid-upload leaves an incomplete multipart upload: parts that are billed, that `list()` cannot see, and that stop the bucket from being deleted while they exist. The SDK can sweep those, and the last section shows the cron. + +**Under the threshold**, the presigned PUT is the object write. Storage has the whole object the moment the last byte lands, before your route has been told anything. The completion request that runs `onUploadComplete` is a separate call, made by the browser, after the PUT. A browser that dies between the two leaves an ordinary object: `list()`-visible, billed, already served by the public host if the bucket is public, and accepted by no callback of yours. + +That is what one round trip instead of three costs for the files most apps upload most often. + +*** + +## Why the SDK cannot tell the difference + +Every single-PUT upload carries a marker. The SDK mints a random id at phase `begin` and writes it into the presigned URL as `x-amz-meta-upstash-upload`, as a signed header. Signed matters: the browser has to send the header verbatim or storage answers 403, so it cannot be forged, dropped or aimed somewhere else. `metadata.upstash-upload` is reserved, and returning it from `onBeforeUpload` is refused with `invalid_input`. + +At phase `end` the route reads the object back and compares: + +```ts +if (head.metadata[UPLOAD_MARKER] !== t.id) throw new BlobError("not_found", { message: "the upload never landed" }) +``` + +That answers the one question a multipart upload answers by construction: are the bytes at this path the ones **this** upload put there? It is what lets a refusal in `onUploadComplete` delete only what this upload wrote, instead of deleting whatever happens to be standing at the path. + +What it does not answer is whether anything ever accepted the object. The marker stays on the stored object after completion. Phase `end` deletes it from the record it hands your callbacks, so `metadata` in `onUploadComplete` never contains it, but nothing rewrites the object, so `bucket.info(path).metadata['upstash-upload']` is still set on a finished upload. A marker match proves "same upload". It never proves "never accepted". + +So the SDK, looking at a bucket, cannot separate an abandoned object from a finished one. Only your own rows can. + +See [How signing works](/docs/blob/overall/signing) for how a header gets pinned into a signature. + +*** + +## The fix: one pending row + +Write the row before the bytes, flip it after them, and sweep what never flipped. + + + + It runs once per upload, before anything is signed, and it already knows the path. The browser never retries phase `begin`, so one upload is one row. + + + Everything else the callback does happens before the flip. + + + An indexed query on your own table, not a bucket scan. Each row names the exact path to check. + + + +The order in step 2 is the whole pattern. Because the marker survives on accepted objects, the sweep's only safe premise is **row still pending implies the callback never finished**. Work done after the flip breaks that premise: the row says ready, the work never happened, and nothing will ever come back for it. Do the work first, flip last. + +### The route + +```ts lib/uploads.ts +import "server-only" +import { BlobError, uniquePath, uploadHandler, uploadRoute } from "@upstash/blob" +import { sql } from "@/lib/db" + +const attachment = uploadRoute()({ + constraints: { maxBytes: "20mb", contentTypes: ["image/*", "application/pdf"] }, + + onBeforeUpload: async ({ request, file }) => { + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") + + const rowId = crypto.randomUUID() + const path = uniquePath`uploads/${user.id}/${file.name}` + await sql`insert into uploads (id, owner, path, status, created_at) + values (${rowId}, ${user.id}, ${path}, 'pending', now())` + + // metadata is written onto the object and signed into the PUT, so the cron can read it back. + // state only crosses in the completion token, so the callback can read it without a lookup. + return { path, metadata: { rowid: rowId }, state: { rowId } } + }, + + onUploadComplete: async ({ path, size, contentType, url, state }) => { + await indexForSearch(path, contentType) + await notifyOwner(state.rowId) + + // Last. Everything above has to be done before the row stops looking abandoned. + await sql`update uploads + set status = 'ready', size = ${size}, url = ${url ?? null} + where id = ${state.rowId}` + + return { rowId: state.rowId } + }, +}) + +export const uploads = uploadHandler({ routes: { attachment } }) +``` + +Metadata keys come back from storage lowercased, so `{ rowid: ... }` is written the way it will be read. The rest of the [metadata rules](/docs/blob/bucket/writing#metadata) apply here too. + +### The cron + +```ts app/api/cron/sweep-uploads/route.ts +import { BlobError, Bucket } from "@upstash/blob" +import { sql } from "@/lib/db" + +const bucket = Bucket.fromEnv() + +export const GET = async () => { + const rows = await sql`select id, path from uploads + where status = 'pending' and created_at < now() - interval '2 hours' + limit 500` + + let deleted = 0 + for (const row of rows) { + try { + const info = await bucket.info(row.path) + // info() returns metadata unstripped, so this says the object standing at the path is the + // one this row reserved, and not a later upload's that happens to share it. + if (info.metadata.rowid !== row.id) continue + await bucket.del(row.path) + deleted++ + } catch (e) { + // Nothing was ever stored: the browser died before the PUT finished. The row is the leftover. + if (!(BlobError.is(e) && e.code === "not_found")) throw e + } + await sql`delete from uploads where id = ${row.id}` + } + + return Response.json({ swept: rows.length, deleted }) +} +``` + +```json vercel.json +{ "crons": [{ "path": "/api/cron/sweep-uploads", "schedule": "0 * * * *" }] } +``` + +Two constraints come with this example. + +**Verify before deleting.** `bucket.info(path)` is the check, and it is not optional. A row's path can be occupied by a different upload's object by the time the cron runs, and deleting on the row alone would destroy a file somebody's callback accepted. `info()` throws `not_found` rather than returning `undefined`, which is why the example catches `BlobError.is(e) && e.code === 'not_found'` instead of testing for a missing value. + +**Pick a grace window longer than your longest upload.** A row is only evidence once the upload has had time to finish. Completion tokens live seven days and a paused multipart upload can be resumed long after it started, so a window measured in minutes will delete objects out from under uploads that are still running. + + + `uploadId` is not handed to `onBeforeUpload` today. It is minted after the callback returns and + first reaches your code in `onUploadComplete`, which is why the example mints its own row id and + carries it through `metadata` and `state`. `state` reaches `onUploadComplete` typed, and `metadata` + is what `info()` reads back in the cron, so the same id covers both ends. + + +*** + +## The weaker fallback + +An app that will not take a database write on the upload path can list the prefix instead and diff it against whatever rows it does have: + +```ts +const page = await bucket.list({ prefix: "uploads/", limit: 1000 }) +const known = new Set(await recordedPaths()) +const cutoff = Date.now() - 2 * 60 * 60 * 1000 + +const orphans = page.blobs.filter((b) => !known.has(b.path) && b.uploadedAt.getTime() < cutoff) +if (orphans.length) await bucket.del(orphans.map((b) => b.path)) +``` + +This is strictly weaker. `list()` returns `BlobObject`, which carries the path, size, etag and `uploadedAt` and **no metadata**, so all it can compare is keys. It cannot tell which upload wrote the object, it scans the bucket instead of an index, and it pages through every object under the prefix to find the few that do not belong. Use it when a pending row is genuinely not on the table. + +*** + +## Sweeping incomplete multipart uploads + +Over the threshold, the SDK does this half for you. Put it on a cron: + +```ts app/api/cron/abort-stale-uploads/route.ts +import { Bucket } from "@upstash/blob" + +const bucket = Bucket.fromEnv() + +export const GET = async () => { + const aborted = await bucket.abortStaleMultipartUploads({ olderThan: "1d", prefix: "uploads/" }) + return Response.json({ aborted: aborted.length, paths: aborted.map((u) => u.path) }) +} +``` + +```json vercel.json +{ "crons": [{ "path": "/api/cron/abort-stale-uploads", "schedule": "0 4 * * *" }] } +``` + +`abortStaleMultipartUploads` lists the bucket's incomplete uploads, keeps the ones started longer ago than `olderThan`, aborts each one along with every part that landed for it, and returns what it aborted, so an empty array is the log line saying there was nothing to reap. Pick an `olderThan` comfortably longer than your slowest upload, for the same reason as the pending row's grace window. + +[Deleting](/docs/blob/bucket/deleting#incomplete-multipart-uploads) has the rest: the two halves on their own, and what each field of an upload record means. **Over the threshold the SDK sweeps it. Under the threshold you do.** + +*** + +## The escape hatch + +`multipart: true` on the handler or on a route pins parts at every size, whatever the file weighs: + +```ts lib/uploads.ts +export const uploads = uploadHandler({ + multipart: true, + onBeforeUpload: ({ file }) => ({ path: uniquePath`uploads/${file.name}` }), + onUploadComplete: async ({ path }) => recordFile(path), +}) +``` + +Now nothing is stored until your handler completes the upload at phase `end`. A closed tab leaves an incomplete multipart upload, which `abortStaleMultipartUploads()` reaps, and the single-PUT orphan class disappears. Parts also buy pause, resume and per-chunk retry for files that would not have had them. + +One case survives it. If phase `end` is retried after the object already completed, `completeMultipart` throws `NoSuchUpload`, the route confirms the object landed and carries on, but `completedEtag` stays undefined. A refusal from `onUploadComplete` at that point deliberately leaves the object stored rather than delete one it cannot identify, and logs that it did. That leftover is a completed object, so `abortStaleMultipartUploads()` cannot reap it either. + +The cost is two extra server-to-storage round trips rather than extra browser requests. For an app that will not run a cron, this is one option value that removes the common case. + +*** + +## Do not let onUploadComplete throw on a database error + + + Any throw out of `onUploadComplete` runs `discard`, which deletes the object. That is the intended + behavior for a refusal, and a disaster for a transient failure: the bytes uploaded fine, your + database blinked for ten seconds, and the object is gone. The browser retries `end` on a + retryable status, the retry finds nothing at the path, and the user is shown a 404 reading + `the upload never landed`. A database blip costs the upload and then reports it as a phantom. + + +A retryable `BlobError` is not an escape either: any throw deletes the object first, so the retry it asks for arrives at an empty path. Retry the write in place, hand it to a queue, or simply leave the row pending and let the sweep decide later. Throw out of `onUploadComplete` only when you mean to refuse the file, because that throw is what deletes it. [Upload handler](/docs/blob/browser/upload-handler#onuploadcomplete) has the callback in full. + +On a public bucket the delete is also less than it looks. The object has been readable since it was stored, through the whole of your callback, so deleting bounds the exposure to those few round trips rather than undoing it, and an edge that cached the object inside the window keeps serving it for its `Cache-Control`. + +*** + +## Use unique paths unless overwriting is the intent + + + Two single-PUT uploads to the same path do not queue. The second overwrites the first, and the + first upload's `end` then fails its marker check with `not_found`, `the upload never landed`, even + though its bytes did land. The user who uploaded first sees a phantom failure, and the file they + uploaded is gone. + + +The marker is what stops a refusal from deleting somebody else's file. It does not stop a lost update, and it does not stop the spurious 404 the losing upload gets. + +`uniquePath` is the fix: a tagged template that sanitizes every interpolated value and appends a random suffix to the basename, so two uploads of the same filename never collide. Its rules are in [Writing](/docs/blob/bucket/writing#uniquepath). + +```ts +uniquePath`uploads/${user.id}/${file.name}` +// uploads/u128/holiday-pic-k9cECNWP.png +``` + +Stable paths are a legitimate choice when overwriting is exactly what you want, an avatar at `users/7/avatar.png` for instance. Then use `cache: 'revalidate'` and `versionedUrl`, and know that a concurrent upload to that path can hand the loser a 404. + +*** + +## What cancel() already handles + +An explicit cancel is covered. `upload.cancel()` in the browser posts phase `cancel` with the completion token, and the route acts on which kind of upload it is: + +* **Multipart**: the upload is aborted, along with every part that landed. +* **Single PUT**: the route reads the object at the path and deletes it only if the marker matches this upload's id. The request body names no object, so the marker is the whole check, and a cancel cannot be pointed at an object this upload did not write. + +One case is deliberately not covered. A cancel from `finishing`, once phase `end` is already running, does not post at all. The route has been asked to record the object and the answer is its to give, so racing it would ask the route to delete an object `onUploadComplete` may have just accepted and written a row for. The local task is canceled either way. + +The gap is everything that is not an explicit cancel. There is no `beforeunload` handler and no `sendBeacon` anywhere in the SDK, so a crash, a closed tab or a lost network posts nothing at all. Nothing tells your server, and nothing can. + +That is exactly the gap the pending row closes. + +# Constraints +Source: https://upstash.com/docs/blob/browser/constraints + +Constraints are the two limits an upload route enforces before it signs anything: how big a file may be, and what type it may claim to be. They are the only place a direct upload can be refused for free, because past `begin` the bytes go straight to storage and never touch your server. + +See [Upload handler](/docs/blob/browser/upload-handler) for the handler shape, its callbacks, and the hooks. + +*** + +## The two constraints + +`constraints` takes `maxBytes`, `contentTypes`, or both. Write it on the handler, on a route, or on both. + +```ts lib/uploads.ts +import { uploadHandler, uniquePath } from "@upstash/blob" + +export const uploads = uploadHandler({ + constraints: { maxBytes: "20mb", contentTypes: ["image/*", "application/pdf"] }, + onBeforeUpload: ({ file }) => ({ path: uniquePath`uploads/${file.name}` }), +}) +``` + +Both are enforced at phase `begin`, from the name, type and size the browser declared, before anything is signed and before `onBeforeUpload` runs. Nothing has been written down when a file is refused: no presigned URL exists, no row was inserted, no multipart upload was created. + +Omitting `constraints` entirely accepts any type at any size. + +*** + +## maxBytes + +`maxBytes` takes a `Size`: a number of bytes, or a string like `'20mb'`, `'500kb'`, `'5gb'`. + +Sizes are **decimal**, matching how storage is billed. `'2mb'` is 2,000,000 bytes, not 2,097,152. The units are `b`, `kb`, `mb`, `gb` and `tb`, and binary spellings are not part of the vocabulary: `'5mib'` throws. The only binary math in the SDK is multipart part sizing, because R2's part floor is 5 MiB. + +```ts +constraints: { maxBytes: "2mb" } // 2,000,000 +constraints: { maxBytes: 4096 } // a bare number is bytes +``` + +`formatBytes` is exported from `@upstash/blob`, `@upstash/blob/browser` and `@upstash/blob/react`, and it formats sizes the same decimal way they are parsed, so a refusal reads back in the units the limit was written in: + +``` +cat.png is 2.4 MB, over the 2 MB limit +``` + +An unparseable size throws a `TypeError` naming the option, where the option is written, not once per request. A typo in `maxBytes` fails at startup like any other bad option. + +*** + +## contentTypes + +`contentTypes` is a list. Each entry is either an exact `type/subtype`, or one of exactly three wildcards: `image/*`, `video/*` and `audio/*`. + +Anything else throws `invalid_content_type_pattern`. That includes `*/*`, `text/*`, and strings that are not a media type at all (`png`, `image/`, `/png`). An empty list throws too: omit the option to accept anything, rather than write a list that reads enforced and is not. + +Entries are lowercased, deduplicated, and keep the order you wrote them in. + +### What the wildcards expand to + +A wildcard is the media family, not the subset the byte sniffer happens to recognise, so `audio/*` includes `audio/mp4` rather than refusing every voice memo. + +| Wildcard | Expands to | +| --------- | ---------- | +| `image/*` | `image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/bmp`, `image/tiff`, `image/avif`, `image/heic`, `image/heif`, `image/x-icon` | +| `video/*` | `video/mp4`, `video/quicktime`, `video/webm`, `video/x-matroska`, `video/x-msvideo`, `video/mpeg`, `video/ogg`, `video/3gpp` | +| `audio/*` | `audio/mpeg`, `audio/wav`, `audio/ogg`, `audio/opus`, `audio/flac`, `audio/aac`, `audio/mp4`, `audio/webm` | + + + `image/*` deliberately does not include `image/svg+xml`. An SVG is script, so consenting to it has + to be explicit: list `'image/svg+xml'` yourself if you want it. + + +*** + +## Aliases + +Browsers and operating systems send several spellings for types that have one canonical name. Those spellings are canonicalized on both sides: on the type the browser declared, and on the list you wrote. `contentTypes: ['image/jpg']` and a file declared `image/jpeg` agree, and so do the reverse. + +| Written | Canonicalizes to | +| ------- | ---------------- | +| `image/jpg` | `image/jpeg` | +| `image/pjpeg` | `image/jpeg` | +| `image/vnd.microsoft.icon` | `image/x-icon` | +| `audio/x-wav` | `audio/wav` | +| `audio/wave` | `audio/wav` | +| `audio/vnd.wave` | `audio/wav` | +| `audio/mp3` | `audio/mpeg` | +| `audio/x-flac` | `audio/flac` | +| `audio/x-aac` | `audio/aac` | +| `video/avi` | `video/x-msvideo` | +| `video/msvideo` | `video/x-msvideo` | +| `application/x-gzip` | `application/gzip` | +| `application/x-zip-compressed` | `application/zip` | +| `application/vnd.rar` | `application/x-rar-compressed` | + +Parameters are stripped before the comparison, so `image/png; charset=binary` is `image/png`. + +*** + +## Byte sniffing + +A route with `contentTypes` gets more than the declared type. The browser slices the file's first 4100 bytes (`SNIFF_BYTES`), base64-encodes them, and sends them as `head` with phase `begin`. A mislabelled file is then refused before the upload rather than after it. + +The check runs in two steps: + +1. **The declared type against the allow list.** `report.exe` renamed to `report.png` but declared `application/x-msdownload` is refused here, with the allowed list as the hint. This step runs whether or not the bytes arrived. +2. **The bytes against the declaration, on a proven conflict only.** The leading bytes are sniffed. If they prove nothing, the file passes. If they prove something, it is only a refusal when the declared type is in a small closed set and the bytes name a different type in that set. + +That second condition is what keeps real files from being refused. Bytes that prove a container the declaration sits on top of pass: a `.docx` really is a zip, an `.epub` and a `.jar` and an `.apk` are too, and a `.svgz` really is a gzip. `application/octet-stream` is a shrug, not a claim, so bytes never contradict it. + +The closed set, the types a signature proves outright with no sibling format sharing it: + +`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/bmp`, `audio/wav`, `video/x-msvideo`, `application/pdf`, `application/zip`, `application/gzip`, `application/x-7z-compressed`, `application/x-rar-compressed`, `application/x-bzip2` + +Some formats are deliberately left unnamed by the sniffer, because their signature proves a container and not the type above it: + +| Format | Why | +| ------ | --- | +| ISO-BMFF | `ftyp` is mp4, m4a, heic, avif and quicktime alike | +| EBML | webm and mkv share it | +| Ogg | vorbis, opus and theora share it | +| TIFF | also every raw camera format | +| sfnt fonts | ttf, otf and ttc share it | +| MPEG audio | frame sync varies by version and layer | +| tar | its marker sits at offset 257, behind an attacker-controlled filename | + + + This is ergonomics, not a control. The part bodies never reach your server, so a client is free to + send an honest head and then upload something else entirely. It is not malware scanning, and it is + not a substitute for treating stored objects as untrusted. + + +*** + +## Per-route constraints + +A route's `constraints` **replace** the handler's key by key. A key the route does not mention is inherited. `null` clears a key the handler set. + +```ts lib/uploads.ts +export const uploads = uploadHandler({ + constraints: { maxBytes: "20mb", contentTypes: ["image/png"] }, + routes: { + attachment: { + onBeforeUpload: () => ({ path: "attachment/1.png" }), + }, + avatar: { + constraints: { maxBytes: "2mb" }, + onBeforeUpload: () => ({ path: "avatar/demo" }), + }, + large: { + constraints: { maxBytes: "2gb", contentTypes: null }, + onBeforeUpload: () => ({ path: "large/1.bin" }), + }, + }, +}) +``` + +| Route | `maxBytes` | `contentTypes` | +| ----- | ---------- | -------------- | +| `attachment` | 20,000,000, inherited | `['image/png']`, inherited | +| `avatar` | 2,000,000, replaced | `['image/png']`, inherited | +| `large` | 2,000,000,000, replaced | none, cleared by `null` | + +*** + +## Narrowing per user + +`onBeforeUpload` may return `constraints` to narrow the route's further, once it knows who is uploading. + +```ts lib/uploads.ts +export const uploads = uploadHandler({ + constraints: { maxBytes: "1gb", contentTypes: ["image/*", "video/*"] }, + + onBeforeUpload: async ({ request, file }) => { + const user = await getUser(request) + return { + path: uniquePath`${user.id}/${file.name}`, + constraints: user.plan === "free" ? { maxBytes: "25mb", contentTypes: ["image/*"] } : undefined, + } + }, +}) +``` + +The narrowed constraints are checked against the same file, with the same head bytes, right after `onBeforeUpload` returns. + +Widening throws a `TypeError`: `onBeforeUpload widened maxBytes` for a larger cap, `onBeforeUpload widened contentTypes` for a type the route does not already allow, naming the types that were added. The route's own limits are always the ceiling, so reading the code of a route tells you the most it can ever accept. + +*** + +## In the browser + +`GET` on the upload route serves the constraints it enforces: + +```json +{ "constraints": { "contentTypes": ["image/png"], "maxBytes": 2000000 } } +``` + +It carries an ETag and `Cache-Control: public, max-age=60`, and the hook caches it for the same 60 seconds (`CONSTRAINTS_TTL_MS`). Short and revalidated, not immutable: the constraints are your route's code and change with a deploy, and a client that cached them forever would refuse files the route now accepts. + +`useUpload` exposes two things from it. `accept` is `contentTypes` joined with commas, ready for an ``, and empty until the GET lands or when the route serves no type list. `constraints` is the served document itself, so a page can state the cap it enforces. + +```tsx components/upload-button.tsx +"use client" +import { formatBytes } from "@upstash/blob/react" +import { useUpload } from "@/lib/upload-hooks" + +export function UploadButton() { + const { start, upload, accept, constraints } = useUpload() + + return ( + <> + start({ file: e.target.files?.[0] })} /> + {constraints?.maxBytes !== undefined &&

Up to {formatBytes(constraints.maxBytes)}

} + {upload?.error &&

{upload.error.message}

} + + ) +} +``` + +A file over `maxBytes` is refused in the browser before any request is made. It still becomes a record, with `status: 'error'` and a real `BlobError` whose code is `too_large`, so one error path renders both the client-side refusal and the server's. + +The size check is the only one that runs in the browser. Type validation stays on the server, which canonicalizes aliases and sniffs the leading bytes, neither of which the served `accept` list can express. **The server is authoritative.** Constraints that have not arrived yet are not an answer either: the file is sent and the route decides. + +A route that serves no `contentTypes` has nothing to check leading bytes against, so the hook does not read them off the file and does not send them. + +*** + +## Error codes + +A refusal here is `too_large`, `content_type_not_allowed`, `invalid_content_type_pattern` or `empty_body`, and it reaches the browser as a `BlobError` with that code intact, so switch on `error.code` rather than on status numbers. See [Errors](/docs/blob/bucket/errors) for what each one means. + +*** + +## Server-side writes + +`bucket.put()` takes the same `contentTypes` and `maxBytes` options, with the same grammar, the same aliases and the same byte check, for bytes that pass through your own route. See [Writing](/docs/blob/bucket/writing). + +# Large Files +Source: https://upstash.com/docs/blob/browser/large-files + +Every upload crosses one line. Under it a file goes up as a single presigned PUT. Over it the file is cut into real multipart parts, and parts are what buy pause, resume and per-part retry. This page is about where that line sits, what changes on each side of it, and how the browser runs a parted transfer. + +*** + +## The threshold + +The default is **16 MB decimal**, 16,000,000 bytes. Sizes in the SDK are decimal everywhere, the way storage is billed, so `'16mb'` means 16,000,000 and not 16,777,216. + +The comparison is strict. A 16,000,000 byte body is a single PUT. 16,000,001 is multipart. + +The same line governs both halves of the SDK: `bucket.put()` on your server and a direct browser upload split at the same size, so a file does not behave differently depending on which door it came in through. + +Parts are not free. A single PUT is one round trip; a multipart upload is three plus one per chunk, and an upload that is begun and never finished lingers in storage until something aborts it. Parts are what a big file needs, past the single-PUT ceiling and for a chunk that can be retried or resumed on its own. They are not the right shape for every upload. + +*** + +## What changes at the line + +| | Under the threshold | Over the threshold | +| -------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------- | +| Transport | one presigned object PUT | one presigned PUT per part | +| Browser round trips | `begin`, the PUT, `end` | `begin`, one PUT per part, one `parts` call per 16 parts after the first, `end` | +| Extra server-to-storage calls | none | `createMultipart` inside `begin`, `completeMultipart` inside `end` | +| When the object exists | the moment the last byte lands | when phase `end` completes the upload | +| `canPause` | `false` | `true` while uploading | +| A failed chunk | the whole PUT is sent again | only that part is sent again | +| Ceiling | ~5 GiB (5,368,709,120 bytes) | no practical limit | +| A tab that dies mid-upload | a whole stored object no callback accepted | parts, invisible to `list()`, reaped by `abortStaleMultipartUploads()` | + +The row that matters most is when the object comes into existence. Under the threshold the presigned PUT stores the object itself, so by the time phase `end` runs there is already a real, billed, listable object at that path, and a throw out of `onUploadComplete` has to delete it again. Over the threshold nothing exists at the path until `end` calls `completeMultipart`, so an upload that never reaches `end` leaves parts rather than a file. + +That is the whole of what an abandoned upload costs on each side of the line. See [Abandoned uploads](/docs/blob/browser/abandoned-uploads) for the sweep, and for why `multipart: true` is the escape hatch an app that will not run a cron reaches for. + +Storage refuses a single PUT larger than 5,368,709,120 bytes, so past that size parts are used whatever `multipart` says. + +*** + +## Moving the line + +`multipart` takes a size, `true` or `false`. A size becomes the threshold for that handler, route or write; `true` always parts; `false` never does. + +On a handler it is the default for every route: + +```ts lib/uploads.ts +import { uploadHandler, uniquePath } from "@upstash/blob" + +export const uploads = uploadHandler({ + // Everything up to 100 MB goes up as one PUT. + multipart: "100mb", + onBeforeUpload: ({ file }) => ({ path: uniquePath`uploads/${file.name}` }), +}) +``` + +A route replaces that value rather than merging with it: + +```ts lib/uploads.ts +import { uploadHandler, uniquePath } from "@upstash/blob" + +export const uploads = uploadHandler({ + multipart: "100mb", + routes: { + avatar: { + multipart: false, + onBeforeUpload: ({ file }) => ({ path: uniquePath`avatars/${file.name}` }), + }, + video: { + multipart: true, + onBeforeUpload: ({ file }) => ({ path: uniquePath`videos/${file.name}` }), + }, + }, +}) +``` + +And `bucket.put()` takes the same option per write: + +```ts lib/videos.ts +import { Bucket } from "@upstash/blob" + +const bucket = Bucket.fromEnv() + +await bucket.put("videos/clip.mp4", file, { multipart: true }) +await bucket.put("logs/today.ndjson", stream, { size, multipart: "50mb" }) +``` + +A few edges: + +* `multipart: false` on a body over the single-PUT ceiling throws `too_large`, with the hint `multipart: false forbids the parts this body needs`. It is a refusal rather than a silent override, because the option said something the request cannot honour. +* On `bucket.put()`, `overwrite: false` and `ifUnchanged` are single-PUT only, since the conditional header rides on the object write. Passing either turns multipart off. Passing `multipart: true` together with either throws `invalid_input`. +* An unparseable size (`'100 megs'`) throws where the option is written, not per request. A handler resolves it once at construction, so a typo is a startup error and never a 500 raised after `onBeforeUpload` has already inserted your row. + + +The cost of `multipart: true` is not extra browser requests. The browser makes `begin`, the PUTs and `end` either way. It is two extra server-to-storage round trips, `createMultipart` inside `begin` and `completeMultipart` inside `end`, landing as latency inside those two calls. + + +*** + +## Part sizing + +The part size is derived from the file size, never configured: + +```text +partSize = max(5 MiB, ceil(size / 250) rounded up to a whole MiB) +partCount = ceil(size / partSize) +``` + +Two constants shape it, and part math is the only binary math in the SDK: + +* **5 MiB** is the floor storage enforces on every part but the last. It is checked when the upload is completed, so a part below it fails the whole upload at the very end. The SDK never goes under it. +* **250** is the part count the SDK aims at. Targeting a count rather than a size keeps the number of requests roughly flat as files grow, and stays two orders of magnitude below the 10,000 part ceiling. + +| File size | Part size | Parts | +| --------- | --------- | ----- | +| 20 MB | 5 MiB | 4 | +| 200 MB | 5 MiB | 39 | +| 2 GB | 8 MiB | 239 | +| 20 GB | 77 MiB | 248 | + +The last part is the short one. The part size is also the blast radius of one crashed part: on a 5 GB file, 20 MiB parts mean a failure costs at most 20 MiB of re-sent bytes. + +*** + +## The transfer + +Phase `begin` answers with a plan: `partSize`, the list of `parts` to send, and `multipart`. Part URLs are presigned in batches of 16, so a 239 part upload does not sign 239 URLs before the first byte moves. The client asks for the next batch through phase `parts` when it reaches a part it has no URL for. + +```text +begin -> route: onBeforeUpload, createMultipart, presign parts 1..16 + | + v + +--------- 4 parts in flight per upload ----------+ + | PUT part 1 PUT part 2 PUT part 3 PUT 4 | + +-------------------------------------------------+ + | every request in the page shares one cap of 6 + v +parts -> route: presign 17..32, report the parts that landed + | + v +end -> route: completeMultipart, onUploadComplete +``` + +The numbers: + +| Constant | Value | Scope | +| -------------------- | ----- | ------------------------------------------------------------ | +| `PARTS_PER_BATCH` | 16 | part URLs presigned per `parts` call | +| `PARTS_IN_FLIGHT` | 4 | parts uploading at once, per upload | +| `GLOBAL_REQUEST_CAP` | 6 | requests in flight in the whole page, files and parts alike | + +The global cap is shared on purpose. The browser does not care which of our requests a connection belongs to, so three files uploading four parts each would otherwise queue against each other inside the browser, where the SDK cannot see it. One pool means the queueing is ours and the progress numbers stay honest. + +Uploads go out over `XMLHttpRequest` rather than `fetch`, for one reason: `fetch` has no upload progress event. + +A single PUT is expressed as one part covering the whole file, with `partSize` equal to the file size. The browser runs the same loop over one URL, and only the server knows that URL is an object PUT and not a part. + +*** + +## Progress and status + +Every record carries the same fields whichever side of the line it is on: + +| Field | Meaning | +| --------- | ----------------------------------------------------------------------------- | +| `loaded` | bytes of parts that landed, plus bytes on the wire right now | +| `total` | the file's size | +| `percent` | `floor(loaded / total * 100)`, capped at 99 until the status is `done` | +| `pending` | not settled: `queued`, `uploading`, `finishing` or `paused` | +| `stalled` | every in-flight part is waiting on a backoff | + +`percent` is capped at 99 because 100 has to mean stored, not sent. The bar sits there through status `finishing`, which is the stretch where every byte has landed and phase `end` is completing the upload and running `onUploadComplete`. That can take as long as your callback does. Naming the state is the difference between a bar that is working and one that looks stuck. + +In-flight bytes are counted, and a failed part's bytes retreat rather than lying: a part that got 3 MiB out and then took a 500 drops back to zero, because those bytes were never stored. `loaded` never counts a part twice, and a landed part is banked before it leaves the in-flight map, so the bar does not dip between the two. + +```tsx app/upload.tsx +{upload.pending && } +{upload.status === "finishing" &&

Finishing up...

} +{upload.stalled &&

Connection is struggling, retrying...

} +``` + +*** + +## Pause and resume + +`canPause` answers whether `pause()` would do anything: + +| Situation | `canPause` | +| -------------------------- | ---------- | +| single PUT | `false` | +| `queued` | `false` | +| `uploading`, multipart | `true` | +| `paused` | `true` | +| `finishing` | `false` | +| `done`, `error`, `canceled`| `false` | + +A single PUT is one request that is either on the wire or not. Stopping it throws its bytes away rather than parking them, so it is not offered as a pause. While `queued` the answer is also `false`, because which of the two an upload is only becomes known when the route answers `begin`. And from `finishing` there is nothing left to hold back: every part has landed and `end` is already running. + +**Pause stops the queue, not the transfer.** Bytes on the wire are already paid for, so a part that has sent anything finishes and keeps its etag. Only a part that has sent nothing, parked on a backoff or waiting for a pool slot, is dropped and handed back to the queue. Aborting all four threw away up to a part each and snapped the bar back. + +`resume()` restarts the workers. Nothing that landed is sent again. + +```tsx app/upload.tsx +{upload.canPause && upload.status === "uploading" && ( + +)} +{upload.status === "paused" && ( + +)} +``` + +*** + +## Resuming after a reload + +A closed tab is not a canceled upload. At phase `begin` the client writes the upload's completion token to `localStorage`, keyed by a fingerprint of the route, the file name, the size and `lastModified`: + +```text +upstash-blob:v1:/api/upload|movie.mp4|4823110458|1700000000000 +``` + +**The user picking the same file again is the resume gesture.** There is no API to call. A fingerprint match makes the SDK send phase `parts` with the stored token instead of `begin`, the server asks storage for the parts that actually landed (`ListParts`), and only the missing parts are sent. The row `onBeforeUpload` inserted is not written twice, because `onBeforeUpload` never runs again. + +Three properties worth stating: + +* **Nothing about what landed is trusted from `localStorage`.** The token is the only thing kept. What landed is the server's answer, read from storage. +* **A mismatch is a fresh upload, never an error.** A different file, a different size, an expired token, a route that has forgotten the upload: any of these fall back to `begin`. +* **A single PUT has nothing to resume.** There are no parts on the other side, so the file is simply sent again under the same token and to the same path. + +It is best effort. Quota, private mode or a browser with no `localStorage` at all just means no resume, never a failed upload. + +*** + +## Retries + +Every response to a part PUT is classified: + +| Response | Verdict | +| ------------------------------------- | ------------ | +| network failure (no status) | retry | +| 408, 429, 500, 502, 503, 504 | retry | +| 401, 403 | re-presign | +| anything else | fail | + +A 401 or 403 is treated as a clock problem first, not a body problem: an expired presign looks exactly like a tampered request. The SDK drops the batch, asks the route for fresh URLs and sends the part again. Only a URL minted moments ago and refused a second time is reported as `signature_mismatch`. + +The budgets: + +| Constant | Value | Why | +| --------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------- | +| `MAX_ATTEMPTS` | 8 | attempts against a response the server actually wrote | +| `MAX_NETWORK_ATTEMPTS` | 20 | a dropped link is not a refusal: 8 attempts give up ~25s in, which a phone changing cell outlives | +| `NO_BYTES_NETWORK_ATTEMPTS` | 3 | a request that failed without putting a byte on the wire is almost always CORS, which retrying does not fix | +| `STALL_TIMEOUT_MS` | 60,000 ms | silence, not duration: a 5 MiB part on a slow link takes minutes but is never quiet for a minute | +| backoff | 500 ms to 15 s | full jitter, uniform in `[0, min(15s, 500ms * 2^attempt)]` | +| `Retry-After` | honoured to 60s | a seconds count or an HTTP date, clamped | + +The CORS case is the one worth knowing about. A browser blocks a request that fails preflight before a single byte goes out, and script never sees why. Backing off for four and a half minutes only delays the same answer, so the SDK gives up after three attempts and says so in the error's hint. If the browser reports it is offline, the larger network budget applies instead, because that does come back. + +The stall watchdog measures silence rather than total time. `xhr.timeout` is a deadline for the whole request, which a large part on a slow link outruns honestly. Sixty seconds with no upload progress event and no response is the failure, and it covers the wait for the response too, so a connection that dies after the last byte does not hang until the tab closes. + +Calls to your own route follow a smaller policy: `end` and `parts` get three attempts, so two retries, on a network failure or one of the retryable statuses above. `begin` is never retried, because it runs `onBeforeUpload`. + +When the budget runs out the record settles as `error`, carrying a `BlobError` with the code the failure earned. The codes are listed in [Errors](/docs/blob/bucket/errors). + +`retry()` on a failed record runs the same upload again from the parts that landed. The upload the route began still exists, every landed part is still landed, no new `begin` is sent, and `done` is replaced with a fresh promise, since the old one already rejected. + +```tsx app/upload.tsx +{upload.status === "error" && ( + +)} +``` + +*** + +## Cancel + +`cancel()` aborts the parts in flight and posts phase `cancel`, which aborts the multipart upload server-side so the parts stop costing storage. For a single PUT, the same call deletes the object if the bytes already landed and the upload marker proves they are this upload's. + +From status `finishing` the server call is dropped and only the local task is canceled. The route has already been asked to record the object, and the answer is its to give: a cancel that raced `end` and won would ask the route to delete an object `onUploadComplete` may have just accepted and written a row for. The worst case that way round is an object your app has no row for, rather than a row your app has no object for. + +*** + +## Large writes from the server + +`bucket.put()` crosses the same line, with the same options. Over the threshold it streams the body one part at a time: a part is buffered whole so it can be retried, and holding several would multiply that by the concurrency. + +Any failure aborts the upload rather than leaving parts behind, because nothing lists an incomplete upload and an invisible one is invisible billing. + +```ts lib/backup.ts +import { Bucket } from "@upstash/blob" + +const bucket = Bucket.fromEnv() + +export async function archive(stream: ReadableStream, size: number) { + // Pass size for a stream whose length is not otherwise known, or put() buffers + // it to find the content length. + return await bucket.put("backups/2026-01.tar", stream, { size }) +} +``` + +Without `size` and without `maxBytes`, an unknown-length stream throws `length_required`. The rest of the write API is in [Writing](/docs/blob/bucket/writing). + +*** + +## Next steps + + + + What a closed tab leaves behind on each side of the threshold, and how to sweep it. + + + + Routes, context, and the callbacks that run around a transfer. + + + + Size limits and content types, refused before a byte is signed. + + + + `put`, metadata, conditional writes and multipart from the server. + + + +# Upload Handler +Source: https://upstash.com/docs/blob/browser/upload-handler + +`uploadHandler` is one upload endpoint. The bytes go straight from the browser to storage: your server only authorizes the upload, signs it, and records what landed. The file never passes through your app, so nothing is bound by your platform's request body limit and nothing streams through your function's memory. + +Two requests reach your route for an ordinary upload. `begin` runs your authorization and hands the browser presigned URLs, and `end` records the object and runs your completion callback. The PUTs in between go to storage, not to you. A third phase, `parts`, is only asked for when an upload needs more than the first 16 part URLs, or when it resumes after a reload. + +*** + +## A minimal handler + +Four files. The handler, the route it is mounted at, the bound hooks, and the component. + +```ts lib/uploads.ts +import "server-only" +import { BlobError, uniquePath, uploadHandler } from "@upstash/blob" +import { getUser } from "./auth" +import { sql } from "./db" + +export const uploads = uploadHandler({ + constraints: { maxBytes: "20mb", contentTypes: ["image/*", "application/pdf"] }, + + onBeforeUpload: async ({ request, file }) => { + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") // the 401; nothing is signed + return { path: uniquePath`${user.id}/${file.name}`, metadata: { owner: user.id } } + }, + + onUploadComplete: async ({ uploadId, metadata, path, url }) => { + await sql`insert into files (upload_id, owner, path, url) + values (${uploadId}, ${metadata.owner}, ${path}, ${url}) + on conflict (upload_id) do nothing` + return { path } + }, +}) +``` + +```ts app/api/upload/route.ts +import { uploads } from "@/lib/uploads" + +export const { GET, POST } = uploads +``` + +`uploadHooks` binds the client to the handler's type, so route names and completion data are checked at compile time: + +```ts lib/upload-hooks.ts +"use client" +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "./uploads" + +export const { useUpload } = uploadHooks() +``` + +```tsx app/page.tsx +"use client" +import { useUpload } from "@/lib/upload-hooks" + +export function Uploader() { + const { start, upload, accept } = useUpload() + + return ( + <> + start({ file: e.target.files?.[0] })} /> + {upload?.pending && } + {upload?.status === "error" &&

{upload.error.message}

} + {upload?.status === "done" &&

Saved as {upload.blob.data.path}

} + + ) +} +``` + +The client assumes the handler is mounted at `/api/upload`. That is the only default; `endpoint` on `uploadHooks` or on `useUpload` moves it. + + + New to Upstash Blob? Start at the [Quickstart](/docs/blob/overall/quickstart). If the bytes have to pass + through your app instead, write an ordinary route that calls `bucket.put` ([Writing](/docs/blob/bucket/writing)) + and drive it with [`useServerUpload`](#useserverupload). + + +*** + +## Handler options + +```ts lib/uploads.ts +export const uploads = uploadHandler({ + bucket, + constraints: { maxBytes: "20mb", contentTypes: ["image/*"] }, + multipart: "100mb", + endpoint: "/api/upload", + context: (request) => requireUser(request), + input: schema, + onBeforeUpload, + onUploadComplete, + onError, + routes: { avatar, attachment }, +}) +``` + +| Option | Type | What it does | +| --- | --- | --- | +| `bucket` | `Bucket` | The bucket every route writes to. Defaults to `UPSTASH_BLOB_TOKEN`. | +| `constraints` | `{ contentTypes?, maxBytes? }` | What the route accepts. Served by `GET` and enforced at `begin`. | +| `multipart` | `boolean \| Size` | Where an upload stops being one PUT and starts going up in parts. 16 MB by default. | +| `endpoint` | `string` | Where the handler is mounted. Only needed to separate two handlers on one bucket. | +| `context` | `(request: Request) => TCtx` | Runs once per POST. Its value is `ctx` in every callback. | +| `input` | Standard Schema | Validates what the browser sends as `input` before `onBeforeUpload` runs. | +| `onBeforeUpload` | `(args) => { path, ... }` | Authorizes the upload and names the path. Required. | +| `onUploadComplete` | `(args) => TData` | Records the object. What it returns becomes `upload.blob.data`. | +| `onError` | `(args) => BlobError \| Response \| void` | Sees every refusal. The one place to log. | +| `routes` | `Record` | Mounts several routes at this one endpoint. | + +Everything except `routes`, `endpoint` and `context` is a **default**. A route replaces the ones it names and inherits the rest, key by key, so a handler with five routes states the shared policy once. `constraints` merges one level deeper: a route's `constraints` replaces `maxBytes` and `contentTypes` individually, and `null` clears a key the handler set. See [Constraints](/docs/blob/browser/constraints) for the grammar and what a wildcard expands to. + +`onBeforeUpload` is the one callback that has to exist. A route with none of its own, mounted in a handler with none either, is a build error naming the route. + +### The bucket + +With no `bucket` written anywhere, the handler reads `UPSTASH_BLOB_TOKEN` once and builds one bucket for every route, the way `Bucket.fromEnv()` does. Pass `bucket:` when + +* the token lives under another variable, +* the bucket needs `cache` or `visibility`, +* or you are on Cloudflare Workers, where the token only exists on the request's `env` and there is no `process.env` to read. + +```ts lib/uploads.ts +import { Bucket, uploadHandler } from "@upstash/blob" + +const bucket = new Bucket({ token: process.env.MEDIA_BLOB_TOKEN!, cache: "immutable" }) + +export const uploads = uploadHandler({ bucket, onBeforeUpload }) +``` + +Options that cannot be parsed are build errors, not 500s at request time: an unparseable `multipart` size, a route name a URL query cannot carry, an empty `routes` map, and a missing token all throw where they were written. + +*** + +## onBeforeUpload + +Runs on the **first request of an upload only**, before anything is signed and before any bytes exist. It decides whether the upload happens and where the object goes. + +```ts lib/uploads.ts +onBeforeUpload: async ({ ctx, route, request, file, input }) => { + return { path: uniquePath`${ctx.userId}/${file.name}`, metadata: { owner: ctx.userId } } +} +``` + +| Argument | Type | | +| --- | --- | --- | +| `ctx` | `TCtx` | Whatever `context` returned for this request. | +| `route` | `string` | The route this file was sent to. `''` when the handler mounts no named routes. | +| `request` | `Request` | The `begin` request, headers and cookies intact. | +| `file` | `{ name, type, size }` | What the browser declared, before a byte was sent. | +| `input` | `TInput` | The validated `input`, when the route declares a schema. | + +What it returns: + +| Field | Type | | +| --- | --- | --- | +| `path` | `string` | Required. Where the object is stored. | +| `cache` | `CacheOption` | The `Cache-Control` this object is stored with, over the bucket default. See [Caching](/docs/blob/bucket/caching). | +| `metadata` | `Record` | Signed into the upload and handed back to `onUploadComplete`. | +| `constraints` | `{ contentTypes?, maxBytes? }` | Narrows this one upload's limits. | +| `state` | `TState` | Carried to `onUploadComplete` and `onError`. Only `uploadRoute()` can carry one: on a plain-object route the return type is pinned to `state: undefined`, so returning anything else does not compile. | + +`file` is the browser's own claim, so `file.type` is the type the object is stored and served as. The type the bytes really are is checked at `begin` too, against the file's first bytes; that check is described in [Constraints](/docs/blob/browser/constraints). + +### Paths + +`path` is required, and a return without one is a `TypeError`. Paths may not contain `.` or `..` segments. + +The `uniquePath` template tag builds one that is safe to hand a browser filename: + +```ts lib/uploads.ts +import { uniquePath } from "@upstash/blob" + +uniquePath`chat/${threadId}/${file.name}` +// chat/42/holiday-pic-7Kd2mQ9x.png +``` + +Slashes in the literal chunks are structure. Everything inside `${...}` is a value, sanitized down to a slugged basename with a random suffix, so it can never contribute a directory of its own. The full rules are in [Writing](/docs/blob/bucket/writing#uniquepath). + +Without the suffix, a stable path is an overwrite: the second upload replaces the first, and a single-PUT upload that lost the race then gets 404 from its own `end` even though its bytes landed. Use a stable path only when overwriting is the intent. + +### Metadata + +`metadata` is signed into the presigned PUT, so the browser can neither add to it nor change it, and it comes back on `onUploadComplete` as `metadata`. It is stored on the object and readable later with `bucket.info(path)`. + +Values are printable ASCII and keys come back lowercased, under the same rules as a server-side write: see [Writing](/docs/blob/bucket/writing#metadata). + +`metadata["upstash-upload"]` is reserved: the SDK writes its own marker under that key to prove which upload wrote the object at a path, and setting it throws `invalid_input`. + +### Narrowing per user + +`constraints` returned here applies to this upload alone, and may only make the route stricter: + +```ts lib/uploads.ts +onBeforeUpload: async ({ ctx, file }) => ({ + path: uniquePath`${ctx.userId}/${file.name}`, + constraints: ctx.plan === "free" ? { maxBytes: "5mb" } : undefined, +}) +``` + +Widening throws a `TypeError` naming what was widened, for `maxBytes` and for a content type that is not on the route's list. That is a bug in the handler, not a refusal of the file, so it surfaces as a server error rather than a `BlobError`. + +### Refusing + +Throw. A `BlobError("unauthorized")` is the 401, and nothing is signed, no multipart is created, and no URL is handed out: + +```ts lib/uploads.ts +onBeforeUpload: async ({ request, file }) => { + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") + if (await overQuota(user)) throw new BlobError("too_large", { message: "you are out of space" }) + return { path: uniquePath`${user.id}/${file.name}` } +} +``` + +Every `BlobError` reaches the browser with its `code` intact, so a hook can switch on `error.code` instead of reading status numbers. The codes are listed in [Errors](/docs/blob/bucket/errors). + +The browser never retries `begin`: it runs your callback, and a callback that writes a row must not be run twice for one file. + +*** + +## onUploadComplete + +Runs on the last request of an upload, once the object exists. It gets the completed object flattened into its arguments, plus everything this route knew about the upload. + +```ts lib/uploads.ts +onUploadComplete: async ({ uploadId, path, url, size, contentType, metadata, state, ctx }) => { + await sql`insert into files (upload_id, owner, path, url, size, content_type) + values (${uploadId}, ${ctx.userId}, ${path}, ${url}, ${size}, ${contentType}) + on conflict (upload_id) do nothing` + return { path } +} +``` + +| Argument | Type | | +| --- | --- | --- | +| `path` | `string` | Where the object is stored. | +| `url` | `string \| undefined` | The public URL. Undefined on a private bucket; see [How signing works](/docs/blob/overall/signing). | +| `versionedUrl` | `string \| undefined` | `${url}?v=${etag}` with the etag percent-encoded, since storage returns it quoted, so it reads `?v=%22...%22`. For a stable path that gets overwritten. | +| `size` | `number` | Bytes actually stored, verified against what the browser declared. | +| `etag` | `string` | The stored object's etag. | +| `uploadedAt` | `Date` | When storage wrote it. | +| `contentType` | `string` | What the object is stored as. This is the one to record. | +| `ctx` | `TCtx` | What `context` returned for this request. | +| `route` | `string` | The route name, `''` for a sole route. | +| `request` | `Request` | The `end` request. | +| `file` | `{ name, type, size }` | What the browser declared at `begin`. The original filename survives only here. | +| `uploadId` | `string` | Identifies this upload. Stable across retries: the idempotency key. | +| `multipartUploadId` | `string \| undefined` | R2's own multipart id, for `bucket.abortMultipartUpload()`. Undefined for a single PUT. | +| `metadata` | `Record` | What `onBeforeUpload` returned, minus the SDK's marker. | +| `state` | `TState` | What `onBeforeUpload` returned as `state`. | + +What it returns is handed to the browser as `upload.blob.data`, fully typed through `uploadHooks`: + +```tsx app/page.tsx +const { upload } = useUpload() +if (upload?.status === "done") upload.blob.data.path // string, inferred from onUploadComplete +``` + + + **It is at-least-once.** `end` gets three attempts, so two retries, on a network failure and on + 408, 429, 500, 502, 503 or 504. Any other status fails outright. `uploadId` is stable across those + retries and is the key to write against: `on conflict (upload_id) do nothing`, or the equivalent + upsert for your database. + + **Any throw out of it deletes the completed object.** That is the intent for a refusal, and it is a + trap for a database error: a ten-second outage destroys bytes that uploaded fine, the browser + retries `end`, and a single-PUT upload then answers 404 reading "the upload never landed". A + retryable `BlobError` is not an escape either: the delete happens first, so the retry it asks for + arrives at an empty path. Catch your own storage errors and decide deliberately instead of letting + a driver error escape the callback. + + +```ts lib/uploads.ts +onUploadComplete: async ({ uploadId, path, url, metadata }) => { + try { + await sql`insert into files (upload_id, owner, path, url) + values (${uploadId}, ${metadata.owner}, ${path}, ${url}) + on conflict (upload_id) do nothing` + } catch (e) { + console.error("[uploads] could not record", path, e) + // A throw is a refusal: this deletes the object and the retried end answers 404. Throw when + // losing the file is better than keeping an unrecorded one; otherwise return and reconcile. + throw new BlobError("not_ready", { message: "could not record the upload, try again" }) + } + return { path } +} +``` + +Writing the row in `onBeforeUpload` as pending and flipping it to ready here, last, is the pattern that survives a browser that dies mid-upload. [Abandoned uploads](/docs/blob/browser/abandoned-uploads) covers it, and the cron that sweeps the rest. + +*** + +## onError + +Sees every refusal this endpoint produces, including the handler's own and a request for a route nobody mounted. + +```ts lib/uploads.ts +onError: ({ ctx, route, request, error, file, path, metadata, state }) => { + logger.warn({ route, path, user: ctx?.userId, error }) + if (error instanceof PaymentRequired) return new BlobError("forbidden", { message: "plan expired" }) +} +``` + +| Argument | | | +| --- | --- | --- | +| `ctx` | `TCtx \| undefined` | Undefined when `context` itself threw, or when no route matched. | +| `route` | `string` | The name from the query, even when nothing mounts it. | +| `request` | `Request` | | +| `error` | `unknown` | Whatever was thrown. | +| `file`, `path`, `metadata`, `state` | optional | As much as the request had reached before it failed. | + +Return a `BlobError` or a `Response` to answer with it. Return nothing and the answer is left alone. It is the one place to log: the callbacks themselves stay about the happy path. + +*** + +## context + +`context` runs once per POST, before the route is picked and before any body is read. Its resolved value, awaited, is `ctx` in every callback and is typed there. It does not run for `GET`, which serves a public, cacheable constraints document and reads nothing. + +```ts lib/uploads.ts +export const uploads = uploadHandler({ + context: (request) => requireUser(request), // throws BlobError('unauthorized') on a dead session + + routes: { + avatar: { + onBeforeUpload: ({ ctx, file }) => ({ path: uniquePath`avatars/${ctx.id}/${file.name}` }), + onUploadComplete: ({ ctx, url }) => db.users.update(ctx.id, { avatarUrl: url }), + }, + attachment: { + onBeforeUpload: ({ ctx, file }) => ({ path: uniquePath`files/${ctx.id}/${file.name}` }), + }, + }, +}) +``` + +Two things make it worth reaching for. `onBeforeUpload` only runs on the first request of an upload, so `context` is how an authenticated value reaches `onUploadComplete` and `onError` as well. And several routes share one auth check instead of repeating it. + +With a single route, authorizing inside `onBeforeUpload` and carrying an id in `metadata` is shorter, and `metadata` comes back on completion anyway. + +### The ordering rule + +Write `context` **above** `routes` and the callbacks that read `ctx`, or annotate its parameter. An unannotated `(request) =>` is fine in the first position. Written below `routes`, TypeScript has already typed the routes with `ctx: undefined` by the time it reads what `context` returns, and the error lands on `context` itself: `Promise is not assignable to undefined`. Annotating the parameter as `(request: Request) =>` lifts the ordering rule, because TypeScript reads an annotated function's return type before it types anything else in the object literal. + +```ts lib/uploads.ts +// Fine: context first. +uploadHandler({ context: (request) => requireUser(request), routes: { a: routeA } }) + +// Fine: annotated, so the order stops mattering. +uploadHandler({ routes: { a: routeA }, context: (request: Request) => requireUser(request) }) +``` + +For a callback written in another file, annotate the argument with `UploadContext`, which takes the handler and hands back the ctx type: + +```ts lib/callbacks.ts +import { uniquePath } from "@upstash/blob" +import type { BeforeUploadArgs, UploadCompleteArgs, UploadContext } from "@upstash/blob" +import type { uploads } from "./uploads" + +export function shared({ ctx, file }: BeforeUploadArgs) { + return { path: uniquePath`${ctx.id}/${file.name}` } +} + +export async function record({ ctx, url }: UploadCompleteArgs) { + await db.files.insert({ owner: ctx.id, url }) +} + +type Session = UploadContext +``` + +*** + +## Multiple routes + +`routes` mounts several routes at one endpoint. The name travels in the query, `?route=avatar`, and the client names it instead of spelling out a URL: + +```ts lib/uploads.ts +export const uploads = uploadHandler({ + constraints: { maxBytes: "20mb", contentTypes: ["image/*"] }, + context: (request) => requireUser(request), + + routes: { + avatar: { + constraints: { maxBytes: "2mb" }, + onBeforeUpload: ({ ctx }) => ({ path: `avatars/${ctx.id}`, cache: "revalidate" }), + }, + attachment: { + constraints: { contentTypes: null }, // clears the handler's list for this route + multipart: "50mb", + onBeforeUpload: ({ ctx, file }) => ({ path: uniquePath`files/${ctx.id}/${file.name}` }), + onUploadComplete: ({ ctx, path, size }) => db.files.insert({ owner: ctx.id, path, size }), + }, + }, +}) +``` + +```tsx app/page.tsx +const avatar = useUpload("avatar") +const attachment = useUpload("attachment") +``` + +* Route names must match `/^[A-Za-z_][\w-]*$/`, checked when the handler is built rather than per request. +* An unknown name is an ordinary 404 that never names the routes the handler does mount. It still reaches `onError`. +* A name is part of the completion token's identity, so a token minted by one route is not spendable at another. + +A handler with **no** `routes` is itself the route. It is reached with no `?route=` at all, and the bound `useUpload()` takes no argument. A name on the query is then a client bound to some other handler, and it gets a 404 rather than this route by accident. + +Two handlers on the same bucket that mount the same route names derive the same token identity. `endpoint: "/api/one"` tells them apart. + +*** + +## uploadRoute() + +A plain object cannot express two things: a Standard Schema for `input`, and a `state` typed from that route's own `onBeforeUpload`. `uploadRoute()` is the builder that can. It is curried because the ctx has to be named, and it is written outside the `routes` map: + +```ts lib/uploads.ts +import { uploadRoute, uniquePath } from "@upstash/blob" +import * as z from "zod" + +const thread = uploadRoute()({ + input: z.object({ threadId: z.string().uuid() }), + + onBeforeUpload: ({ ctx, input, file }) => ({ + path: uniquePath`chat/${input.threadId}/${file.name}`, + state: { threadId: input.threadId, name: file.name }, + }), + + onUploadComplete: ({ ctx, state, url, uploadId }) => + db.messages.insert({ uploadId, threadId: state.threadId, name: state.name, owner: ctx.id, url }), +}) + +export const uploads = uploadHandler({ + context: (request) => requireUser(request), + routes: { thread }, +}) +``` + +```tsx app/page.tsx +const { start } = useUpload("thread") +start({ file, input: { threadId } }) // input is required here, and its shape is checked +``` + +`input` is validated before `onBeforeUpload` runs, and only the parsed value reaches it. A route with **no** schema refuses any `input` the browser sends, with `invalid_input` rather than dropping it silently. Validation failures come back as `invalid_input` too, with the issues joined into one message as `path: message`, so a bad `threadId` reads `threadId: Invalid uuid`. + +`state` is for what the callback already computed and does not want to look up again. It rides in the completion token, which is signed but readable in devtools, so put a row id there, never a secret. + +Everything else on the route works as it does on a plain object: `bucket`, `constraints`, `multipart`, `onError`, and the same inheritance from the handler. + +*** + +## The client + +`uploadHooks(defaults)` binds `useUpload` to one handler. The bound hook knows the route names, so a typo does not compile, and it knows each route's `input` and completion data. + +```ts lib/upload-hooks.ts +"use client" +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "./uploads" + +export const { useUpload } = uploadHooks({ + headers: async () => ({ authorization: `Bearer ${await getToken()}` }), + concurrency: 3, + endpoint: "/api/upload", + onError: ({ file, error }) => toast.error(`${file.name}: ${error.message}`), +}) +``` + +`headers`, `concurrency`, `endpoint` and `onError` are the defaults `uploadHooks` takes. A call-site option wins over the default, except `onError`, where the configured handler runs first and the call-site one after it. + +Only the configured handler is wrapped: a throw from it is caught and logged as `[upstash-blob] uploadHooks onError threw`, and the call-site handler still runs. The call-site handler is not wrapped. A throw there escapes the store's settle loop before it reaches the step that starts the next queued upload, so the rest of the queue never starts. That handler must not throw. + +Called with no type parameter, `uploadHooks()` returns the unbound `useUpload`, which takes a URL. + +### useUpload + +```tsx app/page.tsx +const { start, uploads, upload, clear, accept, constraints } = useUpload("attachment", { + concurrency: 2, + onDone: (record) => console.log(record.blob.data), + onError: (record) => console.log(record.error.code), +}) +``` + +| | | +| --- | --- | +| `start` | Begins one upload or several. Returns the record(s). | +| `uploads` | Every record, in the order they were started. | +| `upload` | The newest record, or `null`. | +| `clear(id?)` | Removes one record, or all of them. | +| `accept` | The route's `contentTypes`, joined, for an ``. | +| `constraints` | What the route's `GET` served, its own numbers. Undefined until it answers. | + +`start({ file })` returns one record, or `null` when the file is nullish, so an empty file picker is not an error. `start({ files })` takes a `File[]` or a `FileList` and returns an array. + +```tsx app/page.tsx + start({ files: e.target.files })} +/> +``` + +### The record + +| Field | Type | | +| --- | --- | --- | +| `id` | `string` | Stable for the life of the record. The key to render lists with. | +| `file` | `File` | The file this record uploads. | +| `status` | `'queued' \| 'uploading' \| 'finishing' \| 'paused' \| 'done' \| 'canceled' \| 'error'` | | +| `loaded` | `number` | Bytes that have landed. | +| `total` | `number` | The file's size. | +| `percent` | `number` | 0 to 99 while running, 100 only once `done`. | +| `pending` | `boolean` | Not settled: queued, uploading, finishing or paused. | +| `stalled` | `boolean` | Every request in flight is waiting on a backoff. | +| `canPause` | `boolean` | Whether `pause()` would do anything. | +| `blob` | `CompletedBlob & { data }` | On `done` only. | +| `error` | `BlobError` | On `error` only. | +| `pause()` `resume()` `cancel()` `retry()` | `() => boolean` | Each answers whether it did anything. | + +`pending` is the field to drive UI off. Hand-rolling it from `status` is where the off-by-one-state bugs live: an input re-enabled during `finishing`, a progress bar still drawn under an error line. + +`percent` sits at 99 through `finishing`, because 100 has to mean stored rather than sent. [Large files](/docs/blob/browser/large-files#progress-and-status) has the rest of the progress fields. + +`blob.data` is typed from that route's `onUploadComplete`. The payload a state does not carry is declared as `undefined` rather than left out, so `upload?.blob?.url` and `upload?.error?.message` read straight off the record with no narrowing. + +`canPause` is false for a single PUT, which is every file under the route's `multipart` threshold: one request is either on the wire or not, and stopping it throws its bytes away rather than parking them. `retry()` works only from `error`, and resumes from the parts that already landed. [Large files](/docs/blob/browser/large-files) has the whole of pause, resume and multipart. + +Three files are in flight by default and the rest queue. `clear(id?)` removes records from the list; a cleared upload that is still running keeps its place in the queue and finishes, it is just no longer rendered. Unmounting the component does not cancel anything either. + +### headers + +`headers` is a function, not an object, and it is re-read for every request the SDK makes to your route: the constraints `GET`, `begin`, `parts` and `end`. A JWT that rotated between the first byte and the last still ends the upload. + +```tsx app/page.tsx +const { start } = useUpload("attachment", { + headers: async () => { + const token = await auth.getToken() // throwing here refuses the upload + return { authorization: `Bearer ${token}` } + }, +}) +``` + +A throw from it ends the upload carrying that error, with no retry and no rewording as a network fault. That is how an app refuses its own upload: a token it could not refresh, a precondition that failed. + +*** + +## The GET endpoint + +`GET` on the route serves its constraints as JSON. That is what fills `accept` and `constraints` on the hook, and it lets the hook refuse an oversized file locally, as an error record, before any request leaves the browser. The check in the browser is a courtesy: the server is authoritative and enforces the same limits at `begin`. The document, its caching and what the hook does with it are in [Constraints](/docs/blob/browser/constraints#in-the-browser). + +*** + +## Without React + +The same upload, with no hooks: + +```ts app/uploader.ts +import { upload } from "@upstash/blob/browser" + +const task = upload(file, { + route: "/api/upload?route=attachment", + headers: async () => ({ authorization: `Bearer ${await getToken()}` }), + input: { threadId }, +}) + +const stop = task.subscribe(() => { + const { status, percent, stalled } = task.snapshot() + render(status, percent, stalled) +}) + +const blob = await task.done // CompletedBlob & { data } +stop() +``` + +`upload()` starts immediately and returns an `UploadTask`: `snapshot()` for the current state, `subscribe()` for changes, `done` as a promise, and `pause()`, `resume()`, `cancel()` and `retry()`. The snapshot carries the same fields the React record does, since the record is that snapshot plus `id`, `file` and the four methods. + +*** + +## useServerUpload + +For bytes that must pass through your app, do not use an upload handler. Write an ordinary route that calls `bucket.put`: + +```ts app/api/avatar/route.ts +import { Bucket } from "@upstash/blob" + +const bucket = Bucket.fromEnv() + +export async function POST(request: Request) { + const file = (await request.formData()).get("file") + if (!(file instanceof File)) return Response.json({ error: "file field required" }, { status: 400 }) + + const blob = await bucket.put(`avatars/${userId}`, file, { contentTypes: ["image/png"], maxBytes: "2mb" }) + return Response.json({ url: blob.versionedUrl }) +} +``` + +`useServerUpload` drives it as one POST, with upload progress, cancellation and `BlobError` decoding, and hands the route's JSON back exactly as it arrived: + +```tsx app/avatar.tsx +"use client" +import { useServerUpload } from "@upstash/blob/react" + +const { start, upload } = useServerUpload<{ url: string }>("/api/avatar", { field: "file" }) + +start({ file }) +upload?.percent +upload?.status === "done" && upload.response.url // typed from the generic +``` + +Its options are `headers`, `concurrency` and `field`, the multipart field name `start({ file })` sends the file under, `'file'` by default and it has to match what your route reads. `start({ body })` sends a `File`, `Blob` or `FormData` as the raw body instead. The record has `cancel()` only, and statuses `queued`, `uploading`, `finishing`, `done`, `canceled` and `error`: there is no `begin` or `end` to pause between. + +A proxied upload is capped by your platform's request body limit rather than by `maxBytes`, and the refusal happens before your route runs. The SDK surfaces it as `too_large` with the platform numbers as the hint; they are listed in [Errors](/docs/blob/bucket/errors#platform-body-limits). Anything larger belongs on the direct path above. + +*** + +## CORS + +The signed PUT is a cross-origin request from your page to storage, so the bucket's CORS policy has to allow it: the required shape is in [CORS](/docs/blob/overall/quickstart#cors). A PUT that fails with no status and no bytes sent is almost always this, and the SDK says so after three attempts rather than backing off for minutes. + +# Caching +Source: https://upstash.com/docs/blob/bucket/caching + +The `Cache-Control` an object is served with is written once, at upload. It is stored with the object and handed back by the CDN and the browser on every read, so changing it later means writing the object again. There is no per-request override: a read cannot ask for a different `Cache-Control` than the one the object carries. + +That is the whole shape of the feature. Everything below is about choosing the right value at write time, and about the one pattern that makes a stable path and a long cache life work together. + +*** + +## The `cache` option + +`cache` takes a `CacheOption`: one of three words, a duration, or a `Cache-Control` header written out. + +| Value | Stored header | +| ----- | ------------- | +| `'immutable'` | `public, max-age=31536000, immutable` | +| `'revalidate'` | `public, max-age=0, must-revalidate` | +| `'no-store'` | `no-store` | +| a duration (`'15m'`, `3600`) | `public, max-age=` | +| unset | `public, max-age=3600` | +| anything containing `=` or `,` | stored exactly as written | + +### The duration grammar + +A bare number is **seconds**, the unit every TTL option on the web already uses. A string takes a unit: `ms`, `s`, `m`, `h`, `d`, and their long forms (`sec`, `second`, `seconds`, `min`, `minute`, `minutes`, `hr`, `hour`, `hours`, `day`, `days`). A string with no unit at all is read as seconds too. + +```ts +cache: "1h" // public, max-age=3600 +cache: 3600 // public, max-age=3600 +cache: "15 min" // public, max-age=900 +cache: "7d" // public, max-age=604800 +``` + +Durations are converted to whole seconds, so `'1500ms'` stores `max-age=1`. An unparseable duration throws a `TypeError` naming the option. + +### The raw header + +Anything containing `=` or `,` is a header, stored exactly as written, trimmed. A directive separator is what tells the two apart: a header always has one, a duration never does. + +```ts +cache: "public, max-age=60, s-maxage=31536000" +cache: "max-age=0, stale-while-revalidate=86400" +``` + +That is the escape hatch, and it is why `cache` is three words and a duration rather than an object of flags. `s-maxage`, `stale-while-revalidate`, `no-transform` and whatever the spec adds next are all sayable without the option growing a camelCase word for each of them. + +*** + +## `revalidate` versus a short max-age + +`'revalidate'` stores `public, max-age=0, must-revalidate`, and it is the answer for a stable path that gets overwritten. The copy is kept and checked with `If-None-Match`, so an unchanged object costs a 304 with no body instead of the whole object once every max-age. + +A short max-age is the wrong tool there. It is stale until it expires, and then it re-downloads: + +| | `cache: 'revalidate'` | `cache: '60s'` | +| --- | --- | --- | +| Unchanged object | 304, no body | full object, once a minute | +| Object just overwritten | next read sees it | up to 60 s of the old bytes | + +`'revalidate'` costs a round trip per read. It never costs the bytes twice, and it is never stale. + +*** + +## Where you can set it + +Four places, innermost wins. A per-call `cache` overrides the bucket default. + +### On the bucket + +The default for every object this bucket stores. + +```ts lib/blob.ts +import { Bucket } from "@upstash/blob" + +export const bucket = new Bucket({ + token: process.env.UPSTASH_BLOB_TOKEN!, + cache: "immutable", +}) +``` + +### On a put + +```ts +await bucket.put("avatars/7.png", file, { + contentType: "image/png", + cache: "revalidate", +}) +``` + +`updateJson` takes it too, for the object it rewrites. See [Writing](/docs/blob/bucket/writing) for the rest of `put`. + +### On a signed upload URL + +The `Cache-Control` is pinned into the signature and handed back in `headers`, so the uploader has to send it verbatim. + +```ts +const upload = await bucket.signedUploadUrl("u/7/report.pdf", { + contentType: "application/pdf", + cache: "immutable", +}) + +await fetch(upload.url, { method: "PUT", headers: upload.headers, body }) +``` + +### On a direct browser upload + +`onBeforeUpload` returns the path, and may return the `cache` alongside it. It is decided per upload, on your server, and signed into the presigned PUT. + +```ts lib/uploads.ts +import { uniquePath, uploadHandler } from "@upstash/blob" + +export const uploads = uploadHandler({ + onBeforeUpload: ({ file }) => ({ + path: uniquePath`uploads/${file.name}`, + cache: "immutable", + }), +}) +``` + +See [Upload handler](/docs/blob/browser/upload-handler) for the rest of the callback. + +*** + +## Private buckets + +On a private bucket, `private` replaces `public` in the stored directive. A shared cache must not keep a copy of an object only a signed request may read, and `public` on such an object invites every shared cache between storage and the reader to keep one and hand it to the next reader. + +| `cache` | Public bucket | Private bucket | +| ------- | ------------- | -------------- | +| unset | `public, max-age=3600` | `private, max-age=3600` | +| `'1m'` | `public, max-age=60` | `private, max-age=60` | +| `'immutable'` | `public, max-age=31536000, immutable` | `private, max-age=31536000, immutable` | +| `'revalidate'` | `public, max-age=0, must-revalidate` | `private, max-age=0, must-revalidate` | +| `'no-store'` | `no-store` | `no-store` | + +This follows the bucket's real visibility, taken from the credentials response, not just what you declared in `new Bucket({ visibility })`. A bucket that never declared anything still stores the right directive. + +A raw header string is passed through as written, so `cache: 'public, max-age=60'` on a private bucket stores `public, max-age=60`. Once you write the header out, the visibility is yours to state too. + +Reads on a private bucket go through `signedReadUrl()`. See [Reading](/docs/blob/bucket/reading). + +*** + +## Immutable plus a versioned URL + +This is the pattern worth learning, because it is the one that gets a year of caching out of a path that changes. + +Every record carries `versionedUrl`, which is `${url}?v=${etag}` with the etag percent-encoded, since storage returns it quoted: the query reads `?v=%22...%22`. The etag changes whenever the content does, so the URL changes whenever the content does. A stable path stored with `cache: 'immutable'` and served through `versionedUrl` is cached for a year by URL, and an overwrite mints a new URL that no cache has ever seen. + +```ts app/api/avatar/route.ts +const blob = await bucket.put(`avatars/${user.id}.png`, file, { + contentType: "image/png", + cache: "immutable", +}) + +await db.users.update(user.id, { avatar: blob.versionedUrl }) +``` + +```tsx + +``` + +The path never moves, so nothing has to be deleted and the old object is not left behind. The URL in your row moves on every write, so the next render points at bytes no cache holds. + + + `url` and `versionedUrl` are both undefined on a private bucket, since no public host serves it. + + +### The three shapes + +| | Path | `cache` | Invalidation | +| --- | --- | --- | --- | +| Content-addressed or unique path | new path per upload (`uniquePath`) | `'immutable'` | none needed, the path never repeats | +| Stable path, versioned URL | stable | `'immutable'` | `versionedUrl` changes with the etag | +| Stable path, plain URL | stable | `'revalidate'` | a 304 per read | + +A unique path per upload is the simplest of the three: nothing ever overwrites anything, so `immutable` is unconditionally correct and there is no URL to update. The cost is that old objects accumulate and are yours to delete. + +`'revalidate'` is the fallback for a stable URL you do not control, such as one already printed somewhere or served to a client that will not carry a query string. + +### Choosing + +| Object | Use | +| ------ | --- | +| Content-addressed, or a `uniquePath` per upload | `cache: 'immutable'` | +| Stable path that changes rarely | `cache: 'immutable'` plus `versionedUrl` | +| User-editable, read through a fixed URL | `cache: 'revalidate'` | +| Private or sensitive | `cache: 'no-store'`, or a short duration on a private bucket | + +*** + +## `no-store` and signed reads + +For anything served through `signedReadUrl()`, two separate mechanisms are in play and both matter. + +The link expires. `signedReadUrl()` defaults to 5 minutes and is capped by the credential that signed it, so `expiresAt` on the result is the answer per link rather than a number you assume. + +```ts +const { url, expiresAt } = await bucket.signedReadUrl("private/report.pdf") +``` + +The stored `Cache-Control` is a different thing entirely, and it outlives the link. A long max-age on a private object still lets the requester's own browser keep the bytes after the link stops working, because the browser is caching a response it was allowed to fetch. If a reader must not keep the bytes, say so on the object: + +```ts +await bucket.put("private/report.pdf", body, { + contentType: "application/pdf", + cache: "no-store", +}) +``` + +`no-store` is the one value that drops the visibility scope entirely: it stores `no-store` on a public and a private bucket alike, because nothing is to be kept either way. + +See [How signing works](/docs/blob/overall/signing) for how link lifetimes are capped. + +*** + +## What the upload route itself caches + +An upload route's `GET` serves its constraints with a short, revalidated `Cache-Control` of its own, unrelated to the objects the route stores. That document and its caching are covered in [Constraints](/docs/blob/browser/constraints#in-the-browser). + +# Deleting +Source: https://upstash.com/docs/blob/bucket/deleting + +`bucket.del()` is the only delete verb, and it takes three shapes: one path, an array of paths, or a prefix. All three resolve to `Promise`, and all three treat "already gone" as success. What differs is how many requests they make and what they throw when storage refuses part of the work. + +This page also covers the deletes that are not `del()`: the copy `move` leaves behind when its delete fails, the incomplete multipart uploads that `list()` cannot see, and the objects the upload handler removes on your behalf. + +*** + +## The three shapes + +```ts +import { Bucket } from "@upstash/blob" + +const bucket = Bucket.fromEnv() + +await bucket.del("avatars/me.png") // one path +await bucket.del(["a.png", "b.png", "c.png"]) // an array of paths +await bucket.del({ prefix: "tmp/" }) // everything under a prefix +``` + +The type is `DeleteTarget`: + +```ts +type DeleteTarget = string | string[] | { prefix: string; all?: boolean } +``` + +Anything else is refused with `invalid_input`: `Del: expected a path, an array of paths, or { prefix }`. There is no `del()` with no argument. + +*** + +## Deleting one path + +One `DELETE` request. A 404 counts as success, so deleting something that is not there does not throw: + +```ts +await bucket.del("drafts/9f3c.txt") +await bucket.del("drafts/9f3c.txt") // fine, still no throw +``` + +That is what makes a delete safe to run from a retried job or a queue consumer with at-least-once delivery. Any other failure is a real error: a 403 surfaces as `signature_mismatch`, a 429 as `rate_limited`, a 503 as `not_ready`, and any other 5xx as `request_failed` carrying status 502. See [Errors](/docs/blob/bucket/errors#what-storage-errors-map-to). + + +`del` never tells you whether anything was there. If you need to know, ask first with `bucket.exists(path)`, which answers `false` instead of throwing. See [Reading](/docs/blob/bucket/reading). + + +*** + +## Deleting an array + +An array is sent as S3 batch deletes, in chunks of 1000 paths. A 5000-path array is five `POST` requests, run one after another, not 5000 round trips. + +Two details are worth knowing before you read the error handling. + +First, every path in a chunk is validated before that chunk's request goes out, so a bad path fails the chunk it is in rather than being silently skipped. Chunks before it have already run. + +Second, and this is the one that shapes the API: S3 answers a batch delete with **200 and per-key errors inside the body**. A key that failed is reported in an `` block in an otherwise successful response. The SDK does not take that list at face value: for each key S3 named it makes one more `exists()` call and keeps only the paths that are still there. + +A survivor is the truth and the list is not. An error block for an object that is in fact gone would otherwise be reported to you as a failure you cannot act on, and the whole point of `failed` is that you can act on it. + +If anything survives, `del` throws `partial_delete`, status 500, whose `failed` array names exactly which paths are still there: + +```ts +import { BlobError } from "@upstash/blob" + +try { + await bucket.del(paths) +} catch (e) { + if (BlobError.is(e) && e.code === "partial_delete") { + // e.failed is string[]: the paths that are still in the bucket, verified one by one + console.error(`${e.failed?.length} objects survived`, e.failed) + await requeue(e.failed ?? []) + return + } + throw e +} +``` + +Everything not in `failed` was deleted. `partial_delete` is a report, not a rollback: retrying with `e.failed` is the whole recovery, and it is safe because a delete of something already gone is success. + + +Use `BlobError.is(e)`, never `instanceof`. An ESM copy and a CJS copy of the class are two different classes. See [Errors](/docs/blob/bucket/errors). + + +A batch delete is a `POST`, and the SDK only retries idempotent verbs, so a failure on a batch surfaces on the first try rather than being sent twice: a 503 as `not_ready`, any other 5xx as `request_failed` at status 502. + +*** + +## Deleting by prefix + +`del({ prefix })` pages through `list()` at 1000 objects per page and batch-deletes each page as it goes: + +```ts +await bucket.del({ prefix: "users/7/tmp/" }) +``` + +So the cost scales with the number of objects under the prefix, not with the one call you wrote. A prefix over 100,000 objects is 100 list requests and 100 batch deletes, run sequentially. It is not atomic: objects written under the prefix while it runs may or may not be caught, depending on which page they land on. + +Failures work exactly as they do for an array. Survivors from every page are collected, and if any remain the call throws `partial_delete` with them in `failed`. + + +`del({ prefix: '' })` matches every object in the bucket, so an empty prefix from an unset variable or an empty form field would wipe the bucket. It is refused with `invalid_input` before a single request is sent, and the hint tells you the deliberate form: `pass { prefix: '', all: true } if that is what you mean`. + + +```ts +// Refused: invalid_input, status 400, nothing sent +await bucket.del({ prefix: userFolder }) + +// Deliberate: this is how you say "yes, the whole bucket" +await bucket.del({ prefix: "", all: true }) +``` + +`all` is only consulted for the empty prefix. `del({ prefix: 'tmp/' })` needs nothing extra. + +*** + +## Paths are validated, never normalized + +Every path reaching storage goes through `encodeKey`, which percent-encodes each segment and refuses outright any path containing a `.` or `..` segment: + +```ts +await bucket.del("users/7/../8/private.pdf") +// TypeError: path may not contain "." or ".." segments: users/7/../8/private.pdf +``` + +The reason is the trust model rather than tidiness. Your server holds a temporary credential that authorizes the whole bucket, and the URL parser resolves `..` before the request is signed. A traversing key would sign a delete against a different object than the one your code named, and the credential would happily allow it. Normalizing the path would hide that; rejecting it does not. + +This applies to `del` in all three shapes, and to `put`, `copy`, `move`, `signedUploadUrl` and `abortMultipartUpload` alike. If you build paths from user input, build them with `uniquePath`, which strips directory components out of every interpolated value. See [Writing](/docs/blob/bucket/writing). + +*** + +## `move` leaves a copy on failure + +`move(from, to)` is not a primitive. It is a copy followed by a delete: + +```ts +const blob = await bucket.move("tmp/9f3c", "avatars/7.png") +``` + +If the copy fails, nothing has changed and you get the copy's error. If the copy succeeds and the delete fails, the SDK throws `move_left_a_copy`, status 500, and **keeps the destination**. You are left with two objects rather than zero, which is the failure mode that loses no data: + +```ts +import { BlobError } from "@upstash/blob" + +try { + await bucket.move("tmp/9f3c", "avatars/7.png") +} catch (e) { + if (BlobError.is(e) && e.code === "move_left_a_copy") { + // avatars/7.png exists and is correct. tmp/9f3c is also still there. + // The recovery is to retry the source delete, not the move. + await bucket.del("tmp/9f3c") + return + } + throw e +} +``` + +The original error is attached as `cause`, so you can see whether the delete was refused, rate limited, or something else. + +*** + +## Incomplete multipart uploads + +These are the deletes people forget, because nothing in the ordinary API shows them. + +A multipart upload is created, parts land against it, and it becomes an object only when it is completed. Between those two moments the parts are real, billed storage that `list()` cannot see, and a bucket cannot be deleted while one exists. A browser tab closed mid-upload leaves exactly this behind. + +`bucket.put()` cleans up after itself: any failure inside its multipart path aborts the upload before rethrowing. So does a browser upload that calls `cancel()`. What is left over is the case nobody handled, and it needs a sweep. + +### Listing them + +```ts +const uploads = await bucket.listMultipartUploads({ prefix: "uploads/" }) +// [{ path: 'uploads/big.mp4', uploadId: 'ABC...', initiatedAt: Date }, ...] +``` + +`listMultipartUploads` returns every upload started and neither completed nor aborted, paging internally until it has them all. `prefix` is optional; without one you get the whole bucket. + +| Field | Meaning | +| --- | --- | +| `path` | The object key the upload was started for. Nothing is stored there yet. | +| `uploadId` | R2's own id for the upload, needed to abort it. | +| `initiatedAt` | When it was started. This is what "stale" is measured against. | + +### Aborting one + +```ts +await bucket.abortMultipartUpload({ path: "uploads/big.mp4", uploadId: "ABC..." }) +``` + +This throws the upload away along with every part that landed for it. Missing is success, exactly like `del` on a path that is not there. + +That is also why it takes the record `listMultipartUploads()` returned rather than two positional strings. If the wire treats "not there" as success, then `abortMultipartUpload(uploadId, path)` with the arguments swapped would abort nothing, answer 204, and report that it worked. A named `{ path, uploadId }` pair cannot be swapped by accident, and an empty `uploadId` is refused with `invalid_input` before anything is sent. + + +`onUploadComplete` receives `multipartUploadId` for exactly this pair. Store it alongside your row and you can abort a specific upload later without listing the bucket. It is `undefined` when the file went up as a single PUT. See [Upload handler](/docs/blob/browser/upload-handler). + + +### Sweeping the stale ones + +`abortStaleMultipartUploads` is list plus abort in one call, meant for a cron. It returns what it aborted: + +```ts app/api/cron/sweep-uploads/route.ts +import { Bucket } from "@upstash/blob" + +export async function GET(request: Request) { + if (request.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) { + return new Response("unauthorized", { status: 401 }) + } + + const bucket = Bucket.fromEnv() + const aborted = await bucket.abortStaleMultipartUploads({ + olderThan: "1d", + prefix: "uploads/", + }) + + for (const upload of aborted) { + console.info(`[sweep] aborted ${upload.path}, started ${upload.initiatedAt.toISOString()}`) + } + + return Response.json({ aborted: aborted.length }) +} +``` + +`olderThan` is required, and it is a `Duration`: a bare number is seconds, or write a string like `'15m'`, `'2h'`, `'7d'`. Only uploads started longer ago than that are touched, which is what keeps the sweep from aborting an upload that is still running. Pick a window comfortably longer than your slowest legitimate upload; a day is a reasonable default. + +`prefix` narrows the sweep the same way it narrows `listMultipartUploads`. + + +An abandoned upload **under** the multipart threshold is not a multipart upload at all. The browser's presigned PUT stored the object the moment its last byte landed, so what it leaves behind is a whole, ordinary, `list()`-visible, billed object, and none of the calls on this page can find it. That needs a different sweep: see [Abandoned uploads](/docs/blob/browser/abandoned-uploads). + + +*** + +## When the SDK deletes for you + +Two paths in the upload handler delete objects without you asking: a throw out of `onUploadComplete`, and a `cancel()` from the browser. A cancel on a multipart upload aborts it, parts and all. Everything else deletes a stored object, and that goes through one guard, because R2 has no conditional delete. The object's etag is re-read first and the delete only happens when it still matches the one this upload produced, so a later upload to the same path is left alone with a warning; an upload the handler cannot identify at all is left stored with an error logged, because an orphan costs storage and a log line while a blind delete costs somebody else's accepted file. On a single PUT, the `upstash-upload` marker signed into the presigned URL is what says the object is this upload's at all. + +Any throw out of `onUploadComplete` runs that discard, including a retryable `BlobError`: the object is deleted first, so the retry the error asks for arrives at an empty path. Catch your own storage errors rather than letting them escape the callback. See [Upload handler](/docs/blob/browser/upload-handler#onuploadcomplete) for the callback and [Abandoned uploads](/docs/blob/browser/abandoned-uploads) for what happens when nothing is ever posted at all. + +*** + +## Error codes + +Deleting raises `partial_delete`, `move_left_a_copy` and `invalid_input`; each is described where it is raised above, and the statuses and extra fields are on [Errors](/docs/blob/bucket/errors#the-codes). Two things are specific to this page. `not_found` is never raised by `del`, which treats a missing object as success. And a path containing a `.` or `..` segment throws a `TypeError` rather than a `BlobError`, because it is a programming mistake rather than a runtime condition. + +# Errors +Source: https://upstash.com/docs/blob/bucket/errors + +Everything the SDK throws is a `BlobError`. It carries a `code` from a closed list of eighteen, a `status`, and a `message` written to be printed. That holds on the server, in the browser, and inside the React hooks: one class, one list of codes, one shape to handle. + +```ts lib/avatar.ts +import { BlobError, Bucket } from "@upstash/blob" + +const bucket = Bucket.fromEnv() + +export async function avatar(path: string) { + try { + return await bucket.info(path) + } catch (e) { + if (BlobError.is(e) && e.code === "not_found") return null + throw e + } +} +``` + +`BlobError` is exported from all three entrypoints: `@upstash/blob`, `@upstash/blob/browser` and `@upstash/blob/react`. + +*** + +## Use `BlobError.is()`, not `instanceof` + + + An ESM copy and a CJS copy of the class are two different classes, so `instanceof` can return + false for an error that genuinely is one. `BlobError.is()` checks a `Symbol.for` marker instead, + which is shared across every copy of the package in the process. + + +```ts +if (BlobError.is(e)) { + e.code // BlobErrorCode + e.status // number + e.message // string +} +``` + +`is()` is a type guard, so the fields are typed after it. It is the only supported check, and it is what the SDK itself uses internally at every boundary. + +*** + +## The codes + +`e.status` is what a route answers with, so the table is API surface rather than a detail. + +| Code | Status | Default message | When you see it | +| ---- | ------ | --------------- | --------------- | +| `not_found` | 404 | `not found` | `get`, `info` or `copy` on a path that is not there. Storage answered `NoSuchKey` or `NoSuchUpload`. An unknown upload route. A completion whose object never landed. | +| `already_exists` | 409 | `already exists` | `put` with `overwrite: false` against a path that already has an object. Carries `etag` and `size`. | +| `conflict` | 409 | `the object changed since it was read` | `ifUnchanged` did not match, or storage answered `PreconditionFailed`. Also `updateJson` giving up after six attempts. | +| `content_type_not_allowed` | 400 | `content type not allowed` | The declared type is not in `contentTypes`, or the file's leading bytes contradict the declaration. | +| `invalid_input` | 400 | `invalid input` | Arguments the SDK will not accept: metadata outside printable ASCII, a malformed upload request body, `input` that fails the route's schema, a `del` target that is none of the three shapes. | +| `too_large` | 413 | `too large` | Over `maxBytes`, over the route's `constraints`, or over what a single PUT can carry when `multipart: false` forbids the parts the body needs. Also a 413 from storage or from your platform. | +| `empty_body` | 400 | `empty body` | A zero-byte file at `begin`, or a `put` from a `Request` with no body left to read. | +| `length_required` | 411 | `length required` | `put` of an unknown-length stream with neither `size` nor `maxBytes`, so nothing knows how long the body is. | +| `signature_mismatch` | 403 | `signature mismatch` | A 403 from storage that is not a credential problem: the body length or type does not match what was signed. Also a completion where the stored size is not the declared size. | +| `unauthorized` | 401 | `unauthorized` | The bucket token was rejected, or your own auth check refused the upload. | +| `forbidden` | 403 | `forbidden` | A completion token that is not valid for this route, or has expired. | +| `rate_limited` | 429 | `rate limited` | Storage answered `SlowDown` or `TooManyRequests`, or credential requests are being rate limited. | +| `mint_backoff` | 429 | `the credential service asked for a backoff longer than a request can wait` | The credential service asked for more than 10 seconds of backoff. `retryAfter` says how long. | +| `not_ready` | 503 | `bucket is not ready` | The bucket is not ready to serve requests yet. | +| `partial_delete` | 500 | `some paths were not deleted` | An array or prefix `del` where some objects survived. `failed` lists them. | +| `move_left_a_copy` | 500 | `move left a copy at the source` | `move` copied the object but could not delete the source. The destination is kept. | +| `invalid_content_type_pattern` | 500 | `invalid content type pattern` | A `contentTypes` entry that is not a `type/subtype` or one of `image/*`, `video/*`, `audio/*`. An empty list throws this too. | +| `request_failed` | 500 | `request failed` | Everything else. This is the one code whose `status` the thrower sets, so it also carries 502 and 503. | + +Bad option values are not in this list. An unparseable `'5mib'`, a missing `token`, a route with no `onBeforeUpload`: those throw a `TypeError` where the option is written, not a `BlobError` per request. + +See [Writing](/docs/blob/bucket/writing), [Reading](/docs/blob/bucket/reading) and [Deleting](/docs/blob/bucket/deleting) for which calls raise which. + +*** + +## Extra fields + +Beyond `code`, `status` and `message`, an error carries whatever the code has to say. + +| Field | Type | Set by | +| ----- | ---- | ------ | +| `hint` | `string \| undefined` | Any code. `signature_mismatch` and `length_required` have a built-in one, and many call sites add their own. | +| `failed` | `string[] \| undefined` | `partial_delete`: the paths that survived the delete. | +| `etag` | `string \| undefined` | `already_exists`: the etag of what is already there. | +| `size` | `number \| undefined` | `already_exists`: the size of what is already there. | +| `retryAfter` | `number \| undefined` | `mint_backoff` and `rate_limited`: seconds the service asked the caller to wait. | +| `cause` | `unknown` | The underlying error, when there was one. Standard `Error.cause`. | + +```ts +try { + await bucket.del(["a.png", "b.png", "c.png"]) +} catch (e) { + if (!BlobError.is(e)) throw e + if (e.code === "partial_delete") await queueForRetry(e.failed ?? []) + if (e.code === "rate_limited") await sleep((e.retryAfter ?? 1) * 1000) +} +``` + +`already_exists` hands back what blocked the write, so a conditional put does not need a second round trip to find out: + +```ts +try { + await bucket.put("avatars/7.png", file, { overwrite: false }) +} catch (e) { + if (BlobError.is(e) && e.code === "already_exists") { + console.log("kept", e.etag, e.size) + } +} +``` + +*** + +## Messages are written to be shown + +Messages are lowercase in the source and sentence-cased when the error is built, so an app can print `e.message` straight into its error line without writing its own `capitalize()`. + +A message that opens with an identifier keeps its case. A MIME type, a file name or a metadata key is not a word to raise: "Image/png is not allowed" names a type that does not exist, and "Cat.png" is not the file the user picked. + +```ts +new BlobError("not_found").message // 'Not found' +new BlobError("forbidden", "not your thread").message // 'Not your thread' +new BlobError("too_large", "cat.png is 3.1 MB, over the 2 MB limit").message +// 'cat.png is 3.1 MB, over the 2 MB limit' +``` + +`e.message` never carries a credential, a token, or an internal path. Every message is assembled from a code, a caller-supplied string, or an HTTP status. + +### Hints fold into the message + +A hint is appended to the message in parentheses, so printing `message` alone is enough. `e.hint` is still there separately if you want to lay it out yourself. + +| Code | Built-in hint | +| ---- | ------------- | +| `signature_mismatch` | a 403 from R2 usually means the body length or type differs from the signature | +| `length_required` | pass `{ size }` or `{ maxBytes }` so the length is known before the first byte | + +```ts +new BlobError("length_required").message +// 'Length required (pass { size } or { maxBytes } so the length is known before the first byte)' +``` + +A message that already contains its hint is not doubled. + +*** + +## Errors across the wire + +This is what makes the browser half usable. An upload route answers every refusal with `BlobError.toJSON()` at the error's own status, and the browser rebuilds it with `BlobError.fromJSON()`. So `error.code` inside a hook is the code your server raised, not a status number you have to decode back into a meaning. + +```tsx app/picker.tsx +"use client" +import type { BlobError } from "@upstash/blob/react" +import { useUpload } from "@/lib/upload-hooks" + +export function Picker() { + const { start, upload, accept } = useUpload() + + return ( + <> + start({ file: e.target.files?.[0] })} + /> + {upload?.status === "error" &&

{describe(upload.error)}

} + + ) +} + +function describe(error: BlobError): string { + switch (error.code) { + case "unauthorized": + return "Your session expired. Sign in and try again." + case "rate_limited": + return `Too many uploads. Try again in ${error.retryAfter ?? 30}s.` + case "not_ready": + return "Storage is warming up. Try again in a moment." + default: + // too_large, content_type_not_allowed and the rest already read as a sentence. + return error.message + } +} +``` + +### What reaches the browser, in order + +A route runs `onError` first. If it returns a `Response`, that is the answer; if it returns a `BlobError`, the answer is that error's JSON at its own status. Otherwise the throw falls through three cases: + +1. **A `BlobError` is answered as itself.** `toJSON()` at `e.status`, with `hint`, `failed`, `etag`, `size` and `retryAfter` when they are set. +2. **An app error carrying an integer `status` between 400 and 599 is mapped through the status table below.** This is how an auth check that throws its own 401 reaches the browser as `unauthorized`, so a caller can tell a dead session from a rejected file without reading status numbers. +3. **Anything else is treated as your bug and rethrown**, so the framework logs it with its stack rather than masking it as a generic 500. + +| Status | Code | +| ------ | ---- | +| 401 | `unauthorized` | +| 403 | `forbidden` | +| 404 | `not_found` | +| 409 | `conflict` | +| 411 | `length_required` | +| 413 | `too_large` | +| 429 | `rate_limited` | + +Any other status becomes `request_failed`, keeping the status it arrived with. + +*** + +## `onError` + +`onError` is the one place to log. It sees every refusal, the SDK's own included, and it runs before the answer is written. + +```ts lib/uploads.ts +import { BlobError, uniquePath, uploadHandler } from "@upstash/blob" + +export const uploads = uploadHandler({ + constraints: { maxBytes: "20mb", contentTypes: ["image/*"] }, + + onBeforeUpload: ({ file }) => ({ path: uniquePath`uploads/${file.name}` }), + + onError: ({ route, error, path, file, metadata }) => { + logger.error("upload refused", { + route, + path, + file: file?.name, + owner: metadata?.owner, + code: BlobError.is(error) ? error.code : "unknown", + message: error instanceof Error ? error.message : String(error), + }) + // Returning nothing leaves the answer alone. + }, +}) +``` + +It is handed `{ ctx, route, request, error, file?, path?, metadata?, state? }`, with as much as the request had reached before it failed. A file refused at `begin` has `file` and no `path`; one refused after `onBeforeUpload` has both. + +Return a `BlobError` or a `Response` to answer with it instead: + +```ts +onError: ({ error }) => { + if (!BlobError.is(error)) return new BlobError("request_failed", "could not record the upload") +}, +``` + +Written on the handler it is the default for every route, and a route with its own `onError` replaces it. See [Upload handler](/docs/blob/browser/upload-handler). + +*** + +## The `onUploadComplete` footgun + +Any throw out of `onUploadComplete` deletes the completed object, which is right for a refusal and wrong for a database error that has nothing to do with the file. Catch your own storage errors instead of letting them escape, and see [Upload handler](/docs/blob/browser/upload-handler#onuploadcomplete) for the callback and [Abandoned uploads](/docs/blob/browser/abandoned-uploads) for the pending-row pattern that reconciles the rest. + +*** + +## What storage errors map to + +Errors from R2 are normalised before they leave the SDK, first matching wins. + +| Storage answered | Becomes | +| ---------------- | ------- | +| 404, or `NoSuchKey` / `NoSuchUpload` | `not_found` | +| 412, or `PreconditionFailed` | `conflict` | +| 401 | `unauthorized` | +| 403 with `ExpiredToken`, `InvalidAccessKeyId` or `TokenRefreshRequired` | `unauthorized`, "storage refused the temporary credential", hinting that it expired mid-request and the SDK re-mints and retries once | +| any other 403 | `signature_mismatch` | +| 429, or `SlowDown` / `TooManyRequests` | `rate_limited`, "R2 rate limited the request" | +| 503 | `not_ready` | +| 413, or `EntityTooLarge` | `too_large` | +| anything else | `request_failed`, message `R2 responded : ` | + +For that last row the status is passed through, except that a 5xx is normalised to 502: the failure is upstream of your app, not in it. + +*** + +## Errors the browser raises on its own + +Some failures never reach your route, so the browser names them itself. + +**A PUT that fails with no status and no bytes sent** is not a dropped link. The browser refused it before it went out, and the reason is never visible to script because it is the preflight that failed. That gets three attempts rather than the twenty a real network failure gets, and the hint says so: + +``` +the browser blocked the request before sending any bytes, which is almost always CORS: +the bucket has to allow PUT and the signed headers from this origin +``` + +The policy that fixes it is in [CORS](/docs/blob/overall/quickstart#cors). + +**A 403 on a freshly minted presign** becomes `signature_mismatch`. A 401 or 403 on an older URL is read as an expired signature and the browser asks the route for a new one instead. The whole classification is in [Large files](/docs/blob/browser/large-files#retries). + +**Exhausted retries** become `request_failed`, carrying the attempt count and the last status, hinted with what to do next: + +``` +Upload failed after 8 attempts (last status 500) (the parts that landed are kept: +task.retry(), or pick the same file again) +``` + +`retry()` runs the same upload again from the parts that landed, so nothing already uploaded is re-sent. See [Large files](/docs/blob/browser/large-files). + +**A canceled upload rejects with an `AbortError`, not a `BlobError`.** The record's status is `canceled` and it carries no `error` at all, so a cancel never renders as a failure. + +```ts +const record = start({ file }) +record?.cancel() // status becomes 'canceled', error stays undefined +``` + +*** + +## Platform body limits + +This applies to `useServerUpload` and to any route of your own that the bytes pass through. It does not apply to direct browser uploads, where the bytes go straight to storage and never touch your server. + +A 413 from the platform never reached your route, so it carries no code of its own. The SDK turns it into `too_large` and attaches the limits as a hint: + +``` +Too large (Vercel caps a serverless request body at 4.5MB, AWS Lambda at 6MB, +Cloudflare at 100MB on the free plan) +``` + +```tsx +const { start, upload } = useServerUpload("/api/avatar") + +if (upload?.status === "error" && upload.error.code === "too_large") { + // Either your own maxBytes or the platform's body cap. e.hint says which. +} +``` + +Keep a proxied route's own `maxBytes` under the platform's cap, so the refusal comes from your code with your wording rather than from the platform with none. A file that has to be bigger than the cap wants a direct browser upload instead. + +*** + +## Credential errors + +Three codes come from the credential service rather than from storage or from your code. + +| Code | Status | Meaning | What to do | +| ---- | ------ | ------- | ---------- | +| `unauthorized` | 401 | The bucket token was rejected. | Check `UPSTASH_BLOB_TOKEN`. Nothing retries this. | +| `not_ready` | 503 | The bucket is not ready yet. | Retry the request. | +| `mint_backoff` | 429 | The service asked for a backoff longer than a request can wait, over 10 seconds. `retryAfter` says how long. | Retry the request later rather than blocking on it. | + +The SDK already waits out short backoffs itself, up to three times. `mint_backoff` is what is left over: a pause no single request can sit through, so it is handed back to the caller instead of holding a serverless invocation open for it. + +```ts +try { + await bucket.put("u/7/report.pdf", body) +} catch (e) { + if (BlobError.is(e) && e.code === "mint_backoff") { + return retryAfterSeconds(e.retryAfter ?? 10) + } + throw e +} +``` + +Credentials are short-lived, cached per token, and re-minted just before they expire. A credential that expires mid-request is caught inside the SDK: it re-mints once and asks again, and only a second refusal surfaces. See [How signing works](/docs/blob/overall/signing) for how that lifetime caps a signed link, and [Quickstart](/docs/blob/overall/quickstart) for where the token comes from. + +# Reading +Source: https://upstash.com/docs/blob/bucket/reading + +Reading covers everything that gets bytes or facts back out of a bucket: `get` for the bytes, `info` for the facts, `exists` for the question, `list` for a page of keys, and a URL, public or signed, for everything that reads the object without going through your server at all. + +Every example below starts from a bucket: + +```ts lib/bucket.ts +import { Bucket } from "@upstash/blob" + +export const bucket = Bucket.fromEnv() // reads UPSTASH_BLOB_TOKEN +``` + +See [Quickstart](/docs/blob/overall/quickstart) for the token, and [Writing](/docs/blob/bucket/writing) for the other half of the API. + +*** + +## The record types + +Four record shapes come back from the SDK. They nest, so the rest of this page names them rather than repeating their fields. + +| Type | Is | Comes back from | +| --- | --- | --- | +| `BlobObject` | `path`, `url?`, `versionedUrl?`, `size`, `etag`, `uploadedAt` | `list()`, `copy()`, `move()`, `updateJson()` | +| `CompletedBlob` | `BlobObject` plus `contentType` | `put()`, and `onUploadComplete` | +| `BlobInfo` | `BlobObject` plus `contentType` and `metadata` | `info()` | +| `BlobDownload` | `BlobInfo` plus `body: ReadableStream` | `get()` | + +`BlobObject` is the base, and it is what a bucket listing can carry: + +| Field | Type | | +| --- | --- | --- | +| `path` | `string` | The object's key. | +| `url` | `string \| undefined` | The public object URL. Undefined on a private bucket. | +| `versionedUrl` | `string \| undefined` | `url` with the etag on the query. Undefined when `url` is. | +| `size` | `number` | Bytes. | +| `etag` | `string` | Storage's etag, quoted as it arrives: `"9f3c..."`. | +| `uploadedAt` | `Date` | Last modified. | + +### `blob` is a record, never bytes + +In this SDK `blob` always names a record, and never the bytes of one. The DOM already has a `Blob` and it is bytes, so the two must never swap places: nothing in the API takes a parameter named `blob`, and bytes go in as `body`. That is why `put(path, body)` reads the way it does, and why the bytes on a download sit under `body` on a record rather than being the return value. + +*** + +## `get(path)` + +`get` returns the whole record plus the response body as a stream. Nothing is buffered for you, so a large object costs whatever you do with the stream and no more. + +```ts +const res = await bucket.get("reports/2026-01.pdf") + +res.contentType // 'application/pdf' +res.size // 184_302 +res.etag // '"9f3c..."' +res.metadata // { owner: 'u7' } +res.body // ReadableStream +``` + +Wrap the body in a `Response` to get the usual conversions: + +```ts +const text = await new Response((await bucket.get("notes/1.md")).body).text() +const buffer = await new Response((await bucket.get("img/1.png")).body).arrayBuffer() +``` + +A missing object is a throw, not an `undefined` return: `get` raises a `BlobError` with code `not_found` and status 404. See [Errors](/docs/blob/bucket/errors) for the code list and for `BlobError.is`. + +There is no range option. Reading part of an object is what [the S3 escape hatch](#the-s3-escape-hatch) is for. + +*** + +## `info(path)` + +`info` is the same record with no bytes: one HEAD request, so it costs nothing to read a 2 GB object's facts. + +```ts +const info = await bucket.info("reports/2026-01.pdf") + +info.size // 184_302 +info.etag // '"9f3c..."' +info.contentType // 'application/pdf' +info.metadata // { owner: 'u7' } +info.uploadedAt // Date +``` + +Like `get`, a missing object throws `not_found` rather than answering `undefined`. + +Metadata keys come back lowercased, since they cross the wire as `x-amz-meta-*` headers: `{ uploadedBy: 'u1' }` written at upload reads back as `metadata.uploadedby`. The rules that govern what can be written are in [Writing](/docs/blob/bucket/writing#metadata). + +`metadata` is the reason this call exists next to `exists()`. It comes back from `get` and from `info`, and from nothing else: a listing does not carry it. So `info` is the call a cleanup cron makes before it deletes anything, to confirm the object at that path is the one its row reserved rather than a later upload that reused the path. That sweep, and why a row is the only thing that can tell an abandoned upload from a finished one, is in [Abandoned uploads](/docs/blob/browser/abandoned-uploads). + +*** + +## `exists(path)` + +`exists` answers `false` instead of throwing. It is the same HEAD request as `info`, with the record thrown away. + +```ts +if (await bucket.exists("avatars/u7.png")) { + // ... +} +``` + +Prefer `info()` whenever you are going to want the etag, the size or the metadata anyway: `exists()` then `info()` is two round trips for one answer, and the `not_found` catch you would write around `info` is the same branch as the `false`. + +*** + +## `list(options)` + +`list` returns one page of objects. + +| Option | Type | | +| --- | --- | --- | +| `prefix` | `string` | Only keys starting with this. | +| `limit` | `number` | Page size, clamped to 1 to 1000. Omit it and storage picks. | +| `cursor` | `string` | The `cursor` from the previous page. | + +```ts +const page = await bucket.list({ prefix: "avatars/", limit: 100 }) + +page.blobs // BlobObject[] +page.cursor // string | undefined +``` + +`cursor` is set only while more remains, so a full walk is a `do ... while` and never needs a separate "is there more" check: + +```ts +let cursor: string | undefined +const paths: string[] = [] + +do { + const page = await bucket.list({ prefix: "avatars/", limit: 1000, cursor }) + for (const blob of page.blobs) paths.push(blob.path) + cursor = page.cursor +} while (cursor) +``` + +A listing carries `BlobObject`, which means it has the path, size, etag, timestamp and URLs, and it does not have `contentType` or `metadata`. Storage does not return those in a listing, and fetching them would be one HEAD per key. + +`prefix` is also the only filter there is. There is no query by owner, by type, by date or by anything else, and the only way to find "this user's files" is a prefix you chose at upload time. An app that has to ask real questions about its files should keep its own table, write the row when the upload is authorized, and treat the bucket as the bytes rather than the index. That table is also what makes deleting and re-rendering cheap, since it holds the metadata a listing cannot. + +*** + +## Public URLs + +On a public bucket every record already carries `url`, and you can compute one for any path without a record: + +```ts +bucket.publicUrl("avatars/u7.png") +// 'https://b0f3a91c24d.blob.upstash.io/avatars/u7.png' +``` + +There is no network call. The bucket's public DNS label is carried in the token itself, so `publicUrl` is string work against `.blob.upstash.io` and the path, percent-encoded. It returns `undefined` on a private bucket. It throws a `TypeError` for a path that is empty or contains a `.` or `..` segment, the same check every other path takes. + +### `versionedUrl` + +`versionedUrl` is `${url}?v=${etag}`, with the etag percent-encoded because storage returns it quoted. + +It exists for the stable path. If `avatars/u7.png` is overwritten every time the user picks a new picture, the URL never changes, so every cache between your object and the reader is free to keep serving the old bytes. `versionedUrl` changes whenever the content changes, because the etag does, which turns "the URL is stale" into "the URL is different". + +```tsx +const avatar = await bucket.info(`avatars/${user.id}.png`) + +``` + +Pair it with `cache: 'immutable'` at upload: the bytes at any one versioned URL genuinely never change, so a year-long `max-age` is honest and the new picture is a new URL rather than a revalidation. See [Caching](/docs/blob/bucket/caching) for the other cache options and when `'revalidate'` is the better trade. + +*** + +## Private buckets + +A private bucket has no public host, so a URL on one of its records would be a link that 404s. Declare it and `url` and `versionedUrl` are dropped from every record the SDK builds: + +```ts +const bucket = new Bucket({ token, visibility: "private" }) + +const blob = await bucket.put("reports/2026-01.pdf", pdf) +blob.url // undefined +blob.versionedUrl // undefined +bucket.publicUrl("reports/2026-01.pdf") // undefined +``` + +A `visibility` in the credentials response wins over what you declared, so a bucket that is private in the console stays private here even if the code says otherwise. Reads on a private bucket go through `signedReadUrl()`. + +*** + +## `signedReadUrl(path, options)` + +A time-limited URL anyone can GET, for a private bucket or for an object you do not want linked from a public page. + +```ts +const { url, expiresAt } = await bucket.signedReadUrl("reports/2026-01.pdf", { + expiresIn: "2m", + downloadAs: "Report Q3.pdf", +}) +``` + +| Option | Type | | +| --- | --- | --- | +| `expiresIn` | `Duration` | How long to ask for. `'15m'`, `'2h'`, or a bare number of seconds. Default 5 minutes. | +| `downloadAs` | `string` | Save as this filename instead of displaying inline. | +| `contentType` | `string` | What storage answers with as `Content-Type`, overriding what was stored. | + +The return is `{ url, expiresAt }`. + +### The lifetime is answered, not chosen + +Links are signed with the bucket's short-lived credential, and a signature cannot outlive the credential that made it. So `expiresIn` is what you ask for, and `expiresAt` is what you got: it is never later than the signing credential's own expiry, and it is the value to cache the link against rather than a duration you compute yourself. + +```ts +const cached = await cache.get(key) +if (!cached || cached.expiresAt < new Date()) { + const link = await bucket.signedReadUrl(path, { expiresIn: "5m" }) + await cache.set(key, link) +} +``` + +The default with no `expiresIn` is 5 minutes, shortened if the credential has less than that left. Asking for more than the credential can cover re-mints where that helps, and is capped where it does not. [How signing works](/docs/blob/overall/signing) covers the mechanism, and `signedUploadUrl` for the write direction. + +### `downloadAs` + +`downloadAs` sets a `Content-Disposition: attachment` on the response, so the browser saves the file under that name rather than rendering it. + +The name is carried as an RFC 6266 `filename*` ext-value, percent-encoded, with an ASCII `filename` fallback cut back to characters that cannot end the quoted string. A name with a quote, a semicolon or a CRLF in it cannot add a parameter or a second header, and a Unicode name arrives intact: + +```ts +await bucket.signedReadUrl(path, { downloadAs: "café ☕.pdf" }) +// content-disposition: attachment; filename="caf_ _.pdf"; filename*=UTF-8''caf%C3%A9%20%E2%98%95.pdf +``` + +The disposition is signed into the URL along with everything else, so it cannot be edited off the query string by whoever holds the link. + +### `contentType` + +`contentType` overrides what storage answers with, without rewriting the object: + +```ts +await bucket.signedReadUrl("exports/rows.bin", { contentType: "text/csv" }) +``` + +It is validated as a media type and throws `invalid_input` if it is not one, for the same reason `downloadAs` is encoded: this value becomes a response header. + +*** + +## Incomplete uploads + +`listMultipartUploads()` is the one read that does not answer with objects. + +```ts +const uploads = await bucket.listMultipartUploads({ prefix: "uploads/" }) +// [{ path: 'uploads/big.bin', uploadId: 'mp-1', initiatedAt: Date }] +``` + +A multipart upload that was started and never completed or aborted is billed storage that `list()` cannot see, which makes this the only call that can find them. Finding them is not the job though: sweeping them is, and `abortStaleMultipartUploads()` is in [Deleting](/docs/blob/bucket/deleting#incomplete-multipart-uploads). + +*** + +## The S3 escape hatch + +The bucket is S3-compatible, and `bucket.s3()` hands the aws-sdk what it needs for anything the SDK does not model: byte ranges, conditional GETs, delimiters and common prefixes, object tagging. + +```ts +import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3" +import { bucket } from "@/lib/bucket" + +const { endpoint, region, bucket: name, credentials } = bucket.s3() +const s3 = new S3Client({ endpoint, region, credentials }) + +const res = await s3.send( + new GetObjectCommand({ Bucket: name, Key: "reports/2026-01.pdf", Range: "bytes=0-1023" }), +) +``` + +`endpoint` and `credentials` are async providers rather than values, for the reason given in [Writing](/docs/blob/bucket/writing#the-s3-escape-hatch). + +*** + +## Next steps + + + + `put`, metadata, conditional writes and multipart from the server. + + + + What `Cache-Control` an object is stored with, and pairing it with `versionedUrl`. + + + + One path, a list, a prefix, and sweeping incomplete uploads. + + + + How links are signed, and the upload direction. + + + + `BlobError`, the code list, and `BlobError.is`. + + + + Direct browser uploads, and what `onUploadComplete` is handed. + + + +# Writing +Source: https://upstash.com/docs/blob/bucket/writing + +Everything on this page runs on your server, with the bucket token. `put` writes bytes, `copy` and `move` rearrange them, `updateJson` reads and writes a document under a compare-and-set loop, and `signedUploadUrl` hands the write to somebody else. + +If you have not installed the SDK or created a bucket yet, start at the [Quickstart](/docs/blob/overall/quickstart). For bytes that live in a browser, do not proxy them through your app: see [Upload handler](/docs/blob/browser/upload-handler). + +*** + +## The bucket client + +`Bucket.fromEnv()` reads `UPSTASH_BLOB_TOKEN`. + +```ts lib/blob.ts +import { Bucket } from "@upstash/blob" + +export const bucket = Bucket.fromEnv() +``` + +The constructor takes the token directly, plus three options that apply to every write this client makes. + +```ts +const bucket = new Bucket({ + token: process.env.UPSTASH_BLOB_TOKEN!, + visibility: "private", // drops url and versionedUrl everywhere + cache: "immutable", // the default Cache-Control for objects this client stores + enableTelemetry: false, +}) +``` + +| Option | Type | What it does | +| --- | --- | --- | +| `token` | `string` | Required. The bucket token. | +| `visibility` | `'public' \| 'private'` | `'private'` drops `url` and `versionedUrl` from every record, since nothing serves a private bucket over the public host. A visibility in the credentials response wins over this. | +| `cache` | `CacheOption` | The `Cache-Control` written on every object this bucket stores. A per-call `cache` overrides it. See [Caching](/docs/blob/bucket/caching). | +| `enableTelemetry` | `boolean` | Default `true`. See [Telemetry](#telemetry). | + +On Cloudflare Workers there is no `process`, so the token only exists on the request's `env`. `Bucket.fromEnv()` throws there and says so; pass the token instead. + +```ts src/index.ts +import { Bucket } from "@upstash/blob" + +export default { + async fetch(request: Request, env: { UPSTASH_BLOB_TOKEN: string }) { + const bucket = new Bucket({ token: env.UPSTASH_BLOB_TOKEN }) + await bucket.put("hits.txt", "1") + return new Response("ok") + }, +} +``` + + +The credential cache is keyed by token, not held per instance. Constructing a `Bucket` per request on a serverless platform is the intended shape and does not mint a credential each time: two clients built from the same token share one. + + +*** + +## put + +```ts +const blob = await bucket.put("reports/q3.pdf", pdf, { contentType: "application/pdf" }) +``` + +### Options + +| Option | Type | What it does | +| --- | --- | --- | +| `contentType` | `string` | What the object is stored as. Falls back to what the body carries, then to `application/octet-stream`. | +| `contentTypes` | `readonly string[]` | An allow list. The declared type is checked against it, and the body's first 4100 bytes are checked against the declared type. A refusal is `content_type_not_allowed`, before anything is written. | +| `maxBytes` | `Size` | Refuses a body over this with `too_large`. Also the buffer cap for a body whose length is not known. | +| `cache` | `CacheOption` | The `Cache-Control` this object is stored with, overriding the bucket default. See [Caching](/docs/blob/bucket/caching). | +| `metadata` | `Record` | Stored as `x-amz-meta-*`. See [Metadata](#metadata). | +| `size` | `number` | The declared length of a body whose size is not otherwise known. | +| `overwrite` | `boolean` | `false` refuses the write if something is already at the path. | +| `ifUnchanged` | `string` | An etag. The write fails if the object changed. | +| `multipart` | `boolean \| Size` | Where the multipart path starts. Default 16 MB. | + +Sizes are decimal, so `'2mb'` is 2,000,000 bytes. `maxBytes` accepts a number of bytes or a string like `'20mb'`; `size` is a number of bytes. + +### What it returns + +A `CompletedBlob`, which is the record a listing carries plus the type the object was stored as. + +| Field | Type | | +| --- | --- | --- | +| `path` | `string` | The path you wrote to. | +| `url` | `string \| undefined` | The public object URL. `undefined` on a private bucket. | +| `versionedUrl` | `string \| undefined` | `${url}?v=${etag}`, for a stable path that gets overwritten. `undefined` whenever `url` is. | +| `size` | `number` | Bytes stored. | +| `etag` | `string` | Quoted, and what `ifUnchanged` takes. | +| `uploadedAt` | `Date` | | +| `contentType` | `string` | What the object was stored as. | + +```ts +const blob = await bucket.put("avatars/7.png", file, { contentType: "image/png" }) + +blob.url // https://b3f9a2c7d1e4.blob.upstash.io/avatars/7.png +blob.versionedUrl // ...?v=%22d41d8...%22 +blob.etag +``` + +On a private bucket `url` and `versionedUrl` are `undefined` and reads go through a signed link instead. See [How signing works](/docs/blob/overall/signing). + +### Bodies + +`put` takes a `PutBody`. Some of these already know how long they are and what they contain, which is what decides whether `put` has to buffer anything and what the object is stored as. + +| Body | Carries its length | Carries a content type | +| --- | --- | --- | +| `Request` | From its `content-length` header | From its `content-type` header | +| `Blob` / `File` | Yes | Yes, when `type` is set | +| `ArrayBuffer` | Yes | No | +| Typed array | Yes | No | +| `string` | Yes, once UTF-8 encoded | No | +| `ReadableStream` | No | No | + +The default content type is `application/octet-stream`, so a string or a buffer with no `contentType` is stored as that. An explicit `contentType` always wins over what the body carries. + +```ts app/api/avatar/route.ts +import { bucket } from "@/lib/blob" + +export async function POST(request: Request) { + // A Request carries both, so nothing has to be declared. + const blob = await bucket.put("avatars/me.png", request, { + contentTypes: ["image/*"], + maxBytes: "5mb", + }) + return Response.json({ url: blob.url }) +} +``` + +A `Request` with no body, or one that has already been read, throws `empty_body`. Anything that is not one of the types above is a `TypeError`. + +*** + +## Streams and unknown lengths + +Storage needs a content length before the first byte goes out, and a `ReadableStream` has no length. Without `size` or `maxBytes` there is nothing `put` can do with one, so it refuses up front: + +```ts +await bucket.put("export.csv", stream) +// BlobError: Length required (pass { size } or { maxBytes } so the length is known +// before the first byte) -- code 'length_required', status 411 +``` + +There are two ways through. + +**Pass `maxBytes`.** The stream is read into memory up to that many bytes, which is what makes the length knowable, and a stream that runs past the cap is cancelled with `too_large`. Keep the cap somewhere your process can hold. + +```ts +const blob = await bucket.put("export.csv", stream, { maxBytes: "10mb" }) +``` + +**Pass `size`.** Nothing is buffered and the bytes go straight through. + +```ts +const blob = await bucket.put("export.csv", stream, { size: 5000 }) +``` + +A declared `size` is what the request is sent with: it becomes the `Content-Length`, and it is signed, so a body that does not match it fails the request rather than being stored at the wrong length. A body large enough to take the multipart path is counted as it streams, and the mismatch is named there instead: see [Large bodies](#large-bodies). + +The same applies to a `Request` that arrived chunked: delete or ignore its `content-length` and it is an unknown length like any other stream. + +When bytes are being proxied through a route, keep `maxBytes` under the platform's own request body cap, since that refusal happens before your route runs. The numbers are in [Errors](/docs/blob/bucket/errors#platform-body-limits). + +*** + +## Paths + +A path is any non-empty string, with `/` as structure. It is percent-encoded for you, so spaces and unicode are fine. + +`.` and `..` segments are rejected outright rather than normalised: + +```ts +await bucket.put("uploads/../secrets/key.pem", body) +// TypeError: path may not contain "." or ".." segments +``` + +Normalising would be the wrong answer here. The temporary credential the SDK signs with authorizes the whole bucket, and a URL parser resolves `..` on its own, so a traversing key would quietly touch a different object than the one it names. Refusing is the only outcome that cannot surprise you. + +### uniquePath + +`uniquePath` is a template tag for building a path out of values you do not control, like a filename a browser handed you. + +```ts +import { uniquePath } from "@upstash/blob" + +const path = uniquePath`${user.id}/${file.name}` +// 'u7/holiday-pic-3xK9mBqR.png' +``` + +The trust boundary is the interpolation. Slashes in the literal chunks are structure; slashes inside `${}` are stripped along with the rest of the directory component, so an interpolated value can never contribute a directory of its own. + +```ts +uniquePath`chat/${"../admin/x.png"}` // 'chat/x-9fQ2mAe7.png' +uniquePath`a/${"b/c"}` // 'a/c-Kd3xR8wP' +``` + +Each interpolated value is reduced to its basename, stripped of control and format characters, NFC-normalized, lowercased, and slugged: runs of anything that is not a letter or a number become `-`. Letters and digits from any script survive, so `café.pdf` stays `café`. The stem is capped at 64 characters. The extension, up to 8 characters, is kept and lowercased. + +An 8 character base58 suffix is then appended to the final basename, before the extension. The alphabet leaves out `0`, `O`, `I` and `l`, so a path read aloud or retyped stays the same path. + +```ts +uniquePath`${"Q3 Report (final).pdf"}` // 'q3-report-final-7hTbN2xY.pdf' +uniquePath`${"!!! ***"}` // 'file-Wm4pQ8dK' +``` + +Use it whenever two people picking `photo.png` must not land on the same object. When overwriting is the intent, write the path yourself. + +*** + +## Metadata + +`metadata` is a flat `Record` stored alongside the object as `x-amz-meta-*` headers. + +```ts +await bucket.put("invoices/7.pdf", pdf, { + contentType: "application/pdf", + metadata: { owner: "u7", invoiceId: "2026-0042" }, +}) + +const info = await bucket.info("invoices/7.pdf") +info.metadata // { owner: 'u7', invoiceid: '2026-0042' } +``` + +Header names are case-insensitive, so keys come back lowercased. Write them lowercase and there is no surprise. + +Keys must be valid header names, and values must be printable ASCII. Anything else is refused before the request goes out: + +```ts +await bucket.put("a.txt", "x", { metadata: { note: "café" } }) +// BlobError: metadata.note has characters storage does not carry back unchanged +// (metadata is printable ASCII; percent-encode anything else with encodeURIComponent) +// -- code 'invalid_input', status 400 +``` + +This is stricter than it looks, and the reason is measurable. R2 does not store a non-ASCII value verbatim: `{ note: 'café' }` comes back as `=?utf-8?Q?caf=C3=A9?=`. Accepting it would mean handing you back a different string than the one you wrote, and finding out about it on the read. Percent-encode instead, and it round trips exactly: + +```ts +await bucket.put("a.txt", "x", { metadata: { note: encodeURIComponent("café") } }) +decodeURIComponent((await bucket.info("a.txt")).metadata.note!) // 'café' +``` + + +Metadata comes back from `info()` and `get()`, but not from `list()`. A listing carries paths, sizes, etags and urls only, so a sweep that has to read metadata is one `info()` per object. See [Reading](/docs/blob/bucket/reading). + + +*** + +## Conditional writes + +Two options turn `put` into a conditional write. Both are enforced by storage, not by a read-then-write in the SDK, so neither has a race window. + +**`overwrite: false`** sends `If-None-Match: *`. If something is already at the path the write is a real 412, and the SDK raises `already_exists` carrying what is there: + +```ts +try { + await bucket.put("u/7/profile.json", body, { overwrite: false }) +} catch (e) { + if (BlobError.is(e) && e.code === "already_exists") { + e.etag // the etag of the object that is already there + e.size // and its size + } +} +``` + +**`ifUnchanged: etag`** sends `If-Match`. If the object changed since you read that etag, the write throws `conflict`: + +```ts +const current = await bucket.info("u/7/profile.json") +await bucket.put("u/7/profile.json", next, { ifUnchanged: current.etag }) +// throws BlobError 'conflict' if somebody else wrote first +``` + +Both are single-PUT only, because a multipart upload has no conditional complete. They turn multipart off, which is why a conditional write of a large body still goes up as one request. Asking for both at once is a build-time mistake rather than a silent downgrade: + +```ts +await bucket.put("big.bin", data, { multipart: true, overwrite: false }) +// BlobError: Multipart: overwrite:false and ifUnchanged are single-PUT only +// -- code 'invalid_input' +``` + +*** + +## updateJson + +`updateJson` is the compare-and-set loop those two options are for, written once. It reads the document, calls your function with the parsed value, and writes the result back with `If-Match`, or with `If-None-Match: *` when there was nothing there. A conflict means somebody wrote in between, so it reads again and re-runs your function against what actually landed. + +```ts +interface Settings { + theme: string +} + +await bucket.updateJson("u/7.json", (prev) => ({ + ...(prev ?? {}), + theme: "dark", +})) +``` + +Your function is handed `null` when there is nothing to read. An object that exists but is empty also reads as `null`: there is no JSON document either way, so the callback sees the same "nothing here yet" both times. + +The object is written as `application/json`. Existing metadata is carried over unless you pass `metadata` of your own, and `cache` is available the same way: + +```ts +await bucket.updateJson( + "u/7.json", + (prev) => ({ ...(prev ?? {}), theme: "dark" }), + { metadata: { owner: "u7" }, cache: "no-store" }, +) +``` + +Your function may be async, and it is re-run on every attempt, so keep it a pure transform rather than somewhere to do work with side effects. + +There are six attempts in total. A document that keeps changing under all six throws: + +```ts +// BlobError: u/7.json kept changing across 6 attempts -- code 'conflict', status 409 +``` + +*** + +## copy and move + +`copy(from, to)` is a server-side copy: the bytes never travel through your app. It returns the destination's record. + +```ts +const archived = await bucket.copy("tmp/9f3c", "archive/2026/report.pdf") +archived.size +``` + +`move(from, to)` is a copy followed by a delete of the source. + +```ts +const moved = await bucket.move("tmp/9f3c", "reports/q3.pdf") +``` + +A move is not atomic. If the copy lands and the delete fails, `move` throws `move_left_a_copy` and keeps the destination, so you are left with two objects rather than none. Do not read that throw as "nothing happened": the recovery is to retry the source delete, and it is written out in [Deleting](/docs/blob/bucket/deleting#move-leaves-a-copy-on-failure). + +Copying a path that does not exist throws `not_found`. + +*** + +## Large bodies + +A body over 16 MB, decimal, goes up as a multipart upload instead of one PUT. `multipart` moves that line: a size is the threshold to use instead (`'100mb'`), `true` always uses parts, `false` never does. + +```ts +await bucket.put("video.mp4", data, { multipart: "100mb" }) +``` + +R2 refuses a single PUT larger than about 5 GiB, so past that there is no choice. `multipart: false` on a body that big is refused rather than attempted: + +```ts +// BlobError: 6 GB is over the 5.4 GB a single PUT can carry +// (multipart: false forbids the parts this body needs) -- code 'too_large' +``` + +Server-side, parts are sent one at a time. A part is buffered whole so it can be retried, and holding several would multiply that memory by the concurrency. If anything fails, the SDK aborts the whole upload before throwing, because an incomplete multipart upload is billed storage that `list()` cannot see. + +This is also the path that counts the body against the `size` you declared, since it is already reading the stream part by part. Too many bytes throws `invalid_input` with `Body is longer than the declared 5000 bytes`, and too few throws `invalid_input` with `Body was 4000 bytes, 5000 were declared`. + +Parts, pause, resume and per-part retry are covered in full in [Large files](/docs/blob/browser/large-files), including the cron for upload parts a closed browser tab left behind. + +*** + +## Signed upload URLs + +`signedUploadUrl` produces a URL somebody else can PUT exactly one object to. It is the write-side counterpart of a signed read link, for a CLI, a build step, or a server-to-server job that has bytes you do not want to relay. + +```ts +const upload = await bucket.signedUploadUrl("u/7/report.pdf", { + contentType: "application/pdf", + size: pdf.size, + expiresIn: "15m", +}) + +await fetch(upload.url, { method: "PUT", headers: upload.headers, body: pdf }) +``` + +| Option | Type | What it does | +| --- | --- | --- | +| `expiresIn` | `Duration` | How long the link should live. Default one hour. | +| `contentType` | `string` | The `Content-Type` the upload must send, and what the object is stored as. Defaults to `application/octet-stream`. | +| `cache` | `CacheOption` | The `Cache-Control` the object is stored with. | +| `metadata` | `Record` | Written as `x-amz-meta-*`, under the same rules as `put`. | +| `size` | `Size` | Pins the body's exact length, so a URL handed out for one file cannot upload another size. | +| `overwrite` | `boolean` | `false` refuses the upload if something is already at the path. | + +It returns `{ url, headers, expiresAt }`. + + +`headers` are pinned into the signature and must be sent verbatim. Drop one, change one, or add one, and storage answers **403** rather than letting the caller choose what the object is stored as. That is also what makes `metadata` yours and not the uploader's. + + +A link can never outlive the credential that signed it, so `expiresAt` is the answer rather than what you asked for. The SDK re-mints to cover a longer ask where it can, and `expiresAt` reports what actually came out. + +For a browser upload, use the upload handler instead. It also handles multipart, resume, and the completion callback this cannot: a signed URL is one PUT, and nothing tells your server it happened. See [Upload handler](/docs/blob/browser/upload-handler) and [How signing works](/docs/blob/overall/signing). + +*** + +## The S3 escape hatch + +`bucket.s3()` hands back a config for `@aws-sdk/client-s3`, for the S3 operations this SDK does not wrap. + +```ts +import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3" +import { bucket } from "@/lib/blob" + +const { endpoint, region, bucket: name, credentials } = bucket.s3() +const s3 = new S3Client({ endpoint, region, credentials }) + +await s3.send(new ListObjectsV2Command({ Bucket: name, Prefix: "reports/" })) +``` + +`endpoint` and `credentials` are async providers rather than values. The endpoint is only known from a credentials response, and the credential itself is short-lived, so handing the aws-sdk providers is what lets it re-read a fresh one on expiry instead of failing an hour in. + +*** + +## Telemetry + +The SDK sends its version, the runtime it is on, and the platform as headers on credential requests to Upstash. Those are one request per credential lifetime, not per object, so nothing on the hot path carries them. Turn it off with `UPSTASH_DISABLE_TELEMETRY` in the environment, or with `enableTelemetry: false` on the `Bucket`. Setting the variable to `false`, `0`, `no` or `off` does not opt out: an environment variable that says false and means true is a trap. + +*** + +## Next steps + + + + `get`, `info`, `exists` and paging through `list`. + + + + One path, a list, a prefix, and what a partial delete reports. + + + + What `cache` accepts and why it is written once, at upload. + + + + Every code, what raises it, and how to test for one. + + + +# Formulas +Source: https://upstash.com/docs/blob/formulas/overview + +The reference pages describe one option at a time: what `cache` accepts, what `constraints` refuse, what `onUploadComplete` is handed. A formula is the other direction. It is one real feature wired end to end, with every file it takes, and with the choices already made and explained. Copy one, rename the paths, and it works. + +*** + +## Pick your shape + +Almost every decision in an upload feature is the same five: where the object goes, what it is cached as, what the route accepts, whether it goes up in parts, and who cleans up after a browser that never came back. These are the answers for the cases that come up most. + +| Feature | Path | Cache | Constraints | Multipart | Notes | +| ------- | ---- | ----- | ----------- | --------- | ----- | +| Avatar | `avatars/${user.id}.png`, stable | [`'immutable'`](/docs/blob/bucket/caching) and serve `versionedUrl`, or `'revalidate'` if you link the bare `url` | [`image/*`](/docs/blob/browser/constraints), `maxBytes: '5mb'` | default, a 5 MB file is always one PUT | Overwriting is the intent, so there is no orphan and nothing to sweep | +| Chat or issue attachment | [`uniquePath`](/docs/blob/bucket/writing#uniquepath) under `threads/${threadId}/` | default, `public, max-age=3600` | `maxBytes: '25mb'`, no type list | default, or `true` to skip the sweep | [Pending row plus a cron](/docs/blob/browser/abandoned-uploads); `uploadId` is the idempotency key | +| User document library | `uniquePath` under `docs/${user.id}/` | `'immutable'`, the path is already unique | `['application/pdf']`, `maxBytes: '100mb'` | default | [`bucket.list({ prefix })`](/docs/blob/bucket/reading) is the listing, your rows are the metadata | +| Large video upload | `uniquePath` under `videos/${user.id}/` | `'immutable'` | [`video/*`](/docs/blob/browser/constraints), `maxBytes: '5gb'` | [`multipart: true`](/docs/blob/browser/large-files) | Only parts can pause, resume and retry; a closed tab leaves parts for `abortStaleMultipartUploads` | +| Private report or invoice | `invoices/${invoice.id}.pdf`, stable | `'no-store'` | written by your server, so [`bucket.put`](/docs/blob/bucket/writing) rather than a route | default | `visibility: 'private'` drops `url`, and reads go through [`signedReadUrl`](/docs/blob/bucket/reading) | + +Two rules run underneath the whole table. Use `uniquePath` unless overwriting is the intent. And write the row that says an upload is in flight before the bytes are: under the multipart threshold the object exists the moment the last byte lands, whether or not any callback ever accepted it. Both are argued out in [Abandoned uploads](/docs/blob/browser/abandoned-uploads). + +*** + +## Avatar upload + +One object per user, at a path derived from the user id, served through a URL that changes with the bytes. + +```ts lib/uploads.ts +import "server-only" +import { BlobError, uploadHandler } from "@upstash/blob" +import { getUser } from "./auth" +import { db } from "./db" + +export const uploads = uploadHandler({ + constraints: { contentTypes: ["image/*"], maxBytes: "5mb" }, + + onBeforeUpload: async ({ request, file }) => { + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") // the 401, and nothing is signed + + return { + path: `avatars/${user.id}.png`, + cache: "immutable", + metadata: { owner: user.id }, + } + }, + + onUploadComplete: async ({ metadata, path, versionedUrl }) => { + try { + await db.users.update({ id: metadata.owner, avatarUrl: versionedUrl }) + } catch (e) { + // A throw here deletes the object that just landed. The bytes are fine and the path is + // derivable, so a stale row is the cheaper failure. + console.error("[avatar] could not record", path, e) + } + return { avatarUrl: versionedUrl } + }, +}) +``` + +The `.png` in the path is cosmetic. The object is stored and served as the `Content-Type` the browser declared, and `contentTypes: ['image/*']` is what checks that. + +```ts app/api/upload/route.ts +import { uploads } from "@/lib/uploads" + +export const { GET, POST } = uploads +``` + +```ts lib/upload-hooks.ts +"use client" + +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "./uploads" + +export const { useUpload } = uploadHooks() +``` + +```tsx components/avatar-picker.tsx +"use client" + +import { useUpload } from "@/lib/upload-hooks" + +export function AvatarPicker({ src }: { src: string }) { + const { start, upload, accept } = useUpload() + const current = upload?.status === "done" ? (upload.blob.data.avatarUrl ?? src) : src + + return ( + + ) +} +``` + +`upload.blob.data` is typed from what `onUploadComplete` returned, so `avatarUrl` is checked at compile time rather than hoped for. + +### Why this shape + +A stable path is the one case where overwriting is the intent. There is exactly one object per user, the second upload replaces the first, and that removes both jobs the unique-path shape has to do: no pending row to reconcile, because a completion that never arrives leaves the previous avatar in place rather than an orphan, and no old avatars to sweep, because there are never two. + +`cache: 'immutable'` on a path that gets overwritten would normally be the wrong answer, and it is safe here only because nothing links the bare `url`. `versionedUrl` is `url` with `?v=` appended, so new bytes are a new URL and the old one is never asked for again. The alternative is `'revalidate'`, which keeps one URL and pays a 304 per read. + +The `try`/`catch` is not decoration: any throw out of `onUploadComplete` [deletes the object that just landed](/docs/blob/browser/upload-handler#onuploadcomplete). Swallowing the database error costs a stale `avatarUrl` instead, and that is recoverable, because the path is `avatars/${user.id}.png` and `bucket.info(path)` gives the etag the URL is built from. + +*** + +## Chat attachments + +Many files per thread, none of them overwriting each other, with a row written before the upload and cleared after it. + +```ts lib/uploads.ts +import "server-only" +import * as z from "zod" +import { BlobError, uniquePath, uploadHandler, uploadRoute } from "@upstash/blob" +import { requireUser, type Session } from "./auth" +import { sql } from "./db" + +export const uploads = uploadHandler({ + // Written above `routes`: it runs once per POST, before any body is read, and its value is `ctx`. + context: (request: Request) => requireUser(request), + + routes: { + attachment: uploadRoute()({ + constraints: { maxBytes: "25mb" }, + input: z.object({ threadId: z.string().uuid() }), + + onBeforeUpload: async ({ ctx, input, file }) => { + const thread = await sql`select id from threads + where id = ${input.threadId} and member_id = ${ctx.id}` + if (thread.length === 0) throw new BlobError("forbidden") + + const path = uniquePath`threads/${input.threadId}/${file.name}` + const [row] = await sql`insert into pending_uploads (thread_id, user_id, path) + values (${input.threadId}, ${ctx.id}, ${path}) + returning id` + + return { + path, + metadata: { row: row.id }, + state: { rowId: row.id, threadId: input.threadId }, + } + }, + + onUploadComplete: async ({ state, uploadId, path, url, size, contentType, file }) => { + // uploadId is stable across the browser's retries of the completion request, so + // at-least-once delivery writes one row. + await sql`insert into attachments (upload_id, thread_id, path, url, size, content_type, name) + values (${uploadId}, ${state.threadId}, ${path}, ${url ?? null}, + ${size}, ${contentType}, ${file.name}) + on conflict (upload_id) do nothing` + + // Last, always. "Row still pending" is what the sweep below reads as "never accepted". + await sql`delete from pending_uploads where id = ${state.rowId}` + + return { attachmentId: uploadId } + }, + }), + }, +}) +``` + +The schema is validated before `onBeforeUpload` runs, so a thread id that is not a UUID is a `400` and nothing is signed, no row is inserted, and no presigned URL exists. + +```ts app/api/upload/route.ts +import { uploads } from "@/lib/uploads" + +export const { GET, POST } = uploads +``` + +```ts app/api/cron/sweep-uploads/route.ts +import { BlobError, Bucket } from "@upstash/blob" +import { sql } from "@/lib/db" + +const bucket = Bucket.fromEnv() + +export async function GET() { + const stale = await sql`select id, path from pending_uploads + where created_at < now() - interval '1 hour' + limit 500` + + for (const row of stale) { + try { + // metadata comes back unstripped, so this confirms the object is the one the row reserved. + const info = await bucket.info(row.path) + if (info.metadata.row === row.id) await bucket.del(row.path) + } catch (e) { + // info() throws rather than returning undefined. Already gone is the good case. + if (!BlobError.is(e) || e.code !== "not_found") throw e + } + await sql`delete from pending_uploads where id = ${row.id}` + } + + // Parts a tab left behind over the multipart threshold, which list() cannot see. + const aborted = await bucket.abortStaleMultipartUploads({ olderThan: "1d", prefix: "threads/" }) + + return Response.json({ swept: stale.length, aborted: aborted.length }) +} +``` + +```tsx components/attachment-input.tsx +"use client" + +import { useUpload } from "@/lib/upload-hooks" + +export function AttachmentInput({ threadId }: { threadId: string }) { + const { start, uploads: files } = useUpload("attachment") + + return ( + <> + start({ files: e.target.files, input: { threadId } })} + /> + +
    + {files.map((file) => ( +
  • + {file.file.name} + {file.pending && } + {file.status === "done" && attached} + {file.status === "error" && {file.error.message}} +
  • + ))} +
+ + ) +} +``` + +`input` is required by the hook because the route declared a schema, and its shape is the schema's, so a missing or misspelled `threadId` fails to compile rather than at `begin`. + +### Why this shape + +`uniquePath` sanitizes what you interpolate and appends a random suffix, so two people sending `photo.png` to one thread get two objects rather than a lost update. + +The pending row is what makes a closed tab recoverable: write it in `onBeforeUpload`, clear it last in `onUploadComplete`, and sweep what is still pending past a grace window. Why the row is the only thing that can tell an abandoned upload from a finished one is in [Abandoned uploads](/docs/blob/browser/abandoned-uploads). + +Because the sweep exists, a database error is allowed to escape `onUploadComplete` here, unlike in the avatar formula. The throw deletes the object, the pending row survives, the cron's `not_found` branch clears it, and the user gets an error for an upload that genuinely did not land. + +*** + +## More formulas coming + +The other rows of the table above land here next, each as a full set of files. + +# Pricing & Limits +Source: https://upstash.com/docs/blob/overall/pricing + +Please check our [pricing page](https://upstash.com/pricing/blob) for the most up-to-date information on pricing and limits. + +# Quickstart +Source: https://upstash.com/docs/blob/overall/quickstart + +Upstash Blob is S3-compatible object storage with an SDK for three jobs: writing objects from your server with `Bucket`, letting a browser upload straight to storage without the bytes ever passing through your app, and driving that upload from React with hooks. This page takes the direct browser upload path from nothing to a working file picker on Next.js App Router. + +*** + +## Install + + +```bash npm +npm install @upstash/blob +``` + +```bash pnpm +pnpm add @upstash/blob +``` + +```bash yarn +yarn add @upstash/blob +``` + +```bash bun +bun add @upstash/blob +``` + + +The package has three entrypoints: `@upstash/blob` for the server, `@upstash/blob/browser` for a plain browser client, and `@upstash/blob/react` for the hooks. + +*** + +## Get a bucket token + +Create a bucket in the [Upstash Console](https://console.upstash.com) and copy its token. + +```bash .env.local +UPSTASH_BLOB_TOKEN=... +``` + +Everything below reads this variable, and `Bucket.fromEnv()` is the call that reads it. + +*** + +## Upload from the browser + + + + + +The handler decides who may upload, where the object goes, and what happens once it lands. It runs on your server and signs the upload. It never sees the bytes. With no `bucket` of its own it builds one from `UPSTASH_BLOB_TOKEN`, once. + +The smallest one that works states what it accepts and where the object goes: + +```ts lib/uploads.ts +import "server-only" +import { uniquePath, uploadHandler } from "@upstash/blob" + +export const uploads = uploadHandler({ + constraints: { maxBytes: "20mb", contentTypes: ["image/*", "application/pdf"] }, + + onBeforeUpload: ({ file }) => ({ path: uniquePath`uploads/${file.name}` }), +}) +``` + +`uniquePath` sanitizes what you interpolate and adds a random suffix, so two people picking `photo.png` do not land on the same object. Sizes are decimal, so `'20mb'` is 20,000,000 bytes. The grammar behind `constraints` is covered in [Constraints](/docs/blob/browser/constraints). + + + + + +The handler is already a pair of route handlers. `POST` runs the upload, `GET` serves the route's constraints. + +```ts app/api/upload/route.ts +import { uploads } from "@/lib/uploads" + +export const { GET, POST } = uploads +``` + +`/api/upload` is where the hooks look by default, so nothing else has to name a URL. + + + + + +`uploadHooks()` reads the handler's type. That is how `upload.blob.data` on the client is typed from what `onUploadComplete` returned, and how a route name that does not exist fails to compile. + +```ts lib/upload-hooks.ts +"use client" + +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "./uploads" + +export const { useUpload } = uploadHooks() +``` + +The import is `import type`, so the `server-only` module is erased and never reaches the browser bundle. + + + + + +```tsx app/page.tsx +"use client" + +import { useUpload } from "@/lib/upload-hooks" + +export default function Page() { + const { start, upload, accept } = useUpload() + + return ( +
+ start({ file: e.target.files?.[0] })} + /> + + {upload &&

{upload.status}

} + {upload?.pending && } + {upload?.status === "done" && {upload.blob.path}} + {upload?.status === "error" &&

{upload.error.message}

} +
+ ) +} +``` + +`accept` comes from the route's own `GET`, so the file picker is filled from the same list that does the refusing. `status` is one of `queued`, `uploading`, `finishing`, `paused`, `done`, `canceled` or `error`, and `pending` is true for the first four. `upload.error` is a `BlobError` with the `code` your server raised. + +
+ + + +Uploads work at this point. What the handler above does not do is check who is asking or write anything down, and both go in the same two callbacks. `onBeforeUpload` runs before a byte is signed, so a throw there costs nothing; `onUploadComplete` runs once the object exists. + +```ts lib/uploads.ts +import "server-only" +import { BlobError, uniquePath, uploadHandler } from "@upstash/blob" +import { getUser } from "./auth" +import { db } from "./db" + +export const uploads = uploadHandler({ + constraints: { maxBytes: "20mb", contentTypes: ["image/*", "application/pdf"] }, + + onBeforeUpload: async ({ request, file }) => { + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") // the 401, and nothing is signed + return { path: uniquePath`${user.id}/${file.name}`, metadata: { owner: user.id } } + }, + + onUploadComplete: async ({ url, metadata, uploadId }) => { + // uploadId is stable across retries, so the same completion twice writes one row + await db.files.upsert({ uploadId, owner: metadata.owner, url }) + }, +}) +``` + +A throw out of `onUploadComplete` deletes the object that just landed, so catch your own database errors rather than letting them escape. [Upload handler](/docs/blob/browser/upload-handler#onuploadcomplete) has the whole callback. + + + +
+ +A file under 16 MB goes up as one presigned PUT. Anything larger is cut into parts, which is what buys pause, resume and per-part retry. See [Large files](/docs/blob/browser/large-files). + +*** + +## Server uploads + +For bytes that are already on your server, skip the handler and write the object directly. + +```ts lib/reports.ts +import { Bucket } from "@upstash/blob" + +const bucket = Bucket.fromEnv() + +export async function saveReport(pdf: Blob) { + const blob = await bucket.put("reports/2026-01.pdf", pdf, { contentType: "application/pdf" }) + return blob.url +} +``` + +`blob.url` is the public object URL, and `undefined` on a private bucket. The rest of the write API, including metadata, caching and conditional writes, is in [Writing](/docs/blob/bucket/writing). + +*** + +## CORS + +A direct upload PUTs to storage, not to your app, so the bucket's CORS configuration is what decides whether the browser is allowed to send it at all. Four things have to be true, and they come straight from what the SDK signs: + +| The policy must allow | Because | +| --- | --- | +| the origin your page is served from | that is who sends the PUT | +| the `PUT` method | a presigned upload is one `PUT` per object or per part | +| the request headers `content-type`, `cache-control`, and every `x-amz-meta-*` the route writes | a single PUT carries them as real headers, pinned into the signature, so the browser must send them verbatim | +| `ETag` in the exposed response headers | the browser reads each part's etag off a cross-origin response to complete the upload | + +The metadata header names are yours: a route returning `metadata: { owner }` sends `x-amz-meta-owner`. A multipart part PUT pins only `content-length`, so it is the single-PUT path that needs the header list. Exposing `Retry-After` too is worth doing but not required; without it the client backs off on its own schedule rather than the one storage asked for. + +A browser blocks a request that fails CORS before a single byte goes out, and script never sees why. The SDK gives up after three such attempts and says so, rather than backing off for minutes over an answer that will not change. + +*** + +## Next steps + + + + Routes, context, input schemas and the completion callback in full. + + + + Size limits, content types and what the byte check does and does not prove. + + + + Parts, pause, resume and retry, and where the threshold sits. + + + + `put`, metadata, conditional writes and multipart from the server. + + + + Read and upload links for anything outside the browser flow. + + + + Whole features wired end to end: avatars, chat attachments, and the files they take. + + + + What storage, requests and egress cost. + + + +# How Signing Works +Source: https://upstash.com/docs/blob/overall/signing + +Your server holds a bucket token. It exchanges that token for short-lived S3 credentials from Upstash, signs individual URLs with those credentials, and hands only the URLs to the browser. The token, the credentials and the bucket password never leave your server. Every URL a browser holds is scoped to one object, one method, one set of headers, and a few minutes. + +This page follows that chain from the token in your environment down to the bytes landing in storage, so you can reason about what a browser holding one of these URLs can and cannot do. If you only want to upload a file, start with the [Quickstart](/docs/blob/overall/quickstart) instead. + +*** + +## The bucket token + +`UPSTASH_BLOB_TOKEN` is a base64url string. It is not an opaque handle: the SDK decodes it locally with `decodeToken`, and no network call is involved. + +| Offset | Bytes | Meaning | +| --- | --- | --- | +| 0 | 1 | version, `0x02` | +| 1 | 1 | flags | +| 2 | 1 | length of `bucketId` | +| 3 | 2 | length of `password`, big endian | +| 5 | 1 | length of `hashForDomain` | +| 6 | rest | the three fields, in that order, UTF-8 | + +Decoding is strict. A version byte other than `0x02` is `token: unsupported format`. A zero-length field is `token: malformed`. The total length has to be exactly `6 + idLen + pwLen + hLen`, so a trailing byte is tamper rather than padding and is refused. Surrounding whitespace is trimmed, and anything that is not base64url throws `token is not base64url`. + +The three fields do different jobs. + +| Field | What it is for | +| --- | --- | +| `bucketId` | Names the bucket inside your account. It is the `b` claim in every completion token, checked when one is spent, which is what stops a token minted for one bucket from being spent against another. It is also the bucket name `bucket.s3()` hands the aws-sdk. It is not in the public object URL: that is built from `hashForDomain`, and storage request paths use the `bucket` the credentials response names. | +| `hashForDomain` | The bucket's public DNS label. Objects are served from `.blob.upstash.io`, so `bucket.publicUrl(path)` is pure string work with no request. On a private bucket it returns `undefined`, because nothing serves that host. | +| `password` | The HMAC key for [completion tokens](#the-completion-token). It is never sent anywhere, not to Upstash and not to storage. It only ever keys a MAC inside your process. | + + +The token is a bearer secret. Anything holding it can mint credentials for the whole bucket. Keep it server side, never in `NEXT_PUBLIC_`, `VITE_`, or any other variable your bundler inlines into client code. + + +*** + +## Minting temporary credentials + +The token is not an S3 credential. To touch storage, the SDK exchanges it: + +```http +POST https://blob.upstash.io/v1/credentials +Authorization: Bearer +``` + +The request carries no body and times out after 10 seconds. Telemetry headers ride along unless you set `UPSTASH_DISABLE_TELEMETRY` or pass `enableTelemetry: false`. + +The response is the whole picture of where this bucket lives: + +| Field | Meaning | +| --- | --- | +| `accessKeyId`, `secretAccessKey`, `sessionToken` | The temporary S3 credential everything else is signed with. | +| `endpoint` | The R2 endpoint every object request goes to. | +| `bucket` | The bucket name inside that endpoint. | +| `region` | The region the SigV4 scope is built from. | +| `expiresAt` | Unix seconds. The hard ceiling on anything signed with this credential. | +| `visibility` | `'public'` or `'private'`. Private drops `url` and `versionedUrl` from every record. | +| `signing` | Optional, and longer lived. A read-only credential used only for presigning reads. | + +Because the `endpoint` decides where every subsequent request goes, the SDK refuses to take it on faith. It must parse as a URL, its protocol must be `https:`, and its hostname must end in `.r2.cloudflarestorage.com`. Anything else is `request_failed` with the message `Credentials response named an unexpected endpoint`, before a single byte is signed. + +### Caching + +The credential cache is keyed by the token, not held on the `Bucket` instance. `Bucket.fromEnv()` inside a request handler is the documented shape on every serverless platform, and an instance-held cache would mint a fresh credential for each request. Constructing a bucket per request is free. + +Two constants shape when a re-mint happens: + +* `REFRESH_MARGIN_MS` is 30 seconds. A cached credential is considered usable until 30 seconds before it expires. +* `NO_BETTER_MS` is 30 seconds. The Upstash agent hands back its own cached credential until roughly 60 seconds of life remain on it, so asking for a longer one often returns exactly what you already had. When a re-mint comes back no later than the credential it replaced, the SDK stops asking for 30 seconds. Mints are an account-wide budget, and a loop that refetches on every operation is how you reach the rate limiter. + +Concurrent callers share one in-flight mint rather than racing. + +### When minting fails + +| Response | What you get | +| --- | --- | +| 401 | `unauthorized`, `the bucket token was rejected` | +| 429 or 503 with `Retry-After` under 10s | Waited out and retried, up to 3 retries. A missing or unparseable `Retry-After` becomes 2 seconds. | +| 429 or 503 with `Retry-After` over 10s | `mint_backoff` immediately, carrying `retryAfter`. No request can usefully block that long, so the caller is told to come back. | +| 429 after the retries | `rate_limited` | +| 503 after the retries | `not_ready` | +| anything else | `request_failed` with status 502 | + +See [Errors](/docs/blob/bucket/errors) for the full code list. + +*** + +## Signing a request + +Signing is AWS Signature Version 4 over Web Crypto, service `s3`, region taken from the credential. The payload hash is `UNSIGNED-PAYLOAD` everywhere, which is what lets a body stream through without being buffered and hashed first. + +There are two modes, and which one is used tells you who is making the request. + +| Mode | Where the signature lives | Used for | +| --- | --- | --- | +| `signHeaders` | An `Authorization` header, plus `x-amz-date`, `x-amz-content-sha256` and `x-amz-security-token` | Every request your server makes to storage: `put`, `get`, `list`, `del`, `CreateMultipartUpload`, `CompleteMultipartUpload`. | +| `presign` | Query string: `X-Amz-Algorithm`, `X-Amz-Credential`, `X-Amz-Date`, `X-Amz-Expires`, `X-Amz-SignedHeaders`, `X-Amz-Security-Token`, `X-Amz-Signature` | Every URL handed to a browser, and every URL you make with `signedReadUrl()` or `signedUploadUrl()`. | + +### Signed headers + +A presigned URL can pin headers. Each pinned header name and value is folded into the canonical request, lowercased, trimmed, with runs of inner whitespace collapsed, and the sorted list of names is published in `X-Amz-SignedHeaders`. + +The consequence is the important part: the client has to send those headers back byte for byte. Change a value, or omit a header the URL declared, and storage answers 403. That is not a rule the SDK enforces on the client, it is arithmetic inside the signature. A header pinned into a URL is not the client's to choose. + +Query parameters work the same way. `response-content-disposition` on a signed read URL rides inside the signature, so a link whose filename was edited afterwards is refused rather than honoured. + +### Path encoding + +S3 wants every character outside `A-Za-z0-9-_.~` percent-encoded, including the ones `encodeURIComponent` leaves alone, and encoded as uppercase hex UTF-8 bytes. `uriEncode` does that; `encodeKey` applies it per path segment so slashes stay structural. + +`encodeKey` also refuses any path containing a `.` or `..` segment outright, rather than normalising it. The reason is the trust model: a temporary credential authorizes the whole bucket, and the URL parser resolves `..` before the request is signed, so a traversing key would sign a request against a different object than the one your code named. Rejecting is the only safe answer. `uniquePath` guards the same boundary from the other side, by stripping directory components out of every interpolated value. Its rules are on [Writing](/docs/blob/bucket/writing#uniquepath). + +*** + +## How long a presigned URL lives + +A presigned URL cannot outlive the credential that signed it. R2 checks the credential at the start of the request, so a URL with `X-Amz-Expires=3600` on a credential with 200 seconds left stops working in 200 seconds. + +The SDK works with that instead of around it. `R2.presign` signs for `Math.min(expiresIn, credential remaining)`, so the `X-Amz-Expires` in the URL is never a promise the credential cannot keep. + +Being born stale is the other half of the problem. `minRemainingSeconds` is passed down to the credential cache: it means "re-mint if less than this is left", so a URL is signed against a credential that can actually carry it. The direct upload path asks for 3600 seconds with `minRemainingSeconds` of 120, which is why a fresh part URL always has a usable window even when the cached credential was nearly done. + +The defaults and the cap: + +* `DEFAULT_READ_SECONDS` is 300. A read link with no `expiresIn` asks for 5 minutes, or the cap if that is lower. +* `DEFAULT_WRITE_SECONDS` is 3600. A write link with no `expiresIn` asks for an hour, and because writes must use the object credential, `presignWrite` re-mints rather than hand back a link that dies early. +* `capOf(credential)` is the seconds left on whichever credential will actually sign: the read-only `signing` credential when the backend supplied one, otherwise the object credential. +* `worthReminting` decides whether a read that asked for longer than the cap is worth a round trip. It is true only when there is no `signing` credential and the current one has lost more than `WORTH_REMINTING_S`, 30 seconds, of its original lifetime. Below that, a fresh mint would come back with the same expiry, so asking is wasted and the link is simply capped. + +Reads are signed with the `signing` credential when one is present, which is why a read link can outlive the object credential. Writes cannot use it, since it is read-only. + +All of which is why `signedReadUrl()` returns `expiresAt` rather than making you compute it: + +```ts +const { url, expiresAt } = await bucket.signedReadUrl('private/report.pdf'); +// expiresAt is the real answer for this link: min(what you asked for, what the signer had left) +``` + +Cache the link until `expiresAt` and re-sign after. Do not assume five minutes. + +*** + +## The direct upload handshake + +A direct browser upload is four phases against your own route. Your route is the only thing that ever sees the token or the credentials. + +```text +browser your route blob.upstash.io R2 + | | | | + | phase 'begin' | | | + |------------------> | | + | | POST /v1/credentials| | + | |----------------------> | + | | temp credentials | | + | <----------------------| | + | | | | + | | CreateMultipartUpload (multipart only) | + | |---------------------------------------------> + | | uploadId | + | <---------------------------------------------| + | completion token + presigned URLs | | + <------------------| | | + | | | | + | PUT the bytes: presigned URL + pinned headers | + |----------------------------------------------------------------> + | 200 + etag | | + <----------------------------------------------------------------| + | | | | + | phase 'parts': next URL batch, ListParts | + |------------------> | | + | | | | + | phase 'end': part etags | | + |------------------> | | + | | CompleteMultipartUpload, then HEAD | + | |---------------------------------------------> + | blob record + onUploadComplete data | | + <------------------| | | +``` + +| Phase | What your route does | What it signs | What it returns | +| --- | --- | --- | --- | +| `begin` | Enforces [Constraints](/docs/blob/browser/constraints), runs `onBeforeUpload`, and for a large file creates the multipart upload | The first PUT URL, or the first batch of part URLs | `WireBeginResponse`: `completionToken`, `path`, and an upload plan carrying `partSize`, `multipart` and `parts` | +| `parts` | Verifies the completion token, asks R2 `ListParts` for what already landed | The next batch of part URLs, 16 at a time | `WirePartsResponse`: `partSize`, `size`, `multipart`, `parts`, `landed` | +| `end` | Verifies the token, completes the multipart or checks the marker, reads the object back, runs `onUploadComplete` | Nothing new | `WireEndResponse`: the blob record plus whatever `onUploadComplete` returned | +| `cancel` | Verifies the token, aborts the multipart or deletes a matching single-PUT object | Nothing | `{ ok: true }` | + +The browser never sees the bucket token and never sees an S3 credential. It sees per-object presigned URLs, the headers those URLs pin, and a completion token. Nothing it holds can list the bucket, read another object, or write to a path your `onBeforeUpload` did not choose. + +`GET` on the same route serves the constraints document, with an ETag and `max-age=60`, so a file picker can be filled from the same list that does the refusing. See [Upload handler](/docs/blob/browser/upload-handler) for the callbacks and [Large files](/docs/blob/browser/large-files) for the multipart path. + +*** + +## The completion token + +The completion token is what carries an upload's identity between phases without keeping server state. It is a base64url JSON payload and an HMAC-SHA256 over that payload, joined by a dot: + +```text +. +``` + +The key is `upstash-blob-completion:`, so it is derived from the token you already hold and never from anything the request supplies. Comparison is timing safe. + + +The token is signed, not encrypted. Anyone can open devtools, base64-decode the first half, and read the whole payload including `ctx`. Whatever `onBeforeUpload` returns as `state` must be a row id or something equally boring. Never a secret, never a signed URL, never an internal flag you would not print on the page. + + +The payload: + +| Field | What it locks down | +| --- | --- | +| `v` | Payload version. Anything but `1` is refused even with a valid MAC. | +| `b` | Bucket id, checked against this bucket. | +| `r` | Route id, checked against this route. | +| `id` | The upload id. It is the idempotency key `onUploadComplete` receives as `uploadId`, and on a single PUT it is also the marker value. | +| `path` | The object key `onBeforeUpload` chose. The browser cannot move an upload to another path by asking. | +| `n` | The file name the browser gave, which is the one thing the stored object does not keep. | +| `type` | The declared content type. | +| `size` | The declared byte length. `end` compares it against the stored object and refuses a mismatch. | +| `headers` | The headers pinned into the signature, so a re-presign at `parts` reproduces the same ones. | +| `ctx` | Whatever `onBeforeUpload` returned as `state`. | +| `exp` | Unix ms. Seven days out. | +| `uploadId` | R2's own multipart id, so `end` can complete it and `cancel` can abort it. Absent on a single PUT, where there is nothing to complete. | +| `partSize` | The part size for a multipart, or the whole file size for a single PUT, so one part covers it. | + +Verification is three checks past the MAC: the bucket id must match, the route id must match, and `exp` must be in the future. A failure of any of them is `forbidden`, not a 500. A token minted at one route is not spendable at another. + +### Route ids + +The route id comes from `deriveRouteId`, an FNV-1a hash over the route name, its resolved constraints (`contentTypes` and `maxBytes`), and whether the route takes `input`. When a handler declares an `endpoint`, the name is prefixed with it, which is what separates two handlers that mount the same route names on one bucket. All routes on a bucket sign with the same key, so without this a completion token from a 2 MB avatar route would be spendable at a 2 GB video route. + + +FNV-1a is not the security boundary here. It is a short, stable label for "which route is this". The MAC is what makes the payload unforgeable, and it covers the route id like every other field. Changing a route's constraints changes its id, which invalidates completion tokens issued under the old shape. That is intentional: the grant no longer describes what the route enforces. + + +*** + +## Pinned headers, and why the browser cannot forge metadata + +For a file under the multipart threshold, the browser writes the object itself with a single PUT. Everything the object should carry has to be decided on your server and pinned into that URL's signature: + +* `content-type`, from the file the browser declared +* `cache-control`, resolved from the route's or bucket's [cache option](/docs/blob/bucket/caching) and the bucket visibility +* every `x-amz-meta-*` derived from the `metadata` your `onBeforeUpload` returned +* `content-length`, the exact declared size +* `x-amz-meta-upstash-upload`, the marker + +Signed, not merely sent. An unsigned header would be the browser's to choose, and then metadata your app reads back in `onUploadComplete` would be the client's to write. Because they are signed, the browser must echo them exactly and cannot substitute an `owner` that is not theirs. + +For a multipart upload the same headers are pinned earlier and elsewhere: they are sent with `CreateMultipartUpload`, signed by your server with an `Authorization` header, and the object inherits them at completion. Each part URL then signs only `content-length`. Part URLs carry no headers at all in the wire response, which is why the browser sets none of ours on a part PUT. + +Because those headers ride on a cross-origin request, the bucket's CORS policy has to allow them. The exact shape is in [CORS](/docs/blob/overall/quickstart#cors). + +*** + +## The `upstash-upload` marker + +On a single PUT, `begin` mints a UUID, writes it as `x-amz-meta-upstash-upload`, and signs it into the URL. The browser cannot set it, cannot change it, and `metadata.upstash-upload` from your own `onBeforeUpload` is refused as reserved. + +It answers exactly one question: did the bytes at this path come from THIS upload? A multipart upload answers that by construction, because the object does not exist until `end` completes it, so an object that exists is one this token created. A single PUT has no such guarantee. The presigned PUT stores the object the moment the last byte lands, so by the time `end` runs, the object at that path could be a stale token's, a concurrent upload's, or something that was there all along. + +So `end` requires a marker match on the single-PUT path. No match is `not_found`, `the upload never landed`. `cancel` uses the same check, and it is the whole check there, because the request body says nothing about which object to [delete](/docs/blob/bucket/deleting). That is what stops a cancel from deleting someone else's file at the same path. + +The marker is deleted from the record handed to `onUploadComplete` and `onError`, but not from the stored object. Nothing on the completion path rewrites metadata. + +A marker match proves "same upload", and never "no callback accepted it". What that costs, and the pending row that closes it, is on [Abandoned uploads](/docs/blob/browser/abandoned-uploads). + +*** + +## Retries and 403 + +A 403 from storage is ambiguous by design. An expired presigned URL and a tampered request produce the same status, and the browser cannot tell which it is looking at. So the client's `classify` treats 401 and 403 as `represign` rather than `fail`, throwing the batch of URLs away and asking the route for fresh ones. The rest of the classification, and the retry budgets, are in [Large files](/docs/blob/browser/large-files#retries). + +Re-presigning forever would hide a real signature problem, so there is a clock on it. `PRESIGN_STALE_MS` is 60 seconds. A 403 on a URL minted more than a minute ago is read as the clock however often it happens, because a 5 MiB part on a slow link genuinely outruns a presign more than once. A 403 on a freshly minted URL, for a part that has already been re-presigned once, is a real `signature_mismatch` and ends the upload. So does exhausting the 8-attempt budget. + +Two more bounds sit around that loop. `MAX_URL_BATCHES` is 4: a part that waits through four batches without the route ever signing it fails rather than spinning with no backoff. And a re-presign always throws away the whole batch, not just the one URL, because every URL in a batch was signed against the same credential and expires with it. + +Your server has the same ambiguity and resolves it by reading the body. `R2.fetch` re-mints once, and only once per request, when a 403 body matches `ExpiredToken`, `InvalidAccessKeyId` or `TokenRefreshRequired`, or when the cached credential has visibly expired (a `HEAD` carries no body to name a reason). Any other 403 is returned as-is and surfaces as `signature_mismatch`, usually meaning the body length or type differs from what was signed. + +*** + +## What the browser stores + +One thing: the completion token, in `localStorage`, under a key built from the route, the file name, the file size and its `lastModified`. Nothing else is worth the exposure, and in particular nothing about what landed is stored, since the server can ask R2 for that. [Large files](/docs/blob/browser/large-files#resuming-after-a-reload) covers the key, the resume gesture and what happens when `localStorage` is unavailable. + +*** + +## Signed URLs you make yourself + +The same machinery is available directly, for a CLI, a server-to-server job, or a link in an email. + +```ts +const { url, expiresAt } = await bucket.signedReadUrl('private/report.pdf', { + downloadAs: 'Q3 Report.pdf', + expiresIn: '15m', +}); + +const upload = await bucket.signedUploadUrl('u/7/report.pdf', { + contentType: 'application/pdf', + size: bytes.byteLength, +}); +await fetch(upload.url, { method: 'PUT', headers: upload.headers, body: bytes }); +``` + +`signedReadUrl` puts `downloadAs` into `response-content-disposition` as an RFC 6266 header, carrying the real name in `filename*` as an RFC 8187 ext-value, with an ASCII fallback cut back to characters that cannot end the quoted string. The name reaches storage as a query parameter and comes back as a header value, so a quote or a CRLF in it must not be able to add a header. A `contentType` override is validated as a media type for the same reason. + +`signedUploadUrl` pins every header it returns into the signature: `content-type`, `cache-control`, your `x-amz-meta-*`, `content-length` when you pass `size`, and `if-none-match: *` when you pass `overwrite: false`. Send the `headers` object verbatim. Anything changed, dropped or added is a 403, not a header the client got to choose. + +For an existing S3 client, `bucket.s3()` hands back the endpoint and the credentials as async providers rather than values. See [Writing](/docs/blob/bucket/writing#the-s3-escape-hatch). + +Full options are on [Reading](/docs/blob/bucket/reading) and [Writing](/docs/blob/bucket/writing). + +*** + +## What never reaches the browser + +* `UPSTASH_BLOB_TOKEN`, in any form. +* The bucket password. It only ever keys an HMAC inside your process. +* `accessKeyId`, `secretAccessKey` or `sessionToken`. They are only ever folded into a signature. +* Any ability to list, read, overwrite or delete outside the one object a single presigned URL names. +* Anything `context` or `onBeforeUpload` computed, except what you explicitly return as `metadata` (visible on the object) or `state` (visible in the completion token). + # Code Interpreter with Vercel AI SDK Source: https://upstash.com/docs/box/guides/ai-sdk-code-interpreter diff --git a/llms.txt b/llms.txt index c6e0b2d9..419f2364 100644 --- a/llms.txt +++ b/llms.txt @@ -34,6 +34,19 @@ - [Transfer Search Index](https://upstash.com/docs/api-reference/search/transfer-search-index.md): Transfers ownership of a search index to another team. Transferring to a personal account is not supported. However, transferring from a personal account to a team is allowed. - [Get Index Stats](https://upstash.com/docs/api-reference/vector/get-index-stats.md): Retrieves statistics and metrics for a specific vector index - [Get Vector Stats](https://upstash.com/docs/api-reference/vector/get-vector-stats.md): Get vector statistics for all the vector indices associated with the authenticated user +- [Abandoned Uploads](https://upstash.com/docs/blob/browser/abandoned-uploads.md) +- [Constraints](https://upstash.com/docs/blob/browser/constraints.md) +- [Large Files](https://upstash.com/docs/blob/browser/large-files.md) +- [Upload Handler](https://upstash.com/docs/blob/browser/upload-handler.md) +- [Caching](https://upstash.com/docs/blob/bucket/caching.md) +- [Deleting](https://upstash.com/docs/blob/bucket/deleting.md) +- [Errors](https://upstash.com/docs/blob/bucket/errors.md) +- [Reading](https://upstash.com/docs/blob/bucket/reading.md) +- [Writing](https://upstash.com/docs/blob/bucket/writing.md) +- [Formulas](https://upstash.com/docs/blob/formulas/overview.md) +- [Pricing & Limits](https://upstash.com/docs/blob/overall/pricing.md) +- [Quickstart](https://upstash.com/docs/blob/overall/quickstart.md) +- [How Signing Works](https://upstash.com/docs/blob/overall/signing.md) - [Code Interpreter with Vercel AI SDK](https://upstash.com/docs/box/guides/ai-sdk-code-interpreter.md) - [Build a Code Review Agent](https://upstash.com/docs/box/guides/code-review-agent.md) - [Running Tests with Crabbox](https://upstash.com/docs/box/guides/crabbox-setup.md) From 4de56d536f14c4f11b79516cfcfa5f1e868a96db Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Wed, 2 Sep 2026 00:26:43 +0200 Subject: [PATCH 04/41] docs(blob): cut SDK internals and design rationale --- blob/browser/abandoned-uploads.mdx | 50 ++---- blob/browser/constraints.mdx | 48 ++---- blob/browser/large-files.mdx | 142 +++++------------ blob/browser/upload-handler.mdx | 78 +++++---- blob/bucket/caching.mdx | 10 +- blob/bucket/deleting.mdx | 28 ++-- blob/bucket/errors.mdx | 22 ++- blob/bucket/reading.mdx | 35 ++--- blob/bucket/writing.mdx | 53 +++---- blob/overall/quickstart.mdx | 12 +- blob/overall/signing.mdx | 245 ++++++++--------------------- 11 files changed, 238 insertions(+), 485 deletions(-) diff --git a/blob/browser/abandoned-uploads.mdx b/blob/browser/abandoned-uploads.mdx index 4bd2a818..78e80bdf 100644 --- a/blob/browser/abandoned-uploads.mdx +++ b/blob/browser/abandoned-uploads.mdx @@ -24,27 +24,13 @@ The default threshold is 16 MB. [Large files](/blob/browser/large-files#what-cha **Under the threshold**, the presigned PUT is the object write. Storage has the whole object the moment the last byte lands, before your route has been told anything. The completion request that runs `onUploadComplete` is a separate call, made by the browser, after the PUT. A browser that dies between the two leaves an ordinary object: `list()`-visible, billed, already served by the public host if the bucket is public, and accepted by no callback of yours. -That is what one round trip instead of three costs for the files most apps upload most often. - --- ## Why the SDK cannot tell the difference -Every single-PUT upload carries a marker. The SDK mints a random id at phase `begin` and writes it into the presigned URL as `x-amz-meta-upstash-upload`, as a signed header. Signed matters: the browser has to send the header verbatim or storage answers 403, so it cannot be forged, dropped or aimed somewhere else. `metadata.upstash-upload` is reserved, and returning it from `onBeforeUpload` is refused with `invalid_input`. - -At phase `end` the route reads the object back and compares: - -```ts -if (head.metadata[UPLOAD_MARKER] !== t.id) throw new BlobError("not_found", { message: "the upload never landed" }) -``` - -That answers the one question a multipart upload answers by construction: are the bytes at this path the ones **this** upload put there? It is what lets a refusal in `onUploadComplete` delete only what this upload wrote, instead of deleting whatever happens to be standing at the path. +The SDK marks every single-PUT upload with a signed id, so it can always tell whether the object at a path came from **this** upload. That is what lets a refusal in `onUploadComplete` delete only what this upload wrote rather than whatever happens to be standing at the path. -What it does not answer is whether anything ever accepted the object. The marker stays on the stored object after completion. Phase `end` deletes it from the record it hands your callbacks, so `metadata` in `onUploadComplete` never contains it, but nothing rewrites the object, so `bucket.info(path).metadata['upstash-upload']` is still set on a finished upload. A marker match proves "same upload". It never proves "never accepted". - -So the SDK, looking at a bucket, cannot separate an abandoned object from a finished one. Only your own rows can. - -See [How signing works](/blob/overall/signing) for how a header gets pinned into a signature. +What it cannot tell is whether anything ever accepted the object, because the mark stays on a finished upload too. Looking at a bucket alone, an abandoned object and a completed one are identical. Only your own rows can separate them. --- @@ -64,7 +50,7 @@ Write the row before the bytes, flip it after them, and sweep what never flipped -The order in step 2 is the whole pattern. Because the marker survives on accepted objects, the sweep's only safe premise is **row still pending implies the callback never finished**. Work done after the flip breaks that premise: the row says ready, the work never happened, and nothing will ever come back for it. Do the work first, flip last. +The order in step 2 is the whole pattern. The sweep's only premise is **row still pending implies the callback never finished**. Work done after the flip breaks it: the row says ready, the work never happened, and nothing will ever come back for it. Do the work first, flip last. ### The route @@ -149,13 +135,12 @@ Two constraints come with this example. **Verify before deleting.** `bucket.info(path)` is the check, and it is not optional. A row's path can be occupied by a different upload's object by the time the cron runs, and deleting on the row alone would destroy a file somebody's callback accepted. `info()` throws `not_found` rather than returning `undefined`, which is why the example catches `BlobError.is(e) && e.code === 'not_found'` instead of testing for a missing value. -**Pick a grace window longer than your longest upload.** A row is only evidence once the upload has had time to finish. Completion tokens live seven days and a paused multipart upload can be resumed long after it started, so a window measured in minutes will delete objects out from under uploads that are still running. +**Pick a grace window longer than your longest upload.** A paused upload can be resumed long after it started, so a window measured in minutes will delete objects out from under uploads that are still running. - `uploadId` is not handed to `onBeforeUpload` today. It is minted after the callback returns and - first reaches your code in `onUploadComplete`, which is why the example mints its own row id and - carries it through `metadata` and `state`. `state` reaches `onUploadComplete` typed, and `metadata` - is what `info()` reads back in the cron, so the same id covers both ends. + `uploadId` is not handed to `onBeforeUpload`; it first reaches your code in `onUploadComplete`. + That is why the example mints its own row id and carries it through both `metadata`, which the + cron reads back with `info()`, and `state`, which reaches `onUploadComplete` typed. --- @@ -214,11 +199,9 @@ export const uploads = uploadHandler({ }) ``` -Now nothing is stored until your handler completes the upload at phase `end`. A closed tab leaves an incomplete multipart upload, which `abortStaleMultipartUploads()` reaps, and the single-PUT orphan class disappears. Parts also buy pause, resume and per-chunk retry for files that would not have had them. - -One case survives it. If phase `end` is retried after the object already completed, `completeMultipart` throws `NoSuchUpload`, the route confirms the object landed and carries on, but `completedEtag` stays undefined. A refusal from `onUploadComplete` at that point deliberately leaves the object stored rather than delete one it cannot identify, and logs that it did. That leftover is a completed object, so `abortStaleMultipartUploads()` cannot reap it either. +Now nothing is stored until your handler completes the upload. A closed tab leaves an incomplete multipart upload, which `abortStaleMultipartUploads()` reaps, and the single-PUT orphan class disappears. Parts also buy pause, resume and per-chunk retry for files that would not have had them. -The cost is two extra server-to-storage round trips rather than extra browser requests. For an app that will not run a cron, this is one option value that removes the common case. +The cost is two extra round trips between your server and storage, not extra browser requests. For an app that will not run the pending-row cron, this is one option value that removes the common case. --- @@ -232,9 +215,9 @@ The cost is two extra server-to-storage round trips rather than extra browser re `the upload never landed`. A database blip costs the upload and then reports it as a phantom. -A retryable `BlobError` is not an escape either: any throw deletes the object first, so the retry it asks for arrives at an empty path. Retry the write in place, hand it to a queue, or simply leave the row pending and let the sweep decide later. Throw out of `onUploadComplete` only when you mean to refuse the file, because that throw is what deletes it. [Upload handler](/blob/browser/upload-handler#onuploadcomplete) has the callback in full. +A retryable `BlobError` is not an escape either: any throw deletes the object first, so the retry it asks for arrives at an empty path. Retry the write in place, hand it to a queue, or leave the row pending and let the sweep decide later. Throw out of `onUploadComplete` only when you mean to refuse the file. [Upload handler](/blob/browser/upload-handler#onuploadcomplete) has the callback in full. -On a public bucket the delete is also less than it looks. The object has been readable since it was stored, through the whole of your callback, so deleting bounds the exposure to those few round trips rather than undoing it, and an edge that cached the object inside the window keeps serving it for its `Cache-Control`. +On a public bucket the delete is also less than it looks: the object has been readable since it was stored, so deleting bounds the exposure rather than undoing it, and a CDN that cached it inside that window keeps serving it for its `Cache-Control`. --- @@ -247,8 +230,6 @@ On a public bucket the delete is also less than it looks. The object has been re uploaded is gone. -The marker is what stops a refusal from deleting somebody else's file. It does not stop a lost update, and it does not stop the spurious 404 the losing upload gets. - `uniquePath` is the fix: a tagged template that sanitizes every interpolated value and appends a random suffix to the basename, so two uploads of the same filename never collide. Its rules are in [Writing](/blob/bucket/writing#uniquepath). ```ts @@ -262,13 +243,10 @@ Stable paths are a legitimate choice when overwriting is exactly what you want, ## What cancel() already handles -An explicit cancel is covered. `upload.cancel()` in the browser posts phase `cancel` with the completion token, and the route acts on which kind of upload it is: - -- **Multipart**: the upload is aborted, along with every part that landed. -- **Single PUT**: the route reads the object at the path and deletes it only if the marker matches this upload's id. The request body names no object, so the marker is the whole check, and a cancel cannot be pointed at an object this upload did not write. +An explicit cancel is covered. `upload.cancel()` tells your route, which aborts a multipart upload along with every part that landed, or deletes a single-PUT object once it has confirmed the object is this upload's. -One case is deliberately not covered. A cancel from `finishing`, once phase `end` is already running, does not post at all. The route has been asked to record the object and the answer is its to give, so racing it would ask the route to delete an object `onUploadComplete` may have just accepted and written a row for. The local task is canceled either way. +A cancel from `finishing` does not reach the route at all, because your `onUploadComplete` may already have accepted the object and written a row. The local task is canceled either way. -The gap is everything that is not an explicit cancel. There is no `beforeunload` handler and no `sendBeacon` anywhere in the SDK, so a crash, a closed tab or a lost network posts nothing at all. Nothing tells your server, and nothing can. +The gap is everything that is not an explicit cancel. A crash, a closed tab or a lost network tells your server nothing, and nothing can. That is exactly the gap the pending row closes. diff --git a/blob/browser/constraints.mdx b/blob/browser/constraints.mdx index 4e358169..444d0e5b 100644 --- a/blob/browser/constraints.mdx +++ b/blob/browser/constraints.mdx @@ -21,7 +21,7 @@ export const uploads = uploadHandler({ }) ``` -Both are enforced at phase `begin`, from the name, type and size the browser declared, before anything is signed and before `onBeforeUpload` runs. Nothing has been written down when a file is refused: no presigned URL exists, no row was inserted, no multipart upload was created. +Both are enforced from the name, type and size the browser declared, before anything is signed and before `onBeforeUpload` runs. Nothing has been written down when a file is refused: no presigned URL exists, no row was inserted, no multipart upload was created. Omitting `constraints` entirely accepts any type at any size. @@ -31,14 +31,14 @@ Omitting `constraints` entirely accepts any type at any size. `maxBytes` takes a `Size`: a number of bytes, or a string like `'20mb'`, `'500kb'`, `'5gb'`. -Sizes are **decimal**, matching how storage is billed. `'2mb'` is 2,000,000 bytes, not 2,097,152. The units are `b`, `kb`, `mb`, `gb` and `tb`, and binary spellings are not part of the vocabulary: `'5mib'` throws. The only binary math in the SDK is multipart part sizing, because R2's part floor is 5 MiB. +Sizes are **decimal**, matching how storage is billed. `'2mb'` is 2,000,000 bytes, not 2,097,152. The units are `b`, `kb`, `mb`, `gb` and `tb`. Binary spellings are not accepted: `'5mib'` throws. ```ts constraints: { maxBytes: "2mb" } // 2,000,000 constraints: { maxBytes: 4096 } // a bare number is bytes ``` -`formatBytes` is exported from `@upstash/blob`, `@upstash/blob/browser` and `@upstash/blob/react`, and it formats sizes the same decimal way they are parsed, so a refusal reads back in the units the limit was written in: +`formatBytes` is exported from all three entrypoints and formats sizes the same decimal way they are parsed, so a refusal reads back in the units the limit was written in: ``` cat.png is 2.4 MB, over the 2 MB limit @@ -58,8 +58,6 @@ Entries are lowercased, deduplicated, and keep the order you wrote them in. ### What the wildcards expand to -A wildcard is the media family, not the subset the byte sniffer happens to recognise, so `audio/*` includes `audio/mp4` rather than refusing every voice memo. - | Wildcard | Expands to | | --------- | ---------- | | `image/*` | `image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/bmp`, `image/tiff`, `image/avif`, `image/heic`, `image/heif`, `image/x-icon` | @@ -100,35 +98,19 @@ Parameters are stripped before the comparison, so `image/png; charset=binary` is ## Byte sniffing -A route with `contentTypes` gets more than the declared type. The browser slices the file's first 4100 bytes (`SNIFF_BYTES`), base64-encodes them, and sends them as `head` with phase `begin`. A mislabelled file is then refused before the upload rather than after it. +A route with `contentTypes` checks more than the declared type. The browser sends the file's leading bytes with the first request, so a mislabelled file is refused before the upload rather than after it. The check runs in two steps: -1. **The declared type against the allow list.** `report.exe` renamed to `report.png` but declared `application/x-msdownload` is refused here, with the allowed list as the hint. This step runs whether or not the bytes arrived. -2. **The bytes against the declaration, on a proven conflict only.** The leading bytes are sniffed. If they prove nothing, the file passes. If they prove something, it is only a refusal when the declared type is in a small closed set and the bytes name a different type in that set. - -That second condition is what keeps real files from being refused. Bytes that prove a container the declaration sits on top of pass: a `.docx` really is a zip, an `.epub` and a `.jar` and an `.apk` are too, and a `.svgz` really is a gzip. `application/octet-stream` is a shrug, not a claim, so bytes never contradict it. - -The closed set, the types a signature proves outright with no sibling format sharing it: +1. **The declared type against the allow list.** `report.exe` renamed to `report.png` but declared `application/x-msdownload` is refused here, with the allowed list as the hint. +2. **The bytes against the declaration, on a proven conflict only.** If the leading bytes prove nothing, the file passes. -`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/bmp`, `audio/wav`, `video/x-msvideo`, `application/pdf`, `application/zip`, `application/gzip`, `application/x-7z-compressed`, `application/x-rar-compressed`, `application/x-bzip2` - -Some formats are deliberately left unnamed by the sniffer, because their signature proves a container and not the type above it: - -| Format | Why | -| ------ | --- | -| ISO-BMFF | `ftyp` is mp4, m4a, heic, avif and quicktime alike | -| EBML | webm and mkv share it | -| Ogg | vorbis, opus and theora share it | -| TIFF | also every raw camera format | -| sfnt fonts | ttf, otf and ttc share it | -| MPEG audio | frame sync varies by version and layer | -| tar | its marker sits at offset 257, behind an attacker-controlled filename | +That second condition is what keeps real files from being refused. Bytes that prove a container the declaration sits on top of pass: a `.docx` really is a zip, and so are `.epub`, `.jar` and `.apk`. Formats whose signature is shared by several types, like mp4 and heic, or webm and mkv, are never used to contradict a declaration. Neither is `application/octet-stream`, which claims nothing. - This is ergonomics, not a control. The part bodies never reach your server, so a client is free to - send an honest head and then upload something else entirely. It is not malware scanning, and it is - not a substitute for treating stored objects as untrusted. + This is ergonomics, not a control. The bytes never reach your server, so a client is free to send + an honest sample and then upload something else entirely. It is not malware scanning, and it is not + a substitute for treating stored objects as untrusted. --- @@ -182,9 +164,9 @@ export const uploads = uploadHandler({ }) ``` -The narrowed constraints are checked against the same file, with the same head bytes, right after `onBeforeUpload` returns. +The narrowed constraints are checked against the same file right after `onBeforeUpload` returns. -Widening throws a `TypeError`: `onBeforeUpload widened maxBytes` for a larger cap, `onBeforeUpload widened contentTypes` for a type the route does not already allow, naming the types that were added. The route's own limits are always the ceiling, so reading the code of a route tells you the most it can ever accept. +Widening throws a `TypeError` naming what was widened. The route's own limits are always the ceiling, so reading the code of a route tells you the most it can ever accept. --- @@ -196,7 +178,7 @@ Widening throws a `TypeError`: `onBeforeUpload widened maxBytes` for a larger ca { "constraints": { "contentTypes": ["image/png"], "maxBytes": 2000000 } } ``` -It carries an ETag and `Cache-Control: public, max-age=60`, and the hook caches it for the same 60 seconds (`CONSTRAINTS_TTL_MS`). Short and revalidated, not immutable: the constraints are your route's code and change with a deploy, and a client that cached them forever would refuse files the route now accepts. +It is cached for 60 seconds, browser and CDN alike, so a deploy that changes a route's limits reaches clients within a minute. `useUpload` exposes two things from it. `accept` is `contentTypes` joined with commas, ready for an ``, and empty until the GET lands or when the route serves no type list. `constraints` is the served document itself, so a page can state the cap it enforces. @@ -220,9 +202,7 @@ export function UploadButton() { A file over `maxBytes` is refused in the browser before any request is made. It still becomes a record, with `status: 'error'` and a real `BlobError` whose code is `too_large`, so one error path renders both the client-side refusal and the server's. -The size check is the only one that runs in the browser. Type validation stays on the server, which canonicalizes aliases and sniffs the leading bytes, neither of which the served `accept` list can express. **The server is authoritative.** Constraints that have not arrived yet are not an answer either: the file is sent and the route decides. - -A route that serves no `contentTypes` has nothing to check leading bytes against, so the hook does not read them off the file and does not send them. +The size check is the only one that runs in the browser. **The server is authoritative** for everything else, and if the constraints have not arrived yet the file is simply sent and the route decides. --- diff --git a/blob/browser/large-files.mdx b/blob/browser/large-files.mdx index 62f27d1b..2ea1a4dd 100644 --- a/blob/browser/large-files.mdx +++ b/blob/browser/large-files.mdx @@ -10,11 +10,11 @@ Every upload crosses one line. Under it a file goes up as a single presigned PUT The default is **16 MB decimal**, 16,000,000 bytes. Sizes in the SDK are decimal everywhere, the way storage is billed, so `'16mb'` means 16,000,000 and not 16,777,216. -The comparison is strict. A 16,000,000 byte body is a single PUT. 16,000,001 is multipart. +A 16,000,000 byte body is a single PUT. 16,000,001 is multipart. -The same line governs both halves of the SDK: `bucket.put()` on your server and a direct browser upload split at the same size, so a file does not behave differently depending on which door it came in through. +`bucket.put()` on your server and a direct browser upload split at the same size, so a file does not behave differently depending on which door it came in through. -Parts are not free. A single PUT is one round trip; a multipart upload is three plus one per chunk, and an upload that is begun and never finished lingers in storage until something aborts it. Parts are what a big file needs, past the single-PUT ceiling and for a chunk that can be retried or resumed on its own. They are not the right shape for every upload. +Parts are not free. A single PUT is one round trip; a multipart upload is three plus one per chunk, and an upload that is begun and never finished lingers in storage until something aborts it. They are what a big file needs, not the right shape for every upload. --- @@ -23,19 +23,17 @@ Parts are not free. A single PUT is one round trip; a multipart upload is three | | Under the threshold | Over the threshold | | -------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------- | | Transport | one presigned object PUT | one presigned PUT per part | -| Browser round trips | `begin`, the PUT, `end` | `begin`, one PUT per part, one `parts` call per 16 parts after the first, `end` | -| Extra server-to-storage calls | none | `createMultipart` inside `begin`, `completeMultipart` inside `end` | -| When the object exists | the moment the last byte lands | when phase `end` completes the upload | +| When the object exists | the moment the last byte lands | when the upload is completed at the end | | `canPause` | `false` | `true` while uploading | | A failed chunk | the whole PUT is sent again | only that part is sent again | -| Ceiling | ~5 GiB (5,368,709,120 bytes) | no practical limit | +| Ceiling | ~5 GiB | no practical limit | | A tab that dies mid-upload | a whole stored object no callback accepted | parts, invisible to `list()`, reaped by `abortStaleMultipartUploads()` | -The row that matters most is when the object comes into existence. Under the threshold the presigned PUT stores the object itself, so by the time phase `end` runs there is already a real, billed, listable object at that path, and a throw out of `onUploadComplete` has to delete it again. Over the threshold nothing exists at the path until `end` calls `completeMultipart`, so an upload that never reaches `end` leaves parts rather than a file. +The row that matters most is when the object comes into existence. Under the threshold the object is stored the moment the last byte lands, before your route has been told anything, so a throw out of `onUploadComplete` has to delete it again. Over the threshold nothing exists at the path until the upload is completed, so an upload that never gets there leaves parts rather than a file. -That is the whole of what an abandoned upload costs on each side of the line. See [Abandoned uploads](/blob/browser/abandoned-uploads) for the sweep, and for why `multipart: true` is the escape hatch an app that will not run a cron reaches for. +That is what an abandoned upload costs on each side of the line. See [Abandoned uploads](/blob/browser/abandoned-uploads) for the sweep. -Storage refuses a single PUT larger than 5,368,709,120 bytes, so past that size parts are used whatever `multipart` says. +A single PUT cannot carry more than about 5 GiB, so past that size parts are used whatever `multipart` says. --- @@ -88,29 +86,19 @@ await bucket.put("logs/today.ndjson", stream, { size, multipart: "50mb" }) A few edges: -- `multipart: false` on a body over the single-PUT ceiling throws `too_large`, with the hint `multipart: false forbids the parts this body needs`. It is a refusal rather than a silent override, because the option said something the request cannot honour. -- On `bucket.put()`, `overwrite: false` and `ifUnchanged` are single-PUT only, since the conditional header rides on the object write. Passing either turns multipart off. Passing `multipart: true` together with either throws `invalid_input`. -- An unparseable size (`'100 megs'`) throws where the option is written, not per request. A handler resolves it once at construction, so a typo is a startup error and never a 500 raised after `onBeforeUpload` has already inserted your row. +- `multipart: false` on a body over the single-PUT ceiling throws `too_large` rather than silently overriding the option. +- On `bucket.put()`, `overwrite: false` and `ifUnchanged` are single-PUT only. Passing either turns multipart off, and passing `multipart: true` together with either throws `invalid_input`. +- An unparseable size (`'100 megs'`) throws where the option is written, not per request. -The cost of `multipart: true` is not extra browser requests. The browser makes `begin`, the PUTs and `end` either way. It is two extra server-to-storage round trips, `createMultipart` inside `begin` and `completeMultipart` inside `end`, landing as latency inside those two calls. +`multipart: true` costs no extra browser requests. It is two extra round trips between your server and storage, landing as latency inside the first and last calls. --- ## Part sizing -The part size is derived from the file size, never configured: - -```text -partSize = max(5 MiB, ceil(size / 250) rounded up to a whole MiB) -partCount = ceil(size / partSize) -``` - -Two constants shape it, and part math is the only binary math in the SDK: - -- **5 MiB** is the floor storage enforces on every part but the last. It is checked when the upload is completed, so a part below it fails the whole upload at the very end. The SDK never goes under it. -- **250** is the part count the SDK aims at. Targeting a count rather than a size keeps the number of requests roughly flat as files grow, and stays two orders of magnitude below the 10,000 part ceiling. +The part size is derived from the file size, never configured. The SDK aims at roughly 250 parts, with a floor of 5 MiB. | File size | Part size | Parts | | --------- | --------- | ----- | @@ -119,42 +107,9 @@ Two constants shape it, and part math is the only binary math in the SDK: | 2 GB | 8 MiB | 239 | | 20 GB | 77 MiB | 248 | -The last part is the short one. The part size is also the blast radius of one crashed part: on a 5 GB file, 20 MiB parts mean a failure costs at most 20 MiB of re-sent bytes. - ---- - -## The transfer - -Phase `begin` answers with a plan: `partSize`, the list of `parts` to send, and `multipart`. Part URLs are presigned in batches of 16, so a 239 part upload does not sign 239 URLs before the first byte moves. The client asks for the next batch through phase `parts` when it reaches a part it has no URL for. - -```text -begin -> route: onBeforeUpload, createMultipart, presign parts 1..16 - | - v - +--------- 4 parts in flight per upload ----------+ - | PUT part 1 PUT part 2 PUT part 3 PUT 4 | - +-------------------------------------------------+ - | every request in the page shares one cap of 6 - v -parts -> route: presign 17..32, report the parts that landed - | - v -end -> route: completeMultipart, onUploadComplete -``` - -The numbers: +The part size is the blast radius of one crashed part: on a 5 GB file, 8 MiB parts mean a failure costs at most 8 MiB of re-sent bytes. -| Constant | Value | Scope | -| -------------------- | ----- | ------------------------------------------------------------ | -| `PARTS_PER_BATCH` | 16 | part URLs presigned per `parts` call | -| `PARTS_IN_FLIGHT` | 4 | parts uploading at once, per upload | -| `GLOBAL_REQUEST_CAP` | 6 | requests in flight in the whole page, files and parts alike | - -The global cap is shared on purpose. The browser does not care which of our requests a connection belongs to, so three files uploading four parts each would otherwise queue against each other inside the browser, where the SDK cannot see it. One pool means the queueing is ours and the progress numbers stay honest. - -Uploads go out over `XMLHttpRequest` rather than `fetch`, for one reason: `fetch` has no upload progress event. - -A single PUT is expressed as one part covering the whole file, with `partSize` equal to the file size. The browser runs the same loop over one URL, and only the server knows that URL is an object PUT and not a part. +Four parts upload at once per file, and the whole page shares a cap of six requests in flight. Part URLs are presigned in batches as the upload needs them, so a 239 part upload does not sign 239 URLs before the first byte moves. --- @@ -170,9 +125,9 @@ Every record carries the same fields whichever side of the line it is on: | `pending` | not settled: `queued`, `uploading`, `finishing` or `paused` | | `stalled` | every in-flight part is waiting on a backoff | -`percent` is capped at 99 because 100 has to mean stored, not sent. The bar sits there through status `finishing`, which is the stretch where every byte has landed and phase `end` is completing the upload and running `onUploadComplete`. That can take as long as your callback does. Naming the state is the difference between a bar that is working and one that looks stuck. +`percent` is capped at 99 because 100 has to mean stored, not sent. The bar sits there through status `finishing`, where every byte has landed and your `onUploadComplete` is running. That can take as long as your callback does, so render the state rather than leaving a bar that looks stuck. -In-flight bytes are counted, and a failed part's bytes retreat rather than lying: a part that got 3 MiB out and then took a 500 drops back to zero, because those bytes were never stored. `loaded` never counts a part twice, and a landed part is banked before it leaves the in-flight map, so the bar does not dip between the two. +A failed part's bytes retreat rather than lying: a part that got 3 MiB out and then failed drops back to zero, because those bytes were never stored. ```tsx app/upload.tsx {upload.pending && } @@ -195,11 +150,11 @@ In-flight bytes are counted, and a failed part's bytes retreat rather than lying | `finishing` | `false` | | `done`, `error`, `canceled`| `false` | -A single PUT is one request that is either on the wire or not. Stopping it throws its bytes away rather than parking them, so it is not offered as a pause. While `queued` the answer is also `false`, because which of the two an upload is only becomes known when the route answers `begin`. And from `finishing` there is nothing left to hold back: every part has landed and `end` is already running. +A single PUT is one request that is either on the wire or not, so stopping it would throw its bytes away rather than parking them. While `queued` the answer is also `false`, since which of the two an upload is is not known yet. And from `finishing` there is nothing left to hold back. -**Pause stops the queue, not the transfer.** Bytes on the wire are already paid for, so a part that has sent anything finishes and keeps its etag. Only a part that has sent nothing, parked on a backoff or waiting for a pool slot, is dropped and handed back to the queue. Aborting all four threw away up to a part each and snapped the bar back. +**Pause stops the queue, not the transfer.** A part that has already sent bytes finishes and keeps them. Only parts that have sent nothing are dropped back to the queue. -`resume()` restarts the workers. Nothing that landed is sent again. +`resume()` picks up where it left off. Nothing that landed is sent again. ```tsx app/upload.tsx {upload.canPause && upload.status === "uploading" && ( @@ -214,57 +169,36 @@ A single PUT is one request that is either on the wire or not. Stopping it throw ## Resuming after a reload -A closed tab is not a canceled upload. At phase `begin` the client writes the upload's completion token to `localStorage`, keyed by a fingerprint of the route, the file name, the size and `lastModified`: - -```text -upstash-blob:v1:/api/upload|movie.mp4|4823110458|1700000000000 -``` +A closed tab is not a canceled upload. When an upload starts, the client remembers it in `localStorage`, keyed by the route, the file name, the size and its last-modified time. -**The user picking the same file again is the resume gesture.** There is no API to call. A fingerprint match makes the SDK send phase `parts` with the stored token instead of `begin`, the server asks storage for the parts that actually landed (`ListParts`), and only the missing parts are sent. The row `onBeforeUpload` inserted is not written twice, because `onBeforeUpload` never runs again. +**The user picking the same file again is the resume gesture.** There is no API to call. On a match the SDK asks your server which parts actually landed and sends only the missing ones. `onBeforeUpload` never runs again, so the row it inserted is not written twice. Three properties worth stating: -- **Nothing about what landed is trusted from `localStorage`.** The token is the only thing kept. What landed is the server's answer, read from storage. -- **A mismatch is a fresh upload, never an error.** A different file, a different size, an expired token, a route that has forgotten the upload: any of these fall back to `begin`. -- **A single PUT has nothing to resume.** There are no parts on the other side, so the file is simply sent again under the same token and to the same path. +- **Nothing about what landed is trusted from the browser.** What landed is read back from storage by your server. +- **A mismatch is a fresh upload, never an error.** A different file, a different size, an expired upload: any of these start over. +- **A single PUT has nothing to resume.** The file is simply sent again to the same path. -It is best effort. Quota, private mode or a browser with no `localStorage` at all just means no resume, never a failed upload. +It is best effort. Quota, private mode or a browser with no `localStorage` just means no resume, never a failed upload. --- ## Retries -Every response to a part PUT is classified: +Parts are retried on a network failure and on 408, 429, 500, 502, 503 and 504, with an exponential backoff that honours `Retry-After`. A 403 is read as an expired signature first, so the SDK asks your route for fresh URLs and sends the part again; only a freshly signed URL refused a second time is reported as `signature_mismatch`. -| Response | Verdict | -| ------------------------------------- | ------------ | -| network failure (no status) | retry | -| 408, 429, 500, 502, 503, 504 | retry | -| 401, 403 | re-presign | -| anything else | fail | +Two budgets differ from the usual eight attempts: -A 401 or 403 is treated as a clock problem first, not a body problem: an expired presign looks exactly like a tampered request. The SDK drops the batch, asks the route for fresh URLs and sends the part again. Only a URL minted moments ago and refused a second time is reported as `signature_mismatch`. +- **A dropped connection gets twenty.** A phone changing cell outlives a shorter budget. +- **A request that failed without sending a byte gets three.** That is almost always CORS, which retrying does not fix, and the error says so. -The budgets: +A part that goes 60 seconds with no progress and no response is treated as failed, which is silence rather than total time: a large part on a slow link takes minutes but is never quiet for a minute. -| Constant | Value | Why | -| --------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------- | -| `MAX_ATTEMPTS` | 8 | attempts against a response the server actually wrote | -| `MAX_NETWORK_ATTEMPTS` | 20 | a dropped link is not a refusal: 8 attempts give up ~25s in, which a phone changing cell outlives | -| `NO_BYTES_NETWORK_ATTEMPTS` | 3 | a request that failed without putting a byte on the wire is almost always CORS, which retrying does not fix | -| `STALL_TIMEOUT_MS` | 60,000 ms | silence, not duration: a 5 MiB part on a slow link takes minutes but is never quiet for a minute | -| backoff | 500 ms to 15 s | full jitter, uniform in `[0, min(15s, 500ms * 2^attempt)]` | -| `Retry-After` | honoured to 60s | a seconds count or an HTTP date, clamped | +Calls to your own route get three attempts, except the first one, which is never retried because it runs `onBeforeUpload`. -The CORS case is the one worth knowing about. A browser blocks a request that fails preflight before a single byte goes out, and script never sees why. Backing off for four and a half minutes only delays the same answer, so the SDK gives up after three attempts and says so in the error's hint. If the browser reports it is offline, the larger network budget applies instead, because that does come back. +When the budget runs out the record settles as `error`, carrying a `BlobError`. The codes are listed in [Errors](/blob/bucket/errors). -The stall watchdog measures silence rather than total time. `xhr.timeout` is a deadline for the whole request, which a large part on a slow link outruns honestly. Sixty seconds with no upload progress event and no response is the failure, and it covers the wait for the response too, so a connection that dies after the last byte does not hang until the tab closes. - -Calls to your own route follow a smaller policy: `end` and `parts` get three attempts, so two retries, on a network failure or one of the retryable statuses above. `begin` is never retried, because it runs `onBeforeUpload`. - -When the budget runs out the record settles as `error`, carrying a `BlobError` with the code the failure earned. The codes are listed in [Errors](/blob/bucket/errors). - -`retry()` on a failed record runs the same upload again from the parts that landed. The upload the route began still exists, every landed part is still landed, no new `begin` is sent, and `done` is replaced with a fresh promise, since the old one already rejected. +`retry()` on a failed record runs the same upload again from the parts that landed. Nothing already uploaded is re-sent, and `done` is replaced with a fresh promise since the old one already rejected. ```tsx app/upload.tsx {upload.status === "error" && ( @@ -276,17 +210,15 @@ When the budget runs out the record settles as `error`, carrying a `BlobError` w ## Cancel -`cancel()` aborts the parts in flight and posts phase `cancel`, which aborts the multipart upload server-side so the parts stop costing storage. For a single PUT, the same call deletes the object if the bytes already landed and the upload marker proves they are this upload's. +`cancel()` aborts the parts in flight and tells your route to abort the multipart upload, so the parts stop costing storage. For a single PUT, it deletes the object if the bytes already landed and it can prove they are this upload's. -From status `finishing` the server call is dropped and only the local task is canceled. The route has already been asked to record the object, and the answer is its to give: a cancel that raced `end` and won would ask the route to delete an object `onUploadComplete` may have just accepted and written a row for. The worst case that way round is an object your app has no row for, rather than a row your app has no object for. +From status `finishing` only the local task is canceled. Your `onUploadComplete` may already have accepted the object and written a row, so racing it would risk deleting a file your app thinks it has. --- ## Large writes from the server -`bucket.put()` crosses the same line, with the same options. Over the threshold it streams the body one part at a time: a part is buffered whole so it can be retried, and holding several would multiply that by the concurrency. - -Any failure aborts the upload rather than leaving parts behind, because nothing lists an incomplete upload and an invisible one is invisible billing. +`bucket.put()` crosses the same line, with the same options. Over the threshold it streams the body one part at a time, and any failure aborts the upload rather than leaving billed parts behind. ```ts lib/backup.ts import { Bucket } from "@upstash/blob" diff --git a/blob/browser/upload-handler.mdx b/blob/browser/upload-handler.mdx index f73119d7..d59cf5f5 100644 --- a/blob/browser/upload-handler.mdx +++ b/blob/browser/upload-handler.mdx @@ -2,9 +2,9 @@ title: "Upload Handler" --- -`uploadHandler` is one upload endpoint. The bytes go straight from the browser to storage: your server only authorizes the upload, signs it, and records what landed. The file never passes through your app, so nothing is bound by your platform's request body limit and nothing streams through your function's memory. +`uploadHandler` is one upload endpoint. The bytes go straight from the browser to storage: your server only authorizes the upload, signs it, and records what landed. The file never passes through your app, so nothing is bound by your platform's request body limit. -Two requests reach your route for an ordinary upload. `begin` runs your authorization and hands the browser presigned URLs, and `end` records the object and runs your completion callback. The PUTs in between go to storage, not to you. A third phase, `parts`, is only asked for when an upload needs more than the first 16 part URLs, or when it resumes after a reload. +Two requests reach your route for an ordinary upload: `begin` runs your authorization and hands the browser presigned URLs, and `end` records the object and runs your completion callback. The PUTs in between go to storage, not to you. --- @@ -130,7 +130,7 @@ const bucket = new Bucket({ token: process.env.MEDIA_BLOB_TOKEN!, cache: "immuta export const uploads = uploadHandler({ bucket, onBeforeUpload }) ``` -Options that cannot be parsed are build errors, not 500s at request time: an unparseable `multipart` size, a route name a URL query cannot carry, an empty `routes` map, and a missing token all throw where they were written. +Bad options throw where they are written rather than at request time: an unparseable `multipart` size, an invalid route name, an empty `routes` map, a missing token. --- @@ -160,9 +160,9 @@ What it returns: | `cache` | `CacheOption` | The `Cache-Control` this object is stored with, over the bucket default. See [Caching](/blob/bucket/caching). | | `metadata` | `Record` | Signed into the upload and handed back to `onUploadComplete`. | | `constraints` | `{ contentTypes?, maxBytes? }` | Narrows this one upload's limits. | -| `state` | `TState` | Carried to `onUploadComplete` and `onError`. Only `uploadRoute()` can carry one: on a plain-object route the return type is pinned to `state: undefined`, so returning anything else does not compile. | +| `state` | `TState` | Carried to `onUploadComplete` and `onError`. Only [`uploadRoute()`](#uploadroute) can carry one. | -`file` is the browser's own claim, so `file.type` is the type the object is stored and served as. The type the bytes really are is checked at `begin` too, against the file's first bytes; that check is described in [Constraints](/blob/browser/constraints). +`file` is the browser's own claim, so `file.type` is the type the object is stored and served as. What the bytes really are is checked separately; see [Constraints](/blob/browser/constraints). ### Paths @@ -179,15 +179,15 @@ uniquePath`chat/${threadId}/${file.name}` Slashes in the literal chunks are structure. Everything inside `${...}` is a value, sanitized down to a slugged basename with a random suffix, so it can never contribute a directory of its own. The full rules are in [Writing](/blob/bucket/writing#uniquepath). -Without the suffix, a stable path is an overwrite: the second upload replaces the first, and a single-PUT upload that lost the race then gets 404 from its own `end` even though its bytes landed. Use a stable path only when overwriting is the intent. +Without the suffix, a stable path is an overwrite: the second upload replaces the first, and the first upload can then fail with a spurious 404. Use a stable path only when overwriting is the intent. ### Metadata -`metadata` is signed into the presigned PUT, so the browser can neither add to it nor change it, and it comes back on `onUploadComplete` as `metadata`. It is stored on the object and readable later with `bucket.info(path)`. +`metadata` is signed into the presigned PUT, so the browser can neither add to it nor change it. It comes back on `onUploadComplete`, is stored on the object, and is readable later with `bucket.info(path)`. Values are printable ASCII and keys come back lowercased, under the same rules as a server-side write: see [Writing](/blob/bucket/writing#metadata). -`metadata["upstash-upload"]` is reserved: the SDK writes its own marker under that key to prove which upload wrote the object at a path, and setting it throws `invalid_input`. +`metadata["upstash-upload"]` is reserved for the SDK and setting it throws `invalid_input`. ### Narrowing per user @@ -200,7 +200,7 @@ onBeforeUpload: async ({ ctx, file }) => ({ }) ``` -Widening throws a `TypeError` naming what was widened, for `maxBytes` and for a content type that is not on the route's list. That is a bug in the handler, not a refusal of the file, so it surfaces as a server error rather than a `BlobError`. +Widening throws a `TypeError` naming what was widened. The route's own limits are always the ceiling. ### Refusing @@ -217,7 +217,7 @@ onBeforeUpload: async ({ request, file }) => { Every `BlobError` reaches the browser with its `code` intact, so a hook can switch on `error.code` instead of reading status numbers. The codes are listed in [Errors](/blob/bucket/errors). -The browser never retries `begin`: it runs your callback, and a callback that writes a row must not be run twice for one file. +The browser never retries `begin`, so a callback that writes a row is never run twice for one file. --- @@ -238,7 +238,7 @@ onUploadComplete: async ({ uploadId, path, url, size, contentType, metadata, sta | --- | --- | --- | | `path` | `string` | Where the object is stored. | | `url` | `string \| undefined` | The public URL. Undefined on a private bucket; see [How signing works](/blob/overall/signing). | -| `versionedUrl` | `string \| undefined` | `${url}?v=${etag}` with the etag percent-encoded, since storage returns it quoted, so it reads `?v=%22...%22`. For a stable path that gets overwritten. | +| `versionedUrl` | `string \| undefined` | `url` with the etag on the query. For a stable path that gets overwritten. | | `size` | `number` | Bytes actually stored, verified against what the browser declared. | | `etag` | `string` | The stored object's etag. | | `uploadedAt` | `Date` | When storage wrote it. | @@ -248,7 +248,7 @@ onUploadComplete: async ({ uploadId, path, url, size, contentType, metadata, sta | `request` | `Request` | The `end` request. | | `file` | `{ name, type, size }` | What the browser declared at `begin`. The original filename survives only here. | | `uploadId` | `string` | Identifies this upload. Stable across retries: the idempotency key. | -| `multipartUploadId` | `string \| undefined` | R2's own multipart id, for `bucket.abortMultipartUpload()`. Undefined for a single PUT. | +| `multipartUploadId` | `string \| undefined` | For `bucket.abortMultipartUpload()`. Undefined for a single PUT. | | `metadata` | `Record` | What `onBeforeUpload` returned, minus the SDK's marker. | | `state` | `TState` | What `onBeforeUpload` returned as `state`. | @@ -260,17 +260,14 @@ if (upload?.status === "done") upload.blob.data.path // string, inferred from on ``` - **It is at-least-once.** `end` gets three attempts, so two retries, on a network failure and on - 408, 429, 500, 502, 503 or 504. Any other status fails outright. `uploadId` is stable across those - retries and is the key to write against: `on conflict (upload_id) do nothing`, or the equivalent - upsert for your database. - - **Any throw out of it deletes the completed object.** That is the intent for a refusal, and it is a - trap for a database error: a ten-second outage destroys bytes that uploaded fine, the browser - retries `end`, and a single-PUT upload then answers 404 reading "the upload never landed". A - retryable `BlobError` is not an escape either: the delete happens first, so the retry it asks for - arrives at an empty path. Catch your own storage errors and decide deliberately instead of letting - a driver error escape the callback. + **It is at-least-once.** The browser retries `end` on a network failure or a retryable status, so + write against `uploadId`, which is stable across those retries: `on conflict (upload_id) do + nothing`, or the equivalent upsert for your database. + + **Any throw out of it deletes the completed object.** That is the intent for a refusal, and a trap + for a database error: a ten-second outage destroys bytes that uploaded fine, and the retried `end` + then answers 404 reading "the upload never landed". Catch your own storage errors rather than + letting a driver error escape the callback. ```ts lib/uploads.ts @@ -342,7 +339,7 @@ With a single route, authorizing inside `onBeforeUpload` and carrying an id in ` ### The ordering rule -Write `context` **above** `routes` and the callbacks that read `ctx`, or annotate its parameter. An unannotated `(request) =>` is fine in the first position. Written below `routes`, TypeScript has already typed the routes with `ctx: undefined` by the time it reads what `context` returns, and the error lands on `context` itself: `Promise is not assignable to undefined`. Annotating the parameter as `(request: Request) =>` lifts the ordering rule, because TypeScript reads an annotated function's return type before it types anything else in the object literal. +Write `context` **above** `routes`, or annotate its parameter as `(request: Request) =>`. Written below `routes` with an unannotated parameter, TypeScript types the routes with `ctx: undefined` first and the error lands on `context`: `Promise is not assignable to undefined`. ```ts lib/uploads.ts // Fine: context first. @@ -401,13 +398,13 @@ const avatar = useUpload("avatar") const attachment = useUpload("attachment") ``` -- Route names must match `/^[A-Za-z_][\w-]*$/`, checked when the handler is built rather than per request. -- An unknown name is an ordinary 404 that never names the routes the handler does mount. It still reaches `onError`. -- A name is part of the completion token's identity, so a token minted by one route is not spendable at another. +- Route names must match `/^[A-Za-z_][\w-]*$/`, checked when the handler is built. +- An unknown name is a 404 that never names the routes the handler does mount. It still reaches `onError`. +- An upload authorized by one route cannot be completed at another. -A handler with **no** `routes` is itself the route. It is reached with no `?route=` at all, and the bound `useUpload()` takes no argument. A name on the query is then a client bound to some other handler, and it gets a 404 rather than this route by accident. +A handler with **no** `routes` is itself the route. It is reached with no `?route=` at all, and the bound `useUpload()` takes no argument. -Two handlers on the same bucket that mount the same route names derive the same token identity. `endpoint: "/api/one"` tells them apart. +Two handlers on the same bucket that mount the same route names need an `endpoint` to tell them apart. --- @@ -444,7 +441,7 @@ start({ file, input: { threadId } }) // input is required here, and its shape is `input` is validated before `onBeforeUpload` runs, and only the parsed value reaches it. A route with **no** schema refuses any `input` the browser sends, with `invalid_input` rather than dropping it silently. Validation failures come back as `invalid_input` too, with the issues joined into one message as `path: message`, so a bad `threadId` reads `threadId: Invalid uuid`. -`state` is for what the callback already computed and does not want to look up again. It rides in the completion token, which is signed but readable in devtools, so put a row id there, never a secret. +`state` is for what the callback already computed and does not want to look up again. It travels through the browser and is readable in devtools, so put a row id there, never a secret. Everything else on the route works as it does on a plain object: `bucket`, `constraints`, `multipart`, `onError`, and the same inheritance from the handler. @@ -469,7 +466,10 @@ export const { useUpload } = uploadHooks({ `headers`, `concurrency`, `endpoint` and `onError` are the defaults `uploadHooks` takes. A call-site option wins over the default, except `onError`, where the configured handler runs first and the call-site one after it. -Only the configured handler is wrapped: a throw from it is caught and logged as `[upstash-blob] uploadHooks onError threw`, and the call-site handler still runs. The call-site handler is not wrapped. A throw there escapes the store's settle loop before it reaches the step that starts the next queued upload, so the rest of the queue never starts. That handler must not throw. + + A call-site `onError` must not throw. A throw there stops the rest of the upload queue from + starting. A throw from the configured `onError` is caught and logged. + Called with no type parameter, `uploadHooks()` returns the unbound `useUpload`, which takes a URL. @@ -520,19 +520,17 @@ const { start, uploads, upload, clear, accept, constraints } = useUpload("attach | `error` | `BlobError` | On `error` only. | | `pause()` `resume()` `cancel()` `retry()` | `() => boolean` | Each answers whether it did anything. | -`pending` is the field to drive UI off. Hand-rolling it from `status` is where the off-by-one-state bugs live: an input re-enabled during `finishing`, a progress bar still drawn under an error line. - -`percent` sits at 99 through `finishing`, because 100 has to mean stored rather than sent. [Large files](/blob/browser/large-files#progress-and-status) has the rest of the progress fields. +`pending` is the field to drive UI off rather than hand-rolling it from `status`. `percent` sits at 99 through `finishing`, because 100 has to mean stored rather than sent. [Large files](/blob/browser/large-files#progress-and-status) has the rest of the progress fields. -`blob.data` is typed from that route's `onUploadComplete`. The payload a state does not carry is declared as `undefined` rather than left out, so `upload?.blob?.url` and `upload?.error?.message` read straight off the record with no narrowing. +`blob.data` is typed from that route's `onUploadComplete`. Fields a status does not carry are `undefined` rather than absent, so `upload?.blob?.url` and `upload?.error?.message` read straight off the record with no narrowing. -`canPause` is false for a single PUT, which is every file under the route's `multipart` threshold: one request is either on the wire or not, and stopping it throws its bytes away rather than parking them. `retry()` works only from `error`, and resumes from the parts that already landed. [Large files](/blob/browser/large-files) has the whole of pause, resume and multipart. +`canPause` is false for a single PUT, which is every file under the route's `multipart` threshold. `retry()` works only from `error`, and resumes from the parts that already landed. [Large files](/blob/browser/large-files) has the whole of pause, resume and multipart. -Three files are in flight by default and the rest queue. `clear(id?)` removes records from the list; a cleared upload that is still running keeps its place in the queue and finishes, it is just no longer rendered. Unmounting the component does not cancel anything either. +Three files are in flight by default and the rest queue. `clear(id?)` removes records from the list; a cleared upload that is still running finishes anyway, it is just no longer rendered. Unmounting the component does not cancel anything either. ### headers -`headers` is a function, not an object, and it is re-read for every request the SDK makes to your route: the constraints `GET`, `begin`, `parts` and `end`. A JWT that rotated between the first byte and the last still ends the upload. +`headers` is a function, not an object, and it is re-read for every request the SDK makes to your route. A token that rotated mid-upload still ends the upload. ```tsx app/page.tsx const { start } = useUpload("attachment", { @@ -575,7 +573,7 @@ const blob = await task.done // CompletedBlob & { data } stop() ``` -`upload()` starts immediately and returns an `UploadTask`: `snapshot()` for the current state, `subscribe()` for changes, `done` as a promise, and `pause()`, `resume()`, `cancel()` and `retry()`. The snapshot carries the same fields the React record does, since the record is that snapshot plus `id`, `file` and the four methods. +`upload()` starts immediately and returns an `UploadTask`: `snapshot()` for the current state, `subscribe()` for changes, `done` as a promise, and `pause()`, `resume()`, `cancel()` and `retry()`. The snapshot carries the same fields the React record does. --- @@ -618,4 +616,4 @@ A proxied upload is capped by your platform's request body limit rather than by ## CORS -The signed PUT is a cross-origin request from your page to storage, so the bucket's CORS policy has to allow it: the required shape is in [CORS](/blob/overall/quickstart#cors). A PUT that fails with no status and no bytes sent is almost always this, and the SDK says so after three attempts rather than backing off for minutes. +The signed PUT is a cross-origin request from your page to storage, so the bucket's CORS policy has to allow it: the required shape is in [CORS](/blob/overall/quickstart#cors). A PUT that fails with no status and no bytes sent is almost always this. diff --git a/blob/bucket/caching.mdx b/blob/bucket/caching.mdx index 6c9ecc03..c10aaaab 100644 --- a/blob/bucket/caching.mdx +++ b/blob/bucket/caching.mdx @@ -43,7 +43,7 @@ cache: "public, max-age=60, s-maxage=31536000" cache: "max-age=0, stale-while-revalidate=86400" ``` -That is the escape hatch, and it is why `cache` is three words and a duration rather than an object of flags. `s-maxage`, `stale-while-revalidate`, `no-transform` and whatever the spec adds next are all sayable without the option growing a camelCase word for each of them. +That is the escape hatch for `s-maxage`, `stale-while-revalidate`, `no-transform` and anything else the three words do not cover. --- @@ -124,7 +124,7 @@ See [Upload handler](/blob/browser/upload-handler) for the rest of the callback. ## Private buckets -On a private bucket, `private` replaces `public` in the stored directive. A shared cache must not keep a copy of an object only a signed request may read, and `public` on such an object invites every shared cache between storage and the reader to keep one and hand it to the next reader. +On a private bucket, `private` replaces `public` in the stored directive, so no shared cache between storage and the reader keeps a copy of an object only a signed request may read. | `cache` | Public bucket | Private bucket | | ------- | ------------- | -------------- | @@ -134,7 +134,7 @@ On a private bucket, `private` replaces `public` in the stored directive. A shar | `'revalidate'` | `public, max-age=0, must-revalidate` | `private, max-age=0, must-revalidate` | | `'no-store'` | `no-store` | `no-store` | -This follows the bucket's real visibility, taken from the credentials response, not just what you declared in `new Bucket({ visibility })`. A bucket that never declared anything still stores the right directive. +This follows the bucket's real visibility, not just what you declared in `new Bucket({ visibility })`, so a bucket that never declared anything still stores the right directive. A raw header string is passed through as written, so `cache: 'public, max-age=60'` on a private bucket stores `public, max-age=60`. Once you write the header out, the visibility is yours to state too. @@ -146,7 +146,7 @@ Reads on a private bucket go through `signedReadUrl()`. See [Reading](/blob/buck This is the pattern worth learning, because it is the one that gets a year of caching out of a path that changes. -Every record carries `versionedUrl`, which is `${url}?v=${etag}` with the etag percent-encoded, since storage returns it quoted: the query reads `?v=%22...%22`. The etag changes whenever the content does, so the URL changes whenever the content does. A stable path stored with `cache: 'immutable'` and served through `versionedUrl` is cached for a year by URL, and an overwrite mints a new URL that no cache has ever seen. +Every record carries `versionedUrl`, which is `url` with the etag on the query. The etag changes whenever the content does, so a stable path stored with `cache: 'immutable'` and served through `versionedUrl` is cached for a year, and an overwrite mints a new URL that no cache has ever seen. ```ts app/api/avatar/route.ts const blob = await bucket.put(`avatars/${user.id}.png`, file, { @@ -194,7 +194,7 @@ A unique path per upload is the simplest of the three: nothing ever overwrites a For anything served through `signedReadUrl()`, two separate mechanisms are in play and both matter. -The link expires. `signedReadUrl()` defaults to 5 minutes and is capped by the credential that signed it, so `expiresAt` on the result is the answer per link rather than a number you assume. +The link expires. `signedReadUrl()` defaults to 5 minutes, and `expiresAt` on the result is the real answer per link rather than a number you assume. ```ts const { url, expiresAt } = await bucket.signedReadUrl("private/report.pdf") diff --git a/blob/bucket/deleting.mdx b/blob/bucket/deleting.mdx index aa4bf6dd..ed440556 100644 --- a/blob/bucket/deleting.mdx +++ b/blob/bucket/deleting.mdx @@ -39,7 +39,7 @@ await bucket.del("drafts/9f3c.txt") await bucket.del("drafts/9f3c.txt") // fine, still no throw ``` -That is what makes a delete safe to run from a retried job or a queue consumer with at-least-once delivery. Any other failure is a real error: a 403 surfaces as `signature_mismatch`, a 429 as `rate_limited`, a 503 as `not_ready`, and any other 5xx as `request_failed` carrying status 502. See [Errors](/blob/bucket/errors#what-storage-errors-map-to). +That makes a delete safe to run from a retried job or a queue consumer with at-least-once delivery. Any other failure is a real error; see [Errors](/blob/bucket/errors#what-storage-errors-map-to). `del` never tells you whether anything was there. If you need to know, ask first with `bucket.exists(path)`, which answers `false` instead of throwing. See [Reading](/blob/bucket/reading). @@ -49,15 +49,9 @@ That is what makes a delete safe to run from a retried job or a queue consumer w ## Deleting an array -An array is sent as S3 batch deletes, in chunks of 1000 paths. A 5000-path array is five `POST` requests, run one after another, not 5000 round trips. +An array is sent as batch deletes, in chunks of 1000 paths. A 5000-path array is five requests, not 5000 round trips. A bad path fails the chunk it is in; chunks before it have already run. -Two details are worth knowing before you read the error handling. - -First, every path in a chunk is validated before that chunk's request goes out, so a bad path fails the chunk it is in rather than being silently skipped. Chunks before it have already run. - -Second, and this is the one that shapes the API: S3 answers a batch delete with **200 and per-key errors inside the body**. A key that failed is reported in an `` block in an otherwise successful response. The SDK does not take that list at face value: for each key S3 named it makes one more `exists()` call and keeps only the paths that are still there. - -A survivor is the truth and the list is not. An error block for an object that is in fact gone would otherwise be reported to you as a failure you cannot act on, and the whole point of `failed` is that you can act on it. +Storage can report a key as failed inside an otherwise successful batch response. The SDK re-checks each of those keys and keeps only the paths that are genuinely still there, so `failed` is a list you can act on rather than one you have to verify. If anything survives, `del` throws `partial_delete`, status 500, whose `failed` array names exactly which paths are still there: @@ -83,7 +77,7 @@ Everything not in `failed` was deleted. `partial_delete` is a report, not a roll Use `BlobError.is(e)`, never `instanceof`. An ESM copy and a CJS copy of the class are two different classes. See [Errors](/blob/bucket/errors). -A batch delete is a `POST`, and the SDK only retries idempotent verbs, so a failure on a batch surfaces on the first try rather than being sent twice: a 503 as `not_ready`, any other 5xx as `request_failed` at status 502. +A batch delete is not retried internally, so a failure surfaces on the first try rather than being sent twice. --- @@ -117,15 +111,13 @@ await bucket.del({ prefix: "", all: true }) ## Paths are validated, never normalized -Every path reaching storage goes through `encodeKey`, which percent-encodes each segment and refuses outright any path containing a `.` or `..` segment: +A path containing a `.` or `..` segment is refused outright rather than resolved: ```ts await bucket.del("users/7/../8/private.pdf") // TypeError: path may not contain "." or ".." segments: users/7/../8/private.pdf ``` -The reason is the trust model rather than tidiness. Your server holds a temporary credential that authorizes the whole bucket, and the URL parser resolves `..` before the request is signed. A traversing key would sign a delete against a different object than the one your code named, and the credential would happily allow it. Normalizing the path would hide that; rejecting it does not. - This applies to `del` in all three shapes, and to `put`, `copy`, `move`, `signedUploadUrl` and `abortMultipartUpload` alike. If you build paths from user input, build them with `uniquePath`, which strips directory components out of every interpolated value. See [Writing](/blob/bucket/writing). --- @@ -189,9 +181,7 @@ const uploads = await bucket.listMultipartUploads({ prefix: "uploads/" }) await bucket.abortMultipartUpload({ path: "uploads/big.mp4", uploadId: "ABC..." }) ``` -This throws the upload away along with every part that landed for it. Missing is success, exactly like `del` on a path that is not there. - -That is also why it takes the record `listMultipartUploads()` returned rather than two positional strings. If the wire treats "not there" as success, then `abortMultipartUpload(uploadId, path)` with the arguments swapped would abort nothing, answer 204, and report that it worked. A named `{ path, uploadId }` pair cannot be swapped by accident, and an empty `uploadId` is refused with `invalid_input` before anything is sent. +This throws the upload away along with every part that landed for it. Missing is success, exactly like `del` on a path that is not there. Since a wrong pair would silently succeed, the arguments are named rather than positional, and an empty `uploadId` is refused with `invalid_input`. `onUploadComplete` receives `multipartUploadId` for exactly this pair. Store it alongside your row and you can abort a specific upload later without listing the bucket. It is `undefined` when the file went up as a single PUT. See [Upload handler](/blob/browser/upload-handler). @@ -235,9 +225,11 @@ An abandoned upload **under** the multipart threshold is not a multipart upload ## When the SDK deletes for you -Two paths in the upload handler delete objects without you asking: a throw out of `onUploadComplete`, and a `cancel()` from the browser. A cancel on a multipart upload aborts it, parts and all. Everything else deletes a stored object, and that goes through one guard, because R2 has no conditional delete. The object's etag is re-read first and the delete only happens when it still matches the one this upload produced, so a later upload to the same path is left alone with a warning; an upload the handler cannot identify at all is left stored with an error logged, because an orphan costs storage and a log line while a blind delete costs somebody else's accepted file. On a single PUT, the `upstash-upload` marker signed into the presigned URL is what says the object is this upload's at all. +Two paths in the upload handler delete objects without you asking: a throw out of `onUploadComplete`, and a `cancel()` from the browser. + +Both confirm the object is the one this upload wrote before deleting it. A later upload that took the same path is left alone with a warning, and an object the handler cannot identify is left stored with an error logged: an orphan costs storage, while a blind delete costs somebody else's accepted file. -Any throw out of `onUploadComplete` runs that discard, including a retryable `BlobError`: the object is deleted first, so the retry the error asks for arrives at an empty path. Catch your own storage errors rather than letting them escape the callback. See [Upload handler](/blob/browser/upload-handler#onuploadcomplete) for the callback and [Abandoned uploads](/blob/browser/abandoned-uploads) for what happens when nothing is ever posted at all. +Any throw out of `onUploadComplete` runs that delete, including a retryable `BlobError`, so the retry the error asks for arrives at an empty path. Catch your own storage errors rather than letting them escape the callback. See [Upload handler](/blob/browser/upload-handler#onuploadcomplete) and [Abandoned uploads](/blob/browser/abandoned-uploads). --- diff --git a/blob/bucket/errors.mdx b/blob/bucket/errors.mdx index 2310a1ec..24caa813 100644 --- a/blob/bucket/errors.mdx +++ b/blob/bucket/errors.mdx @@ -39,7 +39,7 @@ if (BlobError.is(e)) { } ``` -`is()` is a type guard, so the fields are typed after it. It is the only supported check, and it is what the SDK itself uses internally at every boundary. +`is()` is a type guard, so the fields are typed after it. --- @@ -113,9 +113,7 @@ try { ## Messages are written to be shown -Messages are lowercase in the source and sentence-cased when the error is built, so an app can print `e.message` straight into its error line without writing its own `capitalize()`. - -A message that opens with an identifier keeps its case. A MIME type, a file name or a metadata key is not a word to raise: "Image/png is not allowed" names a type that does not exist, and "Cat.png" is not the file the user picked. +Messages arrive sentence-cased, so an app can print `e.message` straight into its error line. A message that opens with an identifier, like a MIME type or a file name, keeps that identifier's own case. ```ts new BlobError("not_found").message // 'Not found' @@ -146,7 +144,7 @@ A message that already contains its hint is not doubled. ## Errors across the wire -This is what makes the browser half usable. An upload route answers every refusal with `BlobError.toJSON()` at the error's own status, and the browser rebuilds it with `BlobError.fromJSON()`. So `error.code` inside a hook is the code your server raised, not a status number you have to decode back into a meaning. +An upload route answers every refusal with the error's own code and status, and the browser rebuilds it, so `error.code` inside a hook is the code your server raised rather than a status number you have to decode. ```tsx app/picker.tsx "use client" @@ -187,9 +185,9 @@ function describe(error: BlobError): string { A route runs `onError` first. If it returns a `Response`, that is the answer; if it returns a `BlobError`, the answer is that error's JSON at its own status. Otherwise the throw falls through three cases: -1. **A `BlobError` is answered as itself.** `toJSON()` at `e.status`, with `hint`, `failed`, `etag`, `size` and `retryAfter` when they are set. -2. **An app error carrying an integer `status` between 400 and 599 is mapped through the status table below.** This is how an auth check that throws its own 401 reaches the browser as `unauthorized`, so a caller can tell a dead session from a rejected file without reading status numbers. -3. **Anything else is treated as your bug and rethrown**, so the framework logs it with its stack rather than masking it as a generic 500. +1. **A `BlobError` is answered as itself**, at its own status, with `hint`, `failed`, `etag`, `size` and `retryAfter` when they are set. +2. **An app error carrying a `status` between 400 and 599 is mapped through the table below.** This is how an auth check that throws its own 401 reaches the browser as `unauthorized`. +3. **Anything else is treated as your bug and rethrown**, so your framework logs it with its stack rather than masking it as a generic 500. | Status | Code | | ------ | ---- | @@ -267,7 +265,7 @@ Errors from R2 are normalised before they leave the SDK, first matching wins. | 413, or `EntityTooLarge` | `too_large` | | anything else | `request_failed`, message `R2 responded : ` | -For that last row the status is passed through, except that a 5xx is normalised to 502: the failure is upstream of your app, not in it. +For that last row the status is passed through, except that a 5xx becomes 502, since the failure is upstream of your app rather than in it. --- @@ -275,7 +273,7 @@ For that last row the status is passed through, except that a 5xx is normalised Some failures never reach your route, so the browser names them itself. -**A PUT that fails with no status and no bytes sent** is not a dropped link. The browser refused it before it went out, and the reason is never visible to script because it is the preflight that failed. That gets three attempts rather than the twenty a real network failure gets, and the hint says so: +**A PUT that fails with no status and no bytes sent** is not a dropped link. The browser refused it before it went out, and the reason is never visible to script because it is the preflight that failed: ``` the browser blocked the request before sending any bytes, which is almost always CORS: @@ -337,7 +335,7 @@ Three codes come from the credential service rather than from storage or from yo | `not_ready` | 503 | The bucket is not ready yet. | Retry the request. | | `mint_backoff` | 429 | The service asked for a backoff longer than a request can wait, over 10 seconds. `retryAfter` says how long. | Retry the request later rather than blocking on it. | -The SDK already waits out short backoffs itself, up to three times. `mint_backoff` is what is left over: a pause no single request can sit through, so it is handed back to the caller instead of holding a serverless invocation open for it. +The SDK waits out short backoffs itself. `mint_backoff` is what is left over: a pause no single request can sit through, handed back to you instead of holding a serverless invocation open for it. ```ts try { @@ -350,4 +348,4 @@ try { } ``` -Credentials are short-lived, cached per token, and re-minted just before they expire. A credential that expires mid-request is caught inside the SDK: it re-mints once and asks again, and only a second refusal surfaces. See [How signing works](/blob/overall/signing) for how that lifetime caps a signed link, and [Quickstart](/blob/overall/quickstart) for where the token comes from. +Credentials are short-lived and re-minted before they expire. One that expires mid-request is handled inside the SDK, so only a second refusal surfaces. See [How signing works](/blob/overall/signing) for how that lifetime caps a signed link. diff --git a/blob/bucket/reading.mdx b/blob/bucket/reading.mdx index 878a5e6b..ed2ca636 100644 --- a/blob/bucket/reading.mdx +++ b/blob/bucket/reading.mdx @@ -38,9 +38,7 @@ Four record shapes come back from the SDK. They nest, so the rest of this page n | `etag` | `string` | Storage's etag, quoted as it arrives: `"9f3c..."`. | | `uploadedAt` | `Date` | Last modified. | -### `blob` is a record, never bytes - -In this SDK `blob` always names a record, and never the bytes of one. The DOM already has a `Blob` and it is bytes, so the two must never swap places: nothing in the API takes a parameter named `blob`, and bytes go in as `body`. That is why `put(path, body)` reads the way it does, and why the bytes on a download sit under `body` on a record rather than being the return value. +Throughout the SDK, `blob` names a record and never the bytes of one. Bytes always go in and come out as `body`. --- @@ -87,9 +85,9 @@ info.uploadedAt // Date Like `get`, a missing object throws `not_found` rather than answering `undefined`. -Metadata keys come back lowercased, since they cross the wire as `x-amz-meta-*` headers: `{ uploadedBy: 'u1' }` written at upload reads back as `metadata.uploadedby`. The rules that govern what can be written are in [Writing](/blob/bucket/writing#metadata). +Metadata keys come back lowercased: `{ uploadedBy: 'u1' }` written at upload reads back as `metadata.uploadedby`. The rules for what can be written are in [Writing](/blob/bucket/writing#metadata). -`metadata` is the reason this call exists next to `exists()`. It comes back from `get` and from `info`, and from nothing else: a listing does not carry it. So `info` is the call a cleanup cron makes before it deletes anything, to confirm the object at that path is the one its row reserved rather than a later upload that reused the path. That sweep, and why a row is the only thing that can tell an abandoned upload from a finished one, is in [Abandoned uploads](/blob/browser/abandoned-uploads). +`metadata` comes back from `get` and `info` and from nothing else, so `info` is the call a cleanup cron makes to confirm the object at a path is the one it expects. See [Abandoned uploads](/blob/browser/abandoned-uploads). --- @@ -137,9 +135,9 @@ do { } while (cursor) ``` -A listing carries `BlobObject`, which means it has the path, size, etag, timestamp and URLs, and it does not have `contentType` or `metadata`. Storage does not return those in a listing, and fetching them would be one HEAD per key. +A listing carries `BlobObject`: path, size, etag, timestamp and URLs, but no `contentType` or `metadata`. Reading those is one `info()` per object. -`prefix` is also the only filter there is. There is no query by owner, by type, by date or by anything else, and the only way to find "this user's files" is a prefix you chose at upload time. An app that has to ask real questions about its files should keep its own table, write the row when the upload is authorized, and treat the bucket as the bytes rather than the index. That table is also what makes deleting and re-rendering cheap, since it holds the metadata a listing cannot. +`prefix` is also the only filter there is. There is no query by owner, by type or by date, so the only way to find "this user's files" is a prefix you chose at upload time. An app that has to ask real questions about its files should keep its own table and treat the bucket as the bytes rather than the index. --- @@ -152,7 +150,7 @@ bucket.publicUrl("avatars/u7.png") // 'https://b0f3a91c24d.blob.upstash.io/avatars/u7.png' ``` -There is no network call. The bucket's public DNS label is carried in the token itself, so `publicUrl` is string work against `.blob.upstash.io` and the path, percent-encoded. It returns `undefined` on a private bucket. It throws a `TypeError` for a path that is empty or contains a `.` or `..` segment, the same check every other path takes. +There is no network call. It returns `undefined` on a private bucket, and throws a `TypeError` for a path that is empty or contains a `.` or `..` segment. ### `versionedUrl` @@ -182,7 +180,7 @@ blob.versionedUrl // undefined bucket.publicUrl("reports/2026-01.pdf") // undefined ``` -A `visibility` in the credentials response wins over what you declared, so a bucket that is private in the console stays private here even if the code says otherwise. Reads on a private bucket go through `signedReadUrl()`. +A bucket that is private in the console stays private here even if the code does not say so. Reads on a private bucket go through `signedReadUrl()`. --- @@ -205,9 +203,9 @@ const { url, expiresAt } = await bucket.signedReadUrl("reports/2026-01.pdf", { The return is `{ url, expiresAt }`. -### The lifetime is answered, not chosen +### Use `expiresAt`, not `expiresIn` -Links are signed with the bucket's short-lived credential, and a signature cannot outlive the credential that made it. So `expiresIn` is what you ask for, and `expiresAt` is what you got: it is never later than the signing credential's own expiry, and it is the value to cache the link against rather than a duration you compute yourself. +`expiresIn` is what you ask for. `expiresAt` is what you got, and it can be sooner. Cache the link against `expiresAt` rather than a duration you compute yourself. ```ts const cached = await cache.get(key) @@ -217,20 +215,17 @@ if (!cached || cached.expiresAt < new Date()) { } ``` -The default with no `expiresIn` is 5 minutes, shortened if the credential has less than that left. Asking for more than the credential can cover re-mints where that helps, and is capped where it does not. [How signing works](/blob/overall/signing) covers the mechanism, and `signedUploadUrl` for the write direction. +The default with no `expiresIn` is 5 minutes. See [How signing works](/blob/overall/signing) for what caps a link's lifetime. ### `downloadAs` -`downloadAs` sets a `Content-Disposition: attachment` on the response, so the browser saves the file under that name rather than rendering it. - -The name is carried as an RFC 6266 `filename*` ext-value, percent-encoded, with an ASCII `filename` fallback cut back to characters that cannot end the quoted string. A name with a quote, a semicolon or a CRLF in it cannot add a parameter or a second header, and a Unicode name arrives intact: +`downloadAs` sets `Content-Disposition: attachment`, so the browser saves the file under that name rather than rendering it. Unicode names arrive intact: ```ts await bucket.signedReadUrl(path, { downloadAs: "café ☕.pdf" }) -// content-disposition: attachment; filename="caf_ _.pdf"; filename*=UTF-8''caf%C3%A9%20%E2%98%95.pdf ``` -The disposition is signed into the URL along with everything else, so it cannot be edited off the query string by whoever holds the link. +The filename is signed into the URL, so it cannot be edited off the query string by whoever holds the link. ### `contentType` @@ -240,7 +235,7 @@ The disposition is signed into the URL along with everything else, so it cannot await bucket.signedReadUrl("exports/rows.bin", { contentType: "text/csv" }) ``` -It is validated as a media type and throws `invalid_input` if it is not one, for the same reason `downloadAs` is encoded: this value becomes a response header. +It throws `invalid_input` if it is not a valid media type. --- @@ -253,7 +248,7 @@ const uploads = await bucket.listMultipartUploads({ prefix: "uploads/" }) // [{ path: 'uploads/big.bin', uploadId: 'mp-1', initiatedAt: Date }] ``` -A multipart upload that was started and never completed or aborted is billed storage that `list()` cannot see, which makes this the only call that can find them. Finding them is not the job though: sweeping them is, and `abortStaleMultipartUploads()` is in [Deleting](/blob/bucket/deleting#incomplete-multipart-uploads). +A multipart upload that was started and never completed or aborted is billed storage that `list()` cannot see, so this is the only call that can find them. To sweep them, use `abortStaleMultipartUploads()`, in [Deleting](/blob/bucket/deleting#incomplete-multipart-uploads). --- @@ -273,7 +268,7 @@ const res = await s3.send( ) ``` -`endpoint` and `credentials` are async providers rather than values, for the reason given in [Writing](/blob/bucket/writing#the-s3-escape-hatch). +`endpoint` and `credentials` are async providers rather than values. Pass them through as they come. --- diff --git a/blob/bucket/writing.mdx b/blob/bucket/writing.mdx index a8b261cd..b9f5aa63 100644 --- a/blob/bucket/writing.mdx +++ b/blob/bucket/writing.mdx @@ -51,7 +51,7 @@ export default { ``` -The credential cache is keyed by token, not held per instance. Constructing a `Bucket` per request on a serverless platform is the intended shape and does not mint a credential each time: two clients built from the same token share one. +Constructing a `Bucket` per request on a serverless platform is fine and costs nothing. Credentials are cached per token, so two clients built from the same token share one. --- @@ -104,7 +104,7 @@ On a private bucket `url` and `versionedUrl` are `undefined` and reads go throug ### Bodies -`put` takes a `PutBody`. Some of these already know how long they are and what they contain, which is what decides whether `put` has to buffer anything and what the object is stored as. +`put` takes a `PutBody`. Whether a body carries its own length and type decides whether you have to declare `size` or `contentType`. | Body | Carries its length | Carries a content type | | --- | --- | --- | @@ -146,7 +146,7 @@ await bucket.put("export.csv", stream) There are two ways through. -**Pass `maxBytes`.** The stream is read into memory up to that many bytes, which is what makes the length knowable, and a stream that runs past the cap is cancelled with `too_large`. Keep the cap somewhere your process can hold. +**Pass `maxBytes`.** The stream is buffered up to that many bytes, and one that runs past the cap is cancelled with `too_large`. Keep the cap somewhere your process can hold in memory. ```ts const blob = await bucket.put("export.csv", stream, { maxBytes: "10mb" }) @@ -158,9 +158,9 @@ const blob = await bucket.put("export.csv", stream, { maxBytes: "10mb" }) const blob = await bucket.put("export.csv", stream, { size: 5000 }) ``` -A declared `size` is what the request is sent with: it becomes the `Content-Length`, and it is signed, so a body that does not match it fails the request rather than being stored at the wrong length. A body large enough to take the multipart path is counted as it streams, and the mismatch is named there instead: see [Large bodies](#large-bodies). +A declared `size` has to be right. A body that does not match it fails the request rather than being stored at the wrong length. -The same applies to a `Request` that arrived chunked: delete or ignore its `content-length` and it is an unknown length like any other stream. +A `Request` that arrived chunked has no `content-length` either, so it is an unknown length like any other stream. When bytes are being proxied through a route, keep `maxBytes` under the platform's own request body cap, since that refusal happens before your route runs. The numbers are in [Errors](/blob/bucket/errors#platform-body-limits). @@ -177,7 +177,7 @@ await bucket.put("uploads/../secrets/key.pem", body) // TypeError: path may not contain "." or ".." segments ``` -Normalising would be the wrong answer here. The temporary credential the SDK signs with authorizes the whole bucket, and a URL parser resolves `..` on its own, so a traversing key would quietly touch a different object than the one it names. Refusing is the only outcome that cannot surprise you. +Build paths out of user input with `uniquePath` rather than string concatenation. ### uniquePath @@ -190,16 +190,14 @@ const path = uniquePath`${user.id}/${file.name}` // 'u7/holiday-pic-3xK9mBqR.png' ``` -The trust boundary is the interpolation. Slashes in the literal chunks are structure; slashes inside `${}` are stripped along with the rest of the directory component, so an interpolated value can never contribute a directory of its own. +Slashes in the literal chunks are structure. Slashes inside `${}` are stripped along with the rest of the directory component, so an interpolated value can never contribute a directory of its own. ```ts uniquePath`chat/${"../admin/x.png"}` // 'chat/x-9fQ2mAe7.png' uniquePath`a/${"b/c"}` // 'a/c-Kd3xR8wP' ``` -Each interpolated value is reduced to its basename, stripped of control and format characters, NFC-normalized, lowercased, and slugged: runs of anything that is not a letter or a number become `-`. Letters and digits from any script survive, so `café.pdf` stays `café`. The stem is capped at 64 characters. The extension, up to 8 characters, is kept and lowercased. - -An 8 character base58 suffix is then appended to the final basename, before the extension. The alphabet leaves out `0`, `O`, `I` and `l`, so a path read aloud or retyped stays the same path. +Each interpolated value is lowercased and slugged: runs of anything that is not a letter or a number become `-`. Letters and digits from any script survive, so `café.pdf` stays `café`. The stem is capped at 64 characters, the extension at 8. A random 8 character suffix is appended before the extension. ```ts uniquePath`${"Q3 Report (final).pdf"}` // 'q3-report-final-7hTbN2xY.pdf' @@ -235,7 +233,7 @@ await bucket.put("a.txt", "x", { metadata: { note: "café" } }) // -- code 'invalid_input', status 400 ``` -This is stricter than it looks, and the reason is measurable. R2 does not store a non-ASCII value verbatim: `{ note: 'café' }` comes back as `=?utf-8?Q?caf=C3=A9?=`. Accepting it would mean handing you back a different string than the one you wrote, and finding out about it on the read. Percent-encode instead, and it round trips exactly: +Storage does not carry a non-ASCII value back verbatim: `{ note: 'café' }` would read back as `=?utf-8?Q?caf=C3=A9?=`. Percent-encode instead and it round trips exactly: ```ts await bucket.put("a.txt", "x", { metadata: { note: encodeURIComponent("café") } }) @@ -250,9 +248,9 @@ Metadata comes back from `info()` and `get()`, but not from `list()`. A listing ## Conditional writes -Two options turn `put` into a conditional write. Both are enforced by storage, not by a read-then-write in the SDK, so neither has a race window. +Two options turn `put` into a conditional write. Both are enforced by storage, so neither has a race window. -**`overwrite: false`** sends `If-None-Match: *`. If something is already at the path the write is a real 412, and the SDK raises `already_exists` carrying what is there: +**`overwrite: false`** refuses the write if something is already at the path, raising `already_exists` carrying what is there: ```ts try { @@ -265,7 +263,7 @@ try { } ``` -**`ifUnchanged: etag`** sends `If-Match`. If the object changed since you read that etag, the write throws `conflict`: +**`ifUnchanged: etag`** throws `conflict` if the object changed since you read that etag: ```ts const current = await bucket.info("u/7/profile.json") @@ -273,7 +271,7 @@ await bucket.put("u/7/profile.json", next, { ifUnchanged: current.etag }) // throws BlobError 'conflict' if somebody else wrote first ``` -Both are single-PUT only, because a multipart upload has no conditional complete. They turn multipart off, which is why a conditional write of a large body still goes up as one request. Asking for both at once is a build-time mistake rather than a silent downgrade: +Both are single-PUT only, so they turn multipart off: a conditional write of a large body still goes up as one request. Asking for both at once throws rather than silently downgrading: ```ts await bucket.put("big.bin", data, { multipart: true, overwrite: false }) @@ -285,7 +283,7 @@ await bucket.put("big.bin", data, { multipart: true, overwrite: false }) ## updateJson -`updateJson` is the compare-and-set loop those two options are for, written once. It reads the document, calls your function with the parsed value, and writes the result back with `If-Match`, or with `If-None-Match: *` when there was nothing there. A conflict means somebody wrote in between, so it reads again and re-runs your function against what actually landed. +`updateJson` is the compare-and-set loop those two options are for, written once. It reads the document, calls your function with the parsed value, and writes the result back conditionally. If somebody wrote in between, it reads again and re-runs your function against what actually landed. ```ts interface Settings { @@ -349,18 +347,13 @@ A body over 16 MB, decimal, goes up as a multipart upload instead of one PUT. `m await bucket.put("video.mp4", data, { multipart: "100mb" }) ``` -R2 refuses a single PUT larger than about 5 GiB, so past that there is no choice. `multipart: false` on a body that big is refused rather than attempted: - -```ts -// BlobError: 6 GB is over the 5.4 GB a single PUT can carry -// (multipart: false forbids the parts this body needs) -- code 'too_large' -``` +A single PUT cannot carry more than about 5 GiB, so past that there is no choice. `multipart: false` on a body that big throws `too_large` rather than attempting it. -Server-side, parts are sent one at a time. A part is buffered whole so it can be retried, and holding several would multiply that memory by the concurrency. If anything fails, the SDK aborts the whole upload before throwing, because an incomplete multipart upload is billed storage that `list()` cannot see. +Parts are sent one at a time. If anything fails, the SDK aborts the whole upload before throwing, so nothing is left behind. -This is also the path that counts the body against the `size` you declared, since it is already reading the stream part by part. Too many bytes throws `invalid_input` with `Body is longer than the declared 5000 bytes`, and too few throws `invalid_input` with `Body was 4000 bytes, 5000 were declared`. +A body that does not match a declared `size` throws `invalid_input` naming the mismatch. -Parts, pause, resume and per-part retry are covered in full in [Large files](/blob/browser/large-files), including the cron for upload parts a closed browser tab left behind. +Parts, pause, resume and per-part retry are covered in full in [Large files](/blob/browser/large-files). --- @@ -390,12 +383,12 @@ await fetch(upload.url, { method: "PUT", headers: upload.headers, body: pdf }) It returns `{ url, headers, expiresAt }`. -`headers` are pinned into the signature and must be sent verbatim. Drop one, change one, or add one, and storage answers **403** rather than letting the caller choose what the object is stored as. That is also what makes `metadata` yours and not the uploader's. +`headers` are signed into the URL and must be sent verbatim. Drop one, change one, or add one, and storage answers **403**. That is what makes `metadata` yours and not the uploader's. -A link can never outlive the credential that signed it, so `expiresAt` is the answer rather than what you asked for. The SDK re-mints to cover a longer ask where it can, and `expiresAt` reports what actually came out. +`expiresAt` is the real answer for the link, which may be sooner than what you asked for. Use it rather than computing a deadline yourself. -For a browser upload, use the upload handler instead. It also handles multipart, resume, and the completion callback this cannot: a signed URL is one PUT, and nothing tells your server it happened. See [Upload handler](/blob/browser/upload-handler) and [How signing works](/blob/overall/signing). +For a browser upload, use the upload handler instead. A signed URL is one PUT with no multipart, no resume, and nothing to tell your server it happened. See [Upload handler](/blob/browser/upload-handler). --- @@ -413,13 +406,13 @@ const s3 = new S3Client({ endpoint, region, credentials }) await s3.send(new ListObjectsV2Command({ Bucket: name, Prefix: "reports/" })) ``` -`endpoint` and `credentials` are async providers rather than values. The endpoint is only known from a credentials response, and the credential itself is short-lived, so handing the aws-sdk providers is what lets it re-read a fresh one on expiry instead of failing an hour in. +`endpoint` and `credentials` are async providers rather than values. Pass them through as they come, so the aws-sdk can pick up a fresh credential when the old one expires. --- ## Telemetry -The SDK sends its version, the runtime it is on, and the platform as headers on credential requests to Upstash. Those are one request per credential lifetime, not per object, so nothing on the hot path carries them. Turn it off with `UPSTASH_DISABLE_TELEMETRY` in the environment, or with `enableTelemetry: false` on the `Bucket`. Setting the variable to `false`, `0`, `no` or `off` does not opt out: an environment variable that says false and means true is a trap. +The SDK sends its version, runtime and platform as headers on credential requests to Upstash. Turn it off by setting `UPSTASH_DISABLE_TELEMETRY` in the environment (any value), or with `enableTelemetry: false` on the `Bucket`. --- diff --git a/blob/overall/quickstart.mdx b/blob/overall/quickstart.mdx index 13813aa6..ffb22bf9 100644 --- a/blob/overall/quickstart.mdx +++ b/blob/overall/quickstart.mdx @@ -38,7 +38,7 @@ Create a bucket in the [Upstash Console](https://console.upstash.com) and copy i UPSTASH_BLOB_TOKEN=... ``` -Everything below reads this variable, and `Bucket.fromEnv()` is the call that reads it. +`Bucket.fromEnv()` and the upload handler both read this variable. --- @@ -48,7 +48,7 @@ Everything below reads this variable, and `Bucket.fromEnv()` is the call that re -The handler decides who may upload, where the object goes, and what happens once it lands. It runs on your server and signs the upload. It never sees the bytes. With no `bucket` of its own it builds one from `UPSTASH_BLOB_TOKEN`, once. +The handler runs on your server and decides who may upload, where the object goes, and what happens once it lands. It never sees the bytes. The smallest one that works states what it accepts and where the object goes: @@ -83,7 +83,7 @@ export const { GET, POST } = uploads -`uploadHooks()` reads the handler's type. That is how `upload.blob.data` on the client is typed from what `onUploadComplete` returned, and how a route name that does not exist fails to compile. +`uploadHooks()` reads the handler's type, so `upload.blob.data` on the client is typed from what `onUploadComplete` returned and a route name that does not exist fails to compile. ```ts lib/upload-hooks.ts "use client" @@ -131,7 +131,7 @@ export default function Page() { -Uploads work at this point. What the handler above does not do is check who is asking or write anything down, and both go in the same two callbacks. `onBeforeUpload` runs before a byte is signed, so a throw there costs nothing; `onUploadComplete` runs once the object exists. +Uploads work at this point, but the handler above does not check who is asking or write anything down. Both go in the same two callbacks: `onBeforeUpload` runs before anything is signed, `onUploadComplete` once the object exists. ```ts lib/uploads.ts import "server-only" @@ -195,9 +195,9 @@ A direct upload PUTs to storage, not to your app, so the bucket's CORS configura | the request headers `content-type`, `cache-control`, and every `x-amz-meta-*` the route writes | a single PUT carries them as real headers, pinned into the signature, so the browser must send them verbatim | | `ETag` in the exposed response headers | the browser reads each part's etag off a cross-origin response to complete the upload | -The metadata header names are yours: a route returning `metadata: { owner }` sends `x-amz-meta-owner`. A multipart part PUT pins only `content-length`, so it is the single-PUT path that needs the header list. Exposing `Retry-After` too is worth doing but not required; without it the client backs off on its own schedule rather than the one storage asked for. +The metadata header names are yours: a route returning `metadata: { owner }` sends `x-amz-meta-owner`. Exposing `Retry-After` too is worth doing but not required. -A browser blocks a request that fails CORS before a single byte goes out, and script never sees why. The SDK gives up after three such attempts and says so, rather than backing off for minutes over an answer that will not change. +A browser blocks a request that fails CORS before a single byte goes out, and script never sees why, so a PUT that fails with no status and no bytes sent is almost always this. --- diff --git a/blob/overall/signing.mdx b/blob/overall/signing.mdx index 62f0a1e7..ff7edef8 100644 --- a/blob/overall/signing.mdx +++ b/blob/overall/signing.mdx @@ -2,34 +2,21 @@ title: "How Signing Works" --- -Your server holds a bucket token. It exchanges that token for short-lived S3 credentials from Upstash, signs individual URLs with those credentials, and hands only the URLs to the browser. The token, the credentials and the bucket password never leave your server. Every URL a browser holds is scoped to one object, one method, one set of headers, and a few minutes. +Your server holds a bucket token. It exchanges that token for short-lived S3 credentials from Upstash, signs individual URLs with those credentials, and hands only the URLs to the browser. The token and the credentials never leave your server. Every URL a browser holds is scoped to one object, one method, one set of headers, and a few minutes. -This page follows that chain from the token in your environment down to the bytes landing in storage, so you can reason about what a browser holding one of these URLs can and cannot do. If you only want to upload a file, start with the [Quickstart](/blob/overall/quickstart) instead. +This page is for reasoning about what a browser holding one of these URLs can and cannot do. If you only want to upload a file, start with the [Quickstart](/blob/overall/quickstart) instead. --- ## The bucket token -`UPSTASH_BLOB_TOKEN` is a base64url string. It is not an opaque handle: the SDK decodes it locally with `decodeToken`, and no network call is involved. +`UPSTASH_BLOB_TOKEN` is not an opaque handle. It packs three things the SDK reads locally, with no network call: -| Offset | Bytes | Meaning | -| --- | --- | --- | -| 0 | 1 | version, `0x02` | -| 1 | 1 | flags | -| 2 | 1 | length of `bucketId` | -| 3 | 2 | length of `password`, big endian | -| 5 | 1 | length of `hashForDomain` | -| 6 | rest | the three fields, in that order, UTF-8 | - -Decoding is strict. A version byte other than `0x02` is `token: unsupported format`. A zero-length field is `token: malformed`. The total length has to be exactly `6 + idLen + pwLen + hLen`, so a trailing byte is tamper rather than padding and is refused. Surrounding whitespace is trimmed, and anything that is not base64url throws `token is not base64url`. - -The three fields do different jobs. +- **The bucket id**, which names the bucket inside your account. +- **The public DNS label**, which is why `bucket.publicUrl(path)` can build a URL without a request. +- **A secret key**, used only to sign [completion tokens](#the-completion-token) inside your own process. It is never sent anywhere, not to Upstash and not to storage. -| Field | What it is for | -| --- | --- | -| `bucketId` | Names the bucket inside your account. It is the `b` claim in every completion token, checked when one is spent, which is what stops a token minted for one bucket from being spent against another. It is also the bucket name `bucket.s3()` hands the aws-sdk. It is not in the public object URL: that is built from `hashForDomain`, and storage request paths use the `bucket` the credentials response names. | -| `hashForDomain` | The bucket's public DNS label. Objects are served from `.blob.upstash.io`, so `bucket.publicUrl(path)` is pure string work with no request. On a private bucket it returns `undefined`, because nothing serves that host. | -| `password` | The HMAC key for [completion tokens](#the-completion-token). It is never sent anywhere, not to Upstash and not to storage. It only ever keys a MAC inside your process. | +A malformed or tampered token is refused when it is decoded rather than on the first request. The token is a bearer secret. Anything holding it can mint credentials for the whole bucket. Keep it server side, never in `NEXT_PUBLIC_`, `VITE_`, or any other variable your bundler inlines into client code. @@ -39,100 +26,39 @@ The token is a bearer secret. Anything holding it can mint credentials for the w ## Minting temporary credentials -The token is not an S3 credential. To touch storage, the SDK exchanges it: - -```http -POST https://blob.upstash.io/v1/credentials -Authorization: Bearer -``` - -The request carries no body and times out after 10 seconds. Telemetry headers ride along unless you set `UPSTASH_DISABLE_TELEMETRY` or pass `enableTelemetry: false`. - -The response is the whole picture of where this bucket lives: - -| Field | Meaning | -| --- | --- | -| `accessKeyId`, `secretAccessKey`, `sessionToken` | The temporary S3 credential everything else is signed with. | -| `endpoint` | The R2 endpoint every object request goes to. | -| `bucket` | The bucket name inside that endpoint. | -| `region` | The region the SigV4 scope is built from. | -| `expiresAt` | Unix seconds. The hard ceiling on anything signed with this credential. | -| `visibility` | `'public'` or `'private'`. Private drops `url` and `versionedUrl` from every record. | -| `signing` | Optional, and longer lived. A read-only credential used only for presigning reads. | - -Because the `endpoint` decides where every subsequent request goes, the SDK refuses to take it on faith. It must parse as a URL, its protocol must be `https:`, and its hostname must end in `.r2.cloudflarestorage.com`. Anything else is `request_failed` with the message `Credentials response named an unexpected endpoint`, before a single byte is signed. +The token is not an S3 credential. To touch storage, the SDK exchanges it with Upstash for a short-lived one. That exchange also tells the SDK where the bucket lives, when the credential expires, and whether the bucket is public or private. -### Caching +Credentials are cached per token rather than per `Bucket` instance, so constructing a bucket inside a request handler is free and does not mint a credential each time. Concurrent callers share one in-flight mint rather than racing, and a credential is refreshed shortly before it expires. -The credential cache is keyed by the token, not held on the `Bucket` instance. `Bucket.fromEnv()` inside a request handler is the documented shape on every serverless platform, and an instance-held cache would mint a fresh credential for each request. Constructing a bucket per request is free. - -Two constants shape when a re-mint happens: - -- `REFRESH_MARGIN_MS` is 30 seconds. A cached credential is considered usable until 30 seconds before it expires. -- `NO_BETTER_MS` is 30 seconds. The Upstash agent hands back its own cached credential until roughly 60 seconds of life remain on it, so asking for a longer one often returns exactly what you already had. When a re-mint comes back no later than the credential it replaced, the SDK stops asking for 30 seconds. Mints are an account-wide budget, and a loop that refetches on every operation is how you reach the rate limiter. - -Concurrent callers share one in-flight mint rather than racing. - -### When minting fails - -| Response | What you get | -| --- | --- | -| 401 | `unauthorized`, `the bucket token was rejected` | -| 429 or 503 with `Retry-After` under 10s | Waited out and retried, up to 3 retries. A missing or unparseable `Retry-After` becomes 2 seconds. | -| 429 or 503 with `Retry-After` over 10s | `mint_backoff` immediately, carrying `retryAfter`. No request can usefully block that long, so the caller is told to come back. | -| 429 after the retries | `rate_limited` | -| 503 after the retries | `not_ready` | -| anything else | `request_failed` with status 502 | - -See [Errors](/blob/bucket/errors) for the full code list. +A failed mint surfaces as `unauthorized` (the token was rejected), `rate_limited`, `not_ready` or `mint_backoff`. See [Credential errors](/blob/bucket/errors#credential-errors). --- ## Signing a request -Signing is AWS Signature Version 4 over Web Crypto, service `s3`, region taken from the credential. The payload hash is `UNSIGNED-PAYLOAD` everywhere, which is what lets a body stream through without being buffered and hashed first. - -There are two modes, and which one is used tells you who is making the request. - -| Mode | Where the signature lives | Used for | -| --- | --- | --- | -| `signHeaders` | An `Authorization` header, plus `x-amz-date`, `x-amz-content-sha256` and `x-amz-security-token` | Every request your server makes to storage: `put`, `get`, `list`, `del`, `CreateMultipartUpload`, `CompleteMultipartUpload`. | -| `presign` | Query string: `X-Amz-Algorithm`, `X-Amz-Credential`, `X-Amz-Date`, `X-Amz-Expires`, `X-Amz-SignedHeaders`, `X-Amz-Security-Token`, `X-Amz-Signature` | Every URL handed to a browser, and every URL you make with `signedReadUrl()` or `signedUploadUrl()`. | +Signing is AWS Signature Version 4. Requests your server makes to storage carry the signature in an `Authorization` header. Every URL handed to a browser, and every URL from `signedReadUrl()` or `signedUploadUrl()`, carries it on the query string instead. ### Signed headers -A presigned URL can pin headers. Each pinned header name and value is folded into the canonical request, lowercased, trimmed, with runs of inner whitespace collapsed, and the sorted list of names is published in `X-Amz-SignedHeaders`. +A presigned URL can pin headers, and the client then has to send them back byte for byte. Change a value, or omit a header the URL declared, and storage answers 403. That is not a rule the SDK enforces on the client, it is arithmetic inside the signature. A header pinned into a URL is not the client's to choose. -The consequence is the important part: the client has to send those headers back byte for byte. Change a value, or omit a header the URL declared, and storage answers 403. That is not a rule the SDK enforces on the client, it is arithmetic inside the signature. A header pinned into a URL is not the client's to choose. - -Query parameters work the same way. `response-content-disposition` on a signed read URL rides inside the signature, so a link whose filename was edited afterwards is refused rather than honoured. +Query parameters work the same way. The download filename on a signed read URL rides inside the signature, so a link whose filename was edited afterwards is refused rather than honoured. ### Path encoding -S3 wants every character outside `A-Za-z0-9-_.~` percent-encoded, including the ones `encodeURIComponent` leaves alone, and encoded as uppercase hex UTF-8 bytes. `uriEncode` does that; `encodeKey` applies it per path segment so slashes stay structural. +Paths are percent-encoded per segment, so slashes stay structural and everything else survives. -`encodeKey` also refuses any path containing a `.` or `..` segment outright, rather than normalising it. The reason is the trust model: a temporary credential authorizes the whole bucket, and the URL parser resolves `..` before the request is signed, so a traversing key would sign a request against a different object than the one your code named. Rejecting is the only safe answer. `uniquePath` guards the same boundary from the other side, by stripping directory components out of every interpolated value. Its rules are on [Writing](/blob/bucket/writing#uniquepath). +A path containing a `.` or `..` segment is refused outright rather than normalised. Your server's credential authorizes the whole bucket, and the URL parser resolves `..` before the request is signed, so a traversing key would sign a request against a different object than the one your code named. `uniquePath` guards the same boundary from the other side, by stripping directory components out of every interpolated value. Its rules are on [Writing](/blob/bucket/writing#uniquepath). --- ## How long a presigned URL lives -A presigned URL cannot outlive the credential that signed it. R2 checks the credential at the start of the request, so a URL with `X-Amz-Expires=3600` on a credential with 200 seconds left stops working in 200 seconds. - -The SDK works with that instead of around it. `R2.presign` signs for `Math.min(expiresIn, credential remaining)`, so the `X-Amz-Expires` in the URL is never a promise the credential cannot keep. - -Being born stale is the other half of the problem. `minRemainingSeconds` is passed down to the credential cache: it means "re-mint if less than this is left", so a URL is signed against a credential that can actually carry it. The direct upload path asks for 3600 seconds with `minRemainingSeconds` of 120, which is why a fresh part URL always has a usable window even when the cached credential was nearly done. - -The defaults and the cap: - -- `DEFAULT_READ_SECONDS` is 300. A read link with no `expiresIn` asks for 5 minutes, or the cap if that is lower. -- `DEFAULT_WRITE_SECONDS` is 3600. A write link with no `expiresIn` asks for an hour, and because writes must use the object credential, `presignWrite` re-mints rather than hand back a link that dies early. -- `capOf(credential)` is the seconds left on whichever credential will actually sign: the read-only `signing` credential when the backend supplied one, otherwise the object credential. -- `worthReminting` decides whether a read that asked for longer than the cap is worth a round trip. It is true only when there is no `signing` credential and the current one has lost more than `WORTH_REMINTING_S`, 30 seconds, of its original lifetime. Below that, a fresh mint would come back with the same expiry, so asking is wasted and the link is simply capped. +A presigned URL cannot outlive the credential that signed it, so the SDK never signs a link for longer than the credential can carry, and re-mints where a longer ask is worth a round trip. -Reads are signed with the `signing` credential when one is present, which is why a read link can outlive the object credential. Writes cannot use it, since it is read-only. +Read links default to 5 minutes, write links to an hour. Read links can outlive the credential your server writes with, since reads are signed with a separate, longer-lived one. -All of which is why `signedReadUrl()` returns `expiresAt` rather than making you compute it: +That is why `signedReadUrl()` returns `expiresAt` rather than making you compute it: ```ts const { url, expiresAt } = await bucket.signedReadUrl('private/report.pdf'); @@ -148,44 +74,39 @@ Cache the link until `expiresAt` and re-sign after. Do not assume five minutes. A direct browser upload is four phases against your own route. Your route is the only thing that ever sees the token or the credentials. ```text -browser your route blob.upstash.io R2 - | | | | - | phase 'begin' | | | - |------------------> | | - | | POST /v1/credentials| | - | |----------------------> | - | | temp credentials | | - | <----------------------| | - | | | | - | | CreateMultipartUpload (multipart only) | - | |---------------------------------------------> - | | uploadId | - | <---------------------------------------------| - | completion token + presigned URLs | | - <------------------| | | - | | | | - | PUT the bytes: presigned URL + pinned headers | - |----------------------------------------------------------------> - | 200 + etag | | - <----------------------------------------------------------------| - | | | | - | phase 'parts': next URL batch, ListParts | - |------------------> | | - | | | | - | phase 'end': part etags | | - |------------------> | | - | | CompleteMultipartUpload, then HEAD | - | |---------------------------------------------> - | blob record + onUploadComplete data | | - <------------------| | | +browser your route Upstash storage + | | | | + | phase 'begin' | | | + |------------------> | | + | | temp credentials | | + | <------------------->| | + | | | | + | | start the multipart upload (large files) | + | <------------------------------------------> + | completion token + presigned URLs | | + <------------------| | | + | | | | + | PUT the bytes: presigned URL + pinned headers | + |-------------------------------------------------------------> + | 200 + etag | | + <-------------------------------------------------------------| + | | | | + | phase 'parts': next URLs, what already landed | + |------------------> | | + | | | | + | phase 'end' | | | + |------------------> complete the upload, read the object back + | <------------------------------------------> + | blob record + onUploadComplete data | | + <------------------| | | ``` -| Phase | What your route does | What it signs | What it returns | -| --- | --- | --- | --- | -| `begin` | Enforces [Constraints](/blob/browser/constraints), runs `onBeforeUpload`, and for a large file creates the multipart upload | The first PUT URL, or the first batch of part URLs | `WireBeginResponse`: `completionToken`, `path`, and an upload plan carrying `partSize`, `multipart` and `parts` | -| `parts` | Verifies the completion token, asks R2 `ListParts` for what already landed | The next batch of part URLs, 16 at a time | `WirePartsResponse`: `partSize`, `size`, `multipart`, `parts`, `landed` | -| `end` | Verifies the token, completes the multipart or checks the marker, reads the object back, runs `onUploadComplete` | Nothing new | `WireEndResponse`: the blob record plus whatever `onUploadComplete` returned | -| `cancel` | Verifies the token, aborts the multipart or deletes a matching single-PUT object | Nothing | `{ ok: true }` | +| Phase | What your route does | What it signs | +| --- | --- | --- | +| `begin` | Enforces [Constraints](/blob/browser/constraints), runs `onBeforeUpload`, and for a large file creates the multipart upload | The first PUT URL, or the first batch of part URLs | +| `parts` | Verifies the completion token and asks storage what already landed | The next batch of part URLs | +| `end` | Verifies the token, completes the upload, reads the object back, runs `onUploadComplete` | Nothing new | +| `cancel` | Verifies the token, aborts the multipart or deletes a matching single-PUT object | Nothing | The browser never sees the bucket token and never sees an S3 credential. It sees per-object presigned URLs, the headers those URLs pin, and a completion token. Nothing it holds can list the bucket, read another object, or write to a path your `onBeforeUpload` did not choose. @@ -195,45 +116,17 @@ The browser never sees the bucket token and never sees an S3 credential. It sees ## The completion token -The completion token is what carries an upload's identity between phases without keeping server state. It is a base64url JSON payload and an HMAC-SHA256 over that payload, joined by a dot: - -```text -. -``` +The completion token carries an upload's identity between phases, so your route keeps no server state. It is signed with a key derived from the bucket token, which never leaves your process. -The key is `upstash-blob-completion:`, so it is derived from the token you already hold and never from anything the request supplies. Comparison is timing safe. +It pins everything the browser must not be able to change: the path `onBeforeUpload` chose, the declared size and type, the headers signed into the upload, the bucket, and the route. It also carries the upload id your `onUploadComplete` sees as `uploadId`, and whatever `onBeforeUpload` returned as `state`. It expires after seven days. -The token is signed, not encrypted. Anyone can open devtools, base64-decode the first half, and read the whole payload including `ctx`. Whatever `onBeforeUpload` returns as `state` must be a row id or something equally boring. Never a secret, never a signed URL, never an internal flag you would not print on the page. +The token is signed, not encrypted. Anyone can open devtools and read the payload, including `state`. Whatever `onBeforeUpload` returns there must be a row id or something equally boring. Never a secret, never a signed URL, never an internal flag you would not print on the page. -The payload: +A token that fails any of those checks is `forbidden`, not a 500. A token minted at one route is not spendable at another, so a 2 MB avatar route's token cannot be spent at a 2 GB video route. Two handlers on one bucket that mount the same route names need an `endpoint` to tell them apart. -| Field | What it locks down | -| --- | --- | -| `v` | Payload version. Anything but `1` is refused even with a valid MAC. | -| `b` | Bucket id, checked against this bucket. | -| `r` | Route id, checked against this route. | -| `id` | The upload id. It is the idempotency key `onUploadComplete` receives as `uploadId`, and on a single PUT it is also the marker value. | -| `path` | The object key `onBeforeUpload` chose. The browser cannot move an upload to another path by asking. | -| `n` | The file name the browser gave, which is the one thing the stored object does not keep. | -| `type` | The declared content type. | -| `size` | The declared byte length. `end` compares it against the stored object and refuses a mismatch. | -| `headers` | The headers pinned into the signature, so a re-presign at `parts` reproduces the same ones. | -| `ctx` | Whatever `onBeforeUpload` returned as `state`. | -| `exp` | Unix ms. Seven days out. | -| `uploadId` | R2's own multipart id, so `end` can complete it and `cancel` can abort it. Absent on a single PUT, where there is nothing to complete. | -| `partSize` | The part size for a multipart, or the whole file size for a single PUT, so one part covers it. | - -Verification is three checks past the MAC: the bucket id must match, the route id must match, and `exp` must be in the future. A failure of any of them is `forbidden`, not a 500. A token minted at one route is not spendable at another. - -### Route ids - -The route id comes from `deriveRouteId`, an FNV-1a hash over the route name, its resolved constraints (`contentTypes` and `maxBytes`), and whether the route takes `input`. When a handler declares an `endpoint`, the name is prefixed with it, which is what separates two handlers that mount the same route names on one bucket. All routes on a bucket sign with the same key, so without this a completion token from a 2 MB avatar route would be spendable at a 2 GB video route. - - -FNV-1a is not the security boundary here. It is a short, stable label for "which route is this". The MAC is what makes the payload unforgeable, and it covers the route id like every other field. Changing a route's constraints changes its id, which invalidates completion tokens issued under the old shape. That is intentional: the grant no longer describes what the route enforces. - +Changing a route's constraints invalidates completion tokens issued under the old shape, since the grant no longer describes what the route enforces. --- @@ -249,7 +142,7 @@ For a file under the multipart threshold, the browser writes the object itself w Signed, not merely sent. An unsigned header would be the browser's to choose, and then metadata your app reads back in `onUploadComplete` would be the client's to write. Because they are signed, the browser must echo them exactly and cannot substitute an `owner` that is not theirs. -For a multipart upload the same headers are pinned earlier and elsewhere: they are sent with `CreateMultipartUpload`, signed by your server with an `Authorization` header, and the object inherits them at completion. Each part URL then signs only `content-length`. Part URLs carry no headers at all in the wire response, which is why the browser sets none of ours on a part PUT. +For a multipart upload the same headers are set by your server when it creates the upload, and the object inherits them at completion. Part URLs pin only the part's length. Because those headers ride on a cross-origin request, the bucket's CORS policy has to allow them. The exact shape is in [CORS](/blob/overall/quickstart#cors). @@ -257,33 +150,27 @@ Because those headers ride on a cross-origin request, the bucket's CORS policy h ## The `upstash-upload` marker -On a single PUT, `begin` mints a UUID, writes it as `x-amz-meta-upstash-upload`, and signs it into the URL. The browser cannot set it, cannot change it, and `metadata.upstash-upload` from your own `onBeforeUpload` is refused as reserved. - -It answers exactly one question: did the bytes at this path come from THIS upload? A multipart upload answers that by construction, because the object does not exist until `end` completes it, so an object that exists is one this token created. A single PUT has no such guarantee. The presigned PUT stores the object the moment the last byte lands, so by the time `end` runs, the object at that path could be a stale token's, a concurrent upload's, or something that was there all along. +On a single PUT, the SDK writes a random id as `x-amz-meta-upstash-upload` and signs it into the URL. The browser cannot set it or change it, and `metadata["upstash-upload"]` from your own `onBeforeUpload` is refused as reserved. -So `end` requires a marker match on the single-PUT path. No match is `not_found`, `the upload never landed`. `cancel` uses the same check, and it is the whole check there, because the request body says nothing about which object to [delete](/blob/bucket/deleting). That is what stops a cancel from deleting someone else's file at the same path. +It answers exactly one question: did the bytes at this path come from THIS upload? A multipart upload answers that by construction, since the object does not exist until it is completed. A single PUT stores the object the moment the last byte lands, so the object at that path could be a stale upload's, a concurrent upload's, or something that was there all along. -The marker is deleted from the record handed to `onUploadComplete` and `onError`, but not from the stored object. Nothing on the completion path rewrites metadata. +So completing a single-PUT upload requires a marker match. No match is `not_found`, "the upload never landed". A cancel uses the same check, which is what stops it from deleting someone else's file at the same path. -A marker match proves "same upload", and never "no callback accepted it". What that costs, and the pending row that closes it, is on [Abandoned uploads](/blob/browser/abandoned-uploads). +The marker is stripped from the record handed to `onUploadComplete` and `onError`, but stays on the stored object. So a match proves "same upload" and never "no callback accepted it". What that costs, and the pending row that closes it, is on [Abandoned uploads](/blob/browser/abandoned-uploads). --- ## Retries and 403 -A 403 from storage is ambiguous by design. An expired presigned URL and a tampered request produce the same status, and the browser cannot tell which it is looking at. So the client's `classify` treats 401 and 403 as `represign` rather than `fail`, throwing the batch of URLs away and asking the route for fresh ones. The rest of the classification, and the retry budgets, are in [Large files](/blob/browser/large-files#retries). - -Re-presigning forever would hide a real signature problem, so there is a clock on it. `PRESIGN_STALE_MS` is 60 seconds. A 403 on a URL minted more than a minute ago is read as the clock however often it happens, because a 5 MiB part on a slow link genuinely outruns a presign more than once. A 403 on a freshly minted URL, for a part that has already been re-presigned once, is a real `signature_mismatch` and ends the upload. So does exhausting the 8-attempt budget. - -Two more bounds sit around that loop. `MAX_URL_BATCHES` is 4: a part that waits through four batches without the route ever signing it fails rather than spinning with no backoff. And a re-presign always throws away the whole batch, not just the one URL, because every URL in a batch was signed against the same credential and expires with it. +A 403 from storage is ambiguous by design: an expired presigned URL and a tampered request produce the same status. So the browser treats a 403 as an expired signature first, throws the batch of URLs away, and asks your route for fresh ones. A 403 on a URL that was just signed is a real `signature_mismatch` and ends the upload. The rest of the classification, and the retry budgets, are in [Large files](/blob/browser/large-files#retries). -Your server has the same ambiguity and resolves it by reading the body. `R2.fetch` re-mints once, and only once per request, when a 403 body matches `ExpiredToken`, `InvalidAccessKeyId` or `TokenRefreshRequired`, or when the cached credential has visibly expired (a `HEAD` carries no body to name a reason). Any other 403 is returned as-is and surfaces as `signature_mismatch`, usually meaning the body length or type differs from what was signed. +Your server has the same ambiguity and resolves it by reading the response body. It re-mints once per request when the body says the credential expired. Any other 403 surfaces as `signature_mismatch`, usually meaning the body length or type differs from what was signed. --- ## What the browser stores -One thing: the completion token, in `localStorage`, under a key built from the route, the file name, the file size and its `lastModified`. Nothing else is worth the exposure, and in particular nothing about what landed is stored, since the server can ask R2 for that. [Large files](/blob/browser/large-files#resuming-after-a-reload) covers the key, the resume gesture and what happens when `localStorage` is unavailable. +One thing: the completion token, in `localStorage`, keyed by the route and the file. Nothing about what landed is stored, since your server can ask storage for that. [Large files](/blob/browser/large-files#resuming-after-a-reload) covers the resume gesture and what happens when `localStorage` is unavailable. --- @@ -304,9 +191,9 @@ const upload = await bucket.signedUploadUrl('u/7/report.pdf', { await fetch(upload.url, { method: 'PUT', headers: upload.headers, body: bytes }); ``` -`signedReadUrl` puts `downloadAs` into `response-content-disposition` as an RFC 6266 header, carrying the real name in `filename*` as an RFC 8187 ext-value, with an ASCII fallback cut back to characters that cannot end the quoted string. The name reaches storage as a query parameter and comes back as a header value, so a quote or a CRLF in it must not be able to add a header. A `contentType` override is validated as a media type for the same reason. +`signedReadUrl` turns `downloadAs` into a `Content-Disposition` header on the response, encoded so a Unicode name arrives intact and a name containing a quote or a newline cannot inject a second header. A `contentType` override is validated as a media type for the same reason. -`signedUploadUrl` pins every header it returns into the signature: `content-type`, `cache-control`, your `x-amz-meta-*`, `content-length` when you pass `size`, and `if-none-match: *` when you pass `overwrite: false`. Send the `headers` object verbatim. Anything changed, dropped or added is a 403, not a header the client got to choose. +`signedUploadUrl` pins every header it returns into the signature: the content type, cache control, your metadata, the length when you pass `size`, and the conditional when you pass `overwrite: false`. Send the `headers` object verbatim. Anything changed, dropped or added is a 403. For an existing S3 client, `bucket.s3()` hands back the endpoint and the credentials as async providers rather than values. See [Writing](/blob/bucket/writing#the-s3-escape-hatch). @@ -317,7 +204,7 @@ Full options are on [Reading](/blob/bucket/reading) and [Writing](/blob/bucket/w ## What never reaches the browser - `UPSTASH_BLOB_TOKEN`, in any form. -- The bucket password. It only ever keys an HMAC inside your process. -- `accessKeyId`, `secretAccessKey` or `sessionToken`. They are only ever folded into a signature. +- The key that signs completion tokens. It never leaves your process. +- The temporary S3 credentials. They are only ever folded into a signature. - Any ability to list, read, overwrite or delete outside the one object a single presigned URL names. - Anything `context` or `onBeforeUpload` computed, except what you explicitly return as `metadata` (visible on the object) or `state` (visible in the completion token). From 520a7dd12db3a7920e58df47018159c9a64ef14e Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Wed, 2 Sep 2026 00:43:40 +0200 Subject: [PATCH 05/41] docs(blob): split client usage into its own page --- blob/browser/client.mdx | 174 ++++++++++++++++++++++++++++++++ blob/browser/constraints.mdx | 2 +- blob/browser/large-files.mdx | 4 + blob/browser/upload-handler.mdx | 172 +------------------------------ blob/overall/quickstart.mdx | 4 + docs.json | 1 + 6 files changed, 187 insertions(+), 170 deletions(-) create mode 100644 blob/browser/client.mdx diff --git a/blob/browser/client.mdx b/blob/browser/client.mdx new file mode 100644 index 00000000..46ddf92c --- /dev/null +++ b/blob/browser/client.mdx @@ -0,0 +1,174 @@ +--- +title: "The Client" +--- + +`@upstash/blob/react` drives an [upload handler](/blob/browser/upload-handler) from the browser: `uploadHooks` binds the hooks to your handler's type, `useUpload` runs the upload and renders its progress. There is a plain function for apps without React, and `useServerUpload` for the routes where the bytes do pass through your app. + +--- + +## uploadHooks + +`uploadHooks(defaults)` binds `useUpload` to one handler. The bound hook knows the route names, so a typo does not compile, and it knows each route's `input` and completion data. + +```ts lib/upload-hooks.ts +"use client" +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "./uploads" + +export const { useUpload } = uploadHooks({ + headers: async () => ({ authorization: `Bearer ${await getToken()}` }), + concurrency: 3, + endpoint: "/api/upload", + onError: ({ file, error }) => toast.error(`${file.name}: ${error.message}`), +}) +``` + +`headers`, `concurrency`, `endpoint` and `onError` are the defaults `uploadHooks` takes. A call-site option wins over the default, except `onError`, where the configured handler runs first and the call-site one after it. + + + A call-site `onError` must not throw. A throw there stops the rest of the upload queue from + starting. A throw from the configured `onError` is caught and logged. + + +Called with no type parameter, `uploadHooks()` returns the unbound `useUpload`, which takes a URL. + +--- + +## useUpload + +```tsx app/page.tsx +const { start, uploads, upload, clear, accept, constraints } = useUpload("attachment", { + concurrency: 2, + onDone: (record) => console.log(record.blob.data), + onError: (record) => console.log(record.error.code), +}) +``` + +| | | +| --- | --- | +| `start` | Begins one upload or several. Returns the record(s). | +| `uploads` | Every record, in the order they were started. | +| `upload` | The newest record, or `null`. | +| `clear(id?)` | Removes one record, or all of them. | +| `accept` | The route's `contentTypes`, joined, for an ``. | +| `constraints` | What the route's [`GET`](/blob/browser/upload-handler#the-get-endpoint) served, its own numbers. Undefined until it answers. | + +`start({ file })` returns one record, or `null` when the file is nullish, so an empty file picker is not an error. `start({ files })` takes a `File[]` or a `FileList` and returns an array. + +```tsx app/page.tsx + start({ files: e.target.files })} +/> +``` + +### The record + +| Field | Type | | +| --- | --- | --- | +| `id` | `string` | Stable for the life of the record. The key to render lists with. | +| `file` | `File` | The file this record uploads. | +| `status` | `'queued' \| 'uploading' \| 'finishing' \| 'paused' \| 'done' \| 'canceled' \| 'error'` | | +| `loaded` | `number` | Bytes that have landed. | +| `total` | `number` | The file's size. | +| `percent` | `number` | 0 to 99 while running, 100 only once `done`. | +| `pending` | `boolean` | Not settled: queued, uploading, finishing or paused. | +| `stalled` | `boolean` | Every request in flight is waiting on a backoff. | +| `canPause` | `boolean` | Whether `pause()` would do anything. | +| `blob` | `CompletedBlob & { data }` | On `done` only. | +| `error` | `BlobError` | On `error` only. | +| `pause()` `resume()` `cancel()` `retry()` | `() => boolean` | Each answers whether it did anything. | + +`pending` is the field to drive UI off rather than hand-rolling it from `status`. `percent` sits at 99 through `finishing`, because 100 has to mean stored rather than sent. [Large files](/blob/browser/large-files#progress-and-status) has the rest of the progress fields. + +`blob.data` is typed from that route's [`onUploadComplete`](/blob/browser/upload-handler#onuploadcomplete). Fields a status does not carry are `undefined` rather than absent, so `upload?.blob?.url` and `upload?.error?.message` read straight off the record with no narrowing. + +`canPause` is false for a single PUT, which is every file under the route's `multipart` threshold. `retry()` works only from `error`, and resumes from the parts that already landed. [Large files](/blob/browser/large-files) has the whole of pause, resume and multipart. + +Three files are in flight by default and the rest queue. `clear(id?)` removes records from the list; a cleared upload that is still running finishes anyway, it is just no longer rendered. Unmounting the component does not cancel anything either. + +### headers + +`headers` is a function, not an object, and it is re-read for every request the SDK makes to your route. A token that rotated mid-upload still ends the upload. + +```tsx app/page.tsx +const { start } = useUpload("attachment", { + headers: async () => { + const token = await auth.getToken() // throwing here refuses the upload + return { authorization: `Bearer ${token}` } + }, +}) +``` + +A throw from it ends the upload carrying that error, with no retry and no rewording as a network fault. That is how an app refuses its own upload: a token it could not refresh, a precondition that failed. + +--- + +## Without React + +The same upload, with no hooks: + +```ts app/uploader.ts +import { upload } from "@upstash/blob/browser" + +const task = upload(file, { + route: "/api/upload?route=attachment", + headers: async () => ({ authorization: `Bearer ${await getToken()}` }), + input: { threadId }, +}) + +const stop = task.subscribe(() => { + const { status, percent, stalled } = task.snapshot() + render(status, percent, stalled) +}) + +const blob = await task.done // CompletedBlob & { data } +stop() +``` + +`upload()` starts immediately and returns an `UploadTask`: `snapshot()` for the current state, `subscribe()` for changes, `done` as a promise, and `pause()`, `resume()`, `cancel()` and `retry()`. The snapshot carries the same fields the React record does. + +--- + +## useServerUpload + +For bytes that must pass through your app, do not use an upload handler. Write an ordinary route that calls `bucket.put`: + +```ts app/api/avatar/route.ts +import { Bucket } from "@upstash/blob" + +const bucket = Bucket.fromEnv() + +export async function POST(request: Request) { + const file = (await request.formData()).get("file") + if (!(file instanceof File)) return Response.json({ error: "file field required" }, { status: 400 }) + + const blob = await bucket.put(`avatars/${userId}`, file, { contentTypes: ["image/png"], maxBytes: "2mb" }) + return Response.json({ url: blob.versionedUrl }) +} +``` + +`useServerUpload` drives it as one POST, with upload progress, cancellation and `BlobError` decoding, and hands the route's JSON back exactly as it arrived: + +```tsx app/avatar.tsx +"use client" +import { useServerUpload } from "@upstash/blob/react" + +const { start, upload } = useServerUpload<{ url: string }>("/api/avatar", { field: "file" }) + +start({ file }) +upload?.percent +upload?.status === "done" && upload.response.url // typed from the generic +``` + +Its options are `headers`, `concurrency` and `field`, the multipart field name `start({ file })` sends the file under, `'file'` by default and it has to match what your route reads. `start({ body })` sends a `File`, `Blob` or `FormData` as the raw body instead. The record has `cancel()` only, and statuses `queued`, `uploading`, `finishing`, `done`, `canceled` and `error`: there is no `begin` or `end` to pause between. + +A proxied upload is capped by your platform's request body limit rather than by `maxBytes`, and the refusal happens before your route runs. The SDK surfaces it as `too_large` with the platform numbers as the hint; they are listed in [Errors](/blob/bucket/errors#platform-body-limits). Anything larger belongs on the direct path, with an [upload handler](/blob/browser/upload-handler). + +--- + +## CORS + +The signed PUT is a cross-origin request from your page to storage, so the bucket's CORS policy has to allow it: the required shape is in [CORS](/blob/overall/quickstart#cors). A PUT that fails with no status and no bytes sent is almost always this. diff --git a/blob/browser/constraints.mdx b/blob/browser/constraints.mdx index 444d0e5b..5fa4ca68 100644 --- a/blob/browser/constraints.mdx +++ b/blob/browser/constraints.mdx @@ -4,7 +4,7 @@ title: "Constraints" Constraints are the two limits an upload route enforces before it signs anything: how big a file may be, and what type it may claim to be. They are the only place a direct upload can be refused for free, because past `begin` the bytes go straight to storage and never touch your server. -See [Upload handler](/blob/browser/upload-handler) for the handler shape, its callbacks, and the hooks. +See [Upload handler](/blob/browser/upload-handler) for the handler shape and its callbacks, and [The client](/blob/browser/client) for the hooks. --- diff --git a/blob/browser/large-files.mdx b/blob/browser/large-files.mdx index 2ea1a4dd..4ddfe16b 100644 --- a/blob/browser/large-files.mdx +++ b/blob/browser/large-files.mdx @@ -247,6 +247,10 @@ Without `size` and without `maxBytes`, an unknown-length stream throws `length_r Routes, context, and the callbacks that run around a transfer.
+ + `uploadHooks`, `useUpload` and the record that carries progress. + + Size limits and content types, refused before a byte is signed. diff --git a/blob/browser/upload-handler.mdx b/blob/browser/upload-handler.mdx index d59cf5f5..a1f0c83d 100644 --- a/blob/browser/upload-handler.mdx +++ b/blob/browser/upload-handler.mdx @@ -70,12 +70,12 @@ export function Uploader() { } ``` -The client assumes the handler is mounted at `/api/upload`. That is the only default; `endpoint` on `uploadHooks` or on `useUpload` moves it. +The client assumes the handler is mounted at `/api/upload`. That is the only default; `endpoint` on `uploadHooks` or on `useUpload` moves it. [The client](/blob/browser/client) has the hooks in full. New to Upstash Blob? Start at the [Quickstart](/blob/overall/quickstart). If the bytes have to pass through your app instead, write an ordinary route that calls `bucket.put` ([Writing](/blob/bucket/writing)) - and drive it with [`useServerUpload`](#useserverupload). + and drive it with [`useServerUpload`](/blob/browser/client#useserverupload). --- @@ -447,173 +447,7 @@ Everything else on the route works as it does on a plain object: `bucket`, `cons --- -## The client - -`uploadHooks(defaults)` binds `useUpload` to one handler. The bound hook knows the route names, so a typo does not compile, and it knows each route's `input` and completion data. - -```ts lib/upload-hooks.ts -"use client" -import { uploadHooks } from "@upstash/blob/react" -import type { uploads } from "./uploads" - -export const { useUpload } = uploadHooks({ - headers: async () => ({ authorization: `Bearer ${await getToken()}` }), - concurrency: 3, - endpoint: "/api/upload", - onError: ({ file, error }) => toast.error(`${file.name}: ${error.message}`), -}) -``` - -`headers`, `concurrency`, `endpoint` and `onError` are the defaults `uploadHooks` takes. A call-site option wins over the default, except `onError`, where the configured handler runs first and the call-site one after it. - - - A call-site `onError` must not throw. A throw there stops the rest of the upload queue from - starting. A throw from the configured `onError` is caught and logged. - - -Called with no type parameter, `uploadHooks()` returns the unbound `useUpload`, which takes a URL. - -### useUpload - -```tsx app/page.tsx -const { start, uploads, upload, clear, accept, constraints } = useUpload("attachment", { - concurrency: 2, - onDone: (record) => console.log(record.blob.data), - onError: (record) => console.log(record.error.code), -}) -``` - -| | | -| --- | --- | -| `start` | Begins one upload or several. Returns the record(s). | -| `uploads` | Every record, in the order they were started. | -| `upload` | The newest record, or `null`. | -| `clear(id?)` | Removes one record, or all of them. | -| `accept` | The route's `contentTypes`, joined, for an ``. | -| `constraints` | What the route's `GET` served, its own numbers. Undefined until it answers. | - -`start({ file })` returns one record, or `null` when the file is nullish, so an empty file picker is not an error. `start({ files })` takes a `File[]` or a `FileList` and returns an array. - -```tsx app/page.tsx - start({ files: e.target.files })} -/> -``` - -### The record - -| Field | Type | | -| --- | --- | --- | -| `id` | `string` | Stable for the life of the record. The key to render lists with. | -| `file` | `File` | The file this record uploads. | -| `status` | `'queued' \| 'uploading' \| 'finishing' \| 'paused' \| 'done' \| 'canceled' \| 'error'` | | -| `loaded` | `number` | Bytes that have landed. | -| `total` | `number` | The file's size. | -| `percent` | `number` | 0 to 99 while running, 100 only once `done`. | -| `pending` | `boolean` | Not settled: queued, uploading, finishing or paused. | -| `stalled` | `boolean` | Every request in flight is waiting on a backoff. | -| `canPause` | `boolean` | Whether `pause()` would do anything. | -| `blob` | `CompletedBlob & { data }` | On `done` only. | -| `error` | `BlobError` | On `error` only. | -| `pause()` `resume()` `cancel()` `retry()` | `() => boolean` | Each answers whether it did anything. | - -`pending` is the field to drive UI off rather than hand-rolling it from `status`. `percent` sits at 99 through `finishing`, because 100 has to mean stored rather than sent. [Large files](/blob/browser/large-files#progress-and-status) has the rest of the progress fields. - -`blob.data` is typed from that route's `onUploadComplete`. Fields a status does not carry are `undefined` rather than absent, so `upload?.blob?.url` and `upload?.error?.message` read straight off the record with no narrowing. - -`canPause` is false for a single PUT, which is every file under the route's `multipart` threshold. `retry()` works only from `error`, and resumes from the parts that already landed. [Large files](/blob/browser/large-files) has the whole of pause, resume and multipart. - -Three files are in flight by default and the rest queue. `clear(id?)` removes records from the list; a cleared upload that is still running finishes anyway, it is just no longer rendered. Unmounting the component does not cancel anything either. - -### headers - -`headers` is a function, not an object, and it is re-read for every request the SDK makes to your route. A token that rotated mid-upload still ends the upload. - -```tsx app/page.tsx -const { start } = useUpload("attachment", { - headers: async () => { - const token = await auth.getToken() // throwing here refuses the upload - return { authorization: `Bearer ${token}` } - }, -}) -``` - -A throw from it ends the upload carrying that error, with no retry and no rewording as a network fault. That is how an app refuses its own upload: a token it could not refresh, a precondition that failed. - ---- - ## The GET endpoint -`GET` on the route serves its constraints as JSON. That is what fills `accept` and `constraints` on the hook, and it lets the hook refuse an oversized file locally, as an error record, before any request leaves the browser. The check in the browser is a courtesy: the server is authoritative and enforces the same limits at `begin`. The document, its caching and what the hook does with it are in [Constraints](/blob/browser/constraints#in-the-browser). - ---- - -## Without React - -The same upload, with no hooks: - -```ts app/uploader.ts -import { upload } from "@upstash/blob/browser" - -const task = upload(file, { - route: "/api/upload?route=attachment", - headers: async () => ({ authorization: `Bearer ${await getToken()}` }), - input: { threadId }, -}) - -const stop = task.subscribe(() => { - const { status, percent, stalled } = task.snapshot() - render(status, percent, stalled) -}) - -const blob = await task.done // CompletedBlob & { data } -stop() -``` - -`upload()` starts immediately and returns an `UploadTask`: `snapshot()` for the current state, `subscribe()` for changes, `done` as a promise, and `pause()`, `resume()`, `cancel()` and `retry()`. The snapshot carries the same fields the React record does. - ---- - -## useServerUpload - -For bytes that must pass through your app, do not use an upload handler. Write an ordinary route that calls `bucket.put`: - -```ts app/api/avatar/route.ts -import { Bucket } from "@upstash/blob" - -const bucket = Bucket.fromEnv() - -export async function POST(request: Request) { - const file = (await request.formData()).get("file") - if (!(file instanceof File)) return Response.json({ error: "file field required" }, { status: 400 }) - - const blob = await bucket.put(`avatars/${userId}`, file, { contentTypes: ["image/png"], maxBytes: "2mb" }) - return Response.json({ url: blob.versionedUrl }) -} -``` - -`useServerUpload` drives it as one POST, with upload progress, cancellation and `BlobError` decoding, and hands the route's JSON back exactly as it arrived: - -```tsx app/avatar.tsx -"use client" -import { useServerUpload } from "@upstash/blob/react" - -const { start, upload } = useServerUpload<{ url: string }>("/api/avatar", { field: "file" }) - -start({ file }) -upload?.percent -upload?.status === "done" && upload.response.url // typed from the generic -``` - -Its options are `headers`, `concurrency` and `field`, the multipart field name `start({ file })` sends the file under, `'file'` by default and it has to match what your route reads. `start({ body })` sends a `File`, `Blob` or `FormData` as the raw body instead. The record has `cancel()` only, and statuses `queued`, `uploading`, `finishing`, `done`, `canceled` and `error`: there is no `begin` or `end` to pause between. - -A proxied upload is capped by your platform's request body limit rather than by `maxBytes`, and the refusal happens before your route runs. The SDK surfaces it as `too_large` with the platform numbers as the hint; they are listed in [Errors](/blob/bucket/errors#platform-body-limits). Anything larger belongs on the direct path above. - ---- - -## CORS +`GET` on the route serves its constraints as JSON. That is what fills [`accept` and `constraints`](/blob/browser/client#useupload) on the hook, and it lets the hook refuse an oversized file locally, as an error record, before any request leaves the browser. The check in the browser is a courtesy: the server is authoritative and enforces the same limits at `begin`. The document, its caching and what the hook does with it are in [Constraints](/blob/browser/constraints#in-the-browser). -The signed PUT is a cross-origin request from your page to storage, so the bucket's CORS policy has to allow it: the required shape is in [CORS](/blob/overall/quickstart#cors). A PUT that fails with no status and no bytes sent is almost always this. diff --git a/blob/overall/quickstart.mdx b/blob/overall/quickstart.mdx index ffb22bf9..43a74597 100644 --- a/blob/overall/quickstart.mdx +++ b/blob/overall/quickstart.mdx @@ -208,6 +208,10 @@ A browser blocks a request that fails CORS before a single byte goes out, and sc Routes, context, input schemas and the completion callback in full. + + `uploadHooks`, `useUpload`, the record it renders, and the non-React client. + + Size limits, content types and what the byte check does and does not prove. diff --git a/docs.json b/docs.json index 545469ca..834ef567 100644 --- a/docs.json +++ b/docs.json @@ -2106,6 +2106,7 @@ "group": "Browser Usage", "pages": [ "blob/browser/upload-handler", + "blob/browser/client", "blob/browser/constraints", "blob/browser/large-files", "blob/browser/abandoned-uploads" From d44b9f6a4f0975bbb374b444819185c496b4ebe7 Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Wed, 2 Sep 2026 00:45:29 +0200 Subject: [PATCH 06/41] docs(blob): replace raw SQL examples with db client calls --- blob/browser/abandoned-uploads.mdx | 17 +++++-------- blob/browser/upload-handler.mdx | 18 +++++--------- blob/formulas/overview.mdx | 38 +++++++++++++++++------------- 3 files changed, 33 insertions(+), 40 deletions(-) diff --git a/blob/browser/abandoned-uploads.mdx b/blob/browser/abandoned-uploads.mdx index 78e80bdf..2aa2fed5 100644 --- a/blob/browser/abandoned-uploads.mdx +++ b/blob/browser/abandoned-uploads.mdx @@ -57,7 +57,7 @@ The order in step 2 is the whole pattern. The sweep's only premise is **row stil ```ts lib/uploads.ts import "server-only" import { BlobError, uniquePath, uploadHandler, uploadRoute } from "@upstash/blob" -import { sql } from "@/lib/db" +import { db } from "@/lib/db" const attachment = uploadRoute()({ constraints: { maxBytes: "20mb", contentTypes: ["image/*", "application/pdf"] }, @@ -68,8 +68,7 @@ const attachment = uploadRoute()({ const rowId = crypto.randomUUID() const path = uniquePath`uploads/${user.id}/${file.name}` - await sql`insert into uploads (id, owner, path, status, created_at) - values (${rowId}, ${user.id}, ${path}, 'pending', now())` + await db.uploads.insert({ id: rowId, owner: user.id, path, status: "pending" }) // metadata is written onto the object and signed into the PUT, so the cron can read it back. // state only crosses in the completion token, so the callback can read it without a lookup. @@ -81,9 +80,7 @@ const attachment = uploadRoute()({ await notifyOwner(state.rowId) // Last. Everything above has to be done before the row stops looking abandoned. - await sql`update uploads - set status = 'ready', size = ${size}, url = ${url ?? null} - where id = ${state.rowId}` + await db.uploads.update(state.rowId, { status: "ready", size, url }) return { rowId: state.rowId } }, @@ -98,14 +95,12 @@ Metadata keys come back from storage lowercased, so `{ rowid: ... }` is written ```ts app/api/cron/sweep-uploads/route.ts import { BlobError, Bucket } from "@upstash/blob" -import { sql } from "@/lib/db" +import { db } from "@/lib/db" const bucket = Bucket.fromEnv() export const GET = async () => { - const rows = await sql`select id, path from uploads - where status = 'pending' and created_at < now() - interval '2 hours' - limit 500` + const rows = await db.uploads.findPending({ olderThan: "2h", limit: 500 }) let deleted = 0 for (const row of rows) { @@ -120,7 +115,7 @@ export const GET = async () => { // Nothing was ever stored: the browser died before the PUT finished. The row is the leftover. if (!(BlobError.is(e) && e.code === "not_found")) throw e } - await sql`delete from uploads where id = ${row.id}` + await db.uploads.delete(row.id) } return Response.json({ swept: rows.length, deleted }) diff --git a/blob/browser/upload-handler.mdx b/blob/browser/upload-handler.mdx index a1f0c83d..b8fd568a 100644 --- a/blob/browser/upload-handler.mdx +++ b/blob/browser/upload-handler.mdx @@ -16,7 +16,7 @@ Four files. The handler, the route it is mounted at, the bound hooks, and the co import "server-only" import { BlobError, uniquePath, uploadHandler } from "@upstash/blob" import { getUser } from "./auth" -import { sql } from "./db" +import { db } from "./db" export const uploads = uploadHandler({ constraints: { maxBytes: "20mb", contentTypes: ["image/*", "application/pdf"] }, @@ -28,9 +28,7 @@ export const uploads = uploadHandler({ }, onUploadComplete: async ({ uploadId, metadata, path, url }) => { - await sql`insert into files (upload_id, owner, path, url) - values (${uploadId}, ${metadata.owner}, ${path}, ${url}) - on conflict (upload_id) do nothing` + await db.files.upsert({ uploadId, owner: metadata.owner, path, url }) return { path } }, }) @@ -227,9 +225,7 @@ Runs on the last request of an upload, once the object exists. It gets the compl ```ts lib/uploads.ts onUploadComplete: async ({ uploadId, path, url, size, contentType, metadata, state, ctx }) => { - await sql`insert into files (upload_id, owner, path, url, size, content_type) - values (${uploadId}, ${ctx.userId}, ${path}, ${url}, ${size}, ${contentType}) - on conflict (upload_id) do nothing` + await db.files.upsert({ uploadId, owner: ctx.userId, path, url, size, contentType }) return { path } } ``` @@ -261,8 +257,8 @@ if (upload?.status === "done") upload.blob.data.path // string, inferred from on **It is at-least-once.** The browser retries `end` on a network failure or a retryable status, so - write against `uploadId`, which is stable across those retries: `on conflict (upload_id) do - nothing`, or the equivalent upsert for your database. + write against `uploadId`, which is stable across those retries, and upsert on it rather than + inserting a new row. **Any throw out of it deletes the completed object.** That is the intent for a refusal, and a trap for a database error: a ten-second outage destroys bytes that uploaded fine, and the retried `end` @@ -273,9 +269,7 @@ if (upload?.status === "done") upload.blob.data.path // string, inferred from on ```ts lib/uploads.ts onUploadComplete: async ({ uploadId, path, url, metadata }) => { try { - await sql`insert into files (upload_id, owner, path, url) - values (${uploadId}, ${metadata.owner}, ${path}, ${url}) - on conflict (upload_id) do nothing` + await db.files.upsert({ uploadId, owner: metadata.owner, path, url }) } catch (e) { console.error("[uploads] could not record", path, e) // A throw is a refusal: this deletes the object and the retried end answers 404. Throw when diff --git a/blob/formulas/overview.mdx b/blob/formulas/overview.mdx index 53ef1b9c..fdb1c022 100644 --- a/blob/formulas/overview.mdx +++ b/blob/formulas/overview.mdx @@ -121,7 +121,7 @@ import "server-only" import * as z from "zod" import { BlobError, uniquePath, uploadHandler, uploadRoute } from "@upstash/blob" import { requireUser, type Session } from "./auth" -import { sql } from "./db" +import { db } from "./db" export const uploads = uploadHandler({ // Written above `routes`: it runs once per POST, before any body is read, and its value is `ctx`. @@ -133,14 +133,15 @@ export const uploads = uploadHandler({ input: z.object({ threadId: z.string().uuid() }), onBeforeUpload: async ({ ctx, input, file }) => { - const thread = await sql`select id from threads - where id = ${input.threadId} and member_id = ${ctx.id}` - if (thread.length === 0) throw new BlobError("forbidden") + const thread = await db.threads.find({ id: input.threadId, memberId: ctx.id }) + if (!thread) throw new BlobError("forbidden") const path = uniquePath`threads/${input.threadId}/${file.name}` - const [row] = await sql`insert into pending_uploads (thread_id, user_id, path) - values (${input.threadId}, ${ctx.id}, ${path}) - returning id` + const row = await db.pendingUploads.insert({ + threadId: input.threadId, + userId: ctx.id, + path, + }) return { path, @@ -152,13 +153,18 @@ export const uploads = uploadHandler({ onUploadComplete: async ({ state, uploadId, path, url, size, contentType, file }) => { // uploadId is stable across the browser's retries of the completion request, so // at-least-once delivery writes one row. - await sql`insert into attachments (upload_id, thread_id, path, url, size, content_type, name) - values (${uploadId}, ${state.threadId}, ${path}, ${url ?? null}, - ${size}, ${contentType}, ${file.name}) - on conflict (upload_id) do nothing` + await db.attachments.upsert({ + uploadId, + threadId: state.threadId, + path, + url, + size, + contentType, + name: file.name, + }) // Last, always. "Row still pending" is what the sweep below reads as "never accepted". - await sql`delete from pending_uploads where id = ${state.rowId}` + await db.pendingUploads.delete(state.rowId) return { attachmentId: uploadId } }, @@ -177,14 +183,12 @@ export const { GET, POST } = uploads ```ts app/api/cron/sweep-uploads/route.ts import { BlobError, Bucket } from "@upstash/blob" -import { sql } from "@/lib/db" +import { db } from "@/lib/db" const bucket = Bucket.fromEnv() export async function GET() { - const stale = await sql`select id, path from pending_uploads - where created_at < now() - interval '1 hour' - limit 500` + const stale = await db.pendingUploads.findOlderThan({ age: "1h", limit: 500 }) for (const row of stale) { try { @@ -195,7 +199,7 @@ export async function GET() { // info() throws rather than returning undefined. Already gone is the good case. if (!BlobError.is(e) || e.code !== "not_found") throw e } - await sql`delete from pending_uploads where id = ${row.id}` + await db.pendingUploads.delete(row.id) } // Parts a tab left behind over the multipart threshold, which list() cannot see. From 477bc8d64a01d56206710d43a95ffec5edab69d4 Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Wed, 2 Sep 2026 01:01:16 +0200 Subject: [PATCH 07/41] docs(blob): rework quickstart setup and server upload --- blob/overall/quickstart.mdx | 130 ++++++------------ blob/{formulas => recipes}/overview.mdx | 0 blob/{bucket => reference}/errors.mdx | 0 blob/{overall => reference}/signing.mdx | 0 .../abandoned-uploads.mdx | 0 blob/{browser => uploads}/constraints.mdx | 0 blob/{browser => uploads}/large-files.mdx | 0 .../client.mdx => uploads/upload-client.mdx} | 0 blob/{browser => uploads}/upload-handler.mdx | 0 9 files changed, 44 insertions(+), 86 deletions(-) rename blob/{formulas => recipes}/overview.mdx (100%) rename blob/{bucket => reference}/errors.mdx (100%) rename blob/{overall => reference}/signing.mdx (100%) rename blob/{browser => uploads}/abandoned-uploads.mdx (100%) rename blob/{browser => uploads}/constraints.mdx (100%) rename blob/{browser => uploads}/large-files.mdx (100%) rename blob/{browser/client.mdx => uploads/upload-client.mdx} (100%) rename blob/{browser => uploads}/upload-handler.mdx (100%) diff --git a/blob/overall/quickstart.mdx b/blob/overall/quickstart.mdx index 43a74597..2f7f795b 100644 --- a/blob/overall/quickstart.mdx +++ b/blob/overall/quickstart.mdx @@ -6,7 +6,9 @@ Upstash Blob is S3-compatible object storage with an SDK for three jobs: writing --- -## Install +## Setup + +Install the package: ```bash npm @@ -26,19 +28,34 @@ bun add @upstash/blob ``` -The package has three entrypoints: `@upstash/blob` for the server, `@upstash/blob/browser` for a plain browser client, and `@upstash/blob/react` for the hooks. +Then create a bucket in the [Upstash Console](https://console.upstash.com) and put its token in your environment. + +```bash .env +UPSTASH_BLOB_TOKEN=... +``` + +A public bucket serves every object over a public URL, which is what you want for avatars, product images, and anything a page links to directly. A private bucket has no public host: every object is read through a time-limited [signed URL](/blob/reference/signing), which is the right choice for user documents, invoices, and anything else that should not be guessable. Pick private if you are unsure, since a public bucket cannot take back what it has already served. --- -## Get a bucket token +## Upload from your server -Create a bucket in the [Upstash Console](https://console.upstash.com) and copy its token. +Bytes already on your server go straight to the bucket. -```bash .env.local -UPSTASH_BLOB_TOKEN=... +```ts lib/reports.ts +import { Bucket } from "@upstash/blob" + +const bucket = Bucket.fromEnv() + +export async function saveReport(file: Blob) { + const blob = await bucket.put("reports/2026-01.pdf", file, { + contentType: "application/pdf", + }) + return blob.url +} ``` -`Bucket.fromEnv()` and the upload handler both read this variable. +`blob.url` is the public object URL, and `undefined` on a private bucket. Metadata, caching and conditional writes are in [Writing](/blob/bucket/writing). --- @@ -54,16 +71,19 @@ The smallest one that works states what it accepts and where the object goes: ```ts lib/uploads.ts import "server-only" -import { uniquePath, uploadHandler } from "@upstash/blob" +import { uploadHandler } from "@upstash/blob" export const uploads = uploadHandler({ - constraints: { maxBytes: "20mb", contentTypes: ["image/*", "application/pdf"] }, + constraints: { + maxBytes: "20mb", + contentTypes: ["image/*", "application/pdf"], + }, - onBeforeUpload: ({ file }) => ({ path: uniquePath`uploads/${file.name}` }), + onBeforeUpload: ({ file }) => ({ path: `images/${file.name}` }), }) ``` -`uniquePath` sanitizes what you interpolate and adds a random suffix, so two people picking `photo.png` do not land on the same object. Sizes are decimal, so `'20mb'` is 20,000,000 bytes. The grammar behind `constraints` is covered in [Constraints](/blob/browser/constraints). +See [Upload handler](/blob/uploads/upload-handler) for everything these callbacks can do. @@ -83,7 +103,7 @@ export const { GET, POST } = uploads -`uploadHooks()` reads the handler's type, so `upload.blob.data` on the client is typed from what `onUploadComplete` returned and a route name that does not exist fails to compile. +`uploadHooks()` reads the handler's type, so `upload.blob.data` on the client is typed from what `onUploadComplete` returned. ```ts lib/upload-hooks.ts "use client" @@ -118,105 +138,43 @@ export default function Page() { {upload &&

{upload.status}

} {upload?.pending && } - {upload?.status === "done" && {upload.blob.path}} + {upload?.status === "done" && ( + {upload.blob.path} + )} {upload?.status === "error" &&

{upload.error.message}

} ) } ``` -`accept` comes from the route's own `GET`, so the file picker is filled from the same list that does the refusing. `status` is one of `queued`, `uploading`, `finishing`, `paused`, `done`, `canceled` or `error`, and `pending` is true for the first four. `upload.error` is a `BlobError` with the `code` your server raised. - -
- - - -Uploads work at this point, but the handler above does not check who is asking or write anything down. Both go in the same two callbacks: `onBeforeUpload` runs before anything is signed, `onUploadComplete` once the object exists. - -```ts lib/uploads.ts -import "server-only" -import { BlobError, uniquePath, uploadHandler } from "@upstash/blob" -import { getUser } from "./auth" -import { db } from "./db" - -export const uploads = uploadHandler({ - constraints: { maxBytes: "20mb", contentTypes: ["image/*", "application/pdf"] }, - - onBeforeUpload: async ({ request, file }) => { - const user = await getUser(request) - if (!user) throw new BlobError("unauthorized") // the 401, and nothing is signed - return { path: uniquePath`${user.id}/${file.name}`, metadata: { owner: user.id } } - }, - - onUploadComplete: async ({ url, metadata, uploadId }) => { - // uploadId is stable across retries, so the same completion twice writes one row - await db.files.upsert({ uploadId, owner: metadata.owner, url }) - }, -}) -``` - -A throw out of `onUploadComplete` deletes the object that just landed, so catch your own database errors rather than letting them escape. [Upload handler](/blob/browser/upload-handler#onuploadcomplete) has the whole callback. +`accept` comes from the route's own `GET`, so the file picker offers exactly what the server accepts. -A file under 16 MB goes up as one presigned PUT. Anything larger is cut into parts, which is what buys pause, resume and per-part retry. See [Large files](/blob/browser/large-files). - ---- - -## Server uploads - -For bytes that are already on your server, skip the handler and write the object directly. - -```ts lib/reports.ts -import { Bucket } from "@upstash/blob" - -const bucket = Bucket.fromEnv() - -export async function saveReport(pdf: Blob) { - const blob = await bucket.put("reports/2026-01.pdf", pdf, { contentType: "application/pdf" }) - return blob.url -} -``` - -`blob.url` is the public object URL, and `undefined` on a private bucket. The rest of the write API, including metadata, caching and conditional writes, is in [Writing](/blob/bucket/writing). - ---- - -## CORS - -A direct upload PUTs to storage, not to your app, so the bucket's CORS configuration is what decides whether the browser is allowed to send it at all. Four things have to be true, and they come straight from what the SDK signs: - -| The policy must allow | Because | -| --- | --- | -| the origin your page is served from | that is who sends the PUT | -| the `PUT` method | a presigned upload is one `PUT` per object or per part | -| the request headers `content-type`, `cache-control`, and every `x-amz-meta-*` the route writes | a single PUT carries them as real headers, pinned into the signature, so the browser must send them verbatim | -| `ETag` in the exposed response headers | the browser reads each part's etag off a cross-origin response to complete the upload | - -The metadata header names are yours: a route returning `metadata: { owner }` sends `x-amz-meta-owner`. Exposing `Retry-After` too is worth doing but not required. +Uploads work at this point, but the handler does not yet check who is asking or write anything down. Both go in the same two callbacks: see [Upload handler](/blob/uploads/upload-handler). -A browser blocks a request that fails CORS before a single byte goes out, and script never sees why, so a PUT that fails with no status and no bytes sent is almost always this. +A file under 16 MB goes up as one presigned PUT. Anything larger is cut into parts, which is what buys pause, resume and per-part retry. See [Large files](/blob/uploads/large-files). --- ## Next steps - + Routes, context, input schemas and the completion callback in full. - + `uploadHooks`, `useUpload`, the record it renders, and the non-React client. - + Size limits, content types and what the byte check does and does not prove. - + Parts, pause, resume and retry, and where the threshold sits. @@ -224,11 +182,11 @@ A browser blocks a request that fails CORS before a single byte goes out, and sc `put`, metadata, conditional writes and multipart from the server. - + Read and upload links for anything outside the browser flow. - + Whole features wired end to end: avatars, chat attachments, and the files they take. diff --git a/blob/formulas/overview.mdx b/blob/recipes/overview.mdx similarity index 100% rename from blob/formulas/overview.mdx rename to blob/recipes/overview.mdx diff --git a/blob/bucket/errors.mdx b/blob/reference/errors.mdx similarity index 100% rename from blob/bucket/errors.mdx rename to blob/reference/errors.mdx diff --git a/blob/overall/signing.mdx b/blob/reference/signing.mdx similarity index 100% rename from blob/overall/signing.mdx rename to blob/reference/signing.mdx diff --git a/blob/browser/abandoned-uploads.mdx b/blob/uploads/abandoned-uploads.mdx similarity index 100% rename from blob/browser/abandoned-uploads.mdx rename to blob/uploads/abandoned-uploads.mdx diff --git a/blob/browser/constraints.mdx b/blob/uploads/constraints.mdx similarity index 100% rename from blob/browser/constraints.mdx rename to blob/uploads/constraints.mdx diff --git a/blob/browser/large-files.mdx b/blob/uploads/large-files.mdx similarity index 100% rename from blob/browser/large-files.mdx rename to blob/uploads/large-files.mdx diff --git a/blob/browser/client.mdx b/blob/uploads/upload-client.mdx similarity index 100% rename from blob/browser/client.mdx rename to blob/uploads/upload-client.mdx diff --git a/blob/browser/upload-handler.mdx b/blob/uploads/upload-handler.mdx similarity index 100% rename from blob/browser/upload-handler.mdx rename to blob/uploads/upload-handler.mdx From f5a9a339167a8d459ac29c6f83ea8ed454dff73e Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Wed, 2 Sep 2026 01:01:17 +0200 Subject: [PATCH 08/41] docs(blob): reorganize nav groups and rename client page --- blob/bucket/caching.mdx | 6 ++--- blob/bucket/deleting.mdx | 12 +++++----- blob/bucket/reading.mdx | 12 +++++----- blob/bucket/writing.mdx | 12 +++++----- blob/recipes/overview.mdx | 20 ++++++++-------- blob/reference/errors.mdx | 12 +++++----- blob/reference/signing.mdx | 14 +++++------ blob/uploads/abandoned-uploads.mdx | 6 ++--- blob/uploads/constraints.mdx | 4 ++-- blob/uploads/large-files.mdx | 12 +++++----- blob/uploads/upload-client.mdx | 19 ++++++--------- blob/uploads/upload-handler.mdx | 16 ++++++------- docs.json | 37 +++++++++++++++++------------- 13 files changed, 90 insertions(+), 92 deletions(-) diff --git a/blob/bucket/caching.mdx b/blob/bucket/caching.mdx index c10aaaab..3f3c63a3 100644 --- a/blob/bucket/caching.mdx +++ b/blob/bucket/caching.mdx @@ -118,7 +118,7 @@ export const uploads = uploadHandler({ }) ``` -See [Upload handler](/blob/browser/upload-handler) for the rest of the callback. +See [Upload handler](/blob/uploads/upload-handler) for the rest of the callback. --- @@ -211,10 +211,10 @@ await bucket.put("private/report.pdf", body, { `no-store` is the one value that drops the visibility scope entirely: it stores `no-store` on a public and a private bucket alike, because nothing is to be kept either way. -See [How signing works](/blob/overall/signing) for how link lifetimes are capped. +See [How signing works](/blob/reference/signing) for how link lifetimes are capped. --- ## What the upload route itself caches -An upload route's `GET` serves its constraints with a short, revalidated `Cache-Control` of its own, unrelated to the objects the route stores. That document and its caching are covered in [Constraints](/blob/browser/constraints#in-the-browser). +An upload route's `GET` serves its constraints with a short, revalidated `Cache-Control` of its own, unrelated to the objects the route stores. That document and its caching are covered in [Constraints](/blob/uploads/constraints#in-the-browser). diff --git a/blob/bucket/deleting.mdx b/blob/bucket/deleting.mdx index ed440556..b9be67da 100644 --- a/blob/bucket/deleting.mdx +++ b/blob/bucket/deleting.mdx @@ -39,7 +39,7 @@ await bucket.del("drafts/9f3c.txt") await bucket.del("drafts/9f3c.txt") // fine, still no throw ``` -That makes a delete safe to run from a retried job or a queue consumer with at-least-once delivery. Any other failure is a real error; see [Errors](/blob/bucket/errors#what-storage-errors-map-to). +That makes a delete safe to run from a retried job or a queue consumer with at-least-once delivery. Any other failure is a real error; see [Errors](/blob/reference/errors#what-storage-errors-map-to). `del` never tells you whether anything was there. If you need to know, ask first with `bucket.exists(path)`, which answers `false` instead of throwing. See [Reading](/blob/bucket/reading). @@ -74,7 +74,7 @@ try { Everything not in `failed` was deleted. `partial_delete` is a report, not a rollback: retrying with `e.failed` is the whole recovery, and it is safe because a delete of something already gone is success. -Use `BlobError.is(e)`, never `instanceof`. An ESM copy and a CJS copy of the class are two different classes. See [Errors](/blob/bucket/errors). +Use `BlobError.is(e)`, never `instanceof`. An ESM copy and a CJS copy of the class are two different classes. See [Errors](/blob/reference/errors). A batch delete is not retried internally, so a failure surfaces on the first try rather than being sent twice. @@ -184,7 +184,7 @@ await bucket.abortMultipartUpload({ path: "uploads/big.mp4", uploadId: "ABC..." This throws the upload away along with every part that landed for it. Missing is success, exactly like `del` on a path that is not there. Since a wrong pair would silently succeed, the arguments are named rather than positional, and an empty `uploadId` is refused with `invalid_input`. -`onUploadComplete` receives `multipartUploadId` for exactly this pair. Store it alongside your row and you can abort a specific upload later without listing the bucket. It is `undefined` when the file went up as a single PUT. See [Upload handler](/blob/browser/upload-handler). +`onUploadComplete` receives `multipartUploadId` for exactly this pair. Store it alongside your row and you can abort a specific upload later without listing the bucket. It is `undefined` when the file went up as a single PUT. See [Upload handler](/blob/uploads/upload-handler). ### Sweeping the stale ones @@ -218,7 +218,7 @@ export async function GET(request: Request) { `prefix` narrows the sweep the same way it narrows `listMultipartUploads`. -An abandoned upload **under** the multipart threshold is not a multipart upload at all. The browser's presigned PUT stored the object the moment its last byte landed, so what it leaves behind is a whole, ordinary, `list()`-visible, billed object, and none of the calls on this page can find it. That needs a different sweep: see [Abandoned uploads](/blob/browser/abandoned-uploads). +An abandoned upload **under** the multipart threshold is not a multipart upload at all. The browser's presigned PUT stored the object the moment its last byte landed, so what it leaves behind is a whole, ordinary, `list()`-visible, billed object, and none of the calls on this page can find it. That needs a different sweep: see [Abandoned uploads](/blob/uploads/abandoned-uploads). --- @@ -229,10 +229,10 @@ Two paths in the upload handler delete objects without you asking: a throw out o Both confirm the object is the one this upload wrote before deleting it. A later upload that took the same path is left alone with a warning, and an object the handler cannot identify is left stored with an error logged: an orphan costs storage, while a blind delete costs somebody else's accepted file. -Any throw out of `onUploadComplete` runs that delete, including a retryable `BlobError`, so the retry the error asks for arrives at an empty path. Catch your own storage errors rather than letting them escape the callback. See [Upload handler](/blob/browser/upload-handler#onuploadcomplete) and [Abandoned uploads](/blob/browser/abandoned-uploads). +Any throw out of `onUploadComplete` runs that delete, including a retryable `BlobError`, so the retry the error asks for arrives at an empty path. Catch your own storage errors rather than letting them escape the callback. See [Upload handler](/blob/uploads/upload-handler#onuploadcomplete) and [Abandoned uploads](/blob/uploads/abandoned-uploads). --- ## Error codes -Deleting raises `partial_delete`, `move_left_a_copy` and `invalid_input`; each is described where it is raised above, and the statuses and extra fields are on [Errors](/blob/bucket/errors#the-codes). Two things are specific to this page. `not_found` is never raised by `del`, which treats a missing object as success. And a path containing a `.` or `..` segment throws a `TypeError` rather than a `BlobError`, because it is a programming mistake rather than a runtime condition. +Deleting raises `partial_delete`, `move_left_a_copy` and `invalid_input`; each is described where it is raised above, and the statuses and extra fields are on [Errors](/blob/reference/errors#the-codes). Two things are specific to this page. `not_found` is never raised by `del`, which treats a missing object as success. And a path containing a `.` or `..` segment throws a `TypeError` rather than a `BlobError`, because it is a programming mistake rather than a runtime condition. diff --git a/blob/bucket/reading.mdx b/blob/bucket/reading.mdx index ed2ca636..7f45cfc5 100644 --- a/blob/bucket/reading.mdx +++ b/blob/bucket/reading.mdx @@ -63,7 +63,7 @@ const text = await new Response((await bucket.get("notes/1.md")).body).text() const buffer = await new Response((await bucket.get("img/1.png")).body).arrayBuffer() ``` -A missing object is a throw, not an `undefined` return: `get` raises a `BlobError` with code `not_found` and status 404. See [Errors](/blob/bucket/errors) for the code list and for `BlobError.is`. +A missing object is a throw, not an `undefined` return: `get` raises a `BlobError` with code `not_found` and status 404. See [Errors](/blob/reference/errors) for the code list and for `BlobError.is`. There is no range option. Reading part of an object is what [the S3 escape hatch](#the-s3-escape-hatch) is for. @@ -87,7 +87,7 @@ Like `get`, a missing object throws `not_found` rather than answering `undefined Metadata keys come back lowercased: `{ uploadedBy: 'u1' }` written at upload reads back as `metadata.uploadedby`. The rules for what can be written are in [Writing](/blob/bucket/writing#metadata). -`metadata` comes back from `get` and `info` and from nothing else, so `info` is the call a cleanup cron makes to confirm the object at a path is the one it expects. See [Abandoned uploads](/blob/browser/abandoned-uploads). +`metadata` comes back from `get` and `info` and from nothing else, so `info` is the call a cleanup cron makes to confirm the object at a path is the one it expects. See [Abandoned uploads](/blob/uploads/abandoned-uploads). --- @@ -215,7 +215,7 @@ if (!cached || cached.expiresAt < new Date()) { } ``` -The default with no `expiresIn` is 5 minutes. See [How signing works](/blob/overall/signing) for what caps a link's lifetime. +The default with no `expiresIn` is 5 minutes. See [How signing works](/blob/reference/signing) for what caps a link's lifetime. ### `downloadAs` @@ -287,15 +287,15 @@ const res = await s3.send( One path, a list, a prefix, and sweeping incomplete uploads. - + How links are signed, and the upload direction. - + `BlobError`, the code list, and `BlobError.is`. - + Direct browser uploads, and what `onUploadComplete` is handed. diff --git a/blob/bucket/writing.mdx b/blob/bucket/writing.mdx index b9f5aa63..e90b4518 100644 --- a/blob/bucket/writing.mdx +++ b/blob/bucket/writing.mdx @@ -4,7 +4,7 @@ title: "Writing" Everything on this page runs on your server, with the bucket token. `put` writes bytes, `copy` and `move` rearrange them, `updateJson` reads and writes a document under a compare-and-set loop, and `signedUploadUrl` hands the write to somebody else. -If you have not installed the SDK or created a bucket yet, start at the [Quickstart](/blob/overall/quickstart). For bytes that live in a browser, do not proxy them through your app: see [Upload handler](/blob/browser/upload-handler). +If you have not installed the SDK or created a bucket yet, start at the [Quickstart](/blob/overall/quickstart). For bytes that live in a browser, do not proxy them through your app: see [Upload handler](/blob/uploads/upload-handler). --- @@ -100,7 +100,7 @@ blob.versionedUrl // ...?v=%22d41d8...%22 blob.etag ``` -On a private bucket `url` and `versionedUrl` are `undefined` and reads go through a signed link instead. See [How signing works](/blob/overall/signing). +On a private bucket `url` and `versionedUrl` are `undefined` and reads go through a signed link instead. See [How signing works](/blob/reference/signing). ### Bodies @@ -162,7 +162,7 @@ A declared `size` has to be right. A body that does not match it fails the reque A `Request` that arrived chunked has no `content-length` either, so it is an unknown length like any other stream. -When bytes are being proxied through a route, keep `maxBytes` under the platform's own request body cap, since that refusal happens before your route runs. The numbers are in [Errors](/blob/bucket/errors#platform-body-limits). +When bytes are being proxied through a route, keep `maxBytes` under the platform's own request body cap, since that refusal happens before your route runs. The numbers are in [Errors](/blob/reference/errors#platform-body-limits). --- @@ -353,7 +353,7 @@ Parts are sent one at a time. If anything fails, the SDK aborts the whole upload A body that does not match a declared `size` throws `invalid_input` naming the mismatch. -Parts, pause, resume and per-part retry are covered in full in [Large files](/blob/browser/large-files). +Parts, pause, resume and per-part retry are covered in full in [Large files](/blob/uploads/large-files). --- @@ -388,7 +388,7 @@ It returns `{ url, headers, expiresAt }`. `expiresAt` is the real answer for the link, which may be sooner than what you asked for. Use it rather than computing a deadline yourself. -For a browser upload, use the upload handler instead. A signed URL is one PUT with no multipart, no resume, and nothing to tell your server it happened. See [Upload handler](/blob/browser/upload-handler). +For a browser upload, use the upload handler instead. A signed URL is one PUT with no multipart, no resume, and nothing to tell your server it happened. See [Upload handler](/blob/uploads/upload-handler). --- @@ -431,7 +431,7 @@ The SDK sends its version, runtime and platform as headers on credential request What `cache` accepts and why it is written once, at upload. - + Every code, what raises it, and how to test for one. diff --git a/blob/recipes/overview.mdx b/blob/recipes/overview.mdx index fdb1c022..b12fb753 100644 --- a/blob/recipes/overview.mdx +++ b/blob/recipes/overview.mdx @@ -1,8 +1,8 @@ --- -title: "Formulas" +title: "Recipes" --- -The reference pages describe one option at a time: what `cache` accepts, what `constraints` refuse, what `onUploadComplete` is handed. A formula is the other direction. It is one real feature wired end to end, with every file it takes, and with the choices already made and explained. Copy one, rename the paths, and it works. +The other pages describe one option at a time: what `cache` accepts, what `constraints` refuse, what `onUploadComplete` is handed. A recipe is the other direction. It is one real feature wired end to end, with every file it takes, and with the choices already made and explained. Copy one, rename the paths, and it works. --- @@ -12,13 +12,13 @@ Almost every decision in an upload feature is the same five: where the object go | Feature | Path | Cache | Constraints | Multipart | Notes | | ------- | ---- | ----- | ----------- | --------- | ----- | -| Avatar | `avatars/${user.id}.png`, stable | [`'immutable'`](/blob/bucket/caching) and serve `versionedUrl`, or `'revalidate'` if you link the bare `url` | [`image/*`](/blob/browser/constraints), `maxBytes: '5mb'` | default, a 5 MB file is always one PUT | Overwriting is the intent, so there is no orphan and nothing to sweep | -| Chat or issue attachment | [`uniquePath`](/blob/bucket/writing#uniquepath) under `threads/${threadId}/` | default, `public, max-age=3600` | `maxBytes: '25mb'`, no type list | default, or `true` to skip the sweep | [Pending row plus a cron](/blob/browser/abandoned-uploads); `uploadId` is the idempotency key | +| Avatar | `avatars/${user.id}.png`, stable | [`'immutable'`](/blob/bucket/caching) and serve `versionedUrl`, or `'revalidate'` if you link the bare `url` | [`image/*`](/blob/uploads/constraints), `maxBytes: '5mb'` | default, a 5 MB file is always one PUT | Overwriting is the intent, so there is no orphan and nothing to sweep | +| Chat or issue attachment | [`uniquePath`](/blob/bucket/writing#uniquepath) under `threads/${threadId}/` | default, `public, max-age=3600` | `maxBytes: '25mb'`, no type list | default, or `true` to skip the sweep | [Pending row plus a cron](/blob/uploads/abandoned-uploads); `uploadId` is the idempotency key | | User document library | `uniquePath` under `docs/${user.id}/` | `'immutable'`, the path is already unique | `['application/pdf']`, `maxBytes: '100mb'` | default | [`bucket.list({ prefix })`](/blob/bucket/reading) is the listing, your rows are the metadata | -| Large video upload | `uniquePath` under `videos/${user.id}/` | `'immutable'` | [`video/*`](/blob/browser/constraints), `maxBytes: '5gb'` | [`multipart: true`](/blob/browser/large-files) | Only parts can pause, resume and retry; a closed tab leaves parts for `abortStaleMultipartUploads` | +| Large video upload | `uniquePath` under `videos/${user.id}/` | `'immutable'` | [`video/*`](/blob/uploads/constraints), `maxBytes: '5gb'` | [`multipart: true`](/blob/uploads/large-files) | Only parts can pause, resume and retry; a closed tab leaves parts for `abortStaleMultipartUploads` | | Private report or invoice | `invoices/${invoice.id}.pdf`, stable | `'no-store'` | written by your server, so [`bucket.put`](/blob/bucket/writing) rather than a route | default | `visibility: 'private'` drops `url`, and reads go through [`signedReadUrl`](/blob/bucket/reading) | -Two rules run underneath the whole table. Use `uniquePath` unless overwriting is the intent. And write the row that says an upload is in flight before the bytes are: under the multipart threshold the object exists the moment the last byte lands, whether or not any callback ever accepted it. Both are argued out in [Abandoned uploads](/blob/browser/abandoned-uploads). +Two rules run underneath the whole table. Use `uniquePath` unless overwriting is the intent. And write the row that says an upload is in flight before the bytes are: under the multipart threshold the object exists the moment the last byte lands, whether or not any callback ever accepted it. Both are argued out in [Abandoned uploads](/blob/uploads/abandoned-uploads). --- @@ -108,7 +108,7 @@ A stable path is the one case where overwriting is the intent. There is exactly `cache: 'immutable'` on a path that gets overwritten would normally be the wrong answer, and it is safe here only because nothing links the bare `url`. `versionedUrl` is `url` with `?v=` appended, so new bytes are a new URL and the old one is never asked for again. The alternative is `'revalidate'`, which keeps one URL and pays a 304 per read. -The `try`/`catch` is not decoration: any throw out of `onUploadComplete` [deletes the object that just landed](/blob/browser/upload-handler#onuploadcomplete). Swallowing the database error costs a stale `avatarUrl` instead, and that is recoverable, because the path is `avatars/${user.id}.png` and `bucket.info(path)` gives the etag the URL is built from. +The `try`/`catch` is not decoration: any throw out of `onUploadComplete` [deletes the object that just landed](/blob/uploads/upload-handler#onuploadcomplete). Swallowing the database error costs a stale `avatarUrl` instead, and that is recoverable, because the path is `avatars/${user.id}.png` and `bucket.info(path)` gives the etag the URL is built from. --- @@ -246,12 +246,12 @@ export function AttachmentInput({ threadId }: { threadId: string }) { `uniquePath` sanitizes what you interpolate and appends a random suffix, so two people sending `photo.png` to one thread get two objects rather than a lost update. -The pending row is what makes a closed tab recoverable: write it in `onBeforeUpload`, clear it last in `onUploadComplete`, and sweep what is still pending past a grace window. Why the row is the only thing that can tell an abandoned upload from a finished one is in [Abandoned uploads](/blob/browser/abandoned-uploads). +The pending row is what makes a closed tab recoverable: write it in `onBeforeUpload`, clear it last in `onUploadComplete`, and sweep what is still pending past a grace window. Why the row is the only thing that can tell an abandoned upload from a finished one is in [Abandoned uploads](/blob/uploads/abandoned-uploads). -Because the sweep exists, a database error is allowed to escape `onUploadComplete` here, unlike in the avatar formula. The throw deletes the object, the pending row survives, the cron's `not_found` branch clears it, and the user gets an error for an upload that genuinely did not land. +Because the sweep exists, a database error is allowed to escape `onUploadComplete` here, unlike in the avatar recipe. The throw deletes the object, the pending row survives, the cron's `not_found` branch clears it, and the user gets an error for an upload that genuinely did not land. --- -## More formulas coming +## More recipes coming The other rows of the table above land here next, each as a full set of files. diff --git a/blob/reference/errors.mdx b/blob/reference/errors.mdx index 24caa813..9c675c5c 100644 --- a/blob/reference/errors.mdx +++ b/blob/reference/errors.mdx @@ -239,13 +239,13 @@ onError: ({ error }) => { }, ``` -Written on the handler it is the default for every route, and a route with its own `onError` replaces it. See [Upload handler](/blob/browser/upload-handler). +Written on the handler it is the default for every route, and a route with its own `onError` replaces it. See [Upload handler](/blob/uploads/upload-handler). --- ## The `onUploadComplete` footgun -Any throw out of `onUploadComplete` deletes the completed object, which is right for a refusal and wrong for a database error that has nothing to do with the file. Catch your own storage errors instead of letting them escape, and see [Upload handler](/blob/browser/upload-handler#onuploadcomplete) for the callback and [Abandoned uploads](/blob/browser/abandoned-uploads) for the pending-row pattern that reconciles the rest. +Any throw out of `onUploadComplete` deletes the completed object, which is right for a refusal and wrong for a database error that has nothing to do with the file. Catch your own storage errors instead of letting them escape, and see [Upload handler](/blob/uploads/upload-handler#onuploadcomplete) for the callback and [Abandoned uploads](/blob/uploads/abandoned-uploads) for the pending-row pattern that reconciles the rest. --- @@ -280,9 +280,9 @@ the browser blocked the request before sending any bytes, which is almost always the bucket has to allow PUT and the signed headers from this origin ``` -The policy that fixes it is in [CORS](/blob/overall/quickstart#cors). +Buckets allow every origin by default, so this only shows up on a bucket whose CORS policy was narrowed: it has to allow `PUT` and the signed headers from your origin. -**A 403 on a freshly minted presign** becomes `signature_mismatch`. A 401 or 403 on an older URL is read as an expired signature and the browser asks the route for a new one instead. The whole classification is in [Large files](/blob/browser/large-files#retries). +**A 403 on a freshly minted presign** becomes `signature_mismatch`. A 401 or 403 on an older URL is read as an expired signature and the browser asks the route for a new one instead. The whole classification is in [Large files](/blob/uploads/large-files#retries). **Exhausted retries** become `request_failed`, carrying the attempt count and the last status, hinted with what to do next: @@ -291,7 +291,7 @@ Upload failed after 8 attempts (last status 500) (the parts that landed are kept task.retry(), or pick the same file again) ``` -`retry()` runs the same upload again from the parts that landed, so nothing already uploaded is re-sent. See [Large files](/blob/browser/large-files). +`retry()` runs the same upload again from the parts that landed, so nothing already uploaded is re-sent. See [Large files](/blob/uploads/large-files). **A canceled upload rejects with an `AbortError`, not a `BlobError`.** The record's status is `canceled` and it carries no `error` at all, so a cancel never renders as a failure. @@ -348,4 +348,4 @@ try { } ``` -Credentials are short-lived and re-minted before they expire. One that expires mid-request is handled inside the SDK, so only a second refusal surfaces. See [How signing works](/blob/overall/signing) for how that lifetime caps a signed link. +Credentials are short-lived and re-minted before they expire. One that expires mid-request is handled inside the SDK, so only a second refusal surfaces. See [How signing works](/blob/reference/signing) for how that lifetime caps a signed link. diff --git a/blob/reference/signing.mdx b/blob/reference/signing.mdx index ff7edef8..ddcd8a8b 100644 --- a/blob/reference/signing.mdx +++ b/blob/reference/signing.mdx @@ -30,7 +30,7 @@ The token is not an S3 credential. To touch storage, the SDK exchanges it with U Credentials are cached per token rather than per `Bucket` instance, so constructing a bucket inside a request handler is free and does not mint a credential each time. Concurrent callers share one in-flight mint rather than racing, and a credential is refreshed shortly before it expires. -A failed mint surfaces as `unauthorized` (the token was rejected), `rate_limited`, `not_ready` or `mint_backoff`. See [Credential errors](/blob/bucket/errors#credential-errors). +A failed mint surfaces as `unauthorized` (the token was rejected), `rate_limited`, `not_ready` or `mint_backoff`. See [Credential errors](/blob/reference/errors#credential-errors). --- @@ -103,14 +103,14 @@ browser your route Upstash storage | Phase | What your route does | What it signs | | --- | --- | --- | -| `begin` | Enforces [Constraints](/blob/browser/constraints), runs `onBeforeUpload`, and for a large file creates the multipart upload | The first PUT URL, or the first batch of part URLs | +| `begin` | Enforces [Constraints](/blob/uploads/constraints), runs `onBeforeUpload`, and for a large file creates the multipart upload | The first PUT URL, or the first batch of part URLs | | `parts` | Verifies the completion token and asks storage what already landed | The next batch of part URLs | | `end` | Verifies the token, completes the upload, reads the object back, runs `onUploadComplete` | Nothing new | | `cancel` | Verifies the token, aborts the multipart or deletes a matching single-PUT object | Nothing | The browser never sees the bucket token and never sees an S3 credential. It sees per-object presigned URLs, the headers those URLs pin, and a completion token. Nothing it holds can list the bucket, read another object, or write to a path your `onBeforeUpload` did not choose. -`GET` on the same route serves the constraints document, with an ETag and `max-age=60`, so a file picker can be filled from the same list that does the refusing. See [Upload handler](/blob/browser/upload-handler) for the callbacks and [Large files](/blob/browser/large-files) for the multipart path. +`GET` on the same route serves the constraints document, with an ETag and `max-age=60`, so a file picker can be filled from the same list that does the refusing. See [Upload handler](/blob/uploads/upload-handler) for the callbacks and [Large files](/blob/uploads/large-files) for the multipart path. --- @@ -144,8 +144,6 @@ Signed, not merely sent. An unsigned header would be the browser's to choose, an For a multipart upload the same headers are set by your server when it creates the upload, and the object inherits them at completion. Part URLs pin only the part's length. -Because those headers ride on a cross-origin request, the bucket's CORS policy has to allow them. The exact shape is in [CORS](/blob/overall/quickstart#cors). - --- ## The `upstash-upload` marker @@ -156,13 +154,13 @@ It answers exactly one question: did the bytes at this path come from THIS uploa So completing a single-PUT upload requires a marker match. No match is `not_found`, "the upload never landed". A cancel uses the same check, which is what stops it from deleting someone else's file at the same path. -The marker is stripped from the record handed to `onUploadComplete` and `onError`, but stays on the stored object. So a match proves "same upload" and never "no callback accepted it". What that costs, and the pending row that closes it, is on [Abandoned uploads](/blob/browser/abandoned-uploads). +The marker is stripped from the record handed to `onUploadComplete` and `onError`, but stays on the stored object. So a match proves "same upload" and never "no callback accepted it". What that costs, and the pending row that closes it, is on [Abandoned uploads](/blob/uploads/abandoned-uploads). --- ## Retries and 403 -A 403 from storage is ambiguous by design: an expired presigned URL and a tampered request produce the same status. So the browser treats a 403 as an expired signature first, throws the batch of URLs away, and asks your route for fresh ones. A 403 on a URL that was just signed is a real `signature_mismatch` and ends the upload. The rest of the classification, and the retry budgets, are in [Large files](/blob/browser/large-files#retries). +A 403 from storage is ambiguous by design: an expired presigned URL and a tampered request produce the same status. So the browser treats a 403 as an expired signature first, throws the batch of URLs away, and asks your route for fresh ones. A 403 on a URL that was just signed is a real `signature_mismatch` and ends the upload. The rest of the classification, and the retry budgets, are in [Large files](/blob/uploads/large-files#retries). Your server has the same ambiguity and resolves it by reading the response body. It re-mints once per request when the body says the credential expired. Any other 403 surfaces as `signature_mismatch`, usually meaning the body length or type differs from what was signed. @@ -170,7 +168,7 @@ Your server has the same ambiguity and resolves it by reading the response body. ## What the browser stores -One thing: the completion token, in `localStorage`, keyed by the route and the file. Nothing about what landed is stored, since your server can ask storage for that. [Large files](/blob/browser/large-files#resuming-after-a-reload) covers the resume gesture and what happens when `localStorage` is unavailable. +One thing: the completion token, in `localStorage`, keyed by the route and the file. Nothing about what landed is stored, since your server can ask storage for that. [Large files](/blob/uploads/large-files#resuming-after-a-reload) covers the resume gesture and what happens when `localStorage` is unavailable. --- diff --git a/blob/uploads/abandoned-uploads.mdx b/blob/uploads/abandoned-uploads.mdx index 2aa2fed5..f2834762 100644 --- a/blob/uploads/abandoned-uploads.mdx +++ b/blob/uploads/abandoned-uploads.mdx @@ -6,13 +6,13 @@ A user picks a file, the upload starts, and the tab closes halfway through. Noth What that leaves in the bucket depends on which side of the multipart threshold the file was on. Over the threshold the SDK can sweep it up for you. Under the threshold it cannot, and this page is mostly about that half: why the SDK cannot tell an abandoned object from an accepted one, and the one pattern that can. -See [Upload handler](/blob/browser/upload-handler) for the callbacks, and [Large files](/blob/browser/large-files) for the threshold itself. +See [Upload handler](/blob/uploads/upload-handler) for the callbacks, and [Large files](/blob/uploads/large-files) for the threshold itself. --- ## The two kinds -The default threshold is 16 MB. [Large files](/blob/browser/large-files#what-changes-at-the-line) compares the two transports in full; these are the rows that decide who cleans up. +The default threshold is 16 MB. [Large files](/blob/uploads/large-files#what-changes-at-the-line) compares the two transports in full; these are the rows that decide who cleans up. | | Under the threshold, one PUT | Over the threshold, multipart | | --- | --- | --- | @@ -210,7 +210,7 @@ The cost is two extra round trips between your server and storage, not extra bro `the upload never landed`. A database blip costs the upload and then reports it as a phantom.
-A retryable `BlobError` is not an escape either: any throw deletes the object first, so the retry it asks for arrives at an empty path. Retry the write in place, hand it to a queue, or leave the row pending and let the sweep decide later. Throw out of `onUploadComplete` only when you mean to refuse the file. [Upload handler](/blob/browser/upload-handler#onuploadcomplete) has the callback in full. +A retryable `BlobError` is not an escape either: any throw deletes the object first, so the retry it asks for arrives at an empty path. Retry the write in place, hand it to a queue, or leave the row pending and let the sweep decide later. Throw out of `onUploadComplete` only when you mean to refuse the file. [Upload handler](/blob/uploads/upload-handler#onuploadcomplete) has the callback in full. On a public bucket the delete is also less than it looks: the object has been readable since it was stored, so deleting bounds the exposure rather than undoing it, and a CDN that cached it inside that window keeps serving it for its `Cache-Control`. diff --git a/blob/uploads/constraints.mdx b/blob/uploads/constraints.mdx index 5fa4ca68..c2e92dd5 100644 --- a/blob/uploads/constraints.mdx +++ b/blob/uploads/constraints.mdx @@ -4,7 +4,7 @@ title: "Constraints" Constraints are the two limits an upload route enforces before it signs anything: how big a file may be, and what type it may claim to be. They are the only place a direct upload can be refused for free, because past `begin` the bytes go straight to storage and never touch your server. -See [Upload handler](/blob/browser/upload-handler) for the handler shape and its callbacks, and [The client](/blob/browser/client) for the hooks. +See [Upload handler](/blob/uploads/upload-handler) for the handler shape and its callbacks, and [Upload client](/blob/uploads/upload-client) for the hooks. --- @@ -208,7 +208,7 @@ The size check is the only one that runs in the browser. **The server is authori ## Error codes -A refusal here is `too_large`, `content_type_not_allowed`, `invalid_content_type_pattern` or `empty_body`, and it reaches the browser as a `BlobError` with that code intact, so switch on `error.code` rather than on status numbers. See [Errors](/blob/bucket/errors) for what each one means. +A refusal here is `too_large`, `content_type_not_allowed`, `invalid_content_type_pattern` or `empty_body`, and it reaches the browser as a `BlobError` with that code intact, so switch on `error.code` rather than on status numbers. See [Errors](/blob/reference/errors) for what each one means. --- diff --git a/blob/uploads/large-files.mdx b/blob/uploads/large-files.mdx index 4ddfe16b..5c44e4f6 100644 --- a/blob/uploads/large-files.mdx +++ b/blob/uploads/large-files.mdx @@ -31,7 +31,7 @@ Parts are not free. A single PUT is one round trip; a multipart upload is three The row that matters most is when the object comes into existence. Under the threshold the object is stored the moment the last byte lands, before your route has been told anything, so a throw out of `onUploadComplete` has to delete it again. Over the threshold nothing exists at the path until the upload is completed, so an upload that never gets there leaves parts rather than a file. -That is what an abandoned upload costs on each side of the line. See [Abandoned uploads](/blob/browser/abandoned-uploads) for the sweep. +That is what an abandoned upload costs on each side of the line. See [Abandoned uploads](/blob/uploads/abandoned-uploads) for the sweep. A single PUT cannot carry more than about 5 GiB, so past that size parts are used whatever `multipart` says. @@ -196,7 +196,7 @@ A part that goes 60 seconds with no progress and no response is treated as faile Calls to your own route get three attempts, except the first one, which is never retried because it runs `onBeforeUpload`. -When the budget runs out the record settles as `error`, carrying a `BlobError`. The codes are listed in [Errors](/blob/bucket/errors). +When the budget runs out the record settles as `error`, carrying a `BlobError`. The codes are listed in [Errors](/blob/reference/errors). `retry()` on a failed record runs the same upload again from the parts that landed. Nothing already uploaded is re-sent, and `done` is replaced with a fresh promise since the old one already rejected. @@ -239,19 +239,19 @@ Without `size` and without `maxBytes`, an unknown-length stream throws `length_r ## Next steps - + What a closed tab leaves behind on each side of the threshold, and how to sweep it. - + Routes, context, and the callbacks that run around a transfer. - + `uploadHooks`, `useUpload` and the record that carries progress. - + Size limits and content types, refused before a byte is signed. diff --git a/blob/uploads/upload-client.mdx b/blob/uploads/upload-client.mdx index 46ddf92c..9860e9cb 100644 --- a/blob/uploads/upload-client.mdx +++ b/blob/uploads/upload-client.mdx @@ -1,8 +1,8 @@ --- -title: "The Client" +title: "Upload Client" --- -`@upstash/blob/react` drives an [upload handler](/blob/browser/upload-handler) from the browser: `uploadHooks` binds the hooks to your handler's type, `useUpload` runs the upload and renders its progress. There is a plain function for apps without React, and `useServerUpload` for the routes where the bytes do pass through your app. +`@upstash/blob/react` drives an [upload handler](/blob/uploads/upload-handler) from the browser: `uploadHooks` binds the hooks to your handler's type, `useUpload` runs the upload and renders its progress. There is a plain function for apps without React, and `useServerUpload` for the routes where the bytes do pass through your app. --- @@ -51,7 +51,7 @@ const { start, uploads, upload, clear, accept, constraints } = useUpload("attach | `upload` | The newest record, or `null`. | | `clear(id?)` | Removes one record, or all of them. | | `accept` | The route's `contentTypes`, joined, for an ``. | -| `constraints` | What the route's [`GET`](/blob/browser/upload-handler#the-get-endpoint) served, its own numbers. Undefined until it answers. | +| `constraints` | What the route's [`GET`](/blob/uploads/upload-handler#the-get-endpoint) served, its own numbers. Undefined until it answers. | `start({ file })` returns one record, or `null` when the file is nullish, so an empty file picker is not an error. `start({ files })` takes a `File[]` or a `FileList` and returns an array. @@ -81,11 +81,11 @@ const { start, uploads, upload, clear, accept, constraints } = useUpload("attach | `error` | `BlobError` | On `error` only. | | `pause()` `resume()` `cancel()` `retry()` | `() => boolean` | Each answers whether it did anything. | -`pending` is the field to drive UI off rather than hand-rolling it from `status`. `percent` sits at 99 through `finishing`, because 100 has to mean stored rather than sent. [Large files](/blob/browser/large-files#progress-and-status) has the rest of the progress fields. +`pending` is the field to drive UI off rather than hand-rolling it from `status`. `percent` sits at 99 through `finishing`, because 100 has to mean stored rather than sent. [Large files](/blob/uploads/large-files#progress-and-status) has the rest of the progress fields. -`blob.data` is typed from that route's [`onUploadComplete`](/blob/browser/upload-handler#onuploadcomplete). Fields a status does not carry are `undefined` rather than absent, so `upload?.blob?.url` and `upload?.error?.message` read straight off the record with no narrowing. +`blob.data` is typed from that route's [`onUploadComplete`](/blob/uploads/upload-handler#onuploadcomplete). Fields a status does not carry are `undefined` rather than absent, so `upload?.blob?.url` and `upload?.error?.message` read straight off the record with no narrowing. -`canPause` is false for a single PUT, which is every file under the route's `multipart` threshold. `retry()` works only from `error`, and resumes from the parts that already landed. [Large files](/blob/browser/large-files) has the whole of pause, resume and multipart. +`canPause` is false for a single PUT, which is every file under the route's `multipart` threshold. `retry()` works only from `error`, and resumes from the parts that already landed. [Large files](/blob/uploads/large-files) has the whole of pause, resume and multipart. Three files are in flight by default and the rest queue. `clear(id?)` removes records from the list; a cleared upload that is still running finishes anyway, it is just no longer rendered. Unmounting the component does not cancel anything either. @@ -165,10 +165,5 @@ upload?.status === "done" && upload.response.url // typed from the generic Its options are `headers`, `concurrency` and `field`, the multipart field name `start({ file })` sends the file under, `'file'` by default and it has to match what your route reads. `start({ body })` sends a `File`, `Blob` or `FormData` as the raw body instead. The record has `cancel()` only, and statuses `queued`, `uploading`, `finishing`, `done`, `canceled` and `error`: there is no `begin` or `end` to pause between. -A proxied upload is capped by your platform's request body limit rather than by `maxBytes`, and the refusal happens before your route runs. The SDK surfaces it as `too_large` with the platform numbers as the hint; they are listed in [Errors](/blob/bucket/errors#platform-body-limits). Anything larger belongs on the direct path, with an [upload handler](/blob/browser/upload-handler). +A proxied upload is capped by your platform's request body limit rather than by `maxBytes`, and the refusal happens before your route runs. The SDK surfaces it as `too_large` with the platform numbers as the hint; they are listed in [Errors](/blob/reference/errors#platform-body-limits). Anything larger belongs on the direct path, with an [upload handler](/blob/uploads/upload-handler). ---- - -## CORS - -The signed PUT is a cross-origin request from your page to storage, so the bucket's CORS policy has to allow it: the required shape is in [CORS](/blob/overall/quickstart#cors). A PUT that fails with no status and no bytes sent is almost always this. diff --git a/blob/uploads/upload-handler.mdx b/blob/uploads/upload-handler.mdx index b8fd568a..82dfb73e 100644 --- a/blob/uploads/upload-handler.mdx +++ b/blob/uploads/upload-handler.mdx @@ -68,12 +68,12 @@ export function Uploader() { } ``` -The client assumes the handler is mounted at `/api/upload`. That is the only default; `endpoint` on `uploadHooks` or on `useUpload` moves it. [The client](/blob/browser/client) has the hooks in full. +The client assumes the handler is mounted at `/api/upload`. That is the only default; `endpoint` on `uploadHooks` or on `useUpload` moves it. [Upload client](/blob/uploads/upload-client) has the hooks in full. New to Upstash Blob? Start at the [Quickstart](/blob/overall/quickstart). If the bytes have to pass through your app instead, write an ordinary route that calls `bucket.put` ([Writing](/blob/bucket/writing)) - and drive it with [`useServerUpload`](/blob/browser/client#useserverupload). + and drive it with [`useServerUpload`](/blob/uploads/upload-client#useserverupload). --- @@ -108,7 +108,7 @@ export const uploads = uploadHandler({ | `onError` | `(args) => BlobError \| Response \| void` | Sees every refusal. The one place to log. | | `routes` | `Record` | Mounts several routes at this one endpoint. | -Everything except `routes`, `endpoint` and `context` is a **default**. A route replaces the ones it names and inherits the rest, key by key, so a handler with five routes states the shared policy once. `constraints` merges one level deeper: a route's `constraints` replaces `maxBytes` and `contentTypes` individually, and `null` clears a key the handler set. See [Constraints](/blob/browser/constraints) for the grammar and what a wildcard expands to. +Everything except `routes`, `endpoint` and `context` is a **default**. A route replaces the ones it names and inherits the rest, key by key, so a handler with five routes states the shared policy once. `constraints` merges one level deeper: a route's `constraints` replaces `maxBytes` and `contentTypes` individually, and `null` clears a key the handler set. See [Constraints](/blob/uploads/constraints) for the grammar and what a wildcard expands to. `onBeforeUpload` is the one callback that has to exist. A route with none of its own, mounted in a handler with none either, is a build error naming the route. @@ -160,7 +160,7 @@ What it returns: | `constraints` | `{ contentTypes?, maxBytes? }` | Narrows this one upload's limits. | | `state` | `TState` | Carried to `onUploadComplete` and `onError`. Only [`uploadRoute()`](#uploadroute) can carry one. | -`file` is the browser's own claim, so `file.type` is the type the object is stored and served as. What the bytes really are is checked separately; see [Constraints](/blob/browser/constraints). +`file` is the browser's own claim, so `file.type` is the type the object is stored and served as. What the bytes really are is checked separately; see [Constraints](/blob/uploads/constraints). ### Paths @@ -213,7 +213,7 @@ onBeforeUpload: async ({ request, file }) => { } ``` -Every `BlobError` reaches the browser with its `code` intact, so a hook can switch on `error.code` instead of reading status numbers. The codes are listed in [Errors](/blob/bucket/errors). +Every `BlobError` reaches the browser with its `code` intact, so a hook can switch on `error.code` instead of reading status numbers. The codes are listed in [Errors](/blob/reference/errors). The browser never retries `begin`, so a callback that writes a row is never run twice for one file. @@ -233,7 +233,7 @@ onUploadComplete: async ({ uploadId, path, url, size, contentType, metadata, sta | Argument | Type | | | --- | --- | --- | | `path` | `string` | Where the object is stored. | -| `url` | `string \| undefined` | The public URL. Undefined on a private bucket; see [How signing works](/blob/overall/signing). | +| `url` | `string \| undefined` | The public URL. Undefined on a private bucket; see [How signing works](/blob/reference/signing). | | `versionedUrl` | `string \| undefined` | `url` with the etag on the query. For a stable path that gets overwritten. | | `size` | `number` | Bytes actually stored, verified against what the browser declared. | | `etag` | `string` | The stored object's etag. | @@ -280,7 +280,7 @@ onUploadComplete: async ({ uploadId, path, url, metadata }) => { } ``` -Writing the row in `onBeforeUpload` as pending and flipping it to ready here, last, is the pattern that survives a browser that dies mid-upload. [Abandoned uploads](/blob/browser/abandoned-uploads) covers it, and the cron that sweeps the rest. +Writing the row in `onBeforeUpload` as pending and flipping it to ready here, last, is the pattern that survives a browser that dies mid-upload. [Abandoned uploads](/blob/uploads/abandoned-uploads) covers it, and the cron that sweeps the rest. --- @@ -443,5 +443,5 @@ Everything else on the route works as it does on a plain object: `bucket`, `cons ## The GET endpoint -`GET` on the route serves its constraints as JSON. That is what fills [`accept` and `constraints`](/blob/browser/client#useupload) on the hook, and it lets the hook refuse an oversized file locally, as an error record, before any request leaves the browser. The check in the browser is a courtesy: the server is authoritative and enforces the same limits at `begin`. The document, its caching and what the hook does with it are in [Constraints](/blob/browser/constraints#in-the-browser). +`GET` on the route serves its constraints as JSON. That is what fills [`accept` and `constraints`](/blob/uploads/upload-client#useupload) on the hook, and it lets the hook refuse an oversized file locally, as an error record, before any request leaves the browser. The check in the browser is a courtesy: the server is authoritative and enforces the same limits at `begin`. The document, its caching and what the hook does with it are in [Constraints](/blob/uploads/constraints#in-the-browser). diff --git a/docs.json b/docs.json index 834ef567..1862d1c9 100644 --- a/docs.json +++ b/docs.json @@ -2098,34 +2098,39 @@ "group": "Introduction", "pages": [ "blob/overall/quickstart", - "blob/overall/pricing", - "blob/overall/signing" + "blob/overall/pricing" ] }, { - "group": "Browser Usage", + "group": "Bucket API", "pages": [ - "blob/browser/upload-handler", - "blob/browser/client", - "blob/browser/constraints", - "blob/browser/large-files", - "blob/browser/abandoned-uploads" + "blob/bucket/writing", + "blob/bucket/reading", + "blob/bucket/deleting", + "blob/bucket/caching" ] }, { - "group": "Bucket", + "group": "Direct Uploads", "pages": [ - "blob/bucket/writing", - "blob/bucket/reading", - "blob/bucket/deleting", - "blob/bucket/caching", - "blob/bucket/errors" + "blob/uploads/upload-handler", + "blob/uploads/upload-client", + "blob/uploads/constraints", + "blob/uploads/large-files", + "blob/uploads/abandoned-uploads" + ] + }, + { + "group": "Recipes", + "pages": [ + "blob/recipes/overview" ] }, { - "group": "Formulas", + "group": "Reference", "pages": [ - "blob/formulas/overview" + "blob/reference/errors", + "blob/reference/signing" ] } ] From 0b025516badd776288d59cac3980639536ca5345 Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Wed, 2 Sep 2026 01:02:33 +0200 Subject: [PATCH 09/41] docs(blob): replace recipes overview with three use-case pages --- blob/overall/quickstart.mdx | 4 +- blob/recipes/attachments.mdx | 214 ++++++++++++++++++++++++ blob/recipes/avatars.mdx | 158 ++++++++++++++++++ blob/recipes/overview.mdx | 257 ----------------------------- blob/recipes/private-documents.mdx | 194 ++++++++++++++++++++++ docs.json | 4 +- 6 files changed, 571 insertions(+), 260 deletions(-) create mode 100644 blob/recipes/attachments.mdx create mode 100644 blob/recipes/avatars.mdx delete mode 100644 blob/recipes/overview.mdx create mode 100644 blob/recipes/private-documents.mdx diff --git a/blob/overall/quickstart.mdx b/blob/overall/quickstart.mdx index 2f7f795b..395f73cb 100644 --- a/blob/overall/quickstart.mdx +++ b/blob/overall/quickstart.mdx @@ -186,8 +186,8 @@ A file under 16 MB goes up as one presigned PUT. Anything larger is cut into par Read and upload links for anything outside the browser flow. - - Whole features wired end to end: avatars, chat attachments, and the files they take. + + Profile pictures, file attachments and private documents, wired end to end. diff --git a/blob/recipes/attachments.mdx b/blob/recipes/attachments.mdx new file mode 100644 index 00000000..bdf45b04 --- /dev/null +++ b/blob/recipes/attachments.mdx @@ -0,0 +1,214 @@ +--- +title: "File Attachments" +--- + +Files attached to a chat message, a comment, or a support ticket. Many files per thread, uploaded by many users, kept for as long as the thread is. + +Three choices make that work: + +- **A unique path per file.** `uniquePath` adds a random suffix, so two people attaching `photo.png` get two objects. +- **A row per attachment.** The bucket cannot answer "what is attached to this thread". Your database can, and the bucket holds the bytes. +- **A validated `input`.** The browser sends the thread id, and your route checks it, and the user's membership, before anything is signed. + +If you have not set up a bucket yet, start with the [Quickstart](/blob/overall/quickstart). + +--- + +## The handler + +```ts lib/uploads.ts +import "server-only" +import * as z from "zod" +import { BlobError, uniquePath, uploadHandler, uploadRoute } from "@upstash/blob" +import { getUser } from "./auth" +import { db } from "./db" + +const attachment = uploadRoute()({ + constraints: { maxBytes: "25mb" }, + multipart: true, // nothing is stored until the upload completes, see Cleanup below + input: z.object({ threadId: z.string() }), + + onBeforeUpload: async ({ request, input, file }) => { + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") + + const member = await db.threadMembers.exists({ threadId: input.threadId, userId: user.id }) + if (!member) throw new BlobError("forbidden") + + return { + path: uniquePath`threads/${input.threadId}/${file.name}`, + state: { threadId: input.threadId, userId: user.id }, + } + }, + + onUploadComplete: async ({ uploadId, state, path, url, size, contentType, file }) => { + // The browser retries this request on a flaky network, so upsert on uploadId. + await db.attachments.upsert({ + id: uploadId, + threadId: state.threadId, + userId: state.userId, + name: file.name, + path, + url, + size, + contentType, + }) + return { attachmentId: uploadId } + }, +}) + +export const uploads = uploadHandler({ routes: { attachment } }) +``` + +`uploadRoute()` is what allows a schema for `input` and a typed `state`. `state` travels through the browser in a signed token, so put ids in it, never secrets. `file.name` is the original filename, and this callback is the only place it exists, so store it if you want to show it later. + +```ts app/api/upload/route.ts +import { uploads } from "@/lib/uploads" + +export const { GET, POST } = uploads +``` + +```ts lib/upload-hooks.ts +"use client" + +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "./uploads" + +export const { useUpload } = uploadHooks() +``` + +--- + +## The input + +```tsx components/attachment-input.tsx +"use client" + +import { useUpload } from "@/lib/upload-hooks" + +export function AttachmentInput({ threadId }: { threadId: string }) { + const { start, uploads } = useUpload("attachment") + + return ( + <> + start({ files: e.target.files, input: { threadId } })} + /> + +
    + {uploads.map((u) => ( +
  • + {u.file.name} + {u.pending && } + {u.status === "done" && attached} + {u.status === "error" && {u.error.message}} + {u.pending && } +
  • + ))} +
+ + ) +} +``` + +`input` is required by the hook because the route declared a schema, and a missing `threadId` fails to compile. Three files upload at a time by default and the rest queue. + +--- + +## Showing attachments + +Query your table, not the bucket: + +```tsx components/attachment-list.tsx +import { db } from "@/lib/db" + +export async function AttachmentList({ threadId }: { threadId: string }) { + const attachments = await db.attachments.findMany({ threadId }) + + return ( +
    + {attachments.map((a) => ( +
  • + {a.name} ({Math.round(a.size / 1024)} KB) +
  • + ))} +
+ ) +} +``` + +`url` is the public URL, which is right for a public bucket. If attachments must not be linkable by anyone who has the URL, use a private bucket and sign each read. See [Private documents](/blob/recipes/private-documents). + +--- + +## Deleting + +Delete the object first, then the row. If the second step fails you are left with a harmless orphan rather than a link that 404s, and `del` is safe to run again. + +```ts app/actions.ts +"use server" + +import { Bucket } from "@upstash/blob" +import { getUser } from "@/lib/auth" +import { db } from "@/lib/db" + +const bucket = Bucket.fromEnv() + +export async function deleteAttachment(id: string) { + const user = await getUser() + const attachment = await db.attachments.find({ id, userId: user?.id }) + if (!attachment) throw new Error("not found") + + await bucket.del(attachment.path) + await db.attachments.delete(id) +} +``` + +Deleting a whole thread is one call: `bucket.del({ prefix: `threads/${threadId}/` })`. + +--- + +## Cleanup + +A user can close the tab halfway through an upload. With `multipart: true` on the route, nothing is stored until the upload completes, so a dead tab leaves only unfinished parts behind. One daily cron removes them: + +```ts app/api/cron/abort-stale-uploads/route.ts +import { Bucket } from "@upstash/blob" + +const bucket = Bucket.fromEnv() + +export async function GET() { + const aborted = await bucket.abortStaleMultipartUploads({ olderThan: "1d", prefix: "threads/" }) + return Response.json({ aborted: aborted.length }) +} +``` + +```json vercel.json +{ "crons": [{ "path": "/api/cron/abort-stale-uploads", "schedule": "0 4 * * *" }] } +``` + +`olderThan` must be longer than your slowest upload, so a paused upload is not aborted underneath the user. Without `multipart: true`, a file under 16 MB is stored the moment the last byte lands and your server cannot tell an abandoned one from a finished one. That case, and the pending-row pattern that handles it, is in [Abandoned uploads](/blob/uploads/abandoned-uploads). + +--- + +## Next steps + + + + `uploadRoute`, `input`, `state`, and multiple routes on one endpoint. + + + + Pause, resume, and what `multipart` changes. + + + + One path, a list, a prefix, and sweeping incomplete uploads. + + + + The same shape on a private bucket, with signed downloads. + + diff --git a/blob/recipes/avatars.mdx b/blob/recipes/avatars.mdx new file mode 100644 index 00000000..074ad5ab --- /dev/null +++ b/blob/recipes/avatars.mdx @@ -0,0 +1,158 @@ +--- +title: "Profile Pictures" +--- + +One picture per user. A new upload replaces the old one, and every page shows the new picture right away, even with a year-long cache on the image. + +Three choices make that work: + +- **A stable path.** `avatars/${user.id}` is overwritten on every upload, so there is never an old picture to delete. +- **A versioned URL.** `versionedUrl` is the public URL with the object's etag on the query, so new bytes are a new URL and `cache: 'immutable'` is safe. +- **Image-only limits.** `image/*` and a small `maxBytes`, enforced on your server before anything is signed. + +This is a direct browser upload: the bytes go from the browser to storage, and your server only authorizes it. If you have not set up a bucket yet, start with the [Quickstart](/blob/overall/quickstart). + +--- + +## The handler + +```ts lib/uploads.ts +import "server-only" +import { BlobError, uploadHandler } from "@upstash/blob" +import { getUser } from "./auth" +import { db } from "./db" + +export const uploads = uploadHandler({ + constraints: { contentTypes: ["image/*"], maxBytes: "5mb" }, + + onBeforeUpload: async ({ request }) => { + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") + + return { + path: `avatars/${user.id}`, + cache: "immutable", + metadata: { owner: user.id }, + } + }, + + onUploadComplete: async ({ metadata, versionedUrl }) => { + try { + await db.users.update({ id: metadata.owner, avatarUrl: versionedUrl }) + } catch (e) { + // A throw here deletes the picture that just landed. Keep it and log instead. + console.error("[avatar] could not save url", e) + } + return { avatarUrl: versionedUrl } + }, +}) +``` + +The path has no extension on purpose. The object is stored and served as the type the browser declared, and `contentTypes: ['image/*']` is what checks it. + +```ts app/api/upload/route.ts +import { uploads } from "@/lib/uploads" + +export const { GET, POST } = uploads +``` + +```ts lib/upload-hooks.ts +"use client" + +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "./uploads" + +export const { useUpload } = uploadHooks() +``` + +--- + +## The picker + +```tsx components/avatar-picker.tsx +"use client" + +import { useUpload } from "@/lib/upload-hooks" + +export function AvatarPicker({ src }: { src?: string }) { + const { start, upload, accept } = useUpload() + const current = upload?.status === "done" ? upload.blob.data.avatarUrl : src + + return ( + + ) +} +``` + +`accept` comes from the route's constraints, so the file dialog only offers images. `upload.blob.data.avatarUrl` is typed from what `onUploadComplete` returned. + +Everywhere else, render the URL from your own row: + +```tsx +{user.name} +``` + +--- + +## Why a versioned URL + +The path never changes, so the plain `url` never changes either. With `cache: 'immutable'` a browser or CDN that has seen it once would keep the old picture for a year. + +`versionedUrl` is `url` plus `?v=`. The etag changes with the bytes, so every new picture is a URL no cache has seen, and the old one is simply never requested again. You get a full-length cache and instant updates at the same time. + +If you must serve the bare `url`, for example because it is already printed somewhere, use `cache: 'revalidate'` instead. It costs a 304 check per read but is never stale. See [Caching](/blob/bucket/caching). + +--- + +## Removing a picture + +Delete the object and clear the row. `del` treats an already missing object as success, so this is safe to retry. + +```ts app/actions.ts +"use server" + +import { Bucket } from "@upstash/blob" +import { getUser } from "@/lib/auth" +import { db } from "@/lib/db" + +const bucket = Bucket.fromEnv() + +export async function removeAvatar() { + const user = await getUser() + if (!user) throw new Error("unauthorized") + + await bucket.del(`avatars/${user.id}`) + await db.users.update({ id: user.id, avatarUrl: null }) +} +``` + +--- + +## Next steps + + + + `immutable`, `revalidate`, and the versioned URL pattern in full. + + + + What `image/*` expands to, and why SVG is not in it. + + + + Everything the callbacks receive and return. + + + + Many files per thread, each with its own row. + + diff --git a/blob/recipes/overview.mdx b/blob/recipes/overview.mdx deleted file mode 100644 index b12fb753..00000000 --- a/blob/recipes/overview.mdx +++ /dev/null @@ -1,257 +0,0 @@ ---- -title: "Recipes" ---- - -The other pages describe one option at a time: what `cache` accepts, what `constraints` refuse, what `onUploadComplete` is handed. A recipe is the other direction. It is one real feature wired end to end, with every file it takes, and with the choices already made and explained. Copy one, rename the paths, and it works. - ---- - -## Pick your shape - -Almost every decision in an upload feature is the same five: where the object goes, what it is cached as, what the route accepts, whether it goes up in parts, and who cleans up after a browser that never came back. These are the answers for the cases that come up most. - -| Feature | Path | Cache | Constraints | Multipart | Notes | -| ------- | ---- | ----- | ----------- | --------- | ----- | -| Avatar | `avatars/${user.id}.png`, stable | [`'immutable'`](/blob/bucket/caching) and serve `versionedUrl`, or `'revalidate'` if you link the bare `url` | [`image/*`](/blob/uploads/constraints), `maxBytes: '5mb'` | default, a 5 MB file is always one PUT | Overwriting is the intent, so there is no orphan and nothing to sweep | -| Chat or issue attachment | [`uniquePath`](/blob/bucket/writing#uniquepath) under `threads/${threadId}/` | default, `public, max-age=3600` | `maxBytes: '25mb'`, no type list | default, or `true` to skip the sweep | [Pending row plus a cron](/blob/uploads/abandoned-uploads); `uploadId` is the idempotency key | -| User document library | `uniquePath` under `docs/${user.id}/` | `'immutable'`, the path is already unique | `['application/pdf']`, `maxBytes: '100mb'` | default | [`bucket.list({ prefix })`](/blob/bucket/reading) is the listing, your rows are the metadata | -| Large video upload | `uniquePath` under `videos/${user.id}/` | `'immutable'` | [`video/*`](/blob/uploads/constraints), `maxBytes: '5gb'` | [`multipart: true`](/blob/uploads/large-files) | Only parts can pause, resume and retry; a closed tab leaves parts for `abortStaleMultipartUploads` | -| Private report or invoice | `invoices/${invoice.id}.pdf`, stable | `'no-store'` | written by your server, so [`bucket.put`](/blob/bucket/writing) rather than a route | default | `visibility: 'private'` drops `url`, and reads go through [`signedReadUrl`](/blob/bucket/reading) | - -Two rules run underneath the whole table. Use `uniquePath` unless overwriting is the intent. And write the row that says an upload is in flight before the bytes are: under the multipart threshold the object exists the moment the last byte lands, whether or not any callback ever accepted it. Both are argued out in [Abandoned uploads](/blob/uploads/abandoned-uploads). - ---- - -## Avatar upload - -One object per user, at a path derived from the user id, served through a URL that changes with the bytes. - -```ts lib/uploads.ts -import "server-only" -import { BlobError, uploadHandler } from "@upstash/blob" -import { getUser } from "./auth" -import { db } from "./db" - -export const uploads = uploadHandler({ - constraints: { contentTypes: ["image/*"], maxBytes: "5mb" }, - - onBeforeUpload: async ({ request, file }) => { - const user = await getUser(request) - if (!user) throw new BlobError("unauthorized") // the 401, and nothing is signed - - return { - path: `avatars/${user.id}.png`, - cache: "immutable", - metadata: { owner: user.id }, - } - }, - - onUploadComplete: async ({ metadata, path, versionedUrl }) => { - try { - await db.users.update({ id: metadata.owner, avatarUrl: versionedUrl }) - } catch (e) { - // A throw here deletes the object that just landed. The bytes are fine and the path is - // derivable, so a stale row is the cheaper failure. - console.error("[avatar] could not record", path, e) - } - return { avatarUrl: versionedUrl } - }, -}) -``` - -The `.png` in the path is cosmetic. The object is stored and served as the `Content-Type` the browser declared, and `contentTypes: ['image/*']` is what checks that. - -```ts app/api/upload/route.ts -import { uploads } from "@/lib/uploads" - -export const { GET, POST } = uploads -``` - -```ts lib/upload-hooks.ts -"use client" - -import { uploadHooks } from "@upstash/blob/react" -import type { uploads } from "./uploads" - -export const { useUpload } = uploadHooks() -``` - -```tsx components/avatar-picker.tsx -"use client" - -import { useUpload } from "@/lib/upload-hooks" - -export function AvatarPicker({ src }: { src: string }) { - const { start, upload, accept } = useUpload() - const current = upload?.status === "done" ? (upload.blob.data.avatarUrl ?? src) : src - - return ( - - ) -} -``` - -`upload.blob.data` is typed from what `onUploadComplete` returned, so `avatarUrl` is checked at compile time rather than hoped for. - -### Why this shape - -A stable path is the one case where overwriting is the intent. There is exactly one object per user, the second upload replaces the first, and that removes both jobs the unique-path shape has to do: no pending row to reconcile, because a completion that never arrives leaves the previous avatar in place rather than an orphan, and no old avatars to sweep, because there are never two. - -`cache: 'immutable'` on a path that gets overwritten would normally be the wrong answer, and it is safe here only because nothing links the bare `url`. `versionedUrl` is `url` with `?v=` appended, so new bytes are a new URL and the old one is never asked for again. The alternative is `'revalidate'`, which keeps one URL and pays a 304 per read. - -The `try`/`catch` is not decoration: any throw out of `onUploadComplete` [deletes the object that just landed](/blob/uploads/upload-handler#onuploadcomplete). Swallowing the database error costs a stale `avatarUrl` instead, and that is recoverable, because the path is `avatars/${user.id}.png` and `bucket.info(path)` gives the etag the URL is built from. - ---- - -## Chat attachments - -Many files per thread, none of them overwriting each other, with a row written before the upload and cleared after it. - -```ts lib/uploads.ts -import "server-only" -import * as z from "zod" -import { BlobError, uniquePath, uploadHandler, uploadRoute } from "@upstash/blob" -import { requireUser, type Session } from "./auth" -import { db } from "./db" - -export const uploads = uploadHandler({ - // Written above `routes`: it runs once per POST, before any body is read, and its value is `ctx`. - context: (request: Request) => requireUser(request), - - routes: { - attachment: uploadRoute()({ - constraints: { maxBytes: "25mb" }, - input: z.object({ threadId: z.string().uuid() }), - - onBeforeUpload: async ({ ctx, input, file }) => { - const thread = await db.threads.find({ id: input.threadId, memberId: ctx.id }) - if (!thread) throw new BlobError("forbidden") - - const path = uniquePath`threads/${input.threadId}/${file.name}` - const row = await db.pendingUploads.insert({ - threadId: input.threadId, - userId: ctx.id, - path, - }) - - return { - path, - metadata: { row: row.id }, - state: { rowId: row.id, threadId: input.threadId }, - } - }, - - onUploadComplete: async ({ state, uploadId, path, url, size, contentType, file }) => { - // uploadId is stable across the browser's retries of the completion request, so - // at-least-once delivery writes one row. - await db.attachments.upsert({ - uploadId, - threadId: state.threadId, - path, - url, - size, - contentType, - name: file.name, - }) - - // Last, always. "Row still pending" is what the sweep below reads as "never accepted". - await db.pendingUploads.delete(state.rowId) - - return { attachmentId: uploadId } - }, - }), - }, -}) -``` - -The schema is validated before `onBeforeUpload` runs, so a thread id that is not a UUID is a `400` and nothing is signed, no row is inserted, and no presigned URL exists. - -```ts app/api/upload/route.ts -import { uploads } from "@/lib/uploads" - -export const { GET, POST } = uploads -``` - -```ts app/api/cron/sweep-uploads/route.ts -import { BlobError, Bucket } from "@upstash/blob" -import { db } from "@/lib/db" - -const bucket = Bucket.fromEnv() - -export async function GET() { - const stale = await db.pendingUploads.findOlderThan({ age: "1h", limit: 500 }) - - for (const row of stale) { - try { - // metadata comes back unstripped, so this confirms the object is the one the row reserved. - const info = await bucket.info(row.path) - if (info.metadata.row === row.id) await bucket.del(row.path) - } catch (e) { - // info() throws rather than returning undefined. Already gone is the good case. - if (!BlobError.is(e) || e.code !== "not_found") throw e - } - await db.pendingUploads.delete(row.id) - } - - // Parts a tab left behind over the multipart threshold, which list() cannot see. - const aborted = await bucket.abortStaleMultipartUploads({ olderThan: "1d", prefix: "threads/" }) - - return Response.json({ swept: stale.length, aborted: aborted.length }) -} -``` - -```tsx components/attachment-input.tsx -"use client" - -import { useUpload } from "@/lib/upload-hooks" - -export function AttachmentInput({ threadId }: { threadId: string }) { - const { start, uploads: files } = useUpload("attachment") - - return ( - <> - start({ files: e.target.files, input: { threadId } })} - /> - -
    - {files.map((file) => ( -
  • - {file.file.name} - {file.pending && } - {file.status === "done" && attached} - {file.status === "error" && {file.error.message}} -
  • - ))} -
- - ) -} -``` - -`input` is required by the hook because the route declared a schema, and its shape is the schema's, so a missing or misspelled `threadId` fails to compile rather than at `begin`. - -### Why this shape - -`uniquePath` sanitizes what you interpolate and appends a random suffix, so two people sending `photo.png` to one thread get two objects rather than a lost update. - -The pending row is what makes a closed tab recoverable: write it in `onBeforeUpload`, clear it last in `onUploadComplete`, and sweep what is still pending past a grace window. Why the row is the only thing that can tell an abandoned upload from a finished one is in [Abandoned uploads](/blob/uploads/abandoned-uploads). - -Because the sweep exists, a database error is allowed to escape `onUploadComplete` here, unlike in the avatar recipe. The throw deletes the object, the pending row survives, the cron's `not_found` branch clears it, and the user gets an error for an upload that genuinely did not land. - ---- - -## More recipes coming - -The other rows of the table above land here next, each as a full set of files. diff --git a/blob/recipes/private-documents.mdx b/blob/recipes/private-documents.mdx new file mode 100644 index 00000000..d8831211 --- /dev/null +++ b/blob/recipes/private-documents.mdx @@ -0,0 +1,194 @@ +--- +title: "Private Documents" +--- + +Invoices, contracts, medical records, anything a user may download and nobody else may guess. Some are generated by your server, some are uploaded by the user, and every read goes through an ownership check. + +Three choices make that work: + +- **A private bucket.** There is no public host, so an object cannot be fetched by URL at all. +- **A signed read URL per download.** Your route checks who is asking, then hands out a link that expires in minutes. +- **A row per document.** It holds the path, the display name, and the owner, which is what the download route checks. + +Create the bucket as **private** in the [Upstash Console](https://console.upstash.com). Everything below uses `Bucket.fromEnv()`, which reads `UPSTASH_BLOB_TOKEN`. + +--- + +## Files your server creates + +A generated PDF is already on your server, so write it with `put`: + +```ts lib/invoices.ts +import { Bucket } from "@upstash/blob" +import { db } from "./db" +import { renderInvoicePdf } from "./pdf" + +const bucket = Bucket.fromEnv() + +export async function storeInvoice(invoiceId: string) { + const invoice = await db.invoices.find({ id: invoiceId }) + const pdf = await renderInvoicePdf(invoice) + const path = `invoices/${invoice.id}.pdf` + + await bucket.put(path, pdf, { contentType: "application/pdf" }) + await db.documents.upsert({ + id: `invoice-${invoice.id}`, + ownerId: invoice.customerId, + name: `Invoice ${invoice.number}.pdf`, + path, + }) +} +``` + +A stable path is fine here: regenerating an invoice should replace the old one. `blob.url` is `undefined` on a private bucket, so there is nothing to store but the path. + +--- + +## Files the user uploads + +The same table, filled from a browser upload. The user picks a file, the bytes go straight to storage, and the row is written when the upload completes. + +```ts lib/uploads.ts +import "server-only" +import { BlobError, uniquePath, uploadHandler } from "@upstash/blob" +import { getUser } from "./auth" +import { db } from "./db" + +export const uploads = uploadHandler({ + constraints: { contentTypes: ["application/pdf"], maxBytes: "50mb" }, + + onBeforeUpload: async ({ request, file }) => { + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") + + return { + path: uniquePath`documents/${user.id}/${file.name}`, + metadata: { owner: user.id }, + } + }, + + onUploadComplete: async ({ uploadId, metadata, path, size, file }) => { + await db.documents.upsert({ + id: uploadId, + ownerId: metadata.owner, + name: file.name, + path, + size, + }) + return { documentId: uploadId } + }, +}) +``` + +```ts app/api/upload/route.ts +import { uploads } from "@/lib/uploads" + +export const { GET, POST } = uploads +``` + +```tsx components/document-upload.tsx +"use client" + +import { useUpload } from "@/lib/upload-hooks" + +export function DocumentUpload() { + const { start, upload, accept } = useUpload() + + return ( + <> + start({ file: e.target.files?.[0] })} /> + {upload?.pending && } + {upload?.status === "done" &&

Uploaded

} + {upload?.status === "error" &&

{upload.error.message}

} + + ) +} +``` + +`lib/upload-hooks.ts` is the same two lines as in the [Quickstart](/blob/overall/quickstart#upload-from-the-browser). The upload works exactly like it does on a public bucket. Only the reading side changes. + +--- + +## Downloading + +Never put a signed URL in a page. Link to a route of your own, and sign at click time: + +```ts app/api/documents/[id]/route.ts +import { Bucket } from "@upstash/blob" +import { getUser } from "@/lib/auth" +import { db } from "@/lib/db" + +const bucket = Bucket.fromEnv() + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const user = await getUser(request) + if (!user) return new Response("Unauthorized", { status: 401 }) + + const { id } = await params + const doc = await db.documents.find({ id, ownerId: user.id }) + if (!doc) return new Response("Not found", { status: 404 }) // not 403: do not confirm it exists + + const { url } = await bucket.signedReadUrl(doc.path, { + expiresIn: "5m", + downloadAs: doc.name, + }) + return Response.redirect(url, 302) +} +``` + +```tsx +{doc.name} +``` + +The link in the page never expires, because it points at your route. The signed URL lives for five minutes and is only ever handed to someone who passed the ownership check. `downloadAs` makes the browser save the file under its real name rather than the random one in the path. Leave it out to open the PDF inline instead. + +The same route is what to link from an email. A signed URL pasted into an email works for anyone it is forwarded to, until it expires. + +--- + +## Deleting + +Delete the object first, then the row, so a failure leaves an orphan rather than a broken link. `del` is safe to retry. + +```ts app/actions.ts +"use server" + +import { Bucket } from "@upstash/blob" +import { getUser } from "@/lib/auth" +import { db } from "@/lib/db" + +const bucket = Bucket.fromEnv() + +export async function deleteDocument(id: string) { + const user = await getUser() + const doc = await db.documents.find({ id, ownerId: user?.id }) + if (!doc) throw new Error("not found") + + await bucket.del(doc.path) + await db.documents.delete(id) +} +``` + +When a user deletes their account, every upload of theirs is under one prefix: `bucket.del({ prefix: `documents/${user.id}/` })`. + +--- + +## Next steps + + + + `signedReadUrl` options, `expiresAt`, and private buckets. + + + + What a signed URL can and cannot do, and how long it lives. + + + + What `Cache-Control` a private object is stored with, and when to use `no-store`. + + + + Many files per thread, on a public bucket. + + diff --git a/docs.json b/docs.json index 1862d1c9..8c62b0b1 100644 --- a/docs.json +++ b/docs.json @@ -2123,7 +2123,9 @@ { "group": "Recipes", "pages": [ - "blob/recipes/overview" + "blob/recipes/avatars", + "blob/recipes/attachments", + "blob/recipes/private-documents" ] }, { From 9fe107fb08adf87671beb001342d962ed4f36cdd Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Wed, 2 Sep 2026 01:05:01 +0200 Subject: [PATCH 10/41] docs(blob): fix nested backticks in recipe pages --- blob/overall/quickstart.mdx | 11 +++++++++-- blob/recipes/attachments.mdx | 6 +++++- blob/recipes/private-documents.mdx | 6 +++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/blob/overall/quickstart.mdx b/blob/overall/quickstart.mdx index 395f73cb..aea5007c 100644 --- a/blob/overall/quickstart.mdx +++ b/blob/overall/quickstart.mdx @@ -153,9 +153,16 @@ export default function Page() { -Uploads work at this point, but the handler does not yet check who is asking or write anything down. Both go in the same two callbacks: see [Upload handler](/blob/uploads/upload-handler). +That is the whole integration, and a fair amount comes with it: + +- **Big files just work.** Past 16 MB the SDK switches to multipart on its own, picks the part size, signs part URLs in batches and uploads four parts at a time, which is what makes pause, resume and per-part retry possible. See [Large files](/blob/uploads/large-files). +- **Flaky networks are retried for you.** Failed parts back off and go again, `Retry-After` is honoured, and an expired signature is refreshed against your route mid-upload without the user noticing. +- **The picker matches the server.** `accept` and `constraints` are served by the route's own `GET`, so an oversized file is refused before a single request goes out and the server still has the last word. See [Constraints](/blob/uploads/constraints). +- **Progress is already there.** `percent`, `status` and `pending` read the same whether the file went up as one PUT or 200 parts. +- **The types line up end to end.** Whatever `onUploadComplete` returns is what `upload.blob.data` is on the client, checked at compile time. +- **The bytes never touch your app.** They go browser to storage, so no function timeout or body limit sits in the middle. -A file under 16 MB goes up as one presigned PUT. Anything larger is cut into parts, which is what buys pause, resume and per-part retry. See [Large files](/blob/uploads/large-files). +Uploads work at this point, but the handler does not yet check who is asking or write anything down. Both go in the same two callbacks: see [Upload handler](/blob/uploads/upload-handler). --- diff --git a/blob/recipes/attachments.mdx b/blob/recipes/attachments.mdx index bdf45b04..08f855e2 100644 --- a/blob/recipes/attachments.mdx +++ b/blob/recipes/attachments.mdx @@ -166,7 +166,11 @@ export async function deleteAttachment(id: string) { } ``` -Deleting a whole thread is one call: `bucket.del({ prefix: `threads/${threadId}/` })`. +Deleting a whole thread is one call: + +```ts +await bucket.del({ prefix: `threads/${threadId}/` }) +``` --- diff --git a/blob/recipes/private-documents.mdx b/blob/recipes/private-documents.mdx index d8831211..c4a2cd20 100644 --- a/blob/recipes/private-documents.mdx +++ b/blob/recipes/private-documents.mdx @@ -169,7 +169,11 @@ export async function deleteDocument(id: string) { } ``` -When a user deletes their account, every upload of theirs is under one prefix: `bucket.del({ prefix: `documents/${user.id}/` })`. +When a user deletes their account, every upload of theirs is under one prefix: + +```ts +await bucket.del({ prefix: `documents/${user.id}/` }) +``` --- From 024d5a0c1b89cb17354056b7216e29199b11b815 Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Wed, 2 Sep 2026 01:28:58 +0200 Subject: [PATCH 11/41] docs: add Blob to landing product grid --- img/icons/blob.svg | 4 ++++ introduction.mdx | 2 +- llms.txt | 2 +- snippets/landing.jsx | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 img/icons/blob.svg diff --git a/img/icons/blob.svg b/img/icons/blob.svg new file mode 100644 index 00000000..15e3eab9 --- /dev/null +++ b/img/icons/blob.svg @@ -0,0 +1,4 @@ + + + + diff --git a/introduction.mdx b/introduction.mdx index 0b3efe51..110ac62c 100644 --- a/introduction.mdx +++ b/introduction.mdx @@ -1,6 +1,6 @@ --- title: Get Started -description: "Serverless data and messaging for developers: Redis, Vector, QStash, Workflow, Search, and Box, with SDKs, integrations, and a full-featured console." +description: "Serverless data and messaging for developers: Redis, Vector, QStash, Workflow, Search, Box, and Blob, with SDKs, integrations, and a full-featured console." mode: frame --- diff --git a/llms.txt b/llms.txt index 419f2364..8be4a7d7 100644 --- a/llms.txt +++ b/llms.txt @@ -169,7 +169,7 @@ - [upstash_redis_database](https://upstash.com/docs/devops/terraform/resources/upstash_redis_database.md): Create and manage Upstash Redis databases. - [upstash_team](https://upstash.com/docs/devops/terraform/resources/upstash_team.md): Create and manage teams on Upstash. - [Bg color codes](https://upstash.com/docs/img/bg-color-codes.md) -- [Get Started](https://upstash.com/docs/introduction.md): Serverless data and messaging for developers: Redis, Vector, QStash, Workflow, Search, and Box, with SDKs, integrations, and a full-featured console. +- [Get Started](https://upstash.com/docs/introduction.md): Serverless data and messaging for developers: Redis, Vector, QStash, Workflow, Search, Box, and Blob, with SDKs, integrations, and a full-featured console. - [Bulk Delete DLQ messages](https://upstash.com/docs/qstash/api-reference/dlq/bulk-delete-dlq-messages.md): Delete multiple messages from the DLQ - [Bulk Retry DLQ messages](https://upstash.com/docs/qstash/api-reference/dlq/bulk-retry-dlq-messages.md): Retry delivery of multiple messages from the DLQ - [Delete a DLQ message](https://upstash.com/docs/qstash/api-reference/dlq/delete-a-dlq-message.md): Manually remove a message from the DLQ diff --git a/snippets/landing.jsx b/snippets/landing.jsx index 937eb922..4ce3b1d1 100644 --- a/snippets/landing.jsx +++ b/snippets/landing.jsx @@ -28,6 +28,7 @@ export const ProductGrid = () => { { name: "Box", desc: "Secure sandboxes for AI agents and code.", href: "/box/overall/quickstart", icon: "box" }, { name: "Vector", desc: "Vector database for AI and LLM apps.", href: "/vector/overall/getstarted", icon: "vector" }, { name: "Search", desc: "Full-text and semantic search.", href: "/search/overall/getstarted", icon: "search" }, + { name: "Blob", desc: "S3-compatible object storage with browser uploads.", href: "/blob/overall/quickstart", icon: "blob" }, ]; return (
From f71549d5449ff20fd6907d0e5bfeb9c4f1804510 Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Wed, 2 Sep 2026 01:33:00 +0200 Subject: [PATCH 12/41] docs(blob): expand recipes to eight use-case pages --- blob/recipes/ai-images.mdx | 182 ++++++++++++++++++++++++ blob/recipes/attachments.mdx | 57 +++----- blob/recipes/avatars.mdx | 36 ++--- blob/recipes/exports.mdx | 219 +++++++++++++++++++++++++++++ blob/recipes/private-documents.mdx | 52 +++---- blob/recipes/product-images.mdx | 204 +++++++++++++++++++++++++++ blob/recipes/site-assets.mdx | 210 +++++++++++++++++++++++++++ blob/recipes/video.mdx | 198 ++++++++++++++++++++++++++ docs.json | 9 +- 9 files changed, 1076 insertions(+), 91 deletions(-) create mode 100644 blob/recipes/ai-images.mdx create mode 100644 blob/recipes/exports.mdx create mode 100644 blob/recipes/product-images.mdx create mode 100644 blob/recipes/site-assets.mdx create mode 100644 blob/recipes/video.mdx diff --git a/blob/recipes/ai-images.mdx b/blob/recipes/ai-images.mdx new file mode 100644 index 00000000..41d45379 --- /dev/null +++ b/blob/recipes/ai-images.mdx @@ -0,0 +1,182 @@ +--- +title: "AI-Generated Images" +--- + +An image or an audio clip that a model just produced, kept so the user can see it again tomorrow. Providers hand back a temporary URL or a response you can only read once, so the bytes have to land somewhere of your own before they expire. + +Three choices make that work: + +- **A server-side write.** The bytes are already on your server when the model answers, so they go up with `put`. There is no browser upload here. +- **A new path per generation.** `generations/${user.id}/${generationId}.png` is written once and never overwritten, so `cache: 'immutable'` is unconditionally safe. +- **A row per generation.** It holds the prompt, the path and the URL, and it is what the gallery reads. + +Create a public bucket in the [Upstash Console](https://console.upstash.com) if the gallery is public. Everything below uses `Bucket.fromEnv()`, which reads `UPSTASH_BLOB_TOKEN`. + +--- + +## Storing a generation + +The model's response body is a stream, and `put` can take it directly. A stream carries no length, so pass `maxBytes`: the body is buffered up to that cap and anything larger is refused rather than stored. See [Writing](/blob/bucket/writing#streams-and-unknown-lengths) for the `size` variant, which buffers nothing. + +```ts lib/generations.ts +import "server-only" +import { Bucket } from "@upstash/blob" +import { db } from "./db" + +const bucket = Bucket.fromEnv() + +// Any provider works. Whatever returns a Response with image bytes fits here. +async function generateImage(prompt: string) { + return fetch("https://api.your-model-provider.com/v1/images", { + method: "POST", + headers: { authorization: `Bearer ${process.env.MODEL_API_KEY}` }, + body: JSON.stringify({ prompt }), + }) +} + +export async function createGeneration(userId: string, prompt: string) { + const res = await generateImage(prompt) + if (!res.ok || !res.body) throw new Error("the model did not return an image") + + const generationId = crypto.randomUUID() + const path = `generations/${userId}/${generationId}.png` + + const blob = await bucket.put(path, res.body, { + contentType: "image/png", + maxBytes: "20mb", + cache: "immutable", + }) + + return db.generations.create({ + id: generationId, + userId, + prompt, + path, + url: blob.url, + createdAt: new Date(), + }) +} +``` + +`contentType` is declared because a stream does not carry one. Without it the object is stored as `application/octet-stream`, and a browser downloads it instead of rendering it. + +Plenty of providers answer with JSON pointing at a temporary URL instead. Fetch that URL and store what comes back. A `Blob` carries both its length and its type, so nothing has to be declared: + +```ts +const { imageUrl } = await res.json() +const image = await fetch(imageUrl) + +const blob = await bucket.put(path, await image.blob(), { cache: "immutable" }) +``` + +--- + +## The route + +```ts app/api/generate/route.ts +import { getUser } from "@/lib/auth" +import { createGeneration } from "@/lib/generations" + +export async function POST(request: Request) { + const user = await getUser(request) + if (!user) return new Response("Unauthorized", { status: 401 }) + + const { prompt } = await request.json() + const generation = await createGeneration(user.id, prompt) + + return Response.json({ id: generation.id, url: generation.url }) +} +``` + +Return your own URL, never the provider's. The provider's link is the one that stops working in an hour. + +--- + +## The gallery + +The bucket cannot answer "what has this user generated". Your table can, so read rows and render the URL stored on each one: + +```tsx app/gallery/page.tsx +import { getUser } from "@/lib/auth" +import { db } from "@/lib/db" + +export default async function GalleryPage() { + const user = await getUser() + const generations = await db.generations.findMany({ userId: user.id }) + + return ( +
    + {generations.map((g) => ( +
  • + {g.prompt} +

    {g.prompt}

    +
  • + ))} +
+ ) +} +``` + +Each object is stored `immutable` under a path that is never reused, so a browser or CDN that has seen one keeps it for a year and there is nothing to invalidate. + +If the gallery is not public, create the bucket as private instead. `blob.url` is `undefined` there, so the row keeps only the path and each read is signed at click time, after an ownership check. That shape is in [Private documents](/blob/recipes/private-documents). + +```ts +const { url } = await bucket.signedReadUrl(generation.path, { expiresIn: "5m" }) +``` + +--- + +## Deleting + +Delete the object first, then the row, so a failure leaves an orphan rather than a broken image. `del` treats an already missing object as success, so it is safe to retry. + +```ts app/actions.ts +"use server" + +import { Bucket } from "@upstash/blob" +import { getUser } from "@/lib/auth" +import { db } from "@/lib/db" + +const bucket = Bucket.fromEnv() + +export async function deleteGeneration(id: string) { + const user = await getUser() + const generation = await db.generations.find({ id, userId: user?.id }) + if (!generation) throw new Error("not found") + + await bucket.del(generation.path) + await db.generations.delete(id) +} +``` + +When a user deletes their account, their rows already name every object. Hand `del` the array: + +```ts +const generations = await db.generations.findMany({ userId }) + +await bucket.del(generations.map((g) => g.path)) +await db.generations.deleteMany({ userId }) +``` + +--- + +## Next steps + + + + Which bodies `put` accepts, and what a stream needs. + + + + `immutable`, `revalidate`, and what each one stores. + + + + One path, an array, and what a partial delete reports. + + + + The same shape on a private bucket, with signed reads. + + diff --git a/blob/recipes/attachments.mdx b/blob/recipes/attachments.mdx index 08f855e2..d47cacc2 100644 --- a/blob/recipes/attachments.mdx +++ b/blob/recipes/attachments.mdx @@ -2,12 +2,12 @@ title: "File Attachments" --- -Files attached to a chat message, a comment, or a support ticket. Many files per thread, uploaded by many users, kept for as long as the thread is. +Files attached to a chat message, a comment, or a support ticket: many files per thread, uploaded by many people, kept for as long as the thread is. Three choices make that work: - **A unique path per file.** `uniquePath` adds a random suffix, so two people attaching `photo.png` get two objects. -- **A row per attachment.** The bucket cannot answer "what is attached to this thread". Your database can, and the bucket holds the bytes. +- **A row per attachment.** Your table is the index. It answers "what is attached to this thread", and the bucket only holds the bytes. - **A validated `input`.** The browser sends the thread id, and your route checks it, and the user's membership, before anything is signed. If you have not set up a bucket yet, start with the [Quickstart](/blob/overall/quickstart). @@ -25,7 +25,7 @@ import { db } from "./db" const attachment = uploadRoute()({ constraints: { maxBytes: "25mb" }, - multipart: true, // nothing is stored until the upload completes, see Cleanup below + multipart: true, input: z.object({ threadId: z.string() }), onBeforeUpload: async ({ request, input, file }) => { @@ -48,10 +48,7 @@ const attachment = uploadRoute()({ threadId: state.threadId, userId: state.userId, name: file.name, - path, - url, - size, - contentType, + path, url, size, contentType, }) return { attachmentId: uploadId } }, @@ -60,7 +57,9 @@ const attachment = uploadRoute()({ export const uploads = uploadHandler({ routes: { attachment } }) ``` -`uploadRoute()` is what allows a schema for `input` and a typed `state`. `state` travels through the browser in a signed token, so put ids in it, never secrets. `file.name` is the original filename, and this callback is the only place it exists, so store it if you want to show it later. +`uploadRoute()` is the route form that takes an `input` schema and a typed `state`. Put ids in `state`, never secrets: it travels through the browser. `file.name` is the original filename, and this callback is the only place it exists, so store it if you want to show it later. + +`multipart: true` sends every file up in parts, which is what big files need, and nothing is stored at the path until the upload completes. See [Large files](/blob/uploads/large-files). ```ts app/api/upload/route.ts import { uploads } from "@/lib/uploads" @@ -79,7 +78,7 @@ export const { useUpload } = uploadHooks() --- -## The input +## The picker ```tsx components/attachment-input.tsx "use client" @@ -91,11 +90,7 @@ export function AttachmentInput({ threadId }: { threadId: string }) { return ( <> - start({ files: e.target.files, input: { threadId } })} - /> + start({ files: e.target.files, input: { threadId } })} />