Private packages

@onyx/storage

File storage on Cloudflare R2 through files-sdk in Workers binding mode.

@onyx/storage wraps files-sdk with the Cloudflare R2 adapter in Workers binding mode. Reads and writes stay inside the Worker with no credentials, local dev gets a simulated bucket automatically, and swapping the adapter in one place would move storage off R2 without touching callers. The web app already exports a ready instance as storage from apps/web/src/lib/storage.ts; import that rather than constructing your own.

Exports

  • @onyx/storage: createStorage(options), the CreateStorageOptions and Storage types, and a FilesError re-export so callers never import files-sdk directly.
  • @onyx/storage/avatars: shared constraints and key helpers for profile photo uploads.

Reading and writing files

The instance is a files-sdk Files object. Real usage from the avatar server functions and the file-serving route:

import { storage } from '#/lib/storage';

await storage.upload(avatarKey(userId), file, { contentType: file.type });
await storage.delete(avatarKey(userId));

const file = await storage.download(key);
return new Response(file.stream(), {
  headers: { 'Content-Type': file.type },
});

Handle missing files by branching on FilesError codes:

import { FilesError } from '@onyx/storage';

try {
  const file = await storage.download(key);
  // ...
} catch (error) {
  if (error instanceof FilesError && error.code === 'NotFound') {
    return new Response('Not found.', { status: 404 });
  }
  throw error;
}

Avatars

@onyx/storage/avatars keeps upload constraints in one place so the client can reject early while the server stays the source of truth:

  • avatarMaxBytes: 2 MB size limit.
  • avatarContentTypes and isAvatarContentType(value): allowed mime types (JPEG, PNG, WebP).
  • avatarKey(userId): the fixed bucket key avatars/{userId}. Re-uploads overwrite in place, so there is nothing to garbage-collect.
  • avatarUrl(userId): the serving URL /api/files/avatars/{userId}?v=..., where the ?v= query parameter busts caches on re-upload.

Server-side validation in apps/web/src/lib/avatar.ts:

import { avatarKey, avatarMaxBytes, avatarUrl, isAvatarContentType } from '@onyx/storage/avatars';

if (!isAvatarContentType(file.type)) {
  throw new Error('Profile photo must be a JPEG, PNG, or WebP image');
}
if (file.size > avatarMaxBytes) {
  throw new Error('Profile photo must be 2 MB or smaller');
}
await storage.upload(avatarKey(userId), file, { contentType: file.type });

In production, create the bucket once with wrangler r2 bucket create onyx-storage (see DEPLOY.md).

On this page