mcpField
V
vercel-blob

vercel-blob

github.com/jezweb云与 DevOps1.1k333 次安装3 周前更新
代码仓库

关于 vercel-blob

面向使用 Vercel Blob 的开发者,提供文件存储、上传和管理的实现指南。重点说明客户端安全上传、分片上传、文件管理及常见问题防范。

主要功能

  1. 01服务端上传文件
  2. 02安全的客户端上传令牌
  3. 03列出与删除 Blob
  4. 04大文件分片上传
  5. 05文件校验与路径组织
  6. 06常见错误排查

适用场景

  1. 01上传用户头像或文件
  2. 02通过客户端上传大文件
  3. 03分页浏览并清理存储文件

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/jezweb/claude-skills --skill vercel-blob

安装后在 Claude Code 中输入/vercel-blob确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

README.md
.claude-plugin/
plugin.json
assets/
example-template.txt
references/
example-reference.md
scripts/
example-script.sh
templates/
avatar-upload-flow.tsx
drag-drop-upload.tsx
file-list-manager.tsx
package.json

详细介绍

Vercel Blob

最后更新:2026-01-21 版本:@vercel/[email protected] 技能版本:2.1.0


快速开始

# Create Blob store: Vercel Dashboard → Storage → Blob
vercel env pull .env.local  # Creates BLOB_READ_WRITE_TOKEN
npm install @vercel/blob

服务端上传:

'use server';
import { put } from '@vercel/blob';

export async function uploadFile(formData: FormData) {
  const file = formData.get('file') as File;
  const blob = await put(file.name, file, { access: 'public' });
  return blob.url;
}

重要:切勿向客户端暴露 BLOB_READ_WRITE_TOKEN。客户端上传请使用 handleUpload()。


客户端上传(安全)

服务端操作(生成预签名令牌):

'use server';
import { handleUpload } from '@vercel/blob/client';

export async function getUploadToken(filename: string) {
  return await handleUpload({
    body: {
      type: 'blob.generate-client-token',
      payload: { pathname: `uploads/${filename}`, access: 'public' }
    },
    request: new Request('https://dummy'),
    onBeforeGenerateToken: async (pathname) => ({
      allowedContentTypes: ['image/jpeg', 'image/png'],
      maximumSizeInBytes: 5 * 1024 * 1024
    })
  });
}

客户端组件:

'use client';
import { upload } from '@vercel/blob/client';

const tokenResponse = await getUploadToken(file.name);
const blob = await upload(file.name, file, {
  access: 'public',
  handleUploadUrl: tokenResponse.url
});

文件管理

列出/删除:

import { list, del } from '@vercel/blob';

// List with pagination
const { blobs, cursor } = await list({ prefix: 'uploads/', cursor });

// Delete
await del(blobUrl);

分片上传(>500MB):

import { createMultipartUpload, uploadPart, completeMultipartUpload } from '@vercel/blob';

const upload = await createMultipartUpload('large-video.mp4', { access: 'public' });
// Upload chunks in loop...
await completeMultipartUpload({ uploadId: upload.uploadId, parts });

重要规则

始终:

  • ✅ 客户端上传使用 handleUpload()(切勿向客户端暴露 BLOB_READ_WRITE_TOKEN)
  • ✅ 上传前验证文件类型和大小
  • ✅ 使用路径名进行分类(avatars/、uploads/)
  • ✅ 在文件名中添加时间戳/UUID(避免冲突)

切勿:

  • ❌ 向客户端暴露 BLOB_READ_WRITE_TOKEN
  • ❌ 不使用分片上传就上传 >500MB 的文件
  • ❌ 跳过文件验证

已知问题预防

此技能可预防 16 个已记录的问题:

问题 #1:缺少环境变量

错误:Error: BLOB_READ_WRITE_TOKEN is not defined 来源:https://vercel.com/docs/storage/vercel-blob 原因:未在环境中设置令牌 预防措施:运行 vercel env pull .env.local,并确保 .env.local 位于 .gitignore 中。

问题 #2:客户端上传令牌泄露

错误:安全漏洞、未经授权的上传 来源:https://vercel.com/docs/storage/vercel-blob/client-upload 原因:在客户端代码中直接使用 BLOB_READ_WRITE_TOKEN 预防措施:使用 handleUpload() 生成带限制条件的客户端专用令牌。

问题 #3:超出文件大小限制

错误:Error: File size exceeds limit(500MB) 来源:https://vercel.com/docs/storage/vercel-blob/limits 原因:未使用分片上传就上传 >500MB 的文件 预防措施:上传前验证文件大小,大文件使用分片上传。

问题 #4:Content-Type 错误

错误:浏览器下载文件而非显示文件(例如,PDF 以文本形式打开) 来源:生产环境调试 原因:未设置 contentType 选项,Blob 猜测错误 预防措施:始终设置 contentType: file.type 或明确指定 MIME 类型。

问题 #5:公共文件未缓存

错误:文件交付缓慢,出口流量成本高 来源:Vercel Blob 最佳实践 原因:对本应公开的文件使用 access: 'private' 预防措施:对可公开访问的文件使用 access: 'public'(CDN 缓存)。

问题 #6:未处理列表分页

错误:只返回前 1000 个文件,导致文件缺失 来源:https://vercel.com/docs/storage/vercel-blob/using-blob-sdk#list 原因:文件列表较大时未使用游标迭代 预防措施:在循环中使用基于游标的分页,直到 cursor 未定义。

问题 #7:删除静默失败

错误:文件未删除,存储配额耗尽 来源:https://github.com/vercel/storage/issues/150 原因:URL 格式错误,未找到 Blob 预防措施:使用 put() 响应中的完整 Blob URL,并检查删除结果。

问题 #8:上传超时(大文件)+ 服务端 4.5MB 限制

错误:文件 >100MB 时出现 Error: Request timeout(服务端),或上传在 4.5MB 处失败(无服务器函数限制) 来源:Vercel 函数超时限制 + 4.5MB 无服务器限制 + 社区讨论 原因:

  • 服务端上传受无服务器函数超时限制(免费套餐 10 秒,专业套餐 60 秒)
  • 重要:Vercel 无服务器函数的请求正文大小硬性限制为 4.5MB。在服务端操作/API 路由中使用 put(),上传 >4.5MB 的文件会失败。

预防措施:对于 >4.5MB 的文件,使用客户端上传和 handleUpload(),或使用分片上传。

// ❌ Server-side upload fails at 4.5MB
export async function POST(request: Request) {
  const formData = await request.formData();
  const file = formData.get('file') as File; // Fails if >4.5MB
  await put(file.name, file, { access: 'public' });
}

// ✅ Client upload bypasses 4.5MB limit (supports up to 500MB)
const blob = await upload(file.name, file, {
  access: 'public',
  handleUploadUrl: '/api/upload/token',
  multipart: true, // For files >500MB, use multipart
});
问题 #9:文件名冲突

错误:文件被覆盖,数据丢失 来源:生产环境调试 发生原因:多个上传使用同一文件名 预防措施:添加时间戳/UUID:`uploads/${Date.now()}-${file.name}` 或设置 addRandomSuffix: true。

问题 #10:缺少上传回调

错误:上传完成,但应用状态未更新 来源:https://vercel.com/docs/storage/vercel-blob/client-upload#callback-after-upload 发生原因:未实现 onUploadCompleted 回调 预防措施:在 handleUpload() 中使用 onUploadCompleted 更新数据库/状态。

问题 #11:大文件的客户端上传令牌过期

错误:Error: Access denied, please provide a valid token for this resource 来源:GitHub 问题 #443 发生原因:默认令牌在 30 秒后过期。大文件(>100MB)上传耗时较长,导致令牌在验证前过期。 预防措施:为大文件上传设置 validUntil 参数。

// For large files (>100MB), extend token expiration
const jsonResponse = await handleUpload({
  body,
  request,
  onBeforeGenerateToken: async (pathname) => {
    return {
      maximumSizeInBytes: 200 * 1024 * 1024,
      validUntil: Date.now() + 300000, // 5 minutes
    };
  },
});
问题 #12:v2.0.0 重大变更——onUploadCompleted 需要 callbackUrl(非 Vercel 托管)

错误:未托管在 Vercel 上时,onUploadCompleted 回调不会触发 来源:Release Notes @vercel/[email protected] 发生原因:出于安全考虑,v2.0.0 移除了从客户端 location.href 自动推断回调 URL 的功能。未使用 Vercel 系统环境变量时,必须显式提供 callbackUrl。 预防措施:在非 Vercel 托管环境中,于 onBeforeGenerateToken 中显式提供 callbackUrl。

// v2.0.0+ for non-Vercel hosting
await handleUpload({
  body,
  request,
  onBeforeGenerateToken: async (pathname) => {
    return {
      callbackUrl: 'https://example.com', // Required for non-Vercel hosting
    };
  },
  onUploadCompleted: async ({ blob, tokenPayload }) => {
    // Now fires correctly
  },
});

// For local development with ngrok:
// VERCEL_BLOB_CALLBACK_URL=https://abc123.ngrok-free.app
问题 #13:Firefox 不支持 ReadableStream 上传

错误:在 Firefox 中上传始终无法完成 来源:GitHub 问题 #881 发生原因:TypeScript 接口接受 ReadableStream 作为 body 类型,但 Firefox 不支持将 ReadableStream 用作 fetch body。 预防措施:将流转换为 Blob 或 ArrayBuffer,以支持跨浏览器使用。

// ❌ Works in Chrome/Edge, hangs in Firefox
const stream = new ReadableStream({ /* ... */ });
await put('file.bin', stream, { access: 'public' }); // Never completes in Firefox

// ✅ Convert stream to Blob for cross-browser support
const chunks: Uint8Array[] = [];
const reader = stream.getReader();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  chunks.push(value);
}
const blob = new Blob(chunks);
await put('file.bin', blob, { access: 'public' });
问题 #14:无法在 onBeforeGenerateToken 中修改 pathname

错误:尽管尝试在服务端覆盖 pathname,文件仍上传到了错误路径 来源:GitHub 问题 #863 发生原因:无法更改 onBeforeGenerateToken 中的 pathname 参数。它在客户端调用 upload(pathname, ...) 时设定。 预防措施:在客户端构造 pathname,在服务端验证。使用 clientPayload 传递元数据。

// Client: Construct pathname before upload
await upload(`uploads/${Date.now()}-${file.name}`, file, {
  access: 'public',
  handleUploadUrl: '/api/upload',
  clientPayload: JSON.stringify({ userId: '123' }),
});

// Server: Validate pathname matches expected pattern
await handleUpload({
  body,
  request,
  onBeforeGenerateToken: async (pathname, clientPayload) => {
    const { userId } = JSON.parse(clientPayload || '{}');

    // Validate pathname starts with expected prefix
    if (!pathname.startsWith(`uploads/`)) {
      throw new Error('Invalid upload path');
    }

    return {
      allowedContentTypes: ['image/jpeg', 'image/png'],
      tokenPayload: JSON.stringify({ userId }), // Pass to onUploadCompleted
    };
  },
});
问题 #15:分块上传的最小分块大小(5MB)

错误:使用小分块进行手动分块上传时失败 来源:官方文档 + 社区讨论 发生原因:手动分块上传中的每个分块都必须至少为 5MB(最后一个分块除外)。这与 Vercel 的 4.5MB 无服务器函数限制相冲突,导致无法通过服务端路由进行手动分块上传。 预防措施:使用自动分块上传(在 put() 中设置 multipart: true)或客户端上传。

// ❌ Manual multipart upload fails (can't upload 5MB chunks via serverless function)
const upload = await createMultipartUpload('large.mp4', { access: 'public' });
// uploadPart() requires 5MB minimum - hits serverless limit

// ✅ Use automatic multipart via client upload
await upload('large.mp4', file, {
  access: 'public',
  handleUploadUrl: '/api/upload',
  multipart: true, // Automatically handles 5MB+ chunks
});
问题 #16:缺少文件扩展名会导致访问被拒绝错误

错误:Error: Access denied, please provide a valid token for this resource 来源:GitHub 问题 #664 发生原因:不带文件扩展名的 pathname 会导致信息不明确的访问被拒绝错误。 预防措施:始终在 pathname 中包含文件扩展名。

// ❌ Fails with confusing error
await upload('user-12345', file, {
  access: 'public',
  handleUploadUrl: '/api/upload',
}); // Error: Access denied

// ✅ Extract extension and include in pathname
const extension = file.name.split('.').pop();
await upload(`user-${userId}.${extension}`, file, {
  access: 'public',
  handleUploadUrl: '/api/upload',
});

常见模式

带替换功能的头像上传:

'use server';
import { put, del } from '@vercel/blob';

export async function updateAvatar(userId: string, formData: FormData) {
  const file = formData.get('avatar') as File;
  if (!file.type.startsWith('image/')) throw new Error('Only images allowed');

  const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
  if (user?.avatarUrl) await del(user.avatarUrl); // Delete old

  const blob = await put(`avatars/${userId}.jpg`, file, { access: 'public' });
  await db.update(users).set({ avatarUrl: blob.url }).where(eq(users.id, userId));
  return blob.url;
}

受保护的上传(access: 'private'):

const blob = await put(`documents/${userId}/${file.name}`, file, { access: 'private' });

评论

支持 Markdown