Skip to content

Commit

Permalink
Add multipart response download task (2nd edition)
Browse files Browse the repository at this point in the history
Reviewed By: mmmulani

Differential Revision: D3976605

fbshipit-source-id: c15cc859aa1288e831f70256566f743f4a8d9cd2
  • Loading branch information
frantic authored and Facebook Github Bot committed Oct 11, 2016
1 parent 584bd0d commit 84eaeb0
Show file tree
Hide file tree
Showing 5 changed files with 206 additions and 52 deletions.
92 changes: 47 additions & 45 deletions React/Base/RCTJavaScriptLoader.m
Expand Up @@ -14,6 +14,7 @@
#import "RCTSourceCode.h"
#import "RCTUtils.h"
#import "RCTPerformanceLogger.h"
#import "RCTMultipartDataTask.h"

#include <sys/stat.h>

Expand Down Expand Up @@ -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;
}

// 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];

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;
}

// 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)
Expand Down
20 changes: 20 additions & 0 deletions 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 <Foundation/Foundation.h>
#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
119 changes: 119 additions & 0 deletions 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 () <NSURLSessionDataDelegate, NSURLSessionDataDelegate>

@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
21 changes: 14 additions & 7 deletions React/Base/RCTMultipartStreamReader.m
Expand Up @@ -58,7 +58,9 @@ - (void)emitChunk:(NSData *)data callback:(RCTMultipartCallback)callback done:(B

- (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];
Expand All @@ -69,14 +71,18 @@ - (BOOL)readAllParts:(RCTMultipartCallback)callback
[_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;
range = [content rangeOfData:closeDelimiter options:0 range:remainingBufferRange];
}

if (range.location == NSNotFound) {
bytesSeen = content.length;
NSInteger bytesRead = [_stream read:buffer maxLength:bufferLen];
if (bytesRead <= 0 || _stream.streamError) {
return NO;
Expand All @@ -85,20 +91,21 @@ - (BOOL)readAllParts:(RCTMultipartCallback)callback
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];
}

if (isCloseDelimiter) {
return YES;
}

start = end + delimiter.length;
chunkStart = chunkEnd + delimiter.length;
}
}

Expand Down
6 changes: 6 additions & 0 deletions React/React.xcodeproj/project.pbxproj
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -219,6 +220,8 @@
000E6CEA1AB0E980000CDF4D /* RCTSourceCode.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTSourceCode.m; sourceTree = "<group>"; };
001BFCCE1D8381DE008E587E /* RCTMultipartStreamReader.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTMultipartStreamReader.h; sourceTree = "<group>"; };
001BFCCF1D8381DE008E587E /* RCTMultipartStreamReader.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTMultipartStreamReader.m; sourceTree = "<group>"; };
006FC4121D9B20820057AAAD /* RCTMultipartDataTask.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTMultipartDataTask.h; sourceTree = "<group>"; };
006FC4131D9B20820057AAAD /* RCTMultipartDataTask.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTMultipartDataTask.m; sourceTree = "<group>"; };
008341F41D1DB34400876D9A /* RCTJSStackFrame.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTJSStackFrame.m; sourceTree = "<group>"; };
008341F51D1DB34400876D9A /* RCTJSStackFrame.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTJSStackFrame.h; sourceTree = "<group>"; };
131541CF1D3E4893006A0E08 /* CSSLayout-internal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "CSSLayout-internal.h"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down

0 comments on commit 84eaeb0

Please sign in to comment.