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

@mdimranh/filemanager

v0.1.0

Published

A React file manager — folders, uploads with progress, thumbnails, a picker — on top of the onestorage object store.

Readme

@mdimranh/filemanager

A file browser for React, on top of onestorage.

Folders, uploads with real progress, thumbnails, search, sorting, a picker modal — and a server that stores the files in whichever object store you already have. Local disk, Amazon S3, Cloudflare R2, Backblaze B2, MinIO, Azure Blob, Google Cloud Storage, Wasabi: one configuration object, no call site changes.

  • No database required. Folder names, who uploaded what, what a file is called and how often it has been opened are kept as one JSON document beside the objects — in the same bucket — which is fine for one server. A deployment with several servers or a serverless platform with more than one instance wires the same eight-method LibraryRepository interface against its own database (Postgres, SQLite, MongoDB, anything) and the rest of the library does not change.
  • Bytes never pass through your server when the backend can avoid it. An upload is authorised on the server, the browser is handed a presigned PUT, and the row only becomes real once the store confirms an object of the expected size arrived. A 400 MB file does not appear in a request log.
  • Tailwind in, Tailwind out. The components are Tailwind utilities over a small fl-* token vocabulary, so they inherit the palette, radii and dark mode of the project they are dropped into — not a theme of their own. No runtime dependency: the only build-time requirement is the Tailwind you already have. Icons are drawn, dates come from toLocaleString.
  • The rules are the server's. can in every listing is computed by running the same rule the write path enforces, so a button is never the only thing standing between a person and an act.
npm install @mdimranh/filemanager
import { FileLibrary, FileLibraryProvider } from '@mdimranh/filemanager/react';

<FileLibraryProvider baseUrl="/api/files">
  <FileLibrary />
</FileLibraryProvider>
/* your Tailwind entry point — see “Styling” below */
@import "tailwindcss";
@import "@mdimranh/filemanager/styles.css";
@source "../node_modules/@mdimranh/filemanager/dist/react";

There is a running demo in this repository — real objects on disk, the real handler, React bundled by Bun:

bun examples/demo.ts      # → http://localhost:4173

The three entry points

| Import | What it is | Runs where | | --- | --- | --- | | @mdimranh/filemanager | the shared vocabulary: types, file rules, size and date labels | anywhere | | @mdimranh/filemanager/server | the service and its HTTP handler, on onestorage | Node, Bun, Workers | | @mdimranh/filemanager/react | the browser, the picker and the upload dock | the browser |

An application imports two of them and its own framework glue in between:

browser ── FileLibraryProvider ──▶ /api/files ──▶ createFileLibraryHandler ──▶ onestorage ──▶ the store
                (queue, progress)      (HTTP)          (rules, folders, index)

Server

1. Mount the handler

It is one Request → Response function. Next.js, Bun, Deno, Cloudflare Workers and Hono take it directly; Express and Fastify are a three-line adapter away.

// app/api/files/[[...route]]/route.ts
import { createStorage } from 'onestorage';
import { createFileLibraryHandler } from '@mdimranh/filemanager/server';

const storage = createStorage({ driver: 'fs', root: './storage' });

const handler = createFileLibraryHandler({
  storage,
  basePath: '/api/files',
  resolveActor: async (request) => {
    const user = await readSession(request);
    if (!user) return null;
    return {
      id: user.id,
      name: user.name,
      ownerKey: (scope) => (scope === 'mine' ? user.id : user.organizationId),
      ownerLabel: user.organizationName,
      scopes: { shared: true, mine: { read: true, upload: true, update: true, delete: true, share: true } },
    };
  },
});

export { handler as GET, handler as POST, handler as PUT, handler as PATCH, handler as DELETE };

basePath must match where the file is mounted: every URL the library mints — a thumbnail, a presigned upload, a download — is built from it, and a wrong one is a broken image on every row.

2. Say who is asking

resolveActor is the whole integration. It returns:

  • scopes — which trees this caller can reach, and what they may do in each. true is shorthand for every capability.
  • ownerKey — which tree inside a scope. A string, or a function of the scope name. This is where an organisation id and a person's own id are told apart. It defaults to the caller's id, because a default that leaks across tenants is the one mistake this package must not be able to make: a shared tree is something you opt into by naming one key for everybody.
  • ownerLabel — what the toolbar says, so nobody has to guess whose files these are.

Returning null answers every route with 401.

3. Choose the backend

// Local disk. Atomic writes, and nothing ever leaves the machine.
createStorage({ driver: 'fs', root: '/var/lib/files', metadataSidecar: true });

// Cloudflare R2. Uploads go from the browser straight here; the library only signs them.
createStorage({
  driver: 's3',
  provider: 'r2',
  accountId: process.env.R2_ACCOUNT_ID!,
  bucket: 'documents',
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

Everything in the onestorage documentation applies — including one client with several backends, so uploads/ can be hot on R2 while archive/ is cold on disk:

const storage = createStorage({
  default: 'disk',
  drivers: {
    disk: { driver: 'fs', root: '/var/lib/files' },
    hot: { driver: 's3', provider: 'r2', accountId: '…', bucket: 'hot', credentials: r2Credentials },
  },
  mounts: { 'files/hot/': 'hot' },
});

The library decides per key whether a URL can be presigned, so a mounted layout gets the right answer for each object rather than one answer for the process.


React

The browser

import { FileLibrary, FileLibraryProvider } from '@mdimranh/filemanager/react';

export function FilesPage() {
  return (
    <FileLibraryProvider baseUrl="/api/files">
      <FileLibrary
        title="Files"
        ownerNote="Devspire buying house"
        // Mirror every move into the URL, if you want a folder to be a link somebody can send:
        onNavigate={(state) => history.replaceState(null, '', toQuery(state))}
      />
    </FileLibraryProvider>
  );
}

What the screen does: a toolbar (search, kind filter, sort, list/grid, new folder, upload), a breadcrumb trail, folder cards with rolled-up totals, file rows or thumbnail tiles, a pager, a per-file menu (download, preview, copy a direct link, rename, move, description and tags, who can see it, remove) and drag-and-drop upload anywhere on the surface.

Every control is rendered from what the server reported it may do, so a read-only caller sees no upload button and a missing button is never the only thing preventing an act.

The picker

For the places that need a file rather than a list of them — a profile picture, a purchase order attachment:

<FilePicker
  open={open}
  onOpenChange={setOpen}
  title="Choose a profile picture"
  kind="IMAGE"                       // a picture picker should not offer a spreadsheet
  onPick={(entry) => { save(entry.id); setOpen(false); }}
/>

It is the same listing, the same thumbnails, the same rights, and the same upload queue — so it cannot offer a file the full screen would refuse to open. A file uploaded from inside the picker is a document in the library, not a hidden second copy.

For places that need a set of files — a shipment with several invoices, a profile with a few pictures — pass multiple: true and use onPickMultiple instead of onPick:

<FilePicker
  open={open}
  onOpenChange={setOpen}
  title="Choose the invoices for this shipment"
  multiple
  onPickMultiple={(entries) => { save(entries.map((e) => e.id)); setOpen(false); }}
/>

In multi-select mode each tile toggles in pick order, and a freshly uploaded file joins the selection automatically. The two callbacks are mutually exclusive at the type level — TypeScript catches the mismatch at the call site, so a caller cannot wire one up and accidentally call the other.

Loading states

A spinner tells the user the screen is busy. A skeleton tells them what is coming. The library ships two skeletons that mirror the real layout's grid:

import { LibrarySkeleton, PickerSkeleton } from '@mdimranh/filemanager/react';

// While the first view is in flight, drop in the skeleton the same way the component itself does.
{!view && viewer.loading ? <LibrarySkeleton mode={mode} /> : null}

// The picker has its own — a chooser tile grid instead of the browser's folder-and-row layout.
{!view && viewer.loading ? <PickerSkeleton /> : null}

In a Next.js App Router page the route-level loading.tsx is the place for a server-rendered skeleton so the page that arrives looks like the page that was promised — no client JS, and no flash of an empty structure while the segment prepares:

// app/files/loading.tsx — a server component
export default function FilesLoading() {
  return (
    <main className="mx-auto max-w-6xl p-6">
      {/* plain Tailwind, no client JS; matches the in-component LibrarySkeleton */}
    </main>
  );
}

Uploads outlive the screen

The queue lives in the provider, so starting a twelve-file upload and then opening the record those files belong to does not orphan it. The dock sits bottom-right, above dialogs (the picker uploads too), and a reload while anything is outstanding is warned about rather than silently lost.

const uploads = useUploads();
uploads.add(files, { scope: 'shared', folderId, onUploaded: (entries) => console.log(entries) });

Anything the library refuses — an .exe, an empty file, an oversized one — is refused before a byte is sent, and appears in the dock with the reason.

Acting on many files at once

A Select toggle in the toolbar enters select-mode: every row and tile grows a checkbox, clicking the body toggles selection, and a sticky bar above the listing counts the selection and offers the four bulk operations — delete, move, change who can see it, and edit description & tags. The operations the actor is not allowed to perform (move without can.move, share without can.share) are hidden from the menu, not just disabled.

Each bulk action goes through the same per-entry server methods as the single-file actions, so the rules — capability, visibility, duplicate-name in the destination folder — apply per row. A bulk update that targets two files in the same destination folder with a colliding name gets one ok: false row and one ok: true row, with a notice that says which one failed and why.

For custom UIs, the same four operations are available as client methods:

await library.bulkRemoveEntries({ entryIds: ['a', 'b', 'c'] });
await library.bulkMoveEntries({ entryIds: ['a', 'b'], folderId: 'archive' });
await library.bulkSetEntriesVisibility({ entryIds: ['a'], visibility: 'PRIVATE' });
await library.bulkUpdateEntries({ entryIds: ['a'], patch: { tags: ['reviewed', 'q3'] } });

Each returns { results: BulkEntryResultRow[] }. A selection does not survive a folder hop — the toolbar toggle and the selection clear when the user navigates into another folder.

Styling

The components are Tailwind utilities, so there is no stylesheet of theirs to fight. Tailwind needs two things from you, and both are one line.

@import "tailwindcss";

/* the package's tokens and the `.fl` scope. Import it *after* tailwind. */
@import "@mdimranh/filemanager/styles.css";

/* where the package's class names live. `node_modules` is skipped by default, so without
   this every utility compiles to nothing and the browser renders as unstyled HTML. */
@source "../node_modules/@mdimranh/filemanager/dist/react";

The stylesheet above is not components — it is a vocabulary. It declares --color-fl-bg, --color-fl-surface, --color-fl-border, --color-fl-fg, --color-fl-accent and their intention-named siblings, plus --radius-fl-sm|fl|fl-lg and --shadow-fl, and the handful of base rules a component cannot inherit safely.

So to theme it, restate those tokens in your own @theme — nothing is overridden and nothing is wrapped:

@theme {
  --color-fl-accent: light-dark(var(--color-brand-600), var(--color-brand-400));
  --color-fl-border: light-dark(var(--color-ink-200), var(--color-ink-700));
  --radius-fl: 10px;
}

Order matters in one direction only: Tailwind emits @theme in source order, so this block belongs below the package's import. A host that says nothing at all gets the package's neutral palette, which is deliberately one a file browser can be dropped into.

Dark mode is color-scheme, and nothing else. Every colour token — the package's, and the ones you restate — is a light-dark() pair, and a pair inside a custom property resolves against the color-scheme of the element using it, not the one it was declared on. color-scheme is inherited, so the switch is one line, in the place you already do it:

:root { color-scheme: light dark; }   /* follow the system's preference */
.dark { color-scheme: dark; }         /* or a class you already toggle */

Inheritance is the reason it is not an attribute or a class of the package's own: the dialog and the upload dock are portalled to <body>, where a selector scoped to the element that wraps the browser cannot reach them and an inherited property can. Nothing to pass, nothing to render, nothing that can drift out of sync with the rest of your app.

The consequence worth knowing: restating a colour replaces the package's pair, so a restated token needs both halves. Forget the dark one and dark mode renders a white browser on a dark page — a failure the pair shape makes visible in the diff. Radius needs no pair; a shape does not change when the lights go out.

Your own components. Nothing in the package is a class you have to know: a button next to it is your button, styled with your utilities, because the accent it must match is a token and not a selector. The exported renderers — FileLibrary, FilePicker, UploadDock and the dialogs — take className, and the small ones (FileThumb, KindBadge, Badge, FolderGlyph) are there if you would rather build a row of your own.


The model

Folders are metadata, not paths

A folder is a row with a name and a parent. A file's folder is one column on the file, so moving a file between folders rewrites nothing in the store — the object keeps its key, its URL keeps working, and the download count, history and links survive. Renaming changes a label, not an address.

An object's key is <keyPrefix>/<scope>/<ownerKey>/<id>.<ext>: the id carries the identity and the extension carries the content type, and the name is not in the key at all, which is what makes a rename free.

Private means private

A file is either the library's or its uploader's. A private file is readable by the person who uploaded it and by nobody else — not by an administrator of that library, and not by a cross-tenant probe, which gets a 404 rather than a 403 so it cannot tell a hidden file from a missing one. There is no per-person sharing list: roles and trees answer that question, and a share list is where a file library turns into a permissions system nobody can read.

Unfinished uploads are visible

An upload is authorised with the row in PENDING, and the row only becomes ACTIVE once the store confirms an object of the expected size is there. So PENDING is a state a person can see — an upload that was abandoned — rather than a claim about a zero-byte file that never existed. It is removable from the same menu as anything else.

Refusals happen before a byte moves

Extensions that can run code (.exe, .js, .html, .svg, .py, …) are refused at the point of authorisation, not scanned for afterwards. Empty files are refused. Oversized ones are refused against maxFileBytes (250 MB by default). Nothing is ever cleaned up because nothing was written.

What is deliberately not here

No databases in the package itself (you bring your own — see Using a database below), no virus scanning, no image resizing (a thumbnail is the original served with render=1, which the browser scales), no version history, no per-person sharing, no permissions model. Those are either somebody else's product or a decision your application should be making.


Configuration

createFileLibrary({
  storage,                                  // required: an onestorage client
  resolveActor,                             // required: who is asking
  scopes: [{ name: 'shared', label: 'Team files' }],
  keyPrefix: 'files',                       // where objects are written
  repository,                               // bring your own (see below); defaults to a JSON document
  maxFileBytes: 250 * 1024 * 1024,
  maxFolderDepth: 6,
  pageSize: 50,
  upload: 'auto',                           // 'auto' | 'signed' | 'proxy'
  signedUploadExpiresIn: 900,
  signedUrlExpiresIn: 3600,
  validateUpload: ({ name, sizeBytes, mimeType }) => (mimeType === 'image/heic' ? 'Convert that first.' : null),
  onOrphanedObject: ({ entry, error }) => queueForCleanup(entry),
});

upload

  • auto (default) — presign when the backend can be reached by a browser, and stream through the handler when it cannot. That includes the local-disk and in-memory drivers, whose "signed" URLs are for your server to verify rather than for a browser to send.
  • signed — always presign. Use it when the bucket is reachable but the handler is not, and the bytes must not go through your process.
  • proxy — always stream through the handler. Use it behind a private network, or when you want every byte to pass through your own logging.

The client does not care which it got.

Where the metadata lives

By default the metadata is one JSON document in the same store, written through a queue so a single process is always consistent. That is deliberately the only thing that is not multi-writer: a deployment with several servers, or a serverless platform with more than one instance, supplies its own LibraryRepository. See Using a database below.


Using a database

The library never imports an ORM, a driver, or a query builder. Folder and entry metadata lives behind a LibraryRepository — eight typed methods, two tables, fixed input and output shapes. Implement it against your database and pass it through repository:

import {
  createFileLibraryHandler,
  type LibraryRepository,
  type ListTreeInput,
  type IdInput,
  type EntryRecord,
  type FolderRecord,
} from '@mdimranh/filemanager/server';

const repository: LibraryRepository = {
  async listFolders({ scope, ownerKey }: ListTreeInput): Promise<FolderRecord[]> {
    return db
      .select()
      .from(folders)
      .where(and(eq(folders.scope, scope), eq(folders.ownerKey, ownerKey)));
  },
  async getFolder({ id }: IdInput): Promise<FolderRecord | null> {
    const row = await db.select().from(folders).where(eq(folders.id, id)).limit(1);
    return row[0] ?? null;
  },
  async saveFolder(folder: FolderRecord): Promise<void> {
    await db.insert(folders).values(folder).onConflictDoUpdate({ target: folders.id, set: folder });
  },
  async removeFolder({ id }: IdInput): Promise<void> {
    await db.delete(folders).where(eq(folders.id, id));
  },

  async listEntries({ scope, ownerKey }: ListTreeInput): Promise<EntryRecord[]> {
    return db
      .select()
      .from(entries)
      .where(and(eq(entries.scope, scope), eq(entries.ownerKey, ownerKey)));
  },
  async getEntry({ id }: IdInput): Promise<EntryRecord | null> {
    const row = await db.select().from(entries).where(eq(entries.id, id)).limit(1);
    return row[0] ?? null;
  },
  async saveEntry(entry: EntryRecord): Promise<void> {
    await db.insert(entries).values(entry).onConflictDoUpdate({ target: entries.id, set: entry });
  },
  async removeEntry({ id }: IdInput): Promise<void> {
    await db.delete(entries).where(eq(entries.id, id));
  },
};

const handler = createFileLibraryHandler({ storage, resolveActor, repository });

Contract

  • listFolders / listEntries return every folder / entry in one tree (scope + ownerKey). The library does search, sort, visibility filter, and folder totals in memory over what you return — so returning rows from a SELECT * works the same as a denormalised view.
  • getFolder / getEntry return null for an absent row, never throw.
  • saveFolder / saveEntry are upserts — the library always supplies an id, so the same call handles create and update.
  • removeFolder / removeEntry are idempotent — removing an absent row is not an error.
  • A row's scope and ownerKey are the tenant boundary. Rows for one tree never appear in another tree's listing.

Reference implementations shipped with the package

  • createJsonObjectRepository(storage, { key, cacheTtlMs }) — the default. A single JSON document in the same object store as the files, with a short read cache so a screen reads it once rather than three or four times. Suitable for one-server deployments.
  • createInMemoryRepository() — a Map-backed reference. No persistence. Used by the test suite and as a worked example of the contract.

A worked SQLite implementation is in examples/db-sqlite/ — bun add better-sqlite3 && bun examples/db-sqlite runs it end to end against the handler. The same shape translates to Postgres, MySQL, MongoDB, or any KV store.

Notes for real databases

  • The library serialises its own writes per process (createWriteQueue), so a consumer database does not need a per-process lock. Cross-process concurrency is the database's problem — transactions, optimistic locks, unique constraints.
  • A UNIQUE(scope, owner_key, folder_id, name) constraint on folders and a matching one on entries (when the library grows to enforce them) are the right place for the duplicate-name check the library already does in memory.
  • An index on (scope, owner_key) is the only one the bridge's list* calls need; everything else is up to you.

The HTTP API

| Method | Path | What it does | | --- | --- | --- | | GET | /entries | one screen of one folder (scope, folderId, q, kind, sort, dir, page, pageSize) | | POST | /folders | create a folder | | PATCH | /folders/:id | rename a folder | | DELETE | /folders/:id | delete an empty folder | | POST | /uploads | authorise an upload and say where the bytes go | | PUT | /uploads/:id/content | the bytes, when the store cannot be presigned | | POST | /uploads/:id/complete | confirm the object arrived | | GET | /entries/:id | one entry | | PATCH | /entries/:id | rename, move, describe, tag, or change who can see it | | DELETE | /entries/:id | remove an entry and its object | | GET | /entries/:id/content | the bytes — inline=1 to draw, render=1 to draw without counting a download | | GET | /entries/:id/url | a time-limited URL that points straight at the store | | POST | /entries/bulk-remove | delete many entries; body { entryIds: string[] } | | POST | /entries/bulk-move | move many entries; body { entryIds: string[], folderId: string \| null } | | POST | /entries/bulk-visibility | share/private many entries; body { entryIds: string[], visibility: "SCOPE" \| "PRIVATE" } | | POST | /entries/bulk-update | describe/tag many entries; body { entryIds: string[], patch: { description?: string \| null, tags?: string[] } } |

The four bulk endpoints return { results: BulkEntryResultRow[] } — one row per id, each either { id, ok: true } or { id, ok: false, code, message }. The server runs each row through the existing per-entry method, so every check that applies to a single operation (capability, visibility, duplicate-name, folder-exists) applies to each row of the batch. A row that fails leaves the others untouched.

Errors are { error: { code, message } } with a real status: 400 for a refusal, 403 for a missing capability, 404 for something that is not there or not yours, 409 for a folder that is not empty, 413 for a size. createLibraryClient turns all of that into a thrown LibraryRequestError with a code.


Testing

bun test        # 73 tests, no mocks: real objects, real handler, real client

The suite runs the library against onestorage's filesystem driver over HTTP, the client against the handler in-process, and the components through renderToStaticMarkup — so a URL the client builds that no route answers, or a class the stylesheet does not define, fails the build rather than a user's page.


Development

bun link onestorage          # first: `onestorage` is a peer, and bun resolves peers on install
bun install                  # tailwindcss is a dev dependency — the demo compiles examples/demo.css
bun test
bun run typecheck
bun run build                # dist/ plus styles.css
bun examples/demo.ts         # the running demo, styled by examples/demo.css

The demo is also where the styling contract is exercised. examples/demo.css is written the way a host writes one, and it is compiled at startup by the same Tailwind plugin a host would use — so a utility the components use and the stylesheet does not generate shows up on reload as an unstyled screen, rather than as a bug report from somebody whose @source line is missing.

Licence

MIT