S3 (fetch)
AWS S3 and any S3-compatible bucket over a SigV4-signed fetch engine - no @aws-sdk/* packages, built for Cloudflare Workers and other edge runtimes.
Installation
This adapter has no extra peer dependencies. It signs requests with aws4fetch (~2.5 KB gzipped, Web Crypto only), so nothing from @aws-sdk/* is installed or bundled:
npm install files-sdkpnpm add files-sdkyarn add files-sdkbun add files-sdknub add files-sdkaube add files-sdkWhen to use it
files-sdk/s3 and the S3-compatible wrappers built on it (MinIO, Tigris, Wasabi, …) import @aws-sdk/client-s3. On a browser-targeted bundle that SDK’s XML parser needs a DOMParser, which Cloudflare Workers don’t provide - every list() or error-body parse throws DOMParser is not defined at runtime. s3Fetch() is the same engine that backs r2()’s fetch client, pointed at an endpoint of your choosing, for exactly those runtimes.
Outside Workers and edge runtimes prefer files-sdk/s3: it carries the full surface (multipart and resumable uploads, batched deletes, byte-level progress, the AWS credential chain) that this engine trades away for size.
Usage
import { Files } from "files-sdk";
import { s3Fetch } from "files-sdk/s3-fetch";
export default {
async fetch(request: Request, env: Env) {
const files = new Files({
adapter: s3Fetch({
bucket: "uploads",
endpoint: "https://s3.us-east-1.amazonaws.com",
region: "us-east-1",
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
}),
});
// ...
},
};
Credentials are static: pass accessKeyId + secretAccessKey (and sessionToken for temporary credentials), or set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN where a process.env exists. AWS_SESSION_TOKEN is only read when both keys also come from the environment, so keys you pass explicitly never pick up a token that belongs to another identity (a Lambda role or SSO session, say). There is no AWS credential chain - no IAM role, shared profile, or SSO resolution - because that chain lives in the SDK this adapter avoids.
Addressing
Requests default to virtual-hosted style (https://<bucket>.<endpoint host>/<key>). Set forcePathStyle: true for services without per-bucket DNS - MinIO, LocalStack, most self-hosted gateways - and for bucket names containing dots, which break virtual-hosted TLS:
s3Fetch({
bucket: "uploads",
endpoint: "http://localhost:9000",
forcePathStyle: true,
accessKeyId: "minioadmin",
secretAccessKey: "minioadmin",
});
Get this wrong against such a service and the first request fails with a NotFound whose message names the wrong bucket: with no per-bucket DNS the server reads the key’s first folder as the bucket name. The error message points at forcePathStyle when that happens, but only on responses that carry an error body. A HEAD response has none, so head() rejects with a bare NotFound and exists() returns false for every key. If exists() reports everything missing on a new setup, check forcePathStyle first. For MinIO specifically, prefer minio({ client: "fetch" }) - the same engine with path style, region, and error labels already defaulted.
Only the endpoint’s origin is used; a path component is dropped. Front the service with a hostname rather than a route prefix.
Trade-offs
Same coverage and limits as the R2 fetch client: upload, download (including ranges), head, exists, delete, list (including delimiter folding), server-side copy, presigned url(), and signedUploadUrl() are all covered. Against files-sdk/s3:
ReadableStreambodies are buffered in memory before a single PUT (a lone PUT needs aContent-Length, and single-request uploads cap at 5 GB). Inside a Worker that buffering counts against the isolate’s memory limit.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. signedUploadUrl()returns a presigned PUT;maxSizeand a positiveminSizethrow because enforcing either needs a presigned POST policy this engine doesn’t implement. Enforce upload caps at your application gateway instead.- 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. - No provider-native conditional operations.
files.rawis the aws4fetchAwsClient, not anS3Client.
S3’s own limits apply here too: presigned URLs live at most 7 days (a longer expiresIn throws), and copy() is a single CopyObject request up to 5 GiB, past which it switches to a server-side multipart copy (UploadPartCopy).
Options
bucketstring
Bucket name. All operations are scoped to it.
stringendpointstring
Service endpoint origin, e.g. `https://s3.us-east-1.amazonaws.com`, `http://localhost:9000`, or `https://t3.storage.dev`. Only the origin is used — a path component is dropped, so front the service with a host, not a route prefix.
stringregion?string
SigV4 signing region. Falls back to `AWS_REGION` / `AWS_DEFAULT_REGION`, then `us-east-1`. Every S3-compatible service needs *some* region in the signature; most ignore it for routing.
stringaccessKeyId?string
Static credentials. Falls back to `AWS_ACCESS_KEY_ID`; required if that env var isn't set. No credential chain — there is no IAM role, shared profile, or SSO resolution here, which is what keeps this engine `@aws-sdk/*`-free.
stringsecretAccessKey?string
Static credentials. Falls back to `AWS_SECRET_ACCESS_KEY`; required if that env var isn't set.
stringsessionToken?string
Session token for temporary credentials. Falls back to `AWS_SESSION_TOKEN`, but only when `accessKeyId` and `secretAccessKey` also come from the environment. Explicit keys never pick up an env token.
stringforcePathStyle?boolean
Use path-style addressing (`https://endpoint/bucket/key`) instead of the default virtual-hosted style (`https://bucket.endpoint/key`). Set it for MinIO and other self-hosted services without per-bucket DNS, and for bucket names containing dots (virtual-hosted TLS breaks on them).
booleanpublicBaseUrl?string
Origin used to build URLs from `url()`. When set, `url(key)` returns `${publicBaseUrl}/${key}` — for a public bucket policy or a CDN in front of it. When unset, `url()` falls back to a presigned GetObject. An explicit `url(key, { expiresIn })` or `responseContentDisposition` still presigns, since a permanent link can't expire or carry the override.
stringdefaultUrlExpiresIn?number
Default expiry, in seconds, for the presigned URLs returned by `url()` when no per-call `expiresIn` is given. Defaults to 3600 (1 hour).
numberfetch?(request: Request) => Promise<Response>
Override the `fetch` implementation used for every request — for tests, or runtimes that hand out a bound/instrumented fetch. Defaults to `globalThis.fetch`.
(request: Request) => Promise<Response>Compatibility
| Method | Status | Notes |
|---|---|---|
upload |
⚠️ | Single PUT - ReadableStream bodies are buffered in memory first, and multipart / resumable control uploads throw. |
download |
✅ | |
delete |
✅ | Bulk deletes fan out per key (no batched DeleteObjects). |
list |
⚠️ | The S3 list API returns no per-object Content-Type, so contentType 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 - maxSize and a positive minSize throw (no presigned POST policy). |
| conditional operations | ❌ | Not advertised on this engine. |