diff --git a/README.md b/README.md index 924a454..5da3b14 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,13 @@ [cover]: https://img.shields.io/coveralls/unshiftio/requests/master.svg?style=flat-square [irc]: https://img.shields.io/badge/IRC-irc.freenode.net%23unshift-00a8ff.svg?style=flat-square -Requests is a small library that implements fully and true streaming XHR for -browsers that support these methods. It uses a variety of proprietary +Requests is a small library that fully implements true streaming XHR for +Node.js and browsers that support these methods. It uses a variety of proprietary `responseType` properties to force a streaming connection, even for binary data. For browsers that don't support this we will simply fallback to a regular but **async** XHR 1/2 request or ActiveXObject in even older deprecated browsers. +- Chrome: `ReadableByteStream` - Internet Explorer >= 10: `ms-stream` - FireFox >= 9: `moz-chunked` - FireFox < 20: `multipart` diff --git a/browser.js b/browser.js index 0d84cc7..54b1a8a 100644 --- a/browser.js +++ b/browser.js @@ -1,11 +1,28 @@ 'use strict'; -var Requested = require('./requested') +var FetchWrapper = require('./fetch-wrapper.js') + , Requested = require('./requested') , listeners = require('loads') , send = require('xhr-send') + , sameOrigin = require('same-origin') , hang = require('hang') , AXO = require('axo'); +/** + * Root reference for iframes. + * Taken from + * https://github.com/visionmedia/superagent/blob/83892f35fe15676a4567a0eb51eecd096939ad36/lib/client.js#L1 + */ +var root; +if (typeof window !== 'undefined') { // Browser window + root = window; +} else if (typeof self !== 'undefined') { // Web Worker + root = self; +} else { // Other environments + console.warn('Using browser-only version of requests in non-browser environment'); + root = this; +} + /** * RequestS(tream). * @@ -19,7 +36,7 @@ var Requested = require('./requested') * * @constructor * @param {String} url The URL we want to request. - * @param {Object} options Various of request options. + * @param {Object} options Various request options. * @api public */ var Requests = module.exports = Requested.extend({ @@ -64,15 +81,17 @@ var Requests = module.exports = Requested.extend({ /** * Initialize and start requesting the supplied resource. * - * @param {Object} options Passed in defaults. + * @param {String} url The URL we want to request. + * @param {Object} options Various request options. * @api private */ - open: function open() { + open: function open(url, options) { var what - , slice = true , requests = this , socket = requests.socket; + var slice = (requests.hasOwnProperty('slice')) ? requests.slice : true; + requests.on('stream', function stream(data) { if (!slice) { return requests.emit('data', data); @@ -94,15 +113,46 @@ var Requests = module.exports = Requested.extend({ }); if (this.timeout) { + // NOTE the "+" before this.timeout just ensures + // socket.timeout is a number. socket.timeout = +this.timeout; } - if ('cors' === this.mode.toLowerCase() && 'withCredentials' in socket) { - socket.withCredentials = true; + // Polyfilling XMLHttpRequest to accept fetch options + // see https://fetch.spec.whatwg.org/#cors-protocol-and-credentials and + // https://fetch.spec.whatwg.org/#concept-request-credentials-mode + // + // ...credentials mode, which is "omit", "same-origin", or "include". + // Unless stated otherwise, it is "omit". + // + // When request's mode is "navigate", its credentials mode is assumed to + // be "include" and fetch does not currently account for other values. + // If HTML changes here, this standard will need corresponding changes. + if ('withCredentials' in socket) { + var credentials = this.credentials; + if (credentials) { + credentials = credentials.toLowerCase(); + } else if (this.mode) { + var mode = this.mode.toLowerCase(); + if (mode === 'navigate') { + credentials = 'include'; + } + } + + if (credentials) { + if (credentials === 'include') { + socket.withCredentials = true; + } else { + var origin = root.location.origin || (root.location.protocol + '//' + root.location.host); + if (credentials === 'same-origin' && sameOrigin(origin, url)) { + socket.withCredentials = true; + } + } + } } // - // ActiveXObject will throw an `Type Mismatch` exception when setting the to + // ActiveXObject will throw a `Type Mismatch` exception when setting the to // an null-value and to be consistent with all XHR implementations we're going // to cast the value to a string. // @@ -116,15 +166,16 @@ var Requests = module.exports = Requested.extend({ // already eliminates duplicate headers. // for (what in this.headers) { - if (this.headers[what] !== undefined && this.socket.setRequestHeader) { - this.socket.setRequestHeader(what, this.headers[what] +''); + if (this.headers[what] !== undefined && socket.setRequestHeader) { + socket.setRequestHeader(what, this.headers[what] + ''); } } // // Set the correct responseType method. // - if (requests.streaming) { + // TODO how should fetch/ReadableByteStream be handled here? + if (requests.streaming && (requests.method !== 'FETCH')) { if (!this.body || 'string' === typeof this.body) { if ('multipart' in socket) { socket.multipart = true; @@ -142,17 +193,37 @@ var Requests = module.exports = Requested.extend({ } } + // Polyfill XMLHttpRequest to use the fetch headers API + var fetchOrFake = {} + if (requests.method !== 'FETCH') { + // fetchOrFake is fake + fetchOrFake.response = { + headers: { + get: socket.getResponseHeader + } + }; + fetchOrFake.request = socket.request || {}; + // TODO this is just a start + } else { + // fetchOrFake is real fetch + fetchOrFake = socket; + } + listeners(socket, requests, requests.streaming); - requests.emit('before', socket); - send(socket, this.body, hang(function send(err) { - if (err) { - requests.emit('error', err); - requests.emit('end', err); - } + requests.emit('before', fetchOrFake); + + if (requests.method !== 'FETCH') { + send(socket, this.body, hang(function send(err) { + if (err) { + requests.emit('error', err); + requests.emit('end', err); + } - requests.emit('send'); - })); + // NOTE the send event for fetch is in fetch-wrapper.js + requests.emit('send'); + })); + } }, /** @@ -179,6 +250,20 @@ var Requests = module.exports = Requested.extend({ } }); +/** + * Create a new FetchWrapper. + * + * @returns {FetchWrapper} + * @type {Object} requests + * @api private + */ +Requests.FETCH = function create(requests) { + // TODO we need to pass the requests object to FetchWrapper, + // This seems kludgy because it's not parallel with Requests.XHR and Requests.AXO. + requests.slice = false; + return new FetchWrapper(requests); +}; + /** * Create a new XMLHttpRequest. * @@ -229,7 +314,11 @@ Requests.active = {}; * @type {String} * @public */ -Requests.method = !!Requests.XHR() ? 'XHR' : (!!Requests.AXO() ? 'AXO' : ''); +if (typeof root.ReadableByteStream === 'function') { + Requests.method = 'FETCH'; +} else { + Requests.method = !!Requests.XHR() ? 'XHR' : (!!Requests.AXO() ? 'AXO' : ''); +} /** * Boolean indicating @@ -240,13 +329,13 @@ Requests.method = !!Requests.XHR() ? 'XHR' : (!!Requests.AXO() ? 'AXO' : ''); Requests.supported = !!Requests.method; /** - * The different type of `responseType` parsers that are supported in this XHR + * The different types of `responseType` parsers that are supported in this XHR * implementation. * * @type {Object} * @public */ -Requests.type = 'XHR' === Requests.method ? (function detect() { +Requests.type = ('XHR' === Requests.method) ? (function detect() { var types = 'arraybuffer,blob,document,json,text,moz-blob,moz-chunked-text,moz-chunked-arraybuffer,ms-stream'.split(',') , supported = {} , type, xhr, prop; @@ -282,13 +371,16 @@ Requests.type = 'XHR' === Requests.method ? (function detect() { * @type {Boolean} * @private */ -Requests.streaming = 'XHR' === Requests.method && ( - 'multipart' in XMLHttpRequest.prototype - || Requests.type.mozchunkedarraybuffer - || Requests.type.mozchunkedtext - || Requests.type.msstream - || Requests.type.mozblob -); +Requests.streaming = (Requests.method === 'FETCH') || + ( + (Requests.method === 'XHR') && ( + 'multipart' in XMLHttpRequest.prototype || + Requests.type.mozchunkedarraybuffer || + Requests.type.mozchunkedtext || + Requests.type.msstream || + Requests.type.mozblob + ) + ); // // IE has a bug which causes IE10 to freeze when close WebPage during an XHR @@ -296,6 +388,7 @@ Requests.streaming = 'XHR' === Requests.method && ( // // The solution is to completely clean up all active running requests. // +// TODO global vs. root? do we need both? if (global.attachEvent) global.attachEvent('onunload', function reap() { for (var id in Requests.active) { Requests.active[id].destroy(); diff --git a/fetch-wrapper.js b/fetch-wrapper.js new file mode 100644 index 0000000..8071b1e --- /dev/null +++ b/fetch-wrapper.js @@ -0,0 +1,59 @@ +// From https://github.com/jonnyreeves/chunked-request/blob/4dd6b7568e79a920f6cab3cd4d91d1e8d30b0798/src/impl/fetch.js + +//var Requested = require('./requested'); +function FetchWrapper(requests) { + var fetchWrapper = this; + fetchWrapper.requests = requests; + + var headers = requests.headers; + var mode = requests.mode; + var body = requests.body; + var credentials = requests.credentials; + + var decoder = new TextDecoder(); + fetchWrapper.decoder = decoder; + + fetchWrapper.fetchOptions = { + headers: headers, + mode: mode, + body: body, + credentials: credentials + }; +} + +FetchWrapper.prototype.onError = function(err) { + var fetchWrapper = this; + console.error(err.message); + console.error(err.stack); + //fetchWrapper.requests.emit('error', err); +} + +FetchWrapper.prototype.pump = function(reader, res) { + var fetchWrapper = this; + return reader.read() + .then(function(result) { + if (result.done) { + fetchWrapper.requests.emit('end'); + // NOTE: when result.done = true, result.value will always be null + return; + } + fetchWrapper.requests.emit('stream', fetchWrapper.decoder.decode(result.value)); + return fetchWrapper.pump(reader, res); + }, function(err) { + fetchWrapper.requests.emit('error', err); + }); +} + +// TODO is the third arg supposed to be "streaming"? +FetchWrapper.prototype.open = function(method, url, streaming) { + var fetchWrapper = this; + fetch(url, fetchWrapper.fetchOptions) + .then(function(res) { + fetchWrapper.response = res; + fetchWrapper.requests.emit('send', fetchWrapper); + return fetchWrapper.pump(res.body.getReader(), res) + }) + .catch(fetchWrapper.onError); +} + +module.exports = FetchWrapper; diff --git a/package.json b/package.json index 9435a20..2f0d671 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "requests", "version": "0.1.7", - "description": "An streaming XHR abstraction that works in browsers and node.js", + "description": "A streaming XHR abstraction that works in browsers and node.js", "main": "index.js", "browser": "browser.js", "scripts": { @@ -43,11 +43,12 @@ "extendible": "0.1.x", "hang": "1.0.x", "loads": "0.0.x", + "same-origin": "^0.1.1", "xhr-send": "1.0.x" }, "devDependencies": { "argh": "0.1.x", - "assume": "1.3.x", + "assume": "1.4.x", "async-each": "0.1.x", "browserify": "11.x.x", "http-proxy": "1.11.x", diff --git a/requested.js b/requested.js index 7cf1b60..b4ea081 100644 --- a/requested.js +++ b/requested.js @@ -21,7 +21,10 @@ function Requested(url, options) { this.writable = false; if (this.initialize) this.initialize(url); - if (!this.manual && this.open) this.open(url); + // TODO AR changed what is passed in to this.open(). + // But what was happening did not match the + // documentation for this.open anyway. + if (!this.manual && this.open) this.open(url, options); } Requested.extend = require('extendible'); @@ -70,7 +73,7 @@ Requested.prototype.merge = function merge(target) { /** * The defaults for the Requests. These values will be used if no options object - * or matching key is provided. It can be override globally if needed but this + * or matching key is provided. It can be overriden globally if needed, but this * is not advised as it can have some potential side affects for other libraries * that use this module. * @@ -81,7 +84,8 @@ Requested.defaults = { streaming: false, manual: false, method: 'GET', - mode: 'cors', + // A request has an associated mode, which is "same-origin", "cors", "no-cors", "navigate", + // or "websocket". Unless stated otherwise, it is "no-cors". headers: { // // We're forcing text/plain mode by default to ensure that regular diff --git a/test/index.js b/test/index.js index fbe6854..994a5a3 100644 --- a/test/index.js +++ b/test/index.js @@ -102,6 +102,7 @@ kill.hooks = []; wd: argv.wd, ui: argv.ui }) + .on('error', console.error) .bundle(next); } ]);