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

@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

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.

npm version license


Table of Contents


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

No 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 throws StorageClientError and removes the partial file.
  • getDownloadLink(fileId).filePath is 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.
  • expiresAt is always null — this driver never signs URLs. Authorization for GET /storage/local/:fileId is your app's responsibility (e.g. gate it behind your existing session auth).
  • getDownloadLink(fileId) rejects any fileId that resolves outside storageDir (e.g. ../../.env) with StorageClientError 404 (FILE_NOT_FOUND) instead of reading it — relevant because fileId often comes straight from a public URL param (see Serving local files over Fastify).
  • deleteFile(fileId) removes the blob and its .meta.json sidecar. Idempotent — a fileId that doesn't exist (or resolves outside storageDir) 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; pass onError to hook in your own logger.
  • deleteFile(fileId) is idempotent — a 404 response from the remote service is treated as success, matching the local driver'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:

  • localfolderId becomes a real subdirectory under storageDir (created automatically, mkdir recursive, before the file is written). It's also encoded into the returned fileId (<folderId>/<uuid>), so getDownloadLink(fileId) finds the file again without any extra state on your side — just persist fileId as usual (e.g. photoFileId on a user record), nothing else changes in how you call the API. folderId is sanitized before touching the filesystem (.. segments and absolute paths are rejected with StorageClientError INVALID_FOLDER_ID), so don't build it from unsanitized user input beyond an id/slug you control.
  • s2sfolderId is forwarded as-is to the remote storage service's POST /s2s/upload-links call; 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 StorageDriver interface — this is the whole point. A third driver (S3, GCS, …) is just another implementation of that interface plus a branch in createStorageClient; 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. fastify is optional and type-only unless you call createLocalDownloadHandler.
  • local driver 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