Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/test-kmp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ on:
- 'packages/react-native/ReactCommon/react/utils/FloatComparison.h'
- 'packages/react-native/ReactCommon/react/utils/ManagedObjectWrapper.*'
- 'packages/react-native/React/React-RCTFabric.podspec'
- 'packages/react-native/React-Core.podspec'
- 'packages/react-native/React/Base/RCTMultipartStreamReader.*'
- 'packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt'
- 'packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt'
- 'packages/rn-tester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m'
- 'packages/react-native/scripts/react_native_pods.rb'
- 'packages/react-native/scripts/cocoapods/kmp.rb'
- 'packages/react-native/Package.swift'
Expand Down Expand Up @@ -72,6 +77,11 @@ on:
- 'packages/react-native/ReactCommon/react/utils/FloatComparison.h'
- 'packages/react-native/ReactCommon/react/utils/ManagedObjectWrapper.*'
- 'packages/react-native/React/React-RCTFabric.podspec'
- 'packages/react-native/React-Core.podspec'
- 'packages/react-native/React/Base/RCTMultipartStreamReader.*'
- 'packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt'
- 'packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt'
- 'packages/rn-tester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m'
- 'packages/react-native/scripts/react_native_pods.rb'
- 'packages/react-native/scripts/cocoapods/kmp.rb'
- 'packages/react-native/Package.swift'
Expand All @@ -92,6 +102,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
Expand Down Expand Up @@ -135,6 +149,12 @@ jobs:
env:
RCT_KMP_BUILD_TYPE: Release
run: ./scripts/test-apple-gradient.sh
- name: Compare native and shared multipart adapters
if: ${{ !cancelled() && steps.shared.outcome == 'success' }}
working-directory: packages/react-native/ReactShared
run: |
./scripts/test-apple-multipart.sh
python3 scripts/test-android-multipart.py --max-workers 2
- name: Test packaged XCFramework consumption
if: ${{ !cancelled() && steps.shared.outcome == 'success' }}
working-directory: packages/react-native/ReactShared
Expand All @@ -159,6 +179,8 @@ jobs:
path: |
packages/react-native/ReactShared/build/reports/tests
packages/react-native/ReactShared/build/test-results
packages/react-native/ReactShared/build/apple-multipart-test/**/*.log
packages/react-native/ReactShared/build/android-multipart-test/build/test-results
packages/react-native/ReactShared/build/apple-distribution/**/*.json
packages/react-native/ReactShared/build/apple-distribution/**/*.log
packages/react-native/ReactShared/build/apple-distribution/**/consumer.xcresult
Expand Down
18 changes: 17 additions & 1 deletion packages/react-native/React-Core.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ require "json"

package = JSON.parse(File.read(File.join(__dir__, "package.json")))
version = package['version']
kmp_enabled = ENV['RCT_USE_KMP'] == '1'
if kmp_enabled && ENV['RCT_USE_PREBUILT_RNCORE'] != '0'
raise 'RCT_USE_KMP=1 requires React Native core source builds. Use use_react_native! or set RCT_USE_PREBUILT_RNCORE=0.'
end

source = { :git => 'https://github.com/facebook/react-native.git' }
if version == '1000.0.0'
Expand Down Expand Up @@ -54,13 +58,25 @@ Pod::Spec.new do |s|
s.compiler_flags = js_engine_flags()
s.header_dir = "React"
s.weak_framework = "JavaScriptCore"
s.pod_target_xcconfig = {
pod_target_xcconfig = {
"HEADER_SEARCH_PATHS" => header_search_paths,
"DEFINES_MODULE" => "YES",
"GCC_PREPROCESSOR_DEFINITIONS" => "RCT_METRO_PORT=${RCT_METRO_PORT}",
"CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard(),
"FRAMEWORK_SEARCH_PATHS" => frameworks_search_paths.join(" ")
}
if kmp_enabled
s.dependency 'React-KMP'
# React-Core is the common dependency of all Apple consumers. With dynamic
# pods it owns the Kotlin runtime once, even before Core calls a shared API.
# Static builds link the archive in the application via the post-install helper.
%w[iphoneos iphonesimulator].each do |sdk|
pod_target_xcconfig["GCC_PREPROCESSOR_DEFINITIONS[sdk=#{sdk}*]"] = '$(inherited) RCT_USE_KMP=1'
pod_target_xcconfig["FRAMEWORK_SEARCH_PATHS[sdk=#{sdk}*]"] = '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)/ReactNativeSharedKMP"'
pod_target_xcconfig["OTHER_LDFLAGS[sdk=#{sdk}*]"] = '$(inherited) -ObjC -framework ReactNativeShared'
end
end
s.pod_target_xcconfig = pod_target_xcconfig
s.user_target_xcconfig = { "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/Headers/Private/React-Core\""}
s.default_subspec = "Default"

Expand Down
56 changes: 49 additions & 7 deletions packages/react-native/React/Base/RCTMultipartStreamReader.m
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@

#import "RCTMultipartStreamReader.h"
#import <QuartzCore/QuartzCore.h>
#import <TargetConditionals.h>

#if RCT_USE_KMP && TARGET_OS_IOS && !TARGET_OS_MACCATALYST
#define RCT_MULTIPART_USE_KMP 1
#import <ReactNativeShared/ReactNativeShared.h>
#else
#define RCT_MULTIPART_USE_KMP 0
#endif

#define CRLF @"\r\n"

Expand All @@ -30,6 +38,12 @@ - (NSDictionary *)parseHeaders:(NSData *)data
{
NSMutableDictionary *headers = [NSMutableDictionary new];
NSString *text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
#if RCT_MULTIPART_USE_KMP
for (RNSMultipartHeader *header in [RNSMultipartHeaders.shared parseText:text ?: @""]) {
NSString *value = [header.value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
[headers setValue:value forKey:header.name];
}
#else
NSArray<NSString *> *lines = [text componentsSeparatedByString:CRLF];
for (NSString *line in lines) {
NSUInteger location = [line rangeOfString:@":"].location;
Expand All @@ -41,6 +55,7 @@ - (NSDictionary *)parseHeaders:(NSData *)data
stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
[headers setValue:value forKey:key];
}
#endif
return headers;
}

Expand Down Expand Up @@ -84,13 +99,17 @@ - (void)emitProgress:(NSDictionary *)headers
- (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback
progressCallback:(RCTMultipartProgressCallback)progressCallback
{
NSInteger chunkStart = 0;
NSInteger bytesSeen = 0;

NSData *delimiter =
[[NSString stringWithFormat:@"%@--%@%@", CRLF, _boundary, CRLF] dataUsingEncoding:NSUTF8StringEncoding];
NSData *closeDelimiter =
[[NSString stringWithFormat:@"%@--%@--%@", CRLF, _boundary, CRLF] dataUsingEncoding:NSUTF8StringEncoding];
#if RCT_MULTIPART_USE_KMP
RNSMultipartFraming *framing = [[RNSMultipartFraming alloc] initWithDelimiterLength:(int32_t)delimiter.length
closeDelimiterLength:(int32_t)closeDelimiter.length];
#else
NSInteger chunkStart = 0;
NSInteger bytesSeen = 0;
#endif
NSMutableData *content = [[NSMutableData alloc] initWithCapacity:1];
NSDictionary *currentHeaders = nil;
NSUInteger currentHeadersLength = 0;
Expand All @@ -101,9 +120,13 @@ - (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback
[_stream open];
while (true) {
BOOL isCloseDelimiter = NO;
// Search only a subset of chunk that we haven't seen before + few bytes
// to allow for the edge case when the delimiter is cut by read call
#if RCT_MULTIPART_USE_KMP
NSInteger searchStart = [framing searchStartBufferOffset:0];
NSInteger chunkStart = [framing partStartBufferOffset:0];
#else
// Preserve overlap when a delimiter is split between reads.
NSInteger searchStart = MAX(bytesSeen - (NSInteger)closeDelimiter.length, chunkStart);
#endif
NSRange remainingBufferRange = NSMakeRange(searchStart, content.length - searchStart);

// Check for delimiters.
Expand All @@ -113,6 +136,14 @@ - (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback
range = [content rangeOfData:closeDelimiter options:0 range:remainingBufferRange];
}

#if RCT_MULTIPART_USE_KMP
NSInteger index = range.location == NSNotFound ? -1 : (NSInteger)range.location;
RNSMultipartChunk *chunk = [framing nextChunkBufferLength:content.length
bufferOffset:0
delimiterIndex:isCloseDelimiter ? -1 : index
closeDelimiterIndex:isCloseDelimiter ? index : -1];
#endif

if (range.location == NSNotFound) {
if (currentHeaders == nil) {
// Check for the headers delimiter.
Expand All @@ -131,7 +162,9 @@ - (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback
callback:progressCallback];
}

#if !RCT_MULTIPART_USE_KMP
bytesSeen = content.length;
#endif
NSInteger bytesRead = [_stream read:buffer maxLength:bufferLen];
if (bytesRead <= 0 || _stream.streamError) {
return NO;
Expand All @@ -140,12 +173,19 @@ - (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback
continue;
}

#if RCT_MULTIPART_USE_KMP
NSInteger chunkEnd = chunk.end;
BOOL isPart = chunk.isPart;
isCloseDelimiter = chunk.isLast;
#else
NSInteger chunkEnd = range.location;
NSInteger length = chunkEnd - chunkStart;
BOOL isPart = chunkStart > 0;
bytesSeen = chunkEnd;
#endif
NSInteger length = chunkEnd - chunkStart;

// Ignore preamble
if (chunkStart > 0) {
if (isPart) {
NSData *chunk = [content subdataWithRange:NSMakeRange(chunkStart, length)];
[self emitProgress:currentHeaders
contentLength:chunk.length - currentHeadersLength
Expand All @@ -160,7 +200,9 @@ - (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback
return YES;
}

#if !RCT_MULTIPART_USE_KMP
chunkStart = chunkEnd + delimiter.length;
#endif
}
}

Expand Down
2 changes: 0 additions & 2 deletions packages/react-native/React/React-RCTFabric.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,6 @@ Pod::Spec.new do |s|
'GCC_PREPROCESSOR_DEFINITIONS[sdk=iphonesimulator*]' => '$(inherited) RCT_USE_KMP=1',
'FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]' => '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)/ReactNativeSharedKMP"',
'FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]' => '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)/ReactNativeSharedKMP"',
'OTHER_LDFLAGS[sdk=iphoneos*]' => '$(inherited) -framework ReactNativeShared',
'OTHER_LDFLAGS[sdk=iphonesimulator*]' => '$(inherited) -framework ReactNativeShared',
})
end
s.pod_target_xcconfig = pod_target_xcconfig
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

package com.facebook.react.devsupport

import com.facebook.react.shared.MultipartFraming
import com.facebook.react.shared.MultipartHeaders
import java.io.IOException
import java.util.TreeMap
import kotlin.math.max
Expand Down Expand Up @@ -52,30 +54,22 @@ internal class MultipartStreamReader(
// throughput and fewer syscalls. For a 2MB bundle: ~128 reads instead of ~512.
// Memory impact is negligible (12KB increase) while I/O overhead is significantly reduced.
val bufferLen = 16 * 1024
var chunkStart: Long = 0
var bytesSeen: Long = 0
val framing = MultipartFraming(delimiter.size(), closeDelimiter.size())
var bufferOffset = 0L
val content = Buffer()

var currentHeaders: Map<String, String>? = null
var currentBodyStartIndexInContent: Long = -1

while (true) {
var isCloseDelimiter = false

// Search only a subset of chunk that we haven't seen before + few bytes
// to allow for the edge case when the delimiter is cut by read call.
val searchStart =
max((bytesSeen - closeDelimiter.size()).toDouble(), chunkStart.toDouble()).toLong()

var indexOfDelimiter = content.indexOf(delimiter, searchStart)
if (indexOfDelimiter == -1L) {
isCloseDelimiter = true
indexOfDelimiter = content.indexOf(closeDelimiter, searchStart)
}

if (indexOfDelimiter == -1L) {
bytesSeen = content.size()

val searchStart = framing.searchStart(bufferOffset)
val indexOfDelimiter = content.indexOf(delimiter, searchStart)
val indexOfCloseDelimiter =
if (indexOfDelimiter < 0) content.indexOf(closeDelimiter, searchStart) else -1L
val chunk =
framing.nextChunk(content.size(), bufferOffset, indexOfDelimiter, indexOfCloseDelimiter)

if (chunk == null) {
if (currentHeaders == null) {
val indexOfHeadersDelimiter = content.indexOf(headersDelimiter, searchStart)
if (indexOfHeadersDelimiter >= 0) {
Expand All @@ -97,44 +91,37 @@ internal class MultipartStreamReader(
continue
}

val chunkEnd = indexOfDelimiter
val length = chunkEnd - chunkStart
val chunkEnd = chunk.end
val length = chunkEnd - chunk.start

// Ignore preamble
if (chunkStart > 0) {
if (chunk.isPart) {
if (currentHeaders != null && currentBodyStartIndexInContent >= 0) {
val loadedFinal = max(0L, chunkEnd - currentBodyStartIndexInContent)
emitProgress(currentHeaders, loadedFinal, true, listener)
}
content.skip(chunkStart)
emitChunk(content, length, isCloseDelimiter, listener)
content.skip(chunk.start)
emitChunk(content, length, chunk.isLast, listener)

currentHeaders = null
currentBodyStartIndexInContent = -1
} else {
content.skip(chunkEnd)
}
if (isCloseDelimiter) {
if (chunk.isLast) {
return true
}

chunkStart = delimiter.size().toLong()
bytesSeen = chunkStart
bufferOffset += chunkEnd
}
}

private fun parseHeaders(data: Buffer): Map<String, String> {
// Header names are case-insensitive
val headers: MutableMap<String, String> = TreeMap(String.CASE_INSENSITIVE_ORDER)
val text = data.readUtf8()
val lines = text.split(CRLF.toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
for (line in lines) {
val indexOfSeparator = line.indexOf(":")
if (indexOfSeparator == -1) {
continue
}
val key = line.substring(0, indexOfSeparator).trim { it <= ' ' }
val value = line.substring(indexOfSeparator + 1).trim { it <= ' ' }
for (header in MultipartHeaders.parse(text)) {
val key = header.name.trim { it <= ' ' }
val value = header.value.trim { it <= ' ' }
headers[key] = value
}
return headers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,56 @@ class MultipartStreamReaderTest {
}
}

@Test
fun testHeaderWhitespaceDuplicatesAndColonValues() {
val source =
Buffer()
.writeUtf8(
"\r\n--sample\r\n X-Name : first\r\nx-name: second:extra \r\ninvalid\r\n\r\nbody\r\n--sample--\r\n"
)
var calls = 0
assertThat(
MultipartStreamReader(source, "sample")
.readAllParts(
object : CallCountTrackingChunkCallback() {
override fun onChunkComplete(
headers: Map<String, String>,
body: BufferedSource,
isLastChunk: Boolean,
) {
calls++
assertThat(headers).hasSize(1)
assertThat(headers["X-Name"]).isEqualTo("second:extra")
assertThat(body.readUtf8()).isEqualTo("body")
assertThat(isLastChunk).isTrue()
}
}
)
)
.isTrue()
assertThat(calls).isEqualTo(1)
}

@Test
fun testFinalProgressForFragmentedBody() {
val body = "x".repeat(64 * 1024)
val source =
Buffer()
.writeUtf8(
"\r\n--sample\r\nContent-Length: ${body.length}\r\n\r\n$body\r\n--sample--\r\n"
)
val progress = mutableListOf<Pair<Long, Long>>()
val callback =
object : CallCountTrackingChunkCallback() {
override fun onChunkProgress(headers: Map<String, String>, loaded: Long, total: Long) {
progress.add(loaded to total)
}
}
assertThat(MultipartStreamReader(source, "sample").readAllParts(callback)).isTrue()
assertThat(callback.callCount).isEqualTo(1)
assertThat(progress.last()).isEqualTo(body.length.toLong() to body.length.toLong())
}

internal open class CallCountTrackingChunkCallback : MultipartStreamReader.ChunkListener {
var callCount = 0
private set
Expand Down
Loading
Loading