@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), theCreateStorageOptionsandStoragetypes, and aFilesErrorre-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.avatarContentTypesandisAvatarContentType(value): allowed mime types (JPEG, PNG, WebP).avatarKey(userId): the fixed bucket keyavatars/{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).