Claude-skill-registry bknd-serve-files
Use when serving uploaded files to users. Covers API-proxied file serving, direct storage URLs (S3/R2/Cloudinary), CDN configuration, public file URLs, caching headers, image optimization with Cloudinary, and serving files in frontend applications.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/bknd-serve-files" ~/.claude/skills/majiayu000-claude-skill-registry-bknd-serve-files && rm -rf "$T"
skills/data/bknd-serve-files/SKILL.mdServe Files
Serve uploaded files from Bknd storage to users via API proxy or direct storage URLs.
Prerequisites
- Media module enabled in Bknd config
- Storage adapter configured (S3, R2, Cloudinary, or local)
- Files uploaded via
skillbknd-file-upload - For CDN: storage provider with CDN support (S3/R2/Cloudinary)
When to Use UI Mode
- Admin panel > Media section > view/preview files
- Copy file URLs from admin interface
- Quick verification that files are accessible
When to Use Code Mode
- Build file URLs programmatically
- Configure CDN or custom domains
- Implement image optimization
- Access control for file downloads
File Serving Methods
Bknd supports two approaches to serve files:
| Method | Use Case | Performance | Control |
|---|---|---|---|
| API Proxy | Simple setup, private files | Moderate | Full (auth, permissions) |
| Direct URL | High traffic, public files | Best (CDN) | Limited (bucket ACLs) |
Step-by-Step: API Proxy (via Bknd)
Files served through Bknd API at
/api/media/file/{filename}.
Step 1: Get File URL
import { Api } from "bknd"; const api = new Api({ host: "http://localhost:7654" }); // Build file URL const fileUrl = `${api.host}/api/media/file/image.png`; // "http://localhost:7654/api/media/file/image.png"
Step 2: Display in Frontend
function Image({ filename }) { const { api } = useApp(); const src = `${api.host}/api/media/file/${filename}`; return <img src={src} alt="" />; }
Step 3: Download File (SDK)
// Get as File object const file = await api.media.download("image.png"); // Get as stream (for large files) const stream = await api.media.getFileStream("image.png");
Step 4: Verify Access
# Test file access curl -I http://localhost:7654/api/media/file/image.png # Response includes: # Content-Type: image/png # Content-Length: 12345 # ETag: "abc123..."
Step-by-Step: Direct Storage URLs
Serve files directly from S3/R2/Cloudinary for better performance.
S3/R2 Direct URLs
// S3 URL pattern const s3Url = `https://${bucket}.s3.${region}.amazonaws.com/${filename}`; // "https://mybucket.s3.us-east-1.amazonaws.com/image.png" // R2 URL pattern (public bucket) const r2Url = `https://${customDomain}/${filename}`; // "https://media.myapp.com/image.png"
Cloudinary Direct URLs
Cloudinary provides automatic CDN and transformations:
// Basic URL const cloudinaryUrl = `https://res.cloudinary.com/${cloudName}/image/upload/${filename}`; // With transformations const optimizedUrl = `https://res.cloudinary.com/${cloudName}/image/upload/w_800,q_auto,f_auto/${filename}`;
Building URLs in Code
// Helper to get direct URL based on adapter type function getFileUrl(filename: string, config: MediaConfig): string { const { adapter } = config; switch (adapter.type) { case "s3": // S3/R2 URL from configured endpoint return `${adapter.config.url}/${filename}`; case "cloudinary": return `https://res.cloudinary.com/${adapter.config.cloud_name}/image/upload/${filename}`; case "local": // Always use API proxy for local return `/api/media/file/${filename}`; default: return `/api/media/file/${filename}`; } }
CDN Configuration
Cloudflare R2 with Custom Domain
-
Create R2 bucket in Cloudflare dashboard
-
Enable public access on bucket
-
Configure custom domain (Cloudflare DNS):
- Add CNAME:
->media.yourapp.com<bucket>.<account>.r2.dev
- Add CNAME:
-
Use in Bknd config:
export default defineConfig({ media: { enabled: true, adapter: { type: "s3", config: { access_key: process.env.R2_ACCESS_KEY, secret_access_key: process.env.R2_SECRET_KEY, url: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${process.env.R2_BUCKET}`, }, }, }, });
- Serve files via custom domain:
const publicUrl = `https://media.yourapp.com/${filename}`;
AWS S3 with CloudFront
-
Create S3 bucket with public read (or CloudFront OAI)
-
Create CloudFront distribution:
- Origin: S3 bucket
- Cache policy: CachingOptimized
- Custom domain (optional)
-
Use CloudFront URL:
const cdnUrl = `https://d123abc.cloudfront.net/${filename}`; // Or with custom domain const cdnUrl = `https://cdn.yourapp.com/${filename}`;
Cloudinary (Built-in CDN)
Cloudinary includes global CDN automatically:
export default defineConfig({ media: { enabled: true, adapter: { type: "cloudinary", config: { cloud_name: "your-cloud-name", api_key: process.env.CLOUDINARY_API_KEY, api_secret: process.env.CLOUDINARY_API_SECRET, }, }, }, });
Files served from
res.cloudinary.com with global CDN.
Image Optimization
Cloudinary Transformations
// Build optimized image URL function getOptimizedImage(filename: string, options: { width?: number; height?: number; quality?: "auto" | number; format?: "auto" | "webp" | "avif" | "jpg" | "png"; crop?: "fill" | "fit" | "scale" | "thumb"; } = {}) { const cloudName = process.env.CLOUDINARY_CLOUD_NAME; const transforms: string[] = []; if (options.width) transforms.push(`w_${options.width}`); if (options.height) transforms.push(`h_${options.height}`); if (options.quality) transforms.push(`q_${options.quality}`); if (options.format) transforms.push(`f_${options.format}`); if (options.crop) transforms.push(`c_${options.crop}`); const transformStr = transforms.length > 0 ? transforms.join(",") + "/" : ""; return `https://res.cloudinary.com/${cloudName}/image/upload/${transformStr}${filename}`; } // Usage const thumb = getOptimizedImage("avatar.png", { width: 100, height: 100, crop: "fill", quality: "auto", format: "auto", }); // "https://res.cloudinary.com/mycloud/image/upload/w_100,h_100,c_fill,q_auto,f_auto/avatar.png"
Common Transformation Patterns
// Responsive images const srcSet = [400, 800, 1200].map(w => `${getOptimizedImage(filename, { width: w, format: "auto" })} ${w}w` ).join(", "); // Thumbnail generation const thumb = getOptimizedImage(filename, { width: 150, height: 150, crop: "thumb", }); // Automatic format (WebP/AVIF when supported) const optimized = getOptimizedImage(filename, { quality: "auto", format: "auto", });
React Integration
Image Component with Fallback
function StoredImage({ filename, alt, ...props }) { const { api } = useApp(); const [error, setError] = useState(false); // API proxy URL as fallback const apiUrl = `${api.host}/api/media/file/${filename}`; // Direct CDN URL (configure based on your adapter) const cdnUrl = `https://media.yourapp.com/${filename}`; return ( <img src={error ? apiUrl : cdnUrl} alt={alt} onError={() => setError(true)} {...props} /> ); }
Responsive Image Component
function ResponsiveImage({ filename, alt, sizes = "100vw" }) { const cloudName = process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME; const base = `https://res.cloudinary.com/${cloudName}/image/upload`; const srcSet = [400, 800, 1200, 1600].map(w => `${base}/w_${w},q_auto,f_auto/${filename} ${w}w` ).join(", "); return ( <img src={`${base}/w_800,q_auto,f_auto/${filename}`} srcSet={srcSet} sizes={sizes} alt={alt} loading="lazy" /> ); }
File Download Button
function DownloadButton({ filename, label }) { const { api } = useApp(); const [downloading, setDownloading] = useState(false); const handleDownload = async () => { setDownloading(true); try { const file = await api.media.download(filename); // Create download link const url = URL.createObjectURL(file); const link = document.createElement("a"); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); } catch (err) { console.error("Download failed:", err); } finally { setDownloading(false); } }; return ( <button onClick={handleDownload} disabled={downloading}> {downloading ? "Downloading..." : label || "Download"} </button> ); }
Caching Configuration
S3/R2 Cache Headers
Set cache headers when uploading:
// Custom adapter with cache headers (advanced) // S3 adapter doesn't expose this directly; configure via bucket policy // or CloudFront cache behaviors
Cloudflare R2 Cache Rules
In Cloudflare dashboard:
- Go to Caching > Cache Rules
- Create rule for your R2 subdomain
- Set Edge TTL (e.g., 1 year for immutable assets)
API Proxy Caching
Bknd's API proxy supports standard HTTP caching:
# Client can use conditional requests curl -H "If-None-Match: \"abc123\"" \ http://localhost:7654/api/media/file/image.png # Returns 304 Not Modified if unchanged
Access Control
Public Files (No Auth)
Configure default role with media.read permission:
export default defineConfig({ auth: { guard: { roles: { anonymous: { is_default: true, permissions: { "media.read": true, // Public read access }, }, }, }, }, });
Private Files (Auth Required)
Remove media.read from anonymous:
export default defineConfig({ auth: { guard: { roles: { user: { permissions: { "media.read": true, "media.create": true, }, }, // No anonymous role, or no media.read permission }, }, }, });
Access requires auth:
# Fails without auth curl http://localhost:7654/api/media/file/private.pdf # 401 Unauthorized # Works with auth curl -H "Authorization: Bearer $TOKEN" \ http://localhost:7654/api/media/file/private.pdf
Signed URLs (Time-Limited Access)
For S3/R2, generate presigned URLs:
// Custom endpoint for signed URLs (advanced) // Requires S3 SDK directly, not through Bknd adapter import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; async function getSignedDownloadUrl(filename: string): Promise<string> { const client = new S3Client({ /* config */ }); const command = new GetObjectCommand({ Bucket: process.env.S3_BUCKET, Key: filename, }); return getSignedUrl(client, command, { expiresIn: 3600 }); // 1 hour }
REST API Reference
| Method | Endpoint | Description |
|---|---|---|
| GET | | Download/view file |
| GET | | List all files |
Request Headers
| Header | Description |
|---|---|
| Bearer token (if auth required) |
| ETag for conditional request |
| Byte range for partial download |
Response Headers
| Header | Description |
|---|---|
| File MIME type |
| File size in bytes |
| File hash for caching |
| Indicates range support |
Common Pitfalls
404 File Not Found
Problem: File URL returns 404.
Causes:
- Filename doesn't exist
- Wrong path/case sensitivity
- File was deleted
Fix: Verify file exists:
const { data: files } = await api.media.listFiles(); const exists = files.some(f => f.key === filename);
CORS Errors on Direct S3 URL
Problem: Browser blocks direct S3 access.
Fix: Configure CORS on S3 bucket:
{ "CORSRules": [{ "AllowedOrigins": ["https://yourapp.com"], "AllowedMethods": ["GET"], "AllowedHeaders": ["*"], "MaxAgeSeconds": 3600 }] }
Slow File Serving
Problem: Files load slowly via API proxy.
Fix: Use direct storage URLs with CDN:
// Instead of API proxy const slow = "/api/media/file/image.png"; // Use direct CDN URL const fast = "https://cdn.yourapp.com/image.png";
Mixed Content (HTTP/HTTPS)
Problem: HTTPS page loading HTTP file URLs.
Fix: Ensure storage URL uses HTTPS:
// WRONG url: "http://bucket.s3.amazonaws.com", // CORRECT url: "https://bucket.s3.amazonaws.com",
Large File Download Fails
Problem: Download times out or memory error.
Fix: Use streaming for large files:
// Stream instead of loading into memory const stream = await api.media.getFileStream("large-file.zip"); // Or direct download link const downloadUrl = `${api.host}/api/media/file/large-file.zip`; window.location.href = downloadUrl;
Cloudinary File Not Found
Problem: Cloudinary returns 404 for uploaded file.
Cause: Cloudinary uses eventual consistency; file not yet indexed.
Fix: Wait briefly or use upload response URL directly:
const { data } = await api.media.upload(file); // Use data.name immediately rather than re-fetching
Verification
Test file serving setup:
async function testFileServing() { const filename = "test-image.png"; // 1. Verify file exists const { data: files } = await api.media.listFiles(); const file = files.find(f => f.key === filename); console.log("File exists:", !!file); // 2. Test API proxy const apiUrl = `${api.host}/api/media/file/${filename}`; const apiRes = await fetch(apiUrl); console.log("API proxy status:", apiRes.status); console.log("Content-Type:", apiRes.headers.get("content-type")); // 3. Test conditional request const etag = apiRes.headers.get("etag"); if (etag) { const conditionalRes = await fetch(apiUrl, { headers: { "If-None-Match": etag }, }); console.log("Conditional request:", conditionalRes.status === 304 ? "304 (cached)" : conditionalRes.status); } // 4. Test SDK download const downloadedFile = await api.media.download(filename); console.log("SDK download:", downloadedFile.name, downloadedFile.size); }
DOs and DON'Ts
DO:
- Use CDN (Cloudinary/R2/CloudFront) for public high-traffic files
- Set proper cache headers for static assets
- Use API proxy for private/auth-required files
- Implement lazy loading for images
- Use responsive images with srcSet
- Handle file not found gracefully
DON'T:
- Expose private files via public S3 URLs
- Serve large files without streaming
- Hardcode storage URLs (use config/env)
- Forget CORS configuration for direct access
- Use local adapter in production
- Skip error handling for missing files
Related Skills
- bknd-file-upload - Upload files to storage
- bknd-storage-config - Configure storage backends
- bknd-assign-permissions - Set media.read permission
- bknd-public-vs-auth - Configure public vs authenticated access