Blob Storage
Files and uploads need a different shape than key/value data — streamed bodies, byte ranges, and
sometimes a signed URL so the bytes never transit your server. BlobStore is the abstract DI port
for that: inject it, and the owning module decides whether it's an in-memory Map under tests or S3
in production.
Choose storage, blobs, or a database
Pick by the shape of the data, not by habit.
| Reach for | When |
|---|---|
Storage | Session data, short-lived tokens, caches, feature flags — small JSON-shaped values you address by key |
BlobStore | Files, uploads, large or streamed binary payloads — addressed by key, but bytes instead of JSON |
| A database | Domain entities, relationships, joins, transactions, schema evolution — anything you'd query, not just fetch |
Neither port has a compiler plugin — they're ordinary DI providers, resolved by class identity like any other dependency, so adding one is just listing a binding in a module.
Install the port
The memory, filesystem, and Cloudflare subpaths pull in no SDK. Only @heximon/blob/s3 needs the two
optional AWS peers.
pnpm add @heximon/blob
pnpm add @heximon/blob @aws-sdk/client-s3 @aws-sdk/lib-storage
Use the BlobStore port
The port adds streaming and ranges on top of the key/value idea:
abstract class BlobStore {
abstract put(key: string, body: BlobBody, metadata?: BlobMetadata): Promise<void>;
abstract get(key: string, range?: ByteRange): Promise<StoredBlob | null>;
abstract getStream(key: string, range?: ByteRange): Promise<ReadableStream<Uint8Array> | null>;
abstract delete(key: string): Promise<void>; // deleting a missing key is a no-op
abstract exists(key: string): Promise<boolean>;
abstract list(prefix?: string): Promise<string[]>;
}
BlobBodyisUint8Array | ReadableStream<Uint8Array>— a buffered payload, or a byte stream for large uploads. A stream is consumed once, so pass a fresh stream per write.ByteRangeis{ start, end? }, an inclusive[start, end]window (HTTPRange-style). Omitendto read to the end of the blob.- A
StoredBlobexposesbytes,metadata, andstream()— which returns a fresh independent stream each call, so you can pipe the same blob more than once.
import { MemoryBlobStore } from "@heximon/blob/memory";
const store = new MemoryBlobStore();
const encode = (text: string) => new TextEncoder().encode(text);
await store.put("documents/one", encode("first"));
await store.put("documents/two", encode("second"), {
contentType: "text/plain",
custom: { checksum: "abc123" },
});
const stored = await store.get("documents/one"); // StoredBlob | null
new TextDecoder().decode(stored!.bytes); // "first"
stored!.metadata.size; // 5 (defaulted to byte length)
await store.put("ranged", encode("0123456789"));
const middle = await store.get("ranged", { start: 2, end: 5 });
new TextDecoder().decode(middle!.bytes); // "2345" (inclusive)
await store.list("documents/"); // ["documents/one", "documents/two"]
Pick a driver
Four drivers ship, one per environment. Each extends BlobStore, so each is a valid binding for the
abstract token — you swap backends by changing only the module binding.
| Driver | Subpath | Construct with |
|---|---|---|
MemoryBlobStore | @heximon/blob/memory | new MemoryBlobStore() |
FileSystemBlobStore | @heximon/blob/filesystem | new FileSystemBlobStore(rootDirectory) |
R2BlobStore | @heximon/blob/cloudflare | new R2BlobStore(bucket) (a Cloudflare R2Bucket binding) |
S3BlobStore | @heximon/blob/s3 | new S3BlobStore(client, bucket) (an AWS SDK v3 S3Client + bucket name) |
FileSystemBlobStore keeps each blob's bytes in a file under the root and its metadata in a companion
.meta.json file next to it — a real driver for dev or a single host, with streamed writes and positional
reads so large blobs are never fully buffered. S3BlobStore works against AWS S3, MinIO, or any
S3-compatible endpoint (including R2's S3-compatible API).
import { Module } from "@heximon/runtime";
import { BlobStore } from "@heximon/blob";
import { FileSystemBlobStore } from "@heximon/blob/filesystem";
export class StorageModule extends Module({
providers: [
// useFactory deps come from the factory's parameter types — no `inject` array.
{ provide: BlobStore, useFactory: () => new FileSystemBlobStore("/var/data/blobs") },
],
exports: [BlobStore],
}) {}
The Storage & blob adapters
example binds MemoryBlobStore for its uploads feature and shows the controller/contract/routes staying
unchanged no matter which driver is behind the token.
size to stream. Cloudflare's R2Bucket.put rejects a streamed body of unknown
length, so R2BlobStore collects an unsized stream into a buffer before writing — which puts the whole
upload on the heap. Pass metadata.size to keep large uploads streaming:
store.put(key, uploadStream, { size: byteLength }). S3 has no such limit — its multipart Upload
streams unknown-length bodies natively, buffering one ~5 MB part at a time.Hand out signed URLs
For large objects, you don't want every byte flowing through your app. Instead, mint a short-lived
signed URL and let the client upload (PUT) or download (GET) directly against the storage
backend — the file never transits the server.
Not every backend can sign, so it isn't a method on BlobStore. Signing is a capability, modelled
as its own DI token, SignedUrlBlobStore, which extends BlobStore:
abstract class SignedUrlBlobStore extends BlobStore {
abstract createSignedUploadUrl(key: string, options?: SignedUploadOptions): Promise<SignedUpload>;
abstract createSignedDownloadUrl(key: string, options?: SignedDownloadOptions): Promise<SignedDownload>;
}
Only drivers that can sign natively extend it — so the capability is enforced by the type system.
You can only inject a SignedUrlBlobStore where a signing-capable driver is bound:
| Driver | Signs? |
|---|---|
S3BlobStore | Yes — AWS SigV4 presigning (also covers R2's S3-compatible endpoint) |
R2BlobStore (Worker binding) | No — the binding can't presign; point S3BlobStore at R2's S3 endpoint instead |
MemoryBlobStore / FileSystemBlobStore | No — no URL concept |
Bind the capability token to a supported driver, inject it, and forward the minted descriptor. The
framework adds no upload/download route — issuing the URL is your code, exactly as the example's
SigningModule does:
import { S3Client } from "@aws-sdk/client-s3";
import { SignedUrlBlobStore } from "@heximon/blob";
import { S3BlobStore } from "@heximon/blob/s3";
import { Module } from "@heximon/runtime";
import { SigningController } from "./signing.controller";
export class SigningModule extends Module({
providers: [
{
provide: SignedUrlBlobStore,
useFactory: () =>
new S3BlobStore(
new S3Client({ region: "us-east-1", forcePathStyle: true }),
"uploads",
),
},
],
http: { controllers: [SigningController] },
}) {}
const upload = await this.blobs.createSignedUploadUrl(key, { contentType });
// → { url, method: "PUT", headers: { "Content-Type": contentType }, expiresAt }
const download = await this.blobs.createSignedDownloadUrl(key);
// → { url, method: "GET", expiresAt }
The result is a descriptor, not a bare URL. When you bind a contentType, the signature is valid
only for a request that sends that exact Content-Type header — so headers tells the client which
header to send. Pass expiresIn (a TimeSpan; driver default 15 minutes) to change the lifetime.
The S3 driver needs one more optional peer for signing — @aws-sdk/s3-request-presigner — on top of
the two it already uses. Presigning is pure local crypto (no network call) and edge-safe on Workers.
See also
- Upload a file — the shortest path: a multipart route validated by
.files(), streamed into aBlobStore, with the production driver swap. - Key/Value Storage — the
Storageport for small JSON-shaped values instead of bytes. - Drizzle ORM — when the data has relationships, transactions, or needs migrations.
- Branded IDs — type-safe identifiers to use as the keys flowing through your stores.
- Example: Storage & blob adapters
— swappable
BlobStorebindings for uploads plus theSigningModule's signed-URL issuance, wired end-to-end with a test.
Key/Value Storage
The abstract Storage DI token and its BatchStorage capability, MemoryStorage with TTL and LRU bounds, and the first-class UpstashStorage, CloudflareKvStorage, RedisStorage, and UnstorageStorage adapters.
Health & Readiness
Liveness and readiness probes with HealthIndicator, HealthRegistry, and an app-owned controller — DatabaseHealthIndicator, per-indicator timeout, id-keyed results.