Skip to content

Storage

@basaltkit/storage gives every backend one API — a Disk with put/get/exists/delete/list and signed temporaryUrls — and scopes every path by tenant automatically. Local disk and S3 ship in the core; Google Cloud Storage and Azure Blob are drop-in driver packages.

Setup

storagePlugin registers a Storage under the STORAGE token. Declare one or more named disks; start with the local driver, which only needs a folder:

ts
import { createApp } from '@basaltkit/core'
import { storagePlugin, STORAGE } from '@basaltkit/storage'

const app = await createApp({
  plugins: [
    storagePlugin({
      default: 'uploads',
      disks: {
        uploads: { driver: 'local', root: './storage' },
      },
    }),
  ],
}).boot()

const disk = app.container.get(STORAGE).disk()   // the default disk ('uploads')
await disk.put('avatars/1.png', buffer, { contentType: 'image/png' })

Each Disk prefixes paths with tenants/<id> from ctx().tenant — so the same code keeps every tenant's files isolated. Pass scope: null on a disk to turn that off.

put / get / exists / delete / list

put accepts a string or Buffer and creates intermediate folders; get always returns raw bytes as a Buffer:

ts
await disk.put('docs/read-me.txt', 'hello')
await disk.put('img/pixel.bin', Buffer.from([1, 2, 3]))
await disk.put('report.pdf', pdfBuffer, { contentType: 'application/pdf' }) // S3 sets Content-Type

const text = (await disk.get('docs/read-me.txt')).toString()  // Buffer → string

await disk.exists('docs/read-me.txt')  // true
await disk.delete('docs/read-me.txt')  // true (existed and was deleted)
await disk.delete('docs/read-me.txt')  // false (no longer existed)

await disk.list('docs')  // ['docs/read-me.txt', ...] — recursive, sorted
await disk.list()        // every file in the current scope

get on a missing file throws StorageFileNotFoundError.

Multiple named disks

Declare as many disks as you like — e.g. public uploads on one backend, invoices on another — and pick one by name:

ts
storagePlugin({
  default: 'uploads',
  disks: {
    uploads:  { driver: 'local', root: './storage/uploads' },
    invoices: { driver: 's3', bucket: 'company-invoices', region: 'eu-west-1' },
  },
})

const storage = app.container.get(STORAGE)
await storage.disk().put('avatar.png', image)              // default disk
await storage.disk('invoices').put('2026/01.pdf', invoice) // by name

storage.disk('unknown') throws UnknownDiskError.

Drivers

The backend is chosen per disk. local and s3 are strings; cloud drivers are instances (bring the SDK as a peer dependency):

ts
import { GcsStorageDriver } from '@basaltkit/storage-gcs'
import { AzureBlobStorageDriver } from '@basaltkit/storage-azure'

storagePlugin({
  disks: {
    gcs:   { driver: new GcsStorageDriver({ bucket: 'my-bucket', projectId: 'my-project' }) },
    azure: { driver: new AzureBlobStorageDriver({ container: 'uploads', connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING }) },
  },
})
DriverPackageNotes
Local@basaltkit/storageFilesystem — dev and single-node. No temporaryUrl
S3@basaltkit/storageAWS S3, MinIO, Cloudflare R2 (S3-compatible)
GCS@basaltkit/storage-gcsGoogle Cloud Storage (peer: @google-cloud/storage)
Azure Blob@basaltkit/storage-azureAzure Blob (SAS signed URLs; peer: @azure/storage-blob)

S3, MinIO and Cloudflare R2

The s3 driver talks to any S3-compatible service. For AWS, bucket (and usually region) is enough — credentials come from the standard AWS chain. For MinIO or R2, set an endpoint:

ts
storagePlugin({
  disks: {
    uploads: {
      driver: 's3',
      bucket: 'my-app',
      region: 'eu-west-1',
      endpoint: 'http://localhost:9000',          // MinIO / R2 — forcePathStyle becomes true automatically
      credentials: { accessKeyId: '…', secretAccessKey: '…' }, // omit to use the AWS environment
    },
  },
})

Signed URLs

Hand a client a time-limited URL straight to the object, no proxying:

ts
const url = await disk.temporaryUrl('reports/q1.pdf', '15m')

The expiry accepts a duration string ('500ms', '30s', '15m', '2h', '7d') or milliseconds. Supported by s3, GCS and Azure; the local driver throws TemporaryUrlUnsupportedError (serve local files through a route in dev, or run MinIO locally with an s3 disk).

@basaltkit/files builds an upload pipeline on top of this (validation, quota, metadata) — see the File uploads guide.

Errors

ClassCodeWhen
StorageFileNotFoundErrorSTORAGE_FILE_NOT_FOUNDget on a file that doesn't exist
StorageInvalidPathErrorSTORAGE_INVALID_PATHA path escapes the disk root (../…) — local driver blocks traversal
UnknownDiskErrorSTORAGE_UNKNOWN_DISKdisk('name') for a disk that isn't declared
TemporaryUrlUnsupportedErrorSTORAGE_TEMPORARY_URL_UNSUPPORTEDtemporaryUrl on a driver without support (e.g. local)

All extend BasaltError and carry the code above.

Writing a driver

A driver implements the StorageDriver contract — six methods:

ts
import { StorageFileNotFoundError, type PutOptions, type StorageDriver } from '@basaltkit/storage'

export class MyStorageDriver implements StorageDriver {
  readonly name = 'my-backend'
  async put(path: string, content: Buffer | string, options?: PutOptions): Promise<void> { /* … */ }
  async get(path: string): Promise<Buffer> { /* throw StorageFileNotFoundError on miss */ throw 0 }
  async exists(path: string): Promise<boolean> { /* … */ return false }
  async delete(path: string): Promise<boolean> { /* returns whether it existed */ return false }
  async list(prefix: string): Promise<string[]> { /* keys under the prefix */ return [] }
  async temporaryUrl(path: string, expiresInMs: number): Promise<string> { /* optional */ throw 0 }
  async disconnect(): Promise<void> {}
}

Then plug it in as an instance: disks: { d: { driver: new MyStorageDriver() } }. The bundled cloud drivers (@basaltkit/storage-gcs, -azure) take an injectable client, so their logic is unit-tested with a fake — no cloud account. Do the same and your driver is testable in CI.

Released under the MIT License.