Overview
Object storage substrate for AbsoluteJS. One BlobStore interface, multiple adapters, and bounded streaming for large artifacts.
@absolutejs/blobv0.6.2betaData & SyncOne BlobStore interface over local disk and every S3-compatible service, without a hard AWS SDK dependency.
Object storage substrate for the AbsoluteJS PaaS: one BlobStore interface (put, get, getStream, head, delete, list, presign) with adapters for local disk and any S3-compatible service. The S3 adapter takes a narrow S3ClientLike shape instead of depending on the AWS SDK directly, so the SDK stays out of your dependency tree until you need it. Other AbsoluteJS packages ride this substrate for uploads, replay artifacts, and media storage.
bun add @absolutejs/blobObject storage substrate for AbsoluteJS. One BlobStore interface, multiple adapters, and bounded streaming for large artifacts.
Subpath — Backs
@absolutejs/blob/local — Filesystem (dev / single-host prod / tests)
@absolutejs/blob/s3 — AWS S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi, Tigris — any S3-compatible HTTP API
@absolutejs/blob/aws-s3 — Official AWS SDK wiring, including multipart streaming uploads
@absolutejs/blob/uploadthing — UploadThing server SDK with stable application-owned custom ids and signed reads
@absolutejs/blob/inspection — Provider-neutral inspection contract, bounded stored-object inspection, and ClamAV clamd adapter
Both implement the same BlobStore interface — swap providers with one constructor change.
Uploads that can contain customer-controlled bytes should remain under a private quarantine key until an inspector returns clean. The inspection subpath preserves the same workflow across local, UploadThing, S3, R2, and Spaces storage:
The clamd adapter uses the bounded INSTREAM protocol and returns clean, infected, or unavailable. Scanner errors never become a clean verdict. The host owns durable quarantine, retry, retention, and audit policy; Blob only owns byte transport and normalized inspection.
Writes accept maxBytes and an AbortSignal, including multipart S3/R2 uploads: blobs.put(key, body, { maxBytes, signal }).
Files at /. Metadata (contentType, user metadata, cache headers) at /.meta.json. Atomic writes via temp file + rename. presign() throws BlobError('UNSUPPORTED') — use the S3 adapter against a local MinIO if you need presign in dev.
awsS3BlobStore uses the official SDK command clients and @aws-sdk/lib-storage multipart uploads. Streams are never materialized as one control-plane buffer. The lower-level s3BlobStore and S3ClientLike remain available for custom clients.
Cloudflare R2
R2 is fully S3-compatible — the only thing that changes is the endpoint. Same wiring + s3BlobStore adapter.
Backblaze B2, MinIO, Wasabi, Tigris
All the same pattern. Point endpoint at the provider's URL, provide credentials, hand the client into s3BlobStore.
put returns the stored object's metadata (size, contentType,
etag, user metadata).
get returns null for missing keys (not throw).
getStream for large blobs — avoids loading the body into memory.
delete is idempotent: deleting a missing key is success.
list paginates via cursor — pass back into the next call as
options.cursor.
presign builds a time-limited URL for direct browser upload/
download. operation: 'put' for uploads, 'get' (default) for downloads. Throws BlobError('UNSUPPORTED') on local.
Adapters call validateKey() on every operation. Leading slashes, NUL bytes, and . / .. path segments throw BlobError('INVALID_KEY') — closes the path-traversal class of bugs at the substrate level.
@absolutejs/blob/inspection provides createClamdBlobInspector and createBlobInspectionProcessor. Register the processor with your ABS Queue worker. Persist a quarantine record and enqueue its { resourceId, revision } atomically. The host load callback returns { key, filename, maxBytes } only for a current quarantined revision; commit must recheck the same revision under a row lock. Only a clean result may release a file. infected stays blocked. unavailable is committed and then throws BlobInspectionUnavailableError, allowing queue retries. Exhausted jobs must remain quarantined. Pass the worker cancellation signal.
Keep clamd on a private interface or loopback behind an authenticated tunnel: its TCP protocol does not authenticate clients. Maintain fresh signatures, configure archive/size limits to alert rather than silently skip, and monitor the daemon. The inspector bounds transport time and bytes; it cannot establish signature freshness or guarantee detection of every malicious file. Never expose unscanned provider URLs.
ClamAV can silently skip oversized ZIP entries even with AlertExceedsMax (upstream issue #633). The Blob inspector therefore independently checks ZIP/Office containers before accepting a clean result: 25 MiB cumulative expansion, 1,000 entries, four nested levels, and complete streams. Unsupported/malformed ZIPs or exceeded limits produce a blocked Policy.ArchiveLimitsOrInvalid finding. This is a file policy finding, not proof of malware. Non-ZIP formats continue to rely on the engine's format support and limits.
put, get, getStream, head, delete, list, and presign behave identically across adapters, so provider choice is a deploy-time detail.
AWS S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi, and Tigris all work through the same s3BlobStore adapter — only the endpoint changes.
The adapter accepts a narrow S3ClientLike object, keeping @aws-sdk/client-s3 out of your dependency tree as a hard dependency.
presign builds time-limited URLs for direct browser upload (operation: put) or download (operation: get) without proxying bytes through your server.
Every operation runs validateKey, rejecting leading slashes, NUL bytes, and . or .. path segments with BlobError(INVALID_KEY) — path traversal is closed at the substrate level.
getStream serves large blobs without loading them into memory, and list paginates with a cursor you pass back into the next call.
Keep customer-controlled uploads private until bounded inspection returns clean. The ClamAV adapter distinguishes clean, infected, and unavailable; scanner errors never become a clean verdict.
Choose an adapter by deployment and upload requirements without changing the BlobStore contract used by application code.
| Option | Local | Generic S3 | AWS SDK | UploadThing |
|---|---|---|---|---|
| Streaming reads | Yes | Yes | Yes | Yes |
| Presigned transfer | No | Yes | Yes | Signed reads |
| Multipart upload | No | Manual | Built in | Provider managed |
| Best fit | Dev/single host | S3-compatible clouds | AWS production | Managed uploads |
Treat uploaded bytes as untrusted until bounded inspection succeeds.
Outcomes
Object storage substrate for AbsoluteJS. One BlobStore interface, multiple adapters, and bounded streaming for large artifacts.
Subpath — Backs
Uploads that can contain customer-controlled bytes should remain under a private quarantine key until an inspector returns clean. The inspection subpath preserves the same workflow across local, UploadThing, S3, R2, and Spaces storage:
Hardening checklist
Follow in order
# @absolutejs/blob
const store: BlobStore = /* localBlobStore(...) | s3BlobStore(...) */;
await store.put('users/42/avatar.png', body, { contentType: 'image/png' });
const bytes = await store.get('users/42/avatar.png');
const url = await store.presign('users/42/avatar.png', { ttlSeconds: 900 });Uploads that can contain customer-controlled bytes should remain under a private quarantine key until an inspector returns clean. The inspection subpath preserves the same workflow across local, UploadThing, S3, R2, and Spaces storage:
import {
createClamdBlobInspector,
inspectStoredBlob,
} from "@absolutejs/blob/inspection";
const inspector = createClamdBlobInspector({ host: "clamav.internal" });
const result = await inspectStoredBlob(blobs, inspector, {
filename: "evidence.pdf",
key: "quarantine/case/evidence.pdf",
maxBytes: 25 * 1024 * 1024,
});Working example for Local.
import { localBlobStore } from "@absolutejs/blob/local";
const blobs = localBlobStore({ root: "./var/blobs" });
await blobs.put("uploads/file.pdf", body);The local adapter stores files on disk — ideal for development, tests, and single-host deployments. get returns null for missing keys instead of throwing, and delete is idempotent.
import { localBlobStore } from '@absolutejs/blob/local';
const blobs = localBlobStore({ root: './var/blobs' });
await blobs.put('users/42/avatar.png', body, {
contentType: 'image/png'
});
const bytes = await blobs.get('users/42/avatar.png'); // null if missing
const meta = await blobs.head('users/42/avatar.png');
await blobs.delete('users/42/avatar.png'); // idempotentThe S3 adapter works with any S3-compatible service. You hand it an S3ClientLike object wired onto the real AWS SDK client, so the SDK is your dependency, not the substrate’s.
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
HeadObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { s3BlobStore } from '@absolutejs/blob/s3';
// Cloudflare R2 — swap the endpoint for B2, MinIO, Wasabi, Tigris
const aws = new S3Client({
credentials: { accessKeyId, secretAccessKey },
endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`,
region: 'auto'
});
// Wire the narrow S3ClientLike shape onto the real SDK client
const blobs = s3BlobStore({
bucket: 'my-bucket',
client: {
deleteObject: (input) => aws.send(new DeleteObjectCommand(input)),
getObject: (input) => aws.send(new GetObjectCommand(input)),
headObject: (input) => aws.send(new HeadObjectCommand(input)),
listObjectsV2: (input) => aws.send(new ListObjectsV2Command(input)),
presignGetObject: (input, options) =>
getSignedUrl(aws, new GetObjectCommand(input), options),
presignPutObject: (input, options) =>
getSignedUrl(aws, new PutObjectCommand(input), options),
putObject: (input) => aws.send(new PutObjectCommand(input))
}
});
// Time-limited URL for direct browser upload
const url = await blobs.presign('uploads/file.pdf', {
operation: 'put',
ttlSeconds: 900
});Inspect a quarantined object with strict byte bounds before promoting it into application-visible storage.
import {
createClamdBlobInspector,
inspectStoredBlob
} from '@absolutejs/blob/inspection';
const inspector = createClamdBlobInspector({ host: 'clamav.internal' });
const result = await inspectStoredBlob(blobs, inspector, {
filename: 'evidence.pdf',
key: 'quarantine/case/evidence.pdf',
maxBytes: 25 * 1024 * 1024
});
if (result.verdict === 'clean') await promoteUpload(result);Both adapters implement the same BlobStore interface, so swapping providers is a one-constructor change.
Current package surface
Import surface · click to copy