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

@cfast/storage

v0.1.0

Published

Type-safe file uploads to Cloudflare R2 with multipart support and a schema-driven routing API

Readme

@cfast/storage

Type-safe file uploads to Cloudflare R2. Define your file schema like you define your database schema.

Uploading files on Cloudflare Workers is surprisingly manual. You're parsing multipart form data by hand, validating MIME types with string comparisons, juggling R2 bucket bindings, and hoping you remembered to set the right size limit. Every project reinvents this.

@cfast/storage gives you a Drizzle-like schema API for file storage. You declare file types, where they go, what's allowed, and the library handles multipart uploads, validation, and routing to the right R2 bucket. On the client, you get a drop-in upload hook with progress tracking.

Design Goals

  • Schema-driven. Define file types declaratively: allowed MIME types, max size, destination bucket, key pattern. Like Drizzle tables, but for files.
  • Multipart by default. Large files use R2's multipart upload API automatically. Small files use direct PUT. The caller doesn't think about it.
  • Validated before upload. File size and MIME type are checked before any bytes hit R2. On the client, validation runs before the request is even sent.
  • Permission-integrated. File upload/download operations respect @cfast/permissions. A user who can't edit a post can't upload an image to it.
  • Type-safe end to end. The schema defines what file types exist. The upload handler knows which schema entries accept which files. TypeScript catches mismatches.

Planned API

Defining a Storage Schema

import { defineStorage, filetype } from "@cfast/storage";

export const storage = defineStorage({
  avatars: filetype({
    bucket: "UPLOADS",                     // R2 binding name from @cfast/env
    accept: ["image/jpeg", "image/png", "image/webp"],
    maxSize: "2mb",
    key: (file, ctx) => `avatars/${ctx.user.id}/${file.name}`,
    // One avatar per user — uploading replaces the previous one
    replace: true,
  }),

  postImages: filetype({
    bucket: "UPLOADS",
    accept: ["image/jpeg", "image/png", "image/webp", "image/gif"],
    maxSize: "10mb",
    key: (file, ctx) => `posts/${ctx.input.postId}/${crypto.randomUUID()}-${file.name}`,
  }),

  documents: filetype({
    bucket: "DOCUMENTS",                   // Different R2 bucket
    accept: ["application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
    maxSize: "50mb",
    key: (file, ctx) => `docs/${ctx.user.id}/${crypto.randomUUID()}-${file.name}`,
    // Large files use multipart automatically, but you can set the threshold
    multipartThreshold: "10mb",
  }),

  exports: filetype({
    bucket: "DOCUMENTS",
    accept: ["text/csv", "application/json"],
    maxSize: "200mb",
    // System-generated files, no direct user upload
    uploadable: false,
    key: (file) => `exports/${crypto.randomUUID()}.${file.extension}`,
  }),
});

Server-Side Upload Handling

import { storage } from "./storage";

// In a React Router action:
export async function action({ request, context }) {
  const user = await auth.requireUser(request);

  // Parse the multipart form and upload in one call
  const result = await storage.handle("postImages", request, {
    env: context.env,
    user,
    input: { postId: "123" }, // Available in the key function
  });

  // result: { key: "posts/123/abc-photo.jpg", size: 483210, type: "image/jpeg", url: "..." }

  // Save reference in your database
  const saveRef = db.insert(postImages).values({
    postId: sql.placeholder("postId"),
    storageKey: sql.placeholder("storageKey"),
    size: sql.placeholder("size"),
  });
  await saveRef.run({
    postId: "123",
    storageKey: result.key,
    size: result.size,
  });

  return { success: true, url: result.url };
}

Validation

Validation happens in layers — fast failures first:

const result = await storage.handle("avatars", request, { env, user });

// 1. Content-Type header checked before reading body     -> 415 Unsupported Media Type
// 2. Content-Length header checked before reading body    -> 413 Payload Too Large
// 3. MIME type verified by reading file magic bytes       -> 415 (prevents spoofed Content-Type)
// 4. Actual byte count verified during streaming upload   -> 413 (prevents spoofed Content-Length)

Validation errors are structured:

import { StorageError } from "@cfast/storage";

try {
  await storage.handle("avatars", request, { env, user });
} catch (e) {
  if (e instanceof StorageError) {
    e.code;    // "FILE_TOO_LARGE" | "INVALID_MIME_TYPE" | "UPLOAD_FAILED"
    e.detail;  // "File is 5.2MB but avatars allows max 2MB"
    e.status;  // 413
  }
}

Multipart Uploads

Large files are automatically uploaded using R2's multipart upload API:

documents: filetype({
  bucket: "DOCUMENTS",
  maxSize: "200mb",
  multipartThreshold: "10mb",  // Files > 10MB use multipart (default: 5mb)
  partSize: "10mb",            // Size of each part (default: 10mb)
}),

The library handles:

  • Splitting the incoming stream into parts
  • Uploading parts in parallel (configurable concurrency)
  • Completing or aborting the multipart upload
  • Retrying failed parts

Client-Side Upload Hook

import { useUpload } from "@cfast/storage/client";

function AvatarUploader() {
  const upload = useUpload("avatars");

  return (
    <div>
      <input
        type="file"
        accept={upload.accept}       // "image/jpeg,image/png,image/webp" — from schema
        onChange={(e) => upload.start(e.target.files[0])}
      />

      {upload.validationError && (
        // Client-side validation runs before upload starts
        <p>{upload.validationError}</p>  // "File is 5.2MB but max is 2MB"
      )}

      {upload.isUploading && (
        <progress value={upload.progress} max={100} />
      )}

      {upload.result && (
        <img src={upload.result.url} alt="Avatar" />
      )}
    </div>
  );
}

The client hook validates before uploading:

  • Checks file size against the schema's maxSize
  • Checks MIME type against the schema's accept
  • These checks use the same schema definition as the server — no duplication

Serving Files

Generate signed URLs or serve files directly:

// Signed URL (time-limited, for private files)
const url = await storage.getSignedUrl("documents", key, { expiresIn: "1h" });

// Public URL (for files in a public bucket)
const url = storage.getPublicUrl("postImages", key);

// Stream directly from R2 (for custom response headers, transforms, etc.)
const response = await storage.serve("postImages", key, {
  headers: { "Cache-Control": "public, max-age=31536000" },
});

Permission Integration

File uploads are typically triggered by actions, and the action's operations carry the permission requirements. The storage layer itself doesn't check permissions — it handles bytes. The permission gate happens before the upload starts:

const uploadPostImage = createAction({
  input: { postId: "" as string },

  operations: (db, input, ctx) => {
    // The update permission on posts gates the upload
    const checkAccess = db.query(posts).findFirst({
      where: eq(posts.id, sql.placeholder("postId")),
    });

    const saveRef = db.insert(postImages).values({
      postId: sql.placeholder("postId"),
      storageKey: sql.placeholder("storageKey"),
      size: sql.placeholder("size"),
    });

    return compose([checkAccess, saveRef], async (doCheck, doSave) => {
      await doCheck({ postId: input.postId });
      const result = await storage.handle("postImages", ctx.request, {
        env: ctx.env,
        user: ctx.user,
        input: { postId: input.postId },
      });
      await doSave({
        postId: input.postId,
        storageKey: result.key,
        size: result.size,
      });
      return { url: result.url };
    });
  },
});

The action's .permissions includes both read on posts and create on postImages, so the client can check permitted before showing the upload UI.

Lifecycle Hooks

Run code before and after uploads:

postImages: filetype({
  // ...
  hooks: {
    beforeUpload: async (file, ctx) => {
      // e.g., check quota, resize image, generate thumbnail key
    },
    afterUpload: async (result, ctx) => {
      // e.g., save to database, trigger image processing queue
    },
  },
}),

Architecture

@cfast/storage (server)
├── Schema definition (defineStorage, filetype)
├── Multipart form parsing (streaming, no buffering)
├── Validation pipeline (headers → magic bytes → byte count)
├── R2 upload (direct PUT or multipart depending on size)
├── Signed URL generation
└── Permission checks (delegates to @cfast/permissions)

@cfast/storage/client
├── useUpload hook (progress, validation, error handling)
├── Client-side validation (size + MIME from schema)
└── Accept attribute generation