Cloudflare R2
Cloudflare R2 over the S3-compatible HTTP API. Auto-loads R2_* env vars or accepts an R2Bucket binding inside Workers.
Installation
The @aws-sdk/* packages are only needed for the default "aws-sdk" HTTP client. The Workers binding path, hybrid signing, and the lightweight fetch client need none of them - files-sdk alone is enough:
npm install files-sdkpnpm add files-sdkyarn add files-sdkbun add files-sdkFor the default "aws-sdk" HTTP client, @aws-sdk/client-s3, @aws-sdk/s3-presigned-post, and @aws-sdk/s3-request-presigner are optional peer dependencies - install alongside the SDK so the adapter’s imports resolve at runtime.
npm install files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presignerpnpm add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigneryarn add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presignerbun add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presignerOver the HTTP API with the "aws-sdk" client, upload reports true byte-level progress via onProgress when the optional @aws-sdk/lib-storage package is installed (it’s loaded only when onProgress is used). Under the Workers R2Bucket binding and the fetch client the SDK reports progress generically instead — byte-level for stream bodies, start and finish for buffered ones.
Usage
Cloudflare R2 over the S3-compatible HTTP API. Auto-loads from R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY. Inside Cloudflare Workers you can pass an R2Bucket binding directly instead.
import { Files } from "files-sdk";
import { r2 } from "files-sdk/r2";
const files = new Files({
adapter: r2({
bucket: "uploads",
accountId: process.env.R2_ACCOUNT_ID!,
// accessKeyId / secretAccessKey auto-loaded
// from R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY
}),
});
publicBaseUrl - optional, an r2.dev subdomain or custom domain bound to the bucket. When set, url() returns `${publicBaseUrl}/${key}` and skips signing.
Lightweight fetch client
Pass client: "fetch" to swap the @aws-sdk/* stack for a SigV4-signed fetch engine built on aws4fetch (~2.5 KB gzipped, Web Crypto only). No @aws-sdk/* packages are installed or bundled - ideal for Cloudflare Workers and other edge runtimes where the AWS SDK’s ~500 KB defeats the point of web-standard tooling.
const files = new Files({
adapter: r2({
bucket: "uploads",
accountId: process.env.R2_ACCOUNT_ID!,
client: "fetch",
}),
});
The fetch client covers upload, download (including ranges), head, exists, delete, list (including delimiter folding), server-side copy, presigned url(), and signedUploadUrl(). Trade-offs against the default "aws-sdk" client:
ReadableStreambodies are buffered in memory before a single PUT (a lone PUT needs aContent-Length, and single-request uploads cap at 5 GB on R2).multipartand resumable (control) uploads throw instead of engaging the S3 multipart API.- Bulk deletes fan out as per-key
delete()calls instead of batchedDeleteObjectsrequests. - Byte-level
onProgressreporting falls back to the SDK’s generic reporting.
Options
R2AdapterOptions is a union of two shapes depending on whether you have a Workers R2Bucket binding available.
HTTP mode
R2HttpOptions: Cannot read properties of undefined (reading 'ESNext')Binding mode (inside a Worker)
R2BindingOptions: Cannot read properties of undefined (reading 'ESNext')Hybrid: binding + HTTP credentials
Inside a Worker, you can pass both a binding and HTTP credentials. Reads and writes go through the binding (no egress, no extra round trip); url() and signedUploadUrl() route through an S3-compatible SigV4 signer because a Worker binding has no signing primitive. Hybrid signing runs on aws4fetch (Web Crypto only), so neither the binding path nor hybrid mode ever pulls @aws-sdk/* packages into the Worker bundle.
// Inside a Cloudflare Worker. The binding handles uploads/downloads
// (intra-Worker, no egress fees). The HTTP credentials let url() and
// signedUploadUrl() sign presigned URLs the binding alone can't produce.
const files = new Files({
adapter: r2({
binding: env.UPLOADS,
bucket: "uploads",
accountId: env.R2_ACCOUNT_ID,
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
}),
});
Signed uploads and maxSize
signedUploadUrl() returns a presigned PUT URL. Unlike S3, R2 does not implement the S3 POST Object API, so it has no content-length-range policy to enforce an upload size cap at the bucket. Passing maxSize throws a Provider error rather than handing back a POST form that R2 would reject with 501 Not Implemented at upload time.
// ✅ presigned PUT — the browser uploads with fetch(url, { method: "PUT", body: file })
const upload = await files.signedUploadUrl("avatars/abc.png", {
expiresIn: 60,
contentType: "image/png",
});
// ❌ throws: R2 has no server-enforced size limit
await files.signedUploadUrl("avatars/abc.png", {
expiresIn: 60,
maxSize: 5_000_000,
});
To cap upload sizes on R2, enforce the limit at your application gateway before issuing the URL.
Compatibility
HTTP mode
| Method | Status | Notes |
|---|---|---|
upload |
✅ | |
download |
✅ | |
delete |
✅ | |
list |
✅ | |
search |
✅ | |
head |
✅ | |
exists |
✅ | |
copy |
✅ | |
url |
✅ | |
signedUploadUrl |
⚠️ | PUT URL only - Cloudflare R2 doesn’t implement the S3 POST Object API, so maxSize throws (no content-length-range policy; a presigned POST would 501 at upload time). Enforce upload caps at your application gateway instead. |
HTTP mode (client: "fetch")
| Method | Status | Notes |
|---|---|---|
upload |
⚠️ | Single PUT - ReadableStream bodies are buffered in memory first, and multipart / resumable control uploads throw. Use the "aws-sdk" client for multipart. |
download |
✅ | |
delete |
✅ | Bulk deletes fan out per key (no batched DeleteObjects). |
list |
✅ | |
search |
✅ | |
head |
✅ | |
exists |
✅ | |
copy |
✅ | |
url |
✅ | |
signedUploadUrl |
⚠️ | PUT URL only - same maxSize limitation as above. |
Binding mode
| Method | Status | Notes |
|---|---|---|
upload |
✅ | |
download |
✅ | |
delete |
✅ | |
list |
✅ | |
head |
✅ | |
exists |
✅ | |
copy |
⚠️ | Read-then-write - Workers bindings have no native copy command, so the source is fetched and re-uploaded. Not server-side atomic; concurrent writes to the source between the get and put are not detected. |
url |
❌ | Throws unless publicBaseUrl is set on the adapter (an r2.dev subdomain or a custom domain). For a presigned URL from a Worker, switch to hybrid mode by also passing accountId + accessKeyId + secretAccessKey. |
signedUploadUrl |
❌ | Workers bindings can’t sign uploads - the secret access key is not available to the runtime. Use hybrid mode (binding + HTTP credentials) to issue presigned upload URLs. |
Hybrid mode
| Method | Status | Notes |
|---|---|---|
upload |
✅ | |
download |
✅ | |
delete |
✅ | |
list |
✅ | |
head |
✅ | |
exists |
✅ | |
copy |
⚠️ | Read-then-write - copy goes through the binding (no native copy command on Workers). |
url |
✅ | |
signedUploadUrl |
⚠️ | PUT URL only - signing routes through the HTTP signer. R2 doesn’t implement the S3 POST Object API, so maxSize throws (no content-length-range policy; a presigned POST would 501 at upload time). Enforce upload caps at your application gateway instead. |