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.
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.
In this build
- `get`, `head`, `delete`Plain signed
GET,HEADandDELETE, with 404 mapped tonull. - `put`
httpMetadataandcustomMetadatago out as headers, then aHEADfills in the return value, because R2'sputreturns the stored object and S3's doesn't. - `list`
ListObjectsV2, with the cursor mapped to the continuation token.Key,Size,ETag,IsTruncatedandNextContinuationTokencome out of the XML with regexes, since workerd has noDOMParser. - 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.
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
}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); },
};Seams
Where this meets the rest of the build. Each one leads with what you will actually see.
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.
docker compose exec garage /garage bucket info yaos # objects appear after the suite- 4 Nov 2026First written.