@alis-kit/storage-client
v2.0.0
Published
Pluggable file storage client — local disk or S2S relay to a remote storage service, behind one interface
Maintainers
Readme
@alis-kit/storage-client
Pluggable file storage client for Node.js — one interface, two interchangeable
drivers: local (disk on the same server, no external dependency) and
s2s (relay to a remote storage service over HTTP, service-to-service).
Switch drivers via configuration; calling code never branches on which one is
active.
Table of Contents
- Why
- Install
- Quick Start
- Driver:
local - Driver:
s2s - Separating files by folder (
folderId) - Serving local files over Fastify
- Error Handling
- Design Notes
- License
Why
An app that stores files (photos, exports, attachments) shouldn't have to
choose its storage backend at every call site. This package gives you one
StorageDriver interface —
interface StorageDriver {
uploadFile(stream: Readable, meta: UploadFileMeta): Promise<UploadedStorageFile>;
getDownloadLink(fileId: string): Promise<StorageDownloadLink>;
deleteFile(fileId: string): Promise<void>;
}— and two implementations of it. Start with local (zero external services),
move to s2s (a shared storage service, e.g. quota/versioning/thumbnails
handled centrally) later, without touching the code that calls
uploadFileToStorage/getFileDownloadLink.
Install
npm install @alis-kit/storage-clientNo hard dependency on a web framework or a database. fastify is an
optional dependency, needed only if you use the bundled
Fastify download handler.
Quick Start
import { createStorageClient } from '@alis-kit/storage-client';
// Pick the driver from your own env/config — this is the ONLY place that
// needs to know which one is active.
const storage = createStorageClient(
process.env.STORAGE_DRIVER === 's2s'
? {
driver: 's2s',
s2s: {
baseUrl: process.env.FILE_STORAGE_API_BASE_URL!,
apiKey: process.env.FILE_STORAGE_API_KEY!,
},
}
: {
driver: 'local',
local: {
storageDir: process.env.LOCAL_STORAGE_DIR ?? './storage-data',
buildDownloadUrl: (fileId) => `${process.env.PUBLIC_BASE_URL}/storage/local/${fileId}`,
},
},
);
// Everywhere else in your app:
const uploaded = await storage.uploadFile(fileStream, {
fileName: 'laporan.pdf',
sizeBytes: 204_800,
mimeType: 'application/pdf',
});
const link = await storage.getDownloadLink(uploaded.fileId);
// link.url, link.previewUrl, link.expiresAt, link.fileName, link.mimeType, link.sizeBytes
// Replacing a file (e.g. new avatar)? Delete the old one — idempotent, so a
// fileId that's already gone doesn't need its own try/catch:
await storage.deleteFile(previousFileId);Driver: local
Stores the file on disk under storageDir; metadata (original name, MIME
type, size) is kept as a JSON sidecar next to the blob — no database
required.
import { createLocalStorageDriver } from '@alis-kit/storage-client';
const driver = createLocalStorageDriver({
storageDir: './storage-data',
// Optional — omit if you'll serve files yourself via `link.filePath`.
buildDownloadUrl: (fileId) => `https://api.example.com/storage/local/${fileId}`,
});- Upload is streamed to disk (
fs.createWriteStream+pipeline), never buffered fully in memory. - The bytes actually written are verified against the declared
sizeBytes; a mismatch throwsStorageClientErrorand removes the partial file. getDownloadLink(fileId).filePathis the absolute path on disk — read it directly if you're serving the file from the same process (see the Fastify handler below), or use it with any framework's own static/stream response.expiresAtis alwaysnull— this driver never signs URLs. Authorization forGET /storage/local/:fileIdis your app's responsibility (e.g. gate it behind your existing session auth).getDownloadLink(fileId)rejects anyfileIdthat resolves outsidestorageDir(e.g.../../.env) withStorageClientError404 (FILE_NOT_FOUND) instead of reading it — relevant becausefileIdoften comes straight from a public URL param (see Serving local files over Fastify).deleteFile(fileId)removes the blob and its.meta.jsonsidecar. Idempotent — afileIdthat doesn't exist (or resolves outsidestorageDir) resolves without error rather than throwing.
Driver: s2s
Relays uploads/downloads to a remote storage service over HTTP, service-to-service — your app never touches the file bytes directly for metadata, and the API key never reaches end users.
import { createS2sStorageDriver } from '@alis-kit/storage-client';
const driver = createS2sStorageDriver({
baseUrl: 'https://storage.example.com/api',
apiKey: process.env.FILE_STORAGE_API_KEY!,
onError: (event) => logger.error(event, 'storage client error'), // optional
});Expects the remote service to implement this contract (matches the s2s
module of a Fastify + @alis-kit/routers storage service, but any backend
following the same shape works):
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /s2s/upload-links | X-Api-Key | Request a short-lived presigned upload URL |
| PUT | <uploadUrl> | signature in query | Upload the raw bytes |
| GET | /s2s/files/:fileId/download-link | X-Api-Key | Request a presigned download URL |
| DELETE | /s2s/files/:fileId | X-Api-Key | Delete a file |
Responses are expected as { message: string, data: T | null } — data is
null on error, and message is used as the error text. DELETE may also
reply 204 No Content with no body.
- Upload is streamed via
Readable.toWeb()+fetch({ duplex: "half" })— never buffered fully in memory, safe for large files. - Both request failures (network unreachable) and non-2xx responses throw
StorageClientError; passonErrorto hook in your own logger. deleteFile(fileId)is idempotent — a404response from the remote service is treated as success, matching thelocaldriver's behavior.
Separating files by folder (folderId)
Pass folderId in UploadFileMeta to keep uploads from different features,
tenants, or owners from landing in one flat pile — there's no fixed
convention for the value, any consuming app/call site picks its own string:
const uploaded = await storage.uploadFile(fileStream, {
fileName: 'foto-profil.jpg',
sizeBytes: 51_200,
mimeType: 'image/jpeg',
folderId: `profile-photo/${userId}`, // any string you like — not a fixed schema
});Behavior differs by driver, but calling code never needs to know which one is active:
local—folderIdbecomes a real subdirectory understorageDir(created automatically,mkdirrecursive, before the file is written). It's also encoded into the returnedfileId(<folderId>/<uuid>), sogetDownloadLink(fileId)finds the file again without any extra state on your side — just persistfileIdas usual (e.g.photoFileIdon a user record), nothing else changes in how you call the API.folderIdis sanitized before touching the filesystem (..segments and absolute paths are rejected withStorageClientErrorINVALID_FOLDER_ID), so don't build it from unsanitized user input beyond an id/slug you control.s2s—folderIdis forwarded as-is to the remote storage service'sPOST /s2s/upload-linkscall; the separation happens on that service's side (e.g. it may group files under it, or use it purely for reporting).
Omitting folderId (or passing null) keeps the previous flat behavior —
this is fully backward compatible, existing callers don't need to change.
Serving local files over Fastify
import { createLocalDownloadHandler } from '@alis-kit/storage-client';
app.get('/storage/local/:fileId', { preHandler: yourAuthGuard }, createLocalDownloadHandler(driver));fastify is only imported as a type here — calling this function does
not require fastify to be installed unless you actually use it as your web
framework (it's an optionalDependency of this package for that reason).
Using it against an s2s driver always 404s (filePath is only ever set by
the local driver — the file genuinely isn't on this machine).
Error Handling
Every thrown error is a StorageClientError:
export class StorageClientError extends Error {
readonly statusCode: number; // suggested HTTP status
readonly code: string; // e.g. "FILE_NOT_FOUND", "STORAGE_UNREACHABLE"
}Map it to your own error class at the boundary if you want a single error hierarchy across your app:
try {
await storage.uploadFile(stream, meta);
} catch (error) {
if (error instanceof StorageClientError) {
throw new AppError(error.statusCode, error.code, error.message);
}
throw error;
}Design Notes
- Both drivers implement the exact same
StorageDriverinterface — this is the whole point. A third driver (S3, GCS, …) is just another implementation of that interface plus a branch increateStorageClient; nothing else in a consuming app changes. - No framework/database lock-in. The core (
types.ts, both drivers,storage-client.ts) has zero runtime dependencies.fastifyis optional and type-only unless you callcreateLocalDownloadHandler. localdriver has no database dependency on purpose — the JSON sidecar keeps this package usable in any app regardless of what ORM/DB they use. If you need richer local metadata (tags, search, per-file ownership records), wrap this driver in your own layer rather than extending it here — folder-level separation (folderId) is covered natively (see above), but arbitrary querying/indexing is not this package's job.
License
MIT
