How Instatic's Media Storage Registry Supports Pluggable Storage Adapters
Instatic implements a singleton MediaStorageRegistry that maintains a Map of storage adapters, coupled with a database-backed per-role election system, enabling plugins to register custom storage backends via a QuickJS bridge while the host dynamically resolves the correct adapter for each read and write operation.
Instatic is an extensible content management system that treats media storage as a pluggable concern. Rather than hardcoding cloud providers or filesystem paths, the platform delegates all asset persistence to a media storage registry that accepts contributions from third-party plugins. This architecture allows operators to swap storage backends—whether local disk, Amazon S3, or custom CDNs—without modifying core application code.
Core Architecture of the Storage Registry
The pluggable adapter system rests on four coordinated layers: a singleton registry for adapter instances, a database table for per-role elections, a standardized two-phase upload contract, and a QuickJS bridge for plugin registration.
The MediaStorageRegistry Singleton
At the heart of the system lies the MediaStorageRegistry, implemented as a singleton in src/core/plugins/mediaStorageRegistry.ts. This registry maintains an internal Map<string, MediaStorageAdapter> that holds every registered adapter, including the built-in local-disk adapter and any adapters contributed by plugins.
The registry provides three critical methods:
configureLocalDisk()– Bootstraps the sentinel local adapter with an empty string id ('').resolve(adapterId, role)– Retrieves an adapter for write operations, validating that it supports the requested role.resolveForRead(storageAdapterId)– Looks up the adapter associated with a specific asset during publish or serve operations.
When a plugin is disabled, unregisterPlugin(pluginId) removes every adapter whose id is prefixed with that plugin's identifier, ensuring clean lifecycle management.
Per-Role Adapter Election
Storage adapters are not selected globally; instead, Instatic persists a per-role election in the database table active_media_storage_adapter. As implemented in server/repositories/mediaStorageAdapters.ts, the electAdapter(db, role, adapterId, userId) function writes the elected adapter for a specific role such as original, variant, or avatar.
During upload initiation, getElectedAdapterId(db, role) reads the current election once and pins that adapter to the asset for its entire lifetime. This guarantees that an asset's storage backend remains stable even if the election changes later.
The Two-Phase Upload Contract
All adapters implement the MediaStorageAdapter interface defined in src/core/plugin-sdk/types/media.ts. This contract enforces a two-phase upload process:
beginWrite(input)– Returns aMediaStorageUploadPlancontaining one or more HTTP steps (PUT/POST) or a sentinelLOCALstep for the built-in adapter. The host streams bytes directly to the provided URLs.finalizeWrite(input)– Called after successful byte transfer, returning the public URL or internal path.
This design allows adapters to generate pre-signed URLs for external services (like S3) while letting the host handle the actual byte streaming.
Plugin Registration via QuickJS Bridge
Plugins running in the QuickJS sandbox register adapters by calling api.cms.media.registerStorageAdapter(adapter). The host-side validation logic in server/plugins/quickjs/bootstrap/src/buildApi.ts (approximately lines 252–300) ensures:
- The adapter object contains required fields:
id,label,roles,servingMode, and callback functions. - The adapter id is namespaced under the plugin id (
${pluginId}.<adapterName>).
Once validated, the bridge stores the adapter in the MediaStorageRegistry, making it available for election immediately.
Read-Time Resolution
When serving media, the system uses the storage_adapter_id column stored in the media_assets table (managed by server/repositories/media.ts). The publishing pipeline in server/publish/mediaPresentation.ts calls mediaStorageRegistry.resolveForRead(asset.storageAdapterId) to retrieve the correct adapter and generate the final public URL. No role check occurs during reads because the role was already validated during the upload phase.
The Built-in Local-Disk Adapter
Instatic ships with a default adapter registered at boot via configureLocalDisk(). This adapter uses the sentinel id '' and writes directly to the server's uploads folder, serving files via the static /uploads/* handler.
Its beginWrite implementation returns a single step whose method equals the internal constant LOCAL_DISK_STEP_METHOD and whose URL uses the file:// scheme. The upload executor detects this sentinel and writes the file using Node's fs.promises.writeFile, bypassing HTTP streaming for local operations.
Pluggable Adapter Lifecycle
Adapters move through a strict lifecycle governed by the registry and the election repository:
- Registration – Plugins invoke
api.cms.media.registerStorageAdapter(adapter)during initialization. - Validation – The QuickJS bridge verifies the adapter shape and enforces the
${pluginId}.namespace prefix. - Storage – The validated adapter enters the
MediaStorageRegistryMap. - Election – Administrators use the
electAdapterrepository function to assign the adapter to specific roles likeoriginalorvariant. - Unregister – When a plugin disables,
mediaStorageRegistry.unregisterPlugin(pluginId)removes all associated adapters from the Map. The built-in local adapter remains protected and is never removed.
Implementation Examples
Creating a Custom S3 Adapter
Plugins define adapters by implementing the MediaStorageAdapter interface from @core/plugin-sdk. The following example registers an S3-compatible adapter that generates pre-signed PUT URLs:
// src/plugins/my-s3-plugin/mediaAdapter.ts
import type {
MediaStorageAdapter,
MediaStorageBeginWriteInput,
MediaStorageUploadPlan,
MediaStorageFinalizeWriteInput,
MediaStorageWriteResult,
} from '@core/plugin-sdk'
export const s3Adapter: MediaStorageAdapter = {
id: 'my-s3-plugin.s3',
label: 'Amazon S3',
roles: ['original', 'variant'],
servingMode: 'public-url',
async beginWrite(input: MediaStorageBeginWriteInput): Promise<MediaStorageUploadPlan> {
const uploadUrl = await getSignedPutUrl(input.suggestedStoragePath)
return {
storagePath: input.suggestedStoragePath,
steps: [{ method: 'PUT', url: uploadUrl, headers: {} }],
expiresAt: Date.now() + 10 * 60 * 1000,
}
},
async finalizeWrite(input: MediaStorageFinalizeWriteInput): Promise<MediaStorageWriteResult> {
return { publicUrl: `https://my-bucket.s3.amazonaws.com/${input.storagePath}` }
},
async abortWrite({ storagePath }) {
await deletePartialObject(storagePath)
},
async delete(storagePath) {
await deleteObject(storagePath)
},
async verify() {
const ok = await canListBucket()
return ok ? { ok: true } : { ok: false, reason: 'S3 unreachable' }
},
}
// Register from plugin entry point
export function initPlugin(api: any) {
api.cms.media.registerStorageAdapter(s3Adapter)
}
Electing the Adapter for a Specific Role
Server-side code or admin UIs elect adapters by writing to the active_media_storage_adapter table via the repository layer:
import { electAdapter } from '@/server/repositories/mediaStorageAdapters'
import { db } from '@/server/db/client'
async function setS3ForOriginal(userId: string) {
await electAdapter(db, 'original', 'my-s3-plugin.s3', userId)
}
Resolving Adapters at Runtime
During upload handling, the host resolves the elected adapter and delegates the operation:
import { getElectedAdapterId } from '@/server/repositories/mediaStorageAdapters'
import { mediaStorageRegistry } from '@/core/plugins/mediaStorageRegistry'
async function handleUpload(role: MediaAssetRole, input: MediaStorageBeginWriteInput) {
const adapterId = await getElectedAdapterId(db, role)
const adapter = mediaStorageRegistry.resolve(adapterId, role)
if (!adapter) throw new Error('No adapter for role')
const plan = await adapter.beginWrite(input)
// Host streams bytes to plan.steps[0].url
}
Summary
- MediaStorageRegistry maintains a singleton Map of all storage adapters in
src/core/plugins/mediaStorageRegistry.ts, providing resolution methods for reads and writes. - Per-role elections stored in the
active_media_storage_adaptertable determine which adapter handles uploads for specific asset types likeoriginaloravatar. - Plugin registration occurs via
api.cms.media.registerStorageAdapter(), validated inserver/plugins/quickjs/bootstrap/src/buildApi.tsand namespaced under the plugin id. - Two-phase upload contract requires adapters to implement
beginWrite(returning upload plans) andfinalizeWrite(returning public URLs), standardizing integration with external storage. - Lifecycle management ensures adapters unregister automatically when plugins disable, while the built-in local-disk adapter remains permanently available.
Frequently Asked Questions
How does Instatic ensure that changing a storage adapter does not affect existing media assets?
Instatic pins the storage_adapter_id to each asset record in the media_assets table at upload time. When getElectedAdapterId selects an adapter during beginWrite, that identifier is stored with the asset. Subsequent read operations in server/publish/mediaPresentation.ts use mediaStorageRegistry.resolveForRead() to fetch the adapter associated with the stored id, ensuring existing assets continue to resolve through their original backend even if the active election changes.
Can a single plugin register multiple storage adapters?
Yes. A plugin may call api.cms.media.registerStorageAdapter() multiple times with distinct adapter objects, provided each id is unique and prefixed with the plugin's id (e.g., my-plugin.s3-eu and my-plugin.s3-us). The registry stores each entry separately in the internal Map, and administrators can elect different adapters for different roles (e.g., original in EU, variant in US).
What validation occurs when a plugin registers a storage adapter?
The QuickJS bridge in server/plugins/quickjs/bootstrap/src/buildApi.ts validates that the adapter object includes mandatory fields: id, label, roles array, servingMode, and the required callback methods (beginWrite, finalizeWrite, delete, verify). It also enforces that the adapter id begins with the plugin's id followed by a dot (${pluginId}.), preventing namespace collisions between plugins.
How does the built-in local adapter differ from pluggable adapters in its upload handling?
While pluggable adapters typically return HTTP URLs in their upload plans, the built-in local adapter returns a sentinel step with method set to LOCAL_DISK_STEP_METHOD and a file:// URL. The host executor detects this sentinel in src/core/plugins/mediaStorageRegistry.ts and writes the file directly using fs.promises.writeFile, bypassing network streaming and storing the asset in the server's local uploads directory.
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 →