src/resource.js
/*globals XMLHttpRequest,chrome */
/*jslint indent:2,node:true,sloppy:true */
var PromiseCompat = require('es6-promise').Promise;
var util = require('./util');
/**
* The Resource registry for FreeDOM. Used to look up requested Resources,
* and provide lookup and migration of resources.
* @Class Resource
* @param {Debug} debug The logger to use for debugging.
* @constructor
*/
var Resource = function (debug) {
this.debug = debug;
this.files = {};
this.resolvers = [this.httpResolver, this.nullResolver];
this.contentRetrievers = {
'http': this.xhrRetriever,
'https': this.xhrRetriever,
'chrome-extension': this.xhrRetriever,
'resource': this.xhrRetriever,
'chrome': this.xhrRetriever,
'app': this.xhrRetriever,
'gopher': this.xhrRetriever, // For Cordova; see http://crbug.com/513352 .
'manifest': this.manifestRetriever
};
};
/**
* Resolve a resurce URL requested from a module.
* @method get
* @param {String} manifest The canonical address of the module requesting.
* @param {String} url The resource to get.
* @returns {Promise} A promise for the resource address.
*/
Resource.prototype.get = function (manifest, url) {
var key = JSON.stringify([manifest, url]);
return new PromiseCompat(function (resolve, reject) {
if (this.files[key]) {
resolve(this.files[key]);
} else {
this.resolve(manifest, url).then(function (key, resolve, address) {
this.files[key] = address;
//fdom.debug.log('Resolved ' + key + ' to ' + address);
resolve(address);
}.bind(this, key, resolve), reject);
}
}.bind(this));
};
/**
* Get the contents of a resource.
* @method getContents
* @param {String} url The resource to read.
* @returns {Promise} A promise for the resource contents.
*/
Resource.prototype.getContents = function (url) {
return new PromiseCompat(function (resolve, reject) {
var prop;
if (!url) {
this.debug.warn("Asked to get contents of undefined URL.");
return reject();
}
for (prop in this.contentRetrievers) {
if (this.contentRetrievers.hasOwnProperty(prop)) {
if (url.indexOf(prop + "://") === 0) {
return this.contentRetrievers[prop].call(this, url, resolve, reject);
} else if (url.indexOf("://") === -1 && prop === "null") {
return this.contentRetrievers[prop].call(this, url, resolve, reject);
}
}
}
reject();
}.bind(this));
};
/**
* Return a promise that resolves when the first of an array of promises
* resolves, or rejects after all promises reject. Can be thought of as
* the missing 'Promise.any' - race is no good, since early rejections
* preempt a subsequent resolution.
* @private
* @static
* @method FirstPromise
* @param {Promise[]} Promises to select from
* @returns {Promise} Promise resolving with a value from arguments.
*/
var firstPromise = function(promises) {
return new PromiseCompat(function(resolve, reject) {
var errors = [];
promises.forEach(function(promise) {
promise.then(resolve, function(err) {
errors.push(err);
if (errors.length === promises.length) {
reject(errors);
}
});
});
});
};
/**
* Resolve a resource using known resolvers. Unlike get, resolve does
* not cache resolved resources.
* @method resolve
* @private
* @param {String} manifest The module requesting the resource.
* @param {String} url The resource to resolve;
* @returns {Promise} A promise for the resource address.
*/
Resource.prototype.resolve = function (manifest, url) {
return new PromiseCompat(function (resolve, reject) {
var promises = [];
if (url === undefined) {
return reject();
}
util.eachReverse(this.resolvers, function (resolver) {
promises.push(new PromiseCompat(resolver.bind({}, manifest, url)));
}.bind(this));
firstPromise(promises).then(resolve, function() {
reject('No resolvers to handle url: ' + JSON.stringify([manifest, url]));
});
}.bind(this));
};
/**
* Register resolvers: code that knows how to get resources
* needed by the runtime. A resolver will be called with four
* arguments: the absolute manifest of the requester, the
* resource being requested, and a resolve / reject pair to
* fulfill a promise.
* @method addResolver
* @param {Function} resolver The resolver to add.
*/
Resource.prototype.addResolver = function (resolver) {
this.resolvers.push(resolver);
};
/**
* Register retrievers: code that knows how to load resources
* needed by the runtime. A retriever will be called with a URL
* to retrieve with a protocol that it is able to handle.
* @method addRetriever
* @param {String} proto The protocol to register for.
* @param {Function} retriever The retriever to add.
*/
Resource.prototype.addRetriever = function (proto, retriever) {
if (this.contentRetrievers[proto]) {
this.debug.warn("Unwilling to override file retrieval for " + proto);
return;
}
this.contentRetrievers[proto] = retriever;
};
/**
* Register external resolvers and retreavers
* @method register
* @param {{"proto":String, "resolver":Function, "retreaver":Function}[]}
* resolvers The list of retreivers and resolvers.
*/
Resource.prototype.register = function (resolvers) {
if (!resolvers.length) {
return;
}
resolvers.forEach(function (item) {
if (item.resolver) {
this.addResolver(item.resolver);
} else if (item.proto && item.retriever) {
this.addRetriever(item.proto, item.retriever);
}
}.bind(this));
};
/**
* Determine if a URL is an absolute URL of a given Scheme.
* @method hasScheme
* @static
* @private
* @param {String[]} protocols Whitelisted protocols
* @param {String} URL the URL to match.
* @returns {Boolean} If the URL is an absolute example of one of the schemes.
*/
Resource.hasScheme = function (protocols, url) {
var i;
for (i = 0; i < protocols.length; i += 1) {
if (url.indexOf(protocols[i] + "://") === 0) {
return true;
}
}
return false;
};
/**
* Remove './' and '../' from a URL
* Required because Chrome Apps for Mobile (cca) doesn't understand
* XHR paths with these relative components in the URL.
* @method removeRelativePath
* @param {String} url The URL to modify
* @returns {String} url without './' and '../'
**/
Resource.removeRelativePath = function (url) {
var idx = url.indexOf("://") + 3,
stack,
toRemove,
result;
// Remove all instances of /./
url = url.replace(/\/\.\//g, "/");
//Weird bug where in cca, manifest starts with 'chrome:////'
//This forces there to only be 2 slashes
while (url.charAt(idx) === "/") {
url = url.slice(0, idx) + url.slice(idx + 1, url.length);
}
// Advance to next /
idx = url.indexOf("/", idx);
// Removing ../
stack = url.substr(idx + 1).split("/");
while (stack.indexOf("..") !== -1) {
toRemove = stack.indexOf("..");
if (toRemove === 0) {
stack.shift();
} else {
stack.splice((toRemove - 1), 2);
}
}
//Rebuild string
result = url.substr(0, idx);
for (idx = 0; idx < stack.length; idx += 1) {
result += "/" + stack[idx];
}
return result;
};
/**
* Resolve URLs which can be accessed using standard HTTP requests.
* @method httpResolver
* @private
* @param {String} manifest The Manifest URL.
* @param {String} url The URL to resolve.
* @param {Function} resolve The promise to complete.
* @param {Function} reject The promise to reject.
* @returns {Boolean} True if the URL could be resolved.
*/
Resource.prototype.httpResolver = function (manifest, url, resolve, reject) {
var protocols = ["http", "https", "chrome", "chrome-extension", "resource",
"app", "gopher"],
dirname,
protocolIdx,
pathIdx,
path,
base,
result;
if (Resource.hasScheme(protocols, url)) {
resolve(Resource.removeRelativePath(url));
return true;
}
if (!manifest) {
reject();
return false;
}
if (Resource.hasScheme(protocols, manifest) &&
url.indexOf("://") === -1) {
dirname = manifest.substr(0, manifest.lastIndexOf("/"));
protocolIdx = dirname.indexOf("://");
pathIdx = protocolIdx + 3 + dirname.substr(protocolIdx + 3).indexOf("/");
path = dirname.substr(pathIdx);
base = dirname.substr(0, pathIdx);
if (url.indexOf("/") === 0) {
resolve(Resource.removeRelativePath(base + url));
} else {
resolve(Resource.removeRelativePath(base + path + "/" + url));
}
return true;
}
reject();
};
/**
* Resolve URLs which are self-describing.
* @method nullResolver
* @private
* @param {String} manifest The Manifest URL.
* @param {String} url The URL to resolve.
* @param {Function} resolve The promise to complete.
* @param {Function} reject The promise to reject.
* @returns {Boolean} True if the URL could be resolved.
*/
Resource.prototype.nullResolver = function (manifest, url, resolve, reject) {
var protocols = ["manifest"];
if (Resource.hasScheme(protocols, url)) {
resolve(url);
return true;
} else if (url.indexOf('data:') === 0) {
resolve(url);
return true;
}
reject();
};
/**
* Retrieve manifest content from a self-descriptive manifest url.
* These urls are used to reference a manifest without requiring subsequent,
* potentially non-CORS requests.
* @method manifestRetriever
* @private
* @param {String} manifest The Manifest URL
* @param {Function} resolve The promise to complete.
* @param {Function} reject The promise to reject.
*/
Resource.prototype.manifestRetriever = function (manifest, resolve, reject) {
var data;
try {
data = manifest.substr(11);
JSON.parse(data);
resolve(data);
} catch (e) {
this.debug.warn("Invalid manifest URL referenced:" + manifest);
reject();
}
};
/**
* Retrieve resource contents using an XHR request.
* @method xhrRetriever
* @private
* @param {String} url The resource to fetch.
* @param {Function} resolve The promise to complete.
* @param {Function} reject The promise to reject.
*/
Resource.prototype.xhrRetriever = function (url, resolve, reject) {
var ref = new XMLHttpRequest();
ref.addEventListener("readystatechange", function (resolve, reject) {
if (ref.readyState === 4 && ref.responseText) {
resolve(ref.responseText);
} else if (ref.readyState === 4) {
this.debug.warn("Failed to load file " + url + ": " + ref.status);
reject(ref.status);
}
}.bind(this, resolve, reject), false);
ref.overrideMimeType("application/json");
if (typeof navigator !== 'undefined' && navigator && navigator.userAgent &&
navigator.userAgent.indexOf('Chrome') !== -1 && !chrome.app.runtime) {
// Chrome (browser, not apps/extensions) fails to load multiple APIs
// from the same manifest unless they are loaded synchronously
ref.open("GET", url, false);
} else {
// Async for all other cases, especially Chrome apps (which fail otherwise)
ref.open("GET", url, true);
}
ref.send();
};
module.exports = Resource;