Skip to content

Add JSRPC passthroughs on R2Bucket and R2MultipartUpload - #7092

Draft
Carolx715 wants to merge 8 commits into
cloudflare:mainfrom
Carolx715:cxu/r2-jsrpc-bindings
Draft

Carolx715 wants to merge 8 commits into
cloudflare:mainfrom
Carolx715:cxu/r2-jsrpc-bindings

Conversation

@Carolx715

Copy link
Copy Markdown

No description provided.

@github-actions

github-actions Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@Carolx715 Carolx715 changed the title Draft: Add JSRPC methods for delete and head Draft: Add JSRPC passthroughs for delete and head on R2 bucket Aug 21, 2026
Comment thread src/workerd/io/compatibility-date.capnp Outdated
Comment thread src/workerd/api/r2-bucket.c++ Outdated
Comment thread src/workerd/api/tests/r2-jsrpc-test.js Outdated
@Carolx715
Carolx715 force-pushed the cxu/r2-jsrpc-bindings branch from 751c129 to c46f217 Compare September 3, 2026 16:06
@Carolx715 Carolx715 changed the title Draft: Add JSRPC passthroughs for delete and head on R2 bucket Add JSRPC passthroughs for delete and head on R2 bucket Sep 3, 2026
@Carolx715

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request Sep 3, 2026
@Carolx715 Carolx715 changed the title Add JSRPC passthroughs for delete and head on R2 bucket Add JSRPC passthroughs on R2Bucket and R2MultipartUpload Sep 3, 2026

@jp4a50 jp4a50 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flushing review. Looked at the tests so far. Looking good!

Will come back and review the C++ later.

Comment thread src/workerd/api/tests/r2-instrumentation-test.js Outdated
Comment thread src/workerd/api/tests/r2-test.js Outdated
Comment thread src/workerd/api/tests/r2-test.js Outdated
Comment thread src/workerd/api/tests/r2-test.js Outdated
Comment thread src/workerd/api/r2-rpc.c++ Outdated
Comment thread src/workerd/api/r2-bucket.c++ Outdated
Comment thread src/workerd/api/r2-bucket.c++ Outdated
@Carolx715
Carolx715 marked this pull request as ready for review September 15, 2026 15:02
@Carolx715
Carolx715 requested review from a team as code owners September 15, 2026 15:02
@codecov-commenter

codecov-commenter commented Sep 15, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 24.88021% with 2038 lines in your changes missing coverage. Please review.
✅ Project coverage is 38.42%. Comparing base (8ba3337) to head (66e7ee2).
⚠️ Report is 43 commits behind head on main.

Files with missing lines Patch % Lines
src/workerd/api/tests/r2-test.js 0.00% 1626 Missing ⚠️
...c/workerd/api/tests/r2-jsrpc-serialization-test.js 0.00% 181 Missing ⚠️
src/workerd/api/tests/r2-instrumentation-test.js 0.00% 121 Missing ⚠️
src/workerd/api/r2-bucket.c++ 92.25% 21 Missing and 27 partials ⚠️
src/workerd/api/r2-rpc.c++ 44.57% 40 Missing and 6 partials ⚠️
src/workerd/api/r2-multipart.c++ 84.00% 7 Missing and 1 partial ⚠️
src/workerd/api/r2-multipart.h 33.33% 4 Missing ⚠️
src/workerd/api/r2-rpc.h 86.95% 0 Missing and 3 partials ⚠️
src/workerd/api/r2-bucket.h 66.66% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7092      +/-   ##
==========================================
- Coverage   38.47%   38.42%   -0.06%     
==========================================
  Files         862      865       +3     
  Lines      267336   270066    +2730     
  Branches    25184    25395     +211     
==========================================
+ Hits       102868   103782     +914     
- Misses     150393   152166    +1773     
- Partials    14075    14118      +43     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Carolx715
Carolx715 force-pushed the cxu/r2-jsrpc-bindings branch from b7819ac to a807b44 Compare September 16, 2026 20:43
Comment thread src/workerd/api/tests/r2-test.js Outdated
Comment thread src/workerd/api/tests/r2-test.js Outdated
@Carolx715
Carolx715 force-pushed the cxu/r2-jsrpc-bindings branch 4 times, most recently from 60d034e to 2d7e211 Compare September 29, 2026 15:39
@Carolx715
Carolx715 force-pushed the cxu/r2-jsrpc-bindings branch 6 times, most recently from 66e7ee2 to 94d9e94 Compare October 7, 2026 15:53
@Carolx715
Carolx715 requested a review from jp4a50 October 7, 2026 16:18
The JSRPC methods on R2Bucket and R2MultipartUpload no longer create
R2 user trace spans or set request/response tags. The HTTP-backed
methods keep their spans unchanged.

The JSRPC spans could not report cloudflare.r2.response.success or the
R2 error tags, because the gateway's RPC results do not carry the
backend status. Rather than emit spans that diverge from the HTTP
transport, drop them until the RPC protocol can support parity.

Drop addHttpMetadataRequestSpanTags and the TraceContext parameter of
makeSerializableConditional, which only the JSRPC methods used.

r2-jsrpc-test no longer attaches the instrumentation tail worker, and
r2-instrumentation-test compares the HTTP transport's spans exactly.

jsg::Ref<R2Bucket::HeadResult> headResultFromBackend(
jsg::Lock& js, R2Bucket::HeadBackendResult result, MissingMetadataPolicy policy) {
KJ_SWITCH_ONEOF(result) {

@jp4a50 jp4a50 Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HeadBackendResult, GetBackendResult and ListBackendResult make the named JSRPC methods accept both R2 API-typed results and alternative object shapes that we supported before. I don't think we need the latter anymore.

I can see that this has probably been done to continue to support the test variants that manually construct return values directly (e.g. R2JsrpcResponseEntrypoint) rather than return the result of an R2 binding API call (ObservedR2Binding), but we don't need to support the former for the initial custom bindings milestone - we only need to support the "interceptor" custom bindings use case (tested by ObservedR2Binding).

SerializableHeadResult etc. can remain internal serialization payload types, but don't need to be accepted by the client as return types. You should just be able to replace HeadBackendResult's OneOf with the first variant: jsg::Ref<HeadResult>.

Could we also check in a TypeScript definition of the expected server-side interface, along the lines of D1’s D1BindingService and type-check an interceptor test implementation against it?

I'd suggest pulling out the test implementation of it (ObservedR2Binding) into a separate test-only TS file from which the tests can import the type where ObservedR2Binding is defined something like:

interface Env {
  REAL_BUCKET: R2Bucket;
}

export class ObservedR2Binding
  extends WorkerEntrypoint<Env>
  implements R2BindingService
{
  head(key: string): Promise<R2Object | null> {
    return this.env.REAL_BUCKET.head(key);
  }

  async createMultipartUpload(
    key: string,
    options?: R2MultipartOptions,
  ): Promise<{ uploadId: string }>; {
    const upload = await this.env.REAL_BUCKET.createMultipartUpload(key, options);
    return { uploadId: upload.uploadId };
  }

  // Implement and type-check the remaining service methods here.
}

Note that, in that snippet, I'm also suggesting that we should have createMultipartUpload (and by extension resumeMultipartUpload) return an object with a named uploadId field to minimize the chance that a custom binding implementer passes back the key instead of the upload ID.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we keep R2BindingEntrypoint’s return contract in terms of the existing R2 API types, supporting arbitrary custom backends will require a way for their implementations (as well as r2-gateway-worker) to construct those native result types directly without obtaining them from an upstream R2 binding API call.

We can figure out how to do that in a follow-up PR: one approach could be to expose constructors for things like HeadResult to custom bindings user code.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's necessary to remove the Serializable* options from the Head-, List-, and GetBackendResults just because we won't be using them right now? We'll still definitely be calling R2-backed Bindings without an interceptor worker in between and that's what the serializable types help with, we will need it in the future anyway. Unless you mean that having the R2 API types (the Serializable* option) will make it more complicated to support these arbitrary custom backends, although I guess I don't really see the connection there

Yes, I'll add an R2BindingService and can return an object with named uploadId for the functions you mentioned

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's necessary to remove the Serializable* options from the Head-, List-, and GetBackendResults just because we won't be using them right now?

It's not just that "we won't be using them right now". I'm not convinced that we should ever allow implementations of R2BindingEntrypoint to return them directly.

As per this comment, how we support a non-interceptor backend is a separate design problem and, whilst making the Serializable* types is perhaps one way we could potentially support non-interceptor backend implementations, I'm not convinced it's the best one. I think it'd be preferable to allow custom bindings implementers to construct the existing R2 binding resource types themselves and pass them back as JSRPC responses.

That may or may not turn out to be the right direction, but I think at the very least we should tackle that problem in a separate MR to unblock support for custom bindings which is its own deliverable.

@Carolx715
Carolx715 force-pushed the cxu/r2-jsrpc-bindings branch from 94d9e94 to 05dcbab Compare October 20, 2026 15:16
@Carolx715
Carolx715 force-pushed the cxu/r2-jsrpc-bindings branch from 05dcbab to 9906b15 Compare October 20, 2026 15:17
@clanky-the-clanker

Copy link
Copy Markdown

This pull request was not reviewed automatically because its author does not have write access to this repository. A maintainer with write access can start a review by commenting clanky review (or clanker review or @clanky-the-clanker review), optionally followed by instructions (e.g. clanky review focus on the retry logic).

@Carolx715
Carolx715 requested a review from a team as a code owner October 21, 2026 02:23
@Carolx715
Carolx715 requested a review from NuroDev October 21, 2026 02:23
modules = .testModules,
bindings = [
(name = "BUCKET", r2Bucket = "r2-test"),
(name = "R2_STRING_UPLOAD_TEST", text = "true"),

@jp4a50 jp4a50 Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like there is a fair bit of work involved in excluding the expensive string upload and body length tests from being run as part of the main test suite in r2-test.js (which is run in multiple configurations).

Moreover, two different approaches seem to be used to exclude these tests from being run in different places (either don't set some string like R2_STRING_UPLOAD_TEST or re-export only a subset of the test modules, excluding the string upload and body length tests). It's quite difficult to follow what's going on.

How about we instead break out the string upload test module into its own JS file so we don't have to use either technique? r2-test.js is getting pretty monolithic anyway.

( name = "fast-structs",
worker = (
modules = [
(name = "worker", esModule = "import { R2JsrpcResponseEntrypoint, testInvalidR2Responses } from './r2-test.js'; export { R2JsrpcResponseEntrypoint }; export const InvalidR2ResponseTests = { test: testInvalidR2Responses };"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another test organisation suggestion: perhaps it would be clearer if we moved R2JsrpcResponseEntrypoint, testInvalidR2Responses into their own file (r2-jsrpc-response-test.js) and have a default export which exposes what is currently testInvalidR2Responses as its test() handler?

Then both services in r2-jsrpc-response-test.wd-test could embed that file directly instead of constructing an inline wrapper module.

@@ -0,0 +1,209 @@
// Copyright (c) 2017-2026 Cloudflare, Inc.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test suite seems to test a few different things:

  1. R2 binding transport-agnostic behaviour e.g. checking that you can pass env.BUCKET.get(...).body to env.BUCKET.put(...) . I'd suggest moving that and any other assertions that fall into this category into the common r2-test.js test suite.
  2. R2 resource serialization behaviour using env.PLATFORM as an ordinary service binding to do JSRPC serialization round trips. I think that's reasonable to keep in here.
  3. R2 binding JSRPC-specific behaviour e.g. checking that an R2 binding receives an error if the corresponding JSRPC backend call tries to return an object of the wrong type (e.g. returning an R2ObjectBody from head). This seems like it could belong in the tests run by r2-jsrpc-response-test.wd-test.

That would leave r2-jsrpc-serialization-test.js and its .wd-test focused on resource serialization, rather than mixing it with R2 binding behavior. Perhaps then we could rename it and its .wd-test configurations to r2-resource-serialization-test, to distinguish R2 resource serialization from testing the JSRPC-backed R2 binding.

const jsg::TypeHandler<jsg::Promise<R2Bucket::HeadBackendResult>>& completeResultHandler) {
return js.evalNow([&] {
for (auto& part: uploadedParts) {
JSG_REQUIRE(part.partNumber >= 1 && part.partNumber <= 10000, TypeError,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per internal discussions, we want the JSRPC-backed R2 binding implementation to entirely place the responsibility for input validation onto the JSRPC server, so we should remove this. check and any others like it from the client, whilst ensuring that "invalid" values can be serialized and passed over JSRPC without narrowing the input values (for example).

SerializableGetOptions normalized;

KJ_IF_SOME(condition, o.onlyIf) {
// Parsed only for validation, so callers get the same errors as the non-RPC path (e.g.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be able to remove this input validation too?

}
KJ_IF_SOME(requestedIncludes, o.include) {
for (auto& requested: requestedIncludes) {
JSG_REQUIRE(requested.value == "httpMetadata" || requested.value == "customMetadata",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I can see that the desire to keep input validation on the server is somewhat at odds with handling the r2_list_honor_include flag (which is specific to the client) in this case.

Here's something we could do:

  • When the flag is enabled, forward options unchanged.
  • When the flag is disabled and the caller's include list contains only supported values, replace include with both metadata fields.
  • When the flag is disabled but the caller's include list contains one or more unsupported values, forward the original options unchanged and let the backend decide the error. The native R2 passthrough already rejects unsupported values.

The backend's responsibility is then simply to:

  • reject calls that specify invalid includes
  • return exactly the requested includes when the include values are valid (including returning neither field when no includes or [] is specified)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, you should be able to avoid copying all the other ListOptions fields into a new instance - just modify include on the provided options.

}
}

KJ_IF_SOME(range, o.range) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove input validation?

}
}

KJ_IF_SOME(ssecKey, buildSsecKey(kj::mv(o.ssecKey))) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove input validation?

ssecKey = kj::mv(key);
}

rpcOptions = PutOptions{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless I'm missing something, if we stop doing input validation on the client, we can just pass through the original put options unmodified to the RPC call.

};

R2MultipartUpload(kj::String key, kj::String uploadId, jsg::Ref<R2Bucket> bucket)
// The gateway's completion response omits create-time metadata. Created RPC uploads retain

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure exactly what this is saying but it appears that R2MultipartUpload's metadata field is now unused? I'm not sure there is a need for this state to exist beyond the creation RPC call.

"R2 resources can only be deserialized for RPC.");
}

PreparedR2RpcBody prepareR2RpcBody(jsg::Lock& js, R2PutValue& value) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to input validation, I think we should forward R2PutValue variants without performing client-side validation. The variants are JSG-serializable with a couple of caveats:

  1. A stream whose identity length is known on the sender may not report that length when it is hydrated on the RPC receiver. That appears to be a generic JSRPC gap. Fixing it there should let us remove the known-length validation and extra valueSize parameter from the custom-binding RPC methods. An interceptor could then forward received streams directly to its native R2 binding.
  2. JSRPC’s serialized-message size limit means forwarding large strings, buffers, or Blobs inline would regress support for large put/upload values.

I think it's reasonable to keep the conversion of large strings, buffers and Blobs into streams to address #2 but we should definitely try to address #1 with a generic JSRPC fix. I can look into that and raise a separate PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Raised a PR to fix issue 1: #7885

@bthwaites bthwaites mentioned this pull request Oct 23, 2026
@petebacondarwin
petebacondarwin marked this pull request as draft October 23, 2026 17:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants