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.

Shell
npm install @opentf/esrun-s3
Shell
esrun --allow-net=s3.eu-west-1.amazonaws.com app.js

Connect

Pass the bucket, region and credentials:

JavaScript
import { S3Client } from "@opentf/esrun-s3";

const s3 = new S3Client({
  bucket: "uploads",
  region: "eu-west-1",
  accessKeyId: "AKIA…",
  secretAccessKey: "…",
});

For another service, set endpoint:

JavaScript
// 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:

JavaScript
const s3 = await S3Client.fromEnv();
Shell
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
Info

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:

JavaScript
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

JavaScript
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:

JavaScript
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:

JavaScript
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:

JavaScript
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:

JavaScript
// 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:

JavaScript
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:

JavaScript
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

JavaScript
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);
}

A presigned URL grants one operation on one object until it expires, with no credentials:

JavaScript
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

JavaScript
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.

Last updated on
Edit this page