Upload & Download
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
- JavaScript
- Dart/Flutter
- Swift
- Kotlin
- Java
- Python
- Go
- PHP
- Rust
- C#
- C++
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}%`),
});
final bucket = client.storage.bucket('avatars');
await bucket.upload(
'user-1.jpg',
fileBytes,
contentType: 'image/jpeg',
onProgress: (sent, total) => print('$sent / $total'),
);
let bucket = client.storage.bucket("avatars")
try await bucket.upload(
"user-1.jpg",
data: imageData,
contentType: "image/jpeg"
)
val bucket = client.storage.bucket("avatars")
bucket.upload(
"user-1.jpg",
fileBytes,
contentType = "image/jpeg"
)
StorageBucket bucket = client.storage().bucket("avatars");
bucket.upload("user-1.jpg", fileBytes, "image/jpeg");
bucket = client.storage.bucket('avatars')
bucket.upload(
'user-1.jpg',
file_bytes,
content_type='image/jpeg',
)
import "context"
ctx := context.Background()
bucket := admin.Storage().Bucket("avatars")
result, err := bucket.Upload(ctx, "user-1.jpg", fileData, "image/jpeg")
$bucket = $client->storage->bucket('avatars');
$result = $bucket->upload('user-1.jpg', $fileData, 'image/jpeg');
let bucket = client.storage().bucket("avatars");
let result = bucket.upload("user-1.jpg", &file_data, "image/jpeg").await?;
var bucket = admin.Storage.Bucket("avatars");
var result = await bucket.UploadAsync("user-1.jpg", fileBytes, "image/jpeg");
auto bucket = client.storage().bucket("avatars");
auto result = bucket.upload("user-1.jpg", data, "image/jpeg");
When contentType is omitted, the SDK auto-detects it from the file extension (e.g. .jpg → image/jpeg, .pdf → application/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');
| Format | Description |
|---|---|
'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
- JavaScript
- Dart/Flutter
- Swift
- Kotlin
- Java
- Python
- Go
- PHP
- Rust
- C#
- C++
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' });
final bucket = client.storage.bucket('avatars');
final url = bucket.getUrl('user-1.jpg');
final bytes = await bucket.download('user-1.jpg');
let bucket = client.storage.bucket("avatars")
let url = bucket.getUrl("user-1.jpg")
let data = try await bucket.download("user-1.jpg")
val bucket = client.storage.bucket("avatars")
val url = bucket.getUrl("user-1.jpg")
val bytes = bucket.download("user-1.jpg")
StorageBucket bucket = client.storage().bucket("avatars");
String url = bucket.getUrl("user-1.jpg");
byte[] bytes = bucket.download("user-1.jpg");
bucket = client.storage.bucket('avatars')
url = bucket.get_url('user-1.jpg')
data = bucket.download('user-1.jpg')
import "context"
ctx := context.Background()
bucket := admin.Storage().Bucket("avatars")
url := bucket.GetURL("user-1.jpg")
data, err := bucket.Download(ctx, "user-1.jpg")
$bucket = $client->storage->bucket('avatars');
$url = $bucket->getUrl('user-1.jpg');
$data = $bucket->download('user-1.jpg');
let bucket = client.storage().bucket("avatars");
let url = bucket.get_url("user-1.jpg");
let data = bucket.download("user-1.jpg").await?;
var bucket = admin.Storage.Bucket("avatars");
string url = bucket.GetUrl("user-1.jpg");
byte[] data = await bucket.DownloadAsync("user-1.jpg");
auto bucket = client.storage().bucket("avatars");
std::string url = bucket.getUrl("user-1.jpg");
auto result = bucket.download("user-1.jpg");
Download Formats (JavaScript)
| Format | Return Type | Use Case |
|---|---|---|
'blob' (default) | Blob | Images, files for <img> or URL.createObjectURL() |
'text' | string | Text files, JSON, config files |
'arraybuffer' | ArrayBuffer | Binary processing, crypto operations |
'stream' | ReadableStream | Large files, progressive processing |
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-streamwithContent-Disposition: attachmentand a restrictive sandbox CSP. - Every storage body response includes
X-Content-Type-Options: nosniff. - The same policy applies to regular downloads, signed URLs,
200responses, byte-range206/416responses, 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
- JavaScript
- Dart/Flutter
- Swift
- Kotlin
- Java
- C#
- C++
const bucket = client.storage.bucket('avatars');
const exists = await bucket.exists('user-1.jpg');
if (!exists) {
// Upload default avatar
}
final bucket = client.storage.bucket('avatars');
final exists = await bucket.exists('user-1.jpg');
if (!exists) {
// Upload default avatar
}
let bucket = client.storage.bucket("avatars")
let exists = try await bucket.exists("user-1.jpg")
if !exists {
// Upload default avatar
}
val bucket = client.storage.bucket("avatars")
val exists = bucket.exists("user-1.jpg")
if (!exists) {
// Upload default avatar
}
StorageBucket bucket = client.storage().bucket("avatars");
boolean exists = bucket.exists("user-1.jpg");
if (!exists) {
// Upload default avatar
}
var bucket = client.Storage.Bucket("avatars");
var exists = await bucket.ExistsAsync("user-1.jpg");
if (!exists) {
// Upload default avatar
}
auto bucket = client.storage().bucket("avatars");
auto exists = bucket.exists("user-1.jpg");
if (!exists) {
// Upload default avatar
}
Delete
- JavaScript
- Dart/Flutter
- Swift
- Kotlin
- Java
- C#
- C++
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.' }]
final bucket = client.storage.bucket('avatars');
await bucket.delete('old-avatar.jpg');
final result = await bucket.deleteMany([
'old-avatar-1.jpg',
'old-avatar-2.jpg',
'old-avatar-3.jpg',
]);
let bucket = client.storage.bucket("avatars")
try await bucket.delete("old-avatar.jpg")
let result = try await bucket.deleteMany([
"old-avatar-1.jpg",
"old-avatar-2.jpg",
"old-avatar-3.jpg",
])
val bucket = client.storage.bucket("avatars")
bucket.delete("old-avatar.jpg")
val result = bucket.deleteMany(listOf(
"old-avatar-1.jpg",
"old-avatar-2.jpg",
"old-avatar-3.jpg"
))
StorageBucket bucket = client.storage().bucket("avatars");
bucket.delete("old-avatar.jpg");
DeleteManyResult result = bucket.deleteMany(List.of(
"old-avatar-1.jpg",
"old-avatar-2.jpg",
"old-avatar-3.jpg"
));
var bucket = client.Storage.Bucket("avatars");
await bucket.DeleteAsync("old-avatar.jpg");
var result = await bucket.DeleteManyAsync(new[] {
"old-avatar-1.jpg",
"old-avatar-2.jpg",
"old-avatar-3.jpg",
});
auto bucket = client.storage().bucket("avatars");
bucket.delete_file("old-avatar.jpg");
auto result = bucket.delete_many({
"old-avatar-1.jpg",
"old-avatar-2.jpg",
"old-avatar-3.jpg"
});
List Files
- JavaScript
- Dart/Flutter
- Swift
- Kotlin
- Java
- C#
- C++
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
final bucket = client.storage.bucket('avatars');
final result = await bucket.list(
prefix: 'users/',
limit: 50,
);
let bucket = client.storage.bucket("avatars")
let result = try await bucket.list(
prefix: "users/",
limit: 50
)
val bucket = client.storage.bucket("avatars")
val result = bucket.list(
prefix = "users/",
limit = 50
)
StorageBucket bucket = client.storage().bucket("avatars");
ListResult result = bucket.list("users/", 50, null);
var bucket = client.Storage.Bucket("avatars");
var result = await bucket.ListAsync(prefix: "users/", limit: 50);
auto bucket = client.storage().bucket("avatars");
auto result = bucket.list("users/", 50);
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);
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):
| Property | Type | Description |
|---|---|---|
size | number | File size in bytes |
contentType | string | MIME type |
key | string | Requested file path |
read / delete rules — receive R2FileMeta (from stored file):
| Property | Type | Description |
|---|---|---|
size | number | File size in bytes |
contentType | string | MIME type |
key | string | File path |
uploadedBy | string? | ID of the user who uploaded the file |
customMetadata | Record<string, string>? | Custom key-value metadata |
etag | string? | R2 ETag |
uploadedAt | string? | ISO 8601 upload timestamp |