How to Implement a Custom Mount Provider for Cloudflare R2 or Other Storage Backends in Computer
Implement a custom mount provider by creating a function that returns an EagerMount object with a materialize(api) method, following the contract defined in packages/computer/src/mounts/types.ts.
The Computer library lets you mount external storage—R2, GitHub, or any custom backend—into a virtual workspace via a lightweight, type-driven provider pattern. A mount provider is simply a factory function that conforms to the EagerMount interface and streams files into the VFS using the MountWriteAPI during workspace initialization.
Understanding the Mount Provider Architecture
At its core, every mount provider must satisfy two contracts defined in the source:
MountBase(packages/computer/src/mounts/types.ts): Specifieskind(identifier string),mode("read-only"or"read-write"), and optionalmaxBytes/maxEntriescaps.EagerMount: ExtendsMountBasewithstrategy: "eager"and amaterialize(api: MountWriteAPI)async method.
The workspace invokes materialize() exactly once—the first cold start—then persists an indexed flag in _vfs_mounts to skip re-materialization on subsequent boots (packages/computer/src/mounts/registry.ts).
Step 1: Define the Provider Interface
Start by modeling your backend's minimal surface area. The R2 implementation demonstrates that you only need three operations: list objects, get object contents, and optionally handle pagination cursors.
// packages/computer/src/mounts/providers/custom.ts
import type { EagerMount, MountWriteAPI } from "../types.js";
export interface MyBackend {
list(options?: { prefix?: string; cursor?: string; limit?: number }): Promise<{
objects: Array<{ key: string; size: number }>;
truncated: boolean;
cursor?: string;
}>;
get(key: string): Promise<{ body: ReadableStream<Uint8Array>; size: number } | null>;
}
export interface MyBackendOptions {
prefix?: string;
listLimit?: number;
concurrency?: number;
maxBytes?: number;
maxEntries?: number;
}
Step 2: Implement Pagination and Concurrency Helpers
The R2 provider in packages/computer/src/mounts/providers/r2.ts uses two reusable patterns: async generator pagination and bounded-concurrency fan-out.
Pagination helper streams all keys without buffering the entire dataset into memory:
function normalisePrefix(p?: string): string {
if (!p) return "";
return p.endsWith("/") ? p : `${p}/`;
}
async function* paginate(
backend: MyBackend,
prefix: string,
limit: number,
): AsyncGenerator<{ key: string; size: number }> {
let cursor: string | undefined;
while (true) {
const page = await backend.list({ prefix: prefix || undefined, cursor, limit });
for (const obj of page.objects) yield obj;
if (!page.truncated || !page.cursor) return;
cursor = page.cursor;
}
}
Bounded-concurrency runner limits simultaneous get calls to respect backend rate limits:
async function runBounded<T>(tasks: Array<() => Promise<T>>, concurrency: number): Promise<void> {
if (concurrency < 1) concurrency = 1;
let next = 0;
const workers: Promise<void>[] = [];
let firstError: unknown;
for (let i = 0; i < Math.min(concurrency, tasks.length); i++) {
workers.push(
(async () => {
while (true) {
if (firstError !== undefined) return;
const idx = next++;
if (idx >= tasks.length) return;
try {
await tasks[idx]();
} catch (e) {
if (firstError === undefined) firstError = e;
return;
}
}
})(),
);
}
await Promise.all(workers);
if (firstError !== undefined) throw firstError;
}
Step 3: Build the EagerMount Factory
Combine these helpers into the final provider function. The materialize method receives api: MountWriteAPI with two critical methods: writeFile(path, stream) and mkdir(path).
export function MyBackendMount(backend: MyBackend, opts: MyBackendOptions = {}): EagerMount {
const prefix = normalisePrefix(opts.prefix);
const listLimit = opts.listLimit ?? 1000;
const concurrency = opts.concurrency ?? 8;
return {
kind: "my-backend",
mode: "read-only",
strategy: "eager",
maxBytes: opts.maxBytes,
maxEntries: opts.maxEntries,
async materialize(api: MountWriteAPI) {
// Collect all keys via pagination.
const keys: Array<{ key: string; size: number }> = [];
for await (const obj of paginate(backend, prefix, listLimit)) {
keys.push(obj);
}
// Fan-out get operations with bounded concurrency.
const tasks = keys.map((entry) => async () => {
const payload = await backend.get(entry.key);
if (!payload) {
throw new Error(`MyBackendMount: object vanished mid-materialize: ${entry.key}`);
}
const relKey = prefix ? entry.key.slice(prefix.length) : entry.key;
if (!relKey) return; // Skip the prefix-object itself.
await api.writeFile(`${api.root}/${relKey}`, payload.body);
});
await runBounded(tasks, concurrency);
},
};
}
Key implementation details from the R2 reference:
api.rootcontains the absolute VFS path (e.g.,/workspace/my) prepopulated by the workspace.- Streaming: Pass the raw
ReadableStream<Uint8Array>directly towriteFile—no buffering required. - Prefix stripping: Remove the configured prefix so
data/file.txtmounts as/workspace/my/file.txt, not/workspace/my/data/file.txt.
Step 4: Export and Register the Provider
Add your provider to the public exports in packages/computer/src/index.ts:
export { MyBackendMount } from "./mounts/providers/custom.js";
Instantiate the workspace with a mounts map. Each key is an absolute VFS path; each value is the mount instance:
import { Workspace } from "@cloudflare/computer";
import { MyBackendMount } from "@cloudflare/computer/mounts/providers/custom";
const backend = env.MY_BACKEND as MyBackend;
const ws = await Workspace.create({
sessionId: "user-123",
db: env.DATABASE,
mounts: {
"/workspace/my": MyBackendMount(backend, {
prefix: "data/",
maxBytes: 50_000_000,
concurrency: 16,
})
},
});
The Workspace constructor (in packages/computer/src/workspace.ts) iterates the mounts object, invokes materialize() for any unindexed mount, and records completion in _vfs_mounts.
R2-Specific Pattern for Cloudflare Workers
For Cloudflare R2 specifically, use the built-in R2Bucket provider in packages/computer/src/mounts/providers/r2.ts. The pattern is identical to custom providers—the R2 binding itself satisfies the MyBackend interface above:
import { R2Bucket } from "@cloudflare/computer/mounts/providers/r2";
const ws = await Workspace.create({
mounts: {
"/workspace/r2": R2Bucket(env.MY_BUCKET, {
prefix: "uploads/",
maxEntries: 10_000,
})
},
});
The R2 provider adds R2-specific optimizations: automatic HEAD request avoidance when size is known, and integration with R2's native ReadableStream implementation.
Key Source Files Reference
| File | Purpose |
|---|---|
packages/computer/src/mounts/types.ts |
MountBase, EagerMount, MountWriteAPI definitions |
packages/computer/src/mounts/providers/r2.ts |
Reference implementation showing pagination, streaming, concurrency |
packages/computer/src/workspace.ts |
Workspace orchestration and mount lifecycle |
packages/computer/src/mounts/registry.ts |
Indexed flag persistence in _vfs_mounts |
examples/worker-shell/src/index.ts |
Real-world R2 mount example |
Summary
- Custom mount providers implement the
EagerMountcontract frompackages/computer/src/mounts/types.ts. - The
materialize(api)method receivesMountWriteAPIto stream files viawriteFileand create directories viamkdir. - Pagination and bounded concurrency are reusable patterns from the R2 reference implementation.
- Registration happens through the
mountsoption inWorkspace.create(), with automatic indexed-flag caching to skip re-materialization. - Export your provider in
packages/computer/src/index.tsfor consumer access.
Frequently Asked Questions
What is the difference between EagerMount and lazy mount strategies?
EagerMount with strategy: "eager" materializes all files during workspace cold start, blocking until the backend is fully indexed. Computer currently optimizes for eager mounts because they provide predictable, filesystem-like latency after initialization. Lazy or on-demand strategies are not yet implemented in the public API according to packages/computer/src/mounts/types.ts.
How do I enforce storage limits like maxBytes or maxEntries?
The workspace enforces caps automatically when the mount specifies maxBytes or maxEntries in its returned object. During materialize(), the workspace tracks cumulative bytes and entry count; exceeding either limit aborts materialization and throws. Your provider should not pre-filter—return the full dataset and let the workspace apply limits consistently.
Can I implement write support for my custom backend?
Change mode to "read-write" and implement a MountReadWriteAPI, though the current source in packages/computer/src/mounts/types.ts shows mode as primarily informational. Full write support requires additional methods beyond materialize, such as sync() or change watchers. The R2 provider currently uses "read-only"; inspect future releases for expanded MountWriteAPI surface area.
Why does materialization only run once per mount path?
The workspace persists an indexed flag in the _vfs_mounts SQLite table via packages/computer/src/mounts/registry.ts. On subsequent boots, the workspace checks this flag and skips materialize() if set. To force re-materialization (e.g., after backend changes), clear the mount's entry from _vfs_mounts or use a different mount path.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →