Package reference
Mirrors the package README (single source). Install @basaltkit/storage v5.0.0 — npm · source.
<p align="center"> <a href="https://basaltkit-docs.pages.dev"> <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440"> </a> </p>
@basaltkit/storage
Basalt's file storage layer: stores, reads and deletes files (uploads, reports, images, invoices…) with the same API, whether they live on local disk or in an S3-compatible cloud service (AWS S3, MinIO, Cloudflare R2). You need this module whenever your application deals with files.
What this module solves
Storing files seems simple until you need to change where they live: in development you want a folder on your machine; in production you want an object storage service (services like AWS S3 that store files in a bucket, a kind of uniquely-named "folder in the cloud"). Without an abstraction layer, the code ends up full of fs.writeFile in one place and AWS SDK calls in another.
This module defines a single contract (StorageDriver) with two interchangeable drivers: local (filesystem) and s3 (any S3-compatible service). Your code always talks to a Disk — a named "disk" (e.g. uploads, invoices) — and switching drivers is just a configuration change, never a code change.
It also solves two important problems in SaaS applications: tenant isolation (each customer/organization only sees its own files, automatically stored under tenants/<id>/…) and temporary signed URLs (download links that expire — e.g. "this link to the PDF is valid for 15 minutes" — without making the bucket public). The local driver also blocks path traversal (attempts to escape the root folder with ../).
Installation
pnpm add @basaltkit/storageDepends on @basaltkit/core and already includes the AWS SDK (@aws-sdk/client-s3) — you don't need to install anything else, even if you only use the local driver.
Getting started in 5 minutes
- Register the plugin with at least one disk. Start with the
localdriver, which only needs a folder. - Get
Storagevia theSTORAGEtoken and pick a disk. - Store and read files.
import { createApp } from '@basaltkit/core'
import { STORAGE, storagePlugin } from '@basaltkit/storage'
// 1. A disk called 'uploads', stored in the project's ./storage folder
const app = await createApp({
plugins: [
storagePlugin({
default: 'uploads',
disks: {
uploads: { driver: 'local', root: './storage' },
},
}),
],
}).boot()
// 2. Get the storage service and the default disk
const storage = app.container.get(STORAGE)
const disk = storage.disk() // 'uploads', because it's the default
// 3. Write, read, check and delete
await disk.put('docs/welcome.txt', 'Hello!')
const content = await disk.get('docs/welcome.txt') // Buffer
console.log(content.toString()) // 'Hello!'
console.log(await disk.exists('docs/welcome.txt')) // true
await disk.delete('docs/welcome.txt')
await app.shutdown()For production with S3/MinIO, just change the disk's configuration:
storagePlugin({
default: 'uploads',
disks: {
uploads: s3Disk({
bucket: 'my-app',
region: 'eu-west-1',
// For MinIO or another S3-compatible service:
// endpoint: 'http://localhost:9000',
credentials: { accessKeyId: '…', secretAccessKey: '…' },
}),
},
})Usage guide
Writing and reading files
import { Disk, LocalStorageDriver } from '@basaltkit/storage'
const disk = new Disk('uploads', new LocalStorageDriver({ root: './storage' }), { scope: null })
// Accepts strings and Buffers; intermediate folders are created automatically
await disk.put('docs/read-me.txt', 'hello')
await disk.put('img/pixel.bin', Buffer.from([1, 2, 3]))
// On the S3 driver you can specify the content type (Content-Type)
await disk.put('report.pdf', pdfBuffer, { contentType: 'application/pdf' })
// get always returns a Buffer (raw bytes); convert to text if needed
const text = (await disk.get('docs/read-me.txt')).toString()Upload limits (opt-in, per call)
put() accepts two facade-enforced guards. They run in Disk.put, before the driver is touched, so every driver — local, S3, GCS, Azure, your own — gets the same behaviour:
await disk.put('avatar.png', bytes, {
contentType: 'image/png',
maxBytes: 2 * 1024 * 1024, // → STORAGE_TOO_LARGE above 2 MiB
allowedContentTypes: ['image/png', 'image/jpeg'], // → STORAGE_CONTENT_TYPE otherwise
})Both are opt-in and have no default: this package caps nothing on its own. The cap is enforced for the Buffer | string inputs the driver contract accepts, whose byte length is known up front.
If you want a size limit that applies by default, use @basaltkit/files on top — its upload pipeline applies DEFAULT_MAX_FILE_SIZE = 25 MiB (26 214 400 bytes) when you configure nothing. Requests are separately capped by your HTTP adapter's body limit.
Key validation
Every path goes through one guard before scoping, so a key can never escape its tenant prefix or defeat prefix-based list() isolation. Keys that start with / or \, contain a .. segment, or carry NUL/control characters throw StorageInvalidKeyError (STORAGE_INVALID_KEY). Ordinary nested keys like avatars/123/pic.png are untouched.
Keys must be canonical as well: a . or empty segment (a/./b, ./a, a//b, a trailing /, '') is refused on every driver. The local driver would open the same file as a/b while S3/GCS/Azure store distinct objects, so these are rejected rather than normalized (a silent rewrite would let two different strings address one object). A list() prefix may be '' or end in one /.
Large files: streaming, server-side copy and stat
put/get move whole buffers, which is the wrong shape for a 2 GB video or a database dump. Four optional driver capabilities cover that case — local, s3, azure and gcs implement all four; a driver that does not throws a clear STORAGE_*_UNSUPPORTED error, and disk.supports(capability) tells you in advance.
import { createWriteStream } from 'node:fs'
import { pipeline } from 'node:stream/promises'
// Upload without ever holding the body: Node Readable, web ReadableStream or
// any AsyncIterable<Uint8Array>. maxBytes is enforced WHILE it streams.
await disk.putStream('imports/2026.csv', request.raw, {
contentType: 'text/csv',
contentLength: 48_213, // when the client declared one
maxBytes: 200 * 1024 * 1024,
})
// Download as a stream — consume it or destroy() it, never abandon it.
await pipeline(await disk.getStream('imports/2026.csv'), createWriteStream('/tmp/2026.csv'))
// Server-side copy: the bytes never reach this process (S3 CopyObject, Azure
// copy-from-URL, GCS file.copy, local fs.copyFile).
await disk.copy('drafts/a.pdf', 'final/a.pdf')
await disk.copy('drafts/a.pdf', 'archive/a.pdf', { disk: storage.disk('cold') })
// Metadata without a download (HeadObject / getProperties / getMetadata).
const { size, contentType, etag, lastModified } = await disk.stat('final/a.pdf')Same safety rules as put: keys are validated and tenant-prefixed (fail-closed without a tenant), allowedContentTypes is checked before a single byte is read, and past maxBytes the upload is aborted with STORAGE_TOO_LARGE and the source is destroyed (Node) or cancelled (web stream).
contentLength is held to its word. A value that is not a non-negative safe integer is refused before anything is read (400 STORAGE_CONTENT_LENGTH_INVALID), and a body that carries more or fewer bytes fails with 400 STORAGE_CONTENT_LENGTH_MISMATCH — the moment it passes the declared size, or at its end when it falls short, but always before that end reaches the driver. S3, GCS and Azure commit an upload only when its body ends, so nothing is stored; local removes its partial file. The server-side-copy fallback declares the size stat() reported and is verified the same way.
Notes worth knowing:
- S3 and the body length. With a
contentLengththe body streams straight into onePutObject. Without one,@basaltkit/storage-s3uploads it multipart through the optional peer@aws-sdk/lib-storage— holding at mostpartSizeBytes × queueSize(20 MiB by default) whatevermaxBytessays;maxBytesis a limit, never a buffer size. Only without that peer does an unknown length fall back to one bufferedPutObject(up tomaxBytesin memory), orSTORAGE_STREAM_LENGTH_REQUIREDwith nomaxByteseither. Azure (uploadStream, SDK block buffers) and GCS (a resumable upload) stream bodies of unknown length natively, in bounded memory. copyfalls back. Across two different drivers (or one without a server-side copy) it streamsgetStream→putStream, and finallyget→put. Pass{ requireServerSide: true }to make a fallback an error instead of a quiet download-and-re-upload.- Azure's
syncCopyFromURLis limited to 256 MiB per copy. - A failed streaming upload may leave a partial object on backends that cannot roll one back (
localremoves it). Delete the key when that matters —@basaltkit/filesalready does.
Listing, checking and deleting
await disk.put('a/1.txt', 'x')
await disk.put('a/b/2.txt', 'y')
await disk.list('a') // ['a/1.txt', 'a/b/2.txt'] — recursive, sorted
await disk.list() // all files on the disk (within the current scope)
await disk.exists('a/1.txt') // true
await disk.delete('a/1.txt') // true (existed and was deleted)
await disk.delete('a/1.txt') // false (no longer existed)Keys come back relative to the disk's scope — the same keys put/get take — so a listed key goes straight back into get(). Inside tenant acme the object tenants/acme/a/1.txt lists as a/1.txt; only a central disk (scope: null) sees the tenants/… prefix, because there it IS part of the key. A prefix is a directory on every driver: list('a') means list('a/') and never matches ab/…, even on object stores whose own listing is a plain string match.
> Upgrading from 4.x: a tenant-scoped disk used to return > tenants/<id>/a/1.txt, which get() then prefixed a second time. Drop any > code that stripped the prefix by hand (key.slice('tenants/acme/'.length)), > and stop relying on list('invoice') matching invoice-2026/… on a cloud > driver — list the directory (list('invoice-2026')) instead. Central disks > return exactly what they did.
Multiple named disks
You can declare as many disks as you want — for example, public uploads in one bucket and invoices in another:
import { createApp } from '@basaltkit/core'
import { STORAGE, storagePlugin } from '@basaltkit/storage'
const app = await createApp({
plugins: [
storagePlugin({
default: 'uploads',
disks: {
uploads: { driver: 'local', root: './storage/uploads' },
invoices: s3Disk({ bucket: 'company-invoices', region: 'eu-west-1' }),
},
}),
],
}).boot()
const storage = app.container.get(STORAGE)
await storage.disk().put('avatar.png', image) // default disk ('uploads')
await storage.disk('invoices').put('2026/01.pdf', invoice) // disk by nameTemporary URLs (secure downloads)
A signed URL is a link with a cryptographic signature and an expiration — it lets you grant access to a private file without exposing the bucket. Only the s3 driver supports this feature:
// Valid for 15 minutes; after that the link stops working
const url = await storage.disk('invoices').temporaryUrl('2026/01.pdf', '15m')The expiration accepts milliseconds or strings like '500ms', '30s', '15m', '2h', '7d'. On the local driver this call throws TemporaryUrlUnsupportedError.
Content disposition — attachment by default
Disk.temporaryUrl always passes an explicit disposition down to the driver: 'attachment' unless you deliberately ask for 'inline'. The value is pinned into the signature itself — ResponseContentDisposition on S3, responseDisposition on GCS, contentDisposition on the Azure SAS — so the storage origin/CDN cannot be talked out of it.
// default — the browser downloads the object, it never renders top-level
await disk.temporaryUrl('user-upload.svg', '15m')
// deliberate opt-in — only for content you know is safe to render
await disk.temporaryUrl('brochure.pdf', '15m', { disposition: 'inline' })Why it is fail-closed: a user can upload a file and declare it text/html or image/svg+xml. Served inline off the bucket origin, that is stored XSS on the storage domain. attachment neutralises it. Embedded uses (<img>, <video>) render regardless of disposition, so avatars and media are unaffected.
Direct browser uploads (pre-signed PUT)
const upload = await disk.temporaryUploadUrl(`uploads/${randomUUID()}.png`, {
expiresIn: '5m', // capped by maxTemporaryUploadUrlTtl (default 1h)
contentType: 'image/png', // required, signed
contentLength: file.size, // signed: any other size is rejected (S3, GCS)
})
// browser: fetch(upload.url, { method: upload.method, headers: upload.headers, body: file })Same safety rules as temporaryUrl — key validation, tenant prefix, fail-closed without a tenant, TTL cap. Generate the key server-side, keep the TTL short, always bind the content type and length, and verify the object before trusting it (Azure SAS cannot bind headers at all). Drivers without the capability throw TemporaryUploadUrlUnsupportedError (STORAGE_UPLOAD_URL_UNSUPPORTED). Full flow and per-driver matrix in the Storage guide.
Automatic tenant isolation
Just like the cache, every operation reads the tenant from the request context and prefixes paths with tenants/<id>/. Each tenant gets its own private area with no extra code:
import { runWithContext } from '@basaltkit/core'
import { Disk, LocalStorageDriver } from '@basaltkit/storage'
const disk = new Disk('uploads', new LocalStorageDriver({ root: './storage' }))
await runWithContext({ tenant: { id: 'acme' } }, () => disk.put('logo.png', 'acme-logo'))
await runWithContext({ tenant: { id: 'globex' } }, () => disk.put('logo.png', 'globex-logo'))
await disk.put('logo.png', 'central-logo') // outside any tenant → StorageTenantRequiredError
// Each tenant reads ITS OWN logo.png:
// acme → tenants/acme/logo.png
// globex → tenants/globex/logo.pngIn normal HTTP requests you don't need runWithContext — the framework puts the tenant in the context for you. To disable it, configure the disk with scope: null.
No tenant, no root. A scoped disk with no tenant in context throws StorageTenantRequiredError instead of resolving the key against the bucket root (where list('') would enumerate every tenant). That holds for any custom scope that resolves nothing and for every hand-built new Disk() — it cannot know whether tenancy exists. The one implicit root is a storagePlugin disk on the default scope in an app without @basaltkit/tenancy. Otherwise ask for the root explicitly: scope: null (a central disk) or onMissingScope: 'root' (tenant-scoped inside a tenant, central outside one).
Canonical tenant segments. The default scope only turns canonical ids into a path — lowercase ASCII letters, digits, -, _ and inner . (every id @basaltkit/tenancy's default grammar produces). Anything else throws StorageInvalidScopeError: on a case- or normalization-insensitive filesystem (macOS APFS, Windows NTFS) Acme would open acme's directory on the local driver while S3/GCS/Azure keep them apart, so refusing is what keeps every driver identical. Case-sensitive ids (nanoid, ULID) go through a custom scope that maps them to a canonical segment, e.g. `tenants/x${Buffer.from(id).toString('hex')}`.
Copies stay in their tenant. copy() scopes the destination with the destination disk's scope. A central destination (scope: null) has none, so from a tenant-scoped disk a destination inside tenants/ throws StorageCrossTenantCopyError (403); backups/… and the like are fine.
Image processing (resize, WebP, thumbnails)
disk.image(path) opens a fluent, engine-agnostic pipeline. Operations are collected lazily and run only on a terminal (toBuffer / save / metadata). The result of save() goes back through disk.put, so tenant scoping, the key guard, and upload limits all still apply.
The engine (native sharp) is not bundled here — install the opt-in @basaltkit/image-sharp satellite so apps that never touch images carry no native dependency:
pnpm add @basaltkit/image-sharp sharpimport { storagePlugin } from '@basaltkit/storage'
import { SharpImageProcessor } from '@basaltkit/image-sharp'
storagePlugin({
imageProcessor: new SharpImageProcessor(),
disks: { uploads: { driver: 'local', root: './storage' } },
})// resize + re-encode + write back (content type inferred from the format)
await storage.disk('uploads')
.image('avatars/1.png')
.resize(256, 256, { fit: 'cover' })
.webp(80)
.save('avatars/1.webp')
const thumb = await storage.disk('uploads').image('hero.jpg').resize(320).jpeg().toBuffer()
const { width, height } = await storage.disk('uploads').image('hero.jpg').metadata()Chainable ops: .resize(w?, h?, { fit?, position? }), .rotate(deg?), .blur(sigma?), .grayscale(), .flip(), .flop(), and the encoders .webp(q?) / .jpeg(q?) / .png(q?) / .avif(q?). Without an imageProcessor configured, the terminal throws ImageProcessingUnavailableError. Run heavy work inside a @basaltkit/queue job to keep it off the request path.
API reference
class Disk
new Disk(name: string, driver: StorageDriver, options?: DiskOptions)
| Method | Signature | Description |
|---|---|---|
put | put(path: string, content: Buffer | string, options?: PutOptions): Promise<void> | Writes a file (creates intermediate folders). |
get | get(path: string): Promise<Buffer> | Reads a file; throws StorageFileNotFoundError if it doesn't exist. |
exists | exists(path: string): Promise<boolean> | Checks whether the file exists. |
delete | delete(path: string): Promise<boolean> | Deletes; true if it existed. |
list | list(prefix?: string): Promise<string[]> | Keys under the prefix (recursive, sorted), relative to the disk's scope — each one goes back into get() as-is. The prefix is a directory ('a' = 'a/'). Defaults to ''. |
putStream | putStream(path: string, source: StreamSource, options: PutStreamInput): Promise<void> | Streams a body to the backend; maxBytes enforced mid-stream. Throws PutStreamUnsupportedError if the driver can't. |
getStream | getStream(path: string): Promise<Readable> | Reads the object as a stream — consume or destroy() it. Throws GetStreamUnsupportedError if the driver can't. |
copy | copy(from: string, to: string, options?: CopyOptions): Promise<void> | Server-side copy where possible, else getStream→putStream, else get→put. Both keys are validated and scoped. |
stat | stat(path: string): Promise<StorageStat> | { size, contentType?, etag?, lastModified? } without downloading. Throws StatUnsupportedError if the driver can't. |
supports | supports(capability): boolean | Whether the driver implements temporaryUrl / temporaryUploadUrl / putStream / getStream / copy / stat. |
temporaryUrl | temporaryUrl(path: string, expiresIn: DurationInput, options?: TemporaryUrlOptions): Promise<string> | Pre-signed URL, served attachment unless { disposition: 'inline' }; throws TemporaryUrlUnsupportedError if the driver doesn't support it. |
temporaryUploadUrl | temporaryUploadUrl(path: string, options: TemporaryUploadUrlOptions): Promise<TemporaryUploadUrl> | Pre-signed direct-upload URL: { url, method: 'PUT', headers, expiresAt, key }. Throws TemporaryUploadUrlUnsupportedError if the driver doesn't support it. |
image | image(path: string): ImagePipeline | Opens the lazy image pipeline for path. |
DiskOptions
| Option | Type | Required? | Default | Description |
|---|---|---|---|---|
scope | (() => string | undefined) | null | No | reads ctx().tenant.id → tenants/<id> | Dynamic path prefix, resolved on each operation. null disables it. |
onMissingScope | 'root' | 'error' | No | 'error' for every scoped disk; 'root' only for a storagePlugin disk on the default scope without tenancy | What happens with no tenant in context. |
maxTemporaryUrlTtl | DurationInput | No | '7d' | Longest temporaryUrl lifetime. |
maxTemporaryUploadUrlTtl | DurationInput | No | '1h' (or maxTemporaryUrlTtl if lower) | Longest temporaryUploadUrl lifetime. |
PutOptions
| Option | Type | Default | Purpose |
|---|---|---|---|
contentType | string | — | Content type stored with the object (S3/GCS/Azure; ignored by local). Also what allowedContentTypes is checked against. |
maxBytes | number | — (uncapped) | Facade-enforced upload cap. Over it → STORAGE_TOO_LARGE, before the driver runs. Opt-in — set it on user-supplied content. |
allowedContentTypes | readonly string[] | — (anything) | Facade-enforced allowlist. A missing or unlisted contentType → STORAGE_CONTENT_TYPE. Exact matches only, no wildcards (use @basaltkit/files for image/*). |
PutStreamInput
Extends PutOptions (maxBytes, allowedContentTypes) with:
| Option | Type | Default | Purpose |
|---|---|---|---|
contentType | string | — (required) | The type stored with the object, checked against allowedContentTypes before any byte is read. |
contentLength | number | — | Exact body size when known — S3 streams it into one PutObject. Validated up front (STORAGE_CONTENT_LENGTH_INVALID) and verified against the body (STORAGE_CONTENT_LENGTH_MISMATCH) — see Large files. |
CopyOptions
| Option | Type | Default | Purpose |
|---|---|---|---|
disk | Disk | the source disk | Destination disk. The key is scoped against that disk. |
contentType | string | the source's | Content type for the destination object. |
maxBytes | number | — | Cap for a fallback copy (the only one whose bytes pass through this process). |
requireServerSide | boolean | false | Throw CopyUnsupportedError instead of falling back to a download-and-re-upload. |
StorageStat
| Field | Type | Notes |
|---|---|---|
size | number | Bytes. |
contentType | string | undefined | Not reported by local (the filesystem stores none). |
etag | string | undefined | As the backend returns it, quoting included. Not reported by local. |
lastModified | Date | undefined | mtime on local. |
TemporaryUrlOptions
| Option | Type | Default | Purpose |
|---|---|---|---|
disposition | 'attachment' | 'inline' | 'attachment' | How the signed URL serves the object. Leave it alone for user-uploaded content; 'inline' only when top-level rendering is deliberate. |
endpoint | string | the driver's own | Sign for another host of the same bucket (see below). S3 only; Azure/GCS refuse it. |
TemporaryUploadUrlOptions
| Option | Type | Default | Purpose |
|---|---|---|---|
expiresIn | DurationInput | — (required) | URL lifetime, capped by maxTemporaryUploadUrlTtl. |
contentType | string | — (required) | The only content type the upload may declare; signed (S3, GCS). |
contentLength | number | — | Exact body size; signed (S3, GCS). Omit it and any size is accepted. |
checksumSha256 | string (base64) | — | SHA-256 of the body; signed and verified by S3, refused by GCS/Azure. |
maxBytes | number | — | Facade cap on the declared contentLength (which becomes mandatory) → STORAGE_TOO_LARGE. |
allowedContentTypes | readonly string[] | — | Facade allowlist for contentType → STORAGE_CONTENT_TYPE. |
endpoint | string | the driver's own | Sign for another host of the same bucket — an internal service name, a CDN alias — when the process that uploads reaches the bucket under a different name than this one does (http://minio:9000 vs the public endpoint). Validated: absolute http(s), no credentials, no query/fragment → STORAGE_SIGNING_ENDPOINT_INVALID (400). A deployment value, never client input: a signature minted for a host you do not control is a credential handed to that host. S3 signs for it; Azure and GCS refuse it (their SDKs derive the URL from the account/bucket host). A driver-level default is available as s3Disk({ signingEndpoint }). |
Invalid options (missing/malformed type, non-integer length, malformed checksum, maxBytes without contentLength) → StorageUploadUrlInvalidError (400 STORAGE_UPLOAD_URL_INVALID).
class Storage
new Storage(defaultDisk?: string)
| Method | Signature | Description |
|---|---|---|
add | add(disk: Disk): this | Registers a disk (chainable). |
disk | disk(name?: string): Disk | Returns the disk by name; with no argument returns the default (or the first registered one). Throws UnknownDiskError if it doesn't exist. |
storagePlugin(options: StoragePluginOptions)
Registers Storage in the container under the STORAGE token and disconnects all drivers on shutdown.
| Option | Type | Required? | Default | Description |
|---|---|---|---|---|
disks | Record<string, DiskConfig> | Yes | — | Map of disk name → configuration. |
default | string | No | first registered disk | Disk returned by storage.disk() with no argument. |
imageProcessor | ImageProcessor | No | — | Engine shared by every disk's .image(...) pipeline. Pass new SharpImageProcessor() from @basaltkit/image-sharp; without it the pipeline terminals throw ImageProcessingUnavailableError. |
DiskConfig
One of three forms (all also accept scope from DiskOptions):
{ driver: 'local', root: string }—rootis the root folder on the filesystem.s3Disk({ ...S3DriverOptions })from@basaltkit/storage-s3— see below.{ driver: <a StorageDriver instance> }— any custom driver, e.g.@basaltkit/storage-gcsor@basaltkit/storage-azure.
STORAGE
Dependency injection token: app.container.get(STORAGE) returns the Storage.
class S3StorageDriver (Advanced)
new S3StorageDriver(options: S3DriverOptions) — works with AWS S3, MinIO, Cloudflare R2 and other compatible services.
S3DriverOptions
| Option | Type | Required? | Default | Description |
|---|---|---|---|---|
bucket | string | Yes | — | Bucket name. |
region | string | No | 'us-east-1' | AWS region. |
endpoint | string | No | — | Custom endpoint — set this to use MinIO/R2. |
credentials | { accessKeyId: string; secretAccessKey: string } | No | credentials from the AWS environment | Explicit credentials. |
forcePathStyle | boolean | No | true when there's an endpoint, otherwise false | URLs in the form http://host/bucket/key (required by MinIO). |
serverSideEncryption | 'AES256' | { kms: string } | No | — | SSE sent with every put and signed into every upload URL. |
signingEndpoint | string | No | — | Default host pre-signed URLs are signed for, when it differs from the one this process talks to. A per-call endpoint wins. |
class LocalStorageDriver (Advanced)
new LocalStorageDriver(options: { root: string }) — stores on the filesystem, with root resolved to an absolute path. Rejects paths that try to escape the root (throws StorageInvalidPathError). Doesn't support temporaryUrl.
interface StorageDriver (Advanced)
Contract for building your own driver. Required: name (readable string, used in errors), put, get, exists, delete, list, disconnect. Optional capabilities, each surfaced by disk.supports(...): temporaryUrl, temporaryUploadUrl, putStream (receives one normalized Node Readable that already enforces maxBytes and errors before its end when the body contradicts contentLength), getStream, copy and stat. list(prefix) returns full keys and may match prefix as a plain string: the Disk narrows the result to the directory and strips the scope.
A driver that cannot honour a TemporaryUrlOptions.endpoint override MUST throw the unsupported error rather than ignore it — a URL signed for the wrong host is a silently broken one.
Errors
| Error | Code | HTTP | When |
|---|---|---|---|
StorageFileNotFoundError | STORAGE_FILE_NOT_FOUND | 500 | get() on a path that doesn't exist. |
StorageInvalidPathError | STORAGE_INVALID_PATH | 500 | The local driver resolved a path outside its root (../…). |
StorageInvalidKeyError | STORAGE_INVALID_KEY | 500 | The key starts with / or \, has a .., . or empty segment (a//b, a trailing /, ''), or contains NUL/control characters. Checked for every driver. |
StorageTooLargeError | STORAGE_TOO_LARGE | 500 | put() content exceeds the maxBytes you passed. |
StorageContentTypeError | STORAGE_CONTENT_TYPE | 500 | put() contentType is missing from, or absent in, allowedContentTypes. |
UnknownDiskError | STORAGE_UNKNOWN_DISK | 500 | storage.disk('name') for a disk that isn't declared. |
TemporaryUrlUnsupportedError | STORAGE_TEMPORARY_URL_UNSUPPORTED | 500 | temporaryUrl() on a driver without support (e.g. local), or with an endpoint override the driver cannot sign for. |
TemporaryUploadUrlUnsupportedError | STORAGE_UPLOAD_URL_UNSUPPORTED | 500 | temporaryUploadUrl() on a driver without support, or with an option it cannot bind. |
StorageSigningEndpointInvalidError | STORAGE_SIGNING_ENDPOINT_INVALID | 400 | The endpoint override is not an absolute http(s) URL, or carries credentials/query/fragment. |
PutStreamUnsupportedError | STORAGE_PUT_STREAM_UNSUPPORTED | 500 | putStream() on a driver without the capability. |
GetStreamUnsupportedError | STORAGE_GET_STREAM_UNSUPPORTED | 500 | getStream() on a driver without the capability. |
CopyUnsupportedError | STORAGE_COPY_UNSUPPORTED | 500 | copy({ requireServerSide: true }) with no server-side copy available. |
StatUnsupportedError | STORAGE_STAT_UNSUPPORTED | 500 | stat() on a driver without the capability. |
StorageContentLengthInvalidError | STORAGE_CONTENT_LENGTH_INVALID | 400 | putStream() with a contentLength that is negative, fractional, not finite or past MAX_SAFE_INTEGER. Nothing is read. |
StorageContentLengthMismatchError | STORAGE_CONTENT_LENGTH_MISMATCH | 400 | The putStream() body carried more or fewer bytes than its contentLength. Raised before the body's end reaches the driver, so nothing is committed. |
StorageStreamLengthRequiredError | STORAGE_STREAM_LENGTH_REQUIRED | 400 | putStream() on S3 with neither contentLength nor maxBytes, and without the optional @aws-sdk/lib-storage peer that enables multipart. |
ImageProcessingUnavailableError | STORAGE_IMAGE_UNAVAILABLE | 500 | An image-pipeline terminal ran with no imageProcessor configured. |
StorageTenantRequiredError | STORAGE_TENANT_REQUIRED | 400 | A scoped disk ran with no tenant in context (see No tenant, no root). |
StorageInvalidScopeError | STORAGE_INVALID_SCOPE | 500 | The tenant id (or custom scope) is not a safe path prefix, or — default scope — not canonical (uppercase, non-ASCII, trailing .). |
StorageCrossTenantCopyError | STORAGE_CROSS_TENANT_COPY | 403 | copy() from a tenant-scoped disk into tenants/… on a central (scope: null) disk. |
All extend BasaltError from @basaltkit/core and carry the code above.
The ones marked 500 declare no HTTP status, so the adapters' shared error mapper turns them into a generic 500 INTERNAL_ERROR with the message withheld — storage failures are not a client-facing contract. Catch them in your handler and translate deliberately (a missing file is usually your 404, a rejected upload usually your 413/415). @basaltkit/files already does this: its equivalents carry 413/415/402/404/400.
Hooks & events
@basaltkit/storage emits no hooks — it is a passive I/O layer. The upload lifecycle events (file:uploaded, file:deleted, file:scanned) live in @basaltkit/files, which is built on top of this package.
Common errors and solutions (FAQ)
get throws STORAGE_FILE_NOT_FOUND but I just saved the file. You most likely wrote and read in different tenant contexts: with the default scope, the same logo.png lives at tenants/acme/logo.png for one tenant and at logo.png on a disk that allows the root outside any tenant. Check the context or use scope: null.
STORAGE_TENANT_REQUIRED on a disk I built with new Disk(). A hand-built disk with a scope fails closed without a tenant. A disk with no tenants in it is central — say so with scope: null; one that serves both sides takes onMissingScope: 'root'.
STORAGE_INVALID_PATH when using ../ in the path. This is intentional: the local driver blocks any path that escapes the root folder — it's a security protection against path traversal. Always use relative paths within the disk.
Unknown disk "x" when calling storage.disk('x'). The disk must be declared in storagePlugin({ disks: { x: … } }). Check the name (it's case-sensitive).
temporaryUrl fails with "does not support temporary URLs". The local driver can't generate signed URLs — that's an S3 feature. In development, serve files through a route in your application, or use MinIO locally with an s3 disk.
With MinIO I get connection or bucket errors. Set endpoint: 'http://localhost:9000' (or your address). forcePathStyle automatically becomes true when there's an endpoint — you don't need to set it. Confirm the bucket already exists in MinIO.
get returns a Buffer, I wanted text/JSON. A Buffer is raw bytes. Convert it: buffer.toString() for text, JSON.parse(buffer.toString()) for JSON.
How it connects to other modules
@basaltkit/core— providescreateApp, the container, the request context (from which tenant isolation comes),parseDurationfor expirations, and theBasaltErrorbase class.@basaltkit/tenancy— with the tenancy plugin identifying each request's tenant, disks isolate files per tenant automatically.@basaltkit/http/@basaltkit/express/@basaltkit/fastify/@basaltkit/hono— in upload/download routes, getStoragefrom the container and usedisk.put/disk.get/disk.temporaryUrl.@basaltkit/prisma— a common pattern: store the file on a disk and its path/metadata in the database.
Guides: Storage · Files & uploads · Tenancy.