A new storage backend is one factory returning a StorageProvider, plus one
call to the contract suite.
The port
From @orthacms/media-domain, a package with no dependencies
(@orthacms/media-server re-exports the same names):
import type { Readable } from 'node:stream';
interface StorageProvider {
readonly id: string; // stored on every asset row
readonly capabilities: StorageCapabilities;
put(object: PutObject): Promise<StoredObject>;
get(storageKey: string): Promise<Readable>;
remove(storageKey: string): Promise<void>;
directUrl?(storageKey: string, options: DirectUrlOptions): Promise<string>;
verify?(): Promise<void>; // run at boot; a throw refuses the start
}
interface StorageCapabilities {
directUrl: boolean; // can sign a short-lived URL with pinned headers
contentTypeMetadata: boolean; // can store a content type with the bytes
streamingPut: boolean; // streams the body rather than buffering it
}
interface PutObject {
workspaceId: string;
assetId: string;
fileName: string;
contentType: string;
body: Readable;
isVariant?: boolean; // a derivative (thumb, preview)
}
interface StoredObject {
storageKey: string; // you choose it
size: number; // bytes actually written
checksum: string; // hex sha256 of what was written
}
interface DirectUrlOptions {
disposition: 'inline' | 'attachment';
fileName: string;
contentType: string;
expiresInSeconds: number;
}Plus ObjectNotFoundError(storageKey), which get must reject with for a
missing key.
The contract
putreturns the true size and sha256 of what it wrote.putis all-or-nothing. A body that fails mid-stream leaves nothing behind: the key was never returned, so nobody could reclaim it.- Distinct keys for two assets with the same file name, and for a derivative vs. an original of the same name.
getrejects withObjectNotFoundErrorfor a missing or removed key, before the stream opens (after that, the route cannot turn a 200 into a 404).removeis idempotent, a no-op for a key that never existed.- Async all the way: reject, never throw synchronously.
directUrl()exists exactly whencapabilities.directUrlistrue, and only if the URL pinsContent-DispositionandContent-Type.
A complete provider
For a hypothetical HTTP object store with PUT/GET/DELETE per key and a
bearer token:
// packages/media-provider-http-store/src/http-store-provider.ts
import { createHash } from 'node:crypto';
import { Readable } from 'node:stream';
import type { ReadableStream as WebReadableStream } from 'node:stream/web';
import {
ObjectNotFoundError,
type PutObject,
type StorageProvider,
type StoredObject
} from '@orthacms/media-domain';
export interface HttpStoreConfig {
baseUrl: string; // e.g. https://objects.acme.example/cms
token: string;
keyPrefix?: string;
fetch?: typeof globalThis.fetch; // test seam
}
// One safe path segment from an uploader-supplied file name.
const sanitize = (name: string): string =>
name.replace(/[^\w.-]+/g, '_').replace(/^\.+/, '') || 'file';
export function createHttpStoreProvider(config: HttpStoreConfig): StorageProvider {
const doFetch = config.fetch ?? globalThis.fetch;
const base = config.baseUrl.replace(/\/+$/, '');
const prefix = config.keyPrefix?.replace(/^\/+|\/+$/g, '');
const auth = { authorization: `Bearer ${config.token}` };
const urlFor = (key: string) =>
`${base}/${key.split('/').map(encodeURIComponent).join('/')}`;
const keyFor = (object: PutObject): string =>
[
prefix,
object.workspaceId,
object.assetId,
object.isVariant ? 'variants' : undefined, // derivatives never collide with originals
sanitize(object.fileName)
]
.filter(Boolean)
.join('/');
return {
id: 'http-store',
capabilities: { directUrl: false, contentTypeMetadata: true, streamingPut: false },
async put(object: PutObject): Promise<StoredObject> {
const storageKey = keyFor(object);
// Buffer and meter first. A body that fails mid-stream rejects
// here, before anything is written: all-or-nothing by construction.
const hash = createHash('sha256');
const chunks: Buffer[] = [];
for await (const chunk of object.body) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
hash.update(buffer);
chunks.push(buffer);
}
const bytes = Buffer.concat(chunks);
const response = await doFetch(urlFor(storageKey), {
method: 'PUT',
headers: { ...auth, 'content-type': object.contentType },
body: bytes
});
if (!response.ok) {
// Remove any partial write: the key is never returned to a caller.
await doFetch(urlFor(storageKey), { method: 'DELETE', headers: auth }).catch(
() => undefined
);
throw new Error(`http-store refused ${storageKey}: HTTP ${response.status}`);
}
return { storageKey, size: bytes.length, checksum: hash.digest('hex') };
},
async get(storageKey: string): Promise<Readable> {
const response = await doFetch(urlFor(storageKey), { headers: auth });
if (response.status === 404) {
throw new ObjectNotFoundError(storageKey); // before any byte flows
}
if (!response.ok || !response.body) {
// Not "not found": an outage must not be dressed as a 404.
throw new Error(`http-store could not read ${storageKey}: HTTP ${response.status}`);
}
return Readable.fromWeb(response.body as unknown as WebReadableStream);
},
async remove(storageKey: string): Promise<void> {
const response = await doFetch(urlFor(storageKey), { method: 'DELETE', headers: auth });
if (!response.ok && response.status !== 404) {
throw new Error(`http-store could not remove ${storageKey}: HTTP ${response.status}`);
}
},
async verify(): Promise<void> {
const response = await doFetch(`${base}/`, { method: 'HEAD', headers: auth });
if (!response.ok) {
throw new Error(`http-store at ${base} refused the token: HTTP ${response.status}`);
}
}
};
}To stream instead of buffer, pipe object.body through a metering transform
into the upload, declare streamingPut: true, and delete the object in the
catch when anything fails.
A capability is a claim, not a wish
directUrl: true without a directUrl() method fails at boot. Declaring it
for a URL that cannot pin Content-Disposition and Content-Type is worse: an
uploaded .html would render on your storage host’s origin.
Vercel Blob declares false for that
reason.
The contract suite
npm install --save-dev @orthacms/media-provider-testkitdescribeStorageProvider(name, harness) registers Jest-style describe/it
cases. The harness is { create, cleanup?, storedKeys? }:
createruns per case, so each case gets a fresh provider.storedKeysturns “the failedputrejected” into “and left nothing behind”. Provide it.
// packages/media-provider-http-store/src/http-store-provider.spec.ts
import { describeStorageProvider } from '@orthacms/media-provider-testkit';
import { createHttpStoreProvider } from './http-store-provider';
// An in-memory stand-in for the HTTP object store.
function fakeStore() {
const objects = new Map<string, Uint8Array>();
const fetch = (async (input: string | URL | Request, init: RequestInit = {}) => {
const key = decodeURIComponent(new URL(String(input)).pathname.replace(/^\/cms\/?/, ''));
switch (init.method ?? 'GET') {
case 'PUT':
objects.set(key, new Uint8Array(init.body as Uint8Array));
return new Response(null, { status: 201 });
case 'DELETE':
return new Response(null, { status: objects.delete(key) ? 204 : 404 });
case 'HEAD':
return new Response(null, { status: 200 });
default: {
const bytes = objects.get(key);
return bytes ? new Response(bytes) : new Response(null, { status: 404 });
}
}
}) as typeof globalThis.fetch;
return { fetch, objects };
}
let store = fakeStore();
describeStorageProvider('http-store', {
create: () => {
store = fakeStore();
return createHttpStoreProvider({
baseUrl: 'https://objects.test/cms',
token: 'test-token',
fetch: store.fetch
});
},
storedKeys: () => [...store.objects.keys()]
});What it checks: a non-empty id; complete capabilities; directUrl()
present exactly when claimed; true size and sha256; round-trip through get;
distinct keys; derivative vs. original; mid-stream failure leaves nothing;
ObjectNotFoundError for never-written and removed keys; idempotent remove;
no synchronous throws.
Register it
// apps/server/src/plugins.ts
import { createHttpStoreProvider } from '@acme/media-provider-http-store';
MediaServerPlugin({
provider: createHttpStoreProvider({
baseUrl: process.env.MEDIA_HTTP_STORE_URL!,
token: process.env.MEDIA_HTTP_STORE_TOKEN!
}),
config: config.plugins.media
});No registry, no name to declare elsewhere. Then:
- Boot:
verify()runs; a throw refuses the start. - Upload a file (curl example) and check the
object exists at
<workspaceId>/<assetId>/<file-name>. - A database with rows from another provider
idrefuses to boot. Changingidlater strands every existing asset.
Read the in-memory provider first
It is a known-good implementation, short enough to read in one sitting.