diff --git a/.bazelrc b/.bazelrc index feb53f567b1..4ac48ed83a3 100644 --- a/.bazelrc +++ b/.bazelrc @@ -222,7 +222,7 @@ build --@capnp-cpp//src/capnp:gen_rust=True build --@capnp-cpp//src/kj:kj_enable_irequire=True # overriden in config=opt build --@capnp-cpp//src/kj:debug_memory=True -build --cxxopt="-fbracket-depth=512" --host_cxxopt="-fbracket-depth=512" +build --cxxopt="-fbracket-depth=1024" --host_cxxopt="-fbracket-depth=1024" # Additional Rust flags (see https://doc.rust-lang.org/rustc/codegen-options/index.html) # Need to disable debug-assertions for fastbuild, should be off automatically for opt. diff --git a/compile_flags.txt b/compile_flags.txt index 54cdb5bb728..4a757f2967e 100644 --- a/compile_flags.txt +++ b/compile_flags.txt @@ -136,4 +136,4 @@ -Wunused-lambda-capture -Wunused-variable -no-canonical-prefixes --fbracket-depth=512 +-fbracket-depth=1024 diff --git a/doxyfile b/doxyfile index f9f49a065e4..580f6060810 100644 --- a/doxyfile +++ b/doxyfile @@ -1140,7 +1140,7 @@ CLANG_OPTIONS = -std=c++23 \ -stdlib=libc++ \ -xc++ \ -nostdinc \ - -fbracket-depth=512 \ + -fbracket-depth=1024 \ -D_FORTIFY_SOURCE=1 \ -DCAPNP_VERSION=11000 \ -DDEBUG \ diff --git a/src/workerd/api/r2-bucket.c++ b/src/workerd/api/r2-bucket.c++ index bddb57422f6..38726117a56 100644 --- a/src/workerd/api/r2-bucket.c++ +++ b/src/workerd/api/r2-bucket.c++ @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -69,11 +70,46 @@ kj::Own R2Bucket::getHttpClient(IoContext& context, TraceContext KJ_UNREACHABLE; } +TraceContext R2Bucket::makeR2TraceContext(kj::LiteralStringConst spanName, + kj::LiteralStringConst operation, + kj::Maybe key) { + TraceContext traceContext = IoContext::current().makeUserTraceSpan(spanName); + traceContext.setTag("cloudflare.binding.type"_kjc, "r2"_kjc); + KJ_IF_SOME(name, bindingName()) { + traceContext.setTag("cloudflare.binding.name"_kjc, name); + } + traceContext.setTag("cloudflare.r2.operation"_kjc, operation); + KJ_IF_SOME(name, bucketName()) { + traceContext.setTag("cloudflare.r2.bucket"_kjc, name); + } + KJ_IF_SOME(value, key) { + traceContext.setTag("cloudflare.r2.request.key"_kjc, value); + } + return traceContext; +} + static bool isWholeNumber(double x) { double intpart; return modf(x, &intpart) == 0; } +static jsg::Optional> copyBytes( + const jsg::Optional>& bytes) { + return bytes.map([](const kj::Array& value) { return kj::heapArray(value); }); +} + +static jsg::Dict copyStringDict(const jsg::Dict& dict) { + return { + .fields = + KJ_MAP(field, dict.fields) { + return jsg::Dict::Field{ + .name = kj::str(field.name), + .value = kj::str(field.value), + }; + }, + }; +} + // TODO(perf): Would be nice to expose the v8 internals for parsing a date/stringifying it as // something an embedder can call directly rather than doing this rigamarole. It would also avoid // concerns about the user overriding the methods we're invoking. @@ -232,6 +268,30 @@ static kj::Maybe> parseObjectMetadata(jsg::Lock& js, namespace { +class R2GetBodyStream final: public ReadableStreamSource { + public: + R2GetBodyStream(kj::Own inner, kj::Promise pump) + : inner(kj::mv(inner)), + pump(kj::mv(pump).catch_([](kj::Exception&&) {}).eagerlyEvaluate(nullptr)) {} + + kj::Promise tryRead(void* buffer, size_t minBytes, size_t maxBytes) override { + return inner->tryRead(buffer, minBytes, maxBytes); + } + + kj::Maybe tryGetLength(StreamEncoding encoding) override { + return inner->tryGetLength(encoding); + } + + void cancel(kj::Exception reason) override { + pump = nullptr; + inner->cancel(kj::mv(reason)); + } + + private: + kj::Own inner; + kj::Promise pump; +}; + void addEtagsToBuilder( capnp::List::Builder etagListBuilder, kj::ArrayPtr etagArray) { R2Bucket::Etag* currentEtag = etagArray.begin(); @@ -319,6 +379,7 @@ void initOnlyIf(TraceContext& traceContext, jsg::Lock& js, Builder& builder, Opt } } +// Validates and performs string conversion on the SSE-C key. kj::Maybe buildSsecKey( kj::Maybe, kj::String>> maybeRawSsecKey) { KJ_IF_SOME(rawSsecKey, maybeRawSsecKey) { @@ -401,6 +462,17 @@ void addHeadResultSpanTags( } } +static void addListResultSpanTags(TraceContext& traceContext, const R2Bucket::ListResult& result) { + KJ_IF_SOME(cursor, result.cursor) { + traceContext.setTag("cloudflare.r2.response.cursor"_kjc, cursor.asPtr()); + } + traceContext.setTag( + "cloudflare.r2.response.returned_objects"_kjc, static_cast(result.objects.size())); + traceContext.setTag("cloudflare.r2.response.delimited_prefixes"_kjc, + static_cast(result.delimitedPrefixes.size())); + traceContext.setTag("cloudflare.r2.response.truncated"_kjc, result.truncated); +} + template void initGetOptions(TraceContext& traceContext, jsg::Lock& js, Builder& builder, Options& o) { initOnlyIf(traceContext, js, builder, o); @@ -469,17 +541,7 @@ jsg::Promise>> R2Bucket::head(jsg::Lock CompatibilityFlags::Reader flags) { 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()); + TraceContext traceContext = makeR2TraceContext("r2_head"_kjc, "HeadObject"_kjc, key.asPtr()); capnp::JsonCodec json; json.handleByAnnotation(); @@ -512,6 +574,219 @@ jsg::Promise>> R2Bucket::head(jsg::Lock }); } +// Retrieves the RPC method with name methodName. +jsg::Ref R2Bucket::getRpcMethod(jsg::Lock& js, kj::StringPtr methodName) { + auto fetcher = [&]() -> jsg::Ref { + KJ_SWITCH_ONEOF(clientChannel) { + KJ_CASE_ONEOF(channel, uint) { + return js.alloc( + channel, Fetcher::RequiresHostAndProtocol::NO, true /* isInHouse */); + } + KJ_CASE_ONEOF(channel, IoOwn) { + return js.alloc(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))); +} + +// Validates HeadResult shape from JSRPC call. +jsg::Ref headResultFromSerializable( + jsg::Lock& js, R2Bucket::SerializableHeadResult rpc, MissingMetadataPolicy policy) { + KJ_ASSERT(std::isfinite(rpc.size) && rpc.size >= 0 && isWholeNumber(rpc.size), + "Malformed R2 RPC result: size must be a non-negative integer."); + KJ_IF_SOME(range, rpc.range) { + KJ_IF_SOME(offset, range.offset) { + KJ_ASSERT(std::isfinite(offset) && offset >= 0 && isWholeNumber(offset), + "Malformed R2 RPC result: range offset must be a non-negative integer."); + } + KJ_IF_SOME(length, range.length) { + KJ_ASSERT(std::isfinite(length) && length >= 0 && isWholeNumber(length), + "Malformed R2 RPC result: range length must be a non-negative integer."); + } + KJ_IF_SOME(suffix, range.suffix) { + KJ_ASSERT(range.offset == kj::none && range.length == kj::none, + "Malformed R2 RPC result: range suffix is incompatible with offset or length."); + KJ_ASSERT(std::isfinite(suffix) && suffix >= 0 && isWholeNumber(suffix), + "Malformed R2 RPC result: range suffix must be a non-negative integer."); + } + } + + jsg::Optional httpMetadata; + jsg::Optional> customMetadata; + switch (policy) { + case MissingMetadataPolicy::EMPTY: + httpMetadata = kj::mv(rpc.httpMetadata).orDefault(R2Bucket::HttpMetadata{}); + customMetadata = kj::mv(rpc.customMetadata).orDefault(jsg::Dict{}); + break; + case MissingMetadataPolicy::ABSENT: + KJ_IF_SOME(metadata, rpc.httpMetadata) { + httpMetadata = kj::mv(metadata); + } + KJ_IF_SOME(metadata, rpc.customMetadata) { + customMetadata = kj::mv(metadata); + } + break; + } + + return js.alloc(kj::mv(rpc.key), kj::mv(rpc.version), rpc.size, + kj::mv(rpc.etag), + js.alloc(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, kj::mv(httpMetadata), kj::mv(customMetadata), kj::mv(rpc.range), + kj::mv(rpc.storageClass), kj::mv(rpc.ssecKeyMd5)); +} + +static uint64_t getResultBodyLength(R2Bucket::HeadResult& object) { + // Retrieve body length from range/size metadata to avoid depending on the stream to report it. + auto bodyLength = object.getSize(); + KJ_IF_SOME(range, object.getRange()) { + bodyLength = range.length.orDefault(bodyLength); + } + KJ_ASSERT(bodyLength <= 9007199254740991ull, + "Malformed R2 get RPC result: body length must be a safe integer."); + return static_cast(bodyLength); +} + +jsg::Ref headResultFromBackend( + jsg::Lock& js, R2Bucket::HeadBackendResult result, MissingMetadataPolicy policy) { + if (dynamic_cast(result.get()) != nullptr) { + auto body = static_cast(result.get())->getBody(js); + body.forceCancel(js, js.error("Malformed R2 RPC result: expected object metadata.")) + .markAsHandled(js); + KJ_FAIL_ASSERT("Malformed R2 RPC result: expected object metadata, received an object body."); + } + if (policy == MissingMetadataPolicy::EMPTY) { + bool hasHttpMetadata = result->getHttpMetadata() != kj::none; + bool hasCustomMetadata = result->getCustomMetadata() != kj::none; + KJ_ASSERT( + hasHttpMetadata, "Malformed R2 RPC result: object metadata did not include HTTP metadata."); + KJ_ASSERT(hasCustomMetadata, + "Malformed R2 RPC result: object metadata did not include custom metadata."); + } + return kj::mv(result); +} + +jsg::Promise>> R2Bucket::headRpc(jsg::Lock& js, + kj::String key, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler>& headFnHandler, + const jsg::TypeHandler>>& headResultHandler) { + return js.evalNow([&] { + return callR2RpcMethod>(js, getRpcMethod(js, "head"_kj), + rpcPropHandler, headFnHandler, headResultHandler, kj::mv(key)) + .then(js, + [](jsg::Lock& js, + kj::Maybe parsed) -> kj::Maybe> { + // A missing object is null, not an error: the backing entrypoint maps the 404 that + // R2Result::objectNotFound() used to represent onto a null return. + KJ_IF_SOME(backend, parsed) { + return headResultFromBackend(js, kj::mv(backend)); + } + return kj::none; + }); + }); +} + +jsg::Promise>, jsg::Ref>> +R2Bucket::getRpc(jsg::Lock& js, + kj::String key, + jsg::Optional options, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler)>>& + getFnHandler, + const jsg::TypeHandler>>& getResultHandler) { + return js.evalNow([&] { + return callR2RpcMethod>(js, getRpcMethod(js, "get"_kj), + rpcPropHandler, getFnHandler, getResultHandler, kj::mv(key), kj::mv(options)) + .then(js, + [](jsg::Lock& js, kj::Maybe parsed) + -> kj::OneOf>, jsg::Ref> { + KJ_IF_SOME(backend, parsed) { + KJ_SWITCH_ONEOF(backend) { + KJ_CASE_ONEOF(native, jsg::Ref) { + return kj::Maybe>(kj::mv(native)); + } + KJ_CASE_ONEOF(native, jsg::Ref) { + return headResultFromBackend(js, kj::mv(native), MissingMetadataPolicy::EMPTY); + } + } + } + return kj::Maybe>(kj::none); + }); + }); +} + +jsg::Promise R2Bucket::deleteRpc(jsg::Lock& js, + kj::OneOf> keys, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler>)>>& + deleteFnHandler, + const jsg::TypeHandler>& deleteResultHandler) { + return js.evalNow([&] { + // 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 callR2RpcMethod(js, getRpcMethod(js, "delete"_kj), rpcPropHandler, deleteFnHandler, + deleteResultHandler, kj::mv(keys)); + }); +} + +jsg::Promise>> R2Bucket::putRpc(jsg::Lock& js, + kj::String key, + kj::Maybe value, + jsg::Optional options, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler, jsg::Optional)>>& putFnHandler, + const jsg::TypeHandler>>& putResultHandler) { + return js.evalNow([&] { + kj::Maybe rpcValue; + KJ_IF_SOME(v, value) { + rpcValue = prepareR2RpcBody(js, v); + } + return callR2RpcMethod>(js, getRpcMethod(js, "put"_kj), + rpcPropHandler, putFnHandler, putResultHandler, kj::mv(key), kj::mv(rpcValue), + kj::mv(options)) + .then(js, + [](jsg::Lock& js, + kj::Maybe parsed) -> kj::Maybe> { + KJ_IF_SOME(backend, parsed) { + return headResultFromBackend(js, kj::mv(backend)); + } + return kj::none; + }); + }); +} + +jsg::Promise> R2Bucket::createMultipartUploadRpc(jsg::Lock& js, + kj::String key, + jsg::Optional options, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler)>>& + createFnHandler, + const jsg::TypeHandler>& createResultHandler) { + return js.evalNow([&] { + // The key and bucket are already known here, so the backend replies with `{ uploadId }`. + auto uploadPromise = callR2RpcMethod(js, + getRpcMethod(js, "createMultipartUpload"_kj), rpcPropHandler, createFnHandler, + createResultHandler, kj::str(key), kj::mv(options)); + + return uploadPromise.then(js, + [bucket = JSG_THIS, key = kj::mv(key)]( + jsg::Lock& js, CreateMultipartUploadBackendResult result) mutable { + return js.alloc( + kj::mv(key), kj::mv(result.uploadId.value), kj::mv(bucket)); + }); + }); +} + R2Bucket::FeatureFlags::FeatureFlags(CompatibilityFlags::Reader featureFlags) : listHonorsIncludes(featureFlags.getR2ListHonorIncludeFields()) {} @@ -543,6 +818,7 @@ jsg::Ref R2Bucket::deserialize( } } +//////////////////////////////////////////////////////////////////////////////////////////////// jsg::Promise>, jsg::Ref>> R2Bucket::get(jsg::Lock& js, kj::String key, @@ -551,17 +827,7 @@ R2Bucket::get(jsg::Lock& js, CompatibilityFlags::Reader flags) { return js.evalNow([&] { auto& context = IoContext::current(); - TraceContext traceContext = context.makeUserTraceSpan("r2_get"_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, "GetObject"_kjc); - KJ_IF_SOME(b, this->bucketName()) { - traceContext.setTag("cloudflare.r2.bucket"_kjc, b); - } - traceContext.setTag("cloudflare.r2.request.key"_kjc, key.asPtr()); + TraceContext traceContext = makeR2TraceContext("r2_get"_kjc, "GetObject"_kjc, key.asPtr()); capnp::JsonCodec json; json.handleByAnnotation(); @@ -638,17 +904,7 @@ jsg::Promise>> R2Bucket::put(jsg::Lock& }); auto& context = IoContext::current(); - TraceContext traceContext = context.makeUserTraceSpan("r2_put"_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, "PutObject"_kjc); - KJ_IF_SOME(b, this->bucketName()) { - traceContext.setTag("cloudflare.r2.bucket"_kjc, b); - } - traceContext.setTag("cloudflare.r2.request.key"_kjc, key.asPtr()); + TraceContext traceContext = makeR2TraceContext("r2_put"_kjc, "PutObject"_kjc, key.asPtr()); capnp::JsonCodec json; json.handleByAnnotation(); @@ -907,17 +1163,8 @@ jsg::Promise> R2Bucket::createMultipartUpload(jsg::L const jsg::TypeHandler>& errorType) { return js.evalNow([&] { auto& context = IoContext::current(); - TraceContext traceContext = context.makeUserTraceSpan("r2_createMultipartUpload"_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, "CreateMultipartUpload"_kjc); - KJ_IF_SOME(b, this->bucketName()) { - traceContext.setTag("cloudflare.r2.bucket"_kjc, b); - } - traceContext.setTag("cloudflare.r2.request.key"_kjc, key.asPtr()); + TraceContext traceContext = makeR2TraceContext( + "r2_createMultipartUpload"_kjc, "CreateMultipartUpload"_kjc, key.asPtr()); capnp::JsonCodec json; json.handleByAnnotation(); @@ -1034,17 +1281,7 @@ jsg::Promise R2Bucket::delete_(jsg::Lock& js, const jsg::TypeHandler>& errorType) { 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); - } + TraceContext traceContext = makeR2TraceContext("r2_delete"_kjc, "DeleteObject"_kjc); capnp::JsonCodec json; json.handleByAnnotation(); @@ -1089,22 +1326,57 @@ jsg::Promise R2Bucket::delete_(jsg::Lock& js, }); } +jsg::Promise R2Bucket::listRpc(jsg::Lock& js, + jsg::Optional options, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler)>>& listFnHandler, + const jsg::TypeHandler>& listResultHandler, + CompatibilityFlags::Reader flags) { + return js.evalNow([&] { + if (!flags.getR2ListHonorIncludeFields()) { + bool hasOnlySupportedIncludes = true; + KJ_IF_SOME(o, options) { + KJ_IF_SOME(includes, o.include) { + for (const auto& include: includes) { + if (include.value != "httpMetadata" && include.value != "customMetadata") { + hasOnlySupportedIncludes = false; + break; + } + } + } + } + + if (hasOnlySupportedIncludes) { + if (options == kj::none) { + options = ListOptions{}; + } + KJ_IF_SOME(o, options) { + kj::Vector> includes; + includes.add(jsg::NonCoercible{kj::str("httpMetadata")}); + includes.add(jsg::NonCoercible{kj::str("customMetadata")}); + o.include = includes.releaseAsArray(); + } + } + } + + return callR2RpcMethod(js, getRpcMethod(js, "list"_kj), rpcPropHandler, + listFnHandler, listResultHandler, kj::mv(options)) + .then(js, [](jsg::Lock& js, ListResult result) -> ListResult { + result.objects = KJ_MAP(object, result.objects) { + return headResultFromBackend(js, kj::mv(object), MissingMetadataPolicy::ABSENT); + }; + return kj::mv(result); + }); + }); +} + jsg::Promise R2Bucket::list(jsg::Lock& js, jsg::Optional options, const jsg::TypeHandler>& errorType, CompatibilityFlags::Reader flags) { return js.evalNow([&] { auto& context = IoContext::current(); - TraceContext traceContext = context.makeUserTraceSpan("r2_list"_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, "ListObjects"_kjc); - KJ_IF_SOME(b, this->bucketName()) { - traceContext.setTag("cloudflare.r2.bucket"_kjc, b); - } + TraceContext traceContext = makeR2TraceContext("r2_list"_kjc, "ListObjects"_kjc); capnp::JsonCodec json; json.handleByAnnotation(); @@ -1223,19 +1495,13 @@ jsg::Promise R2Bucket::list(jsg::Lock& js, result.truncated = responseBuilder.getTruncated(); if (responseBuilder.hasCursor()) { result.cursor = kj::str(responseBuilder.getCursor()); - traceContext.setTag( - "cloudflare.r2.response.cursor"_kjc, KJ_ASSERT_NONNULL(result.cursor).asPtr()); } if (responseBuilder.hasDelimitedPrefixes()) { result.delimitedPrefixes = KJ_MAP(e, responseBuilder.getDelimitedPrefixes()) { return kj::str(e); }; } - traceContext.setTag("cloudflare.r2.response.returned_objects"_kjc, - static_cast(result.objects.size())); - traceContext.setTag("cloudflare.r2.response.delimited_prefixes"_kjc, - static_cast(result.delimitedPrefixes.size())); - traceContext.setTag("cloudflare.r2.response.truncated"_kjc, result.truncated); + addListResultSpanTags(traceContext, result); return kj::mv(result); }); }); @@ -1428,6 +1694,7 @@ void R2Bucket::HeadResult::writeHttpMetadata(jsg::Lock& js, Headers& headers) { jsg::Promise> R2Bucket::GetResult::arrayBuffer(jsg::Lock& js) { return js.evalNow([&] { + ensureBodyLength(js); auto& context = IoContext::current(); return body.arrayBuffer(js, context.getLimitEnforcer().getBufferingLimit()); }); @@ -1435,6 +1702,7 @@ jsg::Promise> R2Bucket::GetResult::arrayBuffer(js jsg::Promise> R2Bucket::GetResult::bytes(jsg::Lock& js) { return js.evalNow([&] { + ensureBodyLength(js); auto& context = IoContext::current(); return body.bytes(js, context.getLimitEnforcer().getBufferingLimit()); }); @@ -1443,6 +1711,7 @@ jsg::Promise> R2Bucket::GetResult::bytes(jsg::Lock jsg::Promise R2Bucket::GetResult::text(jsg::Lock& js) { // Copy-pasted from http.c++ return js.evalNow([&] { + ensureBodyLength(js); // Check for a disturbed body before emitting the non-text warning below. (body.text() // performs the same check with the same error message; this one just runs first.) JSG_REQUIRE(!body.isDisturbed(js), TypeError, @@ -1482,6 +1751,134 @@ jsg::Promise> R2Bucket::GetResult::blob(jsg::Lock& js) { }); } +R2Bucket::SerializableChecksums R2Bucket::Checksums::toSerializable() const { + return { + .md5 = copyBytes(md5), + .sha1 = copyBytes(sha1), + .sha256 = copyBytes(sha256), + .sha384 = copyBytes(sha384), + .sha512 = copyBytes(sha512), + }; +} + +void R2Bucket::Checksums::serialize(jsg::Lock& js, + jsg::Serializer& serializer, + const jsg::TypeHandler& checksumsHandler) { + requireR2RpcSerializer(serializer); + serializer.write(js, jsg::JsValue(checksumsHandler.wrap(js, toSerializable()))); +} + +jsg::Ref R2Bucket::Checksums::deserialize(jsg::Lock& js, + rpc::SerializationTag tag, + jsg::Deserializer& deserializer, + const jsg::TypeHandler& checksumsHandler) { + requireR2RpcDeserializer(deserializer); + auto checksums = KJ_ASSERT_NONNULL(checksumsHandler.tryUnwrap(js, deserializer.readValue(js)), + "Deserialization failed: invalid R2 checksums payload"); + return js.alloc(kj::mv(checksums.md5), kj::mv(checksums.sha1), + kj::mv(checksums.sha256), kj::mv(checksums.sha384), kj::mv(checksums.sha512)); +} + +R2Bucket::SerializableHeadResult R2Bucket::HeadResult::toSerializable() const { + kj::Maybe rpcHttpMetadata; + KJ_IF_SOME(metadata, httpMetadata) { + rpcHttpMetadata = metadata.clone(); + } + + kj::Maybe> rpcCustomMetadata; + KJ_IF_SOME(metadata, customMetadata) { + rpcCustomMetadata = copyStringDict(metadata); + } + + kj::Maybe rpcRange; + KJ_IF_SOME(value, range) { + rpcRange = Range{ + .offset = value.offset, + .length = value.length, + .suffix = value.suffix, + }; + } + + return { + .key = kj::str(name), + .version = kj::str(version), + .size = size, + .etag = kj::str(etag), + .uploaded = uploaded, + .storageClass = kj::str(storageClass), + .checksums = checksums->toSerializable(), + .httpMetadata = kj::mv(rpcHttpMetadata), + .customMetadata = kj::mv(rpcCustomMetadata), + .range = kj::mv(rpcRange), + .ssecKeyMd5 = ssecKeyMd5.clone(), + }; +} + +void R2Bucket::HeadResult::serialize(jsg::Lock& js, + jsg::Serializer& serializer, + const jsg::TypeHandler& headResultHandler) { + requireR2RpcSerializer(serializer); + serializer.write(js, jsg::JsValue(headResultHandler.wrap(js, toSerializable()))); +} + +jsg::Ref R2Bucket::HeadResult::deserialize(jsg::Lock& js, + rpc::SerializationTag tag, + jsg::Deserializer& deserializer, + const jsg::TypeHandler& headResultHandler) { + requireR2RpcDeserializer(deserializer); + auto headResult = KJ_ASSERT_NONNULL(headResultHandler.tryUnwrap(js, deserializer.readValue(js)), + "Deserialization failed: invalid R2 object payload"); + return headResultFromSerializable(js, kj::mv(headResult), MissingMetadataPolicy::ABSENT); +} + +void R2Bucket::GetResult::ensureBodyLength(jsg::Lock& js) { + KJ_IF_SOME(length, expectedBodyLength) { + auto& context = IoContext::current(); + auto pipe = newIdentityPipe(length); + auto pump = context.waitForDeferredProxy(body.pumpTo(js, kj::mv(pipe.out), EndStream::YES)); + body = JsReadableStream::create( + js, context, kj::heap(kj::mv(pipe.in), kj::mv(pump))); + expectedBodyLength = kj::none; + } +} + +void R2Bucket::GetResult::serialize(jsg::Lock& js, + jsg::Serializer& serializer, + const jsg::TypeHandler& getResultHandler) { + requireR2RpcSerializer(serializer); + ensureBodyLength(js); + serializer.write(js, + jsg::JsValue(getResultHandler.wrap(js, + SerializableGetResult{ + .kind = kj::str("body"), + .object = toSerializable(), + .body = body.addRef(js), + }))); +} + +jsg::Ref R2Bucket::GetResult::deserialize(jsg::Lock& js, + rpc::SerializationTag tag, + jsg::Deserializer& deserializer, + const jsg::TypeHandler& getResultHandler) { + requireR2RpcDeserializer(deserializer); + auto getResult = KJ_ASSERT_NONNULL(getResultHandler.tryUnwrap(js, deserializer.readValue(js)), + "Deserialization failed: invalid R2 object body payload"); + KJ_ASSERT(getResult.kind == "body", + "Deserialization failed: R2 object body payload has an invalid kind"); + KJ_ASSERT(getResult.body != kj::none, + "Deserialization failed: R2 object body payload is missing its body"); + auto object = + headResultFromSerializable(js, kj::mv(getResult.object), MissingMetadataPolicy::EMPTY); + auto bodyLength = getResultBodyLength(*object); + auto customMetadata = object->getCustomMetadata(); + return js.alloc(kj::str(object->getName()), kj::str(object->getVersion()), + object->getSize(), kj::str(object->getEtag()), object->getChecksums(), object->getUploaded(), + object->getHttpMetadata(), kj::mv(customMetadata), object->getRange(), + kj::str(object->getStorageClass()), + object->getSSECKeyMd5().map([](kj::StringPtr s) { return kj::str(s); }), + KJ_ASSERT_NONNULL(kj::mv(getResult.body)), bodyLength); +} + R2Bucket::StringChecksums R2Bucket::Checksums::toJSON() { return { .md5 = this->md5.map(kj::encodeHex), diff --git a/src/workerd/api/r2-bucket.h b/src/workerd/api/r2-bucket.h index a57770965f9..d940dc6547f 100644 --- a/src/workerd/api/r2-bucket.h +++ b/src/workerd/api/r2-bucket.h @@ -7,6 +7,7 @@ #include "r2-rpc.h" #include +#include #include namespace workerd::api { @@ -36,6 +37,9 @@ kj::Own r2GetClient(IoContext& context, uint subrequestChannel, kj::ArrayPtr fillR2Path( kj::StringPtr pathStorage[1], const kj::Maybe& bucket); +kj::Maybe buildSsecKey( + kj::Maybe, kj::String>> maybeRawSsecKey); + class R2MultipartUpload; // A capability to an R2 Bucket. @@ -126,6 +130,8 @@ class R2Bucket: public jsg::Object { JSG_STRUCT_TS_OVERRIDE(R2StringChecksums); }; + struct SerializableChecksums; + class Checksums: public jsg::Object { public: Checksums(jsg::Optional> md5, @@ -146,6 +152,7 @@ class R2Bucket: public jsg::Object { jsg::Optional getSha512(jsg::Lock& js); StringChecksums toJSON(); + SerializableChecksums toSerializable() const; JSG_RESOURCE_TYPE(Checksums) { JSG_LAZY_READONLY_INSTANCE_PROPERTY(md5, getMd5); @@ -163,6 +170,16 @@ class R2Bucket: public jsg::Object { }); } + void serialize(jsg::Lock& js, + jsg::Serializer& serializer, + const jsg::TypeHandler& checksumsHandler); + static jsg::Ref deserialize(jsg::Lock& js, + rpc::SerializationTag tag, + jsg::Deserializer& deserializer, + const jsg::TypeHandler& checksumsHandler); + + JSG_SERIALIZABLE(rpc::SerializationTag::R2_CHECKSUMS); + jsg::Optional> md5; jsg::Optional> sha1; jsg::Optional> sha256; @@ -242,6 +259,60 @@ class R2Bucket: public jsg::Object { JSG_STRUCT_TS_OVERRIDE(R2MultipartOptions); }; + // Internal JSRPC payload types. SerializableChecksums, SerializableHeadResult, and + // SerializableGetResult are the payloads used by the custom serializers for the corresponding + // native R2 resource types. RPC reconstructs those native resources before a named R2 method's + // result is unwrapped. + // + // These are not customer-facing types, so they carry no TS overrides. + + // Serialized Checksums payload. + struct SerializableChecksums { + jsg::Optional> md5; + jsg::Optional> sha1; + jsg::Optional> sha256; + jsg::Optional> sha384; + jsg::Optional> sha512; + + JSG_STRUCT(md5, sha1, sha256, sha384, sha512); + }; + + // The serialized field is `key`, while HeadResult stores it internally as `name`. + // `kj::Maybe` accepts both undefined and null for absent optional fields in a payload. + struct SerializableHeadResult { + kj::String key; + kj::String version; + double size; + kj::String etag; + kj::Date uploaded; + kj::String storageClass; + SerializableChecksums checksums; + kj::Maybe httpMetadata; + kj::Maybe> customMetadata; + kj::Maybe range; + kj::Maybe ssecKeyMd5; + + JSG_STRUCT(key, + version, + size, + etag, + uploaded, + storageClass, + checksums, + httpMetadata, + customMetadata, + range, + ssecKeyMd5); + }; + + struct SerializableGetResult { + kj::String kind; + SerializableHeadResult object; + kj::Maybe body; + + JSG_STRUCT(kind, object, body); + }; + class HeadResult: public jsg::Object { public: HeadResult(kj::String name, @@ -314,6 +385,8 @@ class R2Bucket: public jsg::Object { return range; } + SerializableHeadResult toSerializable() const; + void writeHttpMetadata(jsg::Lock& js, Headers& headers); JSG_RESOURCE_TYPE(HeadResult) { @@ -333,6 +406,16 @@ class R2Bucket: public jsg::Object { JSG_TS_OVERRIDE(R2Object); } + void serialize(jsg::Lock& js, + jsg::Serializer& serializer, + const jsg::TypeHandler& headResultHandler); + static jsg::Ref deserialize(jsg::Lock& js, + rpc::SerializationTag tag, + jsg::Deserializer& deserializer, + const jsg::TypeHandler& headResultHandler); + + JSG_SERIALIZABLE(rpc::SerializationTag::R2_OBJECT); + void visitForMemoryInfo(jsg::MemoryTracker& tracker) const { tracker.trackField("name", name); tracker.trackField("version", version); @@ -377,7 +460,8 @@ class R2Bucket: public jsg::Object { jsg::Optional range, kj::String storageClass, jsg::Optional ssecKeyMd5, - JsReadableStream body) + JsReadableStream body, + kj::Maybe expectedBodyLength = kj::none) : HeadResult(kj::mv(name), kj::mv(version), size, @@ -389,9 +473,11 @@ class R2Bucket: public jsg::Object { range, kj::mv(storageClass), kj::mv(ssecKeyMd5)), - body(kj::mv(body)) {} + body(kj::mv(body)), + expectedBodyLength(expectedBodyLength) {} JsReadableStream getBody(jsg::Lock& js) { + ensureBodyLength(js); return body.addRef(js); } @@ -421,18 +507,36 @@ class R2Bucket: public jsg::Object { }); } + void serialize(jsg::Lock& js, + jsg::Serializer& serializer, + const jsg::TypeHandler& getResultHandler); + static jsg::Ref deserialize(jsg::Lock& js, + rpc::SerializationTag tag, + jsg::Deserializer& deserializer, + const jsg::TypeHandler& getResultHandler); + + JSG_SERIALIZABLE(rpc::SerializationTag::R2_OBJECT_BODY); + void visitForMemoryInfo(jsg::MemoryTracker& tracker) const { body.visitForMemoryInfo(tracker); } private: JsReadableStream body; + // RPC deserialization cannot run the JavaScript needed to wrap a TypeScript stream. + // Its length is restored on first use, after the deserializer finishes reading the graph. + kj::Maybe expectedBodyLength; + + void ensureBodyLength(jsg::Lock& js); void visitForGc(jsg::GcVisitor& visitor) { visitor.visit(body); } }; + using HeadBackendResult = jsg::Ref; + using GetBackendResult = kj::OneOf, jsg::Ref>; + struct ListResult { kj::Array> objects; bool truncated; @@ -449,6 +553,15 @@ class R2Bucket: public jsg::Object { )); }; + // The backing entrypoint's createMultipartUpload() reply. A named field rather than a bare + // string makes it harder for an implementation to return some other string, such as the key, + // by mistake. NonCoercible rejects a non-string uploadId rather than stringifying it. + struct CreateMultipartUploadBackendResult { + jsg::NonCoercible uploadId; + + JSG_STRUCT(uploadId); + }; + struct ListOptions { jsg::Optional limit; jsg::Optional> prefix; @@ -490,20 +603,81 @@ class R2Bucket: public jsg::Object { jsg::Promise delete_(jsg::Lock& js, kj::OneOf> keys, const jsg::TypeHandler>& errorType); + + // JSRPC equivalents of the above, selected by JSG_RESOURCE_TYPE when the r2_binding_jsrpc + // compatibility flag is on. They dispatch to the backing entrypoint instead of synthesizing an + // HTTP request. Typed signatures provide the TypeHandlers that validate the entrypoint contract + // and unwrap the custom JSRPC thenable. Native R2 results are reconstructed by their custom RPC + // deserializers before these handlers receive them. + jsg::Promise>> headRpc(jsg::Lock& js, + kj::String key, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler>& headFnHandler, + const jsg::TypeHandler>>& headResultHandler); + jsg::Promise>, jsg::Ref>> getRpc( + jsg::Lock& js, + kj::String key, + jsg::Optional options, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler)>>& + getFnHandler, + const jsg::TypeHandler>>& getResultHandler); + jsg::Promise deleteRpc(jsg::Lock& js, + kj::OneOf> keys, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler< + jsg::Function>)>>& deleteFnHandler, + const jsg::TypeHandler>& deleteResultHandler); + jsg::Promise>> putRpc(jsg::Lock& js, + kj::String key, + kj::Maybe value, + jsg::Optional options, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler, jsg::Optional)>>& putFnHandler, + const jsg::TypeHandler>>& putResultHandler); + jsg::Promise> createMultipartUploadRpc(jsg::Lock& js, + kj::String key, + jsg::Optional options, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler< + jsg::Function)>>& createFnHandler, + const jsg::TypeHandler>& + createResultHandler); + jsg::Promise listRpc(jsg::Lock& js, + jsg::Optional options, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler)>>& listFnHandler, + const jsg::TypeHandler>& listResultHandler, + CompatibilityFlags::Reader flags); jsg::Promise list(jsg::Lock& js, jsg::Optional options, const jsg::TypeHandler>& errorType, CompatibilityFlags::Reader flags); JSG_RESOURCE_TYPE(R2Bucket, CompatibilityFlags::Reader flags) { - JSG_METHOD(head); - JSG_METHOD(get); - JSG_METHOD(put); - JSG_METHOD(createMultipartUpload); - JSG_METHOD(resumeMultipartUpload); - JSG_METHOD_NAMED(delete, delete_); - JSG_METHOD(list); - + // The compatibility flag restricts the new transport to allowlisted + // workers, because it is marked $experimental and EWC decides who + // may opt in. + // Method order determines the generated TypeScript declaration order, so both branches + // declare resumeMultipartUpload in the same position. + if (flags.getR2BindingsJsrpc()) { + JSG_METHOD_NAMED(head, headRpc); + JSG_METHOD_NAMED(get, getRpc); + JSG_METHOD_NAMED(put, putRpc); + JSG_METHOD_NAMED(createMultipartUpload, createMultipartUploadRpc); + JSG_METHOD(resumeMultipartUpload); + JSG_METHOD_NAMED(delete, deleteRpc); + JSG_METHOD_NAMED(list, listRpc); + } else { + JSG_METHOD(head); + JSG_METHOD(get); + JSG_METHOD(put); + JSG_METHOD(createMultipartUpload); + JSG_METHOD(resumeMultipartUpload); + JSG_METHOD_NAMED(delete, delete_); + JSG_METHOD(list); + } JSG_TS_ROOT(); JSG_TS_OVERRIDE({ // The order of these matters, since typescript tries to match function signatures in order @@ -600,9 +774,31 @@ class R2Bucket: public jsg::Object { kj::Own getHttpClient(IoContext& context, TraceContext& traceContext); + TraceContext makeR2TraceContext(kj::LiteralStringConst spanName, + kj::LiteralStringConst operation, + kj::Maybe key = kj::none); + + // Look up a method on the backing entrypoint over this binding's subrequest + // channel. + jsg::Ref getRpcMethod(jsg::Lock& js, kj::StringPtr methodName); + friend class R2MultipartUpload; }; +// Helper enum to indicate whether returned httpMetadata and customMetadata to be empty or absent. +// Use EMPTY for `head`, successful `get`, conditional `get`, `put`, and multipart completion. Use ABSENT for list fields that were not requested. +enum class MissingMetadataPolicy { + EMPTY, + ABSENT, +}; + +jsg::Ref headResultFromSerializable(jsg::Lock& js, + R2Bucket::SerializableHeadResult rpc, + MissingMetadataPolicy policy = MissingMetadataPolicy::EMPTY); +jsg::Ref headResultFromBackend(jsg::Lock& js, + R2Bucket::HeadBackendResult result, + MissingMetadataPolicy policy = MissingMetadataPolicy::EMPTY); + // Non-generic wrapper avoid moving the parseObjectMetadata implementation into this header file // by making use of dynamic dispatch. kj::Maybe> parseHeadResultWrapper(jsg::Lock& js, diff --git a/src/workerd/api/r2-multipart.c++ b/src/workerd/api/r2-multipart.c++ index ff30b115cd6..7c7e4492735 100644 --- a/src/workerd/api/r2-multipart.c++ +++ b/src/workerd/api/r2-multipart.c++ @@ -41,16 +41,7 @@ jsg::Promise R2MultipartUpload::uploadPart(jsg: "Part number must be between 1 and 10000 (inclusive). Actual value was: ", partNumber); auto& context = IoContext::current(); - TraceContext traceContext = context.makeUserTraceSpan("r2_uploadPart"_kjc); - - traceContext.setTag("cloudflare.binding.type"_kjc, "r2"_kjc); - KJ_IF_SOME(b, this->bucket->bindingName()) { - traceContext.setTag("cloudflare.binding.name"_kjc, b); - } - traceContext.setTag("cloudflare.r2.operation"_kjc, "UploadPart"_kjc); - KJ_IF_SOME(b, this->bucket->bucketName()) { - traceContext.setTag("cloudflare.r2.bucket"_kjc, b); - } + TraceContext traceContext = bucket->makeR2TraceContext("r2_uploadPart"_kjc, "UploadPart"_kjc); traceContext.setTag("cloudflare.r2.request.upload_id"_kjc, uploadId.asPtr()); traceContext.setTag("cloudflare.r2.request.part_number"_kjc, static_cast(partNumber)); traceContext.setTag("cloudflare.r2.request.key"_kjc, key.asPtr()); @@ -138,21 +129,34 @@ jsg::Promise R2MultipartUpload::uploadPart(jsg: }); } +jsg::Promise R2MultipartUpload::uploadPartRpc(jsg::Lock& js, + int partNumber, + R2PutValue value, + jsg::Optional options, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler)>>& + uploadPartFnHandler, + const jsg::TypeHandler>& uploadPartResultHandler) { + return js.evalNow([&] { + auto rpcValue = prepareR2RpcBody(js, value); + + auto promise = callR2RpcMethod(js, bucket->getRpcMethod(js, "uploadPart"_kj), + rpcPropHandler, uploadPartFnHandler, uploadPartResultHandler, kj::str(key), + kj::str(uploadId), partNumber, kj::mv(rpcValue), kj::mv(options)); + return promise.then(js, [partNumber](jsg::Lock& js, UploadedPart uploadedPart) { + return UploadedPart{.partNumber = partNumber, .etag = kj::mv(uploadedPart.etag)}; + }); + }); +} + jsg::Promise> R2MultipartUpload::complete(jsg::Lock& js, kj::Array uploadedParts, const jsg::TypeHandler>& errorType) { return js.evalNow([&] { auto& context = IoContext::current(); - TraceContext traceContext = context.makeUserTraceSpan("r2_completeMultipartUpload"_kjc); - - traceContext.setTag("cloudflare.binding.type"_kjc, "r2"_kjc); - KJ_IF_SOME(b, this->bucket->bindingName()) { - traceContext.setTag("cloudflare.binding.name"_kjc, b); - } - traceContext.setTag("cloudflare.r2.operation"_kjc, "CompleteMultipartUpload"_kjc); - KJ_IF_SOME(b, this->bucket->bucketName()) { - traceContext.setTag("cloudflare.r2.bucket"_kjc, b); - } + TraceContext traceContext = + bucket->makeR2TraceContext("r2_completeMultipartUpload"_kjc, "CompleteMultipartUpload"_kjc); traceContext.setTag("cloudflare.r2.request.upload_id"_kjc, uploadId.asPtr()); traceContext.setTag("cloudflare.r2.request.key"_kjc, key.asPtr()); kj::String partIds = @@ -207,20 +211,28 @@ jsg::Promise> R2MultipartUpload::complete(jsg::Lo }); } +jsg::Promise> R2MultipartUpload::completeRpc(jsg::Lock& js, + kj::Array uploadedParts, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler)>>& completeFnHandler, + const jsg::TypeHandler>& completeResultHandler) { + return js.evalNow([&] { + auto promise = callR2RpcMethod(js, + bucket->getRpcMethod(js, "completeMultipartUpload"_kj), rpcPropHandler, completeFnHandler, + completeResultHandler, kj::str(key), kj::str(uploadId), kj::mv(uploadedParts)); + return promise.then(js, [](jsg::Lock& js, R2Bucket::HeadBackendResult backend) { + return headResultFromBackend(js, kj::mv(backend)); + }); + }); +} + jsg::Promise R2MultipartUpload::abort( jsg::Lock& js, const jsg::TypeHandler>& errorType) { return js.evalNow([&] { auto& context = IoContext::current(); - TraceContext traceContext = context.makeUserTraceSpan("r2_abortMultipartUpload"_kjc); - - traceContext.setTag("cloudflare.binding.type"_kjc, "r2"_kjc); - KJ_IF_SOME(b, this->bucket->bindingName()) { - traceContext.setTag("cloudflare.binding.name"_kjc, b); - } - traceContext.setTag("cloudflare.r2.operation"_kjc, "AbortMultipartUpload"_kjc); - KJ_IF_SOME(b, this->bucket->bucketName()) { - traceContext.setTag("cloudflare.r2.bucket"_kjc, b); - } + TraceContext traceContext = + bucket->makeR2TraceContext("r2_abortMultipartUpload"_kjc, "AbortMultipartUpload"_kjc); traceContext.setTag("cloudflare.r2.request.upload_id"_kjc, uploadId.asPtr()); traceContext.setTag("cloudflare.r2.request.key"_kjc, key.asPtr()); @@ -255,4 +267,14 @@ jsg::Promise R2MultipartUpload::abort( }); }); } + +jsg::Promise R2MultipartUpload::abortRpc(jsg::Lock& js, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler>& abortFnHandler, + const jsg::TypeHandler>& abortResultHandler) { + return js.evalNow([&] { + return callR2RpcMethod(js, bucket->getRpcMethod(js, "abortMultipartUpload"_kj), + rpcPropHandler, abortFnHandler, abortResultHandler, kj::str(key), kj::str(uploadId)); + }); +} } // namespace workerd::api::public_beta diff --git a/src/workerd/api/r2-multipart.h b/src/workerd/api/r2-multipart.h index 693d39f7dd8..6685d753476 100644 --- a/src/workerd/api/r2-multipart.h +++ b/src/workerd/api/r2-multipart.h @@ -47,13 +47,38 @@ class R2MultipartUpload: public jsg::Object { jsg::Promise> complete(jsg::Lock& js, kj::Array uploadedParts, const jsg::TypeHandler>& errorType); + jsg::Promise uploadPartRpc(jsg::Lock& js, + int partNumber, + R2PutValue value, + jsg::Optional options, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler)>>& + uploadPartFnHandler, + const jsg::TypeHandler>& uploadPartResultHandler); + jsg::Promise abortRpc(jsg::Lock& js, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler>& abortFnHandler, + const jsg::TypeHandler>& abortResultHandler); + jsg::Promise> completeRpc(jsg::Lock& js, + kj::Array uploadedParts, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler)>>& completeFnHandler, + const jsg::TypeHandler>& completeResultHandler); - JSG_RESOURCE_TYPE(R2MultipartUpload) { + JSG_RESOURCE_TYPE(R2MultipartUpload, CompatibilityFlags::Reader flags) { JSG_LAZY_READONLY_INSTANCE_PROPERTY(key, getKey); JSG_LAZY_READONLY_INSTANCE_PROPERTY(uploadId, getUploadId); - JSG_METHOD(uploadPart); - JSG_METHOD(abort); - JSG_METHOD(complete); + if (flags.getR2BindingsJsrpc()) { + JSG_METHOD_NAMED(uploadPart, uploadPartRpc); + JSG_METHOD_NAMED(abort, abortRpc); + JSG_METHOD_NAMED(complete, completeRpc); + } else { + JSG_METHOD(uploadPart); + JSG_METHOD(abort); + JSG_METHOD(complete); + } } void visitForMemoryInfo(jsg::MemoryTracker& tracker) const { diff --git a/src/workerd/api/r2-rpc.c++ b/src/workerd/api/r2-rpc.c++ index 37f6038dc2d..f387d98c950 100644 --- a/src/workerd/api/r2-rpc.c++ +++ b/src/workerd/api/r2-rpc.c++ @@ -5,6 +5,8 @@ #include "r2-rpc.h" #include +#include +#include #include #include #include @@ -15,6 +17,77 @@ #include namespace workerd::api { + +namespace { + +constexpr size_t R2_RPC_INLINE_BODY_LIMIT = 16u << 20; +// V8's UTF-16 string serialization can use twice the UTF-8 byte count. Keep strings within +// the same inline budget as binary values, leaving room for the other RPC arguments. +constexpr size_t R2_RPC_INLINE_STRING_LIMIT = R2_RPC_INLINE_BODY_LIMIT / 2; + +JsReadableStream makeR2RpcMemoryStream( + jsg::Lock& js, kj::ArrayPtr bytes, kj::Maybe> backing = kj::none) { + return JsReadableStream::create( + js, IoContext::current(), newMemorySource(bytes, kj::mv(backing))); +} + +} // namespace + +jsg::Promise normalizeR2RpcPromise(jsg::Lock& js, jsg::Value rpcPromise) { + auto paf = js.newPromiseAndResolver(); + paf.resolver.resolve(js, kj::mv(rpcPromise)); + return kj::mv(paf.promise); +} + +void requireR2RpcSerializer(jsg::Serializer& serializer) { + auto& handler = JSG_REQUIRE_NONNULL(serializer.getExternalHandler(), DOMDataCloneError, + "R2 resources can only be serialized for RPC."); + JSG_REQUIRE(dynamic_cast(&handler) != nullptr, DOMDataCloneError, + "R2 resources can only be serialized for RPC."); +} + +void requireR2RpcDeserializer(jsg::Deserializer& deserializer) { + auto& handler = JSG_REQUIRE_NONNULL(deserializer.getExternalHandler(), DOMDataCloneError, + "R2 resources can only be deserialized for RPC."); + JSG_REQUIRE(dynamic_cast(&handler) != nullptr, DOMDataCloneError, + "R2 resources can only be deserialized for RPC."); +} + +R2PutValue prepareR2RpcBody(jsg::Lock& js, R2PutValue& value) { + KJ_SWITCH_ONEOF(value) { + KJ_CASE_ONEOF(stream, JsReadableStream) { + return R2PutValue(kj::mv(stream)); + } + KJ_CASE_ONEOF(data, kj::Array) { + auto size = data.size(); + if (size > R2_RPC_INLINE_BODY_LIMIT) { + // Type-wrapper arrays may alias V8 memory, which cannot be pumped without the isolate lock. + auto owned = kj::heapArray(data.asPtr()); + auto view = owned.asPtr(); + return R2PutValue(makeR2RpcMemoryStream(js, view, kj::heap(kj::mv(owned)))); + } + return R2PutValue(kj::mv(data)); + } + KJ_CASE_ONEOF(text, jsg::NonCoercible) { + auto size = text.value.size(); + if (size > R2_RPC_INLINE_STRING_LIMIT) { + auto bytes = text.value.asBytes(); + return R2PutValue(makeR2RpcMemoryStream(js, bytes, kj::heap(kj::mv(text.value)))); + } + return R2PutValue(kj::mv(text)); + } + KJ_CASE_ONEOF(blob, jsg::Ref) { + auto size = blob->getSize(); + if (size > R2_RPC_INLINE_BODY_LIMIT) { + // Blob bytes are V8-backed, so newMemorySource() must copy them into native memory. + return R2PutValue(makeR2RpcMemoryStream(js, blob->getData(js))); + } + return R2PutValue(kj::mv(blob)); + } + } + KJ_UNREACHABLE; +} + static kj::Own toError(uint statusCode, kj::StringPtr responseBody) { capnp::JsonCodec json; json.handleByAnnotation(); @@ -46,10 +119,9 @@ kj::Maybe R2Result::getR2ErrorMessage() { void R2Result::throwIfError( kj::StringPtr action, const jsg::TypeHandler>& errorType) { KJ_IF_SOME(e, toThrow) { - // TODO(soon): Once jsg::JsPromise exists, switch to using that to tunnel out the exception. As - // it stands today, unfortunately, all we can send back to the user is a message. R2Error isn't - // a registered type in the runtime. When reenabling, make sure to update overrides/r2.d.ts to - // reenable the type + // Keep the existing generic error behavior for R2 bindings. Changing this throw path to + // throw a structured R2Error would be a public compatibility change, and R2Error would also + // need to be made serializable so it can cross JSRPC. #if 0 auto isolate = IoContext::current().getCurrentLock().getIsolate(); (*e)->action = kj::str(action); diff --git a/src/workerd/api/r2-rpc.h b/src/workerd/api/r2-rpc.h index 1b6f16b459b..b9654c654bb 100644 --- a/src/workerd/api/r2-rpc.h +++ b/src/workerd/api/r2-rpc.h @@ -6,6 +6,7 @@ #include #include +#include #include namespace kj { @@ -16,6 +17,59 @@ namespace workerd::api { class ReadableStreamSource; +// JsRpcPromise is a custom thenable. Resolving a fresh promise with it makes V8 adopt it even when +// the unwrap_custom_thenables compatibility flag is disabled. +jsg::Promise normalizeR2RpcPromise(jsg::Lock& js, jsg::Value rpcPromise); + +void requireR2RpcSerializer(jsg::Serializer& serializer); +void requireR2RpcDeserializer(jsg::Deserializer& deserializer); + +template +jsg::Value callR2RpcMethod(jsg::Lock& js, + jsg::Ref rpcProp, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler>& fnHandler, + Args... args) { + auto wrappedProp = rpcPropHandler.wrap(js, kj::mv(rpcProp)); + auto fn = KJ_ASSERT_NONNULL(fnHandler.tryUnwrap(js, wrappedProp)); + return fn(js, kj::mv(args)...); +} + +template +jsg::Promise unwrapR2RpcPromise(jsg::Lock& js, + jsg::Value rpcPromise, + const jsg::TypeHandler>& resultPromiseHandler) { + auto normalizedPromise = normalizeR2RpcPromise(js, kj::mv(rpcPromise)); + if constexpr (kj::isSameType()) { + return KJ_ASSERT_NONNULL( + resultPromiseHandler.tryUnwrap(js, normalizedPromise.consumeHandle(js))); + } else { + // Decode only fulfilled responses so backing-entrypoint rejections retain their public API + // errors. Failures converting a response into the internal wire types are contract violations. + return normalizedPromise.then(js, [&resultPromiseHandler](jsg::Lock& js, jsg::Value value) { + auto fulfilled = js.resolvedPromise(kj::mv(value)); + auto parsed = + KJ_ASSERT_NONNULL(resultPromiseHandler.tryUnwrap(js, fulfilled.consumeHandle(js))); + return parsed.catch_(js, [](jsg::Lock& js, jsg::Value error) -> Result { + auto exception = js.exceptionToKj(kj::mv(error)); + KJ_FAIL_ASSERT("Malformed R2 RPC result.", exception); + }); + }); + } +} + +template +jsg::Promise callR2RpcMethod(jsg::Lock& js, + jsg::Ref rpcProp, + const jsg::TypeHandler>& rpcPropHandler, + const jsg::TypeHandler>& fnHandler, + const jsg::TypeHandler>& resultPromiseHandler, + Args... args) { + auto rpcPromise = + callR2RpcMethod(js, kj::mv(rpcProp), rpcPropHandler, fnHandler, kj::mv(args)...); + return unwrapR2RpcPromise(js, kj::mv(rpcPromise), resultPromiseHandler); +} + // NOTE: We don't currently actually use this as a structured object (hence the `kj::Own` // that we see pop up). // TODO(soon): Switch to structured objects and use jsg::Ref instead of kj::Own @@ -67,6 +121,10 @@ class R2Error: public jsg::Object { using R2PutValue = kj::OneOf, jsg::NonCoercible, jsg::Ref>; +// Converts bodies that are too large for an inline JSRPC message into streams. Known stream lengths +// are preserved by the JSRPC stream transport. +R2PutValue prepareR2RpcBody(jsg::Lock& js, R2PutValue& value); + struct R2Result { uint httpStatus; diff --git a/src/workerd/api/r2.h b/src/workerd/api/r2.h index eb104bbef50..c03acfba6df 100644 --- a/src/workerd/api/r2.h +++ b/src/workerd/api/r2.h @@ -17,6 +17,10 @@ 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::R2Bucket::CreateMultipartUploadBackendResult, \ + api::public_beta::R2MultipartUpload::UploadPartOptions, \ + api::public_beta::R2Bucket::SerializableChecksums, \ + api::public_beta::R2Bucket::SerializableHeadResult, \ + api::public_beta::R2Bucket::SerializableGetResult // The list of r2 types that are added to worker.c++'s JSG_DECLARE_ISOLATE_TYPE } // namespace workerd::api::public_beta diff --git a/src/workerd/api/tests/BUILD.bazel b/src/workerd/api/tests/BUILD.bazel index 27c17237d72..9fc3cf4c988 100644 --- a/src/workerd/api/tests/BUILD.bazel +++ b/src/workerd/api/tests/BUILD.bazel @@ -1,6 +1,7 @@ load("@aspect_rules_js//js:defs.bzl", "js_binary") load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") load("@rules_shell//shell:sh_test.bzl", "sh_test") +load("//:build/lint_test.bzl", "lint_test") load("//:build/wasm_tools_parse.bzl", "wasm_tools_parse") load("//:build/wd_bench.bzl", "wd_bench") load("//:build/wd_test.bzl", "wd_cli_test", "wd_test") @@ -102,6 +103,35 @@ ts_project( deps = ["//src/node:node@tsproject"], ) +# Type-checks the R2 JSRPC binding contract and its test implementation against the Workers types +# in the checked-in snapshot. Kept apart from :ts_project because the snapshot's globals conflict +# with the DOM, WebWorker, and Node.js types that project uses. +ts_config( + name = "r2_binding_service_ts_config", + src = "r2-binding-service/tsconfig.json", + deps = ["@workerd//tools:base-tsconfig"], +) + +ts_project( + name = "r2_binding_service_ts_project", + srcs = glob(["r2-binding-service/*.ts"]), + composite = True, + tsconfig = ":r2_binding_service_ts_config", + deps = ["//types:generated_snapshot_experimental"], +) + +lint_test( + name = "r2_binding_service", + srcs = glob(["r2-binding-service/*.ts"]), + data = ["//types:generated-snapshot/experimental/index.d.ts"], + eslintrc_json = "@workerd//tools:base.eslint.config.mjs", + no_copy_to_bin = [ + "@workerd//tools:base.eslint.config.mjs", + "//types:generated-snapshot/experimental/index.d.ts", + ], + tsconfig_json = "r2-binding-service/tsconfig.json", +) + wd_test( src = "exported-handler-proxy-test.wd-test", data = ["exported-handler-proxy-test.js"], @@ -545,12 +575,91 @@ wd_test( ], ) +wd_test( + src = "r2-http-test.wd-test", + args = ["--experimental"], + data = ["r2-test.js"], +) + +wd_test( + src = "r2-jsrpc-input-test.wd-test", + args = ["--experimental"], + data = ["r2-jsrpc-input-test.js"], + generate_all_compat_flags_variant = False, +) + +wd_test( + src = "r2-jsrpc-response-test.wd-test", + args = ["--experimental"], + data = [ + "r2-jsrpc-response-test.js", + "r2-test.js", + ":r2_binding_service_ts_project", + ], +) + +wd_test( + src = "r2-resource-serialization-test.wd-test", + args = ["--experimental"], + data = [ + "r2-resource-serialization-test.js", + "r2-test.js", + ":r2_binding_service_ts_project", + ], +) + +wd_test( + src = "r2-resource-serialization-ts-test.wd-test", + args = ["--experimental"], + data = [ + "r2-resource-serialization-test.js", + "r2-resource-serialization-test.wd-test", + "r2-test.js", + ":r2_binding_service_ts_project", + ], + lint = False, +) + +wd_test( + src = "r2-jsrpc-test.wd-test", + args = ["--experimental"], + data = [ + "r2-jsrpc-passthrough.capnp", + "r2-test.js", + ":r2_binding_service_ts_project", + ], +) + +wd_test( + src = "r2-jsrpc-legacy-list-test.wd-test", + args = ["--experimental"], + data = [ + "r2-jsrpc-passthrough.capnp", + "r2-test.js", + ":r2_binding_service_ts_project", + ], + generate_all_compat_flags_variant = False, +) + +wd_test( + size = "large", + src = "r2-string-upload-test.wd-test", + args = ["--experimental"], + data = [ + "r2-jsrpc-passthrough.capnp", + "r2-string-upload-test.js", + "r2-test.js", + ":r2_binding_service_ts_project", + ], +) + wd_test( src = "r2-test.wd-test", args = ["--experimental"], data = [ "instrumentation-tail-worker.js", "r2-instrumentation-test.js", + "r2-instrumentation-worker.js", "r2-test.js", ], ) diff --git a/src/workerd/api/tests/r2-binding-service/r2-binding-service.ts b/src/workerd/api/tests/r2-binding-service/r2-binding-service.ts new file mode 100644 index 00000000000..3ace82aa18a --- /dev/null +++ b/src/workerd/api/tests/r2-binding-service/r2-binding-service.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +///////////////////////////////////////////////////////////////////////////// +// Binding contract to create a custom binding // +///////////////////////////////////////////////////////////////////////////// +// +// With the r2_binding_jsrpc compatibility flag, an R2 bucket binding that targets a Worker +// entrypoint calls these named JSRPC methods on it instead of speaking the R2 HTTP protocol. +// R2Bucket and R2MultipartUpload in src/workerd/api/r2-bucket.c++ and r2-multipart.c++ are the +// callers; this file describes what they send and what they accept back. +// +// The backend receives the public R2 option types without client-side validation or normalization. +// It is responsible for validating them. The only compatibility adjustment is to list(): callers +// without r2_list_honor_include request both metadata fields when their include list is absent or +// contains only recognized values. +// +// Results are the native R2 types, so a backend can return what a native R2 binding returns. +// Two constraints the types cannot express: an R2ObjectBody is rejected wherever only object +// metadata is expected, and a native R2Object returned from head, get, put, or +// completeMultipartUpload must include httpMetadata and customMetadata. + +// Request body passed to put and uploadPart. ArrayBufferViews arrive as ArrayBuffers, and +// binary, string, and Blob values above an inline size limit arrive as a ReadableStream. Known +// stream lengths are preserved across JSRPC. +export type R2BindingBody = ReadableStream | ArrayBuffer | string | Blob; + +export type R2BindingService = { + head(key: string): Promise; + + // Returns object metadata without a body when an onlyIf precondition fails. + get( + key: string, + options: R2GetOptions | undefined + ): Promise; + + put( + key: string, + value: R2BindingBody | null, + options: R2PutOptions | undefined + ): Promise; + + // The result is ignored. + delete(keys: string | string[]): Promise; + + list(options: R2ListOptions | undefined): Promise; + + createMultipartUpload( + key: string, + options: R2MultipartOptions | undefined + ): Promise<{ uploadId: string }>; + + // The returned partNumber is ignored in favour of the requested one, but must be present. + uploadPart( + key: string, + uploadId: string, + partNumber: number, + value: R2BindingBody, + options: R2UploadPartOptions | undefined + ): Promise; + + abortMultipartUpload(key: string, uploadId: string): Promise; + + completeMultipartUpload( + key: string, + uploadId: string, + uploadedParts: R2UploadedPart[] + ): Promise; +}; + +///////////////////////////////////////////////////////////////////////////// +// End of the binding contract // +///////////////////////////////////////////////////////////////////////////// diff --git a/src/workerd/api/tests/r2-binding-service/r2-observed-binding.ts b/src/workerd/api/tests/r2-binding-service/r2-observed-binding.ts new file mode 100644 index 00000000000..ca578c3e576 --- /dev/null +++ b/src/workerd/api/tests/r2-binding-service/r2-observed-binding.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import { WorkerEntrypoint } from 'cloudflare:workers'; +import type { R2BindingBody, R2BindingService } from './r2-binding-service'; + +type Env = { + REAL_BUCKET: R2Bucket; +}; + +// Implements the R2 binding's JSRPC methods by forwarding each call to a native R2 binding, in +// the way a customer Worker can stand in for R2. The JSRPC tests target this entrypoint so the +// shared suite exercises the serialization of native R2 results and errors, while REAL_BUCKET +// reaches the same HTTP mock as the HTTP tests. HTTP requests are rejected to detect any +// transport fallback on the JSRPC hop. +export class ObservedR2Binding + extends WorkerEntrypoint + implements R2BindingService +{ + override fetch(): Response { + throw new Error('R2 JSRPC tests must not use HTTP'); + } + + head(key: string): Promise { + return this.env.REAL_BUCKET.head(key); + } + + get( + key: string, + options: R2GetOptions | undefined + ): Promise { + return this.env.REAL_BUCKET.get(key, options); + } + + put( + key: string, + value: R2BindingBody | null, + options: R2PutOptions | undefined + ): Promise { + return this.env.REAL_BUCKET.put(key, value, options); + } + + delete(keys: string | string[]): Promise { + return this.env.REAL_BUCKET.delete(keys); + } + + list(options: R2ListOptions | undefined): Promise { + return this.env.REAL_BUCKET.list(options); + } + + async createMultipartUpload( + key: string, + options: R2MultipartOptions | undefined + ): Promise<{ uploadId: string }> { + const upload = await this.env.REAL_BUCKET.createMultipartUpload( + key, + options + ); + return { uploadId: upload.uploadId }; + } + + uploadPart( + key: string, + uploadId: string, + partNumber: number, + value: R2BindingBody, + options: R2UploadPartOptions | undefined + ): Promise { + return this.env.REAL_BUCKET.resumeMultipartUpload(key, uploadId).uploadPart( + partNumber, + value, + options + ); + } + + abortMultipartUpload(key: string, uploadId: string): Promise { + return this.env.REAL_BUCKET.resumeMultipartUpload(key, uploadId).abort(); + } + + completeMultipartUpload( + key: string, + uploadId: string, + uploadedParts: R2UploadedPart[] + ): Promise { + return this.env.REAL_BUCKET.resumeMultipartUpload(key, uploadId).complete( + uploadedParts + ); + } +} diff --git a/src/workerd/api/tests/r2-binding-service/tsconfig.json b/src/workerd/api/tests/r2-binding-service/tsconfig.json new file mode 100644 index 00000000000..9db40a53284 --- /dev/null +++ b/src/workerd/api/tests/r2-binding-service/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../../tools/base.tsconfig.json", + "compilerOptions": { + "lib": ["esnext"], + "exactOptionalPropertyTypes": false, + "allowJs": false + }, + "files": ["../../../../../types/generated-snapshot/experimental/index.d.ts"], + "include": ["*.ts"] +} diff --git a/src/workerd/api/tests/r2-http-test.wd-test b/src/workerd/api/tests/r2-http-test.wd-test new file mode 100644 index 00000000000..1f75c76acaa --- /dev/null +++ b/src/workerd/api/tests/r2-http-test.wd-test @@ -0,0 +1,23 @@ +using Workerd = import "/workerd/workerd.capnp"; + +# Runs the complete R2 binding suite using the HTTP transport. The instrumented R2 test uses a +# restricted set of exports so that every emitted span can be checked exhaustively. +const unitTests :Workerd.Config = ( + services = [ + ( name = "r2-test", + worker = ( + modules = [ + ( name = "worker", esModule = embed "r2-test.js" ) + ], + bindings = [ + ( name = "BUCKET", r2Bucket = "r2-test" ), + ( name = "SERVICE", service = "r2-test" ), + ( name = "R2_TRANSPORT", text = "http" ), + ( name = "R2_LIST_HONOR_INCLUDE", text = "true" ), + ], + compatibilityFlags = ["nodejs_compat", "service_binding_extra_handlers", + "streams_enable_constructors", "r2_list_honor_include"], + ) + ), + ], +); diff --git a/src/workerd/api/tests/r2-instrumentation-test.js b/src/workerd/api/tests/r2-instrumentation-test.js index 406303129fa..224dad911b7 100644 --- a/src/workerd/api/tests/r2-instrumentation-test.js +++ b/src/workerd/api/tests/r2-instrumentation-test.js @@ -19,6 +19,32 @@ export const test = { // Recorded streaming tail worker events, in insertion order. let received = Array.from(spans.values()); + // Calls using the must-not-call key produce spans even though validation rejects them before + // reaching the backend. These spans are validated here because they are not included in the + // `expected` array below. + const validationSpans = received.filter( + (span) => span['cloudflare.r2.request.key'] === 'must-not-call' + ); + assert.deepStrictEqual( + validationSpans.map((span) => span.name), + [ + ...Array(14).fill('r2_get'), + ...Array(4).fill('r2_put'), + 'r2_createMultipartUpload', + 'r2_uploadPart', + 'r2_completeMultipartUpload', + ] + ); + for (const span of validationSpans) { + assert.strictEqual(span['cloudflare.binding.type'], 'r2'); + assert.strictEqual(span['cloudflare.binding.name'], 'BUCKET'); + assert.strictEqual(span['cloudflare.r2.bucket'], 'r2-test'); + assert.strictEqual(span.closed, true); + } + received = received.filter( + (span) => span['cloudflare.r2.request.key'] !== 'must-not-call' + ); + // spans emitted by r2-test.js in execution order let expected = [ { @@ -31,7 +57,7 @@ export const test = { 'cloudflare.r2.request.size': 7n, 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -47,7 +73,7 @@ export const test = { 'cloudflare.r2.request.size': 7n, 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -62,7 +88,7 @@ export const test = { 'cloudflare.r2.request.key': 'basicKey', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -77,7 +103,7 @@ export const test = { 'cloudflare.r2.request.key': 'basicKey', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -92,7 +118,7 @@ export const test = { 'cloudflare.r2.request.key': 'basicKey', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -145,7 +171,7 @@ export const test = { 'cloudflare.r2.request.uploaded_parts': '1', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -201,7 +227,7 @@ export const test = { 'cloudflare.r2.request.range.length': 3n, 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -217,7 +243,7 @@ export const test = { 'cloudflare.r2.request.range.suffix': 2n, 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -255,7 +281,7 @@ export const test = { 'cloudflare.r2.request.size': 7n, 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -275,7 +301,7 @@ export const test = { 'cloudflare.r2.request.size': 7n, 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -294,7 +320,44 @@ export const test = { '2000-01-01T00:00:00.000Z', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, + 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', + 'cloudflare.r2.response.storage_class': 'Standard', + 'cloudflare.r2.response.custom_metadata': true, + closed: true, + }, + { + name: 'r2_put', + 'cloudflare.binding.type': 'r2', + 'cloudflare.binding.name': 'BUCKET', + 'cloudflare.r2.operation': 'PutObject', + 'cloudflare.r2.bucket': 'r2-test', + 'cloudflare.r2.request.key': 'onlyIfMultipleEtags', + 'cloudflare.r2.request.only_if.etag_matches': 'strongEtag, weakEtag', + 'cloudflare.r2.request.only_if.etag_does_not_match': + 'firstEtag, secondEtag', + 'cloudflare.r2.request.size': 7n, + 'cloudflare.r2.response.success': true, + 'cloudflare.r2.response.etag': 'objectEtag', + 'cloudflare.r2.response.size': 7, + 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', + 'cloudflare.r2.response.storage_class': 'Standard', + 'cloudflare.r2.response.custom_metadata': true, + closed: true, + }, + { + name: 'r2_get', + 'cloudflare.binding.type': 'r2', + 'cloudflare.binding.name': 'BUCKET', + 'cloudflare.r2.operation': 'GetObject', + 'cloudflare.r2.bucket': 'r2-test', + 'cloudflare.r2.request.key': 'onlyIfMultipleEtags', + 'cloudflare.r2.request.only_if.etag_matches': 'strongEtag, weakEtag', + 'cloudflare.r2.request.only_if.etag_does_not_match': + 'firstEtag, secondEtag', + 'cloudflare.r2.response.success': true, + 'cloudflare.r2.response.etag': 'objectEtag', + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -313,7 +376,7 @@ export const test = { '2000-01-01T00:00:00.000Z', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -336,7 +399,7 @@ export const test = { 'cloudflare.r2.request.size': 7n, 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.content_type': 'text/plain', @@ -358,7 +421,7 @@ export const test = { 'cloudflare.r2.request.key': 'httpMetadata', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.content_type': 'text/plain', @@ -380,7 +443,7 @@ export const test = { 'cloudflare.r2.request.key': 'httpMetadata', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.content_type': 'text/plain', @@ -436,16 +499,9 @@ export const test = { 'cloudflare.r2.request.uploaded_parts': '', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', - 'cloudflare.r2.response.content_type': 'text/plain', - 'cloudflare.r2.response.content_encoding': 'utf-8', - 'cloudflare.r2.response.content_disposition': - 'attachment; filename = "basicKey.txt"', - 'cloudflare.r2.response.content_language': 'en-US', - 'cloudflare.r2.response.cache_control': 'no-store', - 'cloudflare.r2.response.cache_expiry': '1970-01-01T00:00:01.000Z', 'cloudflare.r2.response.custom_metadata': true, closed: true, }, @@ -466,7 +522,7 @@ export const test = { 'cloudflare.r2.request.size': 7n, 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.content_type': 'text/plain', @@ -488,7 +544,7 @@ export const test = { 'cloudflare.r2.request.key': 'httpMetadata', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.content_type': 'text/plain', @@ -510,7 +566,7 @@ export const test = { 'cloudflare.r2.request.key': 'httpMetadata', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.content_type': 'text/plain', @@ -566,16 +622,9 @@ export const test = { 'cloudflare.r2.request.uploaded_parts': '', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', - 'cloudflare.r2.response.content_type': 'text/plain', - 'cloudflare.r2.response.content_encoding': 'utf-8', - 'cloudflare.r2.response.content_disposition': - 'attachment; filename = "basicKey.txt"', - 'cloudflare.r2.response.content_language': 'en-US', - 'cloudflare.r2.response.cache_control': 'no-store', - 'cloudflare.r2.response.cache_expiry': '1970-01-01T00:00:01.000Z', 'cloudflare.r2.response.custom_metadata': true, closed: true, }, @@ -590,7 +639,7 @@ export const test = { 'cloudflare.r2.request.size': 7n, 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -605,7 +654,7 @@ export const test = { 'cloudflare.r2.request.key': 'customMetadata', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -620,7 +669,7 @@ export const test = { 'cloudflare.r2.request.key': 'customMetadata', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -663,7 +712,7 @@ export const test = { 'cloudflare.r2.request.uploaded_parts': '', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -679,7 +728,7 @@ export const test = { 'cloudflare.r2.request.size': 7n, 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -694,7 +743,7 @@ export const test = { 'cloudflare.r2.request.key': 'classDefault', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -709,7 +758,7 @@ export const test = { 'cloudflare.r2.request.key': 'classDefault', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -737,7 +786,7 @@ export const test = { 'cloudflare.r2.request.uploaded_parts': '', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -754,7 +803,7 @@ export const test = { 'cloudflare.r2.request.size': 7n, 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -769,7 +818,7 @@ export const test = { 'cloudflare.r2.request.key': 'classStandard', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -784,7 +833,7 @@ export const test = { 'cloudflare.r2.request.key': 'classStandard', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -813,7 +862,7 @@ export const test = { 'cloudflare.r2.request.uploaded_parts': '', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.custom_metadata': true, @@ -830,7 +879,7 @@ export const test = { 'cloudflare.r2.request.size': 7n, 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'InfrequentAccess', 'cloudflare.r2.response.custom_metadata': true, @@ -845,7 +894,7 @@ export const test = { 'cloudflare.r2.request.key': 'classInfrequentAccess', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'InfrequentAccess', 'cloudflare.r2.response.custom_metadata': true, @@ -860,7 +909,7 @@ export const test = { 'cloudflare.r2.request.key': 'classInfrequentAccess', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'InfrequentAccess', 'cloudflare.r2.response.custom_metadata': true, @@ -889,7 +938,7 @@ export const test = { 'cloudflare.r2.request.uploaded_parts': '', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'InfrequentAccess', 'cloudflare.r2.response.custom_metadata': true, @@ -906,7 +955,7 @@ export const test = { 'cloudflare.r2.request.size': 7n, 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.ssec_key': true, @@ -923,7 +972,7 @@ export const test = { 'cloudflare.r2.request.ssec_key': true, 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.ssec_key': true, @@ -939,7 +988,7 @@ export const test = { 'cloudflare.r2.request.key': 'ssec', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.ssec_key': true, @@ -984,7 +1033,7 @@ export const test = { 'cloudflare.r2.request.uploaded_parts': '1', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.ssec_key': true, @@ -1002,7 +1051,7 @@ export const test = { 'cloudflare.r2.request.size': 7n, 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.ssec_key': true, @@ -1019,7 +1068,7 @@ export const test = { 'cloudflare.r2.request.ssec_key': true, 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.ssec_key': true, @@ -1035,7 +1084,7 @@ export const test = { 'cloudflare.r2.request.key': 'ssec', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.ssec_key': true, @@ -1080,7 +1129,7 @@ export const test = { 'cloudflare.r2.request.uploaded_parts': '1', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.storage_class': 'Standard', 'cloudflare.r2.response.ssec_key': true, @@ -1100,7 +1149,7 @@ export const test = { 'cloudflare.r2.request.size': 7n, 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.checksum.md5': '9a0364b9e99bb480dd25e1f0284c8555', @@ -1121,7 +1170,7 @@ export const test = { 'cloudflare.r2.request.key': 'multipleChecksums', 'cloudflare.r2.response.success': true, 'cloudflare.r2.response.etag': 'objectEtag', - 'cloudflare.r2.response.size': 123, + 'cloudflare.r2.response.size': 7, 'cloudflare.r2.response.uploaded': '2024-08-27T14:00:57.918Z', 'cloudflare.r2.response.checksum.md5': '9a0364b9e99bb480dd25e1f0284c8555', @@ -1133,6 +1182,16 @@ export const test = { 'cloudflare.r2.response.custom_metadata': true, closed: true, }, + { + name: 'r2_delete', + 'cloudflare.binding.type': 'r2', + 'cloudflare.binding.name': 'BUCKET', + 'cloudflare.r2.operation': 'DeleteObject', + 'cloudflare.r2.bucket': 'r2-test', + 'cloudflare.r2.request.keys': 'existing, missing', + 'cloudflare.r2.response.success': true, + closed: true, + }, ]; assert.deepStrictEqual(received, expected); diff --git a/src/workerd/api/tests/r2-instrumentation-worker.js b/src/workerd/api/tests/r2-instrumentation-worker.js new file mode 100644 index 00000000000..65ac1ecc7e6 --- /dev/null +++ b/src/workerd/api/tests/r2-instrumentation-worker.js @@ -0,0 +1,11 @@ +// Copyright (c) 2017-2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Only export the suites whose spans are checked by r2-instrumentation-test.js. The other shared +// R2 suites are exercised by the HTTP and JSRPC transport tests without instrumentation. +export { + default, + deletePerKeyErrorParityTests, + r2ValidationTests, +} from './r2-test.js'; diff --git a/src/workerd/api/tests/r2-jsrpc-input-test.js b/src/workerd/api/tests/r2-jsrpc-input-test.js new file mode 100644 index 00000000000..8a7b9d4732d --- /dev/null +++ b/src/workerd/api/tests/r2-jsrpc-input-test.js @@ -0,0 +1,198 @@ +// Copyright (c) 2017-2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import assert from 'node:assert'; +import { WorkerEntrypoint } from 'cloudflare:workers'; + +const REMOTE_RELEASED_MESSAGE = + 'ReadableStream sent over RPC was canceled or released by the remote execution context.'; +const STREAM_CANCEL_MESSAGE = 'server canceled put body'; + +function makeObservedStream(chunk) { + const canceled = Promise.withResolvers(); + let cancelCalls = 0; + const stream = new ReadableStream({ + start(controller) { + if (chunk !== undefined) controller.enqueue(chunk); + }, + cancel(reason) { + ++cancelCalls; + canceled.resolve(reason); + }, + }); + return { + stream, + whenCanceled: canceled.promise, + get cancelCalls() { + return cancelCalls; + }, + }; +} + +async function awaitCancellation(observed) { + // A timer keeps the caller's execution context alive while the rejected RPC invocation tears + // down and releases its received stream. + const pending = Symbol('pending'); + for (let attempt = 0; attempt < 100; attempt++) { + const outcome = await Promise.race([ + observed.whenCanceled, + scheduler.wait(50).then(() => pending), + ]); + if (outcome !== pending) return outcome; + } + assert.fail('the origin never observed the backend releasing the stream'); +} + +// Verifies that the named-method client does not reject or normalize malformed arguments before +// the custom backend receives them. Each method throws a recognizable server-side error after +// checking its arguments. +export class InputObservingR2Binding extends WorkerEntrypoint { + get(key, options) { + assert.strictEqual(key, 'invalid-get'); + assert.strictEqual(options.range.offset, -1); + throw new Error('server observed get'); + } + + async put(key, value, options) { + switch (key) { + case 'invalid-put': + assert.strictEqual(value, 'body'); + assert.strictEqual(options.md5.byteLength, 1); + assert(options.httpMetadata instanceof Headers); + throw new Error('server observed put'); + case 'cancel-stream': + assert(value instanceof ReadableStream); + await value.cancel(new Error(STREAM_CANCEL_MESSAGE)); + throw new Error('server canceled stream'); + case 'throw-before-consuming-stream': + assert(value instanceof ReadableStream); + throw new Error('server rejected stream before consuming it'); + case 'throw-during-stream': { + assert(value instanceof ReadableStream); + const reader = value.getReader(); + const first = await reader.read(); + assert.strictEqual(first.done, false); + assert.deepStrictEqual(first.value, new Uint8Array([1, 2, 3])); + reader.releaseLock(); + throw new Error('server rejected stream during consumption'); + } + default: + assert.fail(`unexpected put key: ${key}`); + } + } + + createMultipartUpload(key, options) { + assert.strictEqual(key, 'invalid-create'); + assert.strictEqual(options.ssecKey, 'bad'); + throw new Error('server observed createMultipartUpload'); + } + + uploadPart(key, uploadId, partNumber, value, options) { + assert.strictEqual(key, 'invalid-upload-part'); + assert.strictEqual(uploadId, 'upload-id'); + assert.strictEqual(partNumber, 0); + assert.strictEqual(value, 'body'); + assert.strictEqual(options.ssecKey, 'bad'); + throw new Error('server observed uploadPart'); + } + + completeMultipartUpload(key, uploadId, uploadedParts) { + assert.strictEqual(key, 'invalid-complete'); + assert.strictEqual(uploadId, 'upload-id'); + assert.deepStrictEqual(uploadedParts, [{ partNumber: 0, etag: 'invalid' }]); + throw new Error('server observed completeMultipartUpload'); + } + + list(options) { + assert.deepStrictEqual(options.include, ['unsupported']); + throw new Error('server observed list'); + } +} + +export const inputForwardingTests = { + async test(_controller, env) { + await assert.rejects( + env.BUCKET.get('invalid-get', { range: { offset: -1 } }), + { message: 'server observed get' } + ); + await assert.rejects( + env.BUCKET.put('invalid-put', 'body', { + md5: new Uint8Array(1), + httpMetadata: new Headers({ 'content-type': 'text/plain' }), + }), + { message: 'server observed put' } + ); + await assert.rejects( + env.BUCKET.createMultipartUpload('invalid-create', { ssecKey: 'bad' }), + { message: 'server observed createMultipartUpload' } + ); + + const uploadPart = env.BUCKET.resumeMultipartUpload( + 'invalid-upload-part', + 'upload-id' + ); + await assert.rejects(uploadPart.uploadPart(0, 'body', { ssecKey: 'bad' }), { + message: 'server observed uploadPart', + }); + + const complete = env.BUCKET.resumeMultipartUpload( + 'invalid-complete', + 'upload-id' + ); + await assert.rejects( + complete.complete([{ partNumber: 0, etag: 'invalid' }]), + { message: 'server observed completeMultipartUpload' } + ); + + // Unsupported includes reach the backend both before and after r2_list_honor_include. + await assert.rejects(env.BUCKET.list({ include: ['unsupported'] }), { + message: 'server observed list', + }); + + const explicitlyCanceled = makeObservedStream(); + await assert.rejects( + env.BUCKET.put('cancel-stream', explicitlyCanceled.stream), + { message: 'server canceled stream' } + ); + const explicitReason = await awaitCancellation(explicitlyCanceled); + assert.strictEqual(explicitlyCanceled.cancelCalls, 1); + assert(explicitReason instanceof Error); + assert.strictEqual(explicitReason.message, STREAM_CANCEL_MESSAGE); + + const rejectedBeforeConsumption = makeObservedStream(); + await assert.rejects( + env.BUCKET.put( + 'throw-before-consuming-stream', + rejectedBeforeConsumption.stream + ), + { message: 'server rejected stream before consuming it' } + ); + const beforeConsumptionReason = await awaitCancellation( + rejectedBeforeConsumption + ); + assert.strictEqual(rejectedBeforeConsumption.cancelCalls, 1); + assert(beforeConsumptionReason instanceof Error); + assert.strictEqual( + beforeConsumptionReason.message, + REMOTE_RELEASED_MESSAGE + ); + + const rejectedDuringConsumption = makeObservedStream( + new Uint8Array([1, 2, 3]) + ); + await assert.rejects( + env.BUCKET.put('throw-during-stream', rejectedDuringConsumption.stream), + { message: 'server rejected stream during consumption' } + ); + const duringConsumptionReason = await awaitCancellation( + rejectedDuringConsumption + ); + assert.strictEqual(rejectedDuringConsumption.cancelCalls, 1); + assert(duringConsumptionReason instanceof Error); + assert.strictEqual( + duringConsumptionReason.message, + REMOTE_RELEASED_MESSAGE + ); + }, +}; diff --git a/src/workerd/api/tests/r2-jsrpc-input-test.wd-test b/src/workerd/api/tests/r2-jsrpc-input-test.wd-test new file mode 100644 index 00000000000..58d72148af5 --- /dev/null +++ b/src/workerd/api/tests/r2-jsrpc-input-test.wd-test @@ -0,0 +1,42 @@ +using Workerd = import "/workerd/workerd.capnp"; + +# Runs the input-forwarding contract both before and after r2_list_honor_include. The backend has +# no native R2 binding: any expected R2 error must therefore come from its named method. +const unitTests :Workerd.Config = ( + services = [ + (name = "modern", worker = ( + modules = .testModules, + bindings = [ + (name = "BUCKET", + r2Bucket = (name = "backend", entrypoint = "InputObservingR2Binding")), + ], + compatibilityFlags = ["experimental", "nodejs_compat", "service_binding_extra_handlers", + "streams_enable_constructors", "r2_list_honor_include", + "r2_binding_jsrpc", "rpc", "fetcher_no_get_put_delete"], + )), + (name = "legacy-list", worker = ( + modules = .testModules, + bindings = [ + (name = "BUCKET", + r2Bucket = (name = "backend", entrypoint = "InputObservingR2Binding")), + ], + compatibilityFlags = ["experimental", "nodejs_compat", "service_binding_extra_handlers", + "streams_enable_constructors", "r2_binding_jsrpc", "rpc", + "fetcher_no_get_put_delete"], + )), + (name = "backend", worker = ( + modules = [ + (name = "worker", + esModule = "export { InputObservingR2Binding } from './test.js';"), + (name = "test.js", esModule = embed "r2-jsrpc-input-test.js"), + ], + compatibilityFlags = ["nodejs_compat", "streams_enable_constructors"], + )), + ], + autogates = ["workerd-autogate-jsrpc-readable-cancel-propagation"], +); + +const testModules :List(Workerd.Worker.Module) = [ + (name = "worker", esModule = "export { inputForwardingTests } from './test.js';"), + (name = "test.js", esModule = embed "r2-jsrpc-input-test.js"), +]; diff --git a/src/workerd/api/tests/r2-jsrpc-legacy-list-test.wd-test b/src/workerd/api/tests/r2-jsrpc-legacy-list-test.wd-test new file mode 100644 index 00000000000..12c8fab1ca1 --- /dev/null +++ b/src/workerd/api/tests/r2-jsrpc-legacy-list-test.wd-test @@ -0,0 +1,26 @@ +using Workerd = import "/workerd/workerd.capnp"; +using Passthrough = import "r2-jsrpc-passthrough.capnp"; + +# Compatibility-sensitive coverage for list() before r2_list_honor_include. This lives separately +# because a future compatibility date necessarily enables the behavior under test. +# +# The canonical and shared suites run through the passthrough in r2-jsrpc-passthrough.capnp, with +# the flag disabled for both the JSRPC client and the native binding. +const unitTests :Workerd.Config = ( + services = [ + ( name = "user", + worker = ( + modules = Passthrough.userModules, + bindings = [ + ( name = "BUCKET", + r2Bucket = (name = "r2-test", entrypoint = "ObservedR2Binding") ), + ( name = "R2_TRANSPORT", text = "jsrpc" ), + ( name = "R2_LIST_HONOR_INCLUDE", text = "false" ), + ], + compatibilityFlags = ["experimental", "nodejs_compat", "service_binding_extra_handlers", + "streams_enable_constructors", "r2_binding_jsrpc"], + ) + ), + ( name = "r2-test", worker = Passthrough.passthroughLegacyListWorker ), + ], +); diff --git a/src/workerd/api/tests/r2-jsrpc-passthrough.capnp b/src/workerd/api/tests/r2-jsrpc-passthrough.capnp new file mode 100644 index 00000000000..6f33c0553ff --- /dev/null +++ b/src/workerd/api/tests/r2-jsrpc-passthrough.capnp @@ -0,0 +1,56 @@ +# Shared pieces of the R2 JSRPC passthrough topology, imported by the R2 JSRPC configs. This file +# is not a test config itself. embed paths resolve relative to this file. +# +# user --JSRPC--> r2-test (ObservedR2Binding) --HTTP via native REAL_BUCKET--> r2-test (fetch) +# +# The r2-test service runs ObservedR2Binding without r2_binding_jsrpc, so its native REAL_BUCKET +# uses the HTTP protocol. REAL_BUCKET points back at the same service, whose default export is +# the HTTP mock from r2-test.js. Importing configs must therefore name the service "r2-test", +# which is also the bucket name that R2 spans record. +# +# Each worker variant matches the mock's R2_LIST_HONOR_INCLUDE expectation to its +# r2_list_honor_include flag. The workers do not set a JSG struct mode, so the compatibility date +# of each test variant selects it. + +@0xe8a01684ab8486bc; + +using Workerd = import "/workerd/workerd.capnp"; + +# Modules for a user Worker that runs the canonical and shared suites. +const userModules :List(Workerd.Worker.Module) = [ + ( name = "worker", + esModule = "export { default, deletePerKeyErrorParityTests, r2ValidationTests, r2BindingApiTests, r2MultipartApiTests } from './r2-test.js';" ), + ( name = "r2-test.js", esModule = embed "r2-test.js" ), +]; + +# Exports ObservedR2Binding and the HTTP mock, but no test handlers, so the test runner does not +# run the tests exported by r2-test.js in this service. ObservedR2Binding is compiled from +# r2-binding-service/r2-observed-binding.ts. +const passthroughModules :List(Workerd.Worker.Module) = [ + ( name = "worker", + esModule = "import mock from './r2-test.js'; export { ObservedR2Binding } from './r2-observed-binding.js'; export default { fetch: mock.fetch };" ), + ( name = "r2-test.js", esModule = embed "r2-test.js" ), + ( name = "r2-observed-binding.js", + esModule = embed "r2-binding-service/r2-observed-binding.js" ), +]; + +# r2_list_honor_include. +const passthroughWorker :Workerd.Worker = ( + modules = .passthroughModules, + bindings = [ + ( name = "REAL_BUCKET", r2Bucket = "r2-test" ), + ( name = "R2_LIST_HONOR_INCLUDE", text = "true" ), + ], + compatibilityFlags = ["nodejs_compat", "streams_enable_constructors", + "r2_list_honor_include"], +); + +# No r2_list_honor_include. +const passthroughLegacyListWorker :Workerd.Worker = ( + modules = .passthroughModules, + bindings = [ + ( name = "REAL_BUCKET", r2Bucket = "r2-test" ), + ( name = "R2_LIST_HONOR_INCLUDE", text = "false" ), + ], + compatibilityFlags = ["nodejs_compat", "streams_enable_constructors"], +); diff --git a/src/workerd/api/tests/r2-jsrpc-response-test.js b/src/workerd/api/tests/r2-jsrpc-response-test.js new file mode 100644 index 00000000000..35f1864abb6 --- /dev/null +++ b/src/workerd/api/tests/r2-jsrpc-response-test.js @@ -0,0 +1,84 @@ +// Copyright (c) 2017-2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import assert from 'node:assert'; +import mock from './r2-test.js'; +import { ObservedR2Binding as PassthroughR2Binding } from './r2-observed-binding.js'; + +// Returns native R2 resources that violate a named method's result contract. +export class InvalidR2ResponseEntrypoint extends PassthroughR2Binding { + async head(requestKey) { + if (requestKey === 'body-as-head') { + return this.env.REAL_BUCKET.get('rpc-json'); + } + if (requestKey === 'list-object-as-head') { + const listed = await this.env.REAL_BUCKET.list({ + prefix: 'httpMeta', + include: [], + }); + return listed.objects[0]; + } + return super.head(requestKey); + } + + async get(requestKey, options) { + if (requestKey === 'list-object-as-get') { + const listed = await this.env.REAL_BUCKET.list({ + prefix: 'httpMeta', + include: [], + }); + return listed.objects[0]; + } + return super.get(requestKey, options); + } + + async createMultipartUpload(requestKey, options) { + if (requestKey === 'upload-id-as-string') { + return 'upload-id'; + } + if (requestKey === 'upload-id-as-number') { + return { uploadId: 123 }; + } + if (requestKey === 'upload-id-missing') { + return {}; + } + if (requestKey === 'upload-id-with-key') { + return { key: requestKey, uploadId: 'upload-id' }; + } + return super.createMultipartUpload(requestKey, options); + } +} + +export default { + fetch: mock.fetch, + + async test(_controller, env) { + const internalError = { message: /^internal error; reference = \S+$/ }; + await assert.rejects(() => env.BUCKET.head('body-as-head'), internalError); + await assert.rejects( + () => env.BUCKET.head('list-object-as-head'), + internalError + ); + await assert.rejects( + () => env.BUCKET.get('list-object-as-get'), + internalError + ); + + for (const key of [ + 'upload-id-as-string', + 'upload-id-as-number', + 'upload-id-missing', + ]) { + await assert.rejects( + () => env.BUCKET.createMultipartUpload(key), + internalError + ); + } + + // Extra properties do not prevent construction of a valid local multipart resource. + const upload = await env.BUCKET.createMultipartUpload('upload-id-with-key'); + assert.strictEqual(upload.key, 'upload-id-with-key'); + assert.strictEqual(upload.uploadId, 'upload-id'); + }, +}; diff --git a/src/workerd/api/tests/r2-jsrpc-response-test.wd-test b/src/workerd/api/tests/r2-jsrpc-response-test.wd-test new file mode 100644 index 00000000000..c6ee35b8132 --- /dev/null +++ b/src/workerd/api/tests/r2-jsrpc-response-test.wd-test @@ -0,0 +1,63 @@ +using Workerd = import "/workerd/workerd.capnp"; + +# Response decoding reports malformed native R2 resources as internal contract errors with either +# JSG struct implementation. Each platform Worker also serves the native R2 HTTP mock used to +# construct the resources returned by InvalidR2ResponseEntrypoint. +const unitTests :Workerd.Config = ( + services = [ + (name = "fast-structs", worker = ( + modules = .testModules, + bindings = [ + (name = "BUCKET", + r2Bucket = (name = "fast-platform", entrypoint = "InvalidR2ResponseEntrypoint")), + ], + compatibilityFlags = ["experimental", "nodejs_compat", "service_binding_extra_handlers", + "streams_enable_constructors", "r2_list_honor_include", "r2_binding_jsrpc", + "rpc", "fetcher_no_get_put_delete", "enable_fast_jsg_struct"], + )), + (name = "fast-platform", worker = ( + modules = .platformModules, + bindings = [ + (name = "REAL_BUCKET", r2Bucket = "fast-platform"), + (name = "R2_LIST_HONOR_INCLUDE", text = "true"), + ], + compatibilityFlags = ["nodejs_compat", "streams_enable_constructors", + "r2_list_honor_include", "enable_fast_jsg_struct"], + )), + (name = "slow-structs", worker = ( + modules = .testModules, + bindings = [ + (name = "BUCKET", + r2Bucket = (name = "slow-platform", entrypoint = "InvalidR2ResponseEntrypoint")), + ], + compatibilityFlags = ["experimental", "nodejs_compat", "service_binding_extra_handlers", + "streams_enable_constructors", "r2_list_honor_include", "r2_binding_jsrpc", + "rpc", "fetcher_no_get_put_delete", "disable_fast_jsg_struct"], + )), + (name = "slow-platform", worker = ( + modules = .platformModules, + bindings = [ + (name = "REAL_BUCKET", r2Bucket = "slow-platform"), + (name = "R2_LIST_HONOR_INCLUDE", text = "true"), + ], + compatibilityFlags = ["nodejs_compat", "streams_enable_constructors", + "r2_list_honor_include", "disable_fast_jsg_struct"], + )), + ], +); + +const testModules :List(Workerd.Worker.Module) = [ + (name = "worker", esModule = embed "r2-jsrpc-response-test.js"), + (name = "r2-test.js", esModule = embed "r2-test.js"), + (name = "r2-observed-binding.js", + esModule = embed "r2-binding-service/r2-observed-binding.js"), +]; + +const platformModules :List(Workerd.Worker.Module) = [ + (name = "worker", + esModule = "import mock from './r2-test.js'; export { InvalidR2ResponseEntrypoint } from './test.js'; export default { fetch: mock.fetch };"), + (name = "test.js", esModule = embed "r2-jsrpc-response-test.js"), + (name = "r2-test.js", esModule = embed "r2-test.js"), + (name = "r2-observed-binding.js", + esModule = embed "r2-binding-service/r2-observed-binding.js"), +]; diff --git a/src/workerd/api/tests/r2-jsrpc-test.wd-test b/src/workerd/api/tests/r2-jsrpc-test.wd-test new file mode 100644 index 00000000000..27dc636d085 --- /dev/null +++ b/src/workerd/api/tests/r2-jsrpc-test.wd-test @@ -0,0 +1,33 @@ +using Workerd = import "/workerd/workerd.capnp"; +using Passthrough = import "r2-jsrpc-passthrough.capnp"; + +# Runs the canonical R2 binding suite (r2-test.js) and the shared suites with the JSRPC transport +# enabled, through the passthrough in r2-jsrpc-passthrough.capnp. That passthrough forwards each +# call to a native R2 binding that reaches the same HTTP mock as r2-test.wd-test, so the suites +# check that native R2 results and errors survive the JSRPC hop with the same observable behavior +# as the HTTP transport. +# +# Reaching the JSRPC method table needs two things to line up: +# - the r2_binding_jsrpc compatibility flag, which is $experimental and so also needs +# "experimental" in the flag list here +# - a binding that targets the named entrypoint, which is the production shape; a bare-string +# designator would target the default export instead +const unitTests :Workerd.Config = ( + services = [ + ( name = "user", + worker = ( + modules = Passthrough.userModules, + bindings = [ + ( name = "BUCKET", + r2Bucket = (name = "r2-test", entrypoint = "ObservedR2Binding") ), + ( name = "R2_TRANSPORT", text = "jsrpc" ), + ( name = "R2_LIST_HONOR_INCLUDE", text = "true" ), + ], + compatibilityFlags = ["experimental", "nodejs_compat", "service_binding_extra_handlers", + "streams_enable_constructors", "r2_list_honor_include", + "r2_binding_jsrpc"], + ) + ), + ( name = "r2-test", worker = Passthrough.passthroughWorker ), + ], +); diff --git a/src/workerd/api/tests/r2-resource-serialization-test.js b/src/workerd/api/tests/r2-resource-serialization-test.js new file mode 100644 index 00000000000..dbffd31beb0 --- /dev/null +++ b/src/workerd/api/tests/r2-resource-serialization-test.js @@ -0,0 +1,74 @@ +// Copyright (c) 2017-2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import assert from 'node:assert'; +import { ObservedR2Binding as PassthroughR2Binding } from './r2-observed-binding.js'; + +// Exposes native R2 resources through an ordinary service binding so this suite exercises their +// JSRPC serializers without involving the R2 binding's named-method client. +export class ResourceSerializationEntrypoint extends PassthroughR2Binding { + async checksums() { + return (await this.env.REAL_BUCKET.head('multipleChecksums')).checksums; + } + + headNative(requestKey) { + return this.env.REAL_BUCKET.head(requestKey); + } + + getNative(requestKey) { + return this.env.REAL_BUCKET.get(requestKey); + } + + echoNative(object) { + return object; + } +} + +export const resourceSerializationTests = { + async test(_controller, env) { + const checksums = await env.PLATFORM.checksums(); + assert.throws(() => structuredClone(checksums), { name: 'DataCloneError' }); + const checksumJson = checksums.toJSON(); + assert.strictEqual(checksumJson.md5, '9a0364b9e99bb480dd25e1f0284c8555'); + assert.strictEqual( + checksumJson.sha1, + '2a0364b9e99bb480dd25e1f0284c855511223344' + ); + assert.strictEqual( + checksumJson.sha256, + '3a0364b9e99bb480dd25e1f0284c8555112233445566778899aabbccddeeff00' + ); + + const head = await env.PLATFORM.headNative('multipleChecksums'); + assert.throws(() => structuredClone(head), { name: 'DataCloneError' }); + assert.strictEqual(head.key, 'basicKey'); + assert.strictEqual(typeof head.writeHttpMetadata, 'function'); + assert.deepStrictEqual(head.checksums.toJSON(), checksumJson); + + const directGet = await env.PLATFORM.getNative('rpc-json'); + assert.throws(() => structuredClone(directGet), { name: 'DataCloneError' }); + assert.deepStrictEqual(await directGet.json(), { ok: true }); + assert.strictEqual(directGet.bodyUsed, true); + + // Each consumption method must work without first accessing the body getter. + for (const method of ['arrayBuffer', 'bytes', 'text', 'blob']) { + const result = await env.PLATFORM.getNative('rpc-lazy-body'); + const consumed = await result[method](); + assert.strictEqual(await new Response(consumed).text(), 'content'); + assert.strictEqual(result.bodyUsed, true); + } + + // A deserialized native result must be serializable again before its body is accessed. + const echoed = await env.PLATFORM.echoNative( + await env.PLATFORM.getNative('rpc-json') + ); + assert.deepStrictEqual(await echoed.json(), { ok: true }); + + // The resource serializer preserves a body stream's known length across a service RPC hop. + const source = await env.PLATFORM.getNative('rpc-lazy-body'); + const copied = await env.BUCKET.put('basicKey', source.body); + assert.strictEqual(copied.size, 7); + assert.strictEqual(source.bodyUsed, true); + }, +}; diff --git a/src/workerd/api/tests/r2-resource-serialization-test.wd-test b/src/workerd/api/tests/r2-resource-serialization-test.wd-test new file mode 100644 index 00000000000..f004389553f --- /dev/null +++ b/src/workerd/api/tests/r2-resource-serialization-test.wd-test @@ -0,0 +1,46 @@ +using Workerd = import "/workerd/workerd.capnp"; + +# Native R2 resources cross an ordinary service binding independently of the named-method R2 +# binding transport. The platform Worker obtains them from its native HTTP R2 binding. +const unitTests :Workerd.Config = ( + services = [ + (name = "user", worker = ( + modules = .testModules, + bindings = [ + (name = "BUCKET", + r2Bucket = (name = "platform", entrypoint = "ResourceSerializationEntrypoint")), + (name = "PLATFORM", + service = (name = "platform", entrypoint = "ResourceSerializationEntrypoint")), + ], + compatibilityFlags = ["experimental", "nodejs_compat", "streams_enable_constructors", + "r2_list_honor_include", "r2_binding_jsrpc", "rpc", + "fetcher_no_get_put_delete"], + )), + (name = "platform", worker = .platformWorker), + ], +); + +const testModules :List(Workerd.Worker.Module) = [ + (name = "worker", esModule = "export { resourceSerializationTests } from './test.js';"), + (name = "test.js", esModule = embed "r2-resource-serialization-test.js"), + (name = "r2-test.js", esModule = embed "r2-test.js"), + (name = "r2-observed-binding.js", + esModule = embed "r2-binding-service/r2-observed-binding.js"), +]; + +# Exports no test handlers, so the test runner does not run the tests in the dependency modules. +const platformWorker :Workerd.Worker = ( + modules = [ + (name = "worker", + esModule = "import mock from './r2-test.js'; export { ResourceSerializationEntrypoint } from './test.js'; export default { fetch: mock.fetch };"), + (name = "test.js", esModule = embed "r2-resource-serialization-test.js"), + (name = "r2-test.js", esModule = embed "r2-test.js"), + (name = "r2-observed-binding.js", + esModule = embed "r2-binding-service/r2-observed-binding.js"), + ], + bindings = [ + (name = "REAL_BUCKET", r2Bucket = "platform"), + ], + compatibilityFlags = ["nodejs_compat", "streams_enable_constructors", + "r2_list_honor_include"], +); diff --git a/src/workerd/api/tests/r2-resource-serialization-ts-test.wd-test b/src/workerd/api/tests/r2-resource-serialization-ts-test.wd-test new file mode 100644 index 00000000000..717e599e243 --- /dev/null +++ b/src/workerd/api/tests/r2-resource-serialization-ts-test.wd-test @@ -0,0 +1,26 @@ +using Workerd = import "/workerd/workerd.capnp"; +using NativeR2 = import "r2-resource-serialization-test.wd-test"; + +# Native R2 object bodies cross the RPC deserializer's no-JavaScript graph-read scope. +# The TypeScript stream receiver requires its streams to be hydrated before that scope. +const unitTests :Workerd.Config = ( + services = [ + (name = "user", worker = ( + modules = NativeR2.testModules, + bindings = [ + (name = "BUCKET", + r2Bucket = (name = "platform", entrypoint = "ResourceSerializationEntrypoint")), + (name = "PLATFORM", + service = (name = "platform", entrypoint = "ResourceSerializationEntrypoint")), + ], + compatibilityFlags = ["experimental", "nodejs_compat", "streams_enable_constructors", + "r2_list_honor_include", "r2_binding_jsrpc", "rpc", + "fetcher_no_get_put_delete", "typescript_implemented_streams"], + )), + (name = "platform", worker = NativeR2.platformWorker), + ], + autogates = [ + "workerd-autogate-per-isolate-javascript-bootstrap", + "workerd-autogate-rpc-externals-hydration", + ], +); diff --git a/src/workerd/api/tests/r2-string-upload-test.js b/src/workerd/api/tests/r2-string-upload-test.js new file mode 100644 index 00000000000..9a2e3fea0be --- /dev/null +++ b/src/workerd/api/tests/r2-string-upload-test.js @@ -0,0 +1,35 @@ +// Copyright (c) 2017-2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import assert from 'node:assert'; +import { stringUploadCases } from './r2-test.js'; + +function stringUploadValue({ size, suffix }) { + return ( + 'a'.repeat(size - new TextEncoder().encode(suffix).byteLength) + suffix + ); +} + +export const r2StringPutTests = { + async test(_ctrl, env) { + for (const [key, fixture] of Object.entries(stringUploadCases)) { + const result = await env.BUCKET.put(key, stringUploadValue(fixture)); + assert.strictEqual(result.key, key); + assert.strictEqual(result.size, fixture.size); + } + }, +}; + +export const r2StringUploadPartTests = { + async test(_ctrl, env) { + for (const [key, fixture] of Object.entries(stringUploadCases)) { + const upload = env.BUCKET.resumeMultipartUpload(key, 'string-upload-id'); + const result = await upload.uploadPart(1, stringUploadValue(fixture)); + assert.deepStrictEqual(result, { + partNumber: 1, + etag: `string-upload-${fixture.size}`, + }); + } + }, +}; diff --git a/src/workerd/api/tests/r2-string-upload-test.wd-test b/src/workerd/api/tests/r2-string-upload-test.wd-test new file mode 100644 index 00000000000..bd40cee3474 --- /dev/null +++ b/src/workerd/api/tests/r2-string-upload-test.wd-test @@ -0,0 +1,30 @@ +using Workerd = import "/workerd/workerd.capnp"; +using Passthrough = import "r2-jsrpc-passthrough.capnp"; + +# Both transports upload strings to the same HTTP mock, which verifies the UTF-8 bytes and +# declared length. The JSRPC route forwards through the native R2 interceptor. +const unitTests :Workerd.Config = ( + services = [ + (name = "http", worker = ( + modules = .testModules, + bindings = [ + (name = "BUCKET", r2Bucket = "r2-test"), + ], + compatibilityFlags = ["nodejs_compat", "streams_enable_constructors"], + )), + (name = "jsrpc", worker = ( + modules = .testModules, + bindings = [ + (name = "BUCKET", r2Bucket = (name = "r2-test", entrypoint = "ObservedR2Binding")), + ], + compatibilityFlags = ["experimental", "nodejs_compat", "streams_enable_constructors", + "r2_binding_jsrpc"], + )), + (name = "r2-test", worker = Passthrough.passthroughWorker), + ], +); + +const testModules :List(Workerd.Worker.Module) = [ + (name = "worker", esModule = embed "r2-string-upload-test.js"), + (name = "r2-test.js", esModule = embed "r2-test.js"), +]; diff --git a/src/workerd/api/tests/r2-test.js b/src/workerd/api/tests/r2-test.js index 7b18aec2053..f27088ab890 100644 --- a/src/workerd/api/tests/r2-test.js +++ b/src/workerd/api/tests/r2-test.js @@ -3,9 +3,19 @@ // https://opensource.org/licenses/Apache-2.0 import assert from 'node:assert'; +import { Buffer } from 'node:buffer'; const key = 'basicKey'; const body = 'content'; +const rpcStreamBody = 'café'; +export const stringUploadCases = { + 'string-upload-below-limit': { size: (8 << 20) - 1, suffix: 'Ā' }, + 'string-upload-at-limit': { size: 8 << 20, suffix: 'Ā' }, + 'string-upload-above-limit': { size: (8 << 20) + 1, suffix: 'Ā' }, + 'string-upload-rpc-limit': { size: 16 << 20, suffix: 'Ā' }, + 'string-upload-ascii': { size: 16 << 20, suffix: '' }, +}; +const perKeyErrorDeleteKeys = ['existing', 'missing']; const httpMetaObj = { contentType: 'text/plain', contentLanguage: 'en-US', @@ -59,7 +69,7 @@ const sha256Buffer = new Uint8Array([ const objResponse = { name: key, version: 'objectVersion', - size: '123', + size: '7', etag: 'objectEtag', uploaded: '1724767257918', storageClass: 'Standard', @@ -80,12 +90,161 @@ const HeadObject = { }, httpEtag: '"objectEtag"', etag: 'objectEtag', - size: 123, + size: 7, version: 'objectVersion', key, }; +// The HTTP protocol reports failures with a status code and a CF-R2-Error header, which the +// binding turns into an Error whose message is ": ()". +function r2ErrorHeaders({ v4code, message }) { + return { 'cf-r2-error': JSON.stringify({ version: 1, v4code, message }) }; +} + +function r2ErrorResponse(error) { + return new Response(null, { + status: error.status, + headers: r2ErrorHeaders(error), + }); +} + +const noSuchKeyError = { + status: 404, + v4code: 10007, + message: 'The specified key does not exist.', +}; +const noSuchBucketError = { + status: 404, + v4code: 10006, + message: 'no such bucket', +}; +const preconditionFailedError = { + status: 412, + v4code: 10031, + message: 'At least one of the pre-conditions you specified did not hold.', +}; +const uploadNotFoundError = { + status: 404, + v4code: 10024, + message: 'Upload not found', +}; + +// Multipart uploads for keys starting with 'rpc-multipart-' are tracked so that the mock can +// report uploads that were never created, or were already completed or aborted. The first upload +// for a key gets the ID '-id', and later uploads for the same key get '-id-'. +const activeMultipartUploads = new Map(); +const multipartUploadCounts = new Map(); + +function handleTrackedMultipartRequest(jsonRequest, uploadedBody) { + const requestKey = jsonRequest.object; + const { uploadId } = jsonRequest; + const isActive = activeMultipartUploads.get(uploadId) === requestKey; + switch (jsonRequest.method) { + case 'createMultipartUpload': { + // Metadata is captured when createMultipartUpload() is called, so later changes to the + // caller's objects are not sent. + if (jsonRequest.customFields !== undefined) { + assert.deepStrictEqual(jsonRequest.customFields, [ + { k: 'uploadKey', v: requestKey }, + ]); + assert.deepStrictEqual(jsonRequest.httpFields, httpFields); + } + const count = (multipartUploadCounts.get(requestKey) ?? 0) + 1; + multipartUploadCounts.set(requestKey, count); + const newUploadId = + count === 1 ? `${requestKey}-id` : `${requestKey}-id-${count}`; + activeMultipartUploads.set(newUploadId, requestKey); + return Response.json({ uploadId: newUploadId }); + } + case 'uploadPart': + if (!isActive) { + return r2ErrorResponse(uploadNotFoundError); + } + assert.strictEqual(uploadedBody, body); + return Response.json({ + etag: `${requestKey}/${uploadId}/${jsonRequest.partNumber}`, + }); + case 'completeMultipartUpload': + if (!isActive) { + return r2ErrorResponse(uploadNotFoundError); + } + for (const { part, etag } of jsonRequest.parts ?? []) { + assert.strictEqual(etag, `${requestKey}/${uploadId}/${part}`); + } + activeMultipartUploads.delete(uploadId); + return Response.json({ + ...objResponse, + name: requestKey, + version: uploadId, + }); + case 'abortMultipartUpload': + // Aborting an upload that no longer exists succeeds. + if (isActive) { + activeMultipartUploads.delete(uploadId); + } + return new Response(); + } + throw new Error(`Unexpected method: ${jsonRequest.method}`); +} + +async function handleStringUploadRequest(request, jsonRequest, metadataSize) { + const expected = stringUploadCases[jsonRequest.object]; + const suffix = new TextEncoder().encode(expected.suffix); + const asciiLength = expected.size - suffix.byteLength; + assert.strictEqual( + request.headers.get('content-length'), + String(metadataSize + expected.size) + ); + + // Check every byte without retaining another full copy of the large upload. + const ascii = new Uint8Array(1 << 20).fill(0x61); + const reader = request.body.getReader({ mode: 'byob' }); + let received = 0; + try { + for (;;) { + const { value: chunk, done } = await reader.readAtLeast( + ascii.length, + new Uint8Array(ascii.length) + ); + if (done) break; + const asciiEnd = Math.min( + chunk.length, + Math.max(asciiLength - received, 0) + ); + assert.strictEqual( + Buffer.compare( + chunk.subarray(0, asciiEnd), + ascii.subarray(0, asciiEnd) + ), + 0 + ); + const suffixStart = Math.max(received - asciiLength, 0); + assert.deepStrictEqual( + chunk.subarray(asciiEnd), + suffix.subarray(suffixStart, suffixStart + chunk.length - asciiEnd) + ); + received += chunk.length; + } + } finally { + reader.releaseLock(); + } + assert.strictEqual(received, expected.size); + + if (jsonRequest.method === 'put') { + return Response.json({ + ...objResponse, + name: jsonRequest.object, + size: String(received), + }); + } + assert.strictEqual(jsonRequest.method, 'uploadPart'); + assert.strictEqual(jsonRequest.uploadId, 'string-upload-id'); + assert.strictEqual(jsonRequest.partNumber, 1); + return Response.json({ etag: `string-upload-${received}` }); +} -function buildGetResponse({ head, body, isList } = {}) { +// An R2 error makes this an error response, such as a failed precondition that still carries the +// object's metadata but no body. +function buildGetResponse({ head, body, isList, error } = {}) { const encoder = new TextEncoder(); let meta; if (!isList) { @@ -107,17 +266,51 @@ function buildGetResponse({ head, body, isList } = {}) { }, }) : metadata; + const headers = { + 'cf-r2-metadata-size': metadata.length.toString(), + 'content-length': metadata.length.toString(), + }; + if (error === undefined) { + return new Response(responseBody, { headers }); + } return new Response(responseBody, { - headers: { - 'cf-r2-metadata-size': metadata.length.toString(), - 'content-length': metadata.length.toString(), - }, + status: error.status, + headers: { ...headers, ...r2ErrorHeaders(error) }, }); } + +// With the fastJsgStruct compatibility flag, a JSG struct such as R2HTTPMetadata or R2Range has +// every optional field as an own property, set to undefined when the field is unset. Without the +// flag, unset fields are absent. Dropping unset fields lets an assertion check the same shape in +// both modes. +function withoutUnsetFields(struct) { + return Object.fromEntries( + Object.entries(struct).filter(([, value]) => value !== undefined) + ); +} + +// Copies a list() result into plain objects that compare the same in both JSG struct modes. +function plainListResult(list) { + return { + ...withoutUnsetFields(list), + objects: list.objects.map((object) => { + const plain = { ...object, checksums: { ...object.checksums } }; + if (plain.httpMetadata !== undefined) { + plain.httpMetadata = withoutUnsetFields(plain.httpMetadata); + } + return plain; + }), + }; +} + async function compareResponse(res, { head, body } = {}, bytes) { // Destructuring syntax looks ugly, but gets around needing to construct HeadResponse objects(somehow?) const { ...obj } = await res; obj.checksums = { ...obj.checksums }; + obj.httpMetadata = withoutUnsetFields(obj.httpMetadata); + if (obj.range !== undefined) { + obj.range = withoutUnsetFields(obj.range); + } assert.deepEqual(obj, { ...HeadObject, ...head, @@ -136,7 +329,11 @@ async function compareResponse(res, { head, body } = {}, bytes) { } } -export default { +// TODO: Let each test configure expected calls and responses shared by both transports. +// For example, head('missing') -> not found should produce HTTP 404 or JSRPC null. +// Each adapter should verify the method and arguments, reject unexpected calls, and encode +// the configured response in its transport's format. +const testWorker = { // Handler for HTTP request binding makes to R2 async fetch(request, env, ctx) { // We only expect PUT/Get @@ -158,20 +355,51 @@ export default { reader.releaseLock(); const jsonRequest = JSON.parse(new TextDecoder().decode(value)); + if (jsonRequest.object === 'must-not-call') { + assert.fail('R2 backend must not be called for invalid input'); + } + if (Object.hasOwn(stringUploadCases, jsonRequest.object)) { + return handleStringUploadRequest(request, jsonRequest, metadataSize); + } - // The rest of the body is the object payload. - const decoder = new TextDecoder(); - let payload = ''; + // The metadata has already been read from the body, so a Response cannot wrap it. + const chunks = []; for await (const chunk of request.body) { - payload += decoder.decode(chunk, { stream: true }); + chunks.push(chunk); } - payload += decoder.decode(); + const uploadedBody = await new Blob(chunks).text(); // Assert it's the correct version assert.strictEqual(jsonRequest.version, 1); if (jsonRequest.method === 'delete') { + if (jsonRequest.object === 'boom') { + return r2ErrorResponse({ + status: 400, + v4code: 10021, + message: 'bad keys', + }); + } if (jsonRequest.objects) { + // A batch delete can report individual key failures in a successful HTTP response. + // R2Bucket::delete_() reads this body from the legacy transport but does not expose it. + if (jsonRequest.objects[0] === perKeyErrorDeleteKeys[0]) { + assert.deepEqual(jsonRequest.objects, perKeyErrorDeleteKeys); + return Response.json( + [ + { name: 'existing' }, + { + name: 'missing', + error: { + version: 1, + v4code: 10007, + message: 'The specified key does not exist.', + }, + }, + ], + { status: 200 } + ); + } assert.deepEqual(jsonRequest.objects, [key, key + '2']); } else { assert.deepEqual(jsonRequest.object, key); @@ -179,8 +407,18 @@ export default { return new Response(); } + if (jsonRequest.object.startsWith('rpc-multipart-')) { + return handleTrackedMultipartRequest(jsonRequest, uploadedBody); + } + switch (jsonRequest.object) { case 'basicKey': { + if (jsonRequest.uploadId !== undefined) { + assert( + ['multipartId', 'resumedId'].includes(jsonRequest.uploadId) + ); + } + const resumed = jsonRequest.uploadId === 'resumedId'; switch (jsonRequest.method) { case 'put': { break; @@ -192,18 +430,38 @@ export default { } case 'uploadPart': { return Response.json({ - etag: 'partEtag', + etag: resumed ? 'resumedRpcPartEtag' : 'partEtag', }); } case 'abortMultipartUpload': { return new Response(); } case 'completeMultipartUpload': { - return Response.json(objResponse); + return Response.json( + resumed + ? { ...objResponse, version: 'resumedRpcObjectVersion' } + : objResponse + ); } } break; } + case 'rpcStream': { + assert.strictEqual(uploadedBody, rpcStreamBody); + if (jsonRequest.method === 'uploadPart') { + assert.strictEqual(jsonRequest.uploadId, 'streamedId'); + return Response.json({ etag: 'partEtag' }); + } + assert.strictEqual(jsonRequest.method, 'put'); + break; + } + case 'rpc-conditional-null': { + assert.strictEqual(jsonRequest.method, 'put'); + assert.deepStrictEqual(jsonRequest.onlyIf, { + etagMatches: [{ value: 'strongEtag', type: 'strong' }], + }); + return r2ErrorResponse(preconditionFailedError); + } case 'onlyIfStrongEtag': { assert.deepStrictEqual(jsonRequest.onlyIf, { etagMatches: [ @@ -238,6 +496,19 @@ export default { }); break; } + case 'onlyIfMultipleEtags': { + assert.deepStrictEqual(jsonRequest.onlyIf, { + etagMatches: [ + { value: 'strongEtag', type: 'strong' }, + { value: 'weakEtag', type: 'weak' }, + ], + etagDoesNotMatch: [ + { value: 'firstEtag', type: 'strong' }, + { value: 'secondEtag', type: 'strong' }, + ], + }); + break; + } case 'httpMetadata': { if (jsonRequest.method !== 'completeMultipartUpload') { assert.deepEqual(jsonRequest.httpFields, httpFields); @@ -254,7 +525,7 @@ export default { uploadId: 'multipartId', }); case 'completeMultipartUpload': { - return Response.json(head); + return Response.json(objResponse); } } } @@ -275,7 +546,7 @@ export default { uploadId: 'multipartId', }); case 'completeMultipartUpload': - return Response.json(head); + return Response.json(objResponse); } } // falls through @@ -396,7 +667,7 @@ export default { } case 'blobBody': { assert.strictEqual(jsonRequest.method, 'put'); - assert.strictEqual(payload, body); + assert.strictEqual(uploadedBody, body); break; } } @@ -406,72 +677,166 @@ export default { const rawHeader = request.headers.get('cf-r2-request'); const jsonRequest = JSON.parse(rawHeader); assert.strictEqual(jsonRequest.version, 1); + if (jsonRequest.object === 'must-not-call') { + assert.fail('R2 backend must not be called for invalid input'); + } if (jsonRequest.method === 'list') { + // Without r2_list_honor_include, the binding always asks for both kinds of metadata. + // Each config tells the mock which behavior its R2 bindings use. + const honorsIncludes = env.R2_LIST_HONOR_INCLUDE !== 'false'; + const { include = [], ...listRequest } = jsonRequest; + const assertInclude = (requested) => + assert.deepStrictEqual( + include, + honorsIncludes ? requested : [0, 1] + ); + const listResponse = (result) => + buildGetResponse({ head: result, isList: true }); switch (jsonRequest.prefix) { + case undefined: { + assertInclude([]); + assert.deepStrictEqual(listRequest, { + method: 'list', + newRuntime: true, + version: 1, + }); + return listResponse({ objects: [], truncated: false }); + } case 'basic': { - assert.deepEqual(jsonRequest, { + assertInclude([0, 1]); + assert.deepEqual(listRequest, { cursor: 'ai', delimiter: '/', - include: [0, 1], limit: 1, method: 'list', newRuntime: true, prefix: 'basic', version: 1, }); - return buildGetResponse({ - head: { - objects: [objResponse], - truncated: true, - cursor: 'ai', - deliminatedPrefixes: [], - }, - isList: true, + return listResponse({ + objects: [objResponse], + truncated: true, + cursor: 'ai', }); } case 'httpMeta': { - assert.deepEqual(jsonRequest, { - include: [0], + assert.deepEqual(listRequest, { method: 'list', newRuntime: true, prefix: 'httpMeta', version: 1, }); - - return buildGetResponse({ - head: { - objects: [{ ...objResponse, httpFields, customFields: [] }], + // An empty include list returns objects without optional metadata. + if (include.length === 0) { + return listResponse({ + objects: [objResponse], truncated: false, - deliminatedPrefixes: [], - }, - isList: true, + }); + } + assertInclude([0]); + return listResponse({ + objects: [{ ...objResponse, httpFields, customFields: [] }], + truncated: false, }); } case 'customMeta': { - assert.deepEqual(jsonRequest, { - include: [1], + assertInclude([1]); + assert.deepEqual(listRequest, { method: 'list', newRuntime: true, prefix: 'customMeta', version: 1, }); - - return buildGetResponse({ - head: { - objects: [{ ...objResponse, httpFields: {}, customFields }], - truncated: false, - deliminatedPrefixes: [], - }, - isList: true, + return listResponse({ + objects: [{ ...objResponse, httpFields: {}, customFields }], + truncated: false, + }); + } + // Returns only the kinds of metadata that the request includes, which are empty. + case 'rpc-metadata': { + assert.deepStrictEqual(listRequest, { + method: 'list', + newRuntime: true, + prefix: 'rpc-metadata', + version: 1, + }); + return listResponse({ + objects: [ + { + ...objResponse, + ...(include.includes(0) && { httpFields: {} }), + ...(include.includes(1) && { customFields: [] }), + }, + ], + truncated: false, + }); + } + case 'rpc-options': { + assertInclude([]); + assert.deepStrictEqual(listRequest, { + cursor: 'cursor-in', + delimiter: '/', + limit: 2, + method: 'list', + newRuntime: true, + prefix: 'rpc-options', + startAfter: 'after', + version: 1, + }); + return listResponse({ + objects: [], + truncated: true, + cursor: 'cursor-out', + delimitedPrefixes: ['rpc-options/'], }); } + case 'rpc-boom': + return r2ErrorResponse(noSuchBucketError); } + throw new Error(`Unexpected list prefix: ${jsonRequest.prefix}`); } assert(['get', 'head'].includes(jsonRequest.method)); switch (jsonRequest.object) { - case 'basicKey': { + case 'basicKey': + case 'rpc-lazy-body': + case 'rpc-bytes': + case 'rpc-array-buffer': + case 'rpc-blob': { return buildGetResponse({ body }); } + case 'missing': + return r2ErrorResponse(noSuchKeyError); + case 'boom': + case 'rpc-get-boom': + return r2ErrorResponse(noSuchBucketError); + // The binding converts a non-string key to a string. + case '12345': { + assert.strictEqual(jsonRequest.method, 'head'); + return buildGetResponse(); + } + case 'ranged': { + assert.strictEqual(jsonRequest.method, 'head'); + return buildGetResponse({ + head: { range: { offset: '10', length: '20' } }, + }); + } + case 'rpc-json': { + assert.strictEqual(jsonRequest.method, 'get'); + const jsonBody = JSON.stringify({ ok: true }); + return buildGetResponse({ + head: { size: String(jsonBody.length) }, + body: jsonBody, + }); + } + case 'rpc-conditional-metadata': { + assert.strictEqual(jsonRequest.method, 'get'); + if (jsonRequest.onlyIf !== undefined) { + assert.deepStrictEqual(jsonRequest.onlyIf, { + etagMatches: [{ value: 'objectEtag', type: 'strong' }], + }); + } + return buildGetResponse({ error: preconditionFailedError }); + } case 'rangeOffLen': { assert.deepEqual(jsonRequest.range, { offset: '1', @@ -491,7 +856,7 @@ export default { return buildGetResponse({ head: { range: { - offset: '6', + offset: '5', length: '2', }, }, @@ -532,6 +897,19 @@ export default { }); return buildGetResponse({ body }); } + case 'onlyIfMultipleEtags': { + assert.deepStrictEqual(jsonRequest.onlyIf, { + etagMatches: [ + { value: 'strongEtag', type: 'strong' }, + { value: 'weakEtag', type: 'weak' }, + ], + etagDoesNotMatch: [ + { value: 'firstEtag', type: 'strong' }, + { value: 'secondEtag', type: 'strong' }, + ], + }); + return buildGetResponse({ body }); + } case 'httpMetadata': { const head = { httpFields, @@ -630,7 +1008,10 @@ export default { // GetObject(.bytes()) await compareResponse(env.BUCKET.get(key), { body }, true); // HeadObject - await compareResponse(env.BUCKET.head(key)); + const headObject = await env.BUCKET.head(key); + await compareResponse(headObject); + assert.strictEqual(typeof headObject.writeHttpMetadata, 'function'); + assert.strictEqual(typeof headObject.checksums.toJSON, 'function'); // MultipartUploads { // CreateMultipartUpload @@ -661,9 +1042,7 @@ export default { delimiter: '/', include: ['httpMetadata', 'customMetadata'], }); - list.objects[0] = { ...list.objects[0] }; - list.objects[0].checksums = { ...list.objects[0].checksums }; - assert.deepEqual(list, { + assert.deepEqual(plainListResult(list), { objects: [HeadObject], truncated: true, cursor: 'ai', @@ -672,8 +1051,11 @@ export default { } // DeleteObject { - await env.BUCKET.delete(key); - await env.BUCKET.delete([key, 'basicKey2']); + assert.strictEqual(await env.BUCKET.delete(key), undefined); + assert.strictEqual( + await env.BUCKET.delete([key, 'basicKey2']), + undefined + ); } } // Ranged Reads @@ -704,7 +1086,7 @@ export default { { head: { range: { - offset: 6, + offset: 5, length: 2, }, }, @@ -714,26 +1096,22 @@ export default { } // Conditionals { - try { - await env.BUCKET.put('throwOnInvalidEtag', body, { + await assert.rejects( + env.BUCKET.put('throwOnInvalidEtag', body, { onlyIf: new Headers({ 'if-match': 'strongEtag', }), - }); - throw new Error('This should have thrown'); - } catch { - // intentionally empty - } - try { - await env.BUCKET.put('throwOnInvalidEtag', body, { + }), + { message: 'Invalid ETag in if-match header' } + ); + await assert.rejects( + env.BUCKET.put('throwOnInvalidEtag', body, { onlyIf: new Headers({ 'if-none-match': 'strongEtag', }), - }); - throw new Error('This should have thrown'); - } catch { - // intentionally empty - } + }), + { message: 'Invalid ETag in if-none-match header' } + ); await env.BUCKET.put('onlyIfStrongEtag', body, { onlyIf: { etagMatches: 'strongEtag', @@ -755,6 +1133,16 @@ export default { uploadedBefore: new Date('0'), }, }); + const multipleEtagHeaders = new Headers({ + 'if-match': '"strongEtag", W/"weakEtag"', + 'if-none-match': '"firstEtag", "secondEtag"', + }); + await env.BUCKET.put('onlyIfMultipleEtags', body, { + onlyIf: multipleEtagHeaders, + }); + await env.BUCKET.get('onlyIfMultipleEtags', { + onlyIf: multipleEtagHeaders, + }); await env.BUCKET.get('onlyIfWildcard', { onlyIf: { etagMatches: '*', @@ -797,9 +1185,7 @@ export default { prefix: 'httpMeta', include: ['httpMetadata'], }); - list.objects[0] = { ...list.objects[0] }; - list.objects[0].checksums = { ...list.objects[0].checksums }; - assert.deepEqual(list, { + assert.deepEqual(plainListResult(list), { delimitedPrefixes: [], objects: [{ ...HeadObject, ...head }], truncated: false, @@ -811,8 +1197,7 @@ export default { await env.BUCKET.createMultipartUpload('httpMetadata', { httpMetadata, }) - ).complete([]), - { head } + ).complete([]) ); } // customMetadata @@ -842,9 +1227,7 @@ export default { prefix: 'customMeta', include: ['customMetadata'], }); - list.objects[0] = { ...list.objects[0] }; - list.objects[0].checksums = { ...list.objects[0].checksums }; - assert.deepEqual(list, { + assert.deepEqual(plainListResult(list), { delimitedPrefixes: [], objects: [{ ...HeadObject, ...head }], truncated: false, @@ -856,8 +1239,7 @@ export default { await env.BUCKET.createMultipartUpload('customMetadata', { customMetadata, }) - ).complete([]), - { head } + ).complete([]) ); } } @@ -962,7 +1344,451 @@ export default { // Also test HEAD operation to verify checksum tags const headResp = await env.BUCKET.head('multipleChecksums'); - assert.ok(headResp); + assert.deepStrictEqual(new Uint8Array(headResp.checksums.md5), md5Buffer); + assert.deepStrictEqual( + new Uint8Array(headResp.checksums.sha1), + sha1Buffer + ); + assert.deepStrictEqual( + new Uint8Array(headResp.checksums.sha256), + sha256Buffer + ); + assert.deepStrictEqual(withoutUnsetFields(headResp.checksums.toJSON()), { + md5: '9a0364b9e99bb480dd25e1f0284c8555', + sha1: '2a0364b9e99bb480dd25e1f0284c855511223344', + sha256: + '3a0364b9e99bb480dd25e1f0284c8555112233445566778899aabbccddeeff00', + }); + } + }, +}; + +// Test that documents the behaviour of multi-key deletion where one fails and the other succeeds +export const deletePerKeyErrorParityTests = { + async test(_ctrl, env) { + if (env.R2_TRANSPORT !== 'http' && env.R2_TRANSPORT !== 'jsrpc') { + return; + } + + // Both transports implement Promise: the legacy path discards the successful HTTP body, + // while the RPC path receives the corresponding void result from its backend entrypoint. + const deletion = env.BUCKET.delete(perKeyErrorDeleteKeys); + assert(deletion instanceof Promise); + assert.strictEqual(await deletion, undefined); + }, +}; + +export const r2ValidationTests = { + async test(ctrl, env) { + await assert.rejects( + env.BUCKET.get('must-not-call', { range: { offset: -1 } }), + { + message: + 'Invalid range. Starting offset (-1) must be greater than or equal to 0.', + } + ); + await assert.rejects( + env.BUCKET.get('must-not-call', { range: { suffix: 1, offset: 0 } }), + { name: 'TypeError', message: 'Suffix is incompatible with offset.' } + ); + await assert.rejects( + env.BUCKET.get('must-not-call', { range: { suffix: 1, length: 1 } }), + { name: 'TypeError', message: 'Suffix is incompatible with length.' } + ); + for (const field of ['offset', 'length', 'suffix']) { + for (const value of [-1, 0.5, NaN]) { + await assert.rejects( + env.BUCKET.get('must-not-call', { range: { [field]: value } }), + { name: 'RangeError' } + ); + } + } + await assert.rejects(env.BUCKET.get('must-not-call', { ssecKey: 'bad' }), { + message: 'SSE-C Key must be 32 bytes in length', + }); + await assert.rejects( + env.BUCKET.get('must-not-call', { + onlyIf: { etagMatches: '"quoted"' }, + }), + { + message: 'Conditional ETag should not be wrapped in quotes ("quoted").', + } + ); + + await assert.rejects( + env.BUCKET.put('must-not-call', body, { md5: new Uint8Array(1) }), + { message: 'MD5 is 16 bytes, not 1' } + ); + await assert.rejects( + env.BUCKET.put('must-not-call', body, { + md5: 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz', + }), + { message: "Provided MD5 wasn't a valid hex string" } + ); + await assert.rejects( + env.BUCKET.put('must-not-call', body, { + md5: md5Buffer, + sha1: sha1Buffer, + }), + { message: 'You cannot specify multiple hashing algorithms.' } + ); + + if (env.R2_TRANSPORT === 'jsrpc') { + await assert.rejects( + env.BUCKET.put('must-not-call', body, { + onlyIf: { etagMatches: '"quoted"' }, + }), + { + message: + 'Conditional ETag should not be wrapped in quotes ("quoted").', + } + ); + } else { + // The HTTP client owns this validation and cancels a stream it cannot send. The JSRPC + // client forwards the call, so stream cancellation is instead owned by the backend and the + // generic RPC stream transport. + const invalidOptionsStream = new FixedLengthStream(1); + const invalidOptionsWriter = invalidOptionsStream.writable.getWriter(); + const invalidOptionsWriting = invalidOptionsWriter.write( + new Uint8Array([1]) + ); + await assert.rejects( + env.BUCKET.put('must-not-call', invalidOptionsStream.readable, { + onlyIf: { etagMatches: '"quoted"' }, + }), + { + message: + 'Conditional ETag should not be wrapped in quotes ("quoted").', + } + ); + await assert.rejects(invalidOptionsWriting, { + message: + 'Stream cancelled because the associated put operation encountered an error.', + }); } + + await assert.rejects( + env.BUCKET.createMultipartUpload('must-not-call', { + ssecKey: 'bad', + }), + { message: 'SSE-C Key must be 32 bytes in length' } + ); + const invalidPartUpload = env.BUCKET.resumeMultipartUpload( + 'must-not-call', + 'invalidPartUploadId' + ); + await assert.rejects( + invalidPartUpload.uploadPart(1, body, { ssecKey: 'bad' }), + { message: 'SSE-C Key must be 32 bytes in length' } + ); + await assert.rejects( + invalidPartUpload.complete([{ partNumber: 0, etag: 'invalid' }]), + { + message: + 'Part number must be between 1 and 10000 (inclusive). Actual value was: 0', + } + ); }, }; + +// Public R2 binding behavior that both transports implement the same way. The JSRPC configs run +// these through ObservedR2Binding, so results and errors come from the same HTTP mock as the HTTP +// config. +export const r2BindingApiTests = { + async test(ctrl, env) { + assert.strictEqual(await env.BUCKET.head('missing'), null); + assert.strictEqual(await env.BUCKET.get('missing'), null); + + const ranged = await env.BUCKET.head('ranged'); + assert.deepStrictEqual(withoutUnsetFields(ranged.range), { + offset: 10, + length: 20, + }); + + const coerced = await env.BUCKET.head(12345); + assert.strictEqual(coerced.key, key); + + // Backend errors surface as plain Errors, without an R2Error code property. + await assert.rejects(env.BUCKET.head('boom'), (err) => { + assert.strictEqual(err.message, 'head: no such bucket (10006)'); + assert.strictEqual(err.code, undefined); + return true; + }); + await assert.rejects(env.BUCKET.get('rpc-get-boom'), { + message: 'get: no such bucket (10006)', + }); + await assert.rejects(env.BUCKET.delete('boom'), { + message: 'delete: bad keys (10021)', + }); + + const conditional = await env.BUCKET.get('rpc-conditional-metadata', { + onlyIf: { etagMatches: 'objectEtag' }, + }); + assert.strictEqual(conditional.body, undefined); + assert.deepStrictEqual(withoutUnsetFields(conditional.httpMetadata), {}); + assert.deepStrictEqual(conditional.customMetadata, {}); + assert.strictEqual(typeof conditional.writeHttpMetadata, 'function'); + + const lazyBody = await env.BUCKET.get('rpc-lazy-body'); + assert.strictEqual(lazyBody.bodyUsed, false); + assert(lazyBody.body instanceof ReadableStream); + assert.strictEqual(await lazyBody.text(), body); + assert.strictEqual(lazyBody.bodyUsed, true); + + if (env.R2_TRANSPORT === 'jsrpc') { + const copySource = await env.BUCKET.get('rpc-lazy-body'); + const copied = await env.BUCKET.put('basicKey', copySource.body); + assert.strictEqual(copied.size, body.length); + assert.strictEqual(copySource.bodyUsed, true); + } + + const bytesResult = await env.BUCKET.get('rpc-bytes'); + assert.deepStrictEqual( + await bytesResult.bytes(), + new TextEncoder().encode(body) + ); + assert.strictEqual(bytesResult.bodyUsed, true); + const bufferResult = await env.BUCKET.get('rpc-array-buffer'); + assert.deepStrictEqual( + new Uint8Array(await bufferResult.arrayBuffer()), + new TextEncoder().encode(body) + ); + const jsonResult = await env.BUCKET.get('rpc-json'); + assert.deepStrictEqual(await jsonResult.json(), { ok: true }); + const blobResult = await env.BUCKET.get('rpc-blob'); + assert.strictEqual(await (await blobResult.blob()).text(), body); + + const emptyList = await env.BUCKET.list(); + assert.deepStrictEqual(plainListResult(emptyList), { + objects: [], + truncated: false, + delimitedPrefixes: [], + }); + + const optionList = await env.BUCKET.list({ + limit: 2, + prefix: 'rpc-options', + cursor: 'cursor-in', + delimiter: '/', + startAfter: 'after', + include: [], + }); + assert.deepStrictEqual(plainListResult(optionList), { + objects: [], + truncated: true, + cursor: 'cursor-out', + delimitedPrefixes: ['rpc-options/'], + }); + + if (env.R2_LIST_HONOR_INCLUDE === 'true') { + for (const include of [ + [], + ['httpMetadata'], + ['customMetadata'], + ['httpMetadata', 'customMetadata'], + ]) { + const listed = await env.BUCKET.list({ + prefix: 'rpc-metadata', + include, + }); + const object = listed.objects[0]; + assert.strictEqual(typeof object.writeHttpMetadata, 'function'); + assert.strictEqual(typeof object.checksums.toJSON, 'function'); + assert.strictEqual( + object.httpMetadata === undefined, + !include.includes('httpMetadata') + ); + assert.strictEqual( + object.customMetadata === undefined, + !include.includes('customMetadata') + ); + if (include.includes('httpMetadata')) { + assert.deepStrictEqual(withoutUnsetFields(object.httpMetadata), {}); + } + if (include.includes('customMetadata')) { + assert.deepStrictEqual(object.customMetadata, {}); + } + } + } + + await assert.rejects(env.BUCKET.list({ prefix: 'rpc-boom' }), { + message: 'list: no such bucket (10006)', + }); + + assert.strictEqual( + await env.BUCKET.put('rpc-conditional-null', body, { + onlyIf: { etagMatches: 'strongEtag' }, + }), + null + ); + + const encodedStreamBody = new TextEncoder().encode(rpcStreamBody); + { + const { readable, writable } = new FixedLengthStream( + encodedStreamBody.byteLength + ); + const writer = writable.getWriter(); + const writing = writer + .write(encodedStreamBody) + .then(() => writer.close()); + const result = await env.BUCKET.put('rpcStream', readable); + await writing; + assert.strictEqual(result.size, Number(objResponse.size)); + } + + let unknownLengthCancelReason; + await assert.rejects( + env.BUCKET.put( + 'unknownLengthStream', + new ReadableStream({ + cancel(reason) { + unknownLengthCancelReason = reason; + }, + }) + ), + { + message: + 'Provided readable stream must have a known length (request/response body or readable half of FixedLengthStream)', + } + ); + // The direct HTTP path leaves a validation-failed stream with its caller. Releasing an + // RPC-transferred stream is handled by the RPC transport. + if (env.R2_TRANSPORT !== 'jsrpc') { + assert.strictEqual(unknownLengthCancelReason, undefined); + } + }, +}; + +// Multipart behavior that both transports implement the same way. See r2BindingApiTests. +export const r2MultipartApiTests = { + async test(ctrl, env) { + const resumed = env.BUCKET.resumeMultipartUpload(key, 'resumedId'); + assert.strictEqual(resumed.key, key); + assert.strictEqual(resumed.uploadId, 'resumedId'); + const resumedPart = await resumed.uploadPart(1, body); + assert.deepStrictEqual(resumedPart, { + partNumber: 1, + etag: 'resumedRpcPartEtag', + }); + const resumedObject = await resumed.complete([resumedPart]); + assert.strictEqual(resumedObject.key, key); + assert.strictEqual(resumedObject.version, 'resumedRpcObjectVersion'); + assert.strictEqual(resumedObject.etag, objResponse.etag); + assert.strictEqual(resumedObject.httpEtag, `"${objResponse.etag}"`); + assert.strictEqual(resumedObject.size, Number(objResponse.size)); + assert(resumedObject.uploaded instanceof Date); + assert.strictEqual(resumedObject.storageClass, objResponse.storageClass); + const resumedHeaders = new Headers(); + resumedObject.writeHttpMetadata(resumedHeaders); + assert.deepStrictEqual([...resumedHeaders], []); + assert.strictEqual(typeof resumedObject.checksums.toJSON, 'function'); + assert.strictEqual(JSON.stringify(resumedObject.checksums), '{}'); + + const encodedStreamBody = new TextEncoder().encode(rpcStreamBody); + const streamedUpload = env.BUCKET.resumeMultipartUpload( + 'rpcStream', + 'streamedId' + ); + const { readable, writable } = new FixedLengthStream( + encodedStreamBody.byteLength + ); + const writer = writable.getWriter(); + const writing = writer.write(encodedStreamBody).then(() => writer.close()); + const streamedPart = await streamedUpload.uploadPart(2, readable); + await writing; + assert.deepStrictEqual(streamedPart, { + partNumber: 2, + etag: 'partEtag', + }); + + // Each operation carries its key and upload ID, including interleaved calls on the same key. + const uploads = []; + for (const uploadKey of ['rpc-multipart-first', 'rpc-multipart-second']) { + const headers = new Headers(httpMetaHeaders); + const metadata = { uploadKey }; + const creating = env.BUCKET.createMultipartUpload(uploadKey, { + httpMetadata: headers, + customMetadata: metadata, + }); + headers.set('content-type', 'application/octet-stream'); + metadata.uploadKey = 'mutated'; + const upload = await creating; + assert.strictEqual(upload.key, uploadKey); + assert.strictEqual(upload.uploadId, `${uploadKey}-id`); + uploads.push(upload); + } + // More uploads for the same key, addressed through wrappers resumed from their IDs. + for (const uploadId of [ + 'rpc-multipart-first-id-2', + 'rpc-multipart-first-id-3', + ]) { + const created = await env.BUCKET.createMultipartUpload( + 'rpc-multipart-first' + ); + assert.strictEqual(created.uploadId, uploadId); + const upload = env.BUCKET.resumeMultipartUpload( + 'rpc-multipart-first', + uploadId + ); + assert.strictEqual(upload.key, 'rpc-multipart-first'); + assert.strictEqual(upload.uploadId, uploadId); + uploads.push(upload); + } + const uploadedParts = await Promise.all( + uploads.map(async (upload) => { + const first = await upload.uploadPart(1, body); + const rest = await Promise.all([ + upload.uploadPart(2, body), + upload.uploadPart(3, body), + ]); + const parts = [first, ...rest]; + assert.deepStrictEqual( + parts, + [1, 2, 3].map((partNumber) => ({ + partNumber, + etag: `${upload.key}/${upload.uploadId}/${partNumber}`, + })) + ); + return parts; + }) + ); + + for (let i = 0; i < uploads.length; i++) { + const upload = uploads[i]; + const result = await upload.complete(uploadedParts[i]); + assert.strictEqual(result.key, upload.key); + assert.strictEqual(result.version, upload.uploadId); + // Created and resumed uploads both use the metadata returned by the backend. + assert.deepStrictEqual(result.customMetadata, {}); + const resultHeaders = new Headers(); + result.writeHttpMetadata(resultHeaders); + assert.deepStrictEqual([...resultHeaders], []); + } + + const aborted = await env.BUCKET.createMultipartUpload( + 'rpc-multipart-aborted' + ); + await aborted.uploadPart(1, body); + // Abort must address the same upload when called through a separately resumed wrapper. + await env.BUCKET.resumeMultipartUpload( + aborted.key, + aborted.uploadId + ).abort(); + const missing = env.BUCKET.resumeMultipartUpload( + 'rpc-multipart-missing', + 'missing' + ); + for (const upload of [aborted, missing]) { + await assert.rejects(upload.uploadPart(2, body), { + message: 'uploadPart: Upload not found (10024)', + }); + await assert.rejects(upload.complete([]), { + message: 'completeMultipartUpload: Upload not found (10024)', + }); + await upload.abort(); + await upload.abort(); + } + }, +}; + +export default testWorker; diff --git a/src/workerd/api/tests/r2-test.wd-test b/src/workerd/api/tests/r2-test.wd-test index 169c069cefa..b0719d7fce9 100644 --- a/src/workerd/api/tests/r2-test.wd-test +++ b/src/workerd/api/tests/r2-test.wd-test @@ -5,13 +5,16 @@ const unitTests :Workerd.Config = ( ( name = "r2-test", worker = ( modules = [ - ( name = "worker", esModule = embed "r2-test.js" ) + ( name = "worker", esModule = embed "r2-instrumentation-worker.js" ), + ( name = "r2-test.js", esModule = embed "r2-test.js" ), ], bindings = [ ( name = "BUCKET", r2Bucket = "r2-test" ), ( name = "SERVICE", service = "r2-test" ), + ( name = "R2_TRANSPORT", text = "http" ), + ( name = "R2_LIST_HONOR_INCLUDE", text = "true" ), ], - compatibilityFlags = ["nodejs_compat", "service_binding_extra_handlers", "streams_enable_constructors", "r2_list_honor_include", "disable_fast_jsg_struct"], + compatibilityFlags = ["nodejs_compat", "service_binding_extra_handlers", "streams_enable_constructors", "r2_list_honor_include"], streamingTails = ["tail"], ) ), @@ -28,5 +31,5 @@ const tailWorker :Workerd.Worker = ( modules = [ (name = "worker", esModule = embed "r2-instrumentation-test.js") ], - compatibilityFlags = ["experimental", "nodejs_compat", "disable_fast_jsg_struct"], + compatibilityFlags = ["experimental", "nodejs_compat"], ); diff --git a/src/workerd/api/tests/tsconfig.json b/src/workerd/api/tests/tsconfig.json index a0e91d3a349..38a713f5175 100644 --- a/src/workerd/api/tests/tsconfig.json +++ b/src/workerd/api/tests/tsconfig.json @@ -9,5 +9,8 @@ "allowJs": false, "types": ["@types/node"] }, - "include": ["**/*.ts"] + "include": ["**/*.ts"], + // r2-binding-service has its own project; the other entries repeat the base config's exclude, + // which this one replaces. + "exclude": ["node_modules", "**/*.mjs", "r2-binding-service"] } diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index 81904ebb86a..01078afa5ec 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1761,4 +1761,18 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { # A compiled `WebAssembly.Module` passed to the Worker Loader is recompiled when the loaded Worker # or the Worker that compiled it has this flag, so its frames follow the loaded Worker's setting # and module name. + + r2BindingsJsrpc @200 :Bool + $compatEnableFlag("r2_binding_jsrpc") + $experimental; + # When enabled, R2 bindings dispatch to the R2 gateway worker over JSRPC + # instead of synthesising an HTTP request and calling `fetch`. Without this + # flag, R2 bindings continue to use the HTTP transport. + # + # The binding must specify a named entrypoint implementing the R2 backend + # methods: head, get, put, delete, list, createMultipartUpload, uploadPart, + # abortMultipartUpload, and completeMultipartUpload. Object results use the + # corresponding native R2 API types; createMultipartUpload returns an object + # containing uploadId. The expected TypeScript interface is type-checked in + # src/workerd/api/tests/r2-binding-service/r2-binding-service.ts. } diff --git a/src/workerd/io/worker-interface.capnp b/src/workerd/io/worker-interface.capnp index 659fa0977b2..a00c9b543eb 100644 --- a/src/workerd/io/worker-interface.capnp +++ b/src/workerd/io/worker-interface.capnp @@ -553,6 +553,15 @@ enum SerializationTag { # payload (per `serviceStub`) followed by the wrapper module name. See api/wrapped-binding.{h,c++}. durableObjectSnapshot @18; # A Durable Object storage snapshot handle transferred over RPC. + + r2Checksums @19; + # Checksums attached to an R2 object. + + r2Object @20; + # R2 object metadata without a body. + + r2ObjectBody @21; + # R2 object metadata with a transferred body stream. } enum StreamEncoding { diff --git a/types/BUILD.bazel b/types/BUILD.bazel index 14c92c27191..6326bbef72e 100644 --- a/types/BUILD.bazel +++ b/types/BUILD.bazel @@ -1,4 +1,4 @@ -load("@aspect_rules_js//js:defs.bzl", "js_binary", "js_run_binary") +load("@aspect_rules_js//js:defs.bzl", "js_binary", "js_library", "js_run_binary") load("//:build/wd_ts_project.bzl", "wd_ts_project") load("//:build/wd_ts_test.bzl", "wd_ts_test") load("//:build/wd_ts_type_test.bzl", "wd_ts_type_test") @@ -97,3 +97,16 @@ js_run_binary( wd_ts_type_test(src = src) for src in glob(["test/types/**/*.ts"]) ] + +# The checked-in experimental snapshot, for test code that type-checks against Workers types +# without the cost of regenerating them. CI keeps the snapshot equal to the generated output. +js_library( + name = "generated_snapshot_experimental", + srcs = ["generated-snapshot/experimental/index.d.ts"], + visibility = ["//src/workerd/api/tests:__pkg__"], +) + +exports_files( + ["generated-snapshot/experimental/index.d.ts"], + visibility = ["//src/workerd/api/tests:__pkg__"], +)