Fall 2026brianmars.comBuilt at EGBT Technologies
ReferenceStorage

R2 shim over S3

Five R2 methods mapped to S3, plus the wrapper that injects them, so yaos runs without a patch.

Names and addresses are placeholders. The list says what each one stands for.

What it does here

yaos expects an R2Bucket object on env.YAOS_BUCKET. workerd has no local R2 backend, so a shim about 200 lines long impersonates one and sends each call to Garage as a signed S3 request.

From the manual

Canonical setup

On Cloudflare, an R2 bucket is bound to the Worker in wrangler.toml and the Worker gets it on env as an R2Bucket. R2 speaks the S3 protocol on Cloudflare's side, so any S3-compatible store can hold the same data. Upstream: R2 Workers API.

Edited for this build

In this build

  1. `get`, `head`, `delete`Plain signed GET, HEAD and DELETE, with 404 mapped to null.
  2. `put`httpMetadata and customMetadata go out as headers, then a HEAD fills in the return value, because R2's put returns the stored object and S3's doesn't.
  3. `list`ListObjectsV2, with the cursor mapped to the continuation token. Key, Size, ETag, IsTruncated and NextContinuationToken come out of the XML with regexes, since workerd has no DOMParser.
  4. A wrapper entrypointIt subclasses the two Durable Objects so they see the shim too. yaos writes snapshots from inside VaultSyncServer, so wrapping only the fetch handler would leave the snapshots with no bucket.

Requests are signed with SigV4 by aws4fetch and sent through the S3 service binding.

r2-s3.js
async get(key) {
  const res = await this._send(`${this.base}/${encodeKey(key)}`, { method: "GET" });
  if (res.status === 404) return null;
  if (!res.ok) return this._fail("GET", key, res);
  return new S3ObjectBody(key, res);   // .body, .text(), .arrayBuffer(), .httpMetadata, .size
}

async _send(url, init) {
  const signed = await this.aws.sign(url, init);
  return this.fetcher.fetch(signed);   // env.S3, the service binding
}
entry.js
import yaos, { ServerConfig as BaseServerConfig, VaultSyncServer as BaseVaultSyncServer }
  from "../yaos/server/src/index.ts";
import { S3Bucket } from "./r2-s3.js";

function withBucket(env) {
  // cached per env; adds YAOS_BUCKET = new S3Bucket({ fetcher: env.S3, ... })
}

export class VaultSyncServer extends BaseVaultSyncServer {
  constructor(ctx, env) { super(ctx, withBucket(env)); }
}
export class ServerConfig extends BaseServerConfig {
  constructor(ctx, env) { super(ctx, withBucket(env)); }
}
export default {
  fetch(request, env, ctx) { return yaos.fetch(request, withBucket(env), ctx); },
};
Where it breaks

Seams

Where this meets the rest of the build. Each one leads with what you will actually see.

A future yaos meets the shim's edges

SymptomS3Bucket.get: range/onlyIf not implemented

The shim throws on purpose instead of silently ignoring options it doesn't support. There are no range reads, conditional writes or multipart uploads. If yaos starts using any of them, grep server/src for bucket calls again and extend the shim.

Verify

The yaos snapshots suite covers snapshot create, dedup, list and download, and a blob PUT, exists check and GET. It's the one that goes through the shim and Garage.

Checks
docker compose exec garage /garage bucket info yaos   # objects appear after the suite
Corrections and updates
  1. 4 Nov 2026First written.