diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..f2845f2 --- /dev/null +++ b/.babelrc @@ -0,0 +1,30 @@ +{ + "plugins": [ + "transform-runtime" + ], + "env": { + "test": { + "presets": [ + [ "es2015", { "loose": true } ] + ], + "plugins": [ + "transform-runtime", + "./build/babel-plugin" + ] + }, + "coverage": { + "presets": [ + [ "es2015", { "loose": true } ] + ], + "plugins": [ + [ "istanbul", { "exclude": [ "src/blob.js", "build", "test" ] } ], + "./build/babel-plugin" + ] + }, + "rollup": { + "presets": [ + [ "es2015", { "loose": true, "modules": false } ] + ] + } + } +} diff --git a/.gitignore b/.gitignore index b490573..f212ad9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,8 @@ pids # Directory for instrumented libs generated by jscoverage/JSCover lib-cov -# Coverage directory used by tools like istanbul +# Coverage directory used by tools like nyc and istanbul +.nyc_output coverage # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) @@ -29,3 +30,6 @@ node_modules # OS files .DS_Store + +# Babel-compiled files +lib diff --git a/.nycrc b/.nycrc new file mode 100644 index 0000000..d8d9c14 --- /dev/null +++ b/.nycrc @@ -0,0 +1,7 @@ +{ + "require": [ + "babel-register" + ], + "sourceMap": false, + "instrument": false +} diff --git a/.travis.yml b/.travis.yml index b6c2082..23a2e4a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,8 @@ language: node_js node_js: - - "0.10" - "0.12" + - "4" + - "6" - "node" env: - FORMDATA_VERSION=1.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 857fc8d..20659ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,33 @@ Changelog ========= +# 2.x release + +## v2.0.0-alpha.1 (UNRELEASED) + +- Major: Node.js 0.10.x support is dropped +- Major: rewrite in transpiled ES2015 +- Major: internal methods are no longer exposed +- Major: throw error when a GET/HEAD Request is constructed with a non-null body (per spec) +- Major: `response.text()` no longer attempts to detect encoding, instead always opting for UTF-8 (per spec); use `response.textConverted()` for the old behavior +- Major: make `response.json()` throw error instead of returning an empty object on 204 no-content respose (per spec; reverts behavior set in v1.6.2) +- Major: arrays as parameters to `headers.append` and `headers.set` are joined as a string (per spec) +- Enhance: start testing on Node.js 4, 6, 7 +- Enhance: use Rollup to produce a distributed bundle (less memory overhead and faster startup) +- Enhance: make `toString()` on Headers, Requests, and Responses return correct IDL class strings +- Enhance: add an option to conform to latest spec at the expense of reduced compatibility +- Enhance: set `Content-Length` header for Buffers as well +- Enhance: add `response.arrayBuffer()` (also applies to Requests) +- Enhance: add experimental `response.blob()` (also applies to Requests) +- Enhance: make Headers iterable +- Enhance: make Headers constructor accept an array of tuples +- Enhance: make sure header names and values are valid in HTTP +- Fix: coerce Headers prototype function parameters to strings, where applicable +- Fix: fix Request and Response with `null` body +- Fix: support WHATWG URL objects, created by `whatwg-url` package or `require('url').URL` in Node.js 7+ +- Other: use Codecov for code coverage tracking + + # 1.x release ## v1.6.3 diff --git a/LIMITS.md b/LIMITS.md index d0d41fc..f49fe28 100644 --- a/LIMITS.md +++ b/LIMITS.md @@ -2,7 +2,7 @@ Known differences ================= -*As of 1.x release* +*As of 2.x release* - Topics such as Cross-Origin, Content Security Policy, Mixed Content, Service Workers are ignored, given our server-side context. @@ -16,12 +16,10 @@ Known differences - Also, you can handle rejected fetch requests through checking `err.type` and `err.code`. -- Only support `res.text()`, `res.json()`, `res.buffer()` at the moment, until there are good use-cases for blob/arrayBuffer. +- Only support `res.text()`, `res.json()`, `res.blob()`, `res.arraybuffer()`, `res.buffer()` - There is currently no built-in caching, as server-side caching varies by use-cases. - Current implementation lacks server-side cookie store, you will need to extract `Set-Cookie` headers manually. - If you are using `res.clone()` and writing an isomorphic app, note that stream on Node.js have a smaller internal buffer size (16Kb, aka `highWaterMark`) from client-side browsers (>1Mb, not consistent across browsers). - -- ES6 features such as `headers.entries()` are missing at the moment, but you can use `headers.raw()` to retrieve the raw headers object. diff --git a/README.md b/README.md index ecb5b81..02cf186 100644 --- a/README.md +++ b/README.md @@ -41,44 +41,40 @@ See Matt Andrews' [isomorphic-fetch](https://github.com/matthew-andrews/isomorph # Usage ```javascript -var fetch = require('node-fetch'); +import fetch from 'node-fetch'; +// or +// const fetch = require('node-fetch'); -// if you are on node v0.10, set a Promise library first, eg. -// fetch.Promise = require('bluebird'); +// if you are using your own Promise library, set it through fetch.Promise. Eg. + +// import Bluebird from 'bluebird'; +// fetch.Promise = Bluebird; // plain text or html fetch('https://github.com/') - .then(function(res) { - return res.text(); - }).then(function(body) { - console.log(body); - }); + .then(res => res.text()) + .then(body => console.log(body)); // json fetch('https://api.github.com/users/github') - .then(function(res) { - return res.json(); - }).then(function(json) { - console.log(json); - }); + .then(res => res.json()) + .then(json => console.log(json)); // catching network error // 3xx-5xx responses are NOT network errors, and should be handled in then() // you only need one catch() at the end of your promise chain fetch('http://domain.invalid/') - .catch(function(err) { - console.log(err); - }); + .catch(err => console.error(err)); // stream // the node.js way is to use stream when possible fetch('https://assets-cdn.github.com/images/modules/logos_page/Octocat.png') - .then(function(res) { - var dest = fs.createWriteStream('./octocat.png'); + .then(res => { + const dest = fs.createWriteStream('./octocat.png'); res.body.pipe(dest); }); @@ -86,18 +82,17 @@ fetch('https://assets-cdn.github.com/images/modules/logos_page/Octocat.png') // if you prefer to cache binary data in full, use buffer() // note that buffer() is a node-fetch only API -var fileType = require('file-type'); +import fileType from 'file-type'; + fetch('https://assets-cdn.github.com/images/modules/logos_page/Octocat.png') - .then(function(res) { - return res.buffer(); - }).then(function(buffer) { - fileType(buffer); - }); + .then(res => res.buffer()) + .then(buffer => fileType(buffer)) + .then(type => { /* ... */ }); // meta fetch('https://github.com/') - .then(function(res) { + .then(res => { console.log(res.ok); console.log(res.status); console.log(res.statusText); @@ -108,22 +103,17 @@ fetch('https://github.com/') // post fetch('http://httpbin.org/post', { method: 'POST', body: 'a=1' }) - .then(function(res) { - return res.json(); - }).then(function(json) { - console.log(json); - }); + .then(res => res.json()) + .then(json => console.log(json)); -// post with stream from resumer +// post with stream from file -var resumer = require('resumer'); -var stream = resumer().queue('a=1').end(); +import { createReadStream } from 'fs'; + +const stream = createReadStream('input.txt'); fetch('http://httpbin.org/post', { method: 'POST', body: stream }) - .then(function(res) { - return res.json(); - }).then(function(json) { - console.log(json); - }); + .then(res => res.json()) + .then(json => console.log(json)); // post with JSON @@ -133,45 +123,37 @@ fetch('http://httpbin.org/post', { body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' }, }) - .then(function(res) { - return res.json(); - }).then(function(json) { - console.log(json); - }); + .then(res => res.json()) + .then(json => console.log(json)); // post with form-data (detect multipart) -var FormData = require('form-data'); -var form = new FormData(); +import FormData from 'form-data'; + +const form = new FormData(); form.append('a', 1); fetch('http://httpbin.org/post', { method: 'POST', body: form }) - .then(function(res) { - return res.json(); - }).then(function(json) { - console.log(json); - }); + .then(res => res.json()) + .then(json => console.log(json)); // post with form-data (custom headers) // note that getHeaders() is non-standard API -var FormData = require('form-data'); -var form = new FormData(); +import FormData from 'form-data'; + +const form = new FormData(); form.append('a', 1); fetch('http://httpbin.org/post', { method: 'POST', body: form, headers: form.getHeaders() }) - .then(function(res) { - return res.json(); - }).then(function(json) { - console.log(json); - }); + .then(res => res.json()) + .then(json => console.log(json)); -// node 0.12+, yield with co +// node 7+ with async function -var co = require('co'); -co(function *() { - var res = yield fetch('https://api.github.com/users/github'); - var json = yield res.json(); - console.log(res); -}); +(async function () { + const res = await fetch('https://api.github.com/users/github'); + const json = await res.json(); + console.log(json); +})(); ``` See [test cases](https://github.com/bitinn/node-fetch/blob/master/test/test.js) for more examples. diff --git a/UPGRADE-GUIDE.md b/UPGRADE-GUIDE.md new file mode 100644 index 0000000..592b2ed --- /dev/null +++ b/UPGRADE-GUIDE.md @@ -0,0 +1,95 @@ +# Upgrade to node-fetch v2 + +node-fetch v2 brings about many changes that increase the compliance of +WHATWG's [Fetch Standard][whatwg-fetch]. However, many of these changes meant +that apps written for node-fetch v1 needs to be updated to work with node-fetch +v2 and be conformant with the Fetch Standard. + +## `.text()` no longer tries to detect encoding + +In v1, `response.text()` attempts to guess the text encoding of the input +material and decode it for the user. However, it runs counter to the Fetch +Standard which demands `.text()` to always use UTF-8. + +In "response" to that, we have changed `.text()` to use UTF-8. A new function +**`response.textConverted()`** is created that maintains the behavior of +`.text()` last year. + +## Internal methods hidden + +In v1, the user can access internal methods such as `_clone()`, `_decode()`, +and `_convert()` on the `response` object. While these methods should never +have been used, node-fetch v2 makes these functions completely inaccessible. +If your app makes use of these functions, it may break when upgrading to v2. + +If you have a use case that requires these methods to be available, feel free +to file an issue and we will be happy to help you solve the problem. + +## Headers + +The main goal we have for the `Headers` class in v2 is to make it completely +spec-compliant. However, due to changes in the Fetch Standard itself, total +spec compliance would mean incompatibility with all current major browser +implementations. + +Therefore, in v2, only a limited set of changes was applied to preserve +compatibility with browsers by default. See [#181] for more information on why +a feature is enabled or disabled. + +```js +////////////////////////////////////////////////////////////////////////////// +// If you are using an object as the initializer, all values will be +// stringified. For arrays, the members will be joined with a comma. +const headers = new Headers({ + 'Abc': 'string', + 'Multi': [ 'header1', 'header2' ] +}); + +// before after +headers.get('Multi') => headers.get('Multi') => + 'header1'; 'header1,header2'; +headers.getAll('Multi') => headers.getAll('Multi') => + [ 'header1', 'header2' ]; [ 'header1,header2' ]; + +// Instead, to preserve the older behavior, you can use the header pair array +// syntax. +const headers = new Headers([ + [ 'Abc', 'string' ], + [ 'Multi', 'header1' ], + [ 'Multi', 'header2' ] +]); + + +////////////////////////////////////////////////////////////////////////////// +// All method parameters are now stringified. +const headers = new Headers(); +headers.set('null-header', null); +headers.set('undefined', undefined); + +// before after +headers.get('null-header') headers.get('null-header') + => null => 'null' +headers.get(undefined) headers.get(undefined) + => throws => 'undefined' + + +////////////////////////////////////////////////////////////////////////////// +// Invalid HTTP header names and values are now rejected outright. +const headers = new Headers(); +headers.set('Héy', 'ok'); // now throws +headers.get('Héy'); // now throws +new Headers({ 'Héy': 'ok' }); // now throws +``` + +## 0.10.x support dropped + +If you are still using Node.js v0.10, upgrade ASAP. Not only has Node.js +dropped support for that release branch, it has become too much work for us to +maintain. Therefore, we have dropped official support for v0.10. + +That being said, node-fetch may still work with v0.10, but as we are not +actively trying to support that version, it is in the user's best interest to +upgrade. + +[whatwg-fetch]: https://fetch.spec.whatwg.org/ +[#181]: https://github.com/bitinn/node-fetch/issues/181 diff --git a/build/babel-plugin.js b/build/babel-plugin.js new file mode 100644 index 0000000..08efac9 --- /dev/null +++ b/build/babel-plugin.js @@ -0,0 +1,48 @@ +// This Babel plugin makes it possible to do CommonJS-style function exports + +const walked = Symbol('walked'); + +module.exports = ({ types: t }) => ({ + visitor: { + Program: { + exit(program) { + if (program[walked]) { + return; + } + + for (let path of program.get('body')) { + if (path.isExpressionStatement()) { + const expr = path.get('expression'); + if (expr.isAssignmentExpression() && + expr.get('left').matchesPattern('exports.*')) { + const prop = expr.get('left').get('property'); + if (prop.isIdentifier({ name: 'default' })) { + program.unshiftContainer('body', [ + t.expressionStatement( + t.assignmentExpression('=', + t.identifier('exports'), + t.assignmentExpression('=', + t.memberExpression( + t.identifier('module'), t.identifier('exports') + ), + expr.node.right + ) + ) + ), + t.expressionStatement( + t.assignmentExpression('=', + expr.node.left, t.identifier('exports') + ) + ) + ]); + path.remove(); + } + } + } + } + + program[walked] = true; + } + } + } +}); diff --git a/build/rollup-plugin.js b/build/rollup-plugin.js new file mode 100644 index 0000000..411d677 --- /dev/null +++ b/build/rollup-plugin.js @@ -0,0 +1,16 @@ +export default function tweakDefault() { + return { + transformBundle: function (source) { + var lines = source.split('\n'); + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + var matches = /^exports\['default'] = (.*);$/.exec(line); + if (matches) { + lines[i] = 'module.exports = exports = ' + matches[1] + ';'; + break; + } + } + return lines.join('\n'); + } + }; +} diff --git a/index.js b/index.js deleted file mode 100644 index df89c80..0000000 --- a/index.js +++ /dev/null @@ -1,271 +0,0 @@ - -/** - * index.js - * - * a request API compatible with window.fetch - */ - -var parse_url = require('url').parse; -var resolve_url = require('url').resolve; -var http = require('http'); -var https = require('https'); -var zlib = require('zlib'); -var stream = require('stream'); - -var Body = require('./lib/body'); -var Response = require('./lib/response'); -var Headers = require('./lib/headers'); -var Request = require('./lib/request'); -var FetchError = require('./lib/fetch-error'); - -// commonjs -module.exports = Fetch; -// es6 default export compatibility -module.exports.default = module.exports; - -/** - * Fetch class - * - * @param Mixed url Absolute url or Request instance - * @param Object opts Fetch options - * @return Promise - */ -function Fetch(url, opts) { - - // allow call as function - if (!(this instanceof Fetch)) - return new Fetch(url, opts); - - // allow custom promise - if (!Fetch.Promise) { - throw new Error('native promise missing, set Fetch.Promise to your favorite alternative'); - } - - Body.Promise = Fetch.Promise; - - var self = this; - - // wrap http.request into fetch - return new Fetch.Promise(function(resolve, reject) { - // build request object - var options = new Request(url, opts); - - if (!options.protocol || !options.hostname) { - throw new Error('only absolute urls are supported'); - } - - if (options.protocol !== 'http:' && options.protocol !== 'https:') { - throw new Error('only http(s) protocols are supported'); - } - - var send; - if (options.protocol === 'https:') { - send = https.request; - } else { - send = http.request; - } - - // normalize headers - var headers = new Headers(options.headers); - - if (options.compress) { - headers.set('accept-encoding', 'gzip,deflate'); - } - - if (!headers.has('user-agent')) { - headers.set('user-agent', 'node-fetch/1.0 (+https://github.com/bitinn/node-fetch)'); - } - - if (!headers.has('connection') && !options.agent) { - headers.set('connection', 'close'); - } - - if (!headers.has('accept')) { - headers.set('accept', '*/*'); - } - - // detect form data input from form-data module, this hack avoid the need to pass multipart header manually - if (!headers.has('content-type') && options.body && typeof options.body.getBoundary === 'function') { - headers.set('content-type', 'multipart/form-data; boundary=' + options.body.getBoundary()); - } - - // bring node-fetch closer to browser behavior by setting content-length automatically - if (!headers.has('content-length') && /post|put|patch|delete/i.test(options.method)) { - if (typeof options.body === 'string') { - headers.set('content-length', Buffer.byteLength(options.body)); - // detect form data input from form-data module, this hack avoid the need to add content-length header manually - } else if (options.body && typeof options.body.getLengthSync === 'function') { - // for form-data 1.x - if (options.body._lengthRetrievers && options.body._lengthRetrievers.length == 0) { - headers.set('content-length', options.body.getLengthSync().toString()); - // for form-data 2.x - } else if (options.body.hasKnownLength && options.body.hasKnownLength()) { - headers.set('content-length', options.body.getLengthSync().toString()); - } - // this is only necessary for older nodejs releases (before iojs merge) - } else if (options.body === undefined || options.body === null) { - headers.set('content-length', '0'); - } - } - - options.headers = headers.raw(); - - // http.request only support string as host header, this hack make custom host header possible - if (options.headers.host) { - options.headers.host = options.headers.host[0]; - } - - // send request - var req = send(options); - var reqTimeout; - - if (options.timeout) { - req.once('socket', function(socket) { - reqTimeout = setTimeout(function() { - req.abort(); - reject(new FetchError('network timeout at: ' + options.url, 'request-timeout')); - }, options.timeout); - }); - } - - req.on('error', function(err) { - clearTimeout(reqTimeout); - reject(new FetchError('request to ' + options.url + ' failed, reason: ' + err.message, 'system', err)); - }); - - req.on('response', function(res) { - clearTimeout(reqTimeout); - - // handle redirect - if (self.isRedirect(res.statusCode) && options.redirect !== 'manual') { - if (options.redirect === 'error') { - reject(new FetchError('redirect mode is set to error: ' + options.url, 'no-redirect')); - return; - } - - if (options.counter >= options.follow) { - reject(new FetchError('maximum redirect reached at: ' + options.url, 'max-redirect')); - return; - } - - if (!res.headers.location) { - reject(new FetchError('redirect location header missing at: ' + options.url, 'invalid-redirect')); - return; - } - - // per fetch spec, for POST request with 301/302 response, or any request with 303 response, use GET when following redirect - if (res.statusCode === 303 - || ((res.statusCode === 301 || res.statusCode === 302) && options.method === 'POST')) - { - options.method = 'GET'; - delete options.body; - delete options.headers['content-length']; - } - - options.counter++; - - resolve(Fetch(resolve_url(options.url, res.headers.location), options)); - return; - } - - // normalize location header for manual redirect mode - var headers = new Headers(res.headers); - if (options.redirect === 'manual' && headers.has('location')) { - headers.set('location', resolve_url(options.url, headers.get('location'))); - } - - // prepare response - var body = res.pipe(new stream.PassThrough()); - var response_options = { - url: options.url - , status: res.statusCode - , statusText: res.statusMessage - , headers: headers - , size: options.size - , timeout: options.timeout - }; - - // response object - var output; - - // in following scenarios we ignore compression support - // 1. compression support is disabled - // 2. HEAD request - // 3. no content-encoding header - // 4. no content response (204) - // 5. content not modified response (304) - if (!options.compress || options.method === 'HEAD' || !headers.has('content-encoding') || res.statusCode === 204 || res.statusCode === 304) { - output = new Response(body, response_options); - resolve(output); - return; - } - - // otherwise, check for gzip or deflate - var name = headers.get('content-encoding'); - - // for gzip - if (name == 'gzip' || name == 'x-gzip') { - body = body.pipe(zlib.createGunzip()); - output = new Response(body, response_options); - resolve(output); - return; - - // for deflate - } else if (name == 'deflate' || name == 'x-deflate') { - // handle the infamous raw deflate response from old servers - // a hack for old IIS and Apache servers - var raw = res.pipe(new stream.PassThrough()); - raw.once('data', function(chunk) { - // see http://stackoverflow.com/questions/37519828 - if ((chunk[0] & 0x0F) === 0x08) { - body = body.pipe(zlib.createInflate()); - } else { - body = body.pipe(zlib.createInflateRaw()); - } - output = new Response(body, response_options); - resolve(output); - }); - return; - } - - // otherwise, use response as-is - output = new Response(body, response_options); - resolve(output); - return; - }); - - // accept string, buffer or readable stream as body - // per spec we will call tostring on non-stream objects - if (typeof options.body === 'string') { - req.write(options.body); - req.end(); - } else if (options.body instanceof Buffer) { - req.write(options.body); - req.end() - } else if (typeof options.body === 'object' && options.body.pipe) { - options.body.pipe(req); - } else if (typeof options.body === 'object') { - req.write(options.body.toString()); - req.end(); - } else { - req.end(); - } - }); - -}; - -/** - * Redirect code matching - * - * @param Number code Status code - * @return Boolean - */ -Fetch.prototype.isRedirect = function(code) { - return code === 301 || code === 302 || code === 303 || code === 307 || code === 308; -} - -// expose Promise -Fetch.Promise = global.Promise; -Fetch.Response = Response; -Fetch.Headers = Headers; -Fetch.Request = Request; diff --git a/lib/body.js b/lib/body.js deleted file mode 100644 index e7bbe1d..0000000 --- a/lib/body.js +++ /dev/null @@ -1,260 +0,0 @@ - -/** - * body.js - * - * Body interface provides common methods for Request and Response - */ - -var convert = require('encoding').convert; -var bodyStream = require('is-stream'); -var PassThrough = require('stream').PassThrough; -var FetchError = require('./fetch-error'); - -module.exports = Body; - -/** - * Body class - * - * @param Stream body Readable stream - * @param Object opts Response options - * @return Void - */ -function Body(body, opts) { - - opts = opts || {}; - - this.body = body; - this.bodyUsed = false; - this.size = opts.size || 0; - this.timeout = opts.timeout || 0; - this._raw = []; - this._abort = false; - -} - -/** - * Decode response as json - * - * @return Promise - */ -Body.prototype.json = function() { - - // for 204 No Content response, buffer will be empty, parsing it will throw error - if (this.status === 204) { - return Body.Promise.resolve({}); - } - - return this._decode().then(function(buffer) { - return JSON.parse(buffer.toString()); - }); - -}; - -/** - * Decode response as text - * - * @return Promise - */ -Body.prototype.text = function() { - - return this._decode().then(function(buffer) { - return buffer.toString(); - }); - -}; - -/** - * Decode response as buffer (non-spec api) - * - * @return Promise - */ -Body.prototype.buffer = function() { - - return this._decode(); - -}; - -/** - * Decode buffers into utf-8 string - * - * @return Promise - */ -Body.prototype._decode = function() { - - var self = this; - - if (this.bodyUsed) { - return Body.Promise.reject(new Error('body used already for: ' + this.url)); - } - - this.bodyUsed = true; - this._bytes = 0; - this._abort = false; - this._raw = []; - - return new Body.Promise(function(resolve, reject) { - var resTimeout; - - // body is string - if (typeof self.body === 'string') { - self._bytes = self.body.length; - self._raw = [new Buffer(self.body)]; - return resolve(self._convert()); - } - - // body is buffer - if (self.body instanceof Buffer) { - self._bytes = self.body.length; - self._raw = [self.body]; - return resolve(self._convert()); - } - - // allow timeout on slow response body - if (self.timeout) { - resTimeout = setTimeout(function() { - self._abort = true; - reject(new FetchError('response timeout at ' + self.url + ' over limit: ' + self.timeout, 'body-timeout')); - }, self.timeout); - } - - // handle stream error, such as incorrect content-encoding - self.body.on('error', function(err) { - reject(new FetchError('invalid response body at: ' + self.url + ' reason: ' + err.message, 'system', err)); - }); - - // body is stream - self.body.on('data', function(chunk) { - if (self._abort || chunk === null) { - return; - } - - if (self.size && self._bytes + chunk.length > self.size) { - self._abort = true; - reject(new FetchError('content size at ' + self.url + ' over limit: ' + self.size, 'max-size')); - return; - } - - self._bytes += chunk.length; - self._raw.push(chunk); - }); - - self.body.on('end', function() { - if (self._abort) { - return; - } - - clearTimeout(resTimeout); - resolve(self._convert()); - }); - }); - -}; - -/** - * Detect buffer encoding and convert to target encoding - * ref: http://www.w3.org/TR/2011/WD-html5-20110113/parsing.html#determining-the-character-encoding - * - * @param String encoding Target encoding - * @return String - */ -Body.prototype._convert = function(encoding) { - - encoding = encoding || 'utf-8'; - - var ct = this.headers.get('content-type'); - var charset = 'utf-8'; - var res, str; - - // header - if (ct) { - // skip encoding detection altogether if not html/xml/plain text - if (!/text\/html|text\/plain|\+xml|\/xml/i.test(ct)) { - return Buffer.concat(this._raw); - } - - res = /charset=([^;]*)/i.exec(ct); - } - - // no charset in content type, peek at response body for at most 1024 bytes - if (!res && this._raw.length > 0) { - for (var i = 0; i < this._raw.length; i++) { - str += this._raw[i].toString() - if (str.length > 1024) { - break; - } - } - str = str.substr(0, 1024); - } - - // html5 - if (!res && str) { - res = /= 200 && this.status < 300; - - Body.call(this, body, opts); - -} - -Response.prototype = Object.create(Body.prototype); - -/** - * Clone this response - * - * @return Response - */ -Response.prototype.clone = function() { - return new Response(this._clone(this), { - url: this.url - , status: this.status - , statusText: this.statusText - , headers: this.headers - , ok: this.ok - }); -}; diff --git a/package.json b/package.json index 4ff2149..84a8909 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,18 @@ "name": "node-fetch", "version": "1.6.3", "description": "A light-weight module that brings window.fetch to node.js and io.js", - "main": "index.js", + "main": "lib/index.js", + "jsnext:main": "lib/index.es.js", + "files": [ + "lib/index.js", + "lib/index.es.js" + ], "scripts": { - "test": "mocha test/test.js", - "report": "istanbul cover _mocha -- -R spec test/test.js", - "coverage": "istanbul cover _mocha --report lcovonly -- -R spec test/test.js && codecov -f coverage/coverage.json" + "build": "cross-env BABEL_ENV=rollup rollup -c", + "prepublish": "npm run build", + "test": "cross-env BABEL_ENV=test mocha --compilers js:babel-register test/test.js", + "report": "cross-env BABEL_ENV=coverage nyc --reporter lcov --reporter text mocha -R spec test/test.js", + "coverage": "cross-env BABEL_ENV=coverage nyc --reporter json --reporter text mocha -R spec test/test.js && codecov -f coverage/coverage-final.json" }, "repository": { "type": "git", @@ -24,18 +31,32 @@ }, "homepage": "https://github.com/bitinn/node-fetch", "devDependencies": { + "babel-plugin-istanbul": "^3.0.0", + "babel-plugin-transform-runtime": "^6.15.0", + "babel-preset-es2015": "^6.16.0", + "babel-register": "^6.16.3", "bluebird": "^3.3.4", "chai": "^3.5.0", - "chai-as-promised": "^5.2.0", + "chai-as-promised": "^6.0.0", + "chai-iterator": "^1.1.1", + "chai-string": "^1.3.0", "codecov": "^1.0.1", + "cross-env": "2.0.1", "form-data": ">=1.0.0", - "istanbul": "^0.4.2", - "mocha": "^2.1.0", + "is-builtin-module": "^1.0.0", + "mocha": "^3.1.2", + "nyc": "^10.0.0", "parted": "^0.1.1", "promise": "^7.1.1", - "resumer": "0.0.0" + "resumer": "0.0.0", + "rollup": "^0.37.0", + "rollup-plugin-babel": "^2.6.1", + "rollup-plugin-node-resolve": "^2.0.0", + "whatwg-url": "^4.0.0" }, "dependencies": { + "babel-runtime": "^6.11.6", + "buffer-to-arraybuffer": "0.0.4", "encoding": "^0.1.11", "is-stream": "^1.0.1" } diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..3e3ed20 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,27 @@ +import isBuiltin from 'is-builtin-module'; +import babel from 'rollup-plugin-babel'; +import resolve from 'rollup-plugin-node-resolve'; +import tweakDefault from './build/rollup-plugin'; + +process.env.BABEL_ENV = 'rollup'; + +export default { + entry: 'src/index.js', + plugins: [ + babel({ + runtimeHelpers: true + }), + tweakDefault() + ], + targets: [ + { dest: 'lib/index.js', format: 'cjs' }, + { dest: 'lib/index.es.js', format: 'es' } + ], + external: function (id) { + if (isBuiltin(id)) { + return true; + } + id = id.split('/').slice(0, id[0] === '@' ? 2 : 1).join('/'); + return !!require('./package.json').dependencies[id]; + } +}; diff --git a/src/blob.js b/src/blob.js new file mode 100644 index 0000000..89e6ef2 --- /dev/null +++ b/src/blob.js @@ -0,0 +1,104 @@ +// Based on https://github.com/tmpvar/jsdom/blob/aa85b2abf07766ff7bf5c1f6daafb3726f2f2db5/lib/jsdom/living/blob.js +// (MIT licensed) + +export const BUFFER = Symbol('buffer'); +const TYPE = Symbol('type'); +const CLOSED = Symbol('closed'); + +export default class Blob { + constructor() { + Object.defineProperty(this, Symbol.toStringTag, { + value: 'Blob', + writable: false, + enumerable: false, + configurable: true + }); + + this[CLOSED] = false; + this[TYPE] = ''; + + const blobParts = arguments[0]; + const options = arguments[1]; + + const buffers = []; + + if (blobParts) { + const a = blobParts; + const length = Number(a.length); + for (let i = 0; i < length; i++) { + const element = a[i]; + let buffer; + if (element instanceof Buffer) { + buffer = element; + } else if (ArrayBuffer.isView(element)) { + buffer = new Buffer(new Uint8Array(element.buffer, element.byteOffset, element.byteLength)); + } else if (element instanceof ArrayBuffer) { + buffer = new Buffer(new Uint8Array(element)); + } else if (element instanceof Blob) { + buffer = element[BUFFER]; + } else { + buffer = new Buffer(typeof element === 'string' ? element : String(element)); + } + buffers.push(buffer); + } + } + + this[BUFFER] = Buffer.concat(buffers); + + let type = options && options.type !== undefined && String(options.type).toLowerCase(); + if (type && !/[^\u0020-\u007E]/.test(type)) { + this[TYPE] = type; + } + } + get size() { + return this[CLOSED] ? 0 : this[BUFFER].length; + } + get type() { + return this[TYPE]; + } + get isClosed() { + return this[CLOSED]; + } + slice() { + const size = this.size; + + const start = arguments[0]; + const end = arguments[1]; + let relativeStart, relativeEnd; + if (start === undefined) { + relativeStart = 0; + } else if (start < 0) { + relativeStart = Math.max(size + start, 0); + } else { + relativeStart = Math.min(start, size); + } + if (end === undefined) { + relativeEnd = size; + } else if (end < 0) { + relativeEnd = Math.max(size + end, 0); + } else { + relativeEnd = Math.min(end, size); + } + const span = Math.max(relativeEnd - relativeStart, 0); + + const buffer = this[BUFFER]; + const slicedBuffer = buffer.slice( + relativeStart, + relativeStart + span + ); + const blob = new Blob([], { type: arguments[2] }); + blob[BUFFER] = slicedBuffer; + blob[CLOSED] = this[CLOSED]; + return blob; + } + close() { + this[CLOSED] = true; + } +} + +Object.defineProperty(Blob.prototype, Symbol.toStringTag, { + value: 'BlobPrototype', + writable: false, + enumerable: false, + configurable: true +}); diff --git a/src/body.js b/src/body.js new file mode 100644 index 0000000..c017b69 --- /dev/null +++ b/src/body.js @@ -0,0 +1,387 @@ + +/** + * body.js + * + * Body interface provides common methods for Request and Response + */ + +import {convert} from 'encoding'; +import bodyStream from 'is-stream'; +import toArrayBuffer from 'buffer-to-arraybuffer'; +import {PassThrough} from 'stream'; +import Blob, {BUFFER} from './blob.js'; +import FetchError from './fetch-error.js'; + +const DISTURBED = Symbol('disturbed'); +const CONSUME_BODY = Symbol('consumeBody'); + +/** + * Body class + * + * @param Stream body Readable stream + * @param Object opts Response options + * @return Void + */ +export default class Body { + constructor(body, { + size = 0, + timeout = 0 + } = {}) { + if (body == null) { + // body is undefined or null + body = null; + } else if (typeof body === 'string') { + // body is string + } else if (body instanceof Blob) { + // body is blob + } else if (Buffer.isBuffer(body)) { + // body is buffer + } else if (bodyStream(body)) { + // body is stream + } else { + // none of the above + // coerce to string + body = String(body); + } + this.body = body; + this[DISTURBED] = false; + this.size = size; + this.timeout = timeout; + } + + get bodyUsed() { + return this[DISTURBED]; + } + + /** + * Decode response as ArrayBuffer + * + * @return Promise + */ + arrayBuffer() { + return this[CONSUME_BODY]().then(buf => toArrayBuffer(buf)); + } + + /** + * Return raw response as Blob + * + * @return Promise + */ + blob() { + let ct = this.headers && this.headers.get('content-type') || ''; + return this[CONSUME_BODY]().then(buf => Object.assign( + // Prevent copying + new Blob([], { + type: ct.toLowerCase() + }), + { + [BUFFER]: buf + } + )); + } + + /** + * Decode response as json + * + * @return Promise + */ + json() { + return this[CONSUME_BODY]().then(buffer => JSON.parse(buffer.toString())); + } + + /** + * Decode response as text + * + * @return Promise + */ + text() { + return this[CONSUME_BODY]().then(buffer => buffer.toString()); + } + + /** + * Decode response as buffer (non-spec api) + * + * @return Promise + */ + buffer() { + return this[CONSUME_BODY](); + } + + /** + * Decode response as text, while automatically detecting the encoding and + * trying to decode to UTF-8 (non-spec api) + * + * @return Promise + */ + textConverted() { + return this[CONSUME_BODY]().then(buffer => convertBody(buffer, this.headers)); + } + + /** + * Decode buffers into utf-8 string + * + * @return Promise + */ + [CONSUME_BODY]() { + if (this[DISTURBED]) { + return Body.Promise.reject(new Error(`body used already for: ${this.url}`)); + } + + this[DISTURBED] = true; + + // body is null + if (this.body === null) { + return Body.Promise.resolve(new Buffer(0)); + } + + // body is string + if (typeof this.body === 'string') { + return Body.Promise.resolve(new Buffer(this.body)); + } + + // body is blob + if (this.body instanceof Blob) { + return Body.Promise.resolve(this.body[BUFFER]); + } + + // body is buffer + if (Buffer.isBuffer(this.body)) { + return Body.Promise.resolve(this.body); + } + + // istanbul ignore if: should never happen + if (!bodyStream(this.body)) { + return Body.Promise.resolve(new Buffer(0)); + } + + // body is stream + // get ready to actually consume the body + let accum = []; + let accumBytes = 0; + let abort = false; + + return new Body.Promise((resolve, reject) => { + let resTimeout; + + // allow timeout on slow response body + if (this.timeout) { + resTimeout = setTimeout(() => { + abort = true; + reject(new FetchError(`Response timeout while trying to fetch ${this.url} (over ${this.timeout}ms)`, 'body-timeout')); + }, this.timeout); + } + + // handle stream error, such as incorrect content-encoding + this.body.on('error', err => { + reject(new FetchError(`Invalid response body while trying to fetch ${this.url}: ${err.message}`, 'system', err)); + }); + + this.body.on('data', chunk => { + if (abort || chunk === null) { + return; + } + + if (this.size && accumBytes + chunk.length > this.size) { + abort = true; + reject(new FetchError(`content size at ${this.url} over limit: ${this.size}`, 'max-size')); + return; + } + + accumBytes += chunk.length; + accum.push(chunk); + }); + + this.body.on('end', () => { + if (abort) { + return; + } + + clearTimeout(resTimeout); + resolve(Buffer.concat(accum)); + }); + }); + } + +} + +/** + * Detect buffer encoding and convert to target encoding + * ref: http://www.w3.org/TR/2011/WD-html5-20110113/parsing.html#determining-the-character-encoding + * + * @param Buffer buffer Incoming buffer + * @param String encoding Target encoding + * @return String + */ +function convertBody(buffer, headers) { + const ct = headers.get('content-type'); + let charset = 'utf-8'; + let res, str; + + // header + if (ct) { + res = /charset=([^;]*)/i.exec(ct); + } + + // no charset in content type, peek at response body for at most 1024 bytes + str = buffer.slice(0, 1024).toString(); + + // html5 + if (!res && str) { + res = /= 94 && ch <= 122) + return true; + if (ch >= 65 && ch <= 90) + return true; + if (ch === 45) + return true; + if (ch >= 48 && ch <= 57) + return true; + if (ch === 34 || ch === 40 || ch === 41 || ch === 44) + return false; + if (ch >= 33 && ch <= 46) + return true; + if (ch === 124 || ch === 126) + return true; + return false; +} +/* istanbul ignore next */ +function checkIsHttpToken(val) { + if (typeof val !== 'string' || val.length === 0) + return false; + if (!isValidTokenChar(val.charCodeAt(0))) + return false; + const len = val.length; + if (len > 1) { + if (!isValidTokenChar(val.charCodeAt(1))) + return false; + if (len > 2) { + if (!isValidTokenChar(val.charCodeAt(2))) + return false; + if (len > 3) { + if (!isValidTokenChar(val.charCodeAt(3))) + return false; + for (var i = 4; i < len; i++) { + if (!isValidTokenChar(val.charCodeAt(i))) + return false; + } + } + } + } + return true; +} +export { checkIsHttpToken }; + +/** + * True if val contains an invalid field-vchar + * field-value = *( field-content / obs-fold ) + * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] + * field-vchar = VCHAR / obs-text + * + * checkInvalidHeaderChar() is currently designed to be inlinable by v8, + * so take care when making changes to the implementation so that the source + * code size does not exceed v8's default max_inlined_source_size setting. + **/ +/* istanbul ignore next */ +function checkInvalidHeaderChar(val) { + val += ''; + if (val.length < 1) + return false; + var c = val.charCodeAt(0); + if ((c <= 31 && c !== 9) || c > 255 || c === 127) + return true; + if (val.length < 2) + return false; + c = val.charCodeAt(1); + if ((c <= 31 && c !== 9) || c > 255 || c === 127) + return true; + if (val.length < 3) + return false; + c = val.charCodeAt(2); + if ((c <= 31 && c !== 9) || c > 255 || c === 127) + return true; + for (var i = 3; i < val.length; ++i) { + c = val.charCodeAt(i); + if ((c <= 31 && c !== 9) || c > 255 || c === 127) + return true; + } + return false; +} +export { checkInvalidHeaderChar }; diff --git a/lib/fetch-error.js b/src/fetch-error.js similarity index 72% rename from lib/fetch-error.js rename to src/fetch-error.js index 7cabfb3..4d4b932 100644 --- a/lib/fetch-error.js +++ b/src/fetch-error.js @@ -5,8 +5,6 @@ * FetchError interface for operational errors */ -module.exports = FetchError; - /** * Create FetchError instance * @@ -15,12 +13,9 @@ module.exports = FetchError; * @param String systemError For Node.js system error * @return FetchError */ -function FetchError(message, type, systemError) { +export default function FetchError(message, type, systemError) { + Error.call(this, message); - // hide custom error implementation details from end-users - Error.captureStackTrace(this, this.constructor); - - this.name = this.constructor.name; this.message = message; this.type = type; @@ -29,6 +24,10 @@ function FetchError(message, type, systemError) { this.code = this.errno = systemError.code; } + // hide custom error implementation details from end-users + Error.captureStackTrace(this, this.constructor); } -require('util').inherits(FetchError, Error); +FetchError.prototype = Object.create(Error.prototype); +FetchError.prototype.constructor = FetchError; +FetchError.prototype.name = 'FetchError'; diff --git a/src/headers.js b/src/headers.js new file mode 100644 index 0000000..020ca70 --- /dev/null +++ b/src/headers.js @@ -0,0 +1,309 @@ + +/** + * headers.js + * + * Headers class offers convenient helpers + */ + +import { checkIsHttpToken, checkInvalidHeaderChar } from './common.js'; + +function sanitizeName(name) { + name += ''; + if (!checkIsHttpToken(name)) { + throw new TypeError(`${name} is not a legal HTTP header name`); + } + return name.toLowerCase(); +} + +function sanitizeValue(value) { + value += ''; + if (checkInvalidHeaderChar(value)) { + throw new TypeError(`${value} is not a legal HTTP header value`); + } + return value; +} + +export const MAP = Symbol('map'); +const FOLLOW_SPEC = Symbol('followSpec'); +export default class Headers { + /** + * Headers class + * + * @param Object headers Response headers + * @return Void + */ + constructor(headers) { + this[MAP] = Object.create(null); + this[FOLLOW_SPEC] = Headers.FOLLOW_SPEC; + + // Headers + if (headers instanceof Headers) { + let init = headers.raw(); + for (let name of Object.keys(init)) { + for (let value of init[name]) { + this.append(name, value); + } + } + } else if (typeof headers === 'object' && headers[Symbol.iterator]) { + // array of tuples + for (let el of headers) { + if (typeof el !== 'object' || !el[Symbol.iterator]) { + throw new TypeError('Header pairs must be an iterable object'); + } + el = Array.from(el); + if (el.length !== 2) { + throw new TypeError('Header pairs must contain exactly two items'); + } + this.append(el[0], el[1]); + } + } else if (typeof headers === 'object') { + // plain object + for (const prop of Object.keys(headers)) { + // We don't worry about converting prop to ByteString here as append() + // will handle it. + this.append(prop, headers[prop]); + } + } else if (headers != null) { + throw new TypeError('Provided initializer must be an object'); + } + + Object.defineProperty(this, Symbol.toStringTag, { + value: 'Headers', + writable: false, + enumerable: false, + configurable: true + }); + } + + /** + * Return first header value given name + * + * @param String name Header name + * @return Mixed + */ + get(name) { + const list = this[MAP][sanitizeName(name)]; + if (!list) { + return null; + } + + return this[FOLLOW_SPEC] ? list.join(',') : list[0]; + } + + /** + * Return all header values given name + * + * @param String name Header name + * @return Array + */ + getAll(name) { + if (!this.has(name)) { + return []; + } + + return this[MAP][sanitizeName(name)]; + } + + /** + * Iterate over all headers + * + * @param Function callback Executed for each item with parameters (value, name, thisArg) + * @param Boolean thisArg `this` context for callback function + * @return Void + */ + forEach(callback, thisArg = undefined) { + let pairs = getHeaderPairs(this); + let i = 0; + while (i < pairs.length) { + const [name, value] = pairs[i]; + callback.call(thisArg, value, name, this); + pairs = getHeaderPairs(this); + i++; + } + } + + /** + * Overwrite header values given name + * + * @param String name Header name + * @param String value Header value + * @return Void + */ + set(name, value) { + this[MAP][sanitizeName(name)] = [sanitizeValue(value)]; + } + + /** + * Append a value onto existing header + * + * @param String name Header name + * @param String value Header value + * @return Void + */ + append(name, value) { + if (!this.has(name)) { + this.set(name, value); + return; + } + + this[MAP][sanitizeName(name)].push(sanitizeValue(value)); + } + + /** + * Check for header name existence + * + * @param String name Header name + * @return Boolean + */ + has(name) { + return !!this[MAP][sanitizeName(name)]; + } + + /** + * Delete all header values given name + * + * @param String name Header name + * @return Void + */ + delete(name) { + delete this[MAP][sanitizeName(name)]; + }; + + /** + * Return raw headers (non-spec api) + * + * @return Object + */ + raw() { + return this[MAP]; + } + + /** + * Get an iterator on keys. + * + * @return Iterator + */ + keys() { + return createHeadersIterator(this, 'key'); + } + + /** + * Get an iterator on values. + * + * @return Iterator + */ + values() { + return createHeadersIterator(this, 'value'); + } + + /** + * Get an iterator on entries. + * + * This is the default iterator of the Headers object. + * + * @return Iterator + */ + [Symbol.iterator]() { + return createHeadersIterator(this, 'key+value'); + } +} +Headers.prototype.entries = Headers.prototype[Symbol.iterator]; + +Object.defineProperty(Headers.prototype, Symbol.toStringTag, { + value: 'HeadersPrototype', + writable: false, + enumerable: false, + configurable: true +}); + +function getHeaderPairs(headers, kind) { + if (headers[FOLLOW_SPEC]) { + const keys = Object.keys(headers[MAP]).sort(); + return keys.map( + kind === 'key' ? + k => [k] : + k => [k, headers.get(k)] + ); + } + + const values = []; + + for (let name in headers[MAP]) { + for (let value of headers[MAP][name]) { + values.push([name, value]); + } + } + + return values; +} + +const INTERNAL = Symbol('internal'); + +function createHeadersIterator(target, kind) { + const iterator = Object.create(HeadersIteratorPrototype); + iterator[INTERNAL] = { + target, + kind, + index: 0 + }; + return iterator; +} + +const HeadersIteratorPrototype = Object.setPrototypeOf({ + next() { + // istanbul ignore if + if (!this || + Object.getPrototypeOf(this) !== HeadersIteratorPrototype) { + throw new TypeError('Value of `this` is not a HeadersIterator'); + } + + const { + target, + kind, + index + } = this[INTERNAL]; + const values = getHeaderPairs(target, kind); + const len = values.length; + if (index >= len) { + return { + value: undefined, + done: true + }; + } + + const pair = values[index]; + this[INTERNAL].index = index + 1; + + let result; + if (kind === 'key') { + result = pair[0]; + } else if (kind === 'value') { + result = pair[1]; + } else { + result = pair; + } + + return { + value: result, + done: false + }; + } +}, Object.getPrototypeOf( + Object.getPrototypeOf([][Symbol.iterator]()) +)); + +// On Node.js v0.12 the %IteratorPrototype% object is broken +if (typeof HeadersIteratorPrototype[Symbol.iterator] !== 'function') { + HeadersIteratorPrototype[Symbol.iterator] = function () { + return this; + }; +} + +Object.defineProperty(HeadersIteratorPrototype, Symbol.toStringTag, { + value: 'HeadersIterator', + writable: false, + enumerable: false, + configurable: true +}); + +Headers.FOLLOW_SPEC = false; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..ef668b8 --- /dev/null +++ b/src/index.js @@ -0,0 +1,215 @@ + +/** + * index.js + * + * a request API compatible with window.fetch + */ + +import {resolve as resolve_url} from 'url'; +import * as http from 'http'; +import * as https from 'https'; +import * as zlib from 'zlib'; +import {PassThrough} from 'stream'; + +import Body, { writeToStream } from './body'; +import Response from './response'; +import Headers from './headers'; +import Request, { getNodeRequestOptions } from './request'; +import FetchError from './fetch-error'; + +/** + * Fetch function + * + * @param Mixed url Absolute url or Request instance + * @param Object opts Fetch options + * @return Promise + */ +export default function fetch(url, opts) { + + // allow custom promise + if (!fetch.Promise) { + throw new Error('native promise missing, set fetch.Promise to your favorite alternative'); + } + + Body.Promise = fetch.Promise; + Headers.FOLLOW_SPEC = fetch.FOLLOW_SPEC; + + // wrap http.request into fetch + return new fetch.Promise((resolve, reject) => { + // build request object + const request = new Request(url, opts); + + const options = getNodeRequestOptions(request); + + if (!options.protocol || !options.hostname) { + throw new Error('only absolute urls are supported'); + } + + if (options.protocol !== 'http:' && options.protocol !== 'https:') { + throw new Error('only http(s) protocols are supported'); + } + + const send = (options.protocol === 'https:' ? https : http).request; + + // http.request only support string as host header, this hack make custom host header possible + if (options.headers.host) { + options.headers.host = options.headers.host[0]; + } + + // send request + const req = send(options); + let reqTimeout; + + if (request.timeout) { + req.once('socket', socket => { + reqTimeout = setTimeout(() => { + req.abort(); + reject(new FetchError(`network timeout at: ${request.url}`, 'request-timeout')); + }, request.timeout); + }); + } + + req.on('error', err => { + clearTimeout(reqTimeout); + reject(new FetchError(`request to ${request.url} failed, reason: ${err.message}`, 'system', err)); + }); + + req.on('response', res => { + clearTimeout(reqTimeout); + + // handle redirect + if (fetch.isRedirect(res.statusCode) && request.redirect !== 'manual') { + if (request.redirect === 'error') { + reject(new FetchError(`redirect mode is set to error: ${request.url}`, 'no-redirect')); + return; + } + + if (request.counter >= request.follow) { + reject(new FetchError(`maximum redirect reached at: ${request.url}`, 'max-redirect')); + return; + } + + if (!res.headers.location) { + reject(new FetchError(`redirect location header missing at: ${request.url}`, 'invalid-redirect')); + return; + } + + // per fetch spec, for POST request with 301/302 response, or any request with 303 response, use GET when following redirect + if (res.statusCode === 303 + || ((res.statusCode === 301 || res.statusCode === 302) && request.method === 'POST')) + { + request.method = 'GET'; + request.body = null; + request.headers.delete('content-length'); + } + + request.counter++; + + resolve(fetch(resolve_url(request.url, res.headers.location), request)); + return; + } + + // normalize location header for manual redirect mode + const headers = new Headers(); + for (const name of Object.keys(res.headers)) { + if (Array.isArray(res.headers[name])) { + for (const val of res.headers[name]) { + headers.append(name, val); + } + } else { + headers.append(name, res.headers[name]); + } + } + if (request.redirect === 'manual' && headers.has('location')) { + headers.set('location', resolve_url(request.url, headers.get('location'))); + } + + // prepare response + let body = res.pipe(new PassThrough()); + const response_options = { + url: request.url + , status: res.statusCode + , statusText: res.statusMessage + , headers: headers + , size: request.size + , timeout: request.timeout + }; + + // response object + let output; + + // in following scenarios we ignore compression support + // 1. compression support is disabled + // 2. HEAD request + // 3. no content-encoding header + // 4. no content response (204) + // 5. content not modified response (304) + if (!request.compress || request.method === 'HEAD' || !headers.has('content-encoding') || res.statusCode === 204 || res.statusCode === 304) { + output = new Response(body, response_options); + resolve(output); + return; + } + + // otherwise, check for gzip or deflate + let name = headers.get('content-encoding'); + + // for gzip + if (name == 'gzip' || name == 'x-gzip') { + body = body.pipe(zlib.createGunzip()); + output = new Response(body, response_options); + resolve(output); + return; + + // for deflate + } else if (name == 'deflate' || name == 'x-deflate') { + // handle the infamous raw deflate response from old servers + // a hack for old IIS and Apache servers + const raw = res.pipe(new PassThrough()); + raw.once('data', chunk => { + // see http://stackoverflow.com/questions/37519828 + if ((chunk[0] & 0x0F) === 0x08) { + body = body.pipe(zlib.createInflate()); + } else { + body = body.pipe(zlib.createInflateRaw()); + } + output = new Response(body, response_options); + resolve(output); + }); + return; + } + + // otherwise, use response as-is + output = new Response(body, response_options); + resolve(output); + return; + }); + + writeToStream(req, request); + }); + +}; + +/** + * Redirect code matching + * + * @param Number code Status code + * @return Boolean + */ +fetch.isRedirect = code => code === 301 || code === 302 || code === 303 || code === 307 || code === 308; + +// expose Promise +fetch.Promise = global.Promise; +/** + * Option to make newly constructed Headers objects conformant to the + * **latest** version of the Fetch Standard. Note, that most other + * implementations of fetch() have not yet been updated to the latest + * version, so enabling this option almost certainly breaks any isomorphic + * attempt. Also, changing this variable will only affect new Headers + * objects; existing objects are not affected. + */ +fetch.FOLLOW_SPEC = false; +export { + Headers, + Request, + Response +}; diff --git a/src/request.js b/src/request.js new file mode 100644 index 0000000..50d7117 --- /dev/null +++ b/src/request.js @@ -0,0 +1,147 @@ + +/** + * request.js + * + * Request class contains server only options + */ + +import { format as format_url, parse as parse_url } from 'url'; +import Headers from './headers.js'; +import Body, { clone, extractContentType, getTotalBytes } from './body'; + +const PARSED_URL = Symbol('url'); + +/** + * Request class + * + * @param Mixed input Url or Request instance + * @param Object init Custom options + * @return Void + */ +export default class Request extends Body { + constructor(input, init = {}) { + let parsedURL; + + // normalize input + if (!(input instanceof Request)) { + if (input && input.href) { + // in order to support Node.js' Url objects; though WHATWG's URL objects + // will fall into this branch also (since their `toString()` will return + // `href` property anyway) + parsedURL = parse_url(input.href); + } else { + // coerce input to a string before attempting to parse + parsedURL = parse_url(`${input}`); + } + input = {}; + } else { + parsedURL = parse_url(input.url); + } + + let method = init.method || input.method || 'GET'; + + if ((init.body != null || input instanceof Request && input.body !== null) && + (method === 'GET' || method === 'HEAD')) { + throw new TypeError('Request with GET/HEAD method cannot have body'); + } + + let inputBody = init.body != null ? + init.body : + input instanceof Request && input.body !== null ? + clone(input) : + null; + + super(inputBody, { + timeout: init.timeout || input.timeout || 0, + size: init.size || input.size || 0 + }); + + // fetch spec options + this.method = method; + this.redirect = init.redirect || input.redirect || 'follow'; + this.headers = new Headers(init.headers || input.headers || {}); + + if (init.body != null) { + const contentType = extractContentType(this); + if (contentType !== null && !this.headers.has('Content-Type')) { + this.headers.append('Content-Type', contentType); + } + } + + // server only options + this.follow = init.follow !== undefined ? + init.follow : input.follow !== undefined ? + input.follow : 20; + this.compress = init.compress !== undefined ? + init.compress : input.compress !== undefined ? + input.compress : true; + this.counter = init.counter || input.counter || 0; + this.agent = init.agent || input.agent; + + this[PARSED_URL] = parsedURL; + + Object.defineProperty(this, Symbol.toStringTag, { + value: 'Request', + writable: false, + enumerable: false, + configurable: true + }); + } + + get url() { + return format_url(this[PARSED_URL]); + } + + /** + * Clone this request + * + * @return Request + */ + clone() { + return new Request(this); + } +} + +Object.defineProperty(Request.prototype, Symbol.toStringTag, { + value: 'RequestPrototype', + writable: false, + enumerable: false, + configurable: true +}); + +function normalizeHeaders(request) { + const headers = new Headers(request.headers); + + if (request.compress) { + headers.set('accept-encoding', 'gzip,deflate'); + } + + if (!headers.has('user-agent')) { + headers.set('user-agent', 'node-fetch/1.0 (+https://github.com/bitinn/node-fetch)'); + } + + if (!headers.has('connection') && !request.agent) { + headers.set('connection', 'close'); + } + + if (!headers.has('accept')) { + headers.set('accept', '*/*'); + } + + if (!headers.has('content-length') && /post|put|patch|delete/i.test(request.method)) { + const totalBytes = getTotalBytes(request); + if (typeof totalBytes === 'number') { + headers.set('content-length', totalBytes); + } + } + + return headers; +} + +export function getNodeRequestOptions(request) { + return Object.assign({}, request[PARSED_URL], { + method: request.method, + headers: normalizeHeaders(request).raw(), + agent: request.agent + }); +} diff --git a/src/response.js b/src/response.js new file mode 100644 index 0000000..85b1820 --- /dev/null +++ b/src/response.js @@ -0,0 +1,66 @@ + +/** + * response.js + * + * Response class provides content decoding + */ + +import { STATUS_CODES } from 'http'; +import Headers from './headers.js'; +import Body, { clone } from './body'; + +/** + * Response class + * + * @param Stream body Readable stream + * @param Object opts Response options + * @return Void + */ +export default class Response extends Body { + constructor(body = null, opts = {}) { + super(body, opts); + + this.url = opts.url; + this.status = opts.status || 200; + this.statusText = opts.statusText || STATUS_CODES[this.status]; + this.headers = new Headers(opts.headers); + + Object.defineProperty(this, Symbol.toStringTag, { + value: 'Response', + writable: false, + enumerable: false, + configurable: true + }); + } + + /** + * Convenience property representing if the request ended normally + */ + get ok() { + return this.status >= 200 && this.status < 300; + } + + /** + * Clone this response + * + * @return Response + */ + clone() { + + return new Response(clone(this), { + url: this.url + , status: this.status + , statusText: this.statusText + , headers: this.headers + , ok: this.ok + }); + + } +} + +Object.defineProperty(Response.prototype, Symbol.toStringTag, { + value: 'ResponsePrototype', + writable: false, + enumerable: false, + configurable: true +}); diff --git a/test/server.js b/test/server.js index 08e582d..804603a 100644 --- a/test/server.js +++ b/test/server.js @@ -1,337 +1,336 @@ +import repeat from 'babel-runtime/core-js/string/repeat'; +import * as http from 'http'; +import { parse } from 'url'; +import * as zlib from 'zlib'; +import * as stream from 'stream'; +import { convert } from 'encoding'; +import { multipart as Multipart } from 'parted'; -var http = require('http'); -var parse = require('url').parse; -var zlib = require('zlib'); -var stream = require('stream'); -var convert = require('encoding').convert; -var Multipart = require('parted').multipart; - -module.exports = TestServer; - -function TestServer() { - this.server = http.createServer(this.router); - this.port = 30001; - this.hostname = 'localhost'; - this.server.on('error', function(err) { - console.log(err.stack); - }); - this.server.on('connection', function(socket) { - socket.setTimeout(1500); - }); -} - -TestServer.prototype.start = function(cb) { - this.server.listen(this.port, this.hostname, cb); -} - -TestServer.prototype.stop = function(cb) { - this.server.close(cb); -} - -TestServer.prototype.router = function(req, res) { - - var p = parse(req.url).pathname; - - if (p === '/hello') { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.end('world'); - } - - if (p === '/plain') { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.end('text'); - } - - if (p === '/options') { - res.statusCode = 200; - res.setHeader('Allow', 'GET, HEAD, OPTIONS'); - res.end('hello world'); - } - - if (p === '/html') { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/html'); - res.end(''); - } - - if (p === '/json') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - name: 'value' - })); - } - - if (p === '/gzip') { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.setHeader('Content-Encoding', 'gzip'); - zlib.gzip('hello world', function(err, buffer) { - res.end(buffer); +export default class TestServer { + constructor() { + this.server = http.createServer(this.router); + this.port = 30001; + this.hostname = 'localhost'; + this.server.on('error', function(err) { + console.log(err.stack); + }); + this.server.on('connection', function(socket) { + socket.setTimeout(1500); }); } - if (p === '/deflate') { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.setHeader('Content-Encoding', 'deflate'); - zlib.deflate('hello world', function(err, buffer) { - res.end(buffer); - }); + start(cb) { + this.server.listen(this.port, this.hostname, cb); } - if (p === '/deflate-raw') { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.setHeader('Content-Encoding', 'deflate'); - zlib.deflateRaw('hello world', function(err, buffer) { - res.end(buffer); - }); + stop(cb) { + this.server.close(cb); } - if (p === '/sdch') { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.setHeader('Content-Encoding', 'sdch'); - res.end('fake sdch string'); - } + router(req, res) { + let p = parse(req.url).pathname; - if (p === '/invalid-content-encoding') { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.setHeader('Content-Encoding', 'gzip'); - res.end('fake gzip string'); - } + if (p === '/hello') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('world'); + } - if (p === '/timeout') { - setTimeout(function() { + if (p === '/plain') { res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); res.end('text'); - }, 1000); - } + } - if (p === '/slow') { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.write('test'); - setTimeout(function() { - res.end('test'); - }, 1000); - } + if (p === '/options') { + res.statusCode = 200; + res.setHeader('Allow', 'GET, HEAD, OPTIONS'); + res.end('hello world'); + } - if (p === '/cookie') { - res.statusCode = 200; - res.setHeader('Set-Cookie', ['a=1', 'b=1']); - res.end('cookie'); - } + if (p === '/html') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html'); + res.end(''); + } - if (p === '/size/chunk') { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - setTimeout(function() { + if (p === '/json') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + name: 'value' + })); + } + + if (p === '/gzip') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('Content-Encoding', 'gzip'); + zlib.gzip('hello world', function(err, buffer) { + res.end(buffer); + }); + } + + if (p === '/deflate') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('Content-Encoding', 'deflate'); + zlib.deflate('hello world', function(err, buffer) { + res.end(buffer); + }); + } + + if (p === '/deflate-raw') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('Content-Encoding', 'deflate'); + zlib.deflateRaw('hello world', function(err, buffer) { + res.end(buffer); + }); + } + + if (p === '/sdch') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('Content-Encoding', 'sdch'); + res.end('fake sdch string'); + } + + if (p === '/invalid-content-encoding') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('Content-Encoding', 'gzip'); + res.end('fake gzip string'); + } + + if (p === '/timeout') { + setTimeout(function() { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('text'); + }, 1000); + } + + if (p === '/slow') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); res.write('test'); - }, 50); - setTimeout(function() { - res.end('test'); - }, 100); - } - - if (p === '/size/long') { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.end('testtest'); - } - - if (p === '/encoding/gbk') { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/html'); - res.end(convert('
中文
', 'gbk')); - } - - if (p === '/encoding/gb2312') { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/html'); - res.end(convert('
中文
', 'gb2312')); - } - - if (p === '/encoding/shift-jis') { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/html; charset=Shift-JIS'); - res.end(convert('
日本語
', 'Shift_JIS')); - } - - if (p === '/encoding/euc-jp') { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/xml'); - res.end(convert('日本語', 'EUC-JP')); - } - - if (p === '/encoding/utf8') { - res.statusCode = 200; - res.end('中文'); - } - - if (p === '/encoding/order1') { - res.statusCode = 200; - res.setHeader('Content-Type', 'charset=gbk; text/plain'); - res.end(convert('中文', 'gbk')); - } - - if (p === '/encoding/order2') { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain; charset=gbk; qs=1'); - res.end(convert('中文', 'gbk')); - } - - if (p === '/encoding/chunked') { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/html'); - res.setHeader('Transfer-Encoding', 'chunked'); - var padding = 'a'; - for (var i = 0; i < 10; i++) { - res.write(padding); + setTimeout(function() { + res.end('test'); + }, 1000); } - res.end(convert('
日本語
', 'Shift_JIS')); - } - if (p === '/encoding/invalid') { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/html'); - res.setHeader('Transfer-Encoding', 'chunked'); - // because node v0.12 doesn't have str.repeat - var padding = new Array(120 + 1).join('a'); - for (var i = 0; i < 10; i++) { - res.write(padding); + if (p === '/cookie') { + res.statusCode = 200; + res.setHeader('Set-Cookie', ['a=1', 'b=1']); + res.end('cookie'); } - res.end(convert('中文', 'gbk')); - } - if (p === '/redirect/301') { - res.statusCode = 301; - res.setHeader('Location', '/inspect'); - res.end(); - } + if (p === '/size/chunk') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + setTimeout(function() { + res.write('test'); + }, 50); + setTimeout(function() { + res.end('test'); + }, 100); + } - if (p === '/redirect/302') { - res.statusCode = 302; - res.setHeader('Location', '/inspect'); - res.end(); - } + if (p === '/size/long') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('testtest'); + } - if (p === '/redirect/303') { - res.statusCode = 303; - res.setHeader('Location', '/inspect'); - res.end(); - } + if (p === '/encoding/gbk') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html'); + res.end(convert('
中文
', 'gbk')); + } - if (p === '/redirect/307') { - res.statusCode = 307; - res.setHeader('Location', '/inspect'); - res.end(); - } + if (p === '/encoding/gb2312') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html'); + res.end(convert('
中文
', 'gb2312')); + } - if (p === '/redirect/308') { - res.statusCode = 308; - res.setHeader('Location', '/inspect'); - res.end(); - } + if (p === '/encoding/shift-jis') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=Shift-JIS'); + res.end(convert('
日本語
', 'Shift_JIS')); + } - if (p === '/redirect/chain') { - res.statusCode = 301; - res.setHeader('Location', '/redirect/301'); - res.end(); - } + if (p === '/encoding/euc-jp') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/xml'); + res.end(convert('日本語', 'EUC-JP')); + } - if (p === '/error/redirect') { - res.statusCode = 301; - //res.setHeader('Location', '/inspect'); - res.end(); - } + if (p === '/encoding/utf8') { + res.statusCode = 200; + res.end('中文'); + } - if (p === '/error/400') { - res.statusCode = 400; - res.setHeader('Content-Type', 'text/plain'); - res.end('client error'); - } + if (p === '/encoding/order1') { + res.statusCode = 200; + res.setHeader('Content-Type', 'charset=gbk; text/plain'); + res.end(convert('中文', 'gbk')); + } - if (p === '/error/404') { - res.statusCode = 404; - res.setHeader('Content-Encoding', 'gzip'); - res.end(); - } + if (p === '/encoding/order2') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain; charset=gbk; qs=1'); + res.end(convert('中文', 'gbk')); + } - if (p === '/error/500') { - res.statusCode = 500; - res.setHeader('Content-Type', 'text/plain'); - res.end('server error'); - } + if (p === '/encoding/chunked') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html'); + res.setHeader('Transfer-Encoding', 'chunked'); + res.write(repeat('a', 10)); + res.end(convert('
日本語
', 'Shift_JIS')); + } - if (p === '/error/reset') { - res.destroy(); - } + if (p === '/encoding/invalid') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html'); + res.setHeader('Transfer-Encoding', 'chunked'); + res.write(repeat('a', 1200)); + res.end(convert('中文', 'gbk')); + } - if (p === '/error/json') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end('invalid json'); - } + if (p === '/redirect/301') { + res.statusCode = 301; + res.setHeader('Location', '/inspect'); + res.end(); + } - if (p === '/no-content') { - res.statusCode = 204; - res.end(); - } + if (p === '/redirect/302') { + res.statusCode = 302; + res.setHeader('Location', '/inspect'); + res.end(); + } - if (p === '/no-content/gzip') { - res.statusCode = 204; - res.setHeader('Content-Encoding', 'gzip'); - res.end(); - } + if (p === '/redirect/303') { + res.statusCode = 303; + res.setHeader('Location', '/inspect'); + res.end(); + } - if (p === '/not-modified') { - res.statusCode = 304; - res.end(); - } + if (p === '/redirect/307') { + res.statusCode = 307; + res.setHeader('Location', '/inspect'); + res.end(); + } - if (p === '/not-modified/gzip') { - res.statusCode = 304; - res.setHeader('Content-Encoding', 'gzip'); - res.end(); - } + if (p === '/redirect/308') { + res.statusCode = 308; + res.setHeader('Location', '/inspect'); + res.end(); + } - if (p === '/inspect') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - var body = ''; - req.on('data', function(c) { body += c }); - req.on('end', function() { - res.end(JSON.stringify({ - method: req.method, - url: req.url, - headers: req.headers, - body: body - })); - }); - } + if (p === '/redirect/chain') { + res.statusCode = 301; + res.setHeader('Location', '/redirect/301'); + res.end(); + } - if (p === '/multipart') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - var parser = new Multipart(req.headers['content-type']); - var body = ''; - parser.on('part', function(field, part) { - body += field + '=' + part; - }); - parser.on('end', function() { - res.end(JSON.stringify({ - method: req.method, - url: req.url, - headers: req.headers, - body: body - })); - }); - req.pipe(parser); + if (p === '/error/redirect') { + res.statusCode = 301; + //res.setHeader('Location', '/inspect'); + res.end(); + } + + if (p === '/error/400') { + res.statusCode = 400; + res.setHeader('Content-Type', 'text/plain'); + res.end('client error'); + } + + if (p === '/error/404') { + res.statusCode = 404; + res.setHeader('Content-Encoding', 'gzip'); + res.end(); + } + + if (p === '/error/500') { + res.statusCode = 500; + res.setHeader('Content-Type', 'text/plain'); + res.end('server error'); + } + + if (p === '/error/reset') { + res.destroy(); + } + + if (p === '/error/json') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end('invalid json'); + } + + if (p === '/no-content') { + res.statusCode = 204; + res.end(); + } + + if (p === '/no-content/gzip') { + res.statusCode = 204; + res.setHeader('Content-Encoding', 'gzip'); + res.end(); + } + + if (p === '/not-modified') { + res.statusCode = 304; + res.end(); + } + + if (p === '/not-modified/gzip') { + res.statusCode = 304; + res.setHeader('Content-Encoding', 'gzip'); + res.end(); + } + + if (p === '/inspect') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + let body = ''; + req.on('data', function(c) { body += c }); + req.on('end', function() { + res.end(JSON.stringify({ + method: req.method, + url: req.url, + headers: req.headers, + body + })); + }); + } + + if (p === '/multipart') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + const parser = new Multipart(req.headers['content-type']); + let body = ''; + parser.on('part', function(field, part) { + body += field + '=' + part; + }); + parser.on('end', function() { + res.end(JSON.stringify({ + method: req.method, + url: req.url, + headers: req.headers, + body: body + })); + }); + req.pipe(parser); + } } } + +if (require.main === module) { + const server = new TestServer; + server.start(() => { + console.log(`Server started listening at port ${server.port}`); + }); +} diff --git a/test/test.js b/test/test.js index 6067ccd..129ed03 100644 --- a/test/test.js +++ b/test/test.js @@ -1,54 +1,84 @@ // test tools -var chai = require('chai'); -var cap = require('chai-as-promised'); -chai.use(cap); -var expect = chai.expect; -var bluebird = require('bluebird'); -var then = require('promise'); -var spawn = require('child_process').spawn; -var stream = require('stream'); -var resumer = require('resumer'); -var FormData = require('form-data'); -var http = require('http'); -var fs = require('fs'); +import repeat from 'babel-runtime/core-js/string/repeat'; +import chai from 'chai'; +import chaiPromised from 'chai-as-promised'; +import chaiIterator from 'chai-iterator'; +import chaiString from 'chai-string'; +import bluebird from 'bluebird'; +import then from 'promise'; +import {spawn} from 'child_process'; +import * as stream from 'stream'; +import resumer from 'resumer'; +import FormData from 'form-data'; +import {parse as parseURL} from 'url'; +import * as http from 'http'; +import * as fs from 'fs'; -var TestServer = require('./server'); +chai.use(chaiPromised); +chai.use(chaiIterator); +chai.use(chaiString); +const expect = chai.expect; + +import TestServer from './server'; // test subjects -var fetch = require('../index.js'); -var Headers = require('../lib/headers.js'); -var Response = require('../lib/response.js'); -var Request = require('../lib/request.js'); -var Body = require('../lib/body.js'); -var FetchError = require('../lib/fetch-error.js'); +import fetch, { + Headers, + Request, + Response +} from '../src/'; +import HeadersOrig from '../src/headers.js'; +import RequestOrig from '../src/request.js'; +import ResponseOrig from '../src/response.js'; +import Body from '../src/body.js'; +import Blob from '../src/blob.js'; +import FetchError from '../src/fetch-error.js'; // test with native promise on node 0.11, and bluebird for node 0.10 fetch.Promise = fetch.Promise || bluebird; -var url, opts, local, base; +let URL; +// whatwg-url doesn't support old Node.js, so make it optional +try { + URL = require('whatwg-url').URL; +} catch (err) {} -describe('node-fetch', function() { +const supportToString = ({ + [Symbol.toStringTag]: 'z' +}).toString() === '[object z]'; - before(function(done) { - local = new TestServer(); - base = 'http://' + local.hostname + ':' + local.port; - local.start(done); - }); +const local = new TestServer(); +const base = `http://${local.hostname}:${local.port}/`; +let url, opts; - after(function(done) { - local.stop(done); +before(done => { + local.start(done); +}); + +after(done => { + local.stop(done); +}); + +(runner => { + runner(false); + runner(true); +})(defaultFollowSpec => { + +describe(`node-fetch with FOLLOW_SPEC = ${defaultFollowSpec}`, () => { + before(() => { + fetch.FOLLOW_SPEC = Headers.FOLLOW_SPEC = defaultFollowSpec; }); it('should return a promise', function() { url = 'http://example.com/'; - var p = fetch(url); + const p = fetch(url); expect(p).to.be.an.instanceof(fetch.Promise); expect(p).to.have.property('then'); }); it('should allow custom promise', function() { url = 'http://example.com/'; - var old = fetch.Promise; + const old = fetch.Promise; fetch.Promise = then; expect(fetch(url)).to.be.an.instanceof(then); expect(fetch(url)).to.not.be.an.instanceof(bluebird); @@ -57,18 +87,24 @@ describe('node-fetch', function() { it('should throw error when no promise implementation are found', function() { url = 'http://example.com/'; - var old = fetch.Promise; + const old = fetch.Promise; fetch.Promise = undefined; - expect(function() { + expect(() => { fetch(url) }).to.throw(Error); fetch.Promise = old; }); it('should expose Headers, Response and Request constructors', function() { - expect(fetch.Headers).to.equal(Headers); - expect(fetch.Response).to.equal(Response); - expect(fetch.Request).to.equal(Request); + expect(Headers).to.equal(HeadersOrig); + expect(Response).to.equal(ResponseOrig); + expect(Request).to.equal(RequestOrig); + }); + + (supportToString ? it : it.skip)('should support proper toString output for Headers, Response and Request objects', function() { + expect(new Headers().toString()).to.equal('[object Headers]'); + expect(new Response().toString()).to.equal('[object Response]'); + expect(new Request(base).toString()).to.equal('[object Request]'); }); it('should reject with error if url is protocol relative', function() { @@ -94,8 +130,8 @@ describe('node-fetch', function() { }); it('should resolve into response', function() { - url = base + '/hello'; - return fetch(url).then(function(res) { + url = `${base}hello`; + return fetch(url).then(res => { expect(res).to.be.an.instanceof(Response); expect(res.headers).to.be.an.instanceof(Headers); expect(res.body).to.be.an.instanceof(stream.Transform); @@ -109,10 +145,10 @@ describe('node-fetch', function() { }); it('should accept plain text response', function() { - url = base + '/plain'; - return fetch(url).then(function(res) { + url = `${base}plain`; + return fetch(url).then(res => { expect(res.headers.get('content-type')).to.equal('text/plain'); - return res.text().then(function(result) { + return res.text().then(result => { expect(res.bodyUsed).to.be.true; expect(result).to.be.a('string'); expect(result).to.equal('text'); @@ -121,10 +157,10 @@ describe('node-fetch', function() { }); it('should accept html response (like plain text)', function() { - url = base + '/html'; - return fetch(url).then(function(res) { + url = `${base}html`; + return fetch(url).then(res => { expect(res.headers.get('content-type')).to.equal('text/html'); - return res.text().then(function(result) { + return res.text().then(result => { expect(res.bodyUsed).to.be.true; expect(result).to.be.a('string'); expect(result).to.equal(''); @@ -133,10 +169,10 @@ describe('node-fetch', function() { }); it('should accept json response', function() { - url = base + '/json'; - return fetch(url).then(function(res) { + url = `${base}json`; + return fetch(url).then(res => { expect(res.headers.get('content-type')).to.equal('application/json'); - return res.json().then(function(result) { + return res.json().then(result => { expect(res.bodyUsed).to.be.true; expect(result).to.be.an('object'); expect(result).to.deep.equal({ name: 'value' }); @@ -145,102 +181,102 @@ describe('node-fetch', function() { }); it('should send request with custom headers', function() { - url = base + '/inspect'; + url = `${base}inspect`; opts = { headers: { 'x-custom-header': 'abc' } }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { return res.json(); - }).then(function(res) { + }).then(res => { expect(res.headers['x-custom-header']).to.equal('abc'); }); }); it('should accept headers instance', function() { - url = base + '/inspect'; + url = `${base}inspect`; opts = { headers: new Headers({ 'x-custom-header': 'abc' }) }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { return res.json(); - }).then(function(res) { + }).then(res => { expect(res.headers['x-custom-header']).to.equal('abc'); }); }); it('should accept custom host header', function() { - url = base + '/inspect'; + url = `${base}inspect`; opts = { headers: { host: 'example.com' } }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { return res.json(); - }).then(function(res) { + }).then(res => { expect(res.headers['host']).to.equal('example.com'); }); }); it('should follow redirect code 301', function() { - url = base + '/redirect/301'; - return fetch(url).then(function(res) { - expect(res.url).to.equal(base + '/inspect'); + url = `${base}redirect/301`; + return fetch(url).then(res => { + expect(res.url).to.equal(`${base}inspect`); expect(res.status).to.equal(200); expect(res.ok).to.be.true; }); }); it('should follow redirect code 302', function() { - url = base + '/redirect/302'; - return fetch(url).then(function(res) { - expect(res.url).to.equal(base + '/inspect'); + url = `${base}redirect/302`; + return fetch(url).then(res => { + expect(res.url).to.equal(`${base}inspect`); expect(res.status).to.equal(200); }); }); it('should follow redirect code 303', function() { - url = base + '/redirect/303'; - return fetch(url).then(function(res) { - expect(res.url).to.equal(base + '/inspect'); + url = `${base}redirect/303`; + return fetch(url).then(res => { + expect(res.url).to.equal(`${base}inspect`); expect(res.status).to.equal(200); }); }); it('should follow redirect code 307', function() { - url = base + '/redirect/307'; - return fetch(url).then(function(res) { - expect(res.url).to.equal(base + '/inspect'); + url = `${base}redirect/307`; + return fetch(url).then(res => { + expect(res.url).to.equal(`${base}inspect`); expect(res.status).to.equal(200); }); }); it('should follow redirect code 308', function() { - url = base + '/redirect/308'; - return fetch(url).then(function(res) { - expect(res.url).to.equal(base + '/inspect'); + url = `${base}redirect/308`; + return fetch(url).then(res => { + expect(res.url).to.equal(`${base}inspect`); expect(res.status).to.equal(200); }); }); it('should follow redirect chain', function() { - url = base + '/redirect/chain'; - return fetch(url).then(function(res) { - expect(res.url).to.equal(base + '/inspect'); + url = `${base}redirect/chain`; + return fetch(url).then(res => { + expect(res.url).to.equal(`${base}inspect`); expect(res.status).to.equal(200); }); }); it('should follow POST request redirect code 301 with GET', function() { - url = base + '/redirect/301'; + url = `${base}redirect/301`; opts = { method: 'POST' , body: 'a=1' }; - return fetch(url, opts).then(function(res) { - expect(res.url).to.equal(base + '/inspect'); + return fetch(url, opts).then(res => { + expect(res.url).to.equal(`${base}inspect`); expect(res.status).to.equal(200); - return res.json().then(function(result) { + return res.json().then(result => { expect(result.method).to.equal('GET'); expect(result.body).to.equal(''); }); @@ -248,15 +284,15 @@ describe('node-fetch', function() { }); it('should follow POST request redirect code 302 with GET', function() { - url = base + '/redirect/302'; + url = `${base}redirect/302`; opts = { method: 'POST' , body: 'a=1' }; - return fetch(url, opts).then(function(res) { - expect(res.url).to.equal(base + '/inspect'); + return fetch(url, opts).then(res => { + expect(res.url).to.equal(`${base}inspect`); expect(res.status).to.equal(200); - return res.json().then(function(result) { + return res.json().then(result => { expect(result.method).to.equal('GET'); expect(result.body).to.equal(''); }); @@ -264,15 +300,15 @@ describe('node-fetch', function() { }); it('should follow redirect code 303 with GET', function() { - url = base + '/redirect/303'; + url = `${base}redirect/303`; opts = { method: 'PUT' , body: 'a=1' }; - return fetch(url, opts).then(function(res) { - expect(res.url).to.equal(base + '/inspect'); + return fetch(url, opts).then(res => { + expect(res.url).to.equal(`${base}inspect`); expect(res.status).to.equal(200); - return res.json().then(function(result) { + return res.json().then(result => { expect(result.method).to.equal('GET'); expect(result.body).to.equal(''); }); @@ -280,7 +316,7 @@ describe('node-fetch', function() { }); it('should obey maximum redirect, reject case', function() { - url = base + '/redirect/chain'; + url = `${base}redirect/chain`; opts = { follow: 1 } @@ -290,18 +326,18 @@ describe('node-fetch', function() { }); it('should obey redirect chain, resolve case', function() { - url = base + '/redirect/chain'; + url = `${base}redirect/chain`; opts = { follow: 2 } - return fetch(url, opts).then(function(res) { - expect(res.url).to.equal(base + '/inspect'); + return fetch(url, opts).then(res => { + expect(res.url).to.equal(`${base}inspect`); expect(res.status).to.equal(200); }); }); it('should allow not following redirect', function() { - url = base + '/redirect/301'; + url = `${base}redirect/301`; opts = { follow: 0 } @@ -311,19 +347,19 @@ describe('node-fetch', function() { }); it('should support redirect mode, manual flag', function() { - url = base + '/redirect/301'; + url = `${base}redirect/301`; opts = { redirect: 'manual' }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { expect(res.url).to.equal(url); expect(res.status).to.equal(301); - expect(res.headers.get('location')).to.equal(base + '/inspect'); + expect(res.headers.get('location')).to.equal(`${base}inspect`); }); }); it('should support redirect mode, error flag', function() { - url = base + '/redirect/301'; + url = `${base}redirect/301`; opts = { redirect: 'error' }; @@ -333,11 +369,11 @@ describe('node-fetch', function() { }); it('should support redirect mode, manual flag when there is no redirect', function() { - url = base + '/hello'; + url = `${base}hello`; opts = { redirect: 'manual' }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { expect(res.url).to.equal(url); expect(res.status).to.equal(200); expect(res.headers.get('location')).to.be.null; @@ -345,31 +381,31 @@ describe('node-fetch', function() { }); it('should follow redirect code 301 and keep existing headers', function() { - url = base + '/redirect/301'; + url = `${base}redirect/301`; opts = { headers: new Headers({ 'x-custom-header': 'abc' }) }; - return fetch(url, opts).then(function(res) { - expect(res.url).to.equal(base + '/inspect'); + return fetch(url, opts).then(res => { + expect(res.url).to.equal(`${base}inspect`); return res.json(); - }).then(function(res) { + }).then(res => { expect(res.headers['x-custom-header']).to.equal('abc'); }); }); it('should reject broken redirect', function() { - url = base + '/error/redirect'; + url = `${base}error/redirect`; return expect(fetch(url)).to.eventually.be.rejected .and.be.an.instanceOf(FetchError) .and.have.property('type', 'invalid-redirect'); }); it('should not reject broken redirect under manual redirect', function() { - url = base + '/error/redirect'; + url = `${base}error/redirect`; opts = { redirect: 'manual' }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { expect(res.url).to.equal(url); expect(res.status).to.equal(301); expect(res.headers.get('location')).to.be.null; @@ -377,13 +413,13 @@ describe('node-fetch', function() { }); it('should handle client-error response', function() { - url = base + '/error/400'; - return fetch(url).then(function(res) { + url = `${base}error/400`; + return fetch(url).then(res => { expect(res.headers.get('content-type')).to.equal('text/plain'); expect(res.status).to.equal(400); expect(res.statusText).to.equal('Bad Request'); expect(res.ok).to.be.false; - return res.text().then(function(result) { + return res.text().then(result => { expect(res.bodyUsed).to.be.true; expect(result).to.be.a('string'); expect(result).to.equal('client error'); @@ -392,13 +428,13 @@ describe('node-fetch', function() { }); it('should handle server-error response', function() { - url = base + '/error/500'; - return fetch(url).then(function(res) { + url = `${base}error/500`; + return fetch(url).then(res => { expect(res.headers.get('content-type')).to.equal('text/plain'); expect(res.status).to.equal(500); expect(res.statusText).to.equal('Internal Server Error'); expect(res.ok).to.be.false; - return res.text().then(function(result) { + return res.text().then(result => { expect(res.bodyUsed).to.be.true; expect(result).to.be.a('string'); expect(result).to.equal('server error'); @@ -407,7 +443,7 @@ describe('node-fetch', function() { }); it('should handle network-error response', function() { - url = base + '/error/reset'; + url = `${base}error/reset`; return expect(fetch(url)).to.eventually.be.rejected .and.be.an.instanceOf(FetchError) .and.have.property('code', 'ECONNRESET'); @@ -421,44 +457,34 @@ describe('node-fetch', function() { }); it('should reject invalid json response', function() { - url = base + '/error/json'; - return fetch(url).then(function(res) { + url = `${base}error/json`; + return fetch(url).then(res => { expect(res.headers.get('content-type')).to.equal('application/json'); return expect(res.json()).to.eventually.be.rejectedWith(Error); }); }); it('should handle no content response', function() { - url = base + '/no-content'; - return fetch(url).then(function(res) { + url = `${base}no-content`; + return fetch(url).then(res => { expect(res.status).to.equal(204); expect(res.statusText).to.equal('No Content'); expect(res.ok).to.be.true; - return res.text().then(function(result) { + return res.text().then(result => { expect(result).to.be.a('string'); expect(result).to.be.empty; }); }); }); - it('should return empty object on no-content response', function() { - url = base + '/no-content'; - return fetch(url).then(function(res) { - return res.json().then(function(result) { - expect(result).to.be.an('object'); - expect(result).to.be.empty; - }); - }); - }); - it('should handle no content response with gzip encoding', function() { - url = base + '/no-content/gzip'; - return fetch(url).then(function(res) { + url = `${base}no-content/gzip`; + return fetch(url).then(res => { expect(res.status).to.equal(204); expect(res.statusText).to.equal('No Content'); expect(res.headers.get('content-encoding')).to.equal('gzip'); expect(res.ok).to.be.true; - return res.text().then(function(result) { + return res.text().then(result => { expect(result).to.be.a('string'); expect(result).to.be.empty; }); @@ -466,12 +492,12 @@ describe('node-fetch', function() { }); it('should handle not modified response', function() { - url = base + '/not-modified'; - return fetch(url).then(function(res) { + url = `${base}not-modified`; + return fetch(url).then(res => { expect(res.status).to.equal(304); expect(res.statusText).to.equal('Not Modified'); expect(res.ok).to.be.false; - return res.text().then(function(result) { + return res.text().then(result => { expect(result).to.be.a('string'); expect(result).to.be.empty; }); @@ -479,13 +505,13 @@ describe('node-fetch', function() { }); it('should handle not modified response with gzip encoding', function() { - url = base + '/not-modified/gzip'; - return fetch(url).then(function(res) { + url = `${base}not-modified/gzip`; + return fetch(url).then(res => { expect(res.status).to.equal(304); expect(res.statusText).to.equal('Not Modified'); expect(res.headers.get('content-encoding')).to.equal('gzip'); expect(res.ok).to.be.false; - return res.text().then(function(result) { + return res.text().then(result => { expect(result).to.be.a('string'); expect(result).to.be.empty; }); @@ -493,10 +519,10 @@ describe('node-fetch', function() { }); it('should decompress gzip response', function() { - url = base + '/gzip'; - return fetch(url).then(function(res) { + url = `${base}gzip`; + return fetch(url).then(res => { expect(res.headers.get('content-type')).to.equal('text/plain'); - return res.text().then(function(result) { + return res.text().then(result => { expect(result).to.be.a('string'); expect(result).to.equal('hello world'); }); @@ -504,10 +530,10 @@ describe('node-fetch', function() { }); it('should decompress deflate response', function() { - url = base + '/deflate'; - return fetch(url).then(function(res) { + url = `${base}deflate`; + return fetch(url).then(res => { expect(res.headers.get('content-type')).to.equal('text/plain'); - return res.text().then(function(result) { + return res.text().then(result => { expect(result).to.be.a('string'); expect(result).to.equal('hello world'); }); @@ -515,10 +541,10 @@ describe('node-fetch', function() { }); it('should decompress deflate raw response from old apache server', function() { - url = base + '/deflate-raw'; - return fetch(url).then(function(res) { + url = `${base}deflate-raw`; + return fetch(url).then(res => { expect(res.headers.get('content-type')).to.equal('text/plain'); - return res.text().then(function(result) { + return res.text().then(result => { expect(result).to.be.a('string'); expect(result).to.equal('hello world'); }); @@ -526,10 +552,10 @@ describe('node-fetch', function() { }); it('should skip decompression if unsupported', function() { - url = base + '/sdch'; - return fetch(url).then(function(res) { + url = `${base}sdch`; + return fetch(url).then(res => { expect(res.headers.get('content-type')).to.equal('text/plain'); - return res.text().then(function(result) { + return res.text().then(result => { expect(result).to.be.a('string'); expect(result).to.equal('fake sdch string'); }); @@ -537,8 +563,8 @@ describe('node-fetch', function() { }); it('should reject if response compression is invalid', function() { - url = base + '/invalid-content-encoding'; - return fetch(url).then(function(res) { + url = `${base}invalid-content-encoding`; + return fetch(url).then(res => { expect(res.headers.get('content-type')).to.equal('text/plain'); return expect(res.text()).to.eventually.be.rejected .and.be.an.instanceOf(FetchError) @@ -547,13 +573,13 @@ describe('node-fetch', function() { }); it('should allow disabling auto decompression', function() { - url = base + '/gzip'; + url = `${base}gzip`; opts = { compress: false }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { expect(res.headers.get('content-type')).to.equal('text/plain'); - return res.text().then(function(result) { + return res.text().then(result => { expect(result).to.be.a('string'); expect(result).to.not.equal('hello world'); }); @@ -562,7 +588,7 @@ describe('node-fetch', function() { it('should allow custom timeout', function() { this.timeout(500); - url = base + '/timeout'; + url = `${base}timeout`; opts = { timeout: 100 }; @@ -573,11 +599,11 @@ describe('node-fetch', function() { it('should allow custom timeout on response body', function() { this.timeout(500); - url = base + '/slow'; + url = `${base}slow`; opts = { timeout: 100 }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { expect(res.ok).to.be.true; return expect(res.text()).to.eventually.be.rejected .and.be.an.instanceOf(FetchError) @@ -587,150 +613,228 @@ describe('node-fetch', function() { it('should clear internal timeout on fetch response', function (done) { this.timeout(1000); - spawn('node', ['-e', 'require("./")("' + base + '/hello", { timeout: 5000 })']) - .on('exit', function () { + spawn('node', ['-e', `require('./')('${base}hello', { timeout: 5000 })`]) + .on('exit', () => { done(); }); }); it('should clear internal timeout on fetch redirect', function (done) { this.timeout(1000); - spawn('node', ['-e', 'require("./")("' + base + '/redirect/301", { timeout: 5000 })']) - .on('exit', function () { + spawn('node', ['-e', `require('./')('${base}redirect/301', { timeout: 5000 })`]) + .on('exit', () => { done(); }); }); it('should clear internal timeout on fetch error', function (done) { this.timeout(1000); - spawn('node', ['-e', 'require("./")("' + base + '/error/reset", { timeout: 5000 })']) - .on('exit', function () { + spawn('node', ['-e', `require('./')('${base}error/reset', { timeout: 5000 })`]) + .on('exit', () => { done(); }); }); + it('should set default User-Agent', function () { + url = `${base}inspect`; + fetch(url).then(res => res.json()).then(res => { + expect(res.headers['user-agent']).to.startWith('node-fetch/'); + }); + }); + + it('should allow setting User-Agent', function () { + url = `${base}inspect`; + opts = { + headers: { + 'user-agent': 'faked' + } + }; + fetch(url, opts).then(res => res.json()).then(res => { + expect(res.headers['user-agent']).to.equal('faked'); + }); + }); + + it('should set default Accept header', function () { + url = `${base}inspect`; + fetch(url).then(res => res.json()).then(res => { + expect(res.headers.accept).to.equal('*/*'); + }); + }); + + it('should allow setting Accept header', function () { + url = `${base}inspect`; + opts = { + headers: { + 'accept': 'application/json' + } + }; + fetch(url, opts).then(res => res.json()).then(res => { + expect(res.headers.accept).to.equal('application/json'); + }); + }); + it('should allow POST request', function() { - url = base + '/inspect'; + url = `${base}inspect`; opts = { method: 'POST' }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { return res.json(); - }).then(function(res) { + }).then(res => { expect(res.method).to.equal('POST'); expect(res.headers['transfer-encoding']).to.be.undefined; + expect(res.headers['content-type']).to.be.undefined; expect(res.headers['content-length']).to.equal('0'); }); }); it('should allow POST request with string body', function() { - url = base + '/inspect'; + url = `${base}inspect`; opts = { method: 'POST' , body: 'a=1' }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { return res.json(); - }).then(function(res) { + }).then(res => { expect(res.method).to.equal('POST'); expect(res.body).to.equal('a=1'); expect(res.headers['transfer-encoding']).to.be.undefined; + expect(res.headers['content-type']).to.equal('text/plain;charset=UTF-8'); expect(res.headers['content-length']).to.equal('3'); }); }); it('should allow POST request with buffer body', function() { - url = base + '/inspect'; + url = `${base}inspect`; opts = { method: 'POST' , body: new Buffer('a=1', 'utf-8') }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { return res.json(); - }).then(function(res) { + }).then(res => { expect(res.method).to.equal('POST'); expect(res.body).to.equal('a=1'); - expect(res.headers['transfer-encoding']).to.equal('chunked'); - expect(res.headers['content-length']).to.be.undefined; + expect(res.headers['transfer-encoding']).to.be.undefined; + expect(res.headers['content-type']).to.be.undefined; + expect(res.headers['content-length']).to.equal('3'); + }); + }); + + it('should allow POST request with blob body without type', function() { + url = `${base}inspect`; + opts = { + method: 'POST' + , body: new Blob(['a=1']) + }; + return fetch(url, opts).then(res => { + return res.json(); + }).then(res => { + expect(res.method).to.equal('POST'); + expect(res.body).to.equal('a=1'); + expect(res.headers['transfer-encoding']).to.be.undefined; + expect(res.headers['content-type']).to.be.undefined; + expect(res.headers['content-length']).to.equal('3'); + }); + }); + + it('should allow POST request with blob body with type', function() { + url = `${base}inspect`; + opts = { + method: 'POST', + body: new Blob(['a=1'], { + type: 'text/plain;charset=UTF-8' + }) + }; + return fetch(url, opts).then(res => { + return res.json(); + }).then(res => { + expect(res.method).to.equal('POST'); + expect(res.body).to.equal('a=1'); + expect(res.headers['transfer-encoding']).to.be.undefined; + expect(res.headers['content-type']).to.equal('text/plain;charset=utf-8'); + expect(res.headers['content-length']).to.equal('3'); }); }); it('should allow POST request with readable stream as body', function() { - var body = resumer().queue('a=1').end(); + let body = resumer().queue('a=1').end(); body = body.pipe(new stream.PassThrough()); - url = base + '/inspect'; + url = `${base}inspect`; opts = { method: 'POST' - , body: body + , body }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { return res.json(); - }).then(function(res) { + }).then(res => { expect(res.method).to.equal('POST'); expect(res.body).to.equal('a=1'); expect(res.headers['transfer-encoding']).to.equal('chunked'); + expect(res.headers['content-type']).to.be.undefined; expect(res.headers['content-length']).to.be.undefined; }); }); it('should allow POST request with form-data as body', function() { - var form = new FormData(); + const form = new FormData(); form.append('a','1'); - url = base + '/multipart'; + url = `${base}multipart`; opts = { method: 'POST' , body: form }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { return res.json(); - }).then(function(res) { + }).then(res => { expect(res.method).to.equal('POST'); - expect(res.headers['content-type']).to.contain('multipart/form-data'); + expect(res.headers['content-type']).to.startWith('multipart/form-data;boundary='); expect(res.headers['content-length']).to.be.a('string'); expect(res.body).to.equal('a=1'); }); }); it('should allow POST request with form-data using stream as body', function() { - var form = new FormData(); + const form = new FormData(); form.append('my_field', fs.createReadStream('test/dummy.txt')); - url = base + '/multipart'; + url = `${base}multipart`; opts = { method: 'POST' , body: form }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { return res.json(); - }).then(function(res) { + }).then(res => { expect(res.method).to.equal('POST'); - expect(res.headers['content-type']).to.contain('multipart/form-data'); + expect(res.headers['content-type']).to.startWith('multipart/form-data;boundary='); expect(res.headers['content-length']).to.be.undefined; expect(res.body).to.contain('my_field='); }); }); it('should allow POST request with form-data as body and custom headers', function() { - var form = new FormData(); + const form = new FormData(); form.append('a','1'); - var headers = form.getHeaders(); + const headers = form.getHeaders(); headers['b'] = '2'; - url = base + '/multipart'; + url = `${base}multipart`; opts = { method: 'POST' , body: form - , headers: headers + , headers }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { return res.json(); - }).then(function(res) { + }).then(res => { expect(res.method).to.equal('POST'); - expect(res.headers['content-type']).to.contain('multipart/form-data'); + expect(res.headers['content-type']).to.startWith('multipart/form-data; boundary='); expect(res.headers['content-length']).to.be.a('string'); expect(res.headers.b).to.equal('2'); expect(res.body).to.equal('a=1'); @@ -738,55 +842,57 @@ describe('node-fetch', function() { }); it('should allow POST request with object body', function() { - url = base + '/inspect'; + url = `${base}inspect`; // note that fetch simply calls tostring on an object opts = { method: 'POST' , body: { a:1 } }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { return res.json(); - }).then(function(res) { + }).then(res => { expect(res.method).to.equal('POST'); expect(res.body).to.equal('[object Object]'); + expect(res.headers['content-type']).to.equal('text/plain;charset=UTF-8'); + expect(res.headers['content-length']).to.equal('15'); }); }); it('should allow PUT request', function() { - url = base + '/inspect'; + url = `${base}inspect`; opts = { method: 'PUT' , body: 'a=1' }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { return res.json(); - }).then(function(res) { + }).then(res => { expect(res.method).to.equal('PUT'); expect(res.body).to.equal('a=1'); }); }); it('should allow DELETE request', function() { - url = base + '/inspect'; + url = `${base}inspect`; opts = { method: 'DELETE' }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { return res.json(); - }).then(function(res) { + }).then(res => { expect(res.method).to.equal('DELETE'); }); }); it('should allow POST request with string body', function() { - url = base + '/inspect'; + url = `${base}inspect`; opts = { method: 'POST' , body: 'a=1' }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { return res.json(); - }).then(function(res) { + }).then(res => { expect(res.method).to.equal('POST'); expect(res.body).to.equal('a=1'); expect(res.headers['transfer-encoding']).to.be.undefined; @@ -795,14 +901,14 @@ describe('node-fetch', function() { }); it('should allow DELETE request with string body', function() { - url = base + '/inspect'; + url = `${base}inspect`; opts = { method: 'DELETE' , body: 'a=1' }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { return res.json(); - }).then(function(res) { + }).then(res => { expect(res.method).to.equal('DELETE'); expect(res.body).to.equal('a=1'); expect(res.headers['transfer-encoding']).to.be.undefined; @@ -811,55 +917,55 @@ describe('node-fetch', function() { }); it('should allow PATCH request', function() { - url = base + '/inspect'; + url = `${base}inspect`; opts = { method: 'PATCH' , body: 'a=1' }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { return res.json(); - }).then(function(res) { + }).then(res => { expect(res.method).to.equal('PATCH'); expect(res.body).to.equal('a=1'); }); }); it('should allow HEAD request', function() { - url = base + '/hello'; + url = `${base}hello`; opts = { method: 'HEAD' }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { expect(res.status).to.equal(200); expect(res.statusText).to.equal('OK'); expect(res.headers.get('content-type')).to.equal('text/plain'); expect(res.body).to.be.an.instanceof(stream.Transform); return res.text(); - }).then(function(text) { + }).then(text => { expect(text).to.equal(''); }); }); it('should allow HEAD request with content-encoding header', function() { - url = base + '/error/404'; + url = `${base}error/404`; opts = { method: 'HEAD' }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { expect(res.status).to.equal(404); expect(res.headers.get('content-encoding')).to.equal('gzip'); return res.text(); - }).then(function(text) { + }).then(text => { expect(text).to.equal(''); }); }); it('should allow OPTIONS request', function() { - url = base + '/options'; + url = `${base}options`; opts = { method: 'OPTIONS' }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { expect(res.status).to.equal(200); expect(res.statusText).to.equal('OK'); expect(res.headers.get('allow')).to.equal('GET, HEAD, OPTIONS'); @@ -868,10 +974,10 @@ describe('node-fetch', function() { }); it('should reject decoding body twice', function() { - url = base + '/plain'; - return fetch(url).then(function(res) { + url = `${base}plain`; + return fetch(url).then(res => { expect(res.headers.get('content-type')).to.equal('text/plain'); - return res.text().then(function(result) { + return res.text().then(result => { expect(res.bodyUsed).to.be.true; return expect(res.text()).to.eventually.be.rejectedWith(Error); }); @@ -879,11 +985,11 @@ describe('node-fetch', function() { }); it('should support maximum response size, multiple chunk', function() { - url = base + '/size/chunk'; + url = `${base}size/chunk`; opts = { size: 5 }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { expect(res.status).to.equal(200); expect(res.headers.get('content-type')).to.equal('text/plain'); return expect(res.text()).to.eventually.be.rejected @@ -893,11 +999,11 @@ describe('node-fetch', function() { }); it('should support maximum response size, single chunk', function() { - url = base + '/size/long'; + url = `${base}size/long`; opts = { size: 5 }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { expect(res.status).to.equal(200); expect(res.headers.get('content-type')).to.equal('text/plain'); return expect(res.text()).to.eventually.be.rejected @@ -906,156 +1012,147 @@ describe('node-fetch', function() { }); }); - it('should support encoding decode, xml dtd detect', function() { - url = base + '/encoding/euc-jp'; - return fetch(url).then(function(res) { + it('should only use UTF-8 decoding with text()', function() { + url = `${base}encoding/euc-jp`; + return fetch(url).then(res => { expect(res.status).to.equal(200); - return res.text().then(function(result) { + return res.text().then(result => { + expect(result).to.equal('\ufffd\ufffd\ufffd\u0738\ufffd'); + }); + }); + }); + + it('should support encoding decode, xml dtd detect', function() { + url = `${base}encoding/euc-jp`; + return fetch(url).then(res => { + expect(res.status).to.equal(200); + return res.textConverted().then(result => { expect(result).to.equal('日本語'); }); }); }); it('should support encoding decode, content-type detect', function() { - url = base + '/encoding/shift-jis'; - return fetch(url).then(function(res) { + url = `${base}encoding/shift-jis`; + return fetch(url).then(res => { expect(res.status).to.equal(200); - return res.text().then(function(result) { + return res.textConverted().then(result => { expect(result).to.equal('
日本語
'); }); }); }); it('should support encoding decode, html5 detect', function() { - url = base + '/encoding/gbk'; - return fetch(url).then(function(res) { + url = `${base}encoding/gbk`; + return fetch(url).then(res => { expect(res.status).to.equal(200); - return res.text().then(function(result) { + return res.textConverted().then(result => { expect(result).to.equal('
中文
'); }); }); }); it('should support encoding decode, html4 detect', function() { - url = base + '/encoding/gb2312'; - return fetch(url).then(function(res) { + url = `${base}encoding/gb2312`; + return fetch(url).then(res => { expect(res.status).to.equal(200); - return res.text().then(function(result) { + return res.textConverted().then(result => { expect(result).to.equal('
中文
'); }); }); }); it('should default to utf8 encoding', function() { - url = base + '/encoding/utf8'; - return fetch(url).then(function(res) { + url = `${base}encoding/utf8`; + return fetch(url).then(res => { expect(res.status).to.equal(200); expect(res.headers.get('content-type')).to.be.null; - return res.text().then(function(result) { + return res.textConverted().then(result => { expect(result).to.equal('中文'); }); }); }); it('should support uncommon content-type order, charset in front', function() { - url = base + '/encoding/order1'; - return fetch(url).then(function(res) { + url = `${base}encoding/order1`; + return fetch(url).then(res => { expect(res.status).to.equal(200); - return res.text().then(function(result) { + return res.textConverted().then(result => { expect(result).to.equal('中文'); }); }); }); it('should support uncommon content-type order, end with qs', function() { - url = base + '/encoding/order2'; - return fetch(url).then(function(res) { + url = `${base}encoding/order2`; + return fetch(url).then(res => { expect(res.status).to.equal(200); - return res.text().then(function(result) { + return res.textConverted().then(result => { expect(result).to.equal('中文'); }); }); }); it('should support chunked encoding, html4 detect', function() { - url = base + '/encoding/chunked'; - return fetch(url).then(function(res) { + url = `${base}encoding/chunked`; + return fetch(url).then(res => { expect(res.status).to.equal(200); - // because node v0.12 doesn't have str.repeat - var padding = new Array(10 + 1).join('a'); - return res.text().then(function(result) { - expect(result).to.equal(padding + '
日本語
'); + const padding = repeat('a', 10); + return res.textConverted().then(result => { + expect(result).to.equal(`${padding}
日本語
`); }); }); }); it('should only do encoding detection up to 1024 bytes', function() { - url = base + '/encoding/invalid'; - return fetch(url).then(function(res) { + url = `${base}encoding/invalid`; + return fetch(url).then(res => { expect(res.status).to.equal(200); - // because node v0.12 doesn't have str.repeat - var padding = new Array(1200 + 1).join('a'); - return res.text().then(function(result) { - expect(result).to.not.equal(padding + '中文'); + const padding = repeat('a', 1200); + return res.textConverted().then(result => { + expect(result).to.not.equal(`${padding}中文`); }); }); }); - it('should allow piping response body as stream', function(done) { - url = base + '/hello'; - fetch(url).then(function(res) { + it('should allow piping response body as stream', function() { + url = `${base}hello`; + return fetch(url).then(res => { expect(res.body).to.be.an.instanceof(stream.Transform); - res.body.on('data', function(chunk) { + return streamToPromise(res.body, chunk => { if (chunk === null) { return; } expect(chunk.toString()).to.equal('world'); }); - res.body.on('end', function() { - done(); - }); }); }); - it('should allow cloning a response, and use both as stream', function(done) { - url = base + '/hello'; - return fetch(url).then(function(res) { - var counter = 0; - var r1 = res.clone(); + it('should allow cloning a response, and use both as stream', function() { + url = `${base}hello`; + return fetch(url).then(res => { + const r1 = res.clone(); expect(res.body).to.be.an.instanceof(stream.Transform); expect(r1.body).to.be.an.instanceof(stream.Transform); - res.body.on('data', function(chunk) { + const dataHandler = chunk => { if (chunk === null) { return; } expect(chunk.toString()).to.equal('world'); - }); - res.body.on('end', function() { - counter++; - if (counter == 2) { - done(); - } - }); - r1.body.on('data', function(chunk) { - if (chunk === null) { - return; - } - expect(chunk.toString()).to.equal('world'); - }); - r1.body.on('end', function() { - counter++; - if (counter == 2) { - done(); - } - }); + }; + + return Promise.all([ + streamToPromise(res.body, dataHandler), + streamToPromise(r1.body, dataHandler) + ]); }); }); it('should allow cloning a json response and log it as text response', function() { - url = base + '/json'; - return fetch(url).then(function(res) { - var r1 = res.clone(); - return fetch.Promise.all([res.json(), r1.text()]).then(function(results) { + url = `${base}json`; + return fetch(url).then(res => { + const r1 = res.clone(); + return fetch.Promise.all([res.json(), r1.text()]).then(results => { expect(results[0]).to.deep.equal({name: 'value'}); expect(results[1]).to.equal('{"name":"value"}'); }); @@ -1063,12 +1160,12 @@ describe('node-fetch', function() { }); it('should allow cloning a json response, and then log it as text response', function() { - url = base + '/json'; - return fetch(url).then(function(res) { - var r1 = res.clone(); - return res.json().then(function(result) { + url = `${base}json`; + return fetch(url).then(res => { + const r1 = res.clone(); + return res.json().then(result => { expect(result).to.deep.equal({name: 'value'}); - return r1.text().then(function(result) { + return r1.text().then(result => { expect(result).to.equal('{"name":"value"}'); }); }); @@ -1076,12 +1173,12 @@ describe('node-fetch', function() { }); it('should allow cloning a json response, first log as text response, then return json object', function() { - url = base + '/json'; - return fetch(url).then(function(res) { - var r1 = res.clone(); - return r1.text().then(function(result) { + url = `${base}json`; + return fetch(url).then(res => { + const r1 = res.clone(); + return r1.text().then(result => { expect(result).to.equal('{"name":"value"}'); - return res.json().then(function(result) { + return res.json().then(result => { expect(result).to.deep.equal({name: 'value'}); }); }); @@ -1089,86 +1186,196 @@ describe('node-fetch', function() { }); it('should not allow cloning a response after its been used', function() { - url = base + '/hello'; - return fetch(url).then(function(res) { - return res.text().then(function(result) { - expect(function() { - var r1 = res.clone(); + url = `${base}hello`; + return fetch(url).then(res => + res.text().then(result => { + expect(() => { + res.clone(); }).to.throw(Error); - }); - }) + }) + ); }); it('should allow get all responses of a header', function() { - url = base + '/cookie'; - return fetch(url).then(function(res) { - expect(res.headers.get('set-cookie')).to.equal('a=1'); - expect(res.headers.get('Set-Cookie')).to.equal('a=1'); + url = `${base}cookie`; + return fetch(url).then(res => { + const expected = fetch.FOLLOW_SPEC ? 'a=1,b=1' : 'a=1'; + expect(res.headers.get('set-cookie')).to.equal(expected); + expect(res.headers.get('Set-Cookie')).to.equal(expected); expect(res.headers.getAll('set-cookie')).to.deep.equal(['a=1', 'b=1']); expect(res.headers.getAll('Set-Cookie')).to.deep.equal(['a=1', 'b=1']); }); }); - it('should allow iterating through all headers', function() { - var headers = new Headers({ - a: 1 - , b: [2, 3] - , c: [4] - }); + it('should allow iterating through all headers with forEach', function() { + const headers = new Headers([ + ['b', '2'], + ['c', '4'], + ['b', '3'], + ['a', '1'] + ]); expect(headers).to.have.property('forEach'); - var result = []; - headers.forEach(function(val, key) { + const result = []; + headers.forEach((val, key) => { result.push([key, val]); }); - expected = [ + const expected = Headers.FOLLOW_SPEC ? [ ["a", "1"] - , ["b", "2"] + , ["b", "2,3"] + , ["c", "4"] + ] : [ + ["b", "2"] , ["b", "3"] , ["c", "4"] + , ["a", "1"] ]; expect(result).to.deep.equal(expected); }); + it('should allow iterating through all headers with for-of loop', function() { + const headers = new Headers([ + ['b', '2'], + ['c', '4'], + ['a', '1'] + ]); + headers.append('b', '3'); + expect(headers).to.be.iterable; + + const result = []; + for (let pair of headers) { + result.push(pair); + } + expect(result).to.deep.equal(Headers.FOLLOW_SPEC ? [ + ['a', '1'], + ['b', '2,3'], + ['c', '4'] + ] : [ + ['b', '2'], + ['b', '3'], + ['c', '4'], + ['a', '1'], + ]); + }); + + it('should allow iterating through all headers with entries()', function() { + const headers = new Headers([ + ['b', '2'], + ['c', '4'], + ['a', '1'] + ]); + headers.append('b', '3'); + + expect(headers.entries()).to.be.iterable + .and.to.deep.iterate.over(Headers.FOLLOW_SPEC ? [ + ['a', '1'], + ['b', '2,3'], + ['c', '4'] + ] : [ + ['b', '2'], + ['b', '3'], + ['c', '4'], + ['a', '1'], + ]); + }); + + it('should allow iterating through all headers with keys()', function() { + const headers = new Headers([ + ['b', '2'], + ['c', '4'], + ['a', '1'] + ]); + headers.append('b', '3'); + + expect(headers.keys()).to.be.iterable + .and.to.iterate.over(Headers.FOLLOW_SPEC ? ['a', 'b', 'c'] : ['b', 'b', 'c', 'a']); + }); + + it('should allow iterating through all headers with values()', function() { + const headers = new Headers([ + ['b', '2'], + ['c', '4'], + ['a', '1'] + ]); + headers.append('b', '3'); + + expect(headers.values()).to.be.iterable + .and.to.iterate.over(Headers.FOLLOW_SPEC ? ['1', '2,3', '4'] : ['2', '3', '4', '1']); + }); + + it('should only apply FOLLOW_SPEC when it is requested', function () { + Headers.FOLLOW_SPEC = true; + + const src = [ + ['b', '2'], + ['b', '3'] + ]; + + let headers = new Headers(src); + expect(headers.get('b')).to.equal('2,3'); + + Headers.FOLLOW_SPEC = false; + expect(headers.get('b')).to.equal('2,3'); + + headers = new Headers(src); + expect(headers.get('b')).to.equal('2'); + + Headers.FOLLOW_SPEC = defaultFollowSpec; + }); + it('should allow deleting header', function() { - url = base + '/cookie'; - return fetch(url).then(function(res) { + url = `${base}cookie`; + return fetch(url).then(res => { res.headers.delete('set-cookie'); expect(res.headers.get('set-cookie')).to.be.null; expect(res.headers.getAll('set-cookie')).to.be.empty; }); }); + it('should reject illegal header', function() { + const headers = new Headers(); + expect(() => new Headers({ 'He y': 'ok' })).to.throw(TypeError); + expect(() => new Headers({ 'Hé-y': 'ok' })).to.throw(TypeError); + expect(() => new Headers({ 'He-y': 'ăk' })).to.throw(TypeError); + expect(() => headers.append('Hé-y', 'ok')) .to.throw(TypeError); + expect(() => headers.delete('Hé-y')) .to.throw(TypeError); + expect(() => headers.get('Hé-y')) .to.throw(TypeError); + expect(() => headers.getAll('Hé-y')) .to.throw(TypeError); + expect(() => headers.has('Hé-y')) .to.throw(TypeError); + expect(() => headers.set('Hé-y', 'ok')) .to.throw(TypeError); + + // 'o k' is valid value but invalid name + new Headers({ 'He-y': 'o k' }); + }); + it('should send request with connection keep-alive if agent is provided', function() { - url = base + '/inspect'; + url = `${base}inspect`; opts = { agent: new http.Agent({ keepAlive: true }) }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { return res.json(); - }).then(function(res) { + }).then(res => { expect(res.headers['connection']).to.equal('keep-alive'); }); }); it('should ignore unsupported attributes while reading headers', function() { - var FakeHeader = function() {}; - // prototypes are ignored + const FakeHeader = function () {}; + // prototypes are currently ignored + // This might change in the future: #181 FakeHeader.prototype.z = 'fake'; - var res = new FakeHeader; - // valid + const res = new FakeHeader; res.a = 'string'; res.b = ['1','2']; res.c = ''; res.d = []; - // common mistakes, normalized res.e = 1; res.f = [1, 2]; - // invalid, ignored res.g = { a:1 }; res.h = undefined; res.i = null; @@ -1177,74 +1384,154 @@ describe('node-fetch', function() { res.l = false; res.m = new Buffer('test'); - var h1 = new Headers(res); + const h1 = new Headers(res); + h1.set('n', [1, 2]); + h1.append('n', ['3', 4]) - expect(h1._headers['a']).to.include('string'); - expect(h1._headers['b']).to.include('1'); - expect(h1._headers['b']).to.include('2'); - expect(h1._headers['c']).to.include(''); - expect(h1._headers['d']).to.be.undefined; + const h1Raw = h1.raw(); - expect(h1._headers['e']).to.include('1'); - expect(h1._headers['f']).to.include('1'); - expect(h1._headers['f']).to.include('2'); + expect(h1Raw['a']).to.include('string'); + expect(h1Raw['b']).to.include('1,2'); + expect(h1Raw['c']).to.include(''); + expect(h1Raw['d']).to.include(''); + expect(h1Raw['e']).to.include('1'); + expect(h1Raw['f']).to.include('1,2'); + expect(h1Raw['g']).to.include('[object Object]'); + expect(h1Raw['h']).to.include('undefined'); + expect(h1Raw['i']).to.include('null'); + expect(h1Raw['j']).to.include('NaN'); + expect(h1Raw['k']).to.include('true'); + expect(h1Raw['l']).to.include('false'); + expect(h1Raw['m']).to.include('test'); + expect(h1Raw['n']).to.include('1,2'); + expect(h1Raw['n']).to.include('3,4'); - expect(h1._headers['g']).to.be.undefined; - expect(h1._headers['h']).to.be.undefined; - expect(h1._headers['i']).to.be.undefined; - expect(h1._headers['j']).to.be.undefined; - expect(h1._headers['k']).to.be.undefined; - expect(h1._headers['l']).to.be.undefined; - expect(h1._headers['m']).to.be.undefined; - - expect(h1._headers['z']).to.be.undefined; + expect(h1Raw['z']).to.be.undefined; }); it('should wrap headers', function() { - var h1 = new Headers({ + const h1 = new Headers({ a: '1' }); + const h1Raw = h1.raw(); - var h2 = new Headers(h1); + const h2 = new Headers(h1); h2.set('b', '1'); + const h2Raw = h2.raw(); - var h3 = new Headers(h2); + const h3 = new Headers(h2); h3.append('a', '2'); + const h3Raw = h3.raw(); - expect(h1._headers['a']).to.include('1'); - expect(h1._headers['a']).to.not.include('2'); + expect(h1Raw['a']).to.include('1'); + expect(h1Raw['a']).to.not.include('2'); - expect(h2._headers['a']).to.include('1'); - expect(h2._headers['a']).to.not.include('2'); - expect(h2._headers['b']).to.include('1'); + expect(h2Raw['a']).to.include('1'); + expect(h2Raw['a']).to.not.include('2'); + expect(h2Raw['b']).to.include('1'); - expect(h3._headers['a']).to.include('1'); - expect(h3._headers['a']).to.include('2'); - expect(h3._headers['b']).to.include('1'); + expect(h3Raw['a']).to.include('1'); + expect(h3Raw['a']).to.include('2'); + expect(h3Raw['b']).to.include('1'); + }); + + it('should accept headers as an iterable of tuples', function() { + let headers; + + headers = new Headers([ + ['a', '1'], + ['b', '2'], + ['a', '3'] + ]); + expect(headers.getAll('a')).to.deep.equal(['1', '3']); + expect(headers.getAll('b')).to.deep.equal(['2']); + + headers = new Headers([ + new Set(['a', '1']), + ['b', '2'], + new Map([['a', null], ['3', null]]).keys() + ]); + expect(headers.getAll('a')).to.deep.equal(['1', '3']); + expect(headers.getAll('b')).to.deep.equal(['2']); + + headers = new Headers(new Map([ + ['a', '1'], + ['b', '2'] + ])); + expect(headers.getAll('a')).to.deep.equal(['1']); + expect(headers.getAll('b')).to.deep.equal(['2']); + }); + + it('should throw a TypeError if non-tuple exists in a headers initializer', function() { + expect(() => new Headers([ ['b', '2', 'huh?'] ])).to.throw(TypeError); + expect(() => new Headers([ 'b2' ])).to.throw(TypeError); + expect(() => new Headers('b2')).to.throw(TypeError); }); it('should support fetch with Request instance', function() { - url = base + '/hello'; - var req = new Request(url); - return fetch(req).then(function(res) { + url = `${base}hello`; + const req = new Request(url); + return fetch(req).then(res => { expect(res.url).to.equal(url); expect(res.ok).to.be.true; expect(res.status).to.equal(200); }); }); - it('should support wrapping Request instance', function() { - url = base + '/hello'; + it('should support fetch with Node.js URL object', function() { + url = `${base}hello`; + const urlObj = parseURL(url); + const req = new Request(urlObj); + return fetch(req).then(res => { + expect(res.url).to.equal(url); + expect(res.ok).to.be.true; + expect(res.status).to.equal(200); + }); + }); - var form = new FormData(); + (URL ? it : it.skip)('should support fetch with WHATWG URL object', function() { + url = `${base}hello`; + const urlObj = new URL(url); + const req = new Request(urlObj); + return fetch(req).then(res => { + expect(res.url).to.equal(url); + expect(res.ok).to.be.true; + expect(res.status).to.equal(200); + }); + }); + + it('should support blob round-trip', function() { + url = `${base}hello`; + + let length, type; + + return fetch(url).then(res => res.blob()).then(blob => { + url = `${base}inspect`; + length = blob.size; + type = blob.type; + return fetch(url, { + method: 'POST', + body: blob + }); + }).then(res => res.json()).then(({body, headers}) => { + expect(body).to.equal('world'); + expect(headers['content-type']).to.equal(type); + expect(headers['content-length']).to.equal(String(length)); + }); + }); + + it('should support wrapping Request instance', function() { + url = `${base}hello`; + + const form = new FormData(); form.append('a', '1'); - var r1 = new Request(url, { + const r1 = new Request(url, { method: 'POST' , follow: 1 , body: form }); - var r2 = new Request(r1, { + const r2 = new Request(r1, { follow: 2 }); @@ -1259,8 +1546,8 @@ describe('node-fetch', function() { }); it('should support overwrite Request instance', function() { - url = base + '/inspect'; - var req = new Request(url, { + url = `${base}inspect`; + const req = new Request(url, { method: 'POST' , headers: { a: '1' @@ -1271,25 +1558,36 @@ describe('node-fetch', function() { , headers: { a: '2' } - }).then(function(res) { + }).then(res => { return res.json(); - }).then(function(body) { + }).then(body => { expect(body.method).to.equal('GET'); expect(body.headers.a).to.equal('2'); }); }); + it('should throw error with GET/HEAD requests with body', function() { + expect(() => new Request('.', { body: '' })) + .to.throw(TypeError); + expect(() => new Request('.', { body: 'a' })) + .to.throw(TypeError); + expect(() => new Request('.', { body: '', method: 'HEAD' })) + .to.throw(TypeError); + expect(() => new Request('.', { body: 'a', method: 'HEAD' })) + .to.throw(TypeError); + }); + it('should support empty options in Response constructor', function() { - var body = resumer().queue('a=1').end(); + let body = resumer().queue('a=1').end(); body = body.pipe(new stream.PassThrough()); - var res = new Response(body); - return res.text().then(function(result) { + const res = new Response(body); + return res.text().then(result => { expect(result).to.equal('a=1'); }); }); it('should support parsing headers in Response constructor', function() { - var res = new Response(null, { + const res = new Response(null, { headers: { a: '1' } @@ -1298,30 +1596,50 @@ describe('node-fetch', function() { }); it('should support text() method in Response constructor', function() { - var res = new Response('a=1'); - return res.text().then(function(result) { + const res = new Response('a=1'); + return res.text().then(result => { expect(result).to.equal('a=1'); }); }); it('should support json() method in Response constructor', function() { - var res = new Response('{"a":1}'); - return res.json().then(function(result) { + const res = new Response('{"a":1}'); + return res.json().then(result => { expect(result.a).to.equal(1); }); }); it('should support buffer() method in Response constructor', function() { - var res = new Response('a=1'); - return res.buffer().then(function(result) { + const res = new Response('a=1'); + return res.buffer().then(result => { expect(result.toString()).to.equal('a=1'); }); }); + it('should support blob() method in Response constructor', function() { + const res = new Response('a=1', { + method: 'POST', + headers: { + 'Content-Type': 'text/plain' + } + }); + return res.blob().then(function(result) { + expect(result).to.be.an.instanceOf(Blob); + expect(result.isClosed).to.be.false; + expect(result.size).to.equal(3); + expect(result.type).to.equal('text/plain'); + + result.close(); + expect(result.isClosed).to.be.true; + expect(result.size).to.equal(0); + expect(result.type).to.equal('text/plain'); + }); + }); + it('should support clone() method in Response constructor', function() { - var body = resumer().queue('a=1').end(); + let body = resumer().queue('a=1').end(); body = body.pipe(new stream.PassThrough()); - var res = new Response(body, { + const res = new Response(body, { headers: { a: '1' } @@ -1329,7 +1647,7 @@ describe('node-fetch', function() { , status: 346 , statusText: 'production' }); - var cl = res.clone(); + const cl = res.clone(); expect(cl.headers.get('a')).to.equal('1'); expect(cl.url).to.equal(base); expect(cl.status).to.equal(346); @@ -1337,42 +1655,62 @@ describe('node-fetch', function() { expect(cl.ok).to.be.false; // clone body shouldn't be the same body expect(cl.body).to.not.equal(body); - return cl.text().then(function(result) { + return cl.text().then(result => { expect(result).to.equal('a=1'); }); }); it('should support stream as body in Response constructor', function() { - var body = resumer().queue('a=1').end(); + let body = resumer().queue('a=1').end(); body = body.pipe(new stream.PassThrough()); - var res = new Response(body); - return res.text().then(function(result) { + const res = new Response(body); + return res.text().then(result => { expect(result).to.equal('a=1'); }); }); it('should support string as body in Response constructor', function() { - var res = new Response('a=1'); - return res.text().then(function(result) { + const res = new Response('a=1'); + return res.text().then(result => { expect(result).to.equal('a=1'); }); }); it('should support buffer as body in Response constructor', function() { - var res = new Response(new Buffer('a=1')); - return res.text().then(function(result) { + const res = new Response(new Buffer('a=1')); + return res.text().then(result => { expect(result).to.equal('a=1'); }); }); + it('should support blob as body in Response constructor', function() { + const res = new Response(new Blob(['a=1'])); + return res.text().then(result => { + expect(result).to.equal('a=1'); + }); + }); + + it('should default to null as body', function() { + const res = new Response(); + expect(res.body).to.equal(null); + const req = new Request('.'); + expect(req.body).to.equal(null); + + const cb = result => expect(result).to.equal(''); + return Promise.all([ + res.text().then(cb), + req.text().then(cb) + ]); + }); + it('should default to 200 as status code', function() { - var res = new Response(null); + const res = new Response(null); expect(res.status).to.equal(200); }); it('should support parsing headers in Request constructor', function() { url = base; - var req = new Request(url, { + const req = new Request(url, { headers: { a: '1' } @@ -1381,52 +1719,89 @@ describe('node-fetch', function() { expect(req.headers.get('a')).to.equal('1'); }); - it('should support text() method in Request constructor', function() { + it('should support arrayBuffer() method in Request constructor', function() { url = base; var req = new Request(url, { + method: 'POST', body: 'a=1' }); expect(req.url).to.equal(url); - return req.text().then(function(result) { + return req.arrayBuffer().then(function(result) { + expect(result).to.be.an.instanceOf(ArrayBuffer); + const str = String.fromCharCode.apply(null, new Uint8Array(result)); + expect(str).to.equal('a=1'); + }); + }); + + it('should support text() method in Request constructor', function() { + url = base; + const req = new Request(url, { + method: 'POST', + body: 'a=1' + }); + expect(req.url).to.equal(url); + return req.text().then(result => { expect(result).to.equal('a=1'); }); }); it('should support json() method in Request constructor', function() { url = base; - var req = new Request(url, { + const req = new Request(url, { + method: 'POST', body: '{"a":1}' }); expect(req.url).to.equal(url); - return req.json().then(function(result) { + return req.json().then(result => { expect(result.a).to.equal(1); }); }); it('should support buffer() method in Request constructor', function() { url = base; - var req = new Request(url, { + const req = new Request(url, { + method: 'POST', body: 'a=1' }); expect(req.url).to.equal(url); - return req.buffer().then(function(result) { + return req.buffer().then(result => { expect(result.toString()).to.equal('a=1'); }); }); + it('should support blob() method in Request constructor', function() { + url = base; + var req = new Request(url, { + method: 'POST', + body: new Buffer('a=1') + }); + expect(req.url).to.equal(url); + return req.blob().then(function(result) { + expect(result).to.be.an.instanceOf(Blob); + expect(result.isClosed).to.be.false; + expect(result.size).to.equal(3); + expect(result.type).to.equal(''); + + result.close(); + expect(result.isClosed).to.be.true; + expect(result.size).to.equal(0); + expect(result.type).to.equal(''); + }); + }); + it('should support arbitrary url in Request constructor', function() { url = 'anything'; - var req = new Request(url); + const req = new Request(url); expect(req.url).to.equal('anything'); }); it('should support clone() method in Request constructor', function() { url = base; - var body = resumer().queue('a=1').end(); + let body = resumer().queue('a=1').end(); body = body.pipe(new stream.PassThrough()); - var agent = new http.Agent(); - var req = new Request(url, { - body: body + const agent = new http.Agent(); + const req = new Request(url, { + body , method: 'POST' , redirect: 'manual' , headers: { @@ -1434,9 +1809,9 @@ describe('node-fetch', function() { } , follow: 3 , compress: false - , agent: agent + , agent }); - var cl = req.clone(); + const cl = req.clone(); expect(cl.url).to.equal(url); expect(cl.method).to.equal('POST'); expect(cl.redirect).to.equal('manual'); @@ -1448,24 +1823,26 @@ describe('node-fetch', function() { expect(cl.agent).to.equal(agent); // clone body shouldn't be the same body expect(cl.body).to.not.equal(body); - return fetch.Promise.all([cl.text(), req.text()]).then(function(results) { + return fetch.Promise.all([cl.text(), req.text()]).then(results => { expect(results[0]).to.equal('a=1'); expect(results[1]).to.equal('a=1'); }); }); - it('should support text(), json() and buffer() method in Body constructor', function() { - var body = new Body('a=1'); + it('should support arrayBuffer(), blob(), text(), json() and buffer() method in Body constructor', function() { + const body = new Body('a=1'); + expect(body).to.have.property('arrayBuffer'); + expect(body).to.have.property('blob'); expect(body).to.have.property('text'); expect(body).to.have.property('json'); expect(body).to.have.property('buffer'); }); - it('should create custom FetchError', function() { - var systemError = new Error('system'); + it('should create custom FetchError', function funcName() { + const systemError = new Error('system'); systemError.code = 'ESOMEERROR'; - var err = new FetchError('test message', 'test-error', systemError); + const err = new FetchError('test message', 'test-error', systemError); expect(err).to.be.an.instanceof(Error); expect(err).to.be.an.instanceof(FetchError); expect(err.name).to.equal('FetchError'); @@ -1473,6 +1850,8 @@ describe('node-fetch', function() { expect(err.type).to.equal('test-error'); expect(err.code).to.equal('ESOMEERROR'); expect(err.errno).to.equal('ESOMEERROR'); + expect(err.stack).to.include('funcName') + .and.to.startWith(`${err.name}: ${err.message}`); }); it('should support https request', function() { @@ -1481,10 +1860,24 @@ describe('node-fetch', function() { opts = { method: 'HEAD' }; - return fetch(url, opts).then(function(res) { + return fetch(url, opts).then(res => { expect(res.status).to.equal(200); expect(res.ok).to.be.true; }); }); }); + +}); + +function streamToPromise(stream, dataHandler) { + return new Promise((resolve, reject) => { + stream.on('data', (...args) => { + Promise.resolve() + .then(() => dataHandler(...args)) + .catch(reject); + }); + stream.on('end', resolve); + stream.on('error', reject); + }); +}