Loading...
Loading...
ConvexFS (convex-fs) — path-based file storage and serving component for Convex powered by bunny.net CDN. Covers installation, setup, file upload/download flows, path management, blob lifecycle, atomic transactions (move/copy/delete), compare-and-swap, signed URLs, file expiration, garbage collection, auth for uploads/downloads, multiple filesystems, React integration, and production best practices. Use when working with ConvexFS, uploading files in Convex, serving files via CDN, managing file paths, building file storage features, or configuring bunny.net with Convex. Triggers on: convex-fs, ConvexFS, bunny.net, file upload, file storage convex, blob, commitFiles, registerRoutes, buildDownloadUrl, fs.stat, fs.list, fs.transact, fs.move, fs.copy, fs.delete, fs.writeFile, fs.getDownloadUrl, "how do I upload files in Convex", "serve files from Convex", "ConvexFS setup".
npx skill4agent add imfa-solutions/skills convex-file-system— Path-based file storage with global CDN delivery via bunny.net.convex-fs
npm install convex-fs// 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;// 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
},
});// 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/uploadGET /fs/blobs/{blobId}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)React Client ──▶ Convex Backend (ConvexFS) ──▶ bunny.net CDN (File Storage + Edge)list()prefix: "/users"/users/alice.txt/users-backup/data.binprefix: "/users/"/users/Upload → Pending (4h TTL) → Committed (refCount=1) → Deleted (refCount=0) → GC cleanupblobGracePeriodinterface FileAttributes {
expiresAt?: number; // Unix timestamp — auto-deleted by FGC
}// 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 }// 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)commitFilestransact| Value | Meaning |
|---|---|
| No check — overwrite if exists |
| File must NOT exist |
| File's current blobId must match (compare-and-swap) |
// 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");import { buildDownloadUrl } from "convex-fs";
const url = buildDownloadUrl(siteUrl, "/fs", file.blobId, file.path, { filename: "doc.pdf" });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 });
};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} />;
}downloadAuthimport { 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
}
}| Option | Type | Default | Description |
|---|---|---|---|
| | required | Bunny.net backend config |
| | | Signed URL expiration (seconds) |
| | | Orphaned blob retention (seconds) |