The idea started at college. I needed to pull up a file during a seminar, on a projector attached to a computer that wasn't mine. My choices were to sign into my email on a shared machine, send the file to someone else's inbox, or start a small ceremony involving phones and cables. None of those felt great for a two-minute presentation.
The same problem kept showing up in less serious places. I'd find something silly to show a friend, then realise it was on the wrong device. I wanted to put a file somewhere, copy a link, and open it on a phone, a friend's laptop, or the classroom projector without giving any of them a tour of my inbox.
So I built file-spooder, a tiny file portal at files.mishalshanavas.in. Naturally, I gave my internet shelf a spider name.
It's a small home for the files I want to keep or share. The files can be reached by URL. The librarian controls—upload, rename, move, copy, delete—are behind a password, because handing out a link should not also hand out the keys to the shelves.
One shelf, two kinds of access
The basic rule is pleasantly simple:
Anyone with a file link can read it.
Only I can rearrange the furniture.Cloudflare R2 stores the files. A Cloudflare Worker sits in front of the bucket and does everything else: it renders the directory page, turns folders into a browsable interface, validates actions, and streams the actual file back when somebody asks for it.
There is no database. There is no account system. There is no sign-up page desperately asking for a phone number. R2 object keys are the filing system: folders are prefixes, and a tiny .folder object makes an empty folder exist even when it has nothing interesting to say for itself.
External bookmarks are .link files containing a URL. That means the file shelf can also hold pointers to useful things elsewhere, which is either organised or the beginning of a much larger problem. Time will tell.
The librarian has one password and zero patience
Management requests carry an x-password header. The Worker compares it with an ADMIN_PASSWORD secret configured in Cloudflare:
if (!env.ADMIN_PASSWORD || pass !== env.ADMIN_PASSWORD) {
return new Response("Unauthorized", { status: 401 });
}The missing-secret check is not decorative. Without it, a forgotten deployment secret could turn an empty password into a valid password, which is an extremely efficient way to become your own security incident.
The browser keeps the password in session storage so I do not have to type it before every click. It disappears when the session ends. This is personal-tool security: enough friction for the internet, not enough friction to make me resent my own website.
The large-file chapter, or: apparently bytes need a committee
A small upload is easy. The browser sends a file, the Worker validates its name and size, then streams it into R2.
Then there are large files. A giant single request is where connections drop, timeouts appear, and serverless platforms begin looking at you with concern. So files above 50 MB are uploaded in 25 MB multipart chunks.
large file
|
+--> chunk 1 --> R2
+--> chunk 2 --> R2
+--> chunk 3 --> R2
+--> R2 stitches them togetherThe browser starts an R2 multipart upload, sends each chunk, saves the ETag returned for it, and asks R2 to complete the object once every piece has arrived. If a chunk fails, it aborts the multipart upload instead of leaving a half-built file in the storage equivalent of a construction site.
The funny part is that the original multipart implementation had all the right nouns and one very wrong verb. The Worker parsed every action as JSON, then tried to parse the chunk action as FormData.
Request bodies do not respawn.
After the JSON parser read the body, the multipart handler got nothing. Large uploads failed because the Worker had already eaten the evidence. The repair was simply to let JSON actions parse JSON and multipart actions parse form data—one body, one reader, everyone goes home happier.
If a request body has already been read, it is not “available for another parser.” It has gone to live on a farm upstate with all the other request bodies.
Uploads are capped at 5 GB, filenames are checked on the server, and a same-name upload is rejected rather than silently replacing something important. I would like future me to be mildly annoyed by a conflict message, not spiritually altered by an overwritten backup.
A video player noticed the Worker was bluffing
The Worker used to send this header:
Accept-Ranges: byteswhich is a bold promise. It says a browser can request a slice of a file, seek through video, or resume a download. The Worker did not actually handle a Range request yet, so it was essentially wearing a “yes, I support this” badge while returning the entire file from byte zero.
That has been fixed. A range request now becomes a ranged R2 read and a proper partial response:
HTTP/1.1 206 Partial Content
Content-Range: bytes 1048576-2097151/734003200Now a large video can seek without re-downloading its whole childhood, and an interrupted download can continue from where it left off. The Accept-Ranges header is no longer aspirational.
Moving a giant file should not require carrying it through the Worker
In object storage, renaming a file is really copying it to a new key and deleting the old key. Moving is the same trick with a folder prefix involved.
The bad version of that operation reads the whole source file into memory, writes it back, and hopes the runtime enjoys holding a multi-gigabyte object for a little while. That is not a workflow. That is asking a serverless function to become a very nervous USB stick.
file-spooder streams the source object body straight into the new R2 object instead. Its content type, HTTP metadata, custom metadata, and storage class move along with it. The Worker handles the plumbing; it does not try to become the file.
Folder renames are deliberately more careful:
- Make sure every destination name is unused.
- Copy every object under the old prefix.
- Delete originals only after every copy succeeds.
If copying fails, the source folder is left alone. If deleting fails, there may be duplicates, but nothing disappears. Storage is cheaper than the sentence “I think the original might be gone.”
The catalogue also needed to learn restraint
Object storage listings are paginated. The first directory browser eagerly collected every page and built one enormous HTML response, which was fine until the folder became large enough to turn a page visit into an accidental load test.
The library now shows one page at a time and offers a cursor-based next page. Folder selection is iterative rather than recursive, so deeply nested folders do not eventually send the JavaScript call stack on a short holiday. Even the storage meter has a safe scanning limit; when the bucket is too large to count cheaply, it says ≥ rather than making a little progress bar the most expensive thing on the site.
This is my favourite kind of optimisation: making the less-important feature politely give up before it makes the important feature fail.
The actual thing I ended up with
file-spooder has a job description that fits in one sentence:
Put a file on my own domain, open it on whatever device is nearby, and keep my email account off the seminar projector.
It has drag-and-drop uploads, folders, link files, file actions, storage visibility, public URLs, multipart uploads for the heavy stuff, and byte-range downloads for media. A lot of plumbing for a place to stash silly files? Absolutely. But now the link works wherever I need it, and nobody has to watch me log into Yahoo in front of a classroom.
The code is on GitHub. Bring a Cloudflare account, an R2 bucket, a secret password, and the particular kind of optimism that begins with “I’ll just build a small file browser.”
Built with Cloudflare Workers, R2, multipart uploads, byte ranges, and a healthy distrust of logging into email on shared computers.