TanStack Start 文件上传架构:R2、Multipart 与流式处理

这是一份有关 Cloudflare Workers 上的 TanStack Start 文件上传架构的全面指南——涵盖了 Multipart 表单上传、R2 对象存储的直传机制、针对大文件的流式分块上传,以及完整的端到端图像处理管线,助您在生产级 SaaS 环境中构建出兼顾高性能与边缘原生成本优势的稳定文件基础设施。

Alex Chen
Alex Chen
July 8, 202613 min read
其他语言:Deutsch · English

**总结:**文件上传是大多数 SaaS 应用的核心功能——例如个人资料图片、文档附件、媒体库和数据导入。搭配 Cloudflare Workers 的 TanStack Start 结合 R2 对象存储,提供了一个具备边缘原生性能的完整上传架构。本指南涵盖了 Multipart 表单上传、从浏览器直接上传至 R2、大文件流式上传、图像处理管线、访问控制以及生产级的端到端上传流。


架构概览

组件用途性能
Browser文件选择、分块、进度客户端侧
TanStack Start (Worker)认证、验证、预签名 URL~5ms 冷启动
Cloudflare R2对象存储、兼容 S3全球化、无出站流量费用
Images调整大小、格式转换Workers + R2

Multipart 表单上传

最简单的方法:使用 TanStack Start 的服务器函数,将文件作为表单提交的一部分进行上传:

typescript
// server/uploads.ts
import { createServerFn } from "@tanstack/react-start"

export const uploadProfileImage = createServerFn({
  method: "POST",
}).handler(async ({ request, context }) => {
  const formData = await request.formData()
  const file = formData.get("avatar") as File

  if (!file) {
    throw new Error("No file provided")
  }

  // 验证文件类型和大小
  const allowedTypes = ["image/jpeg", "image/png", "image/webp"]
  if (!allowedTypes.includes(file.type)) {
    return { success: false, error: "Invalid file type" }
  }

  if (file.size > 5 * 1024 * 1024) {
    return { success: false, error: "File too large (max 5MB)" }
  }

  // 上传至 R2
  const key = `avatars/${context.user.id}/${file.name}`
  await context.env.MY_BUCKET.put(key, await file.arrayBuffer(), {
    httpMetadata: { contentType: file.type },
  })

  return {
    success: true,
    url: `https://r2.tanstackship.com/${key}`,
  }
})
typescript
// 客户端组件
import { useMutation } from "@tanstack/react-query"
import { uploadProfileImage } from "../server/uploads"

function AvatarUpload() {
  const mutation = useMutation({
    mutationFn: (file: File) => {
      const formData = new FormData()
      formData.append("avatar", file)
      return uploadProfileImage({ data: formData })
    },
  })

  return (
    <div>
      <input
        type="file"
        accept="image/*"
        onChange={(e) => {
          const file = e.target.files?.[0]
          if (file) mutation.mutate(file)
        }}
      />
      {mutation.isPending && <progress>Uploading...</progress>}
      {mutation.data?.success && <img src={mutation.data.url} alt="avatar" />}
      {mutation.data?.error && <p className="error">{mutation.data.error}</p>}
    </div>
  )
}

直传 R2(预签名 URL)

对于较大的文件,请使用预签名 URL 从浏览器直接上传到 R2,从而完全绕过 Worker:

typescript
// server/uploads.ts
export const getUploadUrl = createServerFn({ method: "GET" }).handler(
  async ({ data, context }: { data: { filename: string; contentType: string } }) => {
    const key = `uploads/${crypto.randomUUID()}-${data.filename}`

    // 生成用于 PUT 的预签名 URL(有效期 1 小时)
    const url = await context.env.MY_BUCKET.createPresignedUrl({
      key,
      method: "PUT",
      expiryInSeconds: 3600,
    })

    return {
      uploadUrl: url,
      publicUrl: `https://r2.tanstackship.com/${key}`,
    }
  }
)
typescript
// 客户端 —— 直传至 R2
async function uploadDirect(file: File) {
  const { uploadUrl } = await getUploadUrl({
    data: { filename: file.name, contentType: file.type },
  })

  const response = await fetch(uploadUrl, {
    method: "PUT",
    body: file,
    headers: { "Content-Type": file.type },
  })

  if (response.ok) {
    showToast("Upload complete!")
  }
}

各种方法的适用场景

方法文件大小用例优点缺点
通过 Worker 上传表单数据< 5 MB个人资料图片、文档简单直观、可在 Worker 内验证增加 Worker 执行时间
预签名 URL< 100 MB媒体上传、备份无 Worker 额外开销、速度快需要多一轮请求获取 URL
分块上传> 100 MB视频、数据集支持暂停/恢复、进度反馈基础实现相对复杂

针对大文件的分块上传

typescript
// server/uploads.ts
export const initiateMultipartUpload = createServerFn({ method: "POST" }).handler(
  async ({ data }: { data: { filename: string; parts: number } }) => {
    const uploadId = crypto.randomUUID()
    const key = `uploads/${uploadId}-${data.filename}`

    // 为每个分块生成预签名 URL
    const presignedUrls = await Promise.all(
      Array.from({ length: data.parts }, (_, i) =>
        context.env.MY_BUCKET.createPresignedUrl({
          key,
          method: "PUT",
          expiryInSeconds: 7200,
        })
      )
    )

    return { uploadId, key, presignedUrls, chunkSize: 5 * 1024 * 1024 } // 5MB 的分块大小
  }
)

export const completeUpload = createServerFn({ method: "POST" }).handler(
  async ({ data }: { data: { key: string; uploadId: string } }) => {
    // 在数据库中将上传标记为完成
    await env.DB.prepare(
      "INSERT INTO uploads (id, key, user_id, created_at) VALUES (?, ?, ?, ?)"
    ).bind(data.uploadId, data.key, context.user.id, Date.now()).run()

    return { url: `https://r2.tanstackship.com/${data.key}` }
  }
)
typescript
// 客户端 —— 带有进度反馈的分块上传
async function uploadLargeFile(file: File, onProgress: (pct: number) => void) {
  const CHUNK_SIZE = 5 * 1024 * 1024 // 5MB
  const parts = Math.ceil(file.size / CHUNK_SIZE)

  const { uploadId, key, presignedUrls } = await initiateMultipartUpload({
    data: { filename: file.name, parts },
  })

  for (let i = 0; i < parts; i++) {
    const start = i * CHUNK_SIZE
    const chunk = file.slice(start, start + CHUNK_SIZE)

    await fetch(presignedUrls[i], {
      method: "PUT",
      body: chunk,
    })

    onProgress(((i + 1) / parts) * 100)
  }

  return await completeUpload({ data: { key, uploadId } })
}

图像处理管线

将 R2 与 Cloudflare Image Resizing 或基于 Workers 的图像处理管线结合使用:

typescript
// server/images.ts
export const processAndUploadImage = createServerFn({ method: "POST" }).handler(
  async ({ data, context }) => {
    const { buffer, filename } = data

    // 根据多种尺寸进行生成
    const sizes = [
      { suffix: "thumb", width: 150, height: 150 },
      { suffix: "small", width: 400, height: 300 },
      { suffix: "full", width: 1200, height: 900 },
    ]

    const uploads = await Promise.all(
      sizes.map(async ({ suffix, width, height }) => {
        const key = `images/${filename}/${suffix}.webp`
        const processed = await resizeImage(buffer, { width, height, format: "webp" })

        await context.env.MY_BUCKET.put(key, processed, {
          httpMetadata: { contentType: "image/webp" },
        })

        return { suffix, url: `https://r2.tanstackship.com/${key}` }
      })
    )

    return { urls: uploads }
  }
)

访问控制

用于私有内容的签名 URL

typescript
export const getSignedDownloadUrl = createServerFn({ method: "GET" }).handler(
  async ({ data, context }: { data: { key: string } }) => {
    // 检查授权
    const upload = await env.DB.prepare(
      "SELECT user_id FROM uploads WHERE key = ?"
    ).bind(data.key).first()

    if (!upload || upload.user_id !== context.user.id) {
      throw new Error("Unauthorized")
    }

    // 生成临时下载 URL
    return await context.env.MY_BUCKET.createPresignedUrl({
      key: data.key,
      method: "GET",
      expiryInSeconds: 3600,
    })
  }
)

文件验证中间件

typescript
// server/uploads.ts
const fileValidators = {
  image: {
    maxSize: 5 * 1024 * 1024, // 5MB
    types: ["image/jpeg", "image/png", "image/webp", "image/avif"],
  },
  document: {
    maxSize: 20 * 1024 * 1024, // 20MB
    types: [
      "application/pdf",
      "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
      "text/csv",
    ],
  },
  video: {
    maxSize: 500 * 1024 * 1024, // 500MB
    types: ["video/mp4", "video/webm"],
  },
} as const

function validateFile(file: File, category: keyof typeof fileValidators) {
  const rules = fileValidators[category]

  if (!rules.types.includes(file.type as any)) {
    return `Invalid file type. Allowed: ${rules.types.join(", ")}`
  }
  if (file.size > rules.maxSize) {
    return `File too large. Maximum: ${rules.maxSize / 1024 / 1024}MB`
  }
  return null
}

成本分析:R2 vs S3 vs 本地存储

存储提供商存储容量 (1TB)出站流量 (1TB)A 类操作 (10M)B 类操作 (10M)
Cloudflare R2$15.00$0.00$0.36$0.36
AWS S3 Standard$23.00$90.00$0.05$0.05
AWS S3 - IA$12.50$90.00$0.10$0.10
Backblaze B2$6.00$10.00$0.00$0.00

R2 的零出站流量费用,对于用户频繁访问上传文件的 SaaS 应用来说是一个显著优势。


生产级安全核对清单

  • 在客户端和服务器进行双端文件类型验证
  • 强制执行文件大小限制 —— 处理之前先拒绝超大文件的上传
  • 采用 UUID 保证文件名唯一,以防止路径遍历(path traversal)攻击
  • 在 R2 对象上显式设置 Content-Type 请求头
  • 为预签名 URL 设置较短的过期时间(PUT 请求最长 1 小时,GET 请求最长 1 天)
  • 生成下载 URL 前进行严格的授权检查
  • 对每个用户实施上传频率限流(防止滥用)
  • 对于文档上传扫描病毒(利用 Workers 上基于 WebAssembly 的 ClamAV)
  • 限制 R2 存储桶的 CORS 配置允许访问的 origin 源
  • 对所有上传与下载事件设定审计日志记录

结论

文件上传架构是任何 SaaS 应用的关键考量因素。配合 Cloudflare R2,TanStack Start 提供了一个高扩展性、高性价比的基础设施,能够处理从小个人资料图片到几 GB 的大媒体文件。

关键的架构决策:

  1. 小文件(< 5 MB) —— 经过 Worker 上传以便进行内联验证
  2. 中型文件(5-100 MB) —— 使用预签名 URL 直接上传至 R2
  3. 大文件(> 100 MB) —— 带有进度追踪的分块上传
  4. 私有文件 —— 附带授权检查的签名 URL
  5. 图像 —— 拥有多种尺寸变体的处理管线

了解更多有关 R2 配置与成本优化的信息,请参阅 Cloudflare R2 对象存储指南。如果想在文件上传后进行图像的优化与转换处理,欢迎阅读现代 Web 应用的图像优化。