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

@kilnjs/storage

v0.1.3

Published

The storage contract for KilnJS, a driver registry, and the local and in-memory drivers; other providers are packages of their own

Readme

@kilnjs/storage

One storage contract for KilnJS, a registry that resolves a driver by name, and the two drivers that need no package of their own.

| | | | --- | --- | | local | files on the disk the server runs on, under one root directory | | memory | nowhere, and what a test run gets whatever it asks for |

Anything that talks to a service is a package of its own, because a provider is a plugin: @kilnjs/storage-s3, and whatever else you install.

Install

yarn add @kilnjs/storage
yarn add @kilnjs/storage-s3     # only if you use it
// app.config.ts
import { storage } from "@kilnjs/storage";
import { s3 } from "@kilnjs/storage-s3";

export default {
  modules: [/* … */ storage({ drivers: [s3()] })],
} satisfies AppConfig;
import { Storage } from "@kilnjs/storage";

await Storage.put(body, "avatars/1.png");

Two ways in

Storage is both the default driver itself and a map of the installed ones:

await Storage.put(body, "avatars/1.png");          // whichever is default
await Storage.exists("avatars/1.png");

await Storage.local.put(body, "avatars/1.png");    // this driver, by name
await Storage.s3.testConnection();                 // including its own methods

Nothing has to be awaited to reach a driver, only to use one. Building a provider needs the credentials, and those come from app.config.ts, which is read asynchronously - so Storage.local is a handle that forwards each method to the provider, and the wait happens inside the call, where there is a promise already.

The handle is built when the driver registers, from the methods on its class, so a provider's own methods are reached the same way as the contract's. Two consequences worth knowing:

  • Methods only. A public field or getter on a provider is not on the handle, because reading one would mean having built the provider already. A helper a provider wants off the handle should be #private, not private, which is a word the compiler keeps to itself.
  • Storage.local instanceof LocalStorage is false - it is a handle, not the provider. storageDriver("local") resolves to the provider itself.

When a path comes back off a row, the driver it was stored with comes with it, and storageDriver(name) is also the door for a name that is only a string:

const file = await database.attachment.findById(id);
const driver = await storageDriver(file.driver);

const body = await driver.buffer(file.path);

Configuration

Four fields in app.config.ts, and two of them are all most servers set:

import type { StorageConfig } from "@kilnjs/storage";

export default {
  storage: {
    driver: "local",

    credentials: {
      local: { root: "storage" },
      s3: { bucket: "uploads", region: "eu-central-1" },
    },
  } satisfies StorageConfig,
} satisfies AppConfig;

That section is this package's, not core's. Core declares no settings for anything it is given; a module merges its own section into ModuleConfig, so storage exists in app.config.ts exactly as long as this package is installed - which is what naming StorageConfig above brings in.

Storage is configured here rather than by a modules entry because nothing of it happens at boot: there is no module to run, only drivers to resolve when something stores a file.

driver is what the bare actions use. credentials is what each driver is configured with, by the name it answers to - and anything left out falls back to that provider's own environment variables, field by field, so secrets can stay in the environment while the rest is declared here.

| | | | --- | --- | | driver | what the bare actions use | | credentials | what each driver is configured with | | substitute | whether a test run reaches the in-memory driver whatever was named. True in a test run unless this says otherwise | | keepExtension | whether a generated upload key ends in the extension the file arrived with |

substitute: false is for an integration suite that means to exercise a real driver: every other suite gets the in-memory one, which is what keeps a unit test from writing to a bucket.

Every part of it is typed:

error TS2561: 'rooot' does not exist in type 'LocalCredentials'. Did you mean 'root'?
error TS2353: ... and 's3' does not exist in type 'Partial<StorageCredentials>'
error TS2322: Type '"s3"' is not assignable to type '"local" | "memory"'

The second is the one worth noticing: configuring a driver you have not installed fails to compile, because the field only exists once its package does.

Providers behave like plugins

A provider package registers its class and merges itself into two interfaces, both of them this package's - core is not involved:

// @kilnjs/storage-s3
declare module "@kilnjs/storage" {
  interface StorageDrivers {
    s3: S3Storage;        // what it is
  }

  interface StorageCredentials {
    s3: S3Credentials;    // what it takes
  }
}

export function s3(): StorageDriver<"s3", S3Storage> {
  return {
    provider: S3Storage,
    build: (credentials) => new S3Storage(credentials),
  };
}

A driver is handed over, not installed. It used to register itself when its package was imported, so app.config.ts carried a bare import "@kilnjs/storage-s3" with a comment explaining that the import was the installation. That is a side effect of loading a file, which means the drivers a server has depend on what happened to get imported and in what order, and nothing declares it. Now importing a package is inert and storage({ drivers: [s3()] }) says in one line what the comment used to say. storage() registers them in setup, so every driver is a decision with a name in the configuration.

The class is passed rather than its name: the registry reads the name off SIGNATURE and the driver's methods off the prototype, which is what the handle is made of. The builder is handed whatever storage.credentials.s3 held - typed, because registerStorage reads that argument off the same interface the provider just merged into, keyed by the name on the class.

Storing an upload

const stored = await putUpload(upload, { prefix: `posts/${post.id}` });

await database.attachment.attach({
  storageKey: stored.key,
  filename: stored.filename,
  contentType: stored.contentType,
  size: stored.size,
});

| | | | --- | --- | | prefix | where under the root it lands. Required | | driver | a named driver, where it is not the default one | | keepExtension | end the key in the extension the file arrived with, over whatever app.config.ts declared | | key | build the whole key, where a generated one will not do |

keepExtension is for a CDN, which reads the key and nothing else: the extension is taken from the filename, lowercased, and kept only where it looks like one, so ../../escape.png still produces a generated key that happens to end in .png.

key is the escape hatch, and it is the one option that hands the naming back: whatever it answers is the key, so it is the caller's job to make sure a path somebody typed cannot reach it.

The key is generated and the filename is kept as data, which is the whole point of the function existing. Three applications wrote these four lines by hand, correctly, because whoever wrote them knew that a filename is a string somebody typed: ../../etc/passwd is a perfectly ordinary one. assertUsablePath already refuses a key that climbs out, so the remaining risks were quieter: a caller choosing where their file lands, two callers colliding on one name, and one overwriting the other. None of that is left to remember now.

The contract

abstract class StorageProvider {
  static readonly SIGNATURE: string;

  abstract put(body: Buffer, path: string): Promise<StoredObject>;
  abstract stream(path: string): Promise<Readable>;
  abstract exists(path: string): Promise<boolean>;
  abstract remove(path: string): Promise<boolean>;
  abstract downloadUrl(path: string, expiresIn?: number): Promise<string | null>;

  buffer(path: string): Promise<Buffer>;   // from stream, for free
}

put answers with a StoredObject - the driver, the path and the size - which is what an application stores on whatever row owns the file, so a later read knows who to ask and for what.

The path it answers with is the path that was given. Where that lands is the driver's business: the local driver roots it under a directory, the S3 one puts it under a key prefix, and both take the same string back for a read, a check or a delete. So a stored path means the same thing whichever driver wrote it, and an application can be moved from one to the other without rewriting a column.

Two guarantees

A test run never reaches a real store. Every driver, named or default, answers with the in-memory one, so no suite can touch a bucket or a disk. Anything a suite relies on should therefore be on the contract above rather than on one provider.

That is also why the shape of a usable path is on the contract: relative, no .. segment, no nul byte, refused by every driver including the one with nowhere to escape to. A rule that lived only in the local driver would be one no suite applies, so an application's own test for a traversal attempt would pass while the attempt "succeeded" in memory. The local driver still resolves and re-checks against its root afterwards, because a lexical rule cannot see a symbolic link.

A driver nothing registered fails everywhere. The registration is checked before that substitution, so a name that resolves nowhere cannot pass in a test and throw in production. The message names what is installed.

The local driver

Suited to development, to a single-host deployment and to a mounted volume. Not suited to more than one host: another one cannot read what this one wrote.

| | | | --- | --- | | root / LOCAL_STORAGE_ROOT | storage - the directory everything is kept under. A relative path resolves against the working directory | | url / LOCAL_STORAGE_URL | where the root is served from, if anything serves it. Unset means downloadUrl answers null |

Keep the root out of version control, and off the path your server publishes unless that is what url says it is.

A path cannot climb out of the root. A stored path is data - it came from an upload, a column or a URL - so ../, and an absolute path, resolve somewhere real on a disk. Every operation routes through one check, and a path that lands outside raises rather than reading back as "nothing is there":

GenericError: That path lands outside the storage root

A file is whole or absent. The bytes are written beside the target and renamed over it, and a rename within a filesystem is atomic, so a crash or a concurrent write leaves no half-file for a reader to find.

A missing file raises where the caller is. stream() checks first: a read stream on nothing emits its error later, to whoever happens to be listening, which is rarely the code that asked.

put identifies the object by the sha256 of its contents, which is what an ETag is for the stores that have one - equal ids mean equal bytes. downloadUrl ignores expiresIn, because the file is served by something else: for how long and to whom is that server's business, and signing would be a promise this driver cannot keep.

Development

yarn verify   # ts:check, lint, build, exports:check, test

The local driver's tests write to a real temporary directory, because what it has to get right is where a path lands on a disk. Nothing else touches one.