This document explains the BucketFS plugin architecture and how to create new storage provider plugins.
BucketFS uses a plugin-based architecture where each storage provider (AWS S3, Google Cloud Storage, Memory, etc.) is implemented as an isolated plugin. Plugins are lazy-loaded only when their provider is used, ensuring dependencies aren't loaded unless needed.
┌─────────────────┐
│ initBucket() │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Plugin Registry │
└────────┬────────┘
│
▼
┌────────┐
│ Plugin │ (lazy-loaded)
└────────┘
│
▼
┌─────────────────┐
│ bucketCore.ts │
│ (delegates to │
│ plugin methods)│
└─────────────────┘
ProviderPluginInterface (src/plugin.ts): Defines the contract all plugins must implementPluginRegistry(src/plugin.ts): Manages plugin registration and lazy loading- Plugin Implementations (
src/plugins/): Individual provider plugins - Core Operations (
src/bucketCore.ts): Delegates all operations to plugin methods
All plugins must implement the ProviderPlugin interface, which defines these methods:
interface ProviderPlugin {
write(bucketName: string, path: string, content: string | Uint8Array): Promise<void>;
read(bucketName: string, path: string): Promise<string | null>;
readBuffer(bucketName: string, path: string): Promise<Uint8Array | null>;
delete(bucketName: string, path: string): Promise<void>;
move(bucketName: string, oldPath: string, newPath: string): Promise<void>;
list(bucketName: string, prefix?: string): Promise<string[]>;
exists(bucketName: string, path: string): Promise<boolean>;
checkAuth(bucketName: string): Promise<boolean>;
}write: Write content (string or binary) to a file at the specified pathread: Read file content as a UTF-8 string, returnsnullif file doesn't existreadBuffer: Read file content as binary (Uint8Array), returnsnullif file doesn't existdelete: Delete a file at the specified pathmove: Move/rename a file fromoldPathtonewPath(typically implemented as copy + delete)list: List all file paths, optionally filtered byprefix(directory path)exists: Check if a file exists at the specified pathcheckAuth: Verify authentication/connection to the storage service
Plugins are automatically discovered and loaded when needed. The registry:
- Checks if a plugin factory is already registered
- If not, dynamically imports the plugin from
src/plugins/{provider}/index.ts - Expects the plugin module to export a
createPluginfunction - Caches plugin instances per
provider:bucketNamecombination
Follow these steps to add a new storage provider:
Create a new directory under src/plugins/ with your provider name:
src/plugins/
└── your-provider/
└── index.ts
Create a class that implements ProviderPlugin:
import type { ProviderPlugin } from "../../plugin.ts";
class YourProviderPlugin implements ProviderPlugin {
private client: YourSDKClient;
constructor(client: YourSDKClient) {
this.client = client;
}
async write(bucketName: string, path: string, content: string | Uint8Array): Promise<void> {
// Implement write logic
}
async read(bucketName: string, path: string): Promise<string | null> {
// Implement read logic
}
// ... implement all other methods
}Export a createPlugin function that:
- Validates required configuration
- Initializes the SDK client
- Returns a new plugin instance
export async function createPlugin(config: {
provider: string;
bucketName: string;
[key: string]: unknown;
}): Promise<ProviderPlugin> {
// Validate required fields
if (!config.yourRequiredField) {
throw new Error("Your required field is missing");
}
// Lazy load SDK (if using dynamic imports)
const { YourSDKClient } = await import("your-sdk"); // Uses import map from deno.json
// Initialize client
const client = new YourSDKClient({
// ... configuration
});
// Return plugin instance
return new YourProviderPlugin(client);
}If your plugin uses external dependencies:
- Add them to
deno.jsonunderimports:
{
"imports": {
"your-sdk": "npm:your-sdk@^1.0.0"
}
}- Import using the import map key:
import { YourSDKClient } from "your-sdk";Or use dynamic imports for lazy loading:
const { YourSDKClient } = await import("your-sdk");Add your provider to the StorageProvider type in src/bucketConfig.ts:
export type StorageProvider =
| "aws-s3"
| "cf-r2"
| "gcs"
| "do-spaces"
| "memory"
| "fs"
| "your-provider"; // Add your provider hereIf your provider needs specific configuration fields, extend the BucketConfig interface:
export interface BucketConfig {
provider: StorageProvider;
bucketName: string;
// ... existing fields
yourProviderField?: string; // Add your provider-specific fields
}Note: The plugin's createPlugin function receives the full BucketConfig object and is responsible for extracting
and validating its own required fields. You don't need to add provider-specific validation in bucketConfig.ts.
The memory plugin is the simplest example - no external dependencies, no configuration needed:
// src/plugins/memory/index.ts
import type { ProviderPlugin } from "../../plugin.ts";
import { MemoryStorage } from "../../memoryStorage.ts";
class MemoryPlugin implements ProviderPlugin {
private storage: MemoryStorage;
constructor() {
this.storage = new MemoryStorage();
}
write(_bucketName: string, path: string, content: string | Uint8Array): Promise<void> {
this.storage.write(path, content);
return Promise.resolve();
}
// ... other methods
}
export async function createPlugin(config: {
provider: string;
bucketName: string;
[key: string]: unknown;
}): Promise<ProviderPlugin> {
return new MemoryPlugin();
}The filesystem plugin shows how to validate configuration:
// src/plugins/fs/index.ts
export async function createPlugin(config: {
provider: string;
bucketName: string;
rootDirectory?: string;
[key: string]: unknown;
}): Promise<ProviderPlugin> {
if (!config.rootDirectory) {
throw new Error("Root directory is required for filesystem provider");
}
return new FSPlugin(config.rootDirectory);
}The AWS S3 plugin demonstrates:
- Using external SDKs
- Lazy loading SDK commands
- Handling SDK-specific errors
// src/plugins/aws-s3/index.ts
import type { ProviderPlugin } from "../../plugin.ts";
import type { S3Client } from "@aws-sdk/client-s3";
class AWSS3Plugin implements ProviderPlugin {
private client: S3Client;
constructor(client: S3Client) {
this.client = client;
}
async write(bucketName: string, path: string, content: string | Uint8Array): Promise<void> {
const { PutObjectCommand } = await import("@aws-sdk/client-s3");
const command = new PutObjectCommand({
Bucket: bucketName,
Key: path,
Body: content,
});
await this.client.send(command);
}
// ... other methods
}
export async function createPlugin(config: {
provider: string;
bucketName: string;
region?: string;
credentials?: {
accessKeyId?: string;
secretAccessKey?: string;
};
[key: string]: unknown;
}): Promise<ProviderPlugin> {
if (!config.region) {
throw new Error("Region is required for AWS S3 provider");
}
if (!config.credentials?.accessKeyId || !config.credentials?.secretAccessKey) {
throw new Error("Access key and secret key are required for AWS S3 provider");
}
const { S3Client } = await import("@aws-sdk/client-s3");
const client = new S3Client({
region: config.region,
credentials: {
accessKeyId: config.credentials.accessKeyId,
secretAccessKey: config.credentials.secretAccessKey,
},
});
return new AWSS3Plugin(client);
}Always validate required configuration in your createPlugin function:
export async function createPlugin(config: YourPluginConfig): Promise<ProviderPlugin> {
if (!config.requiredField) {
throw new Error("Required field is missing");
}
// ...
}Handle provider-specific errors appropriately:
async checkAuth(bucketName: string): Promise<boolean> {
try {
// Test connection
return true;
} catch (error) {
if (error && typeof error === "object") {
if ("name" in error && error.name === "Forbidden") {
return false;
}
// Handle other error types
}
throw error;
}
}If your plugin doesn't use the bucketName parameter (e.g., memory or filesystem plugins), prefix it with _:
write(_bucketName: string, path: string, content: string | Uint8Array): Promise<void> {
// bucketName not used
}- If your operations are synchronous, return
Promise.resolve()instead of usingasync:
write(_bucketName: string, path: string, content: string | Uint8Array): Promise<void> {
this.storage.write(path, content);
return Promise.resolve();
}- If your operations are asynchronous, use
async/await:
async write(bucketName: string, path: string, content: string | Uint8Array): Promise<void> {
await this.client.upload(path, content);
}For large SDKs, consider lazy loading commands/classes:
private async getSDKCommands() {
if (!this.sdkCommands) {
const sdkModule = await import("your-sdk");
this.sdkCommands = {
Command1: sdkModule.Command1,
Command2: sdkModule.Command2,
};
}
return this.sdkCommands;
}Use TypeScript types for better type safety:
import type { ProviderPlugin } from "../../plugin.ts";
import type { YourSDKClient } from "your-sdk";Create a test file in the test/ directory:
// test/your_provider_test.ts
import { assertEquals } from "@std/assert";
import { deleteFile, fileExists, initBucket, listFiles, readFile, writeFile } from "../mod.ts";
Deno.test("Your Provider - Basic Operations", async () => {
await initBucket({
provider: "your-provider",
bucketName: "test-bucket",
// ... your configuration
});
await writeFile("test.txt", "Hello World");
assertEquals(await fileExists("test.txt"), true);
assertEquals(await readFile("test.txt"), "Hello World");
const files = await listFiles();
assertEquals(files.includes("test.txt"), true);
await deleteFile("test.txt");
assertEquals(await fileExists("test.txt"), false);
});To add a new provider plugin:
- ✅ Create
src/plugins/{provider}/index.ts - ✅ Implement
ProviderPlugininterface - ✅ Export
createPluginfunction - ✅ Add dependencies to
deno.json(if needed) - ✅ Update
StorageProvidertype inbucketConfig.ts - ✅ Add configuration fields to
BucketConfig(if needed) - ✅ Write tests
- ✅ Update README.md with usage examples
The plugin system handles the rest automatically - your plugin will be discovered and loaded when users initialize a bucket with your provider!