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

@pilely/simple-blob

v0.4.2

Published

React hooks for the simple_blob service.

Readme

@pilely/simple-blob

React hooks for the simple_blob service — the platform's managed file storage. A query hook for every read, a mutation hook for every write. Render them inside @pilely/core's <PilelyProvider>.

Hooks

| Hook | Wraps | Kind | | --- | --- | --- | | useBlobs({ extension?, limit? }) | /list, paged | infinite query; data is the flattened BlobMeta[] | | useBlobSearch(q, { limit? }) | /search, paged | infinite query; an undefined q disables it | | useAppBlobs(appId, { extension?, limit?, uploaderUserId? }) | /apps/{app_id}/list, paged | infinite query over every blob bound to the app, whoever uploaded it — the app owner's view; an undefined appId disables it | | useAppBlobSearch(appId, q, { limit?, uploaderUserId? }) | /apps/{app_id}/search, paged | infinite query; an undefined appId or q disables it | | useBlobUrl(blobNanoid) | /blobs/{id}/download | query; data is { url, content_type, size_bytes }; an undefined id disables it | | useUpload() | /upload | mutation, { file, extension, content_type, read_group, anon_read?, display_name? } (or base64 in place of file) | | useDeleteBlob() | /blobs/{id}/delete | mutation, the blob's nanoid | | useSetBlobAccess() | /blobs/{id}/access/set | mutation, { blobNanoid, read_group, anon_read? } |

Every write waits for the server: an upload has nothing to show before its bytes land.

useBlobs and useBlobSearch answer the signed-in caller's own uploads. useAppBlobs and useAppBlobSearch answer every blob bound to one app — a visitor's uploads included — and only to that app's current owner, from their owner surface or through that same app; anyone else, and a call through any other app, gets the uniform 404 in error. Pass appId() from @pilely/core for the app the page runs in. uploaderUserId (a user id) narrows the listing to one uploader's blobs.

Who owns a blob, and who pays for it

Every blob carries two read-only ids, stamped at upload:

  • uploader_user_id — the user who uploaded it. The uploader can always list, download, re-scope and delete it, from their owner surface or through the blob's app.
  • payer_user_id — who its storage and downloads are charged to. A blob uploaded through an app is billed to the app's owner, whoever uploaded it; a blob uploaded with no app, or before app owners paid, is billed to its uploader.

The app's owner (and a box of the app) can download, re-scope and delete every blob bound to their app, as well as list it. A blob's read_group may be a group owned by its uploader or by the app's owner, so a visitor can make an upload private to the owner by picking one of the owner's groups. On a blob the app's owner pays for and someone else uploaded, only the app's owner can turn anon_read on: the uploader's upload or useSetBlobAccess with anon_read: true fails with bad_request (400), and the uploader's own access change always leaves anon_read off.

Deleting an app, or its owner's account, permanently deletes every blob other users uploaded through it.

What each write refreshes

| Write | Invalidates | | --- | --- | | upload | every useBlobs, useBlobSearch, useAppBlobs and useAppBlobSearch | | delete / set access on blob B | every useBlobs, useBlobSearch, useAppBlobs and useAppBlobSearch, and useBlobUrl(B) |

Example

import { useBlobUrl, useUpload } from "@pilely/simple-blob";

function Cover({ blob }: { blob: string | undefined }) {
  const { data } = useBlobUrl(blob);
  return data ? <img src={data.url} alt="" /> : null;
}

function CoverPicker({ onUploaded }: { onUploaded: (blob: string) => void }) {
  const upload = useUpload();
  return (
    <>
      <input
        type="file"
        accept="image/*"
        onChange={async (event) => {
          const file = event.target.files?.[0];
          if (!file) return;
          const blob = await upload.mutateAsync({
            file,
            extension: file.name.split(".").pop() ?? "bin",
            content_type: file.type,
            read_group: null,
          });
          onUploaded(blob.blob_nanoid);
        }}
      />
      {upload.error && <p role="alert">{upload.error.message}</p>}
    </>
  );
}

Presigned URL lifetime

useBlobUrl serves a presigned URL the service mints with at least 300 s of life left. The hook treats a URL as stale after 240 s (BLOB_URL_STALE_MS), fetches a fresh one every 240 s while a component shows it, and drops it 50 s (BLOB_URL_GC_MS) after the last component stops showing it — no URL older than 290 s is ever handed out. Render data.url directly; never store it.

Misbound blobs are refused

A blob binds to an app once, at upload, from the token that carried the request. Every upload goes through window.pilely.fetch on the app's own origin, so it binds correctly. If one still comes back bound to no app, useUpload deletes it and fails with PilelyError code blob_misbound instead of handing back a blob nobody can reach.

Query keys

| Key | Query | | --- | --- | | ["pilely", "simple-blob", "list", { extension, limit }] | useBlobs | | ["pilely", "simple-blob", "search", q, { limit }] | useBlobSearch | | ["pilely", "simple-blob", "app-list", appId, { extension, limit, uploaderUserId }] | useAppBlobs | | ["pilely", "simple-blob", "app-search", appId, q, { limit, uploaderUserId }] | useAppBlobSearch | | ["pilely", "simple-blob", "url", blobNanoid] | useBlobUrl |

Mock mode

Importing this package in a build made with VITE_PILELY_MOCK=1 registers an in-browser fake of simple_blob behind @pilely/core's mock runtime — nothing else to call.

The fake keeps uploaded bytes in memory, lists and searches with the service's cursors, and answers useBlobUrl with a blob: URL the page can render directly. Every blob is bound to the mock app id. A missing blob answers the bare 404. It stores read_group and anon_read but enforces neither, and has no size cap, storage wall, quota or traffic credits. Its state is memory-only: blobs are gone after a reload.

The mock user owns the mock app, so useAppBlobs(appId()) lists every blob, and every blob's uploader_user_id and payer_user_id are the mock user's id. The fake bills any other uploader's blob to the app's owner, refuses that uploader's anon_read: true with bad_request, and answers anyone but the uploader and the owner the bare 404 on a delete or access change.

Writes and listings need a signed-in user: a signed-out call answers the uniform bare 404, which reaches the hook's error as PilelyError(404, null). See @pilely/core for the switch, seedMock() and resetMock().

Error codes

Refusals arrive as PilelyError in the hook's error, with a code drawn from SimpleBlobErrorCode. Match on the code, never on the status. Three come only from an upload, and want different handling:

| Code | Status | Means | Do | | --- | --- | --- | --- | | blob_too_large | 413 | over the per-blob cap | reject client-side first | | storage_exceeded | 402 | the payer's storage allowance is full (only the payer sees it) | tell the owner — retrying cannot help | | quota_unavailable | 503 | the allowance could not be read | transient, retry is reasonable |

The rest: not_found (404, the uniform hide), bad_request (400), unauthenticated (401), bad_forwarded_identity (401), out_of_traffic_credits (503), rate_limited (429, over a @simple_limiter policy the app's owner set on this app's blob routes; root answers it with a Retry-After header), internal (500). A refusal caused by the payer's storage or balance shows its real code only to the payer: a visitor uploading through an app whose owner's allowance or balance is out gets the uniform 404 not_found, as does a download or access change denied for the payer's balance, where the payer would see 402 storage_exceeded or 503 out_of_traffic_credits.

Deleting a blob that is already gone, or was never visible, counts as success.

Install

npm install @pilely/simple-blob @pilely/core @tanstack/react-query

@pilely/core, react (^18.3 || ^19) and @tanstack/react-query (^5) are peer dependencies. Always install with the scope — unscoped names like pilely-blob are not owned by this project. This is a pre-1.0 package and moves with the platform.