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 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/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/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/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) } } 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; } 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,