diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index ddb445e..811ba0a 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,49 @@ -This will all make more sense once i finish all the todo items at the bottom. +MAIN TODO: improve this readme. For the moment, you're better off reading the sourcecode of hadoken.coffee -# What is this? - -***On the client*** Hadoken exposes a socket.io client (and soon a simple AJAX api) that can connect to a subdomain that runs a hadoken server. it has full push and ajax support (no jsonp or some such crap) - -***On the server*** You can run a socket.io push server independently from the rest of your app on a different subdomain. i consider this a best practice and it allows you to retrofit existing apps on your domain with cool new push features. - -this is pretty much the same as twitter's phoenix. twitter uses it for ajax requests from twitter.com to api.twitter.com. in this first rough draft i use it just for socket.io connections, but ajax is next in line. - -# How to run the example in dev and production mode +WARNING: there may still be some custom-to-us parts in the code. Feel free to tinker. -I'm drunk and tired so I'll make it real quick. The example assumes you have two hosts in `/etc/hosts` defined like this - - 127.0.0.1 localhost.com - 127.0.0.2 push.localhost.com - -You can add the `127.0.0.2` ip to your loopback interface like this (on mac, probably similar-ish on linux) `sudo ifconfig lo0 alias 127.0.0.2` (the alias will disappear on reboot). Don't forget to `sudo dscacheutil -flushcache` to re-read the hosts file. - -Examples require 'coffee-script', 'connect' and 'socket.io' to be installed in npm. Install them into the hadoken directory, I'll make a package.json later. - -The example is in the example directory, cd there. To test out development mode (only one server running that serves both your app or whatever and the socket.io server), run `coffee server.coffee`. Point your browser to `http://localhost.com:8080` and watch the magic in the console. +# What is this? -To test out production mode, you'll have to start two servers. From the example directory run `coffee server.coffee production`. This will launch the webapp but it won't hook up socket.io. background the task and run `coffee push_server.coffee` in the same directory. this will start a socket.io server that runs on push.localhost.com:8080. Point your browser to `http://localhost.com:8080` again and you'll see push still working. booya. +Hadoken makes your node.js REST and/or push (via socket.io) API available on subdomains and as soon as I get to it on other domains. +Just create your server like you normally would, then call `hadoken.listen` on it with the appropriate options and suddenly you're serving an xd receiver iframe and a bunch of javascript from your server that just works. -# Why? (aka FAQ) +Currently there's a dependency on connect servers because I was lazy, so keep that in mind. -Because I'm an eccentric idealist and I want as little crap as possible to interfere with or share a process/machine/datacenter with my webapp's push server. Maybe the push server and the rest of my app have different scaling requirements and I want to have to put them all on the same machines and behind the same load balancers. In fact, I may want to use a load balancer that doesn't speak websockets for most of my app. I also like to have a clean separation of concerns. +Here's a really quick example (pardon my coffeescript): -More pragmatically, suppose I have a bunch of existing web properties on all kinds of subdomains of mydomain.com. Now I add this shiny new service at api.mydomain.com. What is the easiest way of making that new service available to all my properties? Hadoken! Boom. +***Server on api.example.com*** -Just to be clear: for vanilla websockets this is not even necessary since they can in fact connect to other sudomains. The problem is with the fallback methods like XHR that don't work on subdomains. + server = connect.createServer '/sayhi', (req, res, next) -> + res.end 'hi' + + hadoken.listen + baseDomain: 'example.com' + server: server + + server.listen 80 -# TODO - - * Make a proper example that's easier to run - - * Figure out npm v1.0 stuff - - * Improve hadoken API, the example app still feels a little funky +***Client on cdn.example.com*** + + - * Make hadoken server write configuration vars into parent frame - * Remove dependency on connect, I was just lazy +The client uses a patched version of the reqwest library but that may change soon - * Buffer all socket.io-related function calls in window.hadoken in the parent until hadoken becomes functional +# Why? - * don't copy-paste socket.io client js (and find out why minified version failed) +I initially wrote this to pull out a push server from our API server to separate concerns better. Then it occurred to me that this could be used to public API-ify random webservices and that's awesome. Like I said though, cross-domain is not implemented yet. - * implement a less arbitrary loading and configuration pattern - - * Expose AJAX abstraction and make the whole thing independent from socket.io. \ No newline at end of file +At campfire labs using hadoken allows us to serve all functionality (push, api, static file serving) from one process in development and from three different processes in production. It's pretty sweer \ No newline at end of file diff --git a/example/node_modules b/example/node_modules deleted file mode 120000 index 40b878d..0000000 --- a/example/node_modules +++ /dev/null @@ -1 +0,0 @@ -node_modules/ \ No newline at end of file diff --git a/example/push_logic.coffee b/example/push_logic.coffee deleted file mode 100644 index 9c825ed..0000000 --- a/example/push_logic.coffee +++ /dev/null @@ -1,11 +0,0 @@ -# i can put all my push crap here -# if i want to, i can host this part of the app on a different machine! - -module.exports = - hookup: (socketio_server) -> - socketio_server.on 'connection', (client) -> - i = 0 - interval = setInterval -> - client.send i++ - , 1000 - client.on 'disconnect', -> clearInterval interval diff --git a/example/push_server.coffee b/example/push_server.coffee deleted file mode 100644 index 6694b6b..0000000 --- a/example/push_server.coffee +++ /dev/null @@ -1,8 +0,0 @@ -# stand alone push server for production -hadoken = require '../index' -socketio_server = hadoken.listen - port: 8080 - host: '127.0.0.2' - -push_logic = require './push_logic' -push_logic.hookup socketio_server diff --git a/example/server.coffee b/example/server.coffee deleted file mode 100644 index 8aacb32..0000000 --- a/example/server.coffee +++ /dev/null @@ -1,65 +0,0 @@ -# in development: -# - piggyback push server on this server -# -# in production -# - connect to push server on different domain - -require.paths.unshift '../node_modules' - -connect = require 'connect' -fs = require 'fs' -hadoken = require '../index' -push_logic = require './push_logic' - -process.env.NODE_ENV or= 'development' -if process.argv[2] then process.env.NODE_ENV = process.argv[2] - -NODE_ENV = process.env.NODE_ENV - -if NODE_ENV not in ['development', 'production'] - console.error 'Come on' - process.exit() - -console.log "Starting server in #{NODE_ENV} mode ..." - -DEV_MODE = NODE_ENV is 'development' - -server = connect.createServer() - -# production settings -template_vars = - hadoken_host: 'push.localhost.com' - hadoken_port: '8080' - hadoken_path: '/' - -if DEV_MODE - # hadoken hooks up socket.io and hosts - # all required static files for this spiel - socketio_server = hadoken.listen - server: server - path: '/hadoken/' - - # in dev mode we want to also run the push logic - push_logic.hookup socketio_server - - template_vars = - hadoken_host: 'localhost.com' - hadoken_port: '8080' - hadoken_path: '/hadoken/' - -server.use (req, res, next) -> - - fs.readFile './templates/index.html', 'utf8', (err, index_html) -> - return next err if err - - # GHETTO TEMPLATING FTW! - for key, val of template_vars - regexp = new RegExp "{#{key}}", "g" - index_html = index_html.replace regexp, val - - res.writeHead 200, - 'Content-Length': Buffer.byteLength index_html - 'Content-Type': 'text/html; charset=UTF-8' - res.end index_html - -server.listen 8080, '127.0.0.1' \ No newline at end of file diff --git a/example/templates/index.html b/example/templates/index.html deleted file mode 100644 index b54206b..0000000 --- a/example/templates/index.html +++ /dev/null @@ -1,28 +0,0 @@ - -HADOKEN! - -

Hi there!

-

Here's a bunch of stuff being sent from the server:

- - - - - - - - \ No newline at end of file diff --git a/hadoken.coffee b/hadoken.coffee index b9ad5c8..2c3e4a1 100644 --- a/hadoken.coffee +++ b/hadoken.coffee @@ -1,73 +1,216 @@ -# desired api: -# > hadoken = require('hadoken'); -# > hadoken.listen(options) -# where options contains either an http server -# or host/port combination. +### + + # Hadoken + + ## TODO + - Enable true cross-domain push/api access using `easyXDM`. Make it + optional via enableCrossdomain option. In cross-domain we also need + to proxy the socket.io api on the parent hadoken object (instead of + using the child object as-is) + - Get rid of connect dependency + - Allow `globalVariable = 'namespace.var';` + - Improve parent script to allow same global variable to be used for + multiple backends. If FB for example had two hosts, graph.facebook.com + and realtime.facebook.com, it would be nice if both could be accessed + through a global FB object. + - Allow embedding wrapper-libraries directly into `parent.js` + - Allow changing options while the server is running for robustness. + We could e.g. re-render the iframe and parent js everytime a `setOpts` + method is called. + - Figure out whether there is any reason not to set document.domain + to the second level domain from the request's host header + - consider automatically setting document.domain in parent.js. What + will that break? + + +### http = require 'http' -io = require 'socket.io' fs = require 'fs' coffee = require 'coffee-script' + +# TODO get rid of connect dependency. i was just too lazy ^^ connect = require 'connect' path = require 'path' -default_opts = - server: null # if server is set, host and port areignored - port: 8080 - host: '127.0.0.2' - path: '/' - -resource = (name) -> "#{__dirname}/resources/#{name}" - -# returns HTML of the iframe -iframeContent = -> - template = fs.readFileSync resource('receiver.html'), 'utf8' - script = fs.readFileSync resource('socket.io.js'), 'utf8' - receiver_coffee = fs.readFileSync resource('child.coffee'), 'utf8' - script += coffee.compile receiver_coffee - # ghetto templating ftw! - return template.replace '{SCRIPT}', script - -parentJs = -> - parent_coffee = fs.readFileSync resource('parent.coffee'), 'utf8' - return coffee.compile parent_coffee - -listen = (opts) -> - - for key, val of default_opts - opts[key] or= default_opts[key] - - server = opts.server - stand_alone = false +class Hadoken - if not server - stand_alone = true - server = connect.createServer() + # ## Configuration + @defaults = + + # (optional) Pass in an instance of connect.HTTP(S)Server to hook + # hadoken up to. If you omit this, a server will be created for you, + # but you must call `.listen()` on it yourself. + # + # WARNING: if you want to use socket.io, the server needs to be the one + # that you later call `.listen()` on. You can't nest it in yet another + # server. + server: null - socket = io.listen server + # All other paths are relative to `rootEndpoint`. + rootEndpoint: "hadoken/" - parent_js = parentJs() - parent_js_length = Buffer.byteLength parent_js - server.use path.normalize(opts.path+'/parent.js'), (req, res, next) -> - res.writeHead 200, - 'Content-Type': 'application/javascript; charset=UTF-8' - 'Content-Length': parent_js_length - res.end parent_js + # Serve iframe on `http://host:port/hadoken/` by default + iframeFilename: "" + + # Used as domain policy (`document.domain = x`) if set. Set this to your + # base domain if you want to use your api/push server from a different + # subdomain. + baseDomain: undefined + + # Global variable that is exposed to parent window. Defaults to `hadoken`. + # Set this if you have multiple hadokens on the same page (e.g. one for + # push.yourdomain.com and one for api.yourdomain.com) or if you want to + # expose your API to third parties (Facebook might set this to `FB`) + globalVariable: 'hadoken' + + # Name of the script that sites using your API need to include. + # For all default values, you can load hadoken from + # `http://host:port/hadoken/parent.js` which will provide a global + # variable `hadoken` that you can use to make requests. + parentJsFile: 'parent.js' + + # Set `enableAjax` to true to enable the `request()` method + # on the hadoken object (e.g. `hadoken.request()`). + enableAjax: true + + # Set to true to serve socket.io according to socketOptions. + # See `socket.io-node` docs for socketOptions. + # NOTE: if you set socketOptions.resource, you must set it on + # socketClientOptions as well + enableSocket: false + socketOptions: {} + # Options for the socket.io client + socketClientOptions: {} + + + # ## Public API + + constructor: (options={}) -> + + @_setOpts options + @_server = @options.server or connect.createServer() + + # Add socket.io to server if desired. Get a handle on the `Listener` + # instance using `.getSocket()` to attach event handlers etc. + if @options.enableSocket + @options.socketClientOptions.resource or= @_getSocketIOEndpoint() + @options.socketOptions.resource or= @_getSocketIOEndpoint() + @_socket = require('socket.io').listen @_server, @options.socketOptions + + # Serve iframe and parent javsacript file + @_server.use connect.router (app) => + app.get @_path(@options.parentJsFile), @_serveParentJs + app.get @_path(@options.iframeFilename), @_serveIframe + + + # Returns your `io.Listener` instance. + getSocket: -> @_socket + + # You'll need this to get at the server that was created for you + # if you don't pass in a server + getServer: -> @_server - iframe_html = iframeContent() - iframe_html_length = Buffer.byteLength iframe_html - server.use opts.path, (req, res, next) -> + + # ## Internals + # generate parent javascript from template in `resources/parent.coffee` + _makeParentJs: -> + parent_coffee = fs.readFileSync @_resource('parent.coffee'), 'utf8' + js = coffee.compile parent_coffee + js = @_getClientConfigSnippet() + js + return @_wrapJs js + + # Generate iframe html from template in `resources/receiver.html` + _makeIframeHtml: -> + template = fs.readFileSync @_resource('receiver.html'), 'utf8' + + # 1. configure client + script = @_getClientConfigSnippet() + + # 2. load dependencies + if @options.enableSocket + script += fs.readFileSync @_resource('socket.io.js'), 'utf8' + if @options.enableAjax + script += fs.readFileSync @_resource('reqwest.js'), 'utf8' + + # 3. bootstrap! + receiver_coffee = fs.readFileSync @_resource('child.coffee'), 'utf8' + script += coffee.compile receiver_coffee + script = @_wrapJs script + script = @_getDomainPolicySnippet() + script + return template.replace '{SCRIPT}', script + + + # Returns a sanitized path relative to root endpoint. + _path: -> path.join '/', @options.endpoint, arguments... + + # Sets and merges options + _setOpts: (opts) -> + @options = opts + for key, val of @constructor.defaults + @options[key] = val if typeof @options[key] is 'undefined' + + # Some functions for the http responses. This is broken up like this + # to make it easier to get rid of connect and allow in-flight option changing + # in the future. + _respond: (res, content_type, content, length) => res.writeHead 200, - 'Content-Type': 'text/html; charset=UTF-8' - 'Content-Length': iframe_html_length - res.end iframe_html - - if stand_alone - console.log 'listening on ', opts.port, opts.host - server.listen opts.port, opts.host + 'Content-Type': content_type + 'Content-Length': length + res.end content + _serveIframe: (req, res, next) => + [html, length] = @_getCachedIframe() + @_respond res, 'text/html; charset=UTF-8', html, length + _serveParentJs: (req, res, next) => + [js, length] = @_getCachedParentJs() + # HACK: use req.headers.host field to figure out where + # the parent should request the iframe from. This way the + # developer doesn't have to pass host/port to hadoken. + # Downside is reduced performance and general hackyness + host = req?.headers?.host or '' + iframe_url = "http://#{host}#{@_path @options.iframeFilename}" + placeholder_length = 'IFRAME_URL'.length + iframe_length = Buffer.byteLength iframe_url + js = js.replace 'IFRAME_URL', iframe_url + length = length - placeholder_length + iframe_length + @_respond res, 'application/javascript; charset=UTF-8', js, length + + _getCachedIframe: -> + if not @_cached_iframe + @_cached_iframe = @_makeIframeHtml() + @_cached_iframe_length = Buffer.byteLength @_cached_iframe + return [@_cached_iframe, @_cached_iframe_length] + + _getCachedParentJs: -> + if not @_cached_js + @_cached_js = @_makeParentJs() + @_cached_js_length = Buffer.byteLength @_cached_js + return [@_cached_js, @_cached_js_length] + _wrapJs: (js) -> "(function(){#{js}})();" + _getDomainPolicySnippet: -> + if @options.baseDomain + return "document.domain = '#{@options.baseDomain}';\n" + else return '' + + _getClientConfigSnippet: -> + child_opts = + globalVariable: @options.globalVariable + enableSocket: @options.enableSocket + enableAjax: @options.enableAjax + socketClientOptions: @options.socketClientOptions + + return "var _hadoken_conf = #{JSON.stringify(child_opts)};\n"; + + _getSocketIOEndpoint: -> + resource = @_path 'socket.io' + # hack to remove leading slash + if resource[0] is '/' + resource = resource.substr 1 + return resource + + # Helper function to find resources for this module. + _resource: (name) -> "#{__dirname}/resources/#{name}" - # expose socket.io for now - return socket - module.exports = - listen: listen + Hadoken: Hadoken + listen: -> new Hadoken arguments... diff --git a/resources/child.coffee b/resources/child.coffee index db4f202..28e8ec6 100644 --- a/resources/child.coffee +++ b/resources/child.coffee @@ -1,12 +1,14 @@ -console.log 'Hadoken child loaded' +# GHETTO templating FTW +hadoken = window.parent[_hadoken_conf.globalVariable] or= {} -# TODO -# - hadoken should be a proxy for socket.io. -# - notify parent window that the connection is established -window.parent.hadoken or= {} -hadoken = window.parent.hadoken -socket = hadoken.socket = new io.Socket hadoken.host, - port: hadoken.port -socket.connect() +if _hadoken_conf.enableSocket + socket = hadoken.socket = new io.Socket document.location.hostname, \ + _hadoken_conf.socketClientOptions + cookie = hadoken.cookie = document.cookie -socket.on 'connect', -> hadoken.init() if hadoken.init +if _hadoken_conf.enableAjax + hadoken.ajax = window.reqwest + +if typeof hadoken.init is 'function' + hadoken.initialized = true + hadoken.init() diff --git a/resources/parent.coffee b/resources/parent.coffee index 663b2e0..f9df1b1 100644 --- a/resources/parent.coffee +++ b/resources/parent.coffee @@ -1,10 +1,17 @@ + # TODO buffer up listeners etc now # and hook up to socket.io proper once iframe is there -hadoken or= {} + +hadoken = window[_hadoken_conf.globalVariable] or= {} + +if typeof window.console is 'undefined' + window.console = + log: (->) + error: (->) + dir: (->) ifrm = document.createElement "IFRAME" -src = "http://#{hadoken.host}:#{hadoken.port}#{hadoken.path}" +ifrm.style.display = "none" +src = "IFRAME_URL" ifrm.setAttribute "src", src document.body.appendChild ifrm - -console.log 'Hadoken parent loaded' \ No newline at end of file diff --git a/resources/receiver.html b/resources/receiver.html index a75a353..01a6ac5 100644 --- a/resources/receiver.html +++ b/resources/receiver.html @@ -1,13 +1,11 @@ HADOKEN! - + - + - \ No newline at end of file + diff --git a/resources/reqwest.js b/resources/reqwest.js new file mode 100644 index 0000000..84d6d60 --- /dev/null +++ b/resources/reqwest.js @@ -0,0 +1,251 @@ +/*! + * Reqwest! A x-browser general purpose XHR connection manager + * copyright Dustin Diaz 2011 + * https://github.com/ded/reqwest + * license MIT + */ + + /* + WARNING: this is a patched version of reqwest that fixes incorrect assumptions + about http headers. + */ +!function (window) { + var twoHundo = /^20\d$/, + doc = document, + byTag = 'getElementsByTagName', + topScript = doc[byTag]('script')[0], + head = topScript.parentNode, + xhr = ('XMLHttpRequest' in window) ? + function () { + return new XMLHttpRequest(); + } : + function () { + return new ActiveXObject('Microsoft.XMLHTTP'); + }; + + var uniqid = 0; + + function readyState(o, success, error) { + return function () { + if (o && o.readyState == 4) { + if (twoHundo.test(o.status)) { + success(o); + } else { + error(o); + } + } + }; + } + + function setHeaders(http, options) { + var headers = options.headers || {}; + headers.Accept = 'text/javascript, text/html, application/xml, text/xml, */*'; + headers['X-Requested-With'] = headers['X-Requested-With'] || 'XMLHttpRequest'; + if (options.data && !headers['Content-Type']) { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + for (var h in headers) { + headers.hasOwnProperty(h) && http.setRequestHeader(h, headers[h], false); + } + } + + function getCallbackName(o) { + var callbackVar = o.jsonpCallback || "callback"; + if (o.url.substr(-(callbackVar.length + 2)) == (callbackVar + "=?")) { + // Generate a guaranteed unique callback name + var callbackName = "reqwest_" + uniqid++; + + // Replace the ? in the URL with the generated name + o.url = o.url.substr(0, o.url.length - 1) + callbackName; + return callbackName; + } else { + // Find the supplied callback name + var regex = new RegExp(callbackVar + "=([\\w]+)"); + return o.url.match(regex)[1]; + } + } + + function getRequest(o, fn, err) { + if (o.type == 'jsonp') { + var script = doc.createElement('script'); + + // Add the global callback + var callbackName = getCallbackName(o); + window[callbackName] = function (data) { + // Call the success callback + o.success && o.success(data); + }; + + // Setup our script element + script.type = "text/javascript"; + script.src = o.url; + script.async = true; + script.onload = function () { + // Script has been loaded, and thus the user callback has + // been called, so lets clean up now. + head.removeChild(script); + delete window[callbackName]; + }; + + // Add the script to the DOM head + head.insertBefore(script, topScript); + } else { + var http = xhr(); + http.open(o.method || 'GET', typeof o == 'string' ? o : o.url, true); + setHeaders(http, o); + http.onreadystatechange = readyState(http, fn, err); + o.before && o.before(http); + http.send(o.data || null); + return http; + } + } + + function Reqwest(o, fn) { + this.o = o; + this.fn = fn; + init.apply(this, arguments); + } + + function setType(url) { + if (/\.json$/.test(url)) { + return 'json'; + } + if (/\.jsonp$/.test(url)) { + return 'jsonp'; + } + if (/\.js$/.test(url)) { + return 'js'; + } + if (/\.html?$/.test(url)) { + return 'html'; + } + if (/\.xml$/.test(url)) { + return 'xml'; + } + return 'js'; + } + + function init(o, fn) { + this.url = typeof o == 'string' ? o : o.url; + this.timeout = null; + var type = o.type || setType(this.url), self = this; + fn = fn || function () {}; + + if (o.timeout) { + this.timeout = setTimeout(function () { + self.abort(); + error(); + }, o.timeout); + } + + function complete(resp) { + o.complete && o.complete(resp); + } + + function success(resp) { + o.timeout && clearTimeout(self.timeout) && (self.timeout = null); + var r = resp.responseText; + + switch (type) { + case 'json': + resp = eval('(' + r + ')'); + break; + case 'js': + resp = eval(r); + break; + case 'html': + resp = r; + break; + // default is the response from server + } + + fn(resp); + o.success && o.success(resp); + complete(resp); + } + + function error(resp) { + o.error && o.error(resp); + complete(resp); + } + + this.request = getRequest(o, success, error); + } + + Reqwest.prototype = { + abort: function () { + this.request.abort(); + }, + + retry: function () { + init.call(this, this.o, this.fn); + } + }; + + function reqwest(o, fn) { + return new Reqwest(o, fn); + } + + function enc(v) { + return encodeURIComponent(v); + } + + function serial(el) { + var n = el.name; + // don't serialize elements that are disabled or without a name + if (el.disabled || !n) { + return ''; + } + n = enc(n); + switch (el.tagName.toLowerCase()) { + case 'input': + switch (el.type) { + // silly wabbit + case 'reset': + case 'button': + case 'image': + case 'file': + return ''; + case 'checkbox': + case 'radio': + return el.checked ? n + '=' + (el.value ? enc(el.value) : true) + '&' : ''; + default: // text hidden password submit + return n + '=' + (el.value ? enc(el.value) : true) + '&'; + } + break; + case 'textarea': + return n + '=' + enc(el.value) + '&'; + case 'select': + // @todo refactor beyond basic single selected value case + return n + '=' + enc(el.options[el.selectedIndex].value) + '&'; + } + return ''; + } + + reqwest.serialize = function (form) { + var inputs = form[byTag]('input'), + selects = form[byTag]('select'), + texts = form[byTag]('textarea'); + return (v(inputs).chain().toArray().map(serial).value().join('') + + v(selects).chain().toArray().map(serial).value().join('') + + v(texts).chain().toArray().map(serial).value().join('')).replace(/&$/, ''); + }; + + reqwest.serializeArray = function (f) { + for (var pairs = this.serialize(f).split('&'), i = 0, l = pairs.length, r = [], o; i < l; i++) { + pairs[i] && (o = pairs[i].split('=')) && r.push({name: o[0], value: o[1]}); + } + return r; + }; + + var old = window.reqwest; + reqwest.noConflict = function () { + window.reqwest = old; + return this; + }; + + // defined as extern for Closure Compilation + // do not change to (dot) '.' syntax + window['reqwest'] = reqwest; + +}(this); \ No newline at end of file