upstash-blob-js

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

@upstash/blob SDK

@upstash/blob SDK

S3-compatible object storage.
Bucket
runs on your server;
uploadHandler
plus React hooks upload from the browser straight to storage so the bytes never pass through your app.
S3 兼容对象存储。
Bucket
运行在你的服务器上;
uploadHandler
加上 React hooks 可以从浏览器直接上传到存储,因此数据永远不会经过你的应用。

Install & Setup

安装与设置

bash
npm install @upstash/blob
Create a bucket in the console and set
UPSTASH_BLOB_TOKEN
. The token is a bearer secret for the whole bucket — keep it server side, never in
NEXT_PUBLIC_
or any bundler-inlined variable.
A bucket is public (every object has a URL) or private (no URL; reads go through
signedReadUrl
). That is a console setting, not a client option — the SDK learns it from the backend.
ts
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
bash
npm install @upstash/blob
在控制台中创建一个存储桶,并设置
UPSTASH_BLOB_TOKEN
。该 token 是整个存储桶的 bearer 密钥——请保存在服务器端,绝不要放在
NEXT_PUBLIC_
或任何打包器内联变量中。
存储桶分为 public(每个对象都有一个 URL)或 private(没有 URL;读取通过
signedReadUrl
)。这是一个控制台设置,不是客户端选项——SDK 从后端获取该信息。
ts
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

Writing

写入

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
Bodies:
Request
,
Blob
/
File
,
ArrayBuffer
, typed array,
string
,
ReadableStream
. A stream carries no length — pass
size
(exact, streams through) or
maxSize
(buffers up to the cap), or
put
throws
length_required
.
OptionDefaultWhat it does
contentType
the body's, else
application/octet-stream
What the object is stored as
contentTypes
anyAllow list, e.g.
["image/*", "application/pdf"]
maxSize
noneRefuse a bigger body with
too_large
cache
bucket default
Cache-Control
stored with the object
metadata
none
x-amz-meta-*
; lowercase keys, printable ASCII values
allowOverwrite
true
false
refuses if something is there (
already_exists
)
ifUnchanged
noneAn etag; fails with
conflict
if it changed
multipart
'16mb'
Threshold for going up in parts;
true
/
false
force it
Sizes are decimal:
'20mb'
is 20,000,000 bytes.
'5mib'
throws.
ts
import { uniquePath } from "@upstash/blob"

uniquePath`${user.id}/${file.name}`   // 'u7/holiday-pic-3xK9mBqR.png'
Use
uniquePath
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
photo.png
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
TypeError
rather than a
BlobError
.
bucket.copy(from, to, { contentType, cache, metadata })
and
bucket.move(from, to, options)
preserve source properties you omit.
bucket.updateJson(path, fn, { maxAttempts: 6 })
retries a read-modify-write on conflict with backoff.
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
请求体:
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'
会抛出异常。
ts
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 })
在冲突时使用退避重试读-改-写操作。

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 })
get
/
info
throw
not_found
. Nothing is buffered — wrap the stream to read it:
ts
await new Response((await bucket.get("notes/1.md")).body).text()
list
pages with
page.cursor
(set only while more remains) and carries no
contentType
or
metadata
.
prefix
is the only filter — keep your own table as the index and treat the bucket as storage, not a queryable store.
ts
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
expiresAt
, never a deadline you compute — a link cannot outlive the credential that signed it, so you may get less than you asked for.
await bucket.publicUrl(path)
returns the public URL,
undefined
on a private bucket.
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 })
get
/
info
抛出
not_found
。不会缓冲任何数据——如需读取请包装该流:
ts
await new Response((await bucket.get("notes/1.md")).body).text()
list
通过
page.cursor
分页(仅当还有更多结果时设置),且不携带
contentType
metadata
prefix
是唯一的筛选条件——请保留自己的索引表,并将存储桶视为存储,而不是可查询的数据库。
ts
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

Deleting

删除

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 prefix
Already-gone counts as success, so deletes are safe to retry. An array or prefix delete where objects survive throws
partial_delete
with them in
e.failed
.
del({ prefix: '' })
is refused unless you pass
all: true
.
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 prefix
对象已不存在也算成功,因此删除操作可以安全重试。如果数组或前缀删除后仍有对象存活,会抛出
partial_delete
,并在
e.failed
中列出它们。除非传入
all: true
,否则
del({ 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
getUser
and
db.files.upsert
implementations below.
ts
// 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 } = uploads
ts
// 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>}
  </>
}
GET
serves the route's constraints, so
accept
fills the file dialog and an oversized file is refused before any request leaves the browser.
uploadHooks<typeof uploads>()
types route names and completion data at compile time;
import type
keeps server code out of the bundle.
This example assumes a public bucket. On a private one
url
is
undefined
, so store
path
instead and hand the client a
bucket.signedReadUrl(path)
when it needs to read.
Two rules that bite:
  • onUploadComplete
    can run more than once.
    The browser retries it, so upsert on
    uploadId
    rather than inserting.
  • A throw out of
    onUploadComplete
    deletes the object.
    That is right for a refusal and wrong for a transient database error — catch your own storage errors.
Files over 16 MB go up in parts, which is what gives
pause()
,
resume()
, per-part retry, and resume-after-reload when the user picks the same file again.
percent
caps at 99 until
status
is
done
; drive UI off
upload.pending
.
Use
multipart: true
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:
ts
await bucket.abortStaleMultipartUploads({ olderThan: "1d" })
For routes where the bytes must pass through your app, write an ordinary route calling
bucket.put
and drive it with
useServerUpload
from
@upstash/blob/react
.
该 handler 负责授权和记录;数据直接从浏览器 → 存储,因此平台请求体大小限制不适用。请在下面提供你的应用的
getUser
db.files.upsert
实现。
ts
// 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 } = uploads
ts
// 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>}
  </>
}
GET
提供该路由的约束条件,因此
accept
会填充文件选择对话框,并且超大文件在浏览器发出任何请求之前就会被拒绝。
uploadHooks<typeof uploads>()
在编译时对路由名称和完成数据进行类型检查;
import type
将服务器代码排除在打包之外。
此示例假设使用 public 存储桶。在私有存储桶上,
url
undefined
,因此请存储
path
,并在客户端需要读取时向它提供
bucket.signedReadUrl(path)
两条容易踩坑的规则:
  • onUploadComplete
    可能会运行多次。
    浏览器会重试,所以请基于
    uploadId
    做 upsert,而不是 insert。
  • onUploadComplete
    抛出异常会删除对象。
    这对于拒绝操作是正确的,但对于瞬态数据库错误则不合适——请自行捕获存储错误。
超过 16 MB 的文件会分段上传,这提供了
pause()
resume()
、每段重试,以及当用户再次选择同一文件时重新加载后恢复的功能。
percent
status
变为
done
之前最高为 99;请根据
upload.pending
驱动 UI。
在 handler 上设置
multipart: true
可以让所有上传都使用分段上传。这样,关闭标签页只会留下不完整的分段,而不会留下一个无人记录的存储对象,并且可以通过一个 cron 任务清理:
ts
await bucket.abortStaleMultipartUploads({ olderThan: "1d" })
对于必须经过你的应用传输数据的路由,请编写一个普通路由调用
bucket.put
,并使用
@upstash/blob/react
中的
useServerUpload
来驱动。

Caching

缓存

cache
is written once, at upload, and stored with the object — changing it means writing the object again.
ValueStored
'immutable'
public, max-age=31536000, immutable
'revalidate'
public, max-age=0, must-revalidate
'no-store'
no-store
a duration (
'15m'
,
3600
)
public, max-age=<seconds>
immutable
needs a path that changes when the bytes do — either
uniquePath
per upload, or a stable path served through
versionedUrl
. On a private bucket
private
replaces
public
.
cache
在上传时写入一次,并与对象一起存储——修改它意味着重新写入对象。
存储的 Cache-Control
'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
提供稳定路径。在私有存储桶上,
public
会被替换为
private

Errors

错误

ts
import { BlobError } from "@upstash/blob"

if (BlobError.is(e) && e.code === "not_found") return null
Use
BlobError.is()
, never
instanceof
— an ESM and a CJS copy are different classes. Codes:
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
.
A refusal keeps its code all the way to the browser, so hooks switch on
error.code
rather than status numbers. Bad option values (
'5mib'
, a missing token) throw a
TypeError
where they are written, not a
BlobError
per request.
ts
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
拒绝操作会将错误码一路传递到浏览器,因此 hooks 根据
error.code
而不是状态码进行切换。错误的选项值(如
'5mib'
、缺少 token)会在写入的地方抛出
TypeError
,而不是每次请求抛出一个
BlobError

S3 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 能刷新过期的凭证。