How to Build Custom Media Storage Adapters for External Providers in Instatic
You can build custom media storage adapters in Instatic by implementing the MediaStorageAdapter interface with beginWrite, finalizeWrite, and optional lifecycle methods, then registering the adapter via api.cms.media.registerStorageAdapter() to handle uploads for specific asset roles like S3, R2, or Azure Blob.
Instatic’s media subsystem uses a pluggable architecture that supports external storage providers through self-contained adapters. According to the CoreBunch/Instatic source code, plugins can register adapters that delegate file operations to cloud providers while the host manages the actual byte streaming, ensuring security and performance.
Understanding the Media Storage Adapter Architecture
Instatic treats media storage as a capability-based plugin system. Instead of hardcoding provider logic, the platform delegates upload and retrieval operations to elected adapters through a strict contract defined in src/core/plugin-sdk/types/media.ts.
The Three-Phase Upload Contract
Every adapter must implement a two-phase write contract orchestrated by the host:
-
beginWrite– The adapter receives file metadata and returns aMediaStorageUploadPlancontaining signed URLs and HTTP methods (typically PUT or POST). -
Host streams bytes – The Instatic host uses Bun’s native
fetchto stream the payload directly to the signed URL. For the built-in local-disk adapter, it writes directly to theuploads/directory instead. -
finalizeWrite– After successful upload, the adapter validates the operation and returns the final public URL to persist in the database.
If any phase fails, the host automatically calls adapter.abortWrite() to prevent stray files. This flow is dispatched from server/handlers/cms/mediaUploadDispatch.ts, which looks up the elected adapter per asset role and manages the lifecycle.
Security Boundary and QuickJS Sandbox
Bytes never cross the QuickJS sandbox. Adapters running inside the plugin sandbox only generate signed URLs and metadata; the host performs all network I/O. This invariant is enforced by the architecture test media-storage-no-bytes-in-sandbox.test.ts, ensuring that untrusted plugin code cannot intercept file contents.
Implementing the MediaStorageAdapter Interface
To build a custom adapter, create an object satisfying the MediaStorageAdapter interface exported from the plugin SDK.
Required Interface Methods
At minimum, your adapter must provide:
id– A unique namespaced identifier (e.g.,my-plugin.s3).label– Human-readable text for the admin UI.roles– Array ofMediaAssetRolevalues ('original','variant','avatar','font','plugin-pack') indicating which upload types this adapter handles.servingMode– Either'public-url'(return CDN URL) or'proxy'(stream through Instatic).beginWrite(input)– Returns aMediaStorageUploadPlanwithstoragePath,expiresAt, andstepsarray containing HTTP method, URL, and headers.finalizeWrite(input)– Confirms upload completion and returns{ publicUrl }.
Optional methods include abortWrite() for cleanup, delete() for asset removal, and verify() for configuration health checks.
Adapter Registration via Plugin SDK
Plugins must declare the media.storage.adapter capability in their manifest (defined in src/core/plugin-sdk/capabilities.ts at line 187). During initialization, call the host-exposed API:
api.cms.media.registerStorageAdapter(s3Adapter)
The host’s QuickJS bridge forwards this to MediaStorageRegistry.register() in src/core/plugins/mediaStorageRegistry.ts, storing your adapter in the singleton mediaStorageRegistry.
Complete S3-Compatible Adapter Example
Here is a production-ready pattern for an S3-compatible adapter that handles all asset roles:
// src/plugin/adapters/s3Adapter.ts
import type {
MediaStorageAdapter,
MediaStorageBeginWriteInput,
MediaStorageUploadPlan,
MediaStorageFinalizeWriteInput,
MediaStorageWriteResult,
} from '@core/plugin-sdk'
async function signS3Url(path: string, method: 'PUT' | 'POST'): Promise<string> {
// Your AWS SDK v3 signing logic here
return `https://my-bucket.s3.amazonaws.com/${path}?X-Amz-Signature=...`
}
async function deleteObjectFromS3(path: string): Promise<void> {
// AWS SDK deletion logic
}
export const s3Adapter: MediaStorageAdapter = {
id: 'my-plugin.s3',
label: 'Amazon S3',
roles: ['original', 'variant', 'avatar', 'font', 'plugin-pack'],
servingMode: 'public-url',
async beginWrite(input: MediaStorageBeginWriteInput): Promise<MediaStorageUploadPlan> {
const storagePath = `${Date.now()}_${input.suggestedStoragePath}`
const url = await signS3Url(storagePath, 'PUT')
return {
storagePath,
expiresAt: Date.now() + 5 * 60_000, // 5 minute expiry
steps: [{ method: 'PUT', url, headers: {} }],
}
},
async finalizeWrite({ storagePath }: MediaStorageFinalizeWriteInput): Promise<MediaStorageWriteResult> {
const publicUrl = `https://my-bucket.s3.amazonaws.com/${storagePath}`
return { publicUrl }
},
async abortWrite({ storagePath }: { storagePath: string }): Promise<void> {
await deleteObjectFromS3(storagePath)
},
async delete(storagePath: string): Promise<void> {
await deleteObjectFromS3(storagePath)
},
async verify() {
const ok = await canPutTestObject() // Your implementation
return ok
? { ok: true }
: { ok: false, reason: 'S3 bucket not reachable' }
},
}
// Register at plugin startup
api.cms.media.registerStorageAdapter(s3Adapter)
All contract fields are validated against TypeBox schemas in src/core/plugin-sdk/storageSchemas.ts. Avoid type assertions at the boundary.
Adapter Election and Configuration
Once registered, adapters must be elected for specific asset roles through the admin interface or REST API.
The Active Adapter Registry
Per-role adapter assignments are persisted in the active_media_storage_adapter table, created by migrations in server/db/migrations-pg.ts (line 630) and server/db/migrations-sqlite.ts (line 570). The repository layer in server/repositories/mediaStorageAdapters.ts handles CRUD operations for these records.
Electing Adapters for Asset Roles
Administrators configure storage via the Media Storage Panel at src/admin/pages/media/components/MediaStoragePanel/MediaStoragePanel.tsx. The UI fetches available adapters from mediaStorageRegistry.list() and persists elections via:
curl -X POST https://my-instatic.local/_instatic/api/media/storage/elect \
-H "Authorization: Bearer <admin-token>" \
-H "Content-Type: application/json" \
-d '{
"role": "original",
"adapterId": "my-plugin.s3"
}'
Each asset role (original, variant, avatar, etc.) can use a different adapter. When serving files, mediaStorageRegistry.resolveForRead() retrieves the stored adapter ID from the media record and returns either the cached public URL or proxies the request.
Verification and Health Checks
Before electing an adapter, administrators can verify configuration via POST /media/storage/verify. The host resolves the adapter using mediaStorageRegistry.resolve(adapterId, role) and invokes adapter.verify(), returning a MediaStorageVerifyResult defined in the storage schemas.
Summary
- Implement the interface: Create a
MediaStorageAdapterwithbeginWrite,finalizeWrite, and optional lifecycle methods, ensuring all return types match the TypeBox schemas insrc/core/plugin-sdk/storageSchemas.ts. - Register via plugin: Call
api.cms.media.registerStorageAdapter()during plugin initialization after declaring themedia.storage.adaptercapability. - Never stream bytes: Return signed URLs only; the host handles所有的 byte streaming via Bun’s
fetch, maintaining the security boundary. - Configure per role: Adapters are elected per asset role (
original,variant, etc.) through theactive_media_storage_adaptertable and managed via the admin UI or REST API. - Handle cleanup: Implement
abortWriteto delete partial uploads andverifyto validate configuration before production use.
Frequently Asked Questions
What file paths should I reference when debugging adapter registration?
Consult src/core/plugins/mediaStorageRegistry.ts for the registry singleton logic, server/handlers/cms/mediaUploadDispatch.ts for the upload orchestration, and src/core/plugin-sdk/capabilities.ts for the required capability declaration at line 187.
Can an adapter handle multiple asset roles simultaneously?
Yes. Set the roles array to include any combination of 'original', 'variant', 'avatar', 'font', and 'plugin-pack'. The same adapter instance will be invoked for each role type elected in the admin UI.
How does Instatic ensure plugin adapters cannot access uploaded file contents?
The architecture enforces that bytes never cross the QuickJS sandbox. Your adapter only returns metadata and signed URLs; the host performs the actual HTTP PUT/POST operations using Bun’s native fetch. This is verified by media-storage-no-bytes-in-sandbox.test.ts.
What happens if finalizeWrite fails or the upload is interrupted?
The host calls adapter.abortWrite() with the storagePath if any phase fails, allowing you to clean up partial objects in S3 or other remote storage. Implement this method to avoid orphan files and unexpected storage costs.
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 →