From e2b25c8c9d81327476cae79200894c7ffec8eb63 Mon Sep 17 00:00:00 2001 From: Alex Kotliarskyi Date: Mon, 3 Oct 2016 17:58:19 -0700 Subject: [PATCH] Add multipart response stream reader Summary: Packager can take a long time to load and the progress is usually displayed in another window (Terminal). I'm adding support for showing a UI inside React Native app for packager's progress when loading a bundle. This is how it will work: 1. React Native sends request to packager with `Accept: multipart/mixed` header. 2. Packager will detect that header to detect that client supports progress events and will reply with `Content-Type: multipart/mixed`. 3. While building the bundle it will emit chunks with small metadata (like `{progress: 0.3}`). In the end it will send the last chunk with the content of the bundle. 4. RN runtime will be receiving the events, for each progress event it will update the UI. The last chunk will be the actual bundle which will end the download process. This workflow is totally backwards-compatible -- normally RN doesn't set the `Accept` header. Reviewed By: mmmulani Differential Revision: D3845684 fbshipit-source-id: 5b3d2c5a4c6f4718d7e5de060d98f17491e82aba --- .../UIExplorer.xcodeproj/project.pbxproj | 4 + .../RCTMultipartStreamReaderTests.m | 107 ++++++++++++++++++ React/Base/RCTMultipartStreamReader.h | 22 ++++ React/Base/RCTMultipartStreamReader.m | 105 +++++++++++++++++ React/React.xcodeproj/project.pbxproj | 6 + 5 files changed, 244 insertions(+) create mode 100644 Examples/UIExplorer/UIExplorerUnitTests/RCTMultipartStreamReaderTests.m create mode 100644 React/Base/RCTMultipartStreamReader.h create mode 100644 React/Base/RCTMultipartStreamReader.m diff --git a/Examples/UIExplorer/UIExplorer.xcodeproj/project.pbxproj b/Examples/UIExplorer/UIExplorer.xcodeproj/project.pbxproj index 48e4e839df2ed0..e4a33a8fe97bad 100644 --- a/Examples/UIExplorer/UIExplorer.xcodeproj/project.pbxproj +++ b/Examples/UIExplorer/UIExplorer.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 001BFCE41D838343008E587E /* RCTMultipartStreamReaderTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 001BFCE31D838343008E587E /* RCTMultipartStreamReaderTests.m */; }; 1300627F1B59179B0043FE5A /* RCTGzipTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 1300627E1B59179B0043FE5A /* RCTGzipTests.m */; }; 13129DD41C85F87C007D611C /* RCTModuleInitNotificationRaceTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 13129DD31C85F87C007D611C /* RCTModuleInitNotificationRaceTests.m */; }; 13417FE91AA91432003F314A /* libRCTImage.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 13417FE81AA91428003F314A /* libRCTImage.a */; }; @@ -196,6 +197,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 001BFCE31D838343008E587E /* RCTMultipartStreamReaderTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTMultipartStreamReaderTests.m; sourceTree = ""; }; 004D289E1AAF61C70097A701 /* UIExplorerUnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UIExplorerUnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 1300627E1B59179B0043FE5A /* RCTGzipTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTGzipTests.m; sourceTree = ""; }; 13129DD31C85F87C007D611C /* RCTModuleInitNotificationRaceTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTModuleInitNotificationRaceTests.m; sourceTree = ""; }; @@ -460,6 +462,7 @@ 134CB9291C85A38800265FA6 /* RCTModuleInitTests.m */, 13129DD31C85F87C007D611C /* RCTModuleInitNotificationRaceTests.m */, 1393D0371B68CD1300E1B601 /* RCTModuleMethodTests.m */, + 001BFCE31D838343008E587E /* RCTMultipartStreamReaderTests.m */, 138D6A161B53CD440074A87E /* RCTShadowViewTests.m */, 1497CFAB1B21F5E400C1F8F2 /* RCTUIManagerTests.m */, 13BCE84E1C9C209600DD7AAD /* RCTComponentPropsTests.m */, @@ -979,6 +982,7 @@ 1497CFB31B21F5E400C1F8F2 /* RCTUIManagerTests.m in Sources */, 13DB03481B5D2ED500C27245 /* RCTJSONTests.m in Sources */, 1497CFAC1B21F5E400C1F8F2 /* RCTAllocationTests.m in Sources */, + 001BFCE41D838343008E587E /* RCTMultipartStreamReaderTests.m in Sources */, 13DF61B61B67A45000EDB188 /* RCTMethodArgumentTests.m in Sources */, 138D6A181B53CD440074A87E /* RCTShadowViewTests.m in Sources */, 13B6C1A31C34225900D3FAF5 /* RCTURLUtilsTests.m in Sources */, diff --git a/Examples/UIExplorer/UIExplorerUnitTests/RCTMultipartStreamReaderTests.m b/Examples/UIExplorer/UIExplorerUnitTests/RCTMultipartStreamReaderTests.m new file mode 100644 index 00000000000000..42b14c8805c14e --- /dev/null +++ b/Examples/UIExplorer/UIExplorerUnitTests/RCTMultipartStreamReaderTests.m @@ -0,0 +1,107 @@ +/** + * The examples provided by Facebook are for non-commercial testing and + * evaluation purposes only. + * + * Facebook reserves all rights not expressly granted. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL + * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +#import +#import "RCTUtils.h" +#import "RCTMultipartStreamReader.h" + +@interface RCTMultipartStreamReaderTests : XCTestCase + +@end + +@implementation RCTMultipartStreamReaderTests + +- (void)testSimpleCase { + NSString *response = + @"preable, should be ignored\r\n" + @"--sample_boundary\r\n" + @"Content-Type: application/json; charset=utf-8\r\n" + @"Content-Length: 2\r\n\r\n" + @"{}\r\n" + @"--sample_boundary--\r\n" + @"epilogue, should be ignored"; + + NSInputStream *inputStream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]]; + RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:inputStream boundary:@"sample_boundary"]; + __block NSInteger count = 0; + BOOL success = [reader readAllParts:^(NSDictionary *headers, NSData *content, BOOL done) { + XCTAssertTrue(done); + XCTAssertEqualObjects(headers[@"Content-Type"], @"application/json; charset=utf-8"); + XCTAssertEqualObjects([[NSString alloc] initWithData:content encoding:NSUTF8StringEncoding], @"{}"); + count++; + }]; + XCTAssertTrue(success); + XCTAssertEqual(count, 1); +} + +- (void)testMultipleParts { + NSString *response = + @"preable, should be ignored\r\n" + @"--sample_boundary\r\n" + @"1\r\n" + @"--sample_boundary\r\n" + @"2\r\n" + @"--sample_boundary\r\n" + @"3\r\n" + @"--sample_boundary--\r\n" + @"epilogue, should be ignored"; + + NSInputStream *inputStream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]]; + RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:inputStream boundary:@"sample_boundary"]; + __block NSInteger count = 0; + BOOL success = [reader readAllParts:^(__unused NSDictionary *headers, NSData *content, BOOL done) { + count++; + XCTAssertEqual(done, count == 3); + NSString *expectedBody = [NSString stringWithFormat:@"%ld", (long)count]; + NSString *actualBody = [[NSString alloc] initWithData:content encoding:NSUTF8StringEncoding]; + XCTAssertEqualObjects(actualBody, expectedBody); + }]; + XCTAssertTrue(success); + XCTAssertEqual(count, 3); +} + +- (void)testNoDelimiter { + NSString *response = @"Yolo"; + + NSInputStream *inputStream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]]; + RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:inputStream boundary:@"sample_boundary"]; + __block NSInteger count = 0; + BOOL success = [reader readAllParts:^(__unused NSDictionary *headers, __unused NSData *content, __unused BOOL done) { + count++; + }]; + XCTAssertFalse(success); + XCTAssertEqual(count, 0); +} + +- (void)testNoCloseDelimiter { + NSString *response = + @"preable, should be ignored\r\n" + @"--sample_boundary\r\n" + @"Content-Type: application/json; charset=utf-8\r\n" + @"Content-Length: 2\r\n\r\n" + @"{}\r\n" + @"--sample_boundary\r\n" + @"incomplete message..."; + + NSInputStream *inputStream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]]; + RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:inputStream boundary:@"sample_boundary"]; + __block NSInteger count = 0; + BOOL success = [reader readAllParts:^(__unused NSDictionary *headers, __unused NSData *content, __unused BOOL done) { + count++; + }]; + XCTAssertFalse(success); + XCTAssertEqual(count, 1); +} + +@end diff --git a/React/Base/RCTMultipartStreamReader.h b/React/Base/RCTMultipartStreamReader.h new file mode 100644 index 00000000000000..ab77bd1ae85e28 --- /dev/null +++ b/React/Base/RCTMultipartStreamReader.h @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +#import + +typedef void (^RCTMultipartCallback)(NSDictionary *headers, NSData *content, BOOL done); + + +// RCTMultipartStreamReader can be used to parse responses with Content-Type: multipart/mixed +// See https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html +@interface RCTMultipartStreamReader : NSObject + +- (instancetype)initWithInputStream:(NSInputStream *)stream boundary:(NSString *)boundary; +- (BOOL)readAllParts:(RCTMultipartCallback)callback; + +@end diff --git a/React/Base/RCTMultipartStreamReader.m b/React/Base/RCTMultipartStreamReader.m new file mode 100644 index 00000000000000..458ef70a5ddef2 --- /dev/null +++ b/React/Base/RCTMultipartStreamReader.m @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +#import "RCTMultipartStreamReader.h" + +#define CRLF @"\r\n" + +@implementation RCTMultipartStreamReader { + __strong NSInputStream *_stream; + __strong NSString *_boundary; +} + +- (instancetype)initWithInputStream:(NSInputStream *)stream boundary:(NSString *)boundary +{ + if (self = [super init]) { + _stream = stream; + _boundary = boundary; + } + return self; +} + +- (NSDictionary *)parseHeaders:(NSData *)data +{ + NSMutableDictionary *headers = [NSMutableDictionary new]; + NSString *text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + NSArray *lines = [text componentsSeparatedByString:CRLF]; + for (NSString *line in lines) { + NSUInteger location = [line rangeOfString:@":"].location; + if (location == NSNotFound) { + continue; + } + NSString *key = [line substringToIndex:location]; + NSString *value = [[line substringFromIndex:location + 1] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + [headers setValue:value forKey:key]; + } + return headers; +} + +- (void)emitChunk:(NSData *)data callback:(RCTMultipartCallback)callback done:(BOOL)done +{ + NSData *marker = [CRLF CRLF dataUsingEncoding:NSUTF8StringEncoding]; + NSRange range = [data rangeOfData:marker options:0 range:NSMakeRange(0, data.length)]; + if (range.location == NSNotFound) { + callback(nil, data, done); + } else { + NSData *headersData = [data subdataWithRange:NSMakeRange(0, range.location)]; + NSInteger bodyStart = range.location + marker.length; + NSData *bodyData = [data subdataWithRange:NSMakeRange(bodyStart, data.length - bodyStart)]; + callback([self parseHeaders:headersData], bodyData, done); + } +} + +- (BOOL)readAllParts:(RCTMultipartCallback)callback +{ + NSInteger start = 0; + NSData *delimiter = [[NSString stringWithFormat:@"%@--%@%@", CRLF, _boundary, CRLF] dataUsingEncoding:NSUTF8StringEncoding]; + NSData *closeDelimiter = [[NSString stringWithFormat:@"%@--%@--%@", CRLF, _boundary, CRLF] dataUsingEncoding:NSUTF8StringEncoding]; + NSMutableData *content = [[NSMutableData alloc] initWithCapacity:1]; + + const NSUInteger bufferLen = 4 * 1024; + uint8_t buffer[bufferLen]; + + [_stream open]; + while (true) { + BOOL isCloseDelimiter = NO; + NSRange remainingBufferRange = NSMakeRange(start, content.length - start); + NSRange range = [content rangeOfData:delimiter options:0 range:remainingBufferRange]; + if (range.location == NSNotFound) { + isCloseDelimiter = YES; + range = [content rangeOfData:closeDelimiter options:0 range:remainingBufferRange]; + } + + if (range.location == NSNotFound) { + NSInteger bytesRead = [_stream read:buffer maxLength:bufferLen]; + if (bytesRead <= 0 || _stream.streamError) { + return NO; + } + [content appendBytes:buffer length:bytesRead]; + continue; + } + + NSInteger end = range.location; + NSInteger length = end - start; + + // Ignore preamble + if (start > 0) { + NSData *chunk = [content subdataWithRange:NSMakeRange(start, length)]; + [self emitChunk:chunk callback:callback done:isCloseDelimiter]; + } + + if (isCloseDelimiter) { + return YES; + } + + start = end + delimiter.length; + } +} + +@end diff --git a/React/React.xcodeproj/project.pbxproj b/React/React.xcodeproj/project.pbxproj index cacd9c3197d366..a2ca6a696c05e8 100644 --- a/React/React.xcodeproj/project.pbxproj +++ b/React/React.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 000E6CEB1AB0E980000CDF4D /* RCTSourceCode.m in Sources */ = {isa = PBXBuildFile; fileRef = 000E6CEA1AB0E980000CDF4D /* RCTSourceCode.m */; }; + 001BFCD01D8381DE008E587E /* RCTMultipartStreamReader.m in Sources */ = {isa = PBXBuildFile; fileRef = 001BFCCF1D8381DE008E587E /* RCTMultipartStreamReader.m */; }; 008341F61D1DB34400876D9A /* RCTJSStackFrame.m in Sources */ = {isa = PBXBuildFile; fileRef = 008341F41D1DB34400876D9A /* RCTJSStackFrame.m */; }; 131B6AF41AF1093D00FFC3E0 /* RCTSegmentedControl.m in Sources */ = {isa = PBXBuildFile; fileRef = 131B6AF11AF1093D00FFC3E0 /* RCTSegmentedControl.m */; }; 131B6AF51AF1093D00FFC3E0 /* RCTSegmentedControlManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 131B6AF31AF1093D00FFC3E0 /* RCTSegmentedControlManager.m */; }; @@ -122,6 +123,8 @@ /* Begin PBXFileReference section */ 000E6CE91AB0E97F000CDF4D /* RCTSourceCode.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTSourceCode.h; sourceTree = ""; }; 000E6CEA1AB0E980000CDF4D /* RCTSourceCode.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTSourceCode.m; sourceTree = ""; }; + 001BFCCE1D8381DE008E587E /* RCTMultipartStreamReader.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTMultipartStreamReader.h; sourceTree = ""; }; + 001BFCCF1D8381DE008E587E /* RCTMultipartStreamReader.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTMultipartStreamReader.m; sourceTree = ""; }; 008341F41D1DB34400876D9A /* RCTJSStackFrame.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTJSStackFrame.m; sourceTree = ""; }; 008341F51D1DB34400876D9A /* RCTJSStackFrame.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTJSStackFrame.h; sourceTree = ""; }; 131541CF1D3E4893006A0E08 /* CSSLayout-internal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "CSSLayout-internal.h"; sourceTree = ""; }; @@ -614,6 +617,8 @@ 14C2CA731B3AC64300E6CBB2 /* RCTModuleData.mm */, 14C2CA6F1B3AC63800E6CBB2 /* RCTModuleMethod.h */, 14C2CA701B3AC63800E6CBB2 /* RCTModuleMethod.m */, + 001BFCCE1D8381DE008E587E /* RCTMultipartStreamReader.h */, + 001BFCCF1D8381DE008E587E /* RCTMultipartStreamReader.m */, 13A6E20F1C19ABC700845B82 /* RCTNullability.h */, 13A6E20C1C19AA0C00845B82 /* RCTParserUtils.h */, 13A6E20D1C19AA0C00845B82 /* RCTParserUtils.m */, @@ -730,6 +735,7 @@ 13723B501A82FD3C00F88898 /* RCTStatusBarManager.m in Sources */, 000E6CEB1AB0E980000CDF4D /* RCTSourceCode.m in Sources */, 14A43DF31C20B1C900794BC8 /* RCTJSCProfiler.m in Sources */, + 001BFCD01D8381DE008E587E /* RCTMultipartStreamReader.m in Sources */, 133CAE8E1B8E5CFD00F6AD92 /* RCTDatePicker.m in Sources */, 14C2CA761B3AC64F00E6CBB2 /* RCTFrameUpdate.m in Sources */, 13B07FEF1A69327A00A75B9A /* RCTAlertManager.m in Sources */,