From 6f52ec86df324f2eb37f8b98fc4d0e62d542e2a7 Mon Sep 17 00:00:00 2001 From: sankalpsingh Date: Mon, 27 Jul 2026 01:45:46 +0530 Subject: [PATCH 1/4] fix(android): cap body reads to prevent OOM on large responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces `source.request(Long.MAX_VALUE)` with a bounded read so that large response bodies (images, file downloads, big JSON payloads) no longer duplicate the full payload in heap memory. - `captureResponseBody`: request `limit + 1` bytes so truncation is detectable, append `[... truncated]` marker when body exceeds cap - `captureRequestBody`: write body to a temporary buffer then read only `min(size, limit)` bytes, append truncation marker when needed - Both methods bail early with `[binary content — not captured]` for image/, video/, audio/, octet-stream, zip, pdf, and font/* types - `NetworkToolsManager.maxBodyCaptureBytes` default: 256 KB Closes #17 Co-Authored-By: Claude Sonnet 4.6 --- .../com/networktools/NetworkToolsManager.kt | 6 ++- .../interceptor/NetworkToolsInterceptor.kt | 37 +++++++++++++++---- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/android/src/main/java/com/networktools/NetworkToolsManager.kt b/android/src/main/java/com/networktools/NetworkToolsManager.kt index 1d79023..61d26b5 100644 --- a/android/src/main/java/com/networktools/NetworkToolsManager.kt +++ b/android/src/main/java/com/networktools/NetworkToolsManager.kt @@ -10,10 +10,14 @@ import okhttp3.OkHttpClient object NetworkToolsManager { private val interceptor = NetworkToolsInterceptor() + /** Maximum bytes captured per request/response body. Default: 256 KB. */ + @JvmField + var maxBodyCaptureBytes: Long = 256 * 1024L + /** * Add the NetworkTools interceptor to an OkHttpClient.Builder * This method should be called when configuring your OkHttpClient - * + * * Example usage: * ``` * val client = OkHttpClient.Builder() diff --git a/android/src/main/java/com/networktools/interceptor/NetworkToolsInterceptor.kt b/android/src/main/java/com/networktools/interceptor/NetworkToolsInterceptor.kt index d7c59df..52f9801 100644 --- a/android/src/main/java/com/networktools/interceptor/NetworkToolsInterceptor.kt +++ b/android/src/main/java/com/networktools/interceptor/NetworkToolsInterceptor.kt @@ -1,5 +1,6 @@ package com.networktools.interceptor +import com.networktools.NetworkToolsManager import com.networktools.models.NetworkRequest import com.networktools.storage.NetworkRequestStorage import okhttp3.Interceptor @@ -15,6 +16,11 @@ import java.util.UUID */ class NetworkToolsInterceptor : Interceptor { + private val binaryContentTypePrefixes = listOf( + "image/", "video/", "audio/", "application/octet-stream", "application/zip", + "application/pdf", "application/x-", "font/" + ) + @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() @@ -37,7 +43,6 @@ class NetworkToolsInterceptor : Interceptor { val responseHeaders = captureResponseHeaders(response) val responseBody = captureResponseBody(response) - // Store the network request val networkRequest = NetworkRequest( id = requestId, url = request.url.toString(), @@ -56,10 +61,8 @@ class NetworkToolsInterceptor : Interceptor { NetworkRequestStorage.addRequest(networkRequest) NetworkToolsEventEmitter.emitNetworkRequest(networkRequest) - return response } catch (e: Exception) { - // Capture error details error = e.message ?: "Unknown error" responseTime = System.currentTimeMillis() duration = responseTime - requestTime @@ -96,9 +99,16 @@ class NetworkToolsInterceptor : Interceptor { private fun captureRequestBody(request: Request): String? { return try { + val contentType = request.body?.contentType()?.toString().orEmpty() + if (isBinaryContentType(contentType)) return "[binary content — not captured]" + + val limit = NetworkToolsManager.maxBodyCaptureBytes val buffer = Buffer() request.body?.writeTo(buffer) - buffer.readUtf8() + val totalSize = buffer.size + val isTruncated = totalSize > limit + val body = buffer.readUtf8(minOf(totalSize, limit)) + if (isTruncated) "$body\n[... truncated — $totalSize bytes total]" else body } catch (e: Exception) { null } @@ -114,12 +124,23 @@ class NetworkToolsInterceptor : Interceptor { private fun captureResponseBody(response: Response): String? { return try { - val source = response.body?.source() - source?.request(Long.MAX_VALUE) - val buffer = source?.buffer - buffer?.clone()?.readUtf8() + val contentType = response.body?.contentType()?.toString().orEmpty() + if (isBinaryContentType(contentType)) return "[binary content — not captured]" + + val source = response.body?.source() ?: return null + val limit = NetworkToolsManager.maxBodyCaptureBytes + // Request one extra byte so we can detect truncation without consuming it + source.request(limit + 1) + val buffer = source.buffer + val isTruncated = buffer.size > limit + val capturedBytes = minOf(buffer.size, limit).toInt() + val body = buffer.snapshot().substring(0, capturedBytes).utf8() + if (isTruncated) "$body\n[... truncated]" else body } catch (e: Exception) { null } } + + private fun isBinaryContentType(contentType: String): Boolean = + binaryContentTypePrefixes.any { contentType.startsWith(it) } } From 8fdfb8ef81f5a4e8a2caefff0fdd8d823fe864c2 Mon Sep 17 00:00:00 2001 From: sankalpsingh Date: Mon, 27 Jul 2026 01:46:41 +0530 Subject: [PATCH 2/4] feat(native): expose setMaxBodyCaptureBytes on Android and iOS Android (TurboModule + legacy bridge) and iOS now implement setMaxBodyCaptureBytes(bytes) so the capture limit can be changed at runtime from JavaScript without a rebuild. iOS: maxBodyCaptureBytes is promoted from a static compile-time constant in NetworkToolsInterceptor to a property on the NetworkToolsManager singleton, read dynamically via ntMaxBodyBytes(). Co-Authored-By: Claude Sonnet 4.6 --- .../networktools/NetworkToolsLegacyModule.kt | 5 +++++ .../java/com/networktools/NetworkToolsModule.kt | 4 ++++ ios/NetworkTools.mm | 4 ++++ ios/NetworkToolsInterceptor.m | 17 ++++++++++------- ios/NetworkToolsManager.h | 3 +++ ios/NetworkToolsManager.m | 1 + 6 files changed, 27 insertions(+), 7 deletions(-) diff --git a/android/src/main/java/com/networktools/NetworkToolsLegacyModule.kt b/android/src/main/java/com/networktools/NetworkToolsLegacyModule.kt index 6bbccc0..875bcd2 100644 --- a/android/src/main/java/com/networktools/NetworkToolsLegacyModule.kt +++ b/android/src/main/java/com/networktools/NetworkToolsLegacyModule.kt @@ -36,6 +36,11 @@ class NetworkToolsLegacyModule(reactContext: ReactApplicationContext) : return NetworkToolsModuleDelegate.getRequestCount() } + @ReactMethod + fun setMaxBodyCaptureBytes(bytes: Double) { + NetworkToolsManager.maxBodyCaptureBytes = bytes.toLong() + } + @ReactMethod fun addListener(eventType: String?) {} diff --git a/android/src/main/java/com/networktools/NetworkToolsModule.kt b/android/src/main/java/com/networktools/NetworkToolsModule.kt index 342bf79..453fa8e 100644 --- a/android/src/main/java/com/networktools/NetworkToolsModule.kt +++ b/android/src/main/java/com/networktools/NetworkToolsModule.kt @@ -39,6 +39,10 @@ class NetworkToolsModule(reactContext: ReactApplicationContext) : return NetworkToolsModuleDelegate.getRequestCount() } + override fun setMaxBodyCaptureBytes(bytes: Double) { + NetworkToolsManager.maxBodyCaptureBytes = bytes.toLong() + } + override fun addListener(eventType: String?) { } diff --git a/ios/NetworkTools.mm b/ios/NetworkTools.mm index ef361da..9fc325e 100644 --- a/ios/NetworkTools.mm +++ b/ios/NetworkTools.mm @@ -63,6 +63,10 @@ - (NSNumber *)getRequestCount { return @([[NetworkToolsStorage shared] count]); } +- (void)setMaxBodyCaptureBytes:(double)bytes { + [NetworkToolsManager shared].maxBodyCaptureBytes = (NSInteger)bytes; +} + // addListener / removeListeners are inherited from RCTEventEmitter and satisfy // the NativeNetworkToolsSpec protocol — no override needed. diff --git a/ios/NetworkToolsInterceptor.m b/ios/NetworkToolsInterceptor.m index d54b957..a7fde5a 100644 --- a/ios/NetworkToolsInterceptor.m +++ b/ios/NetworkToolsInterceptor.m @@ -4,7 +4,10 @@ #import static NSString *const kNTHandledKey = @"NetworkToolsHandled"; -static const NSInteger kNTMaxBodyBytes = 256 * 1024; + +static NSInteger ntMaxBodyBytes(void) { + return [NetworkToolsManager shared].maxBodyCaptureBytes; +} // Original IMP saved during swizzle so we can call through to it. static IMP gOriginalProtocolClassesIMP = nil; @@ -197,7 +200,7 @@ - (NSData *)drainStream:(NSInputStream *)stream { uint8_t chunk[4096]; NSInteger bytesRead; NSInteger totalRead = 0; - while (totalRead < kNTMaxBodyBytes && + while (totalRead < ntMaxBodyBytes() && (bytesRead = [stream read:chunk maxLength:sizeof(chunk)]) > 0) { [buffer appendBytes:chunk length:(NSUInteger)bytesRead]; totalRead += bytesRead; @@ -208,9 +211,9 @@ - (NSData *)drainStream:(NSInputStream *)stream { - (nullable NSString *)bodyStringFromData:(NSData *)data { if (data.length == 0) return nil; - NSData *capped = data.length <= (NSUInteger)kNTMaxBodyBytes + NSData *capped = data.length <= (NSUInteger)ntMaxBodyBytes() ? data - : [data subdataWithRange:NSMakeRange(0, (NSUInteger)kNTMaxBodyBytes)]; + : [data subdataWithRange:NSMakeRange(0, (NSUInteger)ntMaxBodyBytes())]; return [[NSString alloc] initWithData:capped encoding:NSUTF8StringEncoding]; } @@ -226,13 +229,13 @@ - (NSString *)responseBodyStringForResponse:(nullable NSHTTPURLResponse *)respon if (self.responseData.length == 0) return @""; - NSData *capped = self.responseData.length <= (NSUInteger)kNTMaxBodyBytes + NSData *capped = self.responseData.length <= (NSUInteger)ntMaxBodyBytes() ? self.responseData - : [self.responseData subdataWithRange:NSMakeRange(0, (NSUInteger)kNTMaxBodyBytes)]; + : [self.responseData subdataWithRange:NSMakeRange(0, (NSUInteger)ntMaxBodyBytes())]; NSString *body = [[NSString alloc] initWithData:capped encoding:NSUTF8StringEncoding] ?: @""; - if (self.responseData.length > (NSUInteger)kNTMaxBodyBytes) { + if (self.responseData.length > (NSUInteger)ntMaxBodyBytes()) { body = [body stringByAppendingFormat:@"\n[... truncated — %lu bytes total]", (unsigned long)self.responseData.length]; } diff --git a/ios/NetworkToolsManager.h b/ios/NetworkToolsManager.h index faa6b4a..44e6ab7 100644 --- a/ios/NetworkToolsManager.h +++ b/ios/NetworkToolsManager.h @@ -18,6 +18,9 @@ NS_ASSUME_NONNULL_BEGIN @property (class, nonatomic, readonly) NetworkToolsManager *shared; +/** Maximum bytes captured per request/response body. Default: 256 KB. */ +@property (nonatomic, assign) NSInteger maxBodyCaptureBytes; + /** * Registers the URLProtocol interceptor. No-op in release builds — the entire * method body is compiled out with #if DEBUG. diff --git a/ios/NetworkToolsManager.m b/ios/NetworkToolsManager.m index 563c081..af755ef 100644 --- a/ios/NetworkToolsManager.m +++ b/ios/NetworkToolsManager.m @@ -13,6 +13,7 @@ + (instancetype)shared { static dispatch_once_t token; dispatch_once(&token, ^{ instance = [[self alloc] init]; + instance->_maxBodyCaptureBytes = 256 * 1024; }); return instance; } From fceeccd050b0304bc58368489411c568a75c3dca Mon Sep 17 00:00:00 2001 From: sankalpsingh Date: Mon, 27 Jul 2026 01:46:57 +0530 Subject: [PATCH 3/4] feat(js): add maxBodyCaptureBytes prop to NetworkMonitorProvider - Adds setMaxBodyCaptureBytes(bytes) to the TurboModule spec and the unavailable-module fallback - NetworkMonitorProviderProps gains an optional maxBodyCaptureBytes field - NetworkMonitorProvider calls the native method in a useEffect whenever the prop changes - setMaxBodyCaptureBytes is exported as a named export and on the default object for callers that bypass the provider Co-Authored-By: Claude Sonnet 4.6 --- src/NativeNetworkTools.ts | 4 ++++ src/context/NetworkMonitorContext.tsx | 8 ++++++++ src/context/types.ts | 1 + src/index.tsx | 3 +++ 4 files changed, 16 insertions(+) diff --git a/src/NativeNetworkTools.ts b/src/NativeNetworkTools.ts index 4a7871a..00dc13a 100644 --- a/src/NativeNetworkTools.ts +++ b/src/NativeNetworkTools.ts @@ -9,6 +9,7 @@ export interface Spec extends TurboModule { getRequestById(id: string): string; clearAllRequests(): void; getRequestCount(): number; + setMaxBodyCaptureBytes(bytes: number): void; addListener(eventType: string): void; removeListeners(count: number): void; } @@ -55,6 +56,9 @@ const unavailableModule: Spec = { warnUnavailable('getRequestCount'); return 0; }, + setMaxBodyCaptureBytes() { + warnUnavailable('setMaxBodyCaptureBytes'); + }, addListener() { warnUnavailable('addListener'); }, diff --git a/src/context/NetworkMonitorContext.tsx b/src/context/NetworkMonitorContext.tsx index dad1c86..96466a9 100644 --- a/src/context/NetworkMonitorContext.tsx +++ b/src/context/NetworkMonitorContext.tsx @@ -28,6 +28,7 @@ export const NetworkMonitorProvider: React.FC = ({ children, maxRequests = 1000, showFloatingMonitor = true, + maxBodyCaptureBytes, }) => { const [requests, setRequests] = useState([]); const emitterRef = useRef(null); @@ -37,6 +38,13 @@ export const NetworkMonitorProvider: React.FC = ({ networkStore.setMaxRequests(maxRequests); }, [maxRequests]); + // Propagate body capture limit to native + useEffect(() => { + if (maxBodyCaptureBytes !== undefined) { + NetworkTools.setMaxBodyCaptureBytes(maxBodyCaptureBytes); + } + }, [maxBodyCaptureBytes]); + // Subscribe to store changes useEffect(() => { const unsubscribe = networkStore.subscribe((updatedRequests) => { diff --git a/src/context/types.ts b/src/context/types.ts index 386ab5c..e8e3d95 100644 --- a/src/context/types.ts +++ b/src/context/types.ts @@ -51,6 +51,7 @@ export interface NetworkMonitorProviderProps { children: ReactNode; maxRequests?: number; showFloatingMonitor?: boolean; + maxBodyCaptureBytes?: number; } export const sample = { diff --git a/src/index.tsx b/src/index.tsx index e5bac77..5d34205 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -20,6 +20,8 @@ export const getNetworkRequestById = (id: string): string => export const clearNetworkRequests = (): void => NetworkTools.clearAllRequests(); export const getNetworkRequestCount = (): number => NetworkTools.getRequestCount(); +export const setMaxBodyCaptureBytes = (bytes: number): void => + NetworkTools.setMaxBodyCaptureBytes(bytes); export const getAllRequests = getAllNetworkRequests; export const getRequestById = getNetworkRequestById; export const clearAllRequests = clearNetworkRequests; @@ -91,6 +93,7 @@ const ReactNativeNetworkTools = { getRequestById: getNetworkRequestById, clearAllRequests: clearNetworkRequests, getRequestCount: getNetworkRequestCount, + setMaxBodyCaptureBytes, getNetworkToolsRuntime, isNativeNetworkToolsAvailable, annotateNetworkRequestError, From 62807d0edde44d4ad91f1ce3ec9c8e4433ced071 Mon Sep 17 00:00:00 2001 From: sankalpsingh Date: Mon, 27 Jul 2026 02:18:38 +0530 Subject: [PATCH 4/4] fix(ci): pin Ruby 3.3 for iOS build to avoid kconv removal in Ruby 3.4 macos-latest now maps to macOS 15 which ships Ruby 3.4. kconv was removed from Ruby's stdlib in 3.4, causing CocoaPods 1.16.2 to crash during pod install with "cannot load such file -- kconv". Adds ruby/setup-ruby@v1 with ruby-version: '3.3' before the pod install step, and delegates bundle install to setup-ruby via bundler-cache: true (which also caches the gem bundle). Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c17a313..074d1a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,11 +184,18 @@ jobs: echo "turbo_cache_hit=1" >> $GITHUB_ENV fi + - name: Setup Ruby + if: env.turbo_cache_hit != 1 + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + working-directory: example + - name: Install cocoapods - if: env.turbo_cache_hit != 1 && steps.cocoapods-cache.outputs.cache-hit != 'true' + if: env.turbo_cache_hit != 1 run: | cd example - bundle install bundle exec pod repo update --verbose bundle exec pod install --project-directory=ios