@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-react2. 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-.jpgLimits
| 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 upA 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.
