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

@vmlts/fs-react

v0.2.0

Published

Upload files to filestore from React

Readme

@vmlts/fs-react

Upload a file straight from the browser, with a progress bar that means it.

Day 0

Three things come from whoever runs filestore for you. They are made already — you do not create them, and you cannot make them from the browser.

| You hold | Looks like | What it is | |---|---|---| | A service URL | https://cdn.you.com | where uploads go, and where the files are read from | | An API key | fs_a3f9... | it names your app. It stays on your server | | A bucket name | photos | the namespace your files land in |

1. Install.

bun add @vmlts/fs-react      # or: npm i @vmlts/fs-react

2. Add one route to your backend, which forwards to filestore.

The browser never holds the API key, so the browser never talks to filestore directly. It talks to your own origin, and your backend attaches the key.

  browser                            your backend              filestore
  ───────                            ────────────              ─────────
  PUT /api/files/v1/photos/cat.jpg
    ├──── same origin ────────────►  add x-api-key
    │     no token                   ├── PUT /v1/... ──────►   stored
    ◄──────────────────────────────  ◄── {key, bucket, url} ──

Anything under /api/files/v1/ forwards unchanged — same paths, same query parameters, same bodies.

// One route. Hono, Express and Next all shape the same.
app.all("/api/files/v1/*", (c) => {
  const url = new URL(c.req.url);
  url.host = "cdn.you.com";                             // your service URL
  url.pathname = url.pathname.replace("/api/files", "");
  return fetch(new Request(url, c.req.raw), {
    headers: { ...c.req.header(), "x-api-key": process.env.FILESTORE_TOKEN! },
  });
});

That route is also where you decide who may upload, which bucket they may write into, and what they may name. A bucket per tenant makes the second of those a string comparison on the first path segment.

3. Wrap the tree that uploads. baseUrl is the path on your own origin, not the service URL.

import { FilestoreProvider } from "@vmlts/fs-react";

<FilestoreProvider baseUrl="/api/files" bucket="photos">
  <Uploader />
</FilestoreProvider>

4. Upload.

import { useUpload } from "@vmlts/fs-react";

function Uploader() {
  const { upload, cancel, state, progress, result, error } = useUpload();

  return (
    <div>
      <input
        type="file"
        onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])}
      />

      {state === "uploading" && progress && (
        <>
          <progress value={progress.loaded} max={progress.total} />
          <button onClick={cancel}>Cancel</button>
        </>
      )}

      {state === "done" && <a href={result!.url}>{result!.url}</a>}
      {state === "error" && <p>{error!.message}</p>}
    </div>
  );
}

5. Open the URL it renders. The file is there. state reaches done only once the object is readable, so there is nothing to poll.

That is the whole of day 0. Everything below is detail you can read when you need it.

Everything the SDK does

| You want to | Call | Notes | |---|---|---| | Upload the picked file | upload(file) | a File or a Blob | | Name the object | upload(file, { key: "a/cat.jpg" }) | leave it out and the SDK builds one | | Upload a large file | upload(file) | over 64 MiB it splits and parallelises itself | | Draw a progress bar | progress.loaded / progress.total | flushed once per animation frame | | See how it was split | plan.parts, plan.partSize | null for a small file | | Cancel | cancel() | the half-uploaded parts are discarded | | Start over | reset() | back to idle | | Await the result | await upload(file) | the same result the state carries | | List objects | useObjects("holiday/") | every page, followed for you | | Delete an object | remove(key) from useObjects | drops it from the list too | | Reach the client directly | useFilestore() | list, delete, listBuckets, url | | Serve a file | <img src={result.url}> | public, cached, no key needed |

const { objects, loading, error, refresh, remove } = useObjects("holiday/");

useObjects follows the cursor to the last page, refetches when the prefix changes, and abandons the fetch when the component unmounts.

| state | Means | |---|---| | idle | Nothing has been uploaded yet, or reset() was called | | uploading | Bytes are going out | | done | result holds the key and the URL | | cancelled | cancel() was called; the parts were discarded | | error | error holds a FilestoreError |

Why the token never reaches the browser

The write API sends no CORS headers and answers no preflight. A browser cannot reach it on another origin, and a token would not help: a token in a browser is a token every visitor holds, and it can write into every bucket of your app.

So the SDK refuses a token for another origin rather than shipping one to every visitor. Point baseUrl at your own origin and keep the key on the server.

Keys and buckets

bucket names the namespace inside your app. Every key is relative to it, so a component never spells it and one tenant's client cannot reach a neighbour's files. The bucket has to exist before a write names it; it is made for you in the console, or with createBucket on the server side.

Turn on ensureBucket and an upload into a bucket your app does not have makes it rather than raising. Your proxy decides which bucket a visitor may write into, so turn this on only where a new bucket is an answer you are happy with.

The app is the other half, and it comes off the API key your backend attaches. So a read URL reads https://cdn.you.com/<app>/<bucket>/<key> while your component only ever spells the last part of it. Every upload and every list carries the finished URL back.

Without a key of your own, the SDK builds one: twelve unguessable characters, then the filename, so the URL still says what it points at.

sunset (1).jpg  ->  f7Kq2xBn9pWv/sunset--1-.jpg

Limits

| Limit | Value | |---|---| | One-request upload | up to 64 MiB; above that the SDK splits the file | | Part size | 1/32 of the file, held between 5 MiB and 100 MB | | Parts in flight | measured while uploading, up to 64 | | Largest object | 1000 GB. A larger file is refused before the first byte goes out | | Longest key | 1024 characters, counting the app and the bucket in front of it | | Delete visible everywhere | up to 1 hour, because reads are cached |

Under the bar

The same engine @vmlts/fs-node runs. A file over 64 MiB goes up as multipart: the SDK works out the split, keeps as many parts in flight as the link will take, retries what is worth retrying, and discards the parts on any way out other than success.

Two things differ from the Node SDK, and both are the browser's doing.

| | Why | |---|---| | It sends through XMLHttpRequest | fetch cannot say how far a request body has gone, and a bar that reads 0% then 100% is worse than no bar. | | The pool has no memory budget | A Blob slice is a reference to bytes on disk, not the bytes, so nothing is held while a part goes out. |

useUpload holds progress in a ref and flushes it once per animation frame. The SDK below it reports every byte it sees, which on a fast link is far more often than a screen redraws.

Failing

Everything raises FilestoreError, and error on the hook holds one.

error.status;    // 401, 403, 413... absent when no response came back
error.detail;    // what the service said
error.attempts;  // how many tries it took to give up

A 4xx other than 408, 425 and 429 is not retried: the service read the request and refused it, so an identical second one earns an identical refusal.