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 "aws-sdk" HTTP client (the default outside Cloudflare Workers). 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-sdknub add files-sdkaube add files-sdkFor the "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-presignernub add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigneraube 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 also needs the optional @aws-sdk/lib-storage package to report true byte-level progress via onProgress, to use multipart, or to upload a ReadableStream body of unknown length. It’s only loaded when upload uses onProgress, multipart, or a ReadableStream body of unknown length. 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 "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. On Workers each call is a subrequest, so chunk large bulk deletes to stay under the per-invocation subrequest cap. - Keys containing
.or..path segments are rejected - URL normalization would silently sign a request for a different key. - Byte-level
onProgressreporting falls back to the SDK’s generic reporting. files.rawis the aws4fetchAwsClient, not anS3Client.
Default inside Cloudflare Workers
Inside Cloudflare Workers the fetch client is the default. The "aws-sdk" client’s XML parsing needs a DOMParser, which workerd doesn’t provide, so it fails at runtime on the first list or error-body parse - long after construction. Detection uses navigator.userAgent === "Cloudflare-Workers", or the workerd-only WebSocketPair global on compatibility dates where navigator is disabled. Two things keep the "aws-sdk" default even in a Worker: an explicit client: "aws-sdk", and a DOMParser on the global (the usual polyfill workaround), since the SDK works once that is present.
The swap is silent, so if a Worker relied on the "aws-sdk" client working - multipart or resumable uploads, batched bulk deletes, files.raw as an S3Client - the trade-offs above now apply to it. Pass client: "aws-sdk" to keep the old engine. Other edge runtimes with the same missing DOMParser (Vercel Edge, for example) are not auto-detected; pass client: "fetch" there explicitly.
For any other S3-compatible endpoint on this engine - AWS S3, MinIO, Tigris, … - use s3Fetch() from files-sdk/s3-fetch.
Options
R2AdapterOptions is a union of two shapes depending on whether you have a Workers R2Bucket binding available.
HTTP mode
bucketstring
R2 bucket name.
stringaccountId?string
Cloudflare account ID. Falls back to `R2_ACCOUNT_ID` env var; required if no env var is set — unless an explicit `endpoint` is passed, which makes `accountId` unnecessary.
stringaccessKeyId?string
R2 access key ID. Falls back to `R2_ACCESS_KEY_ID` env var; required if no env var is set.
stringsecretAccessKey?string
R2 secret access key. Falls back to `R2_SECRET_ACCESS_KEY` env var; required if no env var is set.
stringpublicBaseUrl?string
Origin used to build URLs from `url()` — typically an `r2.dev` subdomain or a custom domain bound to the bucket. When set, `url()` returns `${publicBaseUrl}/${key}` and skips signing. When unset, `url()` returns a presigned GetObject URL (default expiry: 1 hour).
stringdefaultUrlExpiresIn?number
Default expiry, in seconds, for `url()` when `publicBaseUrl` is unset. Defaults to 3600.
numberendpoint?string
Override the S3 API endpoint. Defaults to `https://<accountId>.r2.cloudflarestorage.com`. Set it for jurisdiction buckets, which live on their own hostnames (e.g. `https://<accountId>.eu.r2.cloudflarestorage.com`), or to point the adapter at an S3-compatible stand-in (MinIO, LocalStack) in tests. When set, `accountId` is not required.
stringclient?"aws-sdk" | "fetch"
Which HTTP engine backs the adapter. Defaults to `"aws-sdk"`, except on Cloudflare Workers (detected via `navigator.userAgent === "Cloudflare-Workers"`, or the workerd-only `WebSocketPair` global when `navigator` is disabled), where it defaults to `"fetch"` — the aws-sdk engine's XML parsing needs `DOMParser`, which workerd doesn't provide. A Worker that polyfills `DOMParser` keeps the `"aws-sdk"` default. Note the `"fetch"` engine's narrower surface below before relying on the default in a Worker. - `"aws-sdk"`: `@aws-sdk/client-s3` — the full surface, including multipart/resumable uploads, byte-level upload progress, and batched `deleteMany`. Requires the `@aws-sdk/*` optional peer dependencies. Loaded lazily on first use. - `"fetch"`: SigV4-signed `fetch` via `aws4fetch` (~2.5 KB) — no `@aws-sdk/*` install needed, ideal for Workers and other edge runtimes. Covers upload, download (+ ranges), head, exists, delete, list (+ delimiter), server-side copy, presigned `url()`, and `signedUploadUrl()`. Trade-offs: `ReadableStream` bodies are buffered before the single PUT, `multipart`/`control` uploads throw, bulk deletes fan out per-key instead of batching, keys with `.`/`..` segments are rejected, and `raw` is an aws4fetch `AwsClient`. For a generic S3-compatible endpoint on the same engine, see `s3Fetch()` from `files-sdk/s3-fetch`.
"aws-sdk" | "fetch"fetch?(request: Request) => Promise<Response>
Override the `fetch` implementation used by the `"fetch"` client — for tests, or runtimes that hand out a bound/instrumented fetch. Defaults to `globalThis.fetch`. Ignored by the `"aws-sdk"` client.
(request: Request) => Promise<Response>Binding mode (inside a Worker)
bindingR2Bucket
Workers `R2Bucket` binding. Reads and writes go through the binding. The binding's `put()` needs to know an upload's length up front. Strings, bytes, `Blob`s, and `File`s always work. A `ReadableStream` works only when workerd knows its length: a `request.body` / `response.body` with a `Content-Length`, or the readable side of a `FixedLengthStream`. workerd rejects a stream of unknown length (one you built yourself, or a `TransformStream`'s output), so wrap it in a `FixedLengthStream` or buffer it first.
R2Bucketbucket?string
R2 bucket name. Required for hybrid signing — it names the bucket in the signed URL path that `url()` / `signedUploadUrl()` produce. Without it, the HTTP credentials below are ignored and signing throws with guidance.
stringpublicBaseUrl?string
Origin used to build URLs from `url()` — typically an `r2.dev` subdomain or a custom domain bound to the bucket. Without this (and without HTTP credentials below), `url()` throws because a Workers binding has no signing primitive.
stringaccountId?string
Hybrid mode: Cloudflare account ID, used alongside `accessKeyId` + `secretAccessKey` so `url()` and `signedUploadUrl()` can fall back to an S3-compatible SigV4 signer (aws4fetch — no `@aws-sdk/*` install needed) instead of throwing. Reads and writes still go through the binding so they stay intra-Worker (no egress fees). Useful for Workers that need browser-facing presigned URLs without giving up the binding's I/O performance. An explicit `endpoint` can stand in for `accountId`, which only feeds the default signing hostname.
stringaccessKeyId?string
Hybrid mode: R2 access key ID. See `accountId`.
stringsecretAccessKey?string
Hybrid mode: R2 secret access key. See `accountId`.
stringdefaultUrlExpiresIn?number
Default expiry, in seconds, for `url()` when it falls back to HTTP signing (hybrid mode without `publicBaseUrl`). Defaults to 3600.
numberendpoint?string
Hybrid mode: override the S3 API endpoint used for signing. Defaults to `https://<accountId>.r2.cloudflarestorage.com`. Set it for jurisdiction buckets, which live on their own hostnames (e.g. `https://<accountId>.eu.r2.cloudflarestorage.com`). When set, `accountId` is not required for hybrid signing.
stringThe binding’s put() needs an upload’s length up front. Strings, bytes, Blobs, and Files always work. A ReadableStream works only when workerd knows its length: a request.body or response.body with a Content-Length, or the readable side of a FixedLengthStream. workerd rejects a stream of unknown length (one you built yourself, or a TransformStream’s output), so wrap it in a FixedLengthStream or buffer it first.
Hybrid: binding + HTTP credentials
Inside a Worker, you can pass both a binding and HTTP credentials (bucket, accountId or endpoint, accessKeyId, and secretAccessKey). 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.
Conditional operations
R2 does not advertise the SDK’s provider-native conditional operation contract in this release. That includes the AWS-SDK HTTP path: configuring an S3-compatible endpoint does not inherit AWS S3’s capability claims. Conditional calls fail before R2 I/O.
Compatibility
HTTP mode
| Method | Status | Notes |
|---|---|---|
upload |
✅ | |
download |
✅ | |
delete |
✅ | |
list |
⚠️ | The S3 list API returns no per-object Content-Type, so type is inferred from the key’s extension (application/octet-stream when unknown). Use head() for the stored value. |
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. Multipart needs the "aws-sdk" client, which runs on Workers only with a DOMParser polyfill. |
download |
✅ | |
delete |
✅ | Bulk deletes fan out per key (no batched DeleteObjects). |
list |
⚠️ | The S3 list API returns no per-object Content-Type, so type is inferred from the key’s extension (application/octet-stream when unknown). Use head() for the stored value. |
search |
✅ | |
head |
✅ | |
exists |
✅ | |
copy |
✅ | |
url |
✅ | |
signedUploadUrl |
⚠️ | PUT URL only - same maxSize limitation as above. |
Binding mode
| Method | Status | Notes |
|---|---|---|
upload |
⚠️ | A ReadableStream body needs a known length (a request/response body with Content-Length, or a FixedLengthStream); workerd rejects one of unknown length. Strings, bytes, and Blobs always work. |
download |
✅ | |
delete |
✅ | |
list |
✅ | |
search |
✅ | |
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 bucket + accountId (or endpoint) + 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 |
⚠️ | Goes through the binding, so a ReadableStream body needs a known length, as in binding mode. |
download |
✅ | |
delete |
✅ | |
list |
✅ | |
search |
✅ | |
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. |