/* * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ #include "NetworkIOAgent.h" #include #include "Base64.h" #include "Utf8.h" namespace facebook::react::jsinspector_modern { static constexpr long DEFAULT_BYTES_PER_READ = 1048576; // 1MB (Chrome v112 default) // https://github.com/chromium/chromium/blob/128.0.6593.1/content/browser/devtools/devtools_io_context.cc#L71-L73 static constexpr std::array kTextMIMETypePrefixes{ "text/", "application/x-javascript", "application/json", "application/xml", "application/javascript" // Not in Chromium but emitted by Metro }; namespace { struct InitStreamResult { int httpStatusCode; Headers headers; std::shared_ptr stream; }; using InitStreamError = const std::string; using StreamInitCallback = std::function)>; using IOReadCallback = std::function)>; /** * Private class owning state and implementing the listener for a particular * request * * NetworkRequestListener overrides are thread safe, all other methods must be * called from the same thread. */ class Stream : public NetworkRequestListener, public EnableExecutorFromThis { public: Stream(const Stream& other) = delete; Stream& operator=(const Stream& other) = delete; Stream(Stream&& other) = default; Stream& operator=(Stream&& other) = default; /** * Factory method to create a Stream with a callback for the initial result * of a network request. * \param executor An executor on which all processing of callbacks from * the platform will be performed, and on which the passed callback will be * called. * \param initCb Will be called once either on receipt of HTTP headers or * any prior error, using the given executor. */ static std::shared_ptr create( VoidExecutor executor, StreamInitCallback initCb) { std::shared_ptr stream{new Stream(initCb)}; stream->setExecutor(executor); return stream; } /** * Agent-facing API. Enqueue a read request for up to maxBytesToRead * bytes, starting from the end of the previous read. * \param maxBytesToRead The maximum number of bytes to read from the * source stream. * \param callback Will be called using the executor passed to create() * with the result of the read, or an error string. */ void read(long maxBytesToRead, const IOReadCallback& callback) { pendingReadRequests_.emplace_back( std::make_tuple(maxBytesToRead, callback)); processPending(); } /** * Agent-facing API. Call the platform-provided cancelFunction, if any, * call the error callbacks of any in-flight read requests, and the initial * error callback if it has not already fulfilled with success or error. */ void cancel() { if (cancelFunction_) { (*cancelFunction_)(); } error_ = "Cancelled"; if (initCb_) { auto cb = std::move(initCb_); (*cb)(InitStreamError{"Cancelled"}); } // Respond to any in-flight read requests with an error. processPending(); } /** * Begin implementation of NetworkRequestListener, to be called by platform * HostTargetDelegate. Any of these methods may be called from any thread. */ void onData(std::string_view data) override { data_ << data; bytesReceived_ += data.length(); processPending(); } void onHeaders(int httpStatusCode, const Headers& headers) override { // Find content-type through case-insensitive search of headers. for (const auto& [name, value] : headers) { std::string lowerName = name; std::transform( lowerName.begin(), lowerName.end(), lowerName.begin(), ::tolower); if (lowerName == "content-type") { isText_ = isTextMimeType(value); break; }; } // If we've already seen an error, the initial callback as already been // called with it. if (initCb_) { auto cb = std::move(initCb_); (*cb)( InitStreamResult{httpStatusCode, headers, this->shared_from_this()}); } } void onError(const std::string& message) override { // Only call the error callback once. if (!error_) { error_ = message; if (initCb_) { auto cb = std::move(initCb_); (*cb)(InitStreamError{message}); } } processPending(); } void onCompletion() override { completed_ = true; processPending(); } void setCancelFunction(std::function cancelFunction) override { cancelFunction_ = std::move(cancelFunction); } ~Stream() override { // Cancel any incoming request, if the platform has provided a cancel // callback. if (cancelFunction_) { (*cancelFunction_)(); } } /* End NetworkRequestListener */ private: /** * Private constructor. The caller must call setExecutor immediately * afterwards. */ explicit Stream(const StreamInitCallback& initCb) : initCb_(std::make_unique(initCb)) {} void processPending() { // Go through each pending request in insertion order - execute the // callback and remove it from pending if it can be satisfied. for (auto it = pendingReadRequests_.begin(); it != pendingReadRequests_.end();) { auto maxBytesToRead = std::get<0>(*it); auto callback = std::get<1>(*it); if (error_) { callback(IOReadError{*error_}); } else if ( completed_ || (bytesReceived_ - data_.tellg() >= maxBytesToRead)) { try { callback(respond(maxBytesToRead)); } catch (const std::runtime_error& error) { callback(IOReadError{error.what()}); } } else { // Not yet received enough data ++it; continue; } it = pendingReadRequests_.erase(it); } } IOReadResult respond(long maxBytesToRead) { std::vector buffer(maxBytesToRead); data_.read(buffer.data(), maxBytesToRead); auto bytesRead = data_.gcount(); std::string output; buffer.resize(bytesRead); if (isText_) { auto originalSize = buffer.size(); // Maybe resize to drop the last 1-3 bytes so that buffer is valid. truncateToValidUTF8(buffer); if (buffer.size() < originalSize) { // Rewind the stream so that the next read starts from the start of // the code point we're removing from this chunk. data_.seekg(buffer.size() - originalSize, std::ios_base::cur); } output = std::string(buffer.begin(), buffer.begin() + buffer.size()); } else { // Encode the slice as a base64 string. output = base64Encode(std::string_view(buffer.data(), buffer.size())); } return IOReadResult{ .data = output, .eof = output.length() == 0 && completed_, .base64Encoded = !isText_}; } // https://github.com/chromium/chromium/blob/128.0.6593.1/content/browser/devtools/devtools_io_context.cc#L70-L80 static bool isTextMimeType(const std::string& mimeType) { for (auto& kTextMIMETypePrefix : kTextMIMETypePrefixes) { if (mimeType.starts_with(kTextMIMETypePrefix)) { return true; } } return false; } bool completed_{false}; bool isText_{false}; std::optional error_; std::stringstream data_; long bytesReceived_{0}; std::optional> cancelFunction_{std::nullopt}; std::unique_ptr initCb_; std::vector> pendingReadRequests_; }; } // namespace bool NetworkIOAgent::handleRequest( const cdp::PreparsedRequest& req, LoadNetworkResourceDelegate& delegate) { if (req.method == "Network.loadNetworkResource") { handleLoadNetworkResource(req, delegate); return true; } else if (req.method == "IO.read") { handleIoRead(req); return true; } else if (req.method == "IO.close") { handleIoClose(req); return true; } return false; } void NetworkIOAgent::handleLoadNetworkResource( const cdp::PreparsedRequest& req, LoadNetworkResourceDelegate& delegate) { long long requestId = req.id; LoadNetworkResourceRequest params; if (!req.params.isObject()) { frontendChannel_(cdp::jsonError( req.id, cdp::ErrorCode::InvalidParams, "Invalid params: not an object.")); return; } if ((req.params.count("url") == 0u) || !req.params.at("url").isString()) { frontendChannel_(cdp::jsonError( requestId, cdp::ErrorCode::InvalidParams, "Invalid params: url is missing or not a string.")); return; } else { params.url = req.params.at("url").asString(); } // This is an opaque identifier, but an incrementing integer in a string is // consistent with Chrome. StreamID streamId = std::to_string(nextStreamId_++); auto stream = Stream::create( executor_, [streamId, requestId, frontendChannel = frontendChannel_, streamsWeak = std::weak_ptr(streams_)](auto resultOrError) { NetworkResource resource; std::string cdpError; if (auto* error = std::get_if(&resultOrError)) { resource = NetworkResource{.success = false, .netErrorName = *error}; } else if ( auto* result = std::get_if(&resultOrError)) { if (result->httpStatusCode >= 200 && result->httpStatusCode < 300) { resource = NetworkResource{ .success = true, .stream = streamId, .httpStatusCode = result->httpStatusCode, .headers = result->headers}; } else if (result->httpStatusCode >= 400) { resource = NetworkResource{ .success = false, .httpStatusCode = result->httpStatusCode, .headers = result->headers}; } else { // We can't deal with <200 or 3xx reponses here (though they may be // transparently handled by the delegate). Return a CDP error (not // an unsuccesful resource) to the frontend so that it falls back to // a direct fetch. cdpError = "Handling of status " + std::to_string(result->httpStatusCode) + " not implemented."; } } else { assert(false && "Unhandled IO init result type"); } if (cdpError.length() > 0 || !resource.success) { // Release and destroy the stream after the calling executor returns. // ~Stream will handle cancelling any download in progress. if (auto streams = streamsWeak.lock()) { streams->erase(streamId); } } frontendChannel( cdpError.length() ? cdp::jsonError( requestId, cdp::ErrorCode::InternalError, cdpError) : cdp::jsonResult( requestId, folly::dynamic::object( "resource", resource.toDynamic()))); }); // Begin the network request on the platform, passing an executor scoped to // a Stream (a NetworkRequestListener), which the implementation will call // back into. delegate.loadNetworkResource(params, stream->executorFromThis()); // Retain the stream only if delegate.loadNetworkResource does not throw. streams_->emplace(streamId, stream); } void NetworkIOAgent::handleIoRead(const cdp::PreparsedRequest& req) { long long requestId = req.id; if (!req.params.isObject()) { frontendChannel_(cdp::jsonError( requestId, cdp::ErrorCode::InvalidParams, "Invalid params: not an object.")); return; } if ((req.params.count("handle") == 0u) || !req.params.at("handle").isString()) { frontendChannel_(cdp::jsonError( requestId, cdp::ErrorCode::InvalidParams, "Invalid params: handle is missing or not a string.")); return; } std::optional size = std::nullopt; if ((req.params.count("size") != 0u) && req.params.at("size").isInt()) { size = req.params.at("size").asInt(); } auto streamId = req.params.at("handle").asString(); auto it = streams_->find(streamId); if (it == streams_->end()) { frontendChannel_(cdp::jsonError( requestId, cdp::ErrorCode::InternalError, "Stream not found with handle " + streamId)); return; } else { it->second->read( size ? *size : DEFAULT_BYTES_PER_READ, [requestId, frontendChannel = frontendChannel_, streamId, streamsWeak = std::weak_ptr(streams_)](auto resultOrError) { if (auto* error = std::get_if(&resultOrError)) { // NB: Chrome DevTools calls IO.close after a read error, so any // continuing download or retained data is cleaned up at that point. frontendChannel(cdp::jsonError( requestId, cdp::ErrorCode::InternalError, *error)); } else if (auto* result = std::get_if(&resultOrError)) { frontendChannel(cdp::jsonResult(requestId, result->toDynamic())); } else { assert(false && "Unhandled IO read result type"); } }); return; } } void NetworkIOAgent::handleIoClose(const cdp::PreparsedRequest& req) { long long requestId = req.id; if (!req.params.isObject()) { frontendChannel_(cdp::jsonError( requestId, cdp::ErrorCode::InvalidParams, "Invalid params: not an object.")); return; } if ((req.params.count("handle") == 0u) || !req.params.at("handle").isString()) { frontendChannel_(cdp::jsonError( requestId, cdp::ErrorCode::InvalidParams, "Invalid params: handle is missing or not a string.")); return; } auto streamId = req.params.at("handle").asString(); auto it = streams_->find(streamId); if (it == streams_->end()) { frontendChannel_(cdp::jsonError( requestId, cdp::ErrorCode::InternalError, "Stream not found: " + streamId)); } else { it->second->cancel(); streams_->erase(it->first); frontendChannel_(cdp::jsonResult(requestId)); } } } // namespace facebook::react::jsinspector_modern