upstash-blob-js
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
Chinese@upstash/blob SDK
@upstash/blob SDK
S3-compatible object storage. runs on your server; plus React hooks upload from the browser straight to storage so the bytes never pass through your app.
BucketuploadHandlerS3 兼容对象存储。 运行在你的服务器上; 加上 React hooks 可以从浏览器直接上传到存储,因此数据永远不会经过你的应用。
BucketuploadHandlerInstall & Setup
安装与设置
bash
npm install @upstash/blobCreate a bucket in the console and set . The token is a bearer secret for the whole bucket — keep it server side, never in or any bundler-inlined variable.
UPSTASH_BLOB_TOKENNEXT_PUBLIC_A bucket is public (every object has a URL) or private (no URL; reads go through ). That is a console setting, not a client option — the SDK learns it from the backend.
signedReadUrlts
import { Bucket } from "@upstash/blob"
export const bucket = Bucket.fromEnv() // reads UPSTASH_BLOB_TOKEN
Bucket.fromEnv({ cache: "immutable" }) // default variable, plus options
Bucket.fromEnv("MEDIA_TOKEN", { cache: "immutable" }) // another variable, plus options
new Bucket({ token: env.UPSTASH_BLOB_TOKEN }) // Workers: no process.envbash
npm install @upstash/blob在控制台中创建一个存储桶,并设置 。该 token 是整个存储桶的 bearer 密钥——请保存在服务器端,绝不要放在 或任何打包器内联变量中。
UPSTASH_BLOB_TOKENNEXT_PUBLIC_存储桶分为 public(每个对象都有一个 URL)或 private(没有 URL;读取通过 )。这是一个控制台设置,不是客户端选项——SDK 从后端获取该信息。
signedReadUrlts
import { Bucket } from "@upstash/blob"
export const bucket = Bucket.fromEnv() // reads UPSTASH_BLOB_TOKEN
Bucket.fromEnv({ cache: "immutable" }) // default variable, plus options
Bucket.fromEnv("MEDIA_TOKEN", { cache: "immutable" }) // another variable, plus options
new Bucket({ token: env.UPSTASH_BLOB_TOKEN }) // Workers: no process.envWriting
写入
ts
const blob = await bucket.put("reports/q3.pdf", pdf, { contentType: "application/pdf" })
blob.url // public URL, undefined on a private bucket
blob.versionedUrl // url + ?v=<etag>, changes whenever the bytes do
blob.etag // what ifUnchanged takesBodies: , /, , typed array, , . A stream carries no length — pass (exact, streams through) or (buffers up to the cap), or throws .
RequestBlobFileArrayBufferstringReadableStreamsizemaxSizeputlength_required| Option | Default | What it does |
|---|---|---|
| the body's, else | What the object is stored as |
| any | Allow list, e.g. |
| none | Refuse a bigger body with |
| bucket default | |
| none | |
| | |
| none | An etag; fails with |
| | Threshold for going up in parts; |
Sizes are decimal: is 20,000,000 bytes. throws.
'20mb''5mib'ts
import { uniquePath } from "@upstash/blob"
uniquePath`${user.id}/${file.name}` // 'u7/holiday-pic-3xK9mBqR.png'Use for any value you don't control. Each becomes one slugged filename that can never add a directory, and the finished path gets a random suffix — so two uploads of never collide. The literal parts of the template are passed through as written, so keep and out of them yourself: a path with those segments is refused later, by the call that uses it, with a rather than a .
uniquePath${}photo.png...TypeErrorBlobErrorbucket.copy(from, to, { contentType, cache, metadata })bucket.move(from, to, options)bucket.updateJson(path, fn, { maxAttempts: 6 })ts
const blob = await bucket.put("reports/q3.pdf", pdf, { contentType: "application/pdf" })
blob.url // public URL, undefined on a private bucket
blob.versionedUrl // url + ?v=<etag>, changes whenever the bytes do
blob.etag // what ifUnchanged takes请求体:、/、、类型化数组、、。流不携带长度——需要传入 (精确值,流式传输)或 (缓冲至上限),否则 会抛出 。
RequestBlobFileArrayBufferstringReadableStreamsizemaxSizeputlength_required| 选项 | 默认值 | 作用 |
|---|---|---|
| 请求体自带的类型,否则为 | 对象存储时的类型 |
| 任意 | 允许列表,例如 |
| 无 | 若请求体更大则拒绝,返回 |
| 存储桶默认值 | 随对象存储的 |
| 无 | |
| | 设为 |
| 无 | 一个 etag;如果已更改则失败( |
| | 切换为分段上传的阈值; |
大小单位是十进制: 是 20,000,000 字节。 会抛出异常。
'20mb''5mib'ts
import { uniquePath } from "@upstash/blob"
uniquePath`${user.id}/${file.name}` // 'u7/holiday-pic-3xK9mBqR.png'对任何你不控制的值,请使用 。每个 都会变成一个 slug 化的文件名,绝不可能添加目录,最终路径会带有一个随机后缀——因此两次上传 永远不会冲突。模板中的字面部分会原样传递给路径,所以你需要自己避免在其中使用 和 :包含这些段的路径会在后续使用该路径的调用中被拒绝,抛出的是 而不是 。
uniquePath${}photo.png...TypeErrorBlobErrorbucket.copy(from, to, { contentType, cache, metadata })bucket.move(from, to, options)bucket.updateJson(path, fn, { maxAttempts: 6 })Reading
读取
ts
const res = await bucket.get("reports/q3.pdf") // record + body: ReadableStream
const info = await bucket.info("reports/q3.pdf") // same record, no bytes (HEAD)
await bucket.exists("avatars/u7.png") // boolean instead of a throw
const page = await bucket.list({ prefix: "avatars/", limit: 1000 })getinfonot_foundts
await new Response((await bucket.get("notes/1.md")).body).text()listpage.cursorcontentTypemetadataprefixts
const { url, expiresAt } = await bucket.signedReadUrl("private/report.pdf", {
expiresIn: "2m",
downloadAs: "Report Q3.pdf", // save under this name instead of rendering inline
})Cache the link until , never a deadline you compute — a link cannot outlive the credential that signed it, so you may get less than you asked for. returns the public URL, on a private bucket.
expiresAtawait bucket.publicUrl(path)undefinedts
const res = await bucket.get("reports/q3.pdf") // record + body: ReadableStream
const info = await bucket.info("reports/q3.pdf") // same record, no bytes (HEAD)
await bucket.exists("avatars/u7.png") // boolean instead of a throw
const page = await bucket.list({ prefix: "avatars/", limit: 1000 })getinfonot_foundts
await new Response((await bucket.get("notes/1.md")).body).text()listpage.cursorcontentTypemetadataprefixts
const { url, expiresAt } = await bucket.signedReadUrl("private/report.pdf", {
expiresIn: "2m",
downloadAs: "Report Q3.pdf", // save under this name instead of rendering inline
})请将链接缓存到 ,绝不要使用你自己计算的过期时间——链接的寿命不能超过签名它的凭证,因此你得到的有效期可能比你要求的更短。 返回公共 URL,在私有存储桶上返回 。
expiresAtawait bucket.publicUrl(path)undefinedDeleting
删除
ts
await bucket.del("avatars/me.png") // one path
await bucket.del(["a.png", "b.png"]) // an array, batched by 1000
await bucket.del({ prefix: "tmp/" }) // everything under a prefixAlready-gone counts as success, so deletes are safe to retry. An array or prefix delete where objects survive throws with them in . is refused unless you pass .
partial_deletee.faileddel({ prefix: '' })all: truets
await bucket.del("avatars/me.png") // one path
await bucket.del(["a.png", "b.png"]) // an array, batched by 1000
await bucket.del({ prefix: "tmp/" }) // everything under a prefix对象已不存在也算成功,因此删除操作可以安全重试。如果数组或前缀删除后仍有对象存活,会抛出 ,并在 中列出它们。除非传入 ,否则 会被拒绝。
partial_deletee.failedall: truedel({ prefix: '' })Browser uploads
浏览器上传
The handler authorizes and records; the bytes go browser → storage, so platform request body caps don't apply. Supply your application's and implementations below.
getUserdb.files.upsertts
// lib/uploads.ts
import "server-only"
import { BlobError, uniquePath, uploadHandler } from "@upstash/blob"
import { getUser } from "@/lib/auth"
import { db } from "@/lib/db"
export const uploads = uploadHandler({
constraints: { maxSize: "20mb", contentTypes: ["image/*", "application/pdf"] },
onBeforeUpload: async ({ request, file }) => {
const user = await getUser(request)
if (!user) throw new BlobError("unauthorized") // nothing is signed
return { path: uniquePath`${user.id}/${file.name}`, metadata: { owner: user.id } }
},
onUploadComplete: async ({ uploadId, path, url, metadata }) => {
if (!metadata.owner) throw new BlobError("unauthorized")
await db.files.upsert({ id: uploadId, owner: metadata.owner, path, url })
return { path } // becomes upload.blob.data
},
})ts
// app/api/upload/route.ts
import { uploads } from "@/lib/uploads"
export const { GET, POST } = uploadsts
// lib/upload-hooks.ts
"use client"
import { uploadHooks } from "@upstash/blob/react"
import type { uploads } from "./uploads"
export const { useUpload } = uploadHooks<typeof uploads>()tsx
"use client"
import { useUpload } from "@/lib/upload-hooks"
export function UploadForm() {
const { start, upload, accept } = useUpload()
return <>
<input type="file" accept={accept} onChange={(e) => start({ file: e.target.files?.[0] })} />
{upload?.pending && <progress value={upload.percent} max={100} />}
{upload?.status === "done" && <a href={upload.blob.url}>{upload.blob.data.path}</a>}
{upload?.status === "error" && <p>{upload.error.message}</p>}
</>
}GETacceptuploadHooks<typeof uploads>()import typeThis example assumes a public bucket. On a private one is , so store instead and hand the client a when it needs to read.
urlundefinedpathbucket.signedReadUrl(path)Two rules that bite:
- can run more than once. The browser retries it, so upsert on
onUploadCompleterather than inserting.uploadId - A throw out of deletes the object. That is right for a refusal and wrong for a transient database error — catch your own storage errors.
onUploadComplete
Files over 16 MB go up in parts, which is what gives , , per-part retry, and resume-after-reload when the user picks the same file again. caps at 99 until is ; drive UI off .
pause()resume()percentstatusdoneupload.pendingUse on the handler to make every upload multipart. Then a closed tab leaves incomplete parts rather than a stored object nobody recorded, and one cron cleans up:
multipart: truets
await bucket.abortStaleMultipartUploads({ olderThan: "1d" })For routes where the bytes must pass through your app, write an ordinary route calling and drive it with from .
bucket.putuseServerUpload@upstash/blob/react该 handler 负责授权和记录;数据直接从浏览器 → 存储,因此平台请求体大小限制不适用。请在下面提供你的应用的 和 实现。
getUserdb.files.upsertts
// lib/uploads.ts
import "server-only"
import { BlobError, uniquePath, uploadHandler } from "@upstash/blob"
import { getUser } from "@/lib/auth"
import { db } from "@/lib/db"
export const uploads = uploadHandler({
constraints: { maxSize: "20mb", contentTypes: ["image/*", "application/pdf"] },
onBeforeUpload: async ({ request, file }) => {
const user = await getUser(request)
if (!user) throw new BlobError("unauthorized") // nothing is signed
return { path: uniquePath`${user.id}/${file.name}`, metadata: { owner: user.id } }
},
onUploadComplete: async ({ uploadId, path, url, metadata }) => {
if (!metadata.owner) throw new BlobError("unauthorized")
await db.files.upsert({ id: uploadId, owner: metadata.owner, path, url })
return { path } // becomes upload.blob.data
},
})ts
// app/api/upload/route.ts
import { uploads } from "@/lib/uploads"
export const { GET, POST } = uploadsts
// lib/upload-hooks.ts
"use client"
import { uploadHooks } from "@upstash/blob/react"
import type { uploads } from "./uploads"
export const { useUpload } = uploadHooks<typeof uploads>()tsx
"use client"
import { useUpload } from "@/lib/upload-hooks"
export function UploadForm() {
const { start, upload, accept } = useUpload()
return <>
<input type="file" accept={accept} onChange={(e) => start({ file: e.target.files?.[0] })} />
{upload?.pending && <progress value={upload.percent} max={100} />}
{upload?.status === "done" && <a href={upload.blob.url}>{upload.blob.data.path}</a>}
{upload?.status === "error" && <p>{upload.error.message}</p>}
</>
}GETacceptuploadHooks<typeof uploads>()import type此示例假设使用 public 存储桶。在私有存储桶上, 是 ,因此请存储 ,并在客户端需要读取时向它提供 。
urlundefinedpathbucket.signedReadUrl(path)两条容易踩坑的规则:
- 可能会运行多次。 浏览器会重试,所以请基于
onUploadComplete做 upsert,而不是 insert。uploadId - 从 抛出异常会删除对象。 这对于拒绝操作是正确的,但对于瞬态数据库错误则不合适——请自行捕获存储错误。
onUploadComplete
超过 16 MB 的文件会分段上传,这提供了 、、每段重试,以及当用户再次选择同一文件时重新加载后恢复的功能。 在 变为 之前最高为 99;请根据 驱动 UI。
pause()resume()percentstatusdoneupload.pending在 handler 上设置 可以让所有上传都使用分段上传。这样,关闭标签页只会留下不完整的分段,而不会留下一个无人记录的存储对象,并且可以通过一个 cron 任务清理:
multipart: truets
await bucket.abortStaleMultipartUploads({ olderThan: "1d" })对于必须经过你的应用传输数据的路由,请编写一个普通路由调用 ,并使用 中的 来驱动。
bucket.put@upstash/blob/reactuseServerUploadCaching
缓存
cache| Value | Stored |
|---|---|
| |
| |
| |
a duration ( | |
immutableuniquePathversionedUrlprivatepubliccache| 值 | 存储的 Cache-Control |
|---|---|
| |
| |
| |
时长( | |
immutableuniquePathversionedUrlpublicprivateErrors
错误
ts
import { BlobError } from "@upstash/blob"
if (BlobError.is(e) && e.code === "not_found") return nullUse , never — an ESM and a CJS copy are different classes. Codes: , , , , , , , , , , , , , , , , , .
BlobError.is()instanceofnot_foundalready_existsconflictcontent_type_not_allowedinvalid_inputtoo_largeempty_bodylength_requiredsignature_mismatchunauthorizedforbiddenrate_limitednot_readypartial_deletemove_left_a_copyinvalid_content_type_patternmint_backoffrequest_failedA refusal keeps its code all the way to the browser, so hooks switch on rather than status numbers. Bad option values (, a missing token) throw a where they are written, not a per request.
error.code'5mib'TypeErrorBlobErrorts
import { BlobError } from "@upstash/blob"
if (BlobError.is(e) && e.code === "not_found") return null请使用 ,绝不要使用 ——ESM 和 CJS 的副本是不同的类。错误码:、、、、、、、、、、、、、、、、、。
BlobError.is()instanceofnot_foundalready_existsconflictcontent_type_not_allowedinvalid_inputtoo_largeempty_bodylength_requiredsignature_mismatchunauthorizedforbiddenrate_limitednot_readypartial_deletemove_left_a_copyinvalid_content_type_patternmint_backoffrequest_failed拒绝操作会将错误码一路传递到浏览器,因此 hooks 根据 而不是状态码进行切换。错误的选项值(如 、缺少 token)会在写入的地方抛出 ,而不是每次请求抛出一个 。
error.code'5mib'TypeErrorBlobErrorS3 clients
S3 客户端
ts
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"
const config = bucket.s3()
const s3 = new S3Client(config) // endpoint and credentials are async providers
// config.bucket is the underlying bucket id, available nowhere else
await s3.send(new GetObjectCommand({ Bucket: config.bucket, Key: "reports/q3.pdf" }))Buckets are S3-compatible. Use this for what the SDK doesn't wrap — byte ranges, conditional GETs, tagging. Pass the providers through as they come so the AWS SDK can refresh an expired credential.
ts
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"
const config = bucket.s3()
const s3 = new S3Client(config) // endpoint and credentials are async providers
// config.bucket is the underlying bucket id, available nowhere else
await s3.send(new GetObjectCommand({ Bucket: config.bucket, Key: "reports/q3.pdf" }))存储桶与 S3 兼容。对于 SDK 未封装的功能(字节范围、条件 GET、标签),可以使用此方式。请按原样传递 providers,以便 AWS SDK 能刷新过期的凭证。