关于 upstash-blob-js
@upstash/blob SDK 是面向 JavaScript/TypeScript 应用的 S3 兼容对象存储 SDK。它提供服务端存取能力,以及让浏览器直接上传到存储的处理器和 React hooks。
主要功能
- 01S3 兼容对象存储操作
- 02支持流式读写与分段上传
- 03公共与私有对象读取
- 04浏览器直传及 React hooks
- 05复制、移动、删除和列举对象
- 06签名读取链接与缓存控制
适用场景
- 01在服务端保存和读取文件
- 02让浏览器上传文件而不经过应用服务器
- 03为私有对象生成限时读取链接
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ upstash/ skills --skill upstash-blob-js安装后在 Claude Code 中输入/upstash-blob-js确认已加载。
详细介绍
@upstash/blob SDK
兼容 S3 的对象存储。Bucket 在服务器上运行;uploadHandler 配合 React hooks 可从浏览器直接上传到存储,因此字节数据不会经过你的应用。
内置文档和源码
@upstash/blob npm 包包含位于 node_modules/@upstash/blob/docs/ 的文档,以及位于 node_modules/@upstash/blob/src/ 的可读 TypeScript 源码。编写或调试 SDK 代码前,请阅读并搜索相关文档和源码。
安装和设置
npm install @upstash/blob
在控制台中创建一个存储桶,并设置 UPSTASH_BLOB_TOKEN。此令牌是整个存储桶的 bearer 密钥——请将其保留在服务器端,切勿放入 NEXT_PUBLIC_ 变量或任何由打包工具内联的变量中。
存储桶可以是公开的(每个对象都有 URL),也可以是私有的(没有 URL;读取需通过 signedReadUrl)。这是一项控制台设置,而非客户端选项——SDK 会从后端获取此设置。
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.env
写入
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
请求体支持:Request、Blob/File、ArrayBuffer、类型化数组、string、ReadableStream。流没有长度信息——请传入 size(精确大小,数据以流的方式传输)或 maxSize(缓冲到上限),否则 put 会抛出 length_required。
| 选项 | 默认值 | 作用 |
|---|---|---|
contentType | 请求体的类型,否则为 application/octet-stream | 对象存储时使用的类型 |
contentTypes | 任意 | 允许列表,例如 ["image/*", "application/pdf"] |
maxSize | 无 | 请求体过大时以 too_large 拒绝 |
cache | 存储桶默认值 | 与对象一同存储的 Cache-Control |
metadata | 无 | x-amz-meta-*;键需为小写,值需为可打印的 ASCII 字符 |
allowOverwrite | true | 设为 false 时,若对象已存在则以 already_exists 拒绝 |
ifUnchanged | 无 | 一个 etag;若对象已更改,则以 conflict 失败 |
multipart | '16mb' | 分段上传的阈值;设为 true/false 可强制启用或禁用 |
大小使用十进制:'20mb' 是 20,000,000 字节。'5mib' 会抛出异常。
import { uniquePath } from "@upstash/blob"
uniquePath`${user.id}/${file.name}` // 'u7/holiday-pic-3xK9mBqR.png'
对任何不受你控制的值使用 uniquePath。每个 ${} 都会变成一个经过 slug 处理的文件名,绝不会额外添加目录;最终路径还会添加随机后缀,因此两个 photo.png 上传不会发生冲突。模板中的字面部分会按原样传递,因此请自行避免在其中使用 . 和 ..:带有这些片段的路径之后会被使用它的调用拒绝,并抛出 TypeError 而非 BlobError。
bucket.copy(from, to, { contentType, cache, metadata }) 和 bucket.move(from, to, options) 会保留你未指定的源属性。bucket.updateJson(path, fn, { maxAttempts: 6 }) 会在发生冲突时通过退避重试读-改-写操作。
读取
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 })
get/info 会抛出 not_found。数据不会被缓冲——请包装流来读取:
await new Response((await bucket.get("notes/1.md")).body).text()
list 通过 page.cursor 分页(仅当后续还有内容时才会设置),且不包含 contentType 或 metadata。唯一的筛选条件是 prefix——请将自己的表作为索引,并将存储桶视为存储空间,而非可查询的数据存储。
const { url, expiresAt } = await bucket.signedReadUrl("private/report.pdf", {
expiresIn: "2m",
downloadAs: "Report Q3.pdf", // save under this name instead of rendering inline
})
将链接缓存到 expiresAt,切勿自行计算截止时间——链接的有效期不能超过为其签名的凭证,因此实际有效期可能短于你请求的时长。await bucket.publicUrl(path) 会返回公开 URL;对于私有存储桶则返回 undefined。
删除
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_delete,残留对象位于 e.failed 中。除非传入 all: true,否则会拒绝执行 del({ prefix: '' })。
浏览器上传
处理程序负责授权和记录;数据字节从浏览器直接传到存储,因此不受平台请求正文大小限制。请在下方提供应用的 getUser 和 db.files.upsert 实现。
// 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
},
})
// app/api/upload/route.ts
import { uploads } from "@/lib/uploads"
export const { GET, POST } = uploads
// lib/upload-hooks.ts
"use client"
import { uploadHooks } from "@upstash/blob/react"
import type { uploads } from "./uploads"
export const { useUpload } = uploadHooks<typeof uploads>()
"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>}
</>
}
GET 会提供路由的限制条件,因此 accept 会填充文件选择对话框,超大文件也会在浏览器发出任何请求之前被拒绝。uploadHooks<typeof uploads>() 会在编译时为路由名称和完成数据提供类型;import type 可避免将服务端代码打包进去。
此示例假设使用公开存储桶。对于私有存储桶,url 为 undefined,因此应存储 path,并在客户端需要读取时向其提供 bucket.signedReadUrl(path)。
以下两条规则容易踩坑:
onUploadComplete可能会运行多次。 浏览器会重试,因此应按uploadId执行 upsert,而不是插入。- 从
onUploadComplete抛出错误会删除对象。 对于拒绝上传,这是正确的;对于暂时性的数据库错误则不合适——请自行捕获存储错误。
超过 16 MB 的文件会分块上传,因此才能使用 pause()、resume()、逐块重试,以及用户再次选择同一文件后在重新加载页面时恢复上传。status 为 done 之前,percent 最高为 99;请使用 upload.pending 驱动 UI。
在处理程序上使用 multipart: true,即可让所有上传都采用分块方式。这样,关闭的标签页只会留下未完成的分块,而不会留下无人记录的已存储对象;再用一个 cron 清理即可:
await bucket.abortStaleMultipartUploads({ olderThan: "1d" })
对于数据字节必须经过应用的路由,请编写一个调用 bucket.put 的普通路由,并使用 @upstash/blob/react 中的 useServerUpload 驱动上传。
缓存
cache 仅在上传时写入一次,并与对象一起存储——要更改它,必须重新写入对象。
| 值 | 存储的内容 |
|---|---|
'immutable' | public, max-age=31536000, immutable |
'revalidate' | public, max-age=0, must-revalidate |
'no-store' | no-store |
时长('15m'、3600) | public, max-age=<seconds> |
immutable 需要一个会随数据字节变化的路径——可以为每次上传设置 uniquePath,也可以通过 versionedUrl 提供稳定路径。在私有存储桶上,private 会替代 public。
错误
import { BlobError } from "@upstash/blob"
if (BlobError.is(e) && e.code === "not_found") return null
请使用 BlobError.is(),切勿使用 instanceof——ESM 和 CJS 中的副本属于不同的类。错误代码:not_found、already_exists、conflict、content_type_not_allowed、invalid_input、too_large、empty_body、length_required、signature_mismatch、unauthorized、forbidden、rate_limited、not_ready、partial_delete、move_left_a_copy、invalid_content_type_pattern、mint_backoff、request_failed。
拒绝操作会将其错误代码一路传递到浏览器,因此钩子应根据 error.code 分支处理,而不是根据状态码。无效的选项值('5mib'、缺失的令牌)会在写入这些值的位置抛出 TypeError,而不会针对每个请求抛出 BlobError。
S3 客户端
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、标签),请使用此方式。请按原样传递提供程序,以便 AWS SDK 能够刷新过期的凭证。