488 lines
15 KiB
JavaScript
488 lines
15 KiB
JavaScript
(function(self) {
|
|
'use strict';
|
|
|
|
var appearsBrowserified = typeof self !== 'undefined' &&
|
|
typeof process !== 'undefined' &&
|
|
Object.prototype.toString.call(process) === '[object Object]';
|
|
|
|
var RouteRecognizer = appearsBrowserified ? require('route-recognizer') : self.RouteRecognizer;
|
|
var FakeXMLHttpRequest = appearsBrowserified ? require('fake-xml-http-request') : self.FakeXMLHttpRequest;
|
|
|
|
/**
|
|
* parseURL - decompose a URL into its parts
|
|
* @param {String} url a URL
|
|
* @return {Object} parts of the URL, including the following
|
|
*
|
|
* 'https://www.yahoo.com:1234/mypage?test=yes#abc'
|
|
*
|
|
* {
|
|
* host: 'www.yahoo.com:1234',
|
|
* protocol: 'https:',
|
|
* search: '?test=yes',
|
|
* hash: '#abc',
|
|
* href: 'https://www.yahoo.com:1234/mypage?test=yes#abc',
|
|
* pathname: '/mypage',
|
|
* fullpath: '/mypage?test=yes'
|
|
* }
|
|
*/
|
|
function parseURL(url) {
|
|
// TODO: something for when document isn't present... #yolo
|
|
var anchor = document.createElement('a');
|
|
anchor.href = url;
|
|
|
|
if (!anchor.host) {
|
|
anchor.href = anchor.href; // IE: load the host and protocol
|
|
}
|
|
|
|
var pathname = anchor.pathname;
|
|
if (pathname.charAt(0) !== '/') {
|
|
pathname = '/' + pathname; // IE: prepend leading slash
|
|
}
|
|
|
|
var host = anchor.host;
|
|
if (anchor.port === '80' || anchor.port === '443') {
|
|
host = anchor.hostname; // IE: remove default port
|
|
}
|
|
|
|
return {
|
|
host: host,
|
|
protocol: anchor.protocol,
|
|
search: anchor.search,
|
|
hash: anchor.hash,
|
|
href: anchor.href,
|
|
pathname: pathname,
|
|
fullpath: pathname + (anchor.search || '') + (anchor.hash || '')
|
|
};
|
|
}
|
|
|
|
|
|
/**
|
|
* Registry
|
|
*
|
|
* A registry is a map of HTTP verbs to route recognizers.
|
|
*/
|
|
|
|
function Registry(/* host */) {
|
|
// Herein we keep track of RouteRecognizer instances
|
|
// keyed by HTTP method. Feel free to add more as needed.
|
|
this.verbs = {
|
|
GET: new RouteRecognizer(),
|
|
PUT: new RouteRecognizer(),
|
|
POST: new RouteRecognizer(),
|
|
DELETE: new RouteRecognizer(),
|
|
PATCH: new RouteRecognizer(),
|
|
HEAD: new RouteRecognizer(),
|
|
OPTIONS: new RouteRecognizer()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hosts
|
|
*
|
|
* a map of hosts to Registries, ultimately allowing
|
|
* a per-host-and-port, per HTTP verb lookup of RouteRecognizers
|
|
*/
|
|
function Hosts() {
|
|
this._registries = {};
|
|
}
|
|
|
|
/**
|
|
* Hosts#forURL - retrieve a map of HTTP verbs to RouteRecognizers
|
|
* for a given URL
|
|
*
|
|
* @param {String} url a URL
|
|
* @return {Registry} a map of HTTP verbs to RouteRecognizers
|
|
* corresponding to the provided URL's
|
|
* hostname and port
|
|
*/
|
|
Hosts.prototype.forURL = function(url) {
|
|
var host = parseURL(url).host;
|
|
var registry = this._registries[host];
|
|
|
|
if (registry === undefined) {
|
|
registry = (this._registries[host] = new Registry(host));
|
|
}
|
|
|
|
return registry.verbs;
|
|
};
|
|
|
|
|
|
function Pretender(/* routeMap1, routeMap2, ..., options*/) {
|
|
this.hosts = new Hosts();
|
|
|
|
var lastArg = arguments[arguments.length - 1];
|
|
var options = typeof lastArg === 'object' ? lastArg : null;
|
|
var shouldNotTrack = options && (options.trackRequests === false);
|
|
var noopArray = { push: function() {}, length: 0 };
|
|
|
|
this.handlers = [];
|
|
this.handledRequests = shouldNotTrack ? noopArray: [];
|
|
this.passthroughRequests = shouldNotTrack ? noopArray: [];
|
|
this.unhandledRequests = shouldNotTrack ? noopArray: [];
|
|
this.requestReferences = [];
|
|
this.forcePassthrough = options && (options.forcePassthrough === true);
|
|
this.disableUnhandled = options && (options.disableUnhandled === true);
|
|
|
|
// reference the native XMLHttpRequest object so
|
|
// it can be restored later
|
|
this._nativeXMLHttpRequest = self.XMLHttpRequest;
|
|
this.running = false;
|
|
var ctx = { pretender: this };
|
|
this.ctx = ctx;
|
|
|
|
// capture xhr requests, channeling them into
|
|
// the route map.
|
|
self.XMLHttpRequest = interceptor(ctx);
|
|
|
|
// 'start' the server
|
|
this.running = true;
|
|
|
|
// trigger the route map DSL.
|
|
var argLength = options ? arguments.length - 1 : arguments.length;
|
|
for (var i = 0; i < argLength; i++) {
|
|
this.map(arguments[i]);
|
|
}
|
|
}
|
|
|
|
function interceptor(ctx) {
|
|
function FakeRequest() {
|
|
// super()
|
|
FakeXMLHttpRequest.call(this);
|
|
}
|
|
FakeRequest.prototype = Object.create(FakeXMLHttpRequest.prototype);
|
|
FakeRequest.prototype.constructor = FakeRequest;
|
|
|
|
// extend
|
|
FakeRequest.prototype.send = function send() {
|
|
if (!ctx.pretender.running) {
|
|
throw new Error('You shut down a Pretender instance while there was a pending request. ' +
|
|
'That request just tried to complete. Check to see if you accidentally shut down ' +
|
|
'a pretender earlier than you intended to');
|
|
}
|
|
|
|
FakeXMLHttpRequest.prototype.send.apply(this, arguments);
|
|
|
|
if (ctx.pretender.checkPassthrough(this)) {
|
|
var xhr = createPassthrough(this);
|
|
xhr.send.apply(xhr, arguments);
|
|
} else {
|
|
ctx.pretender.handleRequest(this);
|
|
}
|
|
};
|
|
|
|
|
|
function createPassthrough(fakeXHR) {
|
|
// event types to handle on the xhr
|
|
var evts = ['error', 'timeout', 'abort', 'readystatechange'];
|
|
|
|
// event types to handle on the xhr.upload
|
|
var uploadEvents = [];
|
|
|
|
// properties to copy from the native xhr to fake xhr
|
|
var lifecycleProps = ['readyState', 'responseText', 'responseXML', 'status', 'statusText'];
|
|
|
|
var xhr = fakeXHR._passthroughRequest = new ctx.pretender._nativeXMLHttpRequest();
|
|
xhr.open(fakeXHR.method, fakeXHR.url, fakeXHR.async, fakeXHR.username, fakeXHR.password);
|
|
|
|
if (fakeXHR.responseType === 'arraybuffer') {
|
|
lifecycleProps = ['readyState', 'response', 'status', 'statusText'];
|
|
xhr.responseType = fakeXHR.responseType;
|
|
}
|
|
|
|
// use onload if the browser supports it
|
|
if ('onload' in xhr) {
|
|
evts.push('load');
|
|
}
|
|
|
|
// add progress event for async calls
|
|
// avoid using progress events for sync calls, they will hang https://bugs.webkit.org/show_bug.cgi?id=40996.
|
|
if (fakeXHR.async && fakeXHR.responseType !== 'arraybuffer') {
|
|
evts.push('progress');
|
|
uploadEvents.push('progress');
|
|
}
|
|
|
|
// update `propertyNames` properties from `fromXHR` to `toXHR`
|
|
function copyLifecycleProperties(propertyNames, fromXHR, toXHR) {
|
|
for (var i = 0; i < propertyNames.length; i++) {
|
|
var prop = propertyNames[i];
|
|
if (prop in fromXHR) {
|
|
toXHR[prop] = fromXHR[prop];
|
|
}
|
|
}
|
|
}
|
|
|
|
// fire fake event on `eventable`
|
|
function dispatchEvent(eventable, eventType, event) {
|
|
eventable.dispatchEvent(event);
|
|
if (eventable['on' + eventType]) {
|
|
eventable['on' + eventType](event);
|
|
}
|
|
}
|
|
|
|
// set the on- handler on the native xhr for the given eventType
|
|
function createHandler(eventType) {
|
|
xhr['on' + eventType] = function(event) {
|
|
copyLifecycleProperties(lifecycleProps, xhr, fakeXHR);
|
|
dispatchEvent(fakeXHR, eventType, event);
|
|
};
|
|
}
|
|
|
|
// set the on- handler on the native xhr's `upload` property for
|
|
// the given eventType
|
|
function createUploadHandler(eventType) {
|
|
if (xhr.upload) {
|
|
xhr.upload['on' + eventType] = function(event) {
|
|
dispatchEvent(fakeXHR.upload, eventType, event);
|
|
};
|
|
}
|
|
}
|
|
|
|
var i;
|
|
for (i = 0; i < evts.length; i++) {
|
|
createHandler(evts[i]);
|
|
}
|
|
for (i = 0; i < uploadEvents.length; i++) {
|
|
createUploadHandler(uploadEvents[i]);
|
|
}
|
|
|
|
if (fakeXHR.async) {
|
|
xhr.timeout = fakeXHR.timeout;
|
|
xhr.withCredentials = fakeXHR.withCredentials;
|
|
}
|
|
for (var h in fakeXHR.requestHeaders) {
|
|
xhr.setRequestHeader(h, fakeXHR.requestHeaders[h]);
|
|
}
|
|
return xhr;
|
|
}
|
|
|
|
FakeRequest.prototype._passthroughCheck = function(method, args) {
|
|
if (this._passthroughRequest) {
|
|
return this._passthroughRequest[method].apply(this._passthroughRequest, args);
|
|
}
|
|
return FakeXMLHttpRequest.prototype[method].apply(this, args);
|
|
};
|
|
|
|
FakeRequest.prototype.abort = function abort() {
|
|
return this._passthroughCheck('abort', arguments);
|
|
};
|
|
|
|
FakeRequest.prototype.getResponseHeader = function getResponseHeader() {
|
|
return this._passthroughCheck('getResponseHeader', arguments);
|
|
};
|
|
|
|
FakeRequest.prototype.getAllResponseHeaders = function getAllResponseHeaders() {
|
|
return this._passthroughCheck('getAllResponseHeaders', arguments);
|
|
};
|
|
|
|
if (ctx.pretender._nativeXMLHttpRequest.prototype._passthroughCheck) {
|
|
console.warn('You created a second Pretender instance while there was already one running. ' +
|
|
'Running two Pretender servers at once will lead to unexpected results and will ' +
|
|
'be removed entirely in a future major version.' +
|
|
'Please call .shutdown() on your instances when you no longer need them to respond.');
|
|
}
|
|
return FakeRequest;
|
|
}
|
|
|
|
function verbify(verb) {
|
|
return function(path, handler, async) {
|
|
return this.register(verb, path, handler, async);
|
|
};
|
|
}
|
|
|
|
function scheduleProgressEvent(request, startTime, totalTime) {
|
|
setTimeout(function() {
|
|
if (!request.aborted && !request.status) {
|
|
var ellapsedTime = new Date().getTime() - startTime.getTime();
|
|
request.upload._progress(true, ellapsedTime, totalTime);
|
|
request._progress(true, ellapsedTime, totalTime);
|
|
scheduleProgressEvent(request, startTime, totalTime);
|
|
}
|
|
}, 50);
|
|
}
|
|
|
|
function isArray(array) {
|
|
return Object.prototype.toString.call(array) === '[object Array]';
|
|
}
|
|
|
|
var PASSTHROUGH = {};
|
|
|
|
Pretender.prototype = {
|
|
get: verbify('GET'),
|
|
post: verbify('POST'),
|
|
put: verbify('PUT'),
|
|
'delete': verbify('DELETE'),
|
|
patch: verbify('PATCH'),
|
|
head: verbify('HEAD'),
|
|
options: verbify('OPTIONS'),
|
|
map: function(maps) {
|
|
maps.call(this);
|
|
},
|
|
register: function register(verb, url, handler, async) {
|
|
if (!handler) {
|
|
throw new Error('The function you tried passing to Pretender to handle ' +
|
|
verb + ' ' + url + ' is undefined or missing.');
|
|
}
|
|
|
|
handler.numberOfCalls = 0;
|
|
handler.async = async;
|
|
this.handlers.push(handler);
|
|
|
|
var registry = this.hosts.forURL(url)[verb];
|
|
|
|
registry.add([{
|
|
path: parseURL(url).fullpath,
|
|
handler: handler
|
|
}]);
|
|
|
|
return handler;
|
|
},
|
|
passthrough: PASSTHROUGH,
|
|
checkPassthrough: function checkPassthrough(request) {
|
|
var verb = request.method.toUpperCase();
|
|
var path = parseURL(request.url).fullpath;
|
|
var recognized = this.hosts.forURL(request.url)[verb].recognize(path);
|
|
var match = recognized && recognized[0];
|
|
|
|
if ((match && match.handler === PASSTHROUGH) || this.forcePassthrough) {
|
|
this.passthroughRequests.push(request);
|
|
this.passthroughRequest(verb, path, request);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
handleRequest: function handleRequest(request) {
|
|
var verb = request.method.toUpperCase();
|
|
var path = request.url;
|
|
|
|
var handler = this._handlerFor(verb, path, request);
|
|
|
|
if (handler) {
|
|
handler.handler.numberOfCalls++;
|
|
var async = handler.handler.async;
|
|
this.handledRequests.push(request);
|
|
|
|
var pretender = this;
|
|
|
|
var _handleRequest = function(statusHeadersAndBody) {
|
|
if (!isArray(statusHeadersAndBody)) {
|
|
var note = 'Remember to `return [status, headers, body];` in your route handler.';
|
|
throw new Error('Nothing returned by handler for ' + path + '. ' + note);
|
|
}
|
|
|
|
var status = statusHeadersAndBody[0],
|
|
headers = pretender.prepareHeaders(statusHeadersAndBody[1]),
|
|
body = pretender.prepareBody(statusHeadersAndBody[2], headers);
|
|
|
|
pretender.handleResponse(request, async, function() {
|
|
request.respond(status, headers, body);
|
|
pretender.handledRequest(verb, path, request);
|
|
});
|
|
};
|
|
|
|
try {
|
|
var result = handler.handler(request);
|
|
if (result && typeof result.then === 'function') {
|
|
// `result` is a promise, resolve it
|
|
result.then(function(resolvedResult) {
|
|
_handleRequest(resolvedResult);
|
|
});
|
|
} else {
|
|
_handleRequest(result);
|
|
}
|
|
} catch (error) {
|
|
this.erroredRequest(verb, path, request, error);
|
|
this.resolve(request);
|
|
}
|
|
} else {
|
|
if (!this.disableUnhandled) {
|
|
this.unhandledRequests.push(request);
|
|
this.unhandledRequest(verb, path, request);
|
|
}
|
|
}
|
|
},
|
|
handleResponse: function handleResponse(request, strategy, callback) {
|
|
var delay = typeof strategy === 'function' ? strategy() : strategy;
|
|
delay = typeof delay === 'boolean' || typeof delay === 'number' ? delay : 0;
|
|
|
|
if (delay === false) {
|
|
callback();
|
|
} else {
|
|
var pretender = this;
|
|
pretender.requestReferences.push({
|
|
request: request,
|
|
callback: callback
|
|
});
|
|
|
|
if (delay !== true) {
|
|
scheduleProgressEvent(request, new Date(), delay);
|
|
setTimeout(function() {
|
|
pretender.resolve(request);
|
|
}, delay);
|
|
}
|
|
}
|
|
},
|
|
resolve: function resolve(request) {
|
|
for (var i = 0, len = this.requestReferences.length; i < len; i++) {
|
|
var res = this.requestReferences[i];
|
|
if (res.request === request) {
|
|
res.callback();
|
|
this.requestReferences.splice(i, 1);
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
requiresManualResolution: function(verb, path) {
|
|
var handler = this._handlerFor(verb.toUpperCase(), path, {});
|
|
if (!handler) { return false; }
|
|
|
|
var async = handler.handler.async;
|
|
return typeof async === 'function' ? async() === true : async === true;
|
|
},
|
|
prepareBody: function(body) { return body; },
|
|
prepareHeaders: function(headers) { return headers; },
|
|
handledRequest: function(/* verb, path, request */) { /* no-op */},
|
|
passthroughRequest: function(/* verb, path, request */) { /* no-op */},
|
|
unhandledRequest: function(verb, path/*, request */) {
|
|
throw new Error('Pretender intercepted ' + verb + ' ' +
|
|
path + ' but no handler was defined for this type of request');
|
|
},
|
|
erroredRequest: function(verb, path, request, error) {
|
|
error.message = 'Pretender intercepted ' + verb + ' ' +
|
|
path + ' but encountered an error: ' + error.message;
|
|
throw error;
|
|
},
|
|
_handlerFor: function(verb, url, request) {
|
|
var registry = this.hosts.forURL(url)[verb];
|
|
var matches = registry.recognize(parseURL(url).fullpath);
|
|
|
|
var match = matches ? matches[0] : null;
|
|
if (match) {
|
|
request.params = match.params;
|
|
request.queryParams = matches.queryParams;
|
|
}
|
|
|
|
return match;
|
|
},
|
|
shutdown: function shutdown() {
|
|
self.XMLHttpRequest = this._nativeXMLHttpRequest;
|
|
this.ctx.pretender = undefined;
|
|
// 'stop' the server
|
|
this.running = false;
|
|
}
|
|
};
|
|
|
|
Pretender.parseURL = parseURL;
|
|
Pretender.Hosts = Hosts;
|
|
Pretender.Registry = Registry;
|
|
|
|
if (typeof module === 'object') {
|
|
module.exports = Pretender;
|
|
} else if (typeof define !== 'undefined') {
|
|
define('pretender', [], function() {
|
|
return Pretender;
|
|
});
|
|
}
|
|
self.Pretender = Pretender;
|
|
}(self));
|