Expose a method to synchronously load bundle if possible

Summary:
This diff exposes a new experimental method `[RCTJavaScriptLoader +attemptSynchronousLoadOfBundleAtURL:sourceLength:error:]`. It may be used if you know that a specific call site must load its JavaScript synchronously, or else fail entirely.

This new API will succeed for file URLs that point to a RAM bundle. It will fail for non-RAM-bundle files and for HTTP URLs.

This also cleans up the error domain and codes for this class. This should be the only externally visible change from this diff if you don't use the new API: the codes and domains you receive from the API may change slightly. They were pretty sloppy and undocumented before, so I think this change is for the better.

Reviewed By: bnham

Differential Revision: D3545956

fbshipit-source-id: 30e65f4e8330d2d68f3f50ade077fdc1db4a435e
This commit is contained in:
Adam Ernst 2016-07-12 05:13:32 -07:00 committed by Facebook Github Bot 6
parent 80c71e5cae
commit c7a590655c
2 changed files with 217 additions and 105 deletions

View File

@ -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

View File

@ -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<NSDictionary *> *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<NSDictionary *> *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