How Instatic Implements Media Storage with Pluggable Adapters: A Technical Deep Dive
Instatic routes all media uploads through a singleton registry that delegates to pluggable storage adapters, enforcing a two-phase upload contract that keeps raw file bytes outside the QuickJS sandbox while supporting both local disk and remote cloud storage backends.
Instatic's media subsystem is built around a flexible, plugin-friendly architecture that separates storage concerns from core CMS logic. The system uses a centralized registry to manage adapters that handle everything from local filesystem writes to remote object stores like S3 or Cloudflare R2. This design ensures that media bytes never cross into the QuickJS sandbox, preserving memory efficiency and security boundaries.
The Singleton Registry Pattern
At the heart of Instatic's storage system lies the MediaStorageRegistry, implemented in src/core/plugins/mediaStorageRegistry.ts. This singleton acts as the central authority for all storage operations, maintaining a registry of available adapters and routing upload and read requests to the appropriate implementation.
Built-in Local Disk Adapter
The registry automatically registers a built-in local disk adapter at boot time. This adapter serves as the default storage mechanism and uses the filesystem to persist media files. When no specific adapter is elected for a given media role, the system falls back to this local implementation.
Plugin Registration via QuickJS Bridge
Plugins can contribute custom storage adapters through the QuickJS bridge using the api.cms.media.registerStorageAdapter method. This allows third-party developers to integrate external storage services without modifying core Instatic code. The registry stores these plugin adapters alongside the built-in local adapter, making them available for role-based election.
Role-Based Adapter Election
Instatic supports distinct storage strategies for different media types through its role-based election system. Each media role—original, variant, avatar, font, or plugin-pack—can be assigned a specific storage adapter independently.
The Active Adapter Table
Election results are persisted in the active_media_storage_adapter table, with helper functions defined in server/repositories/mediaStorageAdapters.ts. The system provides utilities like getElectedAdapterId, electAdapter, and countAssetsForAdapter to manage these assignments programmatically. An empty string ('') in the adapter ID field signifies the default local disk adapter.
When an upload request arrives, the dispatch handler (server/handlers/cms/mediaUploadDispatch.ts) queries this table to determine which adapter should handle the operation based on the media's intended role.
The Two-Phase Upload Contract
All storage adapters must implement a strict two-phase upload contract that separates URL generation from byte transfer. This contract is enforced by the upload executor in server/handlers/cms/mediaUploadExecutor.ts.
Phase 1: Initiating the Upload
The process begins when the host calls adapter.beginWrite(input), passing metadata including suggestedStoragePath, role, mimeType, and size. The adapter returns a MediaStorageUploadPlan containing one or more upload steps. Each step specifies either a signed HTTP URL (for remote storage) or a local filesystem indicator.
// Phase 1: Begin upload
const plan = await adapter.beginWrite({
suggestedStoragePath: 'uploads/2024/09/hero.jpg',
role: 'original',
mimeType: 'image/jpeg',
size: 1_024_000,
});
// plan.steps contains signed URLs or local filesystem paths
Phase 2: Streaming Bytes and Finalization
The host then streams file bytes directly to the URLs provided in the plan steps using Bun's native fetch implementation. This bypasses the QuickJS sandbox entirely, ensuring that large media files never consume sandbox memory.
// Phase 2: Host streams bytes directly to each step
for (const step of plan.steps) {
if (step.method === 'LOCAL') {
// Local disk path extraction from file:// protocol
await writeFile(step.url.slice('file://'.length), fileBuffer);
} else {
// Remote upload to signed URL
await fetch(step.url, {
method: step.method, // 'PUT' or 'POST'
headers: step.headers,
body: fileBuffer,
});
}
}
// Phase 3: Finalize and persist
const result = await adapter.finalizeWrite({
storagePath: plan.storagePath,
uploadReceipts: receiptsFromSteps,
});
// result.publicUrl stored in media_assets.public_url
Abort and Cleanup
If any step fails, the host immediately invokes adapter.abortWrite({ storagePath: plan.storagePath }) to trigger cleanup of partial uploads. This ensures that failed transfers do not leave orphaned chunks in remote storage or incomplete files on local disk.
Local Disk vs. Remote Storage Implementation
The local disk adapter uses a sentinel value LOCAL_DISK_STEP_METHOD = 'LOCAL' to distinguish itself from remote adapters. When the executor encounters this method, it writes files using fs/promises.writeFile rather than HTTP requests.
Remote adapters must return actual HTTP methods ('PUT' or 'POST') with fully qualified signed URLs. The host handles all network I/O, maintaining the invariant that the QuickJS sandbox never processes raw media bytes. This distinction is crucial for avoiding the 64 MiB heap limit imposed on the sandbox environment.
Read operations for local assets are handled by the static /uploads/* handler, which is why the local adapter's getReadUrl method returns undefined—the public URL is already known and stored directly in the database.
Read-Side Resolution
When serving media assets, Instatic resolves the original adapter through mediaStorageRegistry.resolveForRead(storageAdapterId). This lookup bypasses role-based checks because the role was validated during the initial upload.
The system retrieves the adapter instance that originally wrote the asset and delegates read URL generation to it. For most remote adapters, this generates signed GET URLs or returns public CDN paths. For local assets, the system simply returns the public_url value stored in the media_assets table (defined in server/repositories/media.ts).
Security: Isolating Media Bytes from the Sandbox
A critical architectural constraint prevents raw media bytes from entering the QuickJS sandbox. The test file src/__tests__/architecture/media-storage-no-bytes-in-sandbox.test.ts enforces this invariant, ensuring that the host—not the plugin runtime—handles all file I/O.
By restricting adapters to only generating URLs and metadata while the host performs the actual byte transfer, Instatic prevents large media uploads from crashing the sandbox or exhausting its limited heap. This design enables support for multi-gigabyte files without risking memory violations in the plugin environment.
Summary
- Singleton Registry: The
MediaStorageRegistryinsrc/core/plugins/mediaStorageRegistry.tsmanages all storage adapters and handles plugin registration viaapi.cms.media.registerStorageAdapter. - Role-Based Election: Each media role can elect a specific adapter through the
active_media_storage_adaptertable, allowing flexible storage strategies per content type. - Two-Phase Contract: Uploads follow a strict pattern of
beginWrite→ direct byte streaming →finalizeWrite, withabortWriteavailable for cleanup on failure. - Sandbox Isolation: Raw media bytes never cross the QuickJS boundary; the host performs all I/O using Bun's native fetch or filesystem APIs, avoiding the 64 MiB sandbox limit.
- Adapter Flexibility: The system supports local disk storage via a
LOCALsentinel value and remote storage through signed URL generation, unified under a common interface.
Frequently Asked Questions
How does Instatic prevent media bytes from entering the QuickJS sandbox?
Instatic enforces a strict two-phase upload contract where adapters only generate upload plans containing signed URLs or local paths. The host application performs all actual byte transfer using Bun's native fetch or fs/promises APIs. This architecture ensures that raw file data never passes through the QuickJS sandbox, preventing memory exhaustion and maintaining the 64 MiB heap limit invariant.
Can different media types use different storage adapters?
Yes. Instatic supports role-based adapter election, allowing each media role—original, variant, avatar, font, or plugin-pack—to be assigned a distinct storage adapter. These assignments are stored in the active_media_storage_adapter table and resolved at upload time by the dispatch handler in server/handlers/cms/mediaUploadDispatch.ts.
What happens if a media upload fails mid-way?
If any step of the upload process fails, the host immediately calls adapter.abortWrite({ storagePath }) to trigger cleanup. This method allows adapters to delete partial uploads from remote storage or remove incomplete files from local disk, preventing orphaned data and storage waste.
How do I implement a custom S3 adapter for Instatic?
To implement a custom adapter, register it via api.cms.media.registerStorageAdapter in your plugin's initialization code. Your adapter must implement beginWrite to return signed S3 URLs in the upload plan steps, finalizeWrite to confirm upload completion and return the public URL, and abortWrite to clean up failed transfers. The host will handle all HTTP communication with S3, keeping your plugin code sandbox-safe.
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 →