From 84eaeb0adff783e1d02090f7a1e92ddae56be26c Mon Sep 17 00:00:00 2001 From: Alex Kotliarskyi Date: Tue, 11 Oct 2016 12:14:37 -0700 Subject: [PATCH] Add multipart response download task (2nd edition) Reviewed By: mmmulani Differential Revision: D3976605 fbshipit-source-id: c15cc859aa1288e831f70256566f743f4a8d9cd2 --- React/Base/RCTJavaScriptLoader.m | 88 +++++++++---------- React/Base/RCTMultipartDataTask.h | 20 +++++ React/Base/RCTMultipartDataTask.m | 119 ++++++++++++++++++++++++++ React/Base/RCTMultipartStreamReader.m | 21 +++-- React/React.xcodeproj/project.pbxproj | 6 ++ 5 files changed, 204 insertions(+), 50 deletions(-) create mode 100644 React/Base/RCTMultipartDataTask.h create mode 100644 React/Base/RCTMultipartDataTask.m diff --git a/React/Base/RCTJavaScriptLoader.m b/React/Base/RCTJavaScriptLoader.m index fd40d5a89..cb19dce80 100755 --- a/React/Base/RCTJavaScriptLoader.m +++ b/React/Base/RCTJavaScriptLoader.m @@ -14,6 +14,7 @@ #import "RCTSourceCode.h" #import "RCTUtils.h" #import "RCTPerformanceLogger.h" +#import "RCTMultipartDataTask.h" #include @@ -151,51 +152,52 @@ static void attemptAsynchronousLoadOfBundleAtURL(NSURL *scriptURL, RCTSourceLoad return; } - // Load remote script file - NSURLSessionDataTask *task = - [[NSURLSession sharedSession] dataTaskWithURL:scriptURL completionHandler: - ^(NSData *data, NSURLResponse *response, NSError *error) { - // Handle general request errors - if (error) { - if ([error.domain isEqualToString:NSURLErrorDomain]) { - error = [NSError errorWithDomain:RCTJavaScriptLoaderErrorDomain - code:RCTJavaScriptLoaderErrorURLLoadFailed - userInfo: - @{ - NSLocalizedDescriptionKey: - [@"Could not connect to development server.\n\n" - "Ensure the following:\n" - "- Node server is running and available on the same network - run 'npm start' from react-native root\n" - "- Node server URL is correctly set in AppDelegate\n\n" - "URL: " stringByAppendingString:scriptURL.absoluteString], - NSLocalizedFailureReasonErrorKey: error.localizedDescription, - NSUnderlyingErrorKey: error, - }]; - } - onComplete(error, nil, 0); - return; - } + RCTMultipartDataTask *task = [[RCTMultipartDataTask alloc] initWithURL:scriptURL partHandler:^(NSInteger statusCode, NSDictionary *headers, NSData *data, NSError *error, BOOL done) { + if (!done) { + // TODO(frantic): Emit progress event + return; + } - // Parse response as text - NSStringEncoding encoding = NSUTF8StringEncoding; - if (response.textEncodingName != nil) { - CFStringEncoding cfEncoding = CFStringConvertIANACharSetNameToEncoding((CFStringRef)response.textEncodingName); - if (cfEncoding != kCFStringEncodingInvalidId) { - encoding = CFStringConvertEncodingToNSStringEncoding(cfEncoding); - } - } - // Handle HTTP errors - if ([response isKindOfClass:[NSHTTPURLResponse class]] && ((NSHTTPURLResponse *)response).statusCode != 200) { - error = [NSError errorWithDomain:@"JSServer" - code:((NSHTTPURLResponse *)response).statusCode - userInfo:userInfoForRawResponse([[NSString alloc] initWithData:data encoding:encoding])]; - onComplete(error, nil, 0); - return; - } - onComplete(nil, data, data.length); - }]; - [task resume]; + // Handle general request errors + if (error) { + if ([error.domain isEqualToString:NSURLErrorDomain]) { + error = [NSError errorWithDomain:RCTJavaScriptLoaderErrorDomain + code:RCTJavaScriptLoaderErrorURLLoadFailed + userInfo: + @{ + NSLocalizedDescriptionKey: + [@"Could not connect to development server.\n\n" + "Ensure the following:\n" + "- Node server is running and available on the same network - run 'npm start' from react-native root\n" + "- Node server URL is correctly set in AppDelegate\n\n" + "URL: " stringByAppendingString:scriptURL.absoluteString], + NSLocalizedFailureReasonErrorKey: error.localizedDescription, + NSUnderlyingErrorKey: error, + }]; + } + onComplete(error, nil, 0); + return; + } + + // For multipart responses packager sets X-Http-Status header in case HTTP status code + // is different from 200 OK + NSString *statusCodeHeader = [headers valueForKey:@"X-Http-Status"]; + if (statusCodeHeader) { + statusCode = [statusCodeHeader integerValue]; + } + + if (statusCode != 200) { + error = [NSError errorWithDomain:@"JSServer" + code:statusCode + userInfo:userInfoForRawResponse([[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding])]; + onComplete(error, nil, 0); + return; + } + onComplete(nil, data, data.length); + }]; + + [task startTask]; } static NSURL *sanitizeURL(NSURL *url) diff --git a/React/Base/RCTMultipartDataTask.h b/React/Base/RCTMultipartDataTask.h new file mode 100644 index 000000000..f01ef609e --- /dev/null +++ b/React/Base/RCTMultipartDataTask.h @@ -0,0 +1,20 @@ +/** + * 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 +#import "RCTMultipartStreamReader.h" + +typedef void (^RCTMultipartDataTaskCallback)(NSInteger statusCode, NSDictionary *headers, NSData *content, NSError *error, BOOL done); + +@interface RCTMultipartDataTask : NSObject + +- (instancetype)initWithURL:(NSURL *)url partHandler:(RCTMultipartDataTaskCallback)partHandler; +- (void)startTask; + +@end diff --git a/React/Base/RCTMultipartDataTask.m b/React/Base/RCTMultipartDataTask.m new file mode 100644 index 000000000..daee3d16d --- /dev/null +++ b/React/Base/RCTMultipartDataTask.m @@ -0,0 +1,119 @@ +/** + * 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 "RCTMultipartDataTask.h" + +@interface RCTMultipartDataTask () + +@end + +// We need this ugly runtime check because [streamTask captureStreams] below fails on iOS version +// earlier than 9.0. Unfortunately none of the proper ways of checking worked: +// +// - NSURLSessionStreamTask class is available and is not Null on iOS 8 +// - [[NSURLSessionStreamTask new] respondsToSelector:@selector(captureStreams)] is always NO +// - The instance we get in URLSession:dataTask:didBecomeStreamTask: is of __NSCFURLLocalStreamTaskFromDataTask +// and it responds to captureStreams on iOS 9+ but doesn't on iOS 8. Which means we can't get direct access +// to the streams on iOS 8 and at that point it's too late to change the behavior back to dataTask +// - The compile-time #ifdef's can't be used because an app compiled for iOS8 can still run on iOS9 + +static BOOL isStreamTaskSupported() { + return [[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:(NSOperatingSystemVersion){9,0,0}]; +} + +@implementation RCTMultipartDataTask { + NSURL *_url; + RCTMultipartDataTaskCallback _partHandler; + NSInteger _statusCode; + NSDictionary *_headers; + NSString *_boundary; + NSMutableData *_data; +} + +- (instancetype)initWithURL:(NSURL *)url partHandler:(RCTMultipartDataTaskCallback)partHandler +{ + if (self = [super init]) { + _url = url; + _partHandler = [partHandler copy]; + } + return self; +} + +- (void)startTask +{ + NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] + delegate:self delegateQueue:nil]; + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:_url]; + if (isStreamTaskSupported()) { + [request addValue:@"multipart/mixed" forHTTPHeaderField:@"Accept"]; + } + NSURLSessionDataTask *dataTask = [session dataTaskWithRequest:request]; + [dataTask resume]; +} + +- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler +{ + if ([response isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; + _headers = [httpResponse allHeaderFields]; + _statusCode = [httpResponse statusCode]; + + NSString *contentType = @""; + for (NSString *key in [_headers keyEnumerator]) { + if ([[key lowercaseString] isEqualToString:@"content-type"]) { + contentType = [_headers valueForKey:key]; + break; + } + } + + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"multipart/mixed;.*boundary=\"([^\"]+)\"" options:0 error:nil]; + NSTextCheckingResult *match = [regex firstMatchInString:contentType options:0 range:NSMakeRange(0, contentType.length)]; + if (match) { + _boundary = [contentType substringWithRange:[match rangeAtIndex:1]]; + completionHandler(NSURLSessionResponseBecomeStream); + return; + } + } + + // In case the server doesn't support multipart/mixed responses, fallback to normal download + _data = [[NSMutableData alloc] initWithCapacity:1024 * 1024]; + completionHandler(NSURLSessionResponseAllow); +} + +- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error +{ + _partHandler(_statusCode, _headers, _data, error, YES); +} + +- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data +{ + [_data appendData:data]; +} + +- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didBecomeStreamTask:(NSURLSessionStreamTask *)streamTask +{ + [streamTask captureStreams]; +} + +- (void)URLSession:(NSURLSession *)session streamTask:(NSURLSessionStreamTask *)streamTask didBecomeInputStream:(NSInputStream *)inputStream outputStream:(NSOutputStream *)outputStream +{ + RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:inputStream boundary:_boundary]; + RCTMultipartDataTaskCallback partHandler = _partHandler; + NSInteger statusCode = _statusCode; + + BOOL completed = [reader readAllParts:^(NSDictionary *headers, NSData *content, BOOL done) { + partHandler(statusCode, headers, content, nil, done); + }]; + if (!completed) { + partHandler(statusCode, nil, nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorCancelled userInfo:nil], YES); + } +} + + +@end diff --git a/React/Base/RCTMultipartStreamReader.m b/React/Base/RCTMultipartStreamReader.m index 458ef70a5..87b85c65e 100644 --- a/React/Base/RCTMultipartStreamReader.m +++ b/React/Base/RCTMultipartStreamReader.m @@ -58,7 +58,9 @@ - (BOOL)readAllParts:(RCTMultipartCallback)callback { - NSInteger start = 0; + NSInteger chunkStart = 0; + NSInteger bytesSeen = 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]; @@ -69,7 +71,10 @@ [_stream open]; while (true) { BOOL isCloseDelimiter = NO; - NSRange remainingBufferRange = NSMakeRange(start, content.length - start); + // 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 + NSInteger searchStart = MAX(bytesSeen - (NSInteger)closeDelimiter.length, chunkStart); + NSRange remainingBufferRange = NSMakeRange(searchStart, content.length - searchStart); NSRange range = [content rangeOfData:delimiter options:0 range:remainingBufferRange]; if (range.location == NSNotFound) { isCloseDelimiter = YES; @@ -77,6 +82,7 @@ } if (range.location == NSNotFound) { + bytesSeen = content.length; NSInteger bytesRead = [_stream read:buffer maxLength:bufferLen]; if (bytesRead <= 0 || _stream.streamError) { return NO; @@ -85,12 +91,13 @@ continue; } - NSInteger end = range.location; - NSInteger length = end - start; + NSInteger chunkEnd = range.location; + NSInteger length = chunkEnd - chunkStart; + bytesSeen = chunkEnd; // Ignore preamble - if (start > 0) { - NSData *chunk = [content subdataWithRange:NSMakeRange(start, length)]; + if (chunkStart > 0) { + NSData *chunk = [content subdataWithRange:NSMakeRange(chunkStart, length)]; [self emitChunk:chunk callback:callback done:isCloseDelimiter]; } @@ -98,7 +105,7 @@ return YES; } - start = end + delimiter.length; + chunkStart = chunkEnd + delimiter.length; } } diff --git a/React/React.xcodeproj/project.pbxproj b/React/React.xcodeproj/project.pbxproj index bccf3cca4..ef1b8dcc6 100644 --- a/React/React.xcodeproj/project.pbxproj +++ b/React/React.xcodeproj/project.pbxproj @@ -9,6 +9,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 */; }; + 006FC4141D9B20820057AAAD /* RCTMultipartDataTask.m in Sources */ = {isa = PBXBuildFile; fileRef = 006FC4131D9B20820057AAAD /* RCTMultipartDataTask.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 */; }; @@ -219,6 +220,8 @@ 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 = ""; }; + 006FC4121D9B20820057AAAD /* RCTMultipartDataTask.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTMultipartDataTask.h; sourceTree = ""; }; + 006FC4131D9B20820057AAAD /* RCTMultipartDataTask.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTMultipartDataTask.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 = ""; }; @@ -720,6 +723,8 @@ 14C2CA731B3AC64300E6CBB2 /* RCTModuleData.mm */, 14C2CA6F1B3AC63800E6CBB2 /* RCTModuleMethod.h */, 14C2CA701B3AC63800E6CBB2 /* RCTModuleMethod.m */, + 006FC4121D9B20820057AAAD /* RCTMultipartDataTask.h */, + 006FC4131D9B20820057AAAD /* RCTMultipartDataTask.m */, 001BFCCE1D8381DE008E587E /* RCTMultipartStreamReader.h */, 001BFCCF1D8381DE008E587E /* RCTMultipartStreamReader.m */, 13A6E20F1C19ABC700845B82 /* RCTNullability.h */, @@ -994,6 +999,7 @@ 13A0C28A1B74F71200B29F6F /* RCTDevMenu.m in Sources */, 13BCE8091C99CB9D00DD7AAD /* RCTRootShadowView.m in Sources */, 14C2CA711B3AC63800E6CBB2 /* RCTModuleMethod.m in Sources */, + 006FC4141D9B20820057AAAD /* RCTMultipartDataTask.m in Sources */, 1321C8D01D3EB50800D58318 /* CSSNodeList.c in Sources */, 13CC8A821B17642100940AE7 /* RCTBorderDrawing.m in Sources */, 83CBBA511A601E3B00E9B192 /* RCTAssert.m in Sources */,