Skip to main content

Upload & Download

Beta

This feature is in beta. Core behavior is stable and ready to try, but some APIs or configuration may still evolve before general availability.

R2-based file storage with $0 egress cost.

How Storage Works

EdgeBase storage is built on Cloudflare R2. Files are organized into buckets — each bucket has its own access rules for read, write, and delete.

storage/
├── avatars/ ← bucket (public read, auth write)
│ ├── user-1.jpg
│ └── user-2.jpg
├── documents/ ← bucket (auth read/write, admin delete)
│ ├── report-q1.pdf
│ └── invoice-2024.pdf
└── uploads/ ← bucket (auth write, signed URL download)
└── large-file.zip

Buckets are declared in edgebase.config.ts:

storage: {
buckets: {
avatars: {
access: {
read: () => true, // Anyone can view
write: (auth) => auth !== null, // Must be logged in to upload
delete: (auth, file) => auth?.id === file.uploadedBy, // Only uploader can delete
},
},
},
}

Each file has a key (its path within the bucket, e.g. user-1.jpg) and auto-tracked metadata including size, content type, upload timestamp, and who uploaded it.


Upload

const bucket = client.storage.bucket('avatars');

await bucket.upload('user-1.jpg', file, {
contentType: 'image/jpeg',
customMetadata: { userId: 'user-1' },
onProgress: (progress) => console.log(`${progress.percent}%`),
});
Auto-detected Content Type

When contentType is omitted, the SDK auto-detects it from the file extension (e.g. .jpgimage/jpeg, .pdfapplication/pdf). For File objects, the browser-provided MIME type is used first. You only need to specify contentType explicitly when using an uncommon extension or when the auto-detected type is wrong.

Cancel Upload

All upload methods (including uploadString) return an UploadTask — a Promise<FileInfo> with a .cancel() method. Calling .cancel() immediately aborts the underlying HTTP request, and the promise rejects with an AbortError:

const task = bucket.upload('video.mp4', largeFile, {
onProgress: (p) => progressBar.style.width = `${p.percent}%`,
});

// Cancel from a button click
cancelButton.onclick = () => task.cancel();

try {
const result = await task;
} catch (err) {
if (err.name === 'AbortError') {
console.log('Upload cancelled');
}
}

Upload from String

Upload string data with format conversion:

// Raw text
await bucket.uploadString('readme.txt', 'Hello, world!', 'raw', {
contentType: 'text/plain',
});

// Base64
await bucket.uploadString('image.png', base64Data, 'base64', {
contentType: 'image/png',
});

// Base64 URL-safe
await bucket.uploadString('file.bin', urlSafeBase64, 'base64url');

// Data URL (content type auto-detected from header)
await bucket.uploadString('photo.jpg', 'data:image/jpeg;base64,/9j/4AAQ...', 'data_url');
FormatDescription
'raw'Plain text (default content type: text/plain)
'base64'Standard Base64 encoded binary
'base64url'URL-safe Base64 (- and _ instead of + and /)
'data_url'Data URL with MIME header (e.g. data:image/png;base64,...)

uploadString returns an UploadTask (same as upload()), so you can use .cancel() and onProgress with string uploads as well.

Upload Response

All upload methods return a FileInfo object:

interface FileInfo {
key: string; // e.g. 'user-1.jpg'
size: number; // File size in bytes
contentType: string; // MIME type
etag: string; // R2 ETag
uploadedAt: string; // ISO 8601 timestamp
uploadedBy: string | null; // Auth user ID (auto-set)
customMetadata: Record<string, string>;
}

Download

const bucket = client.storage.bucket('avatars');

// Get public URL (synchronous — no network call)
const url = bucket.getUrl('user-1.jpg');

// Download as Blob (default)
const blob = await bucket.download('user-1.jpg');

// Download as text
const text = await bucket.download('readme.txt', { as: 'text' });

// Download as ArrayBuffer
const buffer = await bucket.download('data.bin', { as: 'arraybuffer' });

// Download as ReadableStream
const stream = await bucket.download('large.zip', { as: 'stream' });

Download Formats (JavaScript)

FormatReturn TypeUse Case
'blob' (default)BlobImages, files for <img> or URL.createObjectURL()
'text'stringText files, JSON, config files
'arraybuffer'ArrayBufferBinary processing, crypto operations
'stream'ReadableStreamLarge files, progressive processing
info

getUrl() is synchronous — it builds the URL locally without a network call. Use createSignedUrl() if the bucket requires authentication for reads.

Range Requests

Storage downloads support HTTP byte ranges for media players, PDF viewers, and resumable clients:

const res = await fetch(bucket.getUrl('video.mp4'), {
headers: { Range: 'bytes=0-1048575' },
});

if (res.status === 206) {
console.log(res.headers.get('Content-Range'));
}

Valid range requests return 206 Partial Content with Content-Range and Accept-Ranges: bytes. Unsatisfiable ranges return 416 Range Not Satisfiable. Signed download URLs include the file size in the signed grant, so media seeks can usually fetch only the requested byte range without an extra metadata lookup; those responses also use Cache-Control: private within the signed URL lifetime so the same browser can reuse cached ranges.

Safe Content Delivery

An uploaded MIME type is user-controlled metadata, so EdgeBase does not use it as permission to execute a document in your application's origin.

  • Passive raster images, common audio/video formats, fonts, plain text, and WebVTT keep their normalized MIME type and can render inline.
  • HTML, XHTML, SVG, XML, JavaScript, CSS, PDF, binary, unknown, and malformed types are served as application/octet-stream with Content-Disposition: attachment and a restrictive sandbox CSP.
  • Every storage body response includes X-Content-Type-Options: nosniff.
  • The same policy applies to regular downloads, signed URLs, 200 responses, byte-range 206/416 responses, multipart results, and dashboard/backup object downloads.

The stored FileInfo.contentType remains available for metadata and access rules. If you need to render a forced-download document, fetch its bytes and open it in a trusted viewer or on a separate cookieless content origin; do not weaken the application-origin response headers.

Check File Exists

const bucket = client.storage.bucket('avatars');

const exists = await bucket.exists('user-1.jpg');
if (!exists) {
// Upload default avatar
}

Delete

const bucket = client.storage.bucket('avatars');

// Single file
await bucket.delete('old-avatar.jpg');

// Multiple files
const result = await bucket.deleteMany([
'old-avatar-1.jpg',
'old-avatar-2.jpg',
'old-avatar-3.jpg',
]);
// result.deleted: ['old-avatar-1.jpg', 'old-avatar-3.jpg']
// result.failed: [{ key: 'old-avatar-2.jpg', error: 'File not found.' }]

List Files

const bucket = client.storage.bucket('avatars');

const result = await bucket.list({
prefix: 'users/',
limit: 50,
});
// result.files: FileInfo[]
// result.cursor: string | null
// result.truncated: boolean

Pagination

Use cursor to load the next page:

let cursor: string | null = null;
const allFiles: FileInfo[] = [];

do {
const result = await bucket.list({
prefix: 'photos/',
limit: 100,
cursor: cursor ?? undefined,
});
allFiles.push(...result.files);
cursor = result.cursor;
} while (cursor);
info

Maximum limit is 1000 per request. Default is 100.

Bucket Access Rules

// edgebase.config.ts
storage: {
buckets: {
avatars: {
access: {
read() { return true },
write(auth, file) {
return auth !== null &&
file.size <= 5 * 1024 * 1024 &&
['image/jpeg', 'image/png', 'image/webp'].includes(file.contentType);
},
delete(auth, file) { return auth !== null && auth.id === file.uploadedBy },
},
},
},
}

file Object Properties

The file parameter in access rules has different properties depending on the action:

write rule — receives WriteFileMeta (from form data, before upload):

PropertyTypeDescription
sizenumberFile size in bytes
contentTypestringMIME type
keystringRequested file path

read / delete rules — receive R2FileMeta (from stored file):

PropertyTypeDescription
sizenumberFile size in bytes
contentTypestringMIME type
keystringFile path
uploadedBystring?ID of the user who uploaded the file
customMetadataRecord<string, string>?Custom key-value metadata
etagstring?R2 ETag
uploadedAtstring?ISO 8601 upload timestamp