Heximon Logo
Capabilities

Blob Storage

The abstract BlobStore DI token for streamed bodies and byte ranges, the memory/filesystem/R2/S3 drivers, and the SignedUrlBlobStore capability for direct-to-storage signed upload/download URLs.

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 forWhen
StorageSession data, short-lived tokens, caches, feature flags — small JSON-shaped values you address by key
BlobStoreFiles, uploads, large or streamed binary payloads — addressed by key, but bytes instead of JSON
A databaseDomain 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

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[]>;
}
  • BlobBody is Uint8Array | ReadableStream<Uint8Array> — a buffered payload, or a byte stream for large uploads. A stream is consumed once, so pass a fresh stream per write.
  • ByteRange is { start, end? }, an inclusive [start, end] window (HTTP Range-style). Omit end to read to the end of the blob.
  • A StoredBlob exposes bytes, metadata, and stream() — 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.

DriverSubpathConstruct with
MemoryBlobStore@heximon/blob/memorynew MemoryBlobStore()
FileSystemBlobStore@heximon/blob/filesystemnew FileSystemBlobStore(rootDirectory)
R2BlobStore@heximon/blob/cloudflarenew R2BlobStore(bucket) (a Cloudflare R2Bucket binding)
S3BlobStore@heximon/blob/s3new 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).

storage.module.ts
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.

R2 needs a declared 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:

DriverSigns?
S3BlobStoreYes — 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 / FileSystemBlobStoreNo — 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:

signing.module.ts
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] },
}) {}
signing.controller.ts
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 a BlobStore, with the production driver swap.
  • Key/Value Storage — the Storage port 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 BlobStore bindings for uploads plus the SigningModule's signed-URL issuance, wired end-to-end with a test.
Copyright © 2026