Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions src/workerd/api/r2-bucket.c++
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,132 @@ jsg::Promise<kj::Maybe<jsg::Ref<R2Bucket::HeadResult>>> R2Bucket::head(jsg::Lock
});
}

jsg::Ref<JsRpcProperty> R2Bucket::getRpcMethod(jsg::Lock& js, kj::StringPtr methodName) {
auto fetcher = [&]() -> jsg::Ref<Fetcher> {
KJ_SWITCH_ONEOF(clientChannel) {
KJ_CASE_ONEOF(channel, uint) {
return js.alloc<Fetcher>(
channel, Fetcher::RequiresHostAndProtocol::NO, true /* isInHouse */);
}
KJ_CASE_ONEOF(channel, IoOwn<IoChannelFactory::SubrequestChannel>) {
return js.alloc<Fetcher>(IoContext::current().addObject(kj::addRef(*channel)),
Fetcher::RequiresHostAndProtocol::NO, true /* isInHouse */);
}
}
KJ_UNREACHABLE;
}();

// getRpcMethodInternal skips the `rpc` compatibility gate, which matters because R2 bindings must
// keep working on compat dates older than that flag. The lookup is lazy and never fails for a
// real method name -- whether the entrypoint actually implements it is only discovered when the
// call reaches the far side.
return KJ_ASSERT_NONNULL(fetcher->getRpcMethodInternal(js, kj::str(methodName)));
}

namespace {
// Turn the JsRpcPromise a JSRPC call returns into an ordinary jsg::Promise.
//
// JsRpcPromise is a custom thenable whose `then()` takes raw v8 functions and deliberately hides
// the inner promise from JSG, so it cannot be chained from C++ directly. Resolving a fresh promise
// with it makes V8 adopt it, which also keeps this independent of the unwrap_custom_thenables
// compatibility flag.
jsg::Promise<jsg::Value> normalizeRpcPromise(jsg::Lock& js, jsg::Value rpcPromise) {
auto paf = js.newPromiseAndResolver<jsg::Value>();
paf.resolver.resolve(js, kj::mv(rpcPromise));
return kj::mv(paf.promise);
}
} // namespace

jsg::Promise<kj::Maybe<jsg::Ref<R2Bucket::HeadResult>>> R2Bucket::headRpc(jsg::Lock& js,
kj::String key,
const jsg::TypeHandler<jsg::Ref<JsRpcProperty>>& rpcPropType,
const jsg::TypeHandler<jsg::Function<jsg::Value(kj::String)>>& fnType,
const jsg::TypeHandler<kj::Maybe<HeadResultRpc>>& resultType) {
return js.evalNow([&] {
auto& context = IoContext::current();
TraceContext traceContext = context.makeUserTraceSpan("r2_head"_kjc);

traceContext.setTag("cloudflare.binding.type"_kjc, "r2"_kjc);
KJ_IF_SOME(b, this->bindingName()) {
traceContext.setTag("cloudflare.binding.name"_kjc, b);
}
traceContext.setTag("cloudflare.r2.operation"_kjc, "HeadObject"_kjc);
KJ_IF_SOME(b, this->bucketName()) {
traceContext.setTag("cloudflare.r2.bucket"_kjc, b);
}
traceContext.setTag("cloudflare.r2.request.key"_kjc, key.asPtr());

auto rpcProp = getRpcMethod(js, "head"_kj);
auto fn = JSG_REQUIRE_NONNULL(fnType.tryUnwrap(js, rpcPropType.wrap(js, kj::mv(rpcProp))),
Error, "R2 binding entrypoint's head method is not callable");

return normalizeRpcPromise(js, fn(js, kj::mv(key)))
.then(js,
[&resultType, traceContext = kj::mv(traceContext)](
jsg::Lock& js, jsg::Value value) mutable -> kj::Maybe<jsg::Ref<HeadResult>> {
// A missing object is null, not an error: the gateway maps the 404 that
// R2Result::objectNotFound() used to represent onto a null return.
auto parsed = JSG_REQUIRE_NONNULL(resultType.tryUnwrap(js, value.getHandle(js)), Error,
"R2 binding entrypoint returned an unrecognized head result");

KJ_IF_SOME(rpc, parsed) {
auto result = js.alloc<HeadResult>(kj::mv(rpc.key), kj::mv(rpc.version), rpc.size,
kj::mv(rpc.etag),
js.alloc<Checksums>(kj::mv(rpc.checksums.md5), kj::mv(rpc.checksums.sha1),
kj::mv(rpc.checksums.sha256), kj::mv(rpc.checksums.sha384),
kj::mv(rpc.checksums.sha512)),
rpc.uploaded,
// head always reports http and custom metadata, so an absent field means "none set"
// rather than "not requested". parseObjectMetadata synthesises empty values in the same
// situation; GetResult later hard-asserts both are present.
kj::mv(rpc.httpMetadata).orDefault(HttpMetadata{}),
kj::mv(rpc.customMetadata).orDefault(jsg::Dict<kj::String>{}), kj::mv(rpc.range),
kj::mv(rpc.storageClass), kj::mv(rpc.ssecKeyMd5));
addHeadResultSpanTags(js, traceContext, *result.get());
return kj::mv(result);
}
return kj::none;
});
});
}

jsg::Promise<void> R2Bucket::deleteRpc(jsg::Lock& js,
kj::OneOf<kj::String, kj::Array<kj::String>> keys,
const jsg::TypeHandler<jsg::Ref<JsRpcProperty>>& rpcPropType,
const jsg::TypeHandler<jsg::Function<jsg::Value(kj::OneOf<kj::String, kj::Array<kj::String>>)>>&
fnType) {
return js.evalNow([&] {
auto& context = IoContext::current();
TraceContext traceContext = context.makeUserTraceSpan("r2_delete"_kjc);

traceContext.setTag("cloudflare.binding.type"_kjc, "r2"_kjc);
KJ_IF_SOME(b, this->bindingName()) {
traceContext.setTag("cloudflare.binding.name"_kjc, b);
}
traceContext.setTag("cloudflare.r2.operation"_kjc, "DeleteObject"_kjc);
KJ_IF_SOME(b, this->bucketName()) {
traceContext.setTag("cloudflare.r2.bucket"_kjc, b);
}
KJ_SWITCH_ONEOF(keys) {
KJ_CASE_ONEOF(ks, kj::Array<kj::String>) {
traceContext.setTag("cloudflare.r2.request.keys"_kjc, kj::str(ks));
}
KJ_CASE_ONEOF(k, kj::String) {
traceContext.setTag("cloudflare.r2.request.keys"_kjc, kj::str(k));
}
}

auto rpcProp = getRpcMethod(js, "delete"_kj);
auto fn = JSG_REQUIRE_NONNULL(fnType.tryUnwrap(js, rpcPropType.wrap(js, kj::mv(rpcProp))),
Error, "R2 binding entrypoint's delete method is not callable");

// The result is discarded, matching delete_: a missing key is success, and per-key failures in
// a batch delete are reported in a body the binding has never read.
return normalizeRpcPromise(js, fn(js, kj::mv(keys)))
.then(js, [traceContext = kj::mv(traceContext)](jsg::Lock&, jsg::Value) mutable {});
});
}

R2Bucket::FeatureFlags::FeatureFlags(CompatibilityFlags::Reader featureFlags)
: listHonorsIncludes(featureFlags.getR2ListHonorIncludeFields()) {}

Expand Down
99 changes: 97 additions & 2 deletions src/workerd/api/r2-bucket.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
#include "r2-rpc.h"

#include <workerd/api/streams/readable.h>
#include <workerd/api/worker-rpc.h>
#include <workerd/jsg/jsg.h>
#include <workerd/util/autogate.h>

namespace workerd::api {
class Headers;
Expand Down Expand Up @@ -242,6 +244,56 @@ class R2Bucket: public jsg::Object {
JSG_STRUCT_TS_OVERRIDE(R2MultipartOptions);
};

// Object metadata as it crosses the JSRPC boundary, mirroring the shape the R2
// gateway worker returns. Distinct from `HeadResult`, which is a resource type
// carrying methods and lazy accessors that RPC cannot serialize; these plain
// structs are unwrapped from the RPC result and used to build one.
//
// Not part of the public API: these are internal to the JSRPC transport and are
// never handed to user code, so they carry no TS overrides.
struct ChecksumsRpc {
jsg::Optional<kj::Array<kj::byte>> md5;
jsg::Optional<kj::Array<kj::byte>> sha1;
jsg::Optional<kj::Array<kj::byte>> sha256;
jsg::Optional<kj::Array<kj::byte>> sha384;
jsg::Optional<kj::Array<kj::byte>> sha512;

JSG_STRUCT(md5, sha1, sha256, sha384, sha512);
};

// Field names match the gateway's R2ObjectRpc, not HeadResult's members: the
// key arrives as `key` where HeadResult stores it as `name`.
//
// `kj::Maybe` rather than `jsg::Optional` throughout, because jsg::Optional
// accepts `undefined` but not `null`, and only kj::Maybe tolerates both. The
// gateway omits absent fields today, but that is an unenforced cross-repo
// invariant and a null would otherwise be a hard unwrap failure.
struct HeadResultRpc {
kj::String key;
kj::String version;
double size;
kj::String etag;
kj::Date uploaded;
kj::String storageClass;
ChecksumsRpc checksums;
kj::Maybe<HttpMetadata> httpMetadata;
kj::Maybe<jsg::Dict<kj::String>> customMetadata;
kj::Maybe<Range> range;
kj::Maybe<kj::String> ssecKeyMd5;

JSG_STRUCT(key,
version,
size,
etag,
uploaded,
storageClass,
checksums,
httpMetadata,
customMetadata,
range,
ssecKeyMd5);
};

class HeadResult: public jsg::Object {
public:
HeadResult(kj::String name,
Expand Down Expand Up @@ -490,18 +542,56 @@ class R2Bucket: public jsg::Object {
jsg::Promise<void> delete_(jsg::Lock& js,
kj::OneOf<kj::String, kj::Array<kj::String>> keys,
const jsg::TypeHandler<jsg::Ref<R2Error>>& errorType);

// JSRPC equivalents of the above, selected by JSG_RESOURCE_TYPE when the
// R2_BINDINGS_JSRPC autogate and the r2_bindings_jsrpc compatibility flag are
// both on. They dispatch to the gateway's R2BindingEntrypoint instead of
// synthesising an HTTP request, then rebuild the public result types from the
// plain data JSRPC delivers.
//
// These keep ordinary typed signatures rather than taking a raw
// v8::FunctionCallbackInfo the way KvNamespace::deleteBulk does. A raw-args
// passthrough cannot work here: it returns the JsRpcPromise directly, so the
// caller receives the gateway's plain data and R2Object's methods and sync
// accessors are gone. Reconstruction needs the resolved value, which needs a
// real promise, which needs a typed return, which needs an injected
// TypeHandler -- and TypeHandlers are only injected into typed signatures.
jsg::Promise<kj::Maybe<jsg::Ref<HeadResult>>> headRpc(jsg::Lock& js,
kj::String key,
const jsg::TypeHandler<jsg::Ref<JsRpcProperty>>& rpcPropType,
const jsg::TypeHandler<jsg::Function<jsg::Value(kj::String)>>& fnType,
const jsg::TypeHandler<kj::Maybe<HeadResultRpc>>& resultType);
jsg::Promise<void> deleteRpc(jsg::Lock& js,
kj::OneOf<kj::String, kj::Array<kj::String>> keys,
const jsg::TypeHandler<jsg::Ref<JsRpcProperty>>& rpcPropType,
const jsg::TypeHandler<
jsg::Function<jsg::Value(kj::OneOf<kj::String, kj::Array<kj::String>>)>>& fnType);
jsg::Promise<ListResult> list(jsg::Lock& js,
jsg::Optional<ListOptions> options,
const jsg::TypeHandler<jsg::Ref<R2Error>>& errorType,
CompatibilityFlags::Reader flags);

JSG_RESOURCE_TYPE(R2Bucket, CompatibilityFlags::Reader flags) {
JSG_METHOD(head);
// Two gates, and they do different jobs. The autogate is the fleet-wide kill switch, flipped
// per metal via Release Manager; it cannot distinguish accounts. The compatibility flag is what
// restricts the new transport to allowlisted workers, because it is marked $experimental and
// EWC decides who may opt in. Neither alone is sufficient.
//
// Only head and delete are migrated so far; the rest stay on the HTTP transport, including all
// of R2MultipartUpload, whose methods would additionally need the upload's key and uploadId
// threaded into the call.
if (util::Autogate::isEnabled(util::AutogateKey::R2_BINDINGS_JSRPC) &&
flags.getR2BindingsJsrpc()) {
JSG_METHOD_NAMED(head, headRpc);
JSG_METHOD_NAMED(delete, deleteRpc);
} else {
JSG_METHOD(head);
JSG_METHOD_NAMED(delete, delete_);
}
JSG_METHOD(get);
JSG_METHOD(put);
JSG_METHOD(createMultipartUpload);
JSG_METHOD(resumeMultipartUpload);
JSG_METHOD_NAMED(delete, delete_);
JSG_METHOD(list);

JSG_TS_ROOT();
Expand Down Expand Up @@ -600,6 +690,11 @@ class R2Bucket: public jsg::Object {

kj::Own<kj::HttpClient> getHttpClient(IoContext& context, TraceContext& traceContext);

// Look up a method on the gateway's entrypoint over this binding's subrequest
// channel. Which entrypoint that resolves to is decided by the channel's
// configuration, not here -- a JSRPC call carries no entrypoint name.
jsg::Ref<JsRpcProperty> getRpcMethod(jsg::Lock& js, kj::StringPtr methodName);

friend class R2MultipartUpload;
};

Expand Down
3 changes: 2 additions & 1 deletion src/workerd/api/r2.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ namespace workerd::api::public_beta {
api::public_beta::R2Bucket::Checksums, api::public_beta::R2Bucket::StringChecksums, \
api::public_beta::R2Bucket::HttpMetadata, api::public_beta::R2Bucket::ListOptions, \
api::public_beta::R2Bucket::ListResult, \
api::public_beta::R2MultipartUpload::UploadPartOptions
api::public_beta::R2MultipartUpload::UploadPartOptions, \
api::public_beta::R2Bucket::ChecksumsRpc, api::public_beta::R2Bucket::HeadResultRpc
// The list of r2 types that are added to worker.c++'s JSG_DECLARE_ISOLATE_TYPE
} // namespace workerd::api::public_beta
6 changes: 6 additions & 0 deletions src/workerd/api/tests/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,12 @@ wd_test(
],
)

wd_test(
src = "r2-jsrpc-test.wd-test",
args = ["--experimental"],
data = ["r2-jsrpc-test.js"],
)

wd_test(
src = "r2-test.wd-test",
args = ["--experimental"],
Expand Down
Loading
Loading