npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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-adapter

Each 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-blob

createStorageAdapter() 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/storage

Requires 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-presigner

3. 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-blob

3. 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/getSignedUploadUrl need a shared key credential (connection string with AccountKey=, or explicit accountName + accountKey). A connection string built from a SAS token instead of an account key can't mint new signed URLs and will throw a ConfigError.
  • 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: publicRead on 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 throw AccessControlListNotSupported. For those buckets, make the bucket/prefix public via a bucket policy instead and don't pass isPublic.
  • 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