@khair/storage-adapter
v0.1.0
Published
Provider-agnostic object storage adapter (GCS, S3, Azure Blob, or any S3-compatible bucket) behind one interface: upload, download, getUrl, getSignedUrl, delete, list, exists.
Downloads
137
Maintainers
Readme
@khair/storage-adapter
One StorageAdapter interface for object storage — upload (streamed, not
buffered), download, get a URL, get a signed read/upload URL, delete (single
or batch), copy, list, check existence — with adapters for Google Cloud
Storage, AWS S3 (and any S3-compatible bucket: MinIO, Cloudflare R2,
DigitalOcean Spaces), and Azure Blob Storage.
Errors from all three providers are normalized to StorageOperationError
with a .code (NOT_FOUND | ACCESS_DENIED | UNKNOWN) and a .provider,
so error-handling code is provider-agnostic too — the original SDK error is
always available via .cause.
Application/microservice code only ever talks to StorageAdapter. Which
bucket and which provider it actually hits is a config decision, made once at
startup — usually from environment variables — so switching a client from
"GCS bucket" to "S3 bucket" to "Azure container" never touches business logic.
Install
npm install @khair/storage-adapterEach provider's SDK is an optional peer dependency — only install the one the client you're deploying for actually uses:
# pick one (or more, if you genuinely run multiple providers side by side)
npm install @google-cloud/storage
npm install @aws-sdk/client-s3 @aws-sdk/lib-storage @aws-sdk/s3-request-presigner
npm install @azure/storage-blobcreateStorageAdapter() only loads the SDK for the provider you actually
pick — choosing "s3" never touches @google-cloud/storage or
@azure/storage-blob, even though all three adapters ship in this one
package. If you forget to install the SDK a provider needs, you get a
ConfigError telling you exactly what to run, not a raw "Cannot find
module" pointing into this package's internals:
ConfigError: The "gcs" provider requires "@google-cloud/storage", which isn't installed. Run: npm install @google-cloud/storageRequires Node 20+ (the floor set by the AWS and Azure SDKs).
Usage
import { createStorageAdapter } from "@khair/storage-adapter";
const storage = createStorageAdapter({
provider: "gcs",
bucket: "client-a-uploads",
});
const { url } = await storage.upload("invoices/2026-06.pdf", buffer, {
contentType: "application/pdf",
});
const signedUrl = await storage.getSignedUrl("invoices/2026-06.pdf", {
expiresInSeconds: 300,
});
// Let a browser upload directly to the bucket without the file passing through your server.
const uploadUrl = await storage.getSignedUploadUrl("invoices/2026-07.pdf", {
contentType: "application/pdf",
expiresInSeconds: 300,
});
await storage.copy("invoices/2026-06.pdf", "archive/2026-06.pdf");
await storage.deleteMany(["tmp/a.pdf", "tmp/b.pdf"]);upload() accepts a Buffer, Uint8Array, or a NodeJS.ReadableStream —
stream input is piped straight to the provider's streaming/multipart upload
path rather than buffered into memory first, so large files don't blow up
process memory.
Switching the client to S3 is a config change, not a code change:
const storage = createStorageAdapter({
provider: "s3",
bucket: "client-b-uploads",
region: "us-east-1",
});Loading config from the environment
Most services just want "whatever STORAGE_PROVIDER says" at boot:
import { createStorageAdapter, loadStorageConfigFromEnv } from "@khair/storage-adapter";
const storage = createStorageAdapter(loadStorageConfigFromEnv());| Env var | Provider | Required | Notes |
| --- | --- | --- | --- |
| STORAGE_PROVIDER | all | yes | gcs | s3 | azure |
| GCS_BUCKET | gcs | yes | |
| GCS_PROJECT_ID | gcs | no | |
| GCS_KEY_FILENAME | gcs | no | path to service-account JSON |
| GCS_CREDENTIALS_JSON | gcs | no | inline service-account JSON, alternative to key file |
| S3_BUCKET | s3 | yes | |
| S3_REGION | s3 | yes | |
| S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY | s3 | no | omit to use the default AWS credential chain |
| S3_ENDPOINT | s3 | no | set for MinIO / R2 / Spaces / any S3-compatible bucket |
| S3_FORCE_PATH_STYLE | s3 | no | "true" — most S3-compatible providers need this |
| AZURE_CONTAINER | azure | yes | |
| AZURE_CONNECTION_STRING | azure | no* | |
| AZURE_ACCOUNT_NAME / AZURE_ACCOUNT_KEY | azure | no* | |
* Azure needs either a connection string or an account name + key. Signed URLs specifically require a shared key (present in either form) — an account that only has a SAS token won't be able to mint new signed URLs.
Node.js usage guide: AWS S3, S3-compatible storage, and Azure
None of the three adapters create the bucket/container for you — create it in the provider's console or CLI first, then point the adapter at it.
Amazon S3
1. Get credentials. In the AWS console, create an IAM user (or use the role attached to your EC2/ECS/Lambda compute if you're running on AWS) with a policy scoped to your bucket:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::client-a-uploads/*"
},
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::client-a-uploads"
}
]
}2. Install the SDKs:
npm install @khair/storage-adapter @aws-sdk/client-s3 @aws-sdk/lib-storage @aws-sdk/s3-request-presigner3. Configure the adapter. If you're running on AWS compute with an IAM
role attached, omit accessKeyId/secretAccessKey entirely — the AWS SDK's
default credential chain picks up the role automatically. Otherwise pull the
IAM user's keys from environment variables, never hardcode them:
import { createStorageAdapter } from "@khair/storage-adapter";
const storage = createStorageAdapter({
provider: "s3",
bucket: "client-a-uploads",
region: "us-east-1",
// Omit these two on EC2/ECS/Lambda with an attached IAM role:
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
});4. Use it:
const { url } = await storage.upload("invoices/2026-06.pdf", pdfBuffer, {
contentType: "application/pdf",
});
await storage.exists("invoices/2026-06.pdf"); // true
const pdf = await storage.download("invoices/2026-06.pdf");
const signedUrl = await storage.getSignedUrl("invoices/2026-06.pdf", { expiresInSeconds: 300 });
const uploadUrl = await storage.getSignedUploadUrl("invoices/2026-07.pdf", { contentType: "application/pdf" });
await storage.copy("invoices/2026-06.pdf", "archive/2026-06.pdf");
await storage.list("invoices/"); // [{ key, size, lastModified }, ...]
await storage.deleteMany(["tmp/a.pdf", "tmp/b.pdf"]);Don't pass isPublic: true unless your bucket still allows ACLs — buckets
created since ~April 2023 default to ACLs disabled and this will throw. See
Error handling below.
S3-compatible storage (MinIO, Cloudflare R2, DigitalOcean Spaces)
Same S3Adapter, same SDKs as above — just point endpoint at the
provider and set forcePathStyle for the ones that need it. Bucket
permissions are configured in that provider's own console, not IAM.
MinIO (e.g. local dev via docker run -p 9000:9000 minio/minio server /data):
const storage = createStorageAdapter({
provider: "s3",
bucket: "dev-uploads",
region: "us-east-1", // MinIO ignores the value but the SDK requires one
endpoint: "http://localhost:9000",
forcePathStyle: true,
accessKeyId: "minioadmin",
secretAccessKey: "minioadmin",
});Cloudflare R2 (get the account ID and an API token's access key/secret from the R2 dashboard):
const storage = createStorageAdapter({
provider: "s3",
bucket: "client-b-uploads",
region: "auto",
endpoint: "https://<ACCOUNT_ID>.r2.cloudflarestorage.com",
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
});DigitalOcean Spaces (create an access key under API → Spaces Keys):
const storage = createStorageAdapter({
provider: "s3",
bucket: "client-c-uploads",
region: "nyc3", // must match the region the Space was created in
endpoint: "https://nyc3.digitaloceanspaces.com",
accessKeyId: process.env.SPACES_ACCESS_KEY_ID,
secretAccessKey: process.env.SPACES_SECRET_ACCESS_KEY,
});Once configured, every method (upload, getSignedUrl, copy, list, …)
works exactly as in the Amazon S3 section above — that's the point of the
adapter. Check your provider's S3-compatibility notes if something like
isPublic (ACLs) behaves differently than on AWS.
Azure Blob Storage
1. Get credentials. In the Azure portal, open your Storage Account → Access keys, and copy either the connection string or the account name + key. Equivalent via CLI:
az storage account show-connection-string --name <account> --resource-group <group>2. Install the SDK:
npm install @khair/storage-adapter @azure/storage-blob3. Configure the adapter:
import { createStorageAdapter } from "@khair/storage-adapter";
const storage = createStorageAdapter({
provider: "azure",
containerName: "client-d-uploads",
connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
});4. Use it:
const { url } = await storage.upload("invoices/2026-06.pdf", pdfBuffer, {
contentType: "application/pdf",
});
await storage.exists("invoices/2026-06.pdf"); // true
const pdf = await storage.download("invoices/2026-06.pdf");
const signedUrl = await storage.getSignedUrl("invoices/2026-06.pdf", { expiresInSeconds: 300 });
const uploadUrl = await storage.getSignedUploadUrl("invoices/2026-07.pdf", { contentType: "application/pdf" });
await storage.copy("invoices/2026-06.pdf", "archive/2026-06.pdf");
await storage.list("invoices/");
await storage.deleteMany(["tmp/a.pdf", "tmp/b.pdf"]);Two Azure-specific things to know:
getSignedUrl/getSignedUploadUrlneed a shared key credential (connection string withAccountKey=, or explicitaccountName+accountKey). A connection string built from a SAS token instead of an account key can't mint new signed URLs and will throw aConfigError.upload(..., { isPublic: true })always throws — Blob Storage has no per-object ACL, only a container-level public access setting. Set that in the portal (Container → Change access level) instead.
The interface
interface StorageAdapter {
upload(key: string, data: Buffer | Uint8Array | NodeJS.ReadableStream, options?: UploadOptions): Promise<UploadResult>;
download(key: string): Promise<Buffer>;
getUrl(key: string): string;
getSignedUrl(key: string, options?: SignedUrlOptions): Promise<string>;
getSignedUploadUrl(key: string, options?: SignedUploadUrlOptions): Promise<string>;
delete(key: string): Promise<void>;
deleteMany(keys: string[]): Promise<void>;
copy(sourceKey: string, destinationKey: string): Promise<void>;
list(prefix?: string): Promise<StorageObjectInfo[]>;
exists(key: string): Promise<boolean>;
}getUrl returns a best-effort public URL without checking the object exists
or is actually public — use getSignedUrl for private buckets.
Error handling
Every adapter wraps SDK failures into StorageOperationError:
import { StorageOperationError } from "@khair/storage-adapter";
try {
await storage.download("missing.pdf");
} catch (error) {
if (error instanceof StorageOperationError && error.code === "NOT_FOUND") {
// handle missing object the same way regardless of provider
}
throw error;
}error.code is one of "NOT_FOUND" | "ACCESS_DENIED" | "UNKNOWN" — coarse
on purpose, since the three providers don't expose the same granularity of
error detail. error.cause holds the original provider exception if you
need provider-specific detail. ConfigError (thrown for invalid setup, e.g.
missing Azure credentials or requesting isPublic on Azure) is intentionally
not wrapped — those are programming/config mistakes, not runtime failures.
UploadOptions.isPublic is not portable across providers — the three
backends model "public" differently:
- GCS: sets
predefinedAcl: publicReadon the object. Works as long as the bucket hasn't disabled ACLs (uniform bucket-level access). - S3: sets
ACL: 'public-read'on the object. Buckets created since ~April 2023 default to ACLs disabled ("Bucket owner enforced" object ownership), and this will throwAccessControlListNotSupported. For those buckets, make the bucket/prefix public via a bucket policy instead and don't passisPublic. - Azure: throws. Blob Storage has no per-object ACL — public access is a
container-level setting, so
upload()can't grant it; configure the container's public access level instead.
Examples
examples/ has runnable, typechecked usage for the patterns above:
| File | Shows |
| --- | --- |
| 01-basic-usage.ts | upload, exists, download, getSignedUrl, delete |
| 02-switch-provider-per-client.ts | the core use case — same handler code, provider picked per client via .env |
| 03-presigned-browser-upload.ts | issuing a presigned PUT URL so a browser uploads directly, bypassing your server |
| 04-copy-and-cleanup.ts | promoting a draft via copy(), bulk cleanup via list() + deleteMany() |
| 05-error-handling.ts | branching on StorageOperationError.code instead of provider-specific exceptions |
They're checked on every change via npm run typecheck:examples (no emit, just verifies they compile against the real interface). Most need real provider credentials to actually run.
Adding another provider
Implement StorageAdapter (see src/adapters/*.adapter.ts for the existing
three — they import their SDK statically at the top of the file, normally),
add its config shape to the StorageConfig union in src/types.ts, and add
one case to createStorageAdapter in src/factory.ts that lazily
require()s the new adapter file via requireOptionalSdk (follow the
existing cases). Don't re-export the new class from src/index.ts — that
barrel only exports the factory and types, specifically so that importing
the package never eagerly loads every provider's SDK.
Development
npm install
npm run typecheck
npm test
npm run build