Object storage
@opentf/esrun-s3 is an S3 client written in JavaScript over fetch. Its API is Bun's S3Client, and it needs no grant beyond reaching the endpoint.
npm install @opentf/esrun-s3
esrun --allow-net=s3.eu-west-1.amazonaws.com app.js
Connect
Pass the bucket, region and credentials:
import { S3Client } from "@opentf/esrun-s3"; const s3 = new S3Client({ bucket: "uploads", region: "eu-west-1", accessKeyId: "AKIA…", secretAccessKey: "…", });
For another service, set endpoint:
// Cloudflare R2 const r2 = new S3Client({ endpoint: "https://<account-id>.r2.cloudflarestorage.com", region: "auto", bucket: "assets", accessKeyId: "…", secretAccessKey: "…", });
To configure from the environment, use S3Client.fromEnv(). It reads S3_* and then AWS_* names, and only the ones the run grants:
const s3 = await S3Client.fromEnv();
esrun --allow-net=s3.eu-west-1.amazonaws.com \ --allow-env=S3_BUCKET,AWS_REGION,AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY app.js
new S3Client() never reads the environment. A program configures its client on purpose, or through fromEnv().
On AWS infrastructure
On EC2, ECS, EKS or a CI runner with OIDC, pass the provider for where the program runs. It fetches temporary credentials and refreshes them before they expire:
import { S3Client, containerCredentials, instanceMetadata, webIdentity } from "@opentf/esrun-s3"; new S3Client({ bucket, region, credentials: instanceMetadata() }); // EC2 new S3Client({ bucket, region, credentials: containerCredentials() }); // ECS, EKS Pod Identity new S3Client({ bucket, region, credentials: webIdentity() }); // EKS IRSA, GitHub Actions
Each provider needs the grants for what it calls. On EC2, that is --allow-net=169.254.169.254. The package reference lists each provider's variables and grants.
Read and write
await s3.write("notes/today.md", "# Today\n"); await s3.write("data/rows.json", JSON.stringify(rows)); const notes = s3.file("notes/today.md"); await notes.text(); await notes.exists(); await notes.stat(); // { size, lastModified, etag, type, metadata, … } await notes.delete();
write takes a string, bytes, a Blob, a ReadableStream, a Response or another S3File. Large bodies and streams are uploaded in parts, without being held in memory whole:
const upload = await fetch("https://example.com/big.iso"); await s3.write("mirror/big.iso", upload);
Read part of an object with slice, as with a Blob:
const header = await s3.file("video.mp4").slice(0, 64 * 1024).bytes();
A read whose connection drops part-way resumes from the last byte it received, and fails with PreconditionFailed if the object was replaced in the meantime. A long download does not start over.
Write many chunks
Use a writer when the data arrives over time:
const writer = s3.file("exports/users.ndjson").writer(); for await (const user of users()) { await writer.write(JSON.stringify(user) + "\n"); } await writer.end();
write waits while parts are uploading, so a fast producer cannot outrun the network. If a part fails after its retries, the upload is aborted on the server.
Write only if
Conditional writes make S3 a coordination point:
// Create-only await s3.write("locks/nightly", "held", { ifNoneMatch: "*" }); // Update only if unchanged since it was read const { etag } = await s3.stat("config.json"); await s3.write("config.json", updated, { ifMatch: etag });
A lost race fails with PreconditionFailed.
Copy and move
copy runs inside S3, so the bytes never pass through your program, whatever the object's size:
await s3.copy("uploads/raw.mov", "archive/2026/raw.mov"); await s3.copy("a.json", "b.json", { sourceBucket: "staging" });
S3 has no rename. Move an object by copying it and deleting the original:
await s3.copy("inbox/report.pdf", "done/report.pdf"); await s3.delete("inbox/report.pdf");
Pass metadata or type to give the copy new metadata; otherwise it keeps the source's.
List
const page = await s3.list({ prefix: "photos/", delimiter: "/" }); page.commonPrefixes; // the "folders" page.contents; // the objects at this level for await (const object of s3.listAll({ prefix: "logs/2026-11-" })) { console.log(object.key, object.size); }
Share a link
A presigned URL grants one operation on one object until it expires, with no credentials:
const download = await s3.presign("invoices/42.pdf", { expiresIn: 600 }); const upload = await s3.file(`inbox/${id}.jpg`).presign({ method: "PUT", type: "image/jpeg", expiresIn: 300, });
Handle errors
import { S3Error } from "@opentf/esrun-s3"; try { return await s3.file(key).json(); } catch (error) { if (error instanceof S3Error && error.code === "NoSuchKey") return null; throw error; }
error.code is S3's own name for the failure. Throttling and server errors are retried before you see them.
The package reference lists every option. How the client signs, retries and bounds memory is in Internals: S3.