Instatic Media Workspace Storage Adapter System: Architecture and Implementation Guide
The Instatic Media workspace storage adapter system is a pluggable TypeScript architecture that decouples media storage from the CMS core through a role-based adapter interface, enabling seamless integration with local disk, S3, or custom CDN backends.
The Instatic Media workspace storage adapter system powers the media management capabilities in CoreBunch/Instatic, providing a flexible abstraction layer that determines how and where digital assets are stored. This system allows developers to implement custom storage backends—such as AWS S3, Cloudflare R2, or proprietary CDNs—while maintaining a consistent contract with the CMS core. By separating storage concerns from content management, Instatic ensures that upload workflows, asset retrieval, and persistence logic remain modular and extensible.
Core Architecture Components
The storage adapter system rests on three interconnected layers: a strict TypeScript interface, a central registry singleton, and a database-backed election mechanism for role-based adapter assignment.
The MediaStorageAdapter Interface
Every storage adapter must implement the MediaStorageAdapter interface defined in src/core/plugin-sdk/types/media.ts. This contract mandates five critical methods and two identifying properties:
id– A unique string identifier (the built-in local-disk adapter uses an empty string).roles– An array ofMediaAssetRolevalues (image,avatar,font, etc.) that this adapter can handle.beginWrite(input)– Initiates an upload by returning a signedMediaStorageUploadPlancontaining the destination URL and HTTP method.writeBytes(plan, stream)– Streams raw bytes to the storage destination (often a no-op when clients upload directly to signed URLs).finalizeWrite({ storagePath, uploadReceipts })– Commits the asset to permanent storage and updates CMS metadata.abortWrite({ storagePath })– Cleans up partial uploads on failure.
MediaStorageRegistry Singleton
The MediaStorageRegistry class in src/core/plugins/mediaStorageRegistry.ts maintains a singleton Map<string, MediaStorageAdapter> that holds all registered adapters. On server initialization, it automatically configures the built-in local-disk adapter via configureLocalDisk(). Plugins with the media.storage.adapter permission inject custom adapters through the registration API, which the registry stores for subsequent lookups.
The registry exposes two resolution methods:
resolve(adapterId, role)– Returns the adapter assigned to a specific role, falling back to the local-disk adapter when the ID is empty.resolveForRead(adapterId)– Retrieves the adapter responsible for reading a stored asset, bypassing role validation.
Adapter Election and Persistence
Which adapter handles uploads for each role is determined by the repository layer in server/repositories/mediaStorageAdapters.ts. This module reads from and writes to the active_media_storage_adapter database table, allowing administrators to elect specific adapters per role through the admin UI. Once elected, the registry caches these assignments to minimize database queries during high-throughput upload operations.
How the Upload Workflow Works
The Instatic Media workspace storage adapter system orchestrates uploads through a three-phase handshake that keeps the CMS core agnostic of storage implementation details.
Begin Write Phase
When a user initiates an upload, the front-end calls the HTTP API exposed in server/handlers/cms/media.ts (routes under /admin/api/cms/media/*). The handler queries MediaStorageRegistry.resolve() to determine the active adapter for the asset's role, then invokes adapter.beginWrite({ role, filename, mimeType }). This returns a MediaStorageUploadPlan containing:
- The HTTP method (
PUTorPOST) - A pre-signed URL for direct client uploads
- Required headers for authentication
Client Streaming
The client streams file bytes directly to the signed URL provided in the upload plan. This bypasses the Instatic server for the actual data transfer, reducing bandwidth costs and latency. For adapters that do not support direct-to-storage uploads, the writeBytes() method handles the byte stream server-side before forwarding to the destination.
Finalize and Abort Operations
After the client confirms successful upload, the handler calls adapter.finalizeWrite({ storagePath, uploadReceipts }), persisting the asset metadata in the CMS database. If the upload fails or is cancelled, adapter.abortWrite({ storagePath }) triggers cleanup routines—such as deleting partial S3 objects or removing temporary disk files—ensuring storage consistency.
Reading Stored Assets
Asset retrieval follows a simplified path. When a published page requests a media URL, the CMS extracts the adapter ID from the stored storagePath and invokes MediaStorageRegistry.resolveForRead(). For the local-disk adapter, this returns a relative filesystem path; for external adapters, it returns the public CDN URL cached during the finalize phase. This resolution occurs in server/handlers/cms/media.ts when serving media through the admin API or public routes.
Implementing a Custom Storage Adapter
Developers can extend the system by implementing the MediaStorageAdapter interface and registering the instance through the plugin SDK.
Example: S3-Compatible Adapter
import type { MediaStorageAdapter, MediaStorageUploadPlan, MediaAssetRole } from '@core/plugin-sdk/types/media'
export const s3Adapter: MediaStorageAdapter = {
id: 's3',
roles: ['image', 'avatar'] as MediaAssetRole[],
async beginWrite({ role, filename, mimeType }) {
// Generate pre-signed PUT URL for your S3 bucket
const signedUrl = await generatePresignedUrl(filename, mimeType, 'putObject')
return {
storagePath: `s3://${filename}`,
uploadPlan: {
method: 'PUT',
url: signedUrl,
headers: { 'Content-Type': mimeType }
}
} as MediaStorageUploadPlan
},
async writeBytes(plan, stream) {
// Client uploads directly to S3; no server-side streaming required
return Promise.resolve()
},
async finalizeWrite({ storagePath, uploadReceipts }) {
// Verify uploadReceipts checksums or trigger post-processing
console.log(`Asset committed at ${storagePath}`)
},
async abortWrite({ storagePath }) {
// Delete partial upload from S3 bucket
const key = storagePath.replace('s3://', '')
await deleteS3Object(key)
}
}
Registering the Adapter
Plugins with storage permissions register adapters during initialization via the CMS API:
// Inside plugin initialization code
await api.cms.media.registerStorageAdapter({
id: s3Adapter.id,
roles: s3Adapter.roles,
// The host bridge forwards the full adapter implementation to MediaStorageRegistry
})
Built-in Local Disk Adapter
Instatic ships with a default local-disk adapter implemented in the core plugin system. This adapter handles all roles by default when no external adapter is elected, storing files in a configurable directory on the host filesystem. It uses empty string "" as its ID, which MediaStorageRegistry.resolve() treats as the universal fallback. The adapter implements streaming writes to the local filesystem and serves files through static middleware or direct file reads, depending on the deployment configuration.
Summary
- The Instatic Media workspace storage adapter system provides a TypeScript-based plugin architecture that decouples storage backends from CMS logic through the
MediaStorageAdapterinterface. - The MediaStorageRegistry (
src/core/plugins/mediaStorageRegistry.ts) maintains a singleton registry of adapters and provides resolution methods for read and write operations. - Role-based adapter election persists in the
active_media_storage_adaptertable viaserver/repositories/mediaStorageAdapters.ts, allowing per-role storage configuration. - The upload workflow follows a three-phase pattern:
beginWrite(generate signed URLs), client-side streaming, andfinalizeWrite(commit metadata). - Custom adapters integrate cleanly by implementing the interface and calling
api.cms.media.registerStorageAdapter(), enabling support for S3, R2, Azure Blob, or proprietary CDNs without modifying core CMS code.
Frequently Asked Questions
What is the purpose of the MediaStorageAdapter interface?
The MediaStorageAdapter interface defines a strict contract that all storage backends must implement, ensuring the CMS core can initiate uploads, stream bytes, finalize assets, and abort failed transfers without knowing the underlying storage technology. This abstraction enables swapping between local disk, cloud object storage, or custom CDN implementations through configuration alone.
How does Instatic decide which adapter to use for a specific media type?
Instatic resolves adapters by role using the MediaStorageRegistry.resolve() method, which checks the active_media_storage_adapter database table to find the elected adapter for the requested MediaAssetRole (such as image or avatar). If no specific adapter is elected, the system falls back to the built-in local-disk adapter.
Can I use multiple storage adapters simultaneously in the same Instatic instance?
Yes. The registry supports multiple concurrent adapters, each handling distinct roles or overlapping capabilities. For example, you can configure the S3 adapter for image assets while keeping the local-disk adapter for font files, or route high-resolution photos to a CDN while storing thumbnails locally. Each role's active adapter is tracked independently in the database.
What happens if an upload fails halfway through?
If the client fails to complete the upload or the finalize step encounters an error, the system invokes adapter.abortWrite({ storagePath }). This method triggers cleanup logic specific to the adapter—such as deleting partial S3 objects or removing temporary disk files—preventing orphaned data and reclaiming storage space immediately.
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 →