convex-file-system

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

ConvexFS — File Storage for Convex

ConvexFS — Convex的文件存储组件

convex-fs
— Path-based file storage with global CDN delivery via bunny.net.
convex-fs
— 基于路径的文件存储,通过bunny.net实现全球CDN分发。

Installation & Setup

安装与配置

1. Install

1. 安装

bash
npm install convex-fs
bash
npm install convex-fs

2. Register component

2. 注册组件

typescript
// convex/convex.config.ts
import { defineApp } from "convex/server";
import fs from "convex-fs/convex.config.js";

const app = defineApp();
app.use(fs);
export default app;
typescript
// convex/convex.config.ts
import { defineApp } from "convex/server";
import fs from "convex-fs/convex.config.js";

const app = defineApp();
app.use(fs);
export default app;

3. Create ConvexFS instance

3. 创建ConvexFS实例

typescript
// convex/fs.ts
import { ConvexFS } from "convex-fs";
import { components } from "./_generated/api";

export const fs = new ConvexFS(components.fs, {
  storage: {
    type: "bunny",
    apiKey: process.env.BUNNY_API_KEY!,
    storageZoneName: process.env.BUNNY_STORAGE_ZONE!,
    cdnHostname: process.env.BUNNY_CDN_HOSTNAME!,
    tokenKey: process.env.BUNNY_TOKEN_KEY, // recommended for signed URLs
  },
});
typescript
// convex/fs.ts
import { ConvexFS } from "convex-fs";
import { components } from "./_generated/api";

export const fs = new ConvexFS(components.fs, {
  storage: {
    type: "bunny",
    apiKey: process.env.BUNNY_API_KEY!,
    storageZoneName: process.env.BUNNY_STORAGE_ZONE!,
    cdnHostname: process.env.BUNNY_CDN_HOSTNAME!,
    tokenKey: process.env.BUNNY_TOKEN_KEY, // recommended for signed URLs
  },
});

4. Register HTTP routes

4. 注册HTTP路由

typescript
// convex/http.ts
import { httpRouter } from "convex/server";
import { registerRoutes } from "convex-fs";
import { components } from "./_generated/api";
import { fs } from "./fs";

const http = httpRouter();

registerRoutes(http, components.fs, fs, {
  pathPrefix: "/fs",
  uploadAuth: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    return identity !== null;
  },
  downloadAuth: async (ctx, blobId, path) => {
    const identity = await ctx.auth.getUserIdentity();
    return identity !== null;
  },
});

export default http;
Creates:
POST /fs/upload
(upload proxy) and
GET /fs/blobs/{blobId}
(302 redirect to CDN).
typescript
// convex/http.ts
import { httpRouter } from "convex/server";
import { registerRoutes } from "convex-fs";
import { components } from "./_generated/api";
import { fs } from "./fs";

const http = httpRouter();

registerRoutes(http, components.fs, fs, {
  pathPrefix: "/fs",
  uploadAuth: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    return identity !== null;
  },
  downloadAuth: async (ctx, blobId, path) => {
    const identity = await ctx.auth.getUserIdentity();
    return identity !== null;
  },
});

export default http;
创建以下路由:
POST /fs/upload
(上传代理)和
GET /fs/blobs/{blobId}
(302重定向至CDN)。

5. Environment variables

5. 环境变量

Set in Convex dashboard (Settings → Environment Variables):
BUNNY_API_KEY=your-api-key
BUNNY_STORAGE_ZONE=your-storage-zone-name
BUNNY_CDN_HOSTNAME=your-zone.b-cdn.net
BUNNY_TOKEN_KEY=your-token-auth-key
BUNNY_REGION=ny  # Optional: ny, la, sg, uk, se, br, jh, syd (default: Frankfurt)
在Convex控制台中设置(设置 → 环境变量):
BUNNY_API_KEY=your-api-key
BUNNY_STORAGE_ZONE=your-storage-zone-name
BUNNY_CDN_HOSTNAME=your-zone.b-cdn.net
BUNNY_TOKEN_KEY=your-token-auth-key
BUNNY_REGION=ny  # 可选:ny, la, sg, uk, se, br, jh, syd(默认:法兰克福)

Core Concepts

核心概念

Architecture

架构

React Client ──▶ Convex Backend (ConvexFS) ──▶ bunny.net CDN (File Storage + Edge)
  • File metadata (paths, content types, sizes) → Convex tables
  • File contents (blobs) → bunny.net Edge Storage
React Client ──▶ Convex Backend (ConvexFS) ──▶ bunny.net CDN (File Storage + Edge)
  • 文件元数据(路径、内容类型、大小)→ 存储在Convex表中
  • 文件内容(Blob)→ 存储在bunny.net边缘存储中

Paths

路径

Any UTF-8 string.
list()
uses prefix matching (not directory listing):
  • prefix: "/users"
    matches
    /users/alice.txt
    AND
    /users-backup/data.bin
  • prefix: "/users/"
    matches only under
    /users/
支持任意UTF-8字符串。
list()
方法使用前缀匹配(而非目录遍历):
  • prefix: "/users"
    会匹配
    /users/alice.txt
    /users-backup/data.bin
  • prefix: "/users/"
    仅匹配
    /users/
    路径下的文件

Blob lifecycle

Blob生命周期

Upload → Pending (4h TTL) → Committed (refCount=1) → Deleted (refCount=0) → GC cleanup
  • Reference counting: copy increments refCount (zero-copy), delete decrements it.
  • Garbage collection: 3 automatic jobs — upload GC (hourly), blob GC (hourly), file expiration GC (every 15s).
  • Grace period: orphaned blobs retained for
    blobGracePeriod
    (default 24h) before permanent deletion.
上传 → 待处理(4小时TTL)→ 已提交(引用计数=1)→ 已删除(引用计数=0)→ 垃圾回收清理
  • 引用计数:复制操作会增加引用计数(零拷贝),删除操作会减少引用计数。
  • 垃圾回收:3个自动任务 — 上传垃圾回收(每小时)、Blob垃圾回收(每小时)、文件过期垃圾回收(每15秒)。
  • 宽限期:孤立Blob在永久删除前会保留
    blobGracePeriod
    时长(默认24小时)。

Attributes

属性

typescript
interface FileAttributes {
  expiresAt?: number; // Unix timestamp — auto-deleted by FGC
}
Attributes are path-specific: cleared on move, not inherited on copy, removed on overwrite.
typescript
interface FileAttributes {
  expiresAt?: number; // Unix时间戳 — 由FGC自动删除
}
属性是路径专属的:移动文件时会清除属性,复制文件时不会继承属性,覆盖文件时会移除属性。

Core API

核心API

Query methods

查询方法

typescript
// Get file metadata by path
const file = await fs.stat(ctx, "/uploads/photo.jpg");
// Returns: { path, blobId, contentType, size, attributes } | null

// List files with pagination
const result = await fs.list(ctx, {
  prefix: "/uploads/",
  paginationOpts: { numItems: 50, cursor: null },
});
// Returns: { page: FileMetadata[], continueCursor, isDone }
typescript
// 根据路径获取文件元数据
const file = await fs.stat(ctx, "/uploads/photo.jpg");
// 返回:{ path, blobId, contentType, size, attributes } | null

// 分页列出文件
const result = await fs.list(ctx, {
  prefix: "/uploads/",
  paginationOpts: { numItems: 50, cursor: null },
});
// 返回:{ page: FileMetadata[], continueCursor, isDone }

Mutation methods

变更方法

typescript
// Commit uploaded blobs to paths
await fs.commitFiles(ctx, [
  { path: "/file.txt", blobId: "uuid-here" },                     // overwrite if exists
  { path: "/new.txt", blobId: "uuid", basis: null },               // FAIL if exists
  { path: "/update.txt", blobId: "new-uuid", basis: "old-uuid" },  // CAS: fail if changed
]);

// Atomic multi-operation transaction
await fs.transact(ctx, [
  { op: "move", source: file, dest: { path: "/new/path.txt" } },
  { op: "copy", source: file, dest: { path: "/backup.txt", basis: null } },
  { op: "delete", source: file },
  { op: "setAttributes", source: file, attributes: { expiresAt: Date.now() + 3600000 } },
]);

// Convenience methods
await fs.move(ctx, "/old.txt", "/new.txt");  // throws if source missing or dest exists
await fs.copy(ctx, "/a.txt", "/b.txt");      // throws if source missing or dest exists
await fs.delete(ctx, "/file.txt");           // idempotent (no-op if missing)
Basis values (for
commitFiles
and
transact
destinations):
ValueMeaning
undefined
No check — overwrite if exists
null
File must NOT exist
"blobId"
File's current blobId must match (compare-and-swap)
typescript
// 将上传的Blob提交至指定路径
await fs.commitFiles(ctx, [
  { path: "/file.txt", blobId: "uuid-here" },                     // 若已存在则覆盖
  { path: "/new.txt", blobId: "uuid", basis: null },               // 若已存在则失败
  { path: "/update.txt", blobId: "new-uuid", basis: "old-uuid" },  // CAS:若文件已变更则失败
]);

// 原子多操作事务
await fs.transact(ctx, [
  { op: "move", source: file, dest: { path: "/new/path.txt" } },
  { op: "copy", source: file, dest: { path: "/backup.txt", basis: null } },
  { op: "delete", source: file },
  { op: "setAttributes", source: file, attributes: { expiresAt: Date.now() + 3600000 } },
]);

// 便捷方法
await fs.move(ctx, "/old.txt", "/new.txt");  // 若源文件不存在或目标文件已存在则抛出异常
await fs.copy(ctx, "/a.txt", "/b.txt");      // 若源文件不存在或目标文件已存在则抛出异常
await fs.delete(ctx, "/file.txt");           // 幂等操作(若文件不存在则无操作)
Basis值(用于
commitFiles
transact
的目标参数):
含义
undefined
无检查 — 若已存在则覆盖
null
文件必须不存在
"blobId"
文件当前的blobId必须匹配(比较交换)

Action methods

动作方法

typescript
// Generate signed download URL
const url = await fs.getDownloadUrl(ctx, blobId, { extraParams: { filename: "doc.pdf" } });

// Download blob data
const data = await fs.getBlob(ctx, blobId); // ArrayBuffer | null

// Download file contents + metadata
const result = await fs.getFile(ctx, "/file.txt"); // { data, contentType, size } | null

// Upload data and get blobId
const blobId = await fs.writeBlob(ctx, imageData, "image/webp");

// Upload + commit in one call
await fs.writeFile(ctx, "/report.pdf", pdfData, "application/pdf");
typescript
// 生成签名下载URL
const url = await fs.getDownloadUrl(ctx, blobId, { extraParams: { filename: "doc.pdf" } });

// 下载Blob数据
const data = await fs.getBlob(ctx, blobId); // ArrayBuffer | null

// 下载文件内容 + 元数据
const result = await fs.getFile(ctx, "/file.txt"); // { data, contentType, size } | null

// 上传数据并获取blobId
const blobId = await fs.writeBlob(ctx, imageData, "image/webp");

// 上传并提交一步完成
await fs.writeFile(ctx, "/report.pdf", pdfData, "application/pdf");

Client utilities

客户端工具

typescript
import { buildDownloadUrl } from "convex-fs";

const url = buildDownloadUrl(siteUrl, "/fs", file.blobId, file.path, { filename: "doc.pdf" });
typescript
import { buildDownloadUrl } from "convex-fs";

const url = buildDownloadUrl(siteUrl, "/fs", file.blobId, file.path, { filename: "doc.pdf" });

Upload & Serve Flow

上传与分发流程

Upload (React)

上传(React)

tsx
const handleUpload = async (file: File) => {
  const siteUrl = (import.meta.env.VITE_CONVEX_URL ?? "").replace(/\.cloud$/, ".site");

  // 1. Upload blob
  const res = await fetch(`${siteUrl}/fs/upload`, {
    method: "POST",
    headers: { "Content-Type": file.type },
    body: file,
  });
  const { blobId } = await res.json();

  // 2. Commit to path (via mutation)
  await commitFile({ blobId, filename: file.name });
};
tsx
const handleUpload = async (file: File) => {
  const siteUrl = (import.meta.env.VITE_CONVEX_URL ?? "").replace(/\.cloud$/, ".site");

  // 1. 上传Blob
  const res = await fetch(`${siteUrl}/fs/upload`, {
    method: "POST",
    headers: { "Content-Type": file.type },
    body: file,
  });
  const { blobId } = await res.json();

  // 2. 提交至指定路径(通过变更操作)
  await commitFile({ blobId, filename: file.name });
};

Serve (React)

分发(React)

tsx
import { buildDownloadUrl } from "convex-fs";

function Image({ path }: { path: string }) {
  const file = useQuery(api.files.getFile, { path });
  const siteUrl = (import.meta.env.VITE_CONVEX_URL ?? "").replace(/\.cloud$/, ".site");

  if (!file) return <div>Loading...</div>;
  const url = buildDownloadUrl(siteUrl, "/fs", file.blobId, file.path);
  return <img src={url} alt={path} />;
}
tsx
import { buildDownloadUrl } from "convex-fs";

function Image({ path }: { path: string }) {
  const file = useQuery(api.files.getFile, { path });
  const siteUrl = (import.meta.env.VITE_CONVEX_URL ?? "").replace(/\.cloud$/, ".site");

  if (!file) return <div>加载中...</div>;
  const url = buildDownloadUrl(siteUrl, "/fs", file.blobId, file.path);
  return <img src={url} alt={path} />;
}

Security Rules

安全规则

  1. Always authenticate uploads — open upload endpoints are a serious risk.
  2. Use path-based authorization — validate path ownership in
    downloadAuth
    .
  3. Enable token authentication on bunny.net — prevents URL tampering.
  4. Set appropriate URL TTLs: sensitive content 60–300s, general 3600s (default), streaming 3600s+.
  1. 始终验证上传权限 — 开放的上传端点存在严重安全风险。
  2. 使用基于路径的授权 — 在
    downloadAuth
    中验证路径归属。
  3. 在bunny.net上启用令牌认证 — 防止URL篡改。
  4. 设置合适的URL有效期:敏感内容60–300秒,普通内容3600秒(默认),流媒体内容3600秒以上。

Conflict Handling

冲突处理

typescript
import { ConvexError } from "convex/values";
import { isConflictError } from "convex-fs";

try {
  await fs.commitFiles(ctx, files);
} catch (e) {
  if (e instanceof ConvexError && isConflictError(e.data)) {
    // e.data: { code, path, expected, found }
    // Codes: SOURCE_NOT_FOUND, SOURCE_CHANGED, DEST_EXISTS, DEST_NOT_FOUND, DEST_CHANGED, CAS_CONFLICT
  }
}
typescript
import { ConvexError } from "convex/values";
import { isConflictError } from "convex-fs";

try {
  await fs.commitFiles(ctx, files);
} catch (e) {
  if (e instanceof ConvexError && isConflictError(e.data)) {
    // e.data: { code, path, expected, found }
    // 错误码:SOURCE_NOT_FOUND, SOURCE_CHANGED, DEST_EXISTS, DEST_NOT_FOUND, DEST_CHANGED, CAS_CONFLICT
  }
}

Constructor Options

构造函数选项

OptionTypeDefaultDescription
storage
StorageConfig
requiredBunny.net backend config
downloadUrlTtl
number
3600
Signed URL expiration (seconds)
blobGracePeriod
number
86400
Orphaned blob retention (seconds)
选项类型默认值描述
storage
StorageConfig
必填bunny.net后端配置
downloadUrlTtl
number
3600
签名URL有效期(秒)
blobGracePeriod
number
86400
孤立Blob保留时长(秒)

Reference Files

参考文档

  • Patterns & examples: User files, temp files, atomic ops, CAS updates, retry, pagination, React hooks → See references/examples.md
  • Advanced topics: Multiple filesystems, disaster recovery, testing, GC details, Bunny.net setup, TypeScript types, troubleshooting → See references/advanced.md
  • 模式与示例:用户文件、临时文件、原子操作、CAS更新、重试、分页、React钩子 → 查看references/examples.md
  • 高级主题:多文件系统、灾难恢复、测试、垃圾回收细节、bunny.net配置、TypeScript类型、故障排除 → 查看references/advanced.md