mcpField
U
upstash-blob-js

upstash-blob-js

github.com/upstashクラウド・DevOps34528 回インストール1 日前に更新
リポジトリ

upstash-blob-js について

@upstash/blobは、JavaScriptおよびTypeScriptアプリ向けのS3互換オブジェクトストレージSDKです。サーバー側の操作に加え、ブラウザーから直接アップロードするハンドラーとReactフックを提供します。

主な機能

  1. 01S3互換のオブジェクト操作
  2. 02ストリーム読み書きとマルチパートアップロード
  3. 03公開・非公開オブジェクトへのアクセス
  4. 04Reactフックによるブラウザー直接アップロード
  5. 05オブジェクトのコピー、移動、削除、一覧表示
  6. 06署名付き読み取りURLとキャッシュ制御

活用シーン

  1. 01サーバーでファイルを保存・取得
  2. 02アプリを経由せずブラウザーからファイルをアップロード
  3. 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 フックを使えばブラウザからストレージへ直接アップロードできるため、バイト列がアプリを経由することはありません。

同梱ドキュメントとソース

@upstash/blob npm パッケージには、node_modules/@upstash/blob/docs/ にドキュメント、node_modules/@upstash/blob/src/ に読める形式の TypeScript ソースが含まれています。SDK のコードを書いたりデバッグしたりする前に、関連するドキュメントとソースを読んで検索してください。

インストールとセットアップ

npm install @upstash/blob

コンソールでバケットを作成し、UPSTASH_BLOB_TOKEN を設定します。このトークンはバケット全体に対するベアラーシークレットです。サーバー側で保持し、NEXT_PUBLIC_ やバンドラーがインライン展開する変数には絶対に置かないでください。

バケットは public(すべてのオブジェクトに URL がある)か private(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 の値
allowOverwritetruefalse は既存のものがあれば拒否(already_exists)
ifUnchangedなしetag。変更されていれば conflict で失敗
multipart'16mb'分割アップロードに切り替えるしきい値。true/false で強制

サイズは 10 進数です。'20mb' は 20,000,000 バイトです。'5mib' はスローします。

import { uniquePath } from "@upstash/blob"

uniquePath`${user.id}/${file.name}`   // 'u7/holiday-pic-3xK9mBqR.png'

自分で制御していない値には uniquePath を使用してください。各 ${} はディレクトリを追加できないスラッグ化されたファイル名になり、完成したパスにはランダムなサフィックスが付くため、photo.png を 2 回アップロードしても衝突しません。テンプレートのリテラル部分はそのまま渡されるため、. と .. は自分で除外してください。これらのセグメントを含むパスは、後でそれを使用する呼び出しによって、BlobError ではなく TypeError で拒否されます。

bucket.copy(from, to, { contentType, cache, metadata }) と bucket.move(from, to, options) は、省略したソースのプロパティを保持します。bucket.updateJson(path, fn, { maxAttempts: 6 }) は、競合時にバックオフしながら read-modify-write を再試行します。

読み取り

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 を返し、private バケットでは 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

すでに存在しない場合も成功とみなされるため、削除は安全に再試行できます。オブジェクトが残る配列削除またはプレフィックス削除では、それらを e.failed に含めて partial_delete をスローします。del({ prefix: '' }) は all: true を渡さない限り拒否されます。

ブラウザからのアップロード

ハンドラーは認可と記録を行い、バイト列はブラウザ → ストレージへ直接送られるため、プラットフォームのリクエストボディ上限は適用されません。以下に、アプリケーションの 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) を渡してください。

注意すべき2つのルール:

  • onUploadComplete は複数回実行される可能性があります。 ブラウザがこれをリトライするため、挿入ではなく uploadId に対する upsert を行ってください。
  • onUploadComplete から例外が投げられると、オブジェクトが削除されます。 これは拒否の場合は正しい挙動ですが、一時的なデータベースエラーの場合は誤りです — 自身のストレージエラーを捕捉してください。

16 MB を超えるファイルは分割してアップロードされ、これにより pause()、resume()、パート単位のリトライ、およびユーザーが同じファイルを再度選択したときのリロード後の再開が可能になります。percent は status が done になるまで 99 で頭打ちになるため、UI は upload.pending を基に制御してください。

ハンドラーで multipart: true を使用すると、すべてのアップロードがマルチパートになります。そうすると、タブが閉じられても、誰も記録していない保存済みオブジェクトではなく不完全なパートが残り、1つの 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 を通じて配信される安定したパスのいずれかです。プライベートバケットでは public の代わりに private を使用します。

エラー

import { BlobError } from "@upstash/blob"

if (BlobError.is(e) && e.code === "not_found") return null

instanceof ではなく、必ず BlobError.is() を使用してください — 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'、トークンの欠落) は、リクエストごとの BlobError ではなく、記述された場所で TypeError をスローします。

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 が期限切れの認証情報を更新できるようにしてください。

コメント