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:

  1. beginWrite(input) – Returns a MediaStorageUploadPlan containing one or more HTTP steps (PUT/POST) or a sentinel LOCAL step for the built-in adapter. The host streams bytes directly to the provided URLs.
  2. 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:

  1. Registration – Plugins invoke api.cms.media.registerStorageAdapter(adapter) during initialization.
  2. Validation – The QuickJS bridge verifies the adapter shape and enforces the ${pluginId}. namespace prefix.
  3. Storage – The validated adapter enters the MediaStorageRegistry Map.
  4. Election – Administrators use the electAdapter repository function to assign the adapter to specific roles like original or variant.
  5. 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_adapter table determine which adapter handles uploads for specific asset types like original or avatar.
  • Plugin registration occurs via api.cms.media.registerStorageAdapter(), validated in server/plugins/quickjs/bootstrap/src/buildApi.ts and namespaced under the plugin id.
  • Two-phase upload contract requires adapters to implement beginWrite (returning upload plans) and finalizeWrite (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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →