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

@gritframework/upload

v0.2.1

Published

Client-side image optimisation and direct-to-storage uploads for React, Next.js and Expo. Shrinks a 6 MB photo to ~35 KB before it leaves the device.

Readme

@gritframework/upload

Client-side image optimisation and direct-to-storage uploads, for React, Next.js and Expo.

Your user picks a 6 MB photo off their phone. This shrinks it to about 35 KB before it leaves the device, then uploads it straight to S3 through a presigned URL. Your server never sees the bytes: no upload bandwidth, no CPU, no request timeouts.

5.02 MB  4000x3000 JPEG        what the user picked
  31.6 KB  1600x1200 WebP      what gets stored
   3.4 KB   400x400  WebP      the thumbnail, alongside it
-------------------------------------------------------
   147x smaller, measured in Chromium and Mobile Chrome

That is not a compromise against doing it on a server. The browser has a lossy WebP encoder built in, so it matches libvips (33.9 KB on the same image) without any native dependency at all.

Install

npm install @gritframework/upload

Expo also needs the manipulator, because React Native has no canvas:

npx expo install expo-image-manipulator

Use

The optimiser is injected rather than imported, so the same uploader works on Next.js, a Vite SPA and Expo, and no bundler has to resolve a platform at build time.

import { createUploader, createFetchTransport } from "@gritframework/upload";
import { optimizeImage } from "@gritframework/upload/web";

export const uploader = createUploader({
  transport: createFetchTransport("/api/v1", () => localStorage.getItem("token")),
  optimize: optimizeImage,
});

const ref = await uploader.upload(file, file.name, { profile: "product-image" });
// ref.url, ref.thumbnail_url, ref.renditions.thumb, ref.format, ref.optimised

The dropzone

If you want a working dropzone rather than a hook:

import { Dropzone } from "@gritframework/upload/ui";
import "@gritframework/upload/styles.css"; // optional

<Dropzone uploader={uploader} profile="product-image" maxFiles={4} onChange={setFiles} />

It ships unstyled, with stable class names under a grit-dz namespace and a classNames prop that replaces any of them, so it can sit inside your design system without a fight. The stylesheet above is optional and themed through custom properties:

.grit-dz { --grit-dz-accent: #6c5ce7; --grit-dz-radius: 4px; }

Per-file progress rather than one bar for the batch, the saving shown as it happens, and a failed file that stays in the list with the reason instead of vanishing. The drop target is a <label> around a real file input, so keyboard activation and the native mobile picker come for free.

Using Tailwind and want a version to own and edit? Take the Grit UI block instead:

npx shadcn@latest add https://ui.gritframework.dev/r/application-ui-file-upload-optimizing-dropzone.json

React (the hook)

import { useUpload, describeSaving } from "@gritframework/upload/react";

function Upload() {
  const { upload, items } = useUpload(uploader);

  return (
    <>
      <input
        type="file"
        multiple
        onChange={(e) =>
          upload([...(e.target.files ?? [])].map((f) => ({ blob: f, name: f.name })))
        }
      />
      {items.map((it) => (
        <div key={it.id}>
          {it.name} — {it.state} {Math.round(it.progress * 100)}%
          {describeSaving(it) && <em> {describeSaving(it)}</em>}
        </div>
      ))}
    </>
  );
}

useUpload is deliberately not a TanStack Query mutation, though it composes with one. An upload has per-file progress and partial success; useMutation models one request with one result. Use this for the upload and TanStack Query for the record the refs end up attached to.

Expo

import { optimizeNativeImage } from "@gritframework/upload/expo";

const picked = await ImagePicker.launchImageLibraryAsync();
const optimize = (_blob: Blob, profile) => optimizeNativeImage(picked.assets[0], profile);

What your API needs to provide

Three endpoints. If you use Grit they already exist.

| Endpoint | Purpose | | --- | --- | | GET /media/profiles | The optimisation profiles, so the client uses your numbers rather than its own copy | | POST /uploads/presign | Takes {filename, content_type, file_size}, returns {presigned_url, key, public_url} | | POST /uploads/complete | Records the row once the bytes are in storage |

Two things your presign endpoint should do, because moving optimisation to the client changes the server's job from transforming to constraining:

Sign the exact byte count into the URL. The client optimises first, so it knows its size before it asks. Without this the URL is an unbounded write capability: a client can ask to upload two megabytes and then send five gigabytes.

Re-read the object on completion rather than trusting the reported size. The bytes never came through your API, so every number in that request is a claim.

Profiles

Fetched from GET /media/profiles, with a sensible built-in fallback if that request fails.

{
  name: "default",
  max_width: 1600, max_height: 1600, crop: false,
  quality: 0.82,
  format: "auto",
  max_pixels: 50_000_000,
  renditions: { thumb: { width: 400, height: 400, crop: true } },
}

format: "auto" picks per image rather than making you decide: anything with real transparency stays lossless, everything else goes lossy. A transparent logo can never be flattened onto a black JPEG background.

max_pixels refuses a decompression bomb. A solid-colour PNG compresses to almost nothing whatever its dimensions, so a 165 KB file can be 144 megapixels decoded, which will hang or kill a mobile browser tab.

Details that are easy to get wrong

These are handled, and are the reason this is a package rather than forty lines of canvas code:

  • EXIF orientation is applied on decode, or portrait photos come out sideways. It is then stripped by re-encoding, which matters because phone photos carry GPS coordinates.
  • canvas.toBlob silently returns a PNG when asked for an unsupported type instead of failing. Asking for WebP and getting PNG back would make photos larger than the JPEG they replaced, and nothing would look broken. The result is checked.
  • Transparency detection uses a threshold just under opaque, because resampling leaves alpha a hair below 255 and treating that as transparency sends every photo down the lossless path at many times the size.
  • Fit never upscales. Enlarging a small upload spends bytes on pixels the source never had.
  • GIF is skipped, because drawing one to a canvas keeps the first frame only.
  • An undecodable file is uploaded untouched rather than lost. Losing somebody's file because a codec choked is the worse failure.

Licence

MIT