From 133402b7462a1ae08708869a2d1593ce50a10a97 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 13 Nov 2026 19:50:32 +0800 Subject: [PATCH 1/2] feat: expose SNI file upload progress --- .../react-native-sni-connect/SPEC.md | 6 ++- .../java/com/sniconnect/SniConnectModule.kt | 45 +++++++++++++++++-- .../java/com/sniconnect/SniPinnedTransport.kt | 3 ++ .../sniconnect/SniConnectValidationTest.kt | 15 +++++++ .../react-native-sni-connect/docs/SPEC.md | 40 +++++++++++++++++ .../ios/SniConnect.mm | 15 +++++++ .../ios/SniConnect.swift | 26 ++++++++++- .../ios/SniConnectClient.swift | 42 ++++++++++++++++- .../ios/SniConnectCore.swift | 24 ++++++++++ .../SniConnectValidationTests.swift | 11 +++++ .../src/NativeSniConnect.ts | 8 ++++ .../src/__tests__/index.test.ts | 20 +++++++++ .../react-native-sni-connect/src/index.tsx | 11 +++++ 13 files changed, 258 insertions(+), 8 deletions(-) diff --git a/native-modules/react-native-sni-connect/SPEC.md b/native-modules/react-native-sni-connect/SPEC.md index 5493729f5..b41f98ac9 100644 --- a/native-modules/react-native-sni-connect/SPEC.md +++ b/native-modules/react-native-sni-connect/SPEC.md @@ -180,7 +180,9 @@ export type SniConnectUploadFileRequest = SniConnectRequestBase & { I/O. - The caller supplies `content-type` in `headers`; `Content-Length` stays module-owned (Section 4.4). -- Upload progress events are out of scope. `timeout` covers the whole upload. +- Native React Native upload progress is available through the optional + `getUploadProgress(requestId)` capability described in the + [native upload contract](docs/SPEC.md). `timeout` covers the whole upload. - Status: **Implemented** on iOS and Android React Native. Node/Desktop and shared JS adapters do not expose it; adapters MUST feature-detect it and treat a missing native method as "not supported" (Section 3.3). @@ -220,6 +222,8 @@ export type SniConnectResponse = { - `isUploadFileSupported(): boolean` (React Native JS capability probe; see [native upload contract](docs/SPEC.md) for unsupported Promise rejection and consumption-time file bounds) +- `getUploadProgress(requestId): Promise<{ bytesSent: number; totalBytes: number } | null>` + (React Native only; optional on older native binaries) - `cancelRequest(requestId): Promise<{ success: boolean }>` - `cancelAllRequests(): Promise<{ success: boolean }>` - `clearDNSCache(): Promise<{ success: boolean }>` diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt index 669abfef9..a772717d5 100644 --- a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt @@ -165,6 +165,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) : private class ManagedRequest( val call: Call, val settled: AtomicBoolean, + val uploadProgress: UploadProgress?, ) { lateinit var admissionTicket: SniConnectRequestAdmission.Ticket private val explicitlyCancelled = AtomicBoolean(false) @@ -183,6 +184,20 @@ class SniConnectModule(reactContext: ReactApplicationContext) : fun wasExplicitlyCancelled(): Boolean = explicitlyCancelled.get() } + private class UploadProgress { + private var bytesSent = 0L + private var totalBytes = 0L + + @Synchronized + fun record(sent: Long, total: Long) { + totalBytes = total + bytesSent = maxOf(bytesSent, sent.coerceIn(0, total)) + } + + @Synchronized + fun snapshot(): Pair = bytesSent to totalBytes + } + // Cancellation ownership is per module, so cancelAllRequests only affects its RN runtime. private val activeCalls = ConcurrentHashMap() private val allActiveCalls = Collections.newSetFromMap( @@ -242,6 +257,20 @@ class SniConnectModule(reactContext: ReactApplicationContext) : } } + @ReactMethod + override fun getUploadProgress(requestId: String, promise: Promise) { + val progress = synchronized(activeCallsLock) { + activeCalls[requestId]?.takeUnless { it.settled.get() }?.uploadProgress + } + val snapshot = progress?.snapshot() + promise.resolve(snapshot?.let { (sent, total) -> + Arguments.createMap().apply { + putDouble("bytesSent", sent.toDouble()) + putDouble("totalBytes", total.toDouble()) + } + }) + } + @ReactMethod override fun cancelRequest(requestId: String, promise: Promise) { val request = synchronized(activeCallsLock) { @@ -337,10 +366,15 @@ class SniConnectModule(reactContext: ReactApplicationContext) : // Shared by every OkHttp attempt of this logical request. The client's // EventListener marks it once any attempt starts sending request headers. val requestTransmission = SniRequestTransmissionState() + val uploadProgress = if (config.bodyFile != null && config.requestId != null) { + UploadProgress() + } else { + null + } var registeredRequest: ManagedRequest? = null try { val client = getOrCreateClient(config) - val request = buildRequest(config, requestTransmission) + val request = buildRequest(config, requestTransmission, uploadProgress) val call = client.newCall(request) val settled = AtomicBoolean(false) lateinit var managedRequest: ManagedRequest @@ -493,7 +527,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) : } }, ) - managedRequest = ManagedRequest(call, settled).apply { + managedRequest = ManagedRequest(call, settled, uploadProgress).apply { this.admissionTicket = admissionTicket } registerRequest(config.requestId, managedRequest) @@ -632,6 +666,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) : private fun buildRequest( config: RequestConfig, requestTransmission: SniRequestTransmissionState, + uploadProgress: UploadProgress?, ): Request { val url = "https://${config.hostname}${config.path}" val builder = Request.Builder().url(url) @@ -651,7 +686,11 @@ class SniConnectModule(reactContext: ReactApplicationContext) : ?.value ?.toMediaTypeOrNull() // Enforce the body bound when OkHttp consumes the file, after admission. - val requestBody = config.bodyFile?.let { SniUploadFileRequestBody(it, mediaType) } + val requestBody = config.bodyFile?.let { + SniUploadFileRequestBody(it, mediaType) { sent, total -> + uploadProgress?.record(sent, total) + }.also { body -> uploadProgress?.record(0, body.contentLength()) } + } ?: config.body?.toRequestBody(mediaType) when (method) { diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniPinnedTransport.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniPinnedTransport.kt index f51133d8d..9c75bfdf9 100644 --- a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniPinnedTransport.kt +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniPinnedTransport.kt @@ -113,6 +113,7 @@ internal object SniRequestTransmissionEventListenerFactory : EventListener.Facto internal class SniUploadFileRequestBody( private val file: File, private val mediaType: MediaType?, + private val onProgress: ((Long, Long) -> Unit)? = null, ) : RequestBody() { private val length = file.length().also { if (it <= 0 || it > SniConnectValidation.MAX_UPLOAD_FILE_BYTES) { @@ -125,6 +126,7 @@ internal class SniUploadFileRequestBody( override fun contentLength(): Long = length override fun writeTo(sink: BufferedSink) { + onProgress?.invoke(0, length) file.source().use { source -> val buffer = Buffer() var totalBytes = 0L @@ -137,6 +139,7 @@ internal class SniUploadFileRequestBody( } sink.write(buffer, read) totalBytes += read + onProgress?.invoke(totalBytes, length) } if (totalBytes != length) { throw IOException("Upload file was truncated during transmission") diff --git a/native-modules/react-native-sni-connect/android/src/test/java/com/sniconnect/SniConnectValidationTest.kt b/native-modules/react-native-sni-connect/android/src/test/java/com/sniconnect/SniConnectValidationTest.kt index bcbcb772e..e313416ea 100644 --- a/native-modules/react-native-sni-connect/android/src/test/java/com/sniconnect/SniConnectValidationTest.kt +++ b/native-modules/react-native-sni-connect/android/src/test/java/com/sniconnect/SniConnectValidationTest.kt @@ -99,6 +99,21 @@ class SniConnectValidationTest { assertTrue(expected.contentEquals(sink.readByteArray())) } + @Test + fun uploadBodyReportsOnlyAcceptedPayloadBytesPerWrite() { + val file = tempFolder.newFile("progress.zip").apply { writeBytes(ByteArray(16 * 1024) { 1 }) } + val progress = mutableListOf>() + val body = SniUploadFileRequestBody(file, null) { sent, total -> + progress.add(sent to total) + } + val sink = Buffer() + body.writeTo(sink) + assertEquals(0L to file.length(), progress.first()) + assertEquals(file.length() to file.length(), progress.last()) + assertTrue(progress.zipWithNext().all { (before, after) -> after.first >= before.first }) + assertEquals(file.length(), sink.size) + } + @Test fun uploadBodyRejectsGrowthAfterValidation() { val root = tempFolder.newFolder("sandbox") diff --git a/native-modules/react-native-sni-connect/docs/SPEC.md b/native-modules/react-native-sni-connect/docs/SPEC.md index c5f1b49a9..eed21f1de 100644 --- a/native-modules/react-native-sni-connect/docs/SPEC.md +++ b/native-modules/react-native-sni-connect/docs/SPEC.md @@ -11,6 +11,41 @@ upload boundary and its focused review corrections. - `uploadFile(config)` always returns a Promise. When the native method is absent, it rejects with an Error whose `code` is `SNI_UNSUPPORTED`. - Supported native methods receive the caller's config unchanged. +- `getUploadProgress(requestId)` returns the latest active upload snapshot or + `null` for an unknown, inactive, or non-upload request ID. New JS checks for + this native method before invoking it and resolves `null` with older binaries. + It does not change `uploadFile(config)` or its response/error behavior. + +## Upload progress + +Status: **Implemented in source**; native device/network acceptance remains open. + +- Progress is opt-in through the existing optional `requestId`; uploads without + one continue unchanged and have no queryable snapshot. Each native module + instance owns its own snapshots. The ID is validated by existing request + validation; a progress query itself performs no network I/O. +- `bytesSent` and `totalBytes` are payload bytes, not headers or a server + acknowledgement. Once the body is prepared, `totalBytes` is its declared + length, 1 through 20 MiB; before preparation it is zero. `bytesSent` is + between zero and `totalBytes`. iOS counts bytes read by EMASCurl into + libcurl; Android counts bytes accepted by OkHttp's sink. Built-in transport + recovery may replay a body. Android resets its write count for each OkHttp + `writeTo` and retains the largest prefix. EMASCurl's public callback counts + reads over one URLProtocol instance without an exposed retry boundary, so + iOS clamps that cumulative value at `totalBytes`; it may reach 100% across + replayed reads before a single complete attempt. Counts never accumulate + across separate `uploadFile` calls. +- A snapshot becomes available after native registration and remains available + while the upload waits for admission, sends, or awaits its response. Before + the file body is constructed, `totalBytes` may be zero. It is removed on + settlement, cancellation, or module invalidation. Late transport callbacks + cannot restore a removed snapshot. Duplicate active IDs replace the older + operation; completion of the older one cannot erase the replacement. +- Snapshots are retained only for registered operations. Admission limits + steady-state work to 64 active and 256 pending requests process-wide; + concurrent pre-admission registrations can transiently exceed those counts + and are released when admission rejects or the operation settles. Polling is + caller owned; the native module emits no progress events or bridge callbacks. ## File ownership, lifecycle, and bounds @@ -233,6 +268,11 @@ retain the generic logger's sensitive-data filtering. ## Conformance and remaining evidence +- TypeScript typecheck and React Native Codegen parse cover the nullable + progress result; JS API tests cover forwarding and old-native `null` fallback. +- Android RequestBody test verifies progress from sink-accepted payload bytes. + iOS uses EMASCurl 1.5.5's per-request callback, which counts bytes read into + libcurl. Native build and device/network behavior remain unverified here. - JS API tests cover capability detection, missing-native Promise rejection, supported forwarding, and synchronous native errors becoming rejections. - Swift validation tests exercise bounded reads after validated files grow or diff --git a/native-modules/react-native-sni-connect/ios/SniConnect.mm b/native-modules/react-native-sni-connect/ios/SniConnect.mm index 76a3d368c..74ce1ae83 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnect.mm +++ b/native-modules/react-native-sni-connect/ios/SniConnect.mm @@ -16,6 +16,9 @@ - (void)request:(NSDictionary *)config - (void)uploadFile:(NSDictionary *)config resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; +- (void)getUploadProgress:(NSString *)requestId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; - (void)cancelRequest:(NSString *)requestId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; @@ -103,6 +106,12 @@ - (void)uploadFile:(JS::NativeSniConnect::NativeSniConnectUploadFileRequest &)co [_implementation uploadFile:configDict resolve:resolve reject:reject]; } +- (void)getUploadProgress:(NSString *)requestId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + [_implementation getUploadProgress:requestId resolve:resolve reject:reject]; +} + - (void)cancelRequest:(NSString *)requestId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { @@ -154,6 +163,12 @@ - (void)isProxyActiveForUrl:(NSString *)url [_implementation uploadFile:config resolve:resolve reject:reject]; } +RCT_EXPORT_METHOD(getUploadProgress:(NSString *)requestId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + [_implementation getUploadProgress:requestId resolve:resolve reject:reject]; +} + RCT_EXPORT_METHOD(cancelRequest:(NSString *)requestId resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { diff --git a/native-modules/react-native-sni-connect/ios/SniConnect.swift b/native-modules/react-native-sni-connect/ios/SniConnect.swift index 7253ceb8b..a971fa17f 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnect.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnect.swift @@ -61,11 +61,25 @@ final class SniConnectImpl: NSObject { } } + @objc + public func getUploadProgress( + _ requestId: String, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + resolve(client.getUploadProgress(requestId: requestId)) + } + private func handleRequest( - config: SniConnectClient.RequestConfig, + config originalConfig: SniConnectClient.RequestConfig, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { + var configured = originalConfig + if configured.bodyFileURL != nil && configured.requestId != nil { + configured.uploadProgress = SniConnectUploadProgress() + } + let config = configured do { try Self.validate(config) } catch { @@ -92,7 +106,11 @@ final class SniConnectImpl: NSObject { return try await client.performRequest(config: config) } - let registrationToken = client.registerTask(task, for: config.requestId) + let registrationToken = client.registerTask( + task, + for: config.requestId, + uploadProgress: config.uploadProgress + ) // Handle the task result asynchronously Task { @@ -102,6 +120,7 @@ final class SniConnectImpl: NSObject { do { let result = try await task.value + client.finishUploadProgress(token: registrationToken) resolve([ "data": result.data, "status": result.status, @@ -110,10 +129,13 @@ final class SniConnectImpl: NSObject { "multiValueHeaders": result.multiValueHeaders, ]) } catch let error as SniConnectClient.SniConnectError { + client.finishUploadProgress(token: registrationToken) reject(error.code, error.message, error) } catch is CancellationError { + client.finishUploadProgress(token: registrationToken) reject("SNI_CANCELLED", "Request cancelled", nil) } catch { + client.finishUploadProgress(token: registrationToken) SniConnectLog.error(SniConnectLog.event("sni_request_result", [ ("result", "error"), ("code", "SNI_UNKNOWN_ERROR"), diff --git a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift index 340bda0fd..4b7b23558 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift @@ -186,6 +186,7 @@ final class SniConnectClient { // token so cancelAllRequests() also covers requests that do not have a requestId. private var activeTasksByToken: [UUID: Task] = [:] private var requestTokensById: [String: UUID] = [:] + private var uploadProgressByToken: [UUID: SniConnectUploadProgress] = [:] private var invalidated = false private let tasksQueue = DispatchQueue(label: "com.onekey.sni.connect.tasks", attributes: .concurrent) private let requestLimiter = SniConnectRequestLimiter.shared @@ -261,6 +262,7 @@ final class SniConnectClient { let timeout: TimeInterval // Set only by uploadFile(); already resolved inside the app sandbox. var bodyFileURL: URL? = nil + var uploadProgress: SniConnectUploadProgress? = nil // Advanced timeout configurations let connectTimeout: TimeInterval? // Connection establishment timeout @@ -586,6 +588,7 @@ final class SniConnectClient { ])) return false } + self.uploadProgressByToken.removeValue(forKey: token) task.cancel() SniConnectLog.info(SniConnectLog.event("sni_cancel", [ ("requestId", requestId), @@ -617,6 +620,7 @@ final class SniConnectClient { } self.activeTasksByToken.removeAll() self.requestTokensById.removeAll() + self.uploadProgressByToken.removeAll() SniConnectLog.info(SniConnectLog.event(logAction, [ ("cancelledCount", tasks.count), ("success", true), @@ -625,7 +629,11 @@ final class SniConnectClient { } /// Register an active task immediately after creation, before JS can cancel it. - func registerTask(_ task: Task, for requestId: String?) -> UUID { + func registerTask( + _ task: Task, + for requestId: String?, + uploadProgress: SniConnectUploadProgress? = nil + ) -> UUID { let token = UUID() var shouldCancel = false tasksQueue.sync(flags: .barrier) { [weak self] in @@ -636,6 +644,7 @@ final class SniConnectClient { } if let requestId = requestId, let previousToken = self.requestTokensById[requestId], let previousTask = self.activeTasksByToken.removeValue(forKey: previousToken) { + self.uploadProgressByToken.removeValue(forKey: previousToken) previousTask.cancel() SniConnectLog.warn(SniConnectLog.event("sni_duplicate_request_id", [ ("requestId", requestId), @@ -643,6 +652,9 @@ final class SniConnectClient { ])) } self.activeTasksByToken[token] = task + if let uploadProgress = uploadProgress { + self.uploadProgressByToken[token] = uploadProgress + } if let requestId = requestId { self.requestTokensById[requestId] = token } @@ -655,14 +667,31 @@ final class SniConnectClient { /// Unregister a completed or failed task func unregisterTask(requestId: String?, token: UUID) { - tasksQueue.async(flags: .barrier) { [weak self] in + tasksQueue.sync(flags: .barrier) { [weak self] in self?.activeTasksByToken.removeValue(forKey: token) + self?.uploadProgressByToken.removeValue(forKey: token) if let requestId = requestId, self?.requestTokensById[requestId] == token { self?.requestTokensById.removeValue(forKey: requestId) } } } + func getUploadProgress(requestId: String) -> [String: Int64]? { + return tasksQueue.sync { () -> [String: Int64]? in + guard let token = requestTokensById[requestId], + let progress = uploadProgressByToken[token] else { + return nil + } + return progress.snapshot() + } + } + + func finishUploadProgress(token: UUID) { + _ = tasksQueue.sync(flags: .barrier) { + uploadProgressByToken.removeValue(forKey: token) + } + } + func performRequest(config: RequestConfig) async throws -> Response { let startedAt = Date() // Check if task is cancelled @@ -783,6 +812,7 @@ final class SniConnectClient { } mutableRequest.httpBody = fileData bodyBytes = fileData.count + config.uploadProgress?.setTotal(fileData.count) } else if let bodyString = config.body, let bodyData = bodyString.data(using: .utf8) { mutableRequest.httpBody = bodyData bodyBytes = bodyData.count @@ -790,6 +820,14 @@ final class SniConnectClient { // Per-request connect timeout (avoids the process-global setter race). EMASCurlProtocol.setConnectTimeoutIntervalFor(mutableRequest, connectTimeoutInterval: connectTimeoutSeconds) + if let uploadProgress = config.uploadProgress { + EMASCurlProtocol.setUploadProgressUpdateBlockFor( + mutableRequest, + uploadProgressUpdateBlock: { _, _, totalBytesSent, _ in + uploadProgress.record(totalBytesSent) + } + ) + } let request = mutableRequest as URLRequest SniConnectLog.info(SniConnectLog.event("sni_request_start", [ diff --git a/native-modules/react-native-sni-connect/ios/SniConnectCore.swift b/native-modules/react-native-sni-connect/ios/SniConnectCore.swift index 4c6309ac0..19d06f3e3 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnectCore.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnectCore.swift @@ -1,5 +1,29 @@ import Foundation +final class SniConnectUploadProgress { + private let lock = NSLock() + private var sent: Int64 = 0 + private var total: Int64 = 0 + + func setTotal(_ bytes: Int) { + lock.lock() + total = Int64(bytes) + lock.unlock() + } + + func record(_ bytes: Int64) { + lock.lock() + sent = max(sent, min(max(bytes, 0), total)) + lock.unlock() + } + + func snapshot() -> [String: Int64] { + lock.lock() + defer { lock.unlock() } + return ["bytesSent": sent, "totalBytes": total] + } +} + enum SniConnectSessionDelegatePolicy { static let responseDispositionWithoutForwardingDelegate: URLSession.ResponseDisposition = .allow } diff --git a/native-modules/react-native-sni-connect/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift b/native-modules/react-native-sni-connect/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift index 00a89de6e..894712ddb 100644 --- a/native-modules/react-native-sni-connect/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift +++ b/native-modules/react-native-sni-connect/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift @@ -3,6 +3,17 @@ import XCTest final class SniConnectValidationTests: XCTestCase { + func testUploadProgressKeepsMonotonicCountWithinBodyLength() { + let progress = SniConnectUploadProgress() + XCTAssertEqual(progress.snapshot(), ["bytesSent": 0, "totalBytes": 0]) + progress.setTotal(10) + progress.record(4) + progress.record(1) // Delayed or lower transport callbacks cannot move it backward. + XCTAssertEqual(progress.snapshot(), ["bytesSent": 4, "totalBytes": 10]) + progress.record(20) + XCTAssertEqual(progress.snapshot(), ["bytesSent": 10, "totalBytes": 10]) + } + func testUnexpectedResolverHostIsLoggedInPlaintextAndStillRejected() throws { let registry = SniConnectPinnedResolverRegistry() let resolverClass: AnyClass = try registry.resolverClass(hostname: "example.com", ip: "93.184.216.34") { diff --git a/native-modules/react-native-sni-connect/src/NativeSniConnect.ts b/native-modules/react-native-sni-connect/src/NativeSniConnect.ts index 59ced8ea0..ee6abfd89 100644 --- a/native-modules/react-native-sni-connect/src/NativeSniConnect.ts +++ b/native-modules/react-native-sni-connect/src/NativeSniConnect.ts @@ -75,6 +75,11 @@ export type SniConnectResponse = { multiValueHeaders?: MultiValueHeaderMap; }; +export type SniConnectUploadProgress = { + bytesSent: Double; + totalBytes: Double; +}; + export type SniConnectDebugTarget = { ip: string; hostname: string; @@ -94,6 +99,9 @@ export interface Spec extends TurboModule { uploadFile( config: NativeSniConnectUploadFileRequest ): Promise; + getUploadProgress( + requestId: string + ): Promise; cancelRequest(requestId: string): Promise<{ success: boolean }>; cancelAllRequests(): Promise<{ success: boolean }>; clearDNSCache(): Promise<{ success: boolean }>; diff --git a/native-modules/react-native-sni-connect/src/__tests__/index.test.ts b/native-modules/react-native-sni-connect/src/__tests__/index.test.ts index a491aff49..fa18c10a6 100644 --- a/native-modules/react-native-sni-connect/src/__tests__/index.test.ts +++ b/native-modules/react-native-sni-connect/src/__tests__/index.test.ts @@ -8,6 +8,7 @@ jest.mock('../NativeSniConnect', () => ({ getDebugSnapshot: jest.fn(), isProxyActiveForUrl: jest.fn(), uploadFile: jest.fn(), + getUploadProgress: jest.fn(), }, })); @@ -16,6 +17,7 @@ import { cancelRequest, clearDNSCache, getDebugSnapshot, + getUploadProgress, isProxyActiveForUrl, isUploadFileSupported, request, @@ -34,6 +36,7 @@ const mockNativeModule = NativeSniConnect as unknown as { getDebugSnapshot: jest.Mock; isProxyActiveForUrl: jest.Mock; uploadFile: jest.Mock; + getUploadProgress: jest.Mock; }; describe('react-native-sni-connect public API', () => { @@ -100,6 +103,23 @@ describe('react-native-sni-connect public API', () => { ); }); + it('returns upload progress from native when supported', async () => { + const snapshot = { bytesSent: 1024, totalBytes: 2048 }; + mockNativeModule.getUploadProgress.mockResolvedValue(snapshot); + await expect(getUploadProgress('upload-1')).resolves.toBe(snapshot); + expect(mockNativeModule.getUploadProgress).toHaveBeenCalledWith('upload-1'); + }); + + it('returns null when an older native binary lacks upload progress', async () => { + const nativeProgress = mockNativeModule.getUploadProgress; + try { + Reflect.deleteProperty(mockNativeModule, 'getUploadProgress'); + await expect(getUploadProgress('upload-1')).resolves.toBeNull(); + } finally { + mockNativeModule.getUploadProgress = nativeProgress; + } + }); + it('forwards cancelRequest() with the request id', async () => { mockNativeModule.cancelRequest.mockResolvedValue({ success: true }); await expect(cancelRequest('req-1')).resolves.toEqual({ success: true }); diff --git a/native-modules/react-native-sni-connect/src/index.tsx b/native-modules/react-native-sni-connect/src/index.tsx index a742a334a..a15daf1c1 100644 --- a/native-modules/react-native-sni-connect/src/index.tsx +++ b/native-modules/react-native-sni-connect/src/index.tsx @@ -4,6 +4,7 @@ import NativeSniConnect, { type SniConnectRequest, type SniConnectResponse, type SniConnectUploadFileRequest, + type SniConnectUploadProgress, } from './NativeSniConnect'; export function request( @@ -27,6 +28,15 @@ export async function uploadFile( return NativeSniConnect.uploadFile(config); } +export async function getUploadProgress( + requestId: string +): Promise { + if (typeof NativeSniConnect.getUploadProgress !== 'function') { + return null; + } + return NativeSniConnect.getUploadProgress(requestId); +} + export function cancelRequest( requestId: string ): Promise<{ success: boolean }> { @@ -62,4 +72,5 @@ export type { SniConnectResponse, SniConnectUploadFileMethod, SniConnectUploadFileRequest, + SniConnectUploadProgress, } from './NativeSniConnect'; From 163ca02864ae80beef869639df6fdf95623f0a3d Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 13 Nov 2026 20:12:12 +0800 Subject: [PATCH 2/2] fix: require matching native SNI progress module --- native-modules/react-native-sni-connect/SPEC.md | 6 +++--- native-modules/react-native-sni-connect/docs/SPEC.md | 8 ++++---- .../src/__tests__/index.test.ts | 10 ---------- native-modules/react-native-sni-connect/src/index.tsx | 5 +---- 4 files changed, 8 insertions(+), 21 deletions(-) diff --git a/native-modules/react-native-sni-connect/SPEC.md b/native-modules/react-native-sni-connect/SPEC.md index b41f98ac9..573943057 100644 --- a/native-modules/react-native-sni-connect/SPEC.md +++ b/native-modules/react-native-sni-connect/SPEC.md @@ -180,8 +180,8 @@ export type SniConnectUploadFileRequest = SniConnectRequestBase & { I/O. - The caller supplies `content-type` in `headers`; `Content-Length` stays module-owned (Section 4.4). -- Native React Native upload progress is available through the optional - `getUploadProgress(requestId)` capability described in the +- Native React Native upload progress is available through + `getUploadProgress(requestId)` as described in the [native upload contract](docs/SPEC.md). `timeout` covers the whole upload. - Status: **Implemented** on iOS and Android React Native. Node/Desktop and shared JS adapters do not expose it; adapters MUST feature-detect it and @@ -223,7 +223,7 @@ export type SniConnectResponse = { [native upload contract](docs/SPEC.md) for unsupported Promise rejection and consumption-time file bounds) - `getUploadProgress(requestId): Promise<{ bytesSent: number; totalBytes: number } | null>` - (React Native only; optional on older native binaries) + (React Native only; requires the matching JS and native release) - `cancelRequest(requestId): Promise<{ success: boolean }>` - `cancelAllRequests(): Promise<{ success: boolean }>` - `clearDNSCache(): Promise<{ success: boolean }>` diff --git a/native-modules/react-native-sni-connect/docs/SPEC.md b/native-modules/react-native-sni-connect/docs/SPEC.md index eed21f1de..68096a137 100644 --- a/native-modules/react-native-sni-connect/docs/SPEC.md +++ b/native-modules/react-native-sni-connect/docs/SPEC.md @@ -12,9 +12,9 @@ upload boundary and its focused review corrections. absent, it rejects with an Error whose `code` is `SNI_UNSUPPORTED`. - Supported native methods receive the caller's config unchanged. - `getUploadProgress(requestId)` returns the latest active upload snapshot or - `null` for an unknown, inactive, or non-upload request ID. New JS checks for - this native method before invoking it and resolves `null` with older binaries. - It does not change `uploadFile(config)` or its response/error behavior. + `null` for an unknown, inactive, or non-upload request ID. JS calls the native + method directly; the JS and native changes must ship together. This does not + change `uploadFile(config)` or its response/error behavior. ## Upload progress @@ -269,7 +269,7 @@ retain the generic logger's sensitive-data filtering. ## Conformance and remaining evidence - TypeScript typecheck and React Native Codegen parse cover the nullable - progress result; JS API tests cover forwarding and old-native `null` fallback. + progress result; JS API tests cover forwarding. - Android RequestBody test verifies progress from sink-accepted payload bytes. iOS uses EMASCurl 1.5.5's per-request callback, which counts bytes read into libcurl. Native build and device/network behavior remain unverified here. diff --git a/native-modules/react-native-sni-connect/src/__tests__/index.test.ts b/native-modules/react-native-sni-connect/src/__tests__/index.test.ts index fa18c10a6..078fb1a8b 100644 --- a/native-modules/react-native-sni-connect/src/__tests__/index.test.ts +++ b/native-modules/react-native-sni-connect/src/__tests__/index.test.ts @@ -110,16 +110,6 @@ describe('react-native-sni-connect public API', () => { expect(mockNativeModule.getUploadProgress).toHaveBeenCalledWith('upload-1'); }); - it('returns null when an older native binary lacks upload progress', async () => { - const nativeProgress = mockNativeModule.getUploadProgress; - try { - Reflect.deleteProperty(mockNativeModule, 'getUploadProgress'); - await expect(getUploadProgress('upload-1')).resolves.toBeNull(); - } finally { - mockNativeModule.getUploadProgress = nativeProgress; - } - }); - it('forwards cancelRequest() with the request id', async () => { mockNativeModule.cancelRequest.mockResolvedValue({ success: true }); await expect(cancelRequest('req-1')).resolves.toEqual({ success: true }); diff --git a/native-modules/react-native-sni-connect/src/index.tsx b/native-modules/react-native-sni-connect/src/index.tsx index a15daf1c1..10eb7ec32 100644 --- a/native-modules/react-native-sni-connect/src/index.tsx +++ b/native-modules/react-native-sni-connect/src/index.tsx @@ -28,12 +28,9 @@ export async function uploadFile( return NativeSniConnect.uploadFile(config); } -export async function getUploadProgress( +export function getUploadProgress( requestId: string ): Promise { - if (typeof NativeSniConnect.getUploadProgress !== 'function') { - return null; - } return NativeSniConnect.getUploadProgress(requestId); }