diff --git a/React/Base/RCTJavaScriptLoader.h b/React/Base/RCTJavaScriptLoader.h index 1d8a19ee57..bf51d31ae7 100755 --- a/React/Base/RCTJavaScriptLoader.h +++ b/React/Base/RCTJavaScriptLoader.h @@ -13,6 +13,18 @@ extern uint32_t const RCTRAMBundleMagicNumber; +extern NSString *const RCTJavaScriptLoaderErrorDomain; + +NS_ENUM(NSInteger) { + RCTJavaScriptLoaderErrorNoScriptURL = 1, + RCTJavaScriptLoaderErrorFailedOpeningFile = 2, + RCTJavaScriptLoaderErrorFailedReadingFile = 3, + RCTJavaScriptLoaderErrorFailedStatingFile = 3, + RCTJavaScriptLoaderErrorURLLoadFailed = 3, + + RCTJavaScriptLoaderErrorCannotBeLoadedSynchronously = 1000, +}; + @class RCTBridge; /** @@ -22,6 +34,19 @@ extern uint32_t const RCTRAMBundleMagicNumber; */ @interface RCTJavaScriptLoader : NSObject -+ (void)loadBundleAtURL:(NSURL *)moduleURL onComplete:(RCTSourceLoadBlock)onComplete; ++ (void)loadBundleAtURL:(NSURL *)scriptURL onComplete:(RCTSourceLoadBlock)onComplete; + +/** + * @experimental + * Attempts to synchronously load the script at the given URL. The following two conditions must be met: + * 1. It must be a file URL. + * 2. It must point to a RAM bundle, or allowLoadingNonRAMBundles must be YES. + * If the URL does not meet those conditions, this method will return nil and supply an error with the domain + * RCTJavaScriptLoaderErrorDomain and the code RCTJavaScriptLoaderErrorCannotBeLoadedSynchronously. + */ ++ (NSData *)attemptSynchronousLoadOfBundleAtURL:(NSURL *)scriptURL + sourceLength:(int64_t *)sourceLength + allowLoadingNonRAMBundles:(BOOL)allowLoadingNonRAMBundles + error:(NSError **)error; @end diff --git a/React/Base/RCTJavaScriptLoader.m b/React/Base/RCTJavaScriptLoader.m index a44dc5baf2..9e978a51c9 100755 --- a/React/Base/RCTJavaScriptLoader.m +++ b/React/Base/RCTJavaScriptLoader.m @@ -19,134 +19,221 @@ uint32_t const RCTRAMBundleMagicNumber = 0xFB0BD1E5; +NSString *const RCTJavaScriptLoaderErrorDomain = @"RCTJavaScriptLoaderErrorDomain"; + @implementation RCTJavaScriptLoader RCT_NOT_IMPLEMENTED(- (instancetype)init) + (void)loadBundleAtURL:(NSURL *)scriptURL onComplete:(RCTSourceLoadBlock)onComplete { - NSString *unsanitizedScriptURLString = scriptURL.absoluteString; - // Sanitize the script URL - scriptURL = [RCTConvert NSURL:unsanitizedScriptURLString]; - - if (!scriptURL) { - NSString *errorDescription = [NSString stringWithFormat:@"No script URL provided." - @"unsanitizedScriptURLString:(%@)", unsanitizedScriptURLString]; - NSError *error = [NSError errorWithDomain:@"JavaScriptLoader" code:1 userInfo:@{ - NSLocalizedDescriptionKey: errorDescription - }]; - onComplete(error, nil, 0); + int64_t sourceLength; + NSError *error; + NSData *data = [self attemptSynchronousLoadOfBundleAtURL:scriptURL + sourceLength:&sourceLength + allowLoadingNonRAMBundles:NO // we'll do it async + error:&error]; + if (data) { + onComplete(nil, data, sourceLength); return; } + const BOOL isCannotLoadSyncError = + [error.domain isEqualToString:RCTJavaScriptLoaderErrorDomain] + && error.code == RCTJavaScriptLoaderErrorCannotBeLoadedSynchronously; + + if (isCannotLoadSyncError) { + attemptAsynchronousLoadOfBundleAtURL(scriptURL, onComplete); + } else { + onComplete(error, nil, 0); + } +} + ++ (NSData *)attemptSynchronousLoadOfBundleAtURL:(NSURL *)scriptURL + sourceLength:(int64_t *)sourceLength + allowLoadingNonRAMBundles:(BOOL)allowLoadingNonRAMBundles + error:(NSError **)error +{ + NSString *unsanitizedScriptURLString = scriptURL.absoluteString; + // Sanitize the script URL + scriptURL = sanitizeURL(scriptURL); + + if (!scriptURL) { + if (error) { + *error = [NSError errorWithDomain:RCTJavaScriptLoaderErrorDomain + code:RCTJavaScriptLoaderErrorNoScriptURL + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"No script URL provided. " + @"unsanitizedScriptURLString:(%@)", unsanitizedScriptURLString]}]; + } + return nil; + } + // Load local script file - if (scriptURL.fileURL) { - // Load the first 4 bytes to check if the bundle is regular or RAM ("Random Access Modules" bundle). - // The RAM bundle has a magic number in the 4 first bytes `(0xFB0BD1E5)`. - // The benefit of RAM bundle over a regular bundle is that we can lazily inject - // modules into JSC as they're required. - FILE *bundle = fopen(scriptURL.path.UTF8String, "r"); - if (!bundle) { - onComplete(RCTErrorWithMessage([NSString stringWithFormat:@"Error opening bundle %@", scriptURL.path]), nil, 0); - return; + if (!scriptURL.fileURL) { + if (error) { + *error = [NSError errorWithDomain:RCTJavaScriptLoaderErrorDomain + code:RCTJavaScriptLoaderErrorCannotBeLoadedSynchronously + userInfo:@{NSLocalizedDescriptionKey: + @"Cannot load non-file URLs synchronously"}]; } + return nil; + } - uint32_t magicNumber; - size_t readResult = fread(&magicNumber, sizeof(magicNumber), 1, bundle); - fclose(bundle); - if (readResult != 1) { - onComplete(RCTErrorWithMessage(@"Error reading bundle"), nil, 0); - return; + // Load the first 4 bytes to check if the bundle is regular or RAM ("Random Access Modules" bundle). + // The RAM bundle has a magic number in the 4 first bytes `(0xFB0BD1E5)`. + // The benefit of RAM bundle over a regular bundle is that we can lazily inject + // modules into JSC as they're required. + FILE *bundle = fopen(scriptURL.path.UTF8String, "r"); + if (!bundle) { + if (error) { + *error = [NSError errorWithDomain:RCTJavaScriptLoaderErrorDomain + code:RCTJavaScriptLoaderErrorFailedOpeningFile + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Error opening bundle %@", scriptURL.path]}]; } + return nil; + } - magicNumber = NSSwapLittleIntToHost(magicNumber); - if (magicNumber == RCTRAMBundleMagicNumber) { - NSData *source = [NSData dataWithBytes:&magicNumber length:sizeof(magicNumber)]; - NSError *error = nil; - int64_t sourceLength = 0; + uint32_t magicNumber; + size_t readResult = fread(&magicNumber, sizeof(magicNumber), 1, bundle); + fclose(bundle); + if (readResult != 1) { + if (error) { + *error = [NSError errorWithDomain:RCTJavaScriptLoaderErrorDomain + code:RCTJavaScriptLoaderErrorFailedReadingFile + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Error reading bundle %@", scriptURL.path]}]; + } + return nil; + } - struct stat statInfo; - if (stat(scriptURL.path.UTF8String, &statInfo) != 0) { - error = RCTErrorWithMessage(@"Error reading bundle"); - } else { - sourceLength = statInfo.st_size; + magicNumber = NSSwapLittleIntToHost(magicNumber); + if (magicNumber != RCTRAMBundleMagicNumber) { + if (allowLoadingNonRAMBundles) { + NSData *source = [NSData dataWithContentsOfFile:scriptURL.path + options:NSDataReadingMappedIfSafe + error:error]; + if (sourceLength && source != nil) { + *sourceLength = source.length; } - onComplete(error, source, sourceLength); - } else { - // Reading in a large bundle can be slow. Dispatch to the background queue to do it. - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - NSError *error = nil; - NSData *source = [NSData dataWithContentsOfFile:scriptURL.path - options:NSDataReadingMappedIfSafe - error:&error]; - onComplete(error, source, source.length); - }); + return source; } + + if (error) { + *error = [NSError errorWithDomain:RCTJavaScriptLoaderErrorDomain + code:RCTJavaScriptLoaderErrorCannotBeLoadedSynchronously + userInfo:@{NSLocalizedDescriptionKey: + @"Cannot load non-RAM bundled files synchronously"}]; + } + return nil; + } + + struct stat statInfo; + if (stat(scriptURL.path.UTF8String, &statInfo) != 0) { + if (error) { + *error = [NSError errorWithDomain:RCTJavaScriptLoaderErrorDomain + code:RCTJavaScriptLoaderErrorFailedStatingFile + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Error stating bundle %@", scriptURL.path]}]; + } + return nil; + } + if (sourceLength) { + *sourceLength = statInfo.st_size; + } + return [NSData dataWithBytes:&magicNumber length:sizeof(magicNumber)]; +} + +static void attemptAsynchronousLoadOfBundleAtURL(NSURL *scriptURL, RCTSourceLoadBlock onComplete) +{ + scriptURL = sanitizeURL(scriptURL); + + if (scriptURL.fileURL) { + // Reading in a large bundle can be slow. Dispatch to the background queue to do it. + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSError *error = nil; + NSData *source = [NSData dataWithContentsOfFile:scriptURL.path + options:NSDataReadingMappedIfSafe + error:&error]; + onComplete(error, source, source.length); + }); return; } // Load remote script file - NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithURL:scriptURL completionHandler: - ^(NSData *data, NSURLResponse *response, NSError *error) { + NSURLSessionDataTask *task = + [[NSURLSession sharedSession] dataTaskWithURL:scriptURL completionHandler: + ^(NSData *data, NSURLResponse *response, NSError *error) { - // Handle general request errors - if (error) { - if ([error.domain isEqualToString:NSURLErrorDomain]) { - NSString *desc = [@"Could not connect to development server.\n\nEnsure 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\nURL: " stringByAppendingString:scriptURL.absoluteString]; - NSDictionary *userInfo = @{ - NSLocalizedDescriptionKey: desc, - NSLocalizedFailureReasonErrorKey: error.localizedDescription, - NSUnderlyingErrorKey: error, - }; - error = [NSError errorWithDomain:@"JSServer" - code:error.code - userInfo:userInfo]; - } - 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) { - NSString *rawText = [[NSString alloc] initWithData:data encoding:encoding]; - NSDictionary *userInfo; - NSDictionary *errorDetails = RCTJSONParse(rawText, nil); - if ([errorDetails isKindOfClass:[NSDictionary class]] && - [errorDetails[@"errors"] isKindOfClass:[NSArray class]]) { - NSMutableArray *fakeStack = [NSMutableArray new]; - for (NSDictionary *err in errorDetails[@"errors"]) { - [fakeStack addObject: @{ - @"methodName": err[@"description"] ?: @"", - @"file": err[@"filename"] ?: @"", - @"lineNumber": err[@"lineNumber"] ?: @0 - }]; - } - userInfo = @{ - NSLocalizedDescriptionKey: errorDetails[@"message"] ?: @"No message provided", - @"stack": fakeStack, - }; - } else { - userInfo = @{NSLocalizedDescriptionKey: rawText}; - } - error = [NSError errorWithDomain:@"JSServer" - code:((NSHTTPURLResponse *)response).statusCode - userInfo:userInfo]; - - onComplete(error, nil, 0); - return; - } - onComplete(nil, data, data.length); - }]; + // 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]; } +static NSURL *sanitizeURL(NSURL *url) +{ + // Why we do this is lost to time. We probably shouldn't; passing a valid URL is the caller's responsibility not ours. + return [RCTConvert NSURL:url.absoluteString]; +} + +static NSDictionary *userInfoForRawResponse(NSString *rawText) +{ + NSDictionary *parsedResponse = RCTJSONParse(rawText, nil); + if (![parsedResponse isKindOfClass:[NSDictionary class]]) { + return @{NSLocalizedDescriptionKey: rawText}; + } + NSArray *errors = parsedResponse[@"errors"]; + if (![errors isKindOfClass:[NSArray class]]) { + return @{NSLocalizedDescriptionKey: rawText}; + } + NSMutableArray *fakeStack = [NSMutableArray new]; + for (NSDictionary *err in errors) { + [fakeStack addObject: + @{ + @"methodName": err[@"description"] ?: @"", + @"file": err[@"filename"] ?: @"", + @"lineNumber": err[@"lineNumber"] ?: @0 + }]; + } + return @{NSLocalizedDescriptionKey: parsedResponse[@"message"] ?: @"No message provided", @"stack": [fakeStack copy]}; +} + @end