├── .gitignore ├── .prettierrc ├── .editorconfig ├── .travis.yml ├── tests ├── tests.js ├── test-utf8.js ├── test-url.js ├── test-track.js ├── test-bust.js ├── test-compression.js ├── test-node.js ├── test-retry.js ├── test-mock.js ├── test-io.js ├── server.js └── test-stream.js ├── package.json ├── main.js ├── stream.js ├── README.md └── node.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 160, 3 | "singleQuote": true, 4 | "bracketSpacing": false, 5 | "arrowParens": "avoid", 6 | "trailingComma": "none" 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: node_js 4 | 5 | node_js: 6 | - "8" 7 | - "10" 8 | - "12" 9 | - "14" 10 | 11 | before_script: 12 | - node tests/server.js & 13 | - sleep 5 14 | 15 | script: npm test 16 | -------------------------------------------------------------------------------- /tests/tests.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./test-io'); 4 | require('./test-bust'); 5 | require('./test-track'); 6 | require('./test-mock'); 7 | require('./test-retry'); 8 | require('./test-url'); 9 | require('./test-compression'); 10 | require('./test-node'); 11 | require('./test-stream'); 12 | require('./test-utf8'); 13 | 14 | var unit = require('heya-unit'); 15 | 16 | unit.run(); 17 | -------------------------------------------------------------------------------- /tests/test-utf8.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const unit = require('heya-unit'); 4 | const io = require('../main'); 5 | 6 | unit.add(module, [ 7 | function test_utf8(t) { 8 | const x = t.startAsync(); 9 | const pattern = '一鸟在手胜过双鸟在林。', 10 | repeat = 100000; 11 | io({ 12 | url: 'http://localhost:3000/api', 13 | query: {pattern, repeat} 14 | }).then(function (data) { 15 | eval(t.TEST('data.length === pattern.length * repeat')); 16 | x.done(); 17 | }); 18 | } 19 | ]); 20 | -------------------------------------------------------------------------------- /tests/test-url.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This module tests only `url` module of `heya-io`. 4 | // It is there because Node's environment guarantees ES6 features 5 | // required to test tagged template literals. 6 | 7 | var unit = require('heya-unit'); 8 | 9 | const url = require('heya-io/url'); 10 | 11 | 12 | unit.add(module, [ 13 | function test_url (t) { 14 | eval(t.TEST('typeof url == "function"')); 15 | eval(t.TEST('url`/api/22?q=1` === "/api/22?q=1"')); 16 | 17 | const id = 22, q = 1; 18 | eval(t.TEST('url`/api/${id}?q=${q}` === "/api/22?q=1"')); 19 | 20 | const client = 'Bob & Jordan & Co'; 21 | eval(t.TEST('url`/api/${client}/details` === "/api/Bob%20%26%20Jordan%20%26%20Co/details"')); 22 | } 23 | ]); 24 | -------------------------------------------------------------------------------- /tests/test-track.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var unit = require('heya-unit'); 4 | var io = require('../main'); 5 | 6 | require('heya-io/track'); 7 | 8 | 9 | unit.add(module, [ 10 | function test_setup () { 11 | io.track.attach(); 12 | }, 13 | function test_exist (t) { 14 | eval(t.TEST('typeof io.track == "object"')); 15 | }, 16 | function test_dedupe (t) { 17 | var x = t.startAsync(); 18 | Promise.all([ 19 | io.get('http://localhost:3000/api'), 20 | io.get('http://localhost:3000/api') 21 | ]).then(function (results) { 22 | eval(t.TEST('results.length === 2')); 23 | eval(t.TEST('results[0].counter === results[1].counter')); 24 | x.done(); 25 | }); 26 | }, 27 | function test_no_dedupe (t) { 28 | var x = t.startAsync(), counter; 29 | io.get('http://localhost:3000/api').then(function (value) { 30 | counter = value.counter; 31 | return io.get('http://localhost:3000/api'); 32 | }).then(function (value) { 33 | eval(t.TEST('counter !== value.counter')); 34 | x.done(); 35 | }); 36 | }, 37 | function test_teardown () { 38 | io.track.detach(); 39 | } 40 | ]); 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "heya-io-node", 3 | "version": "1.3.0", 4 | "description": "Intelligent I/O for Node", 5 | "main": "main.js", 6 | "directories": { 7 | "test": "tests" 8 | }, 9 | "scripts": { 10 | "start": "node tests/server.js", 11 | "test": "node tests/tests.js" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git://github.com/heya/io-node.git" 16 | }, 17 | "keywords": [ 18 | "I/O", 19 | "XHR", 20 | "IO", 21 | "fetch" 22 | ], 23 | "author": "Eugene Lazutkin (http://www.lazutkin.com/)", 24 | "license": "BSD-3-Clause", 25 | "bugs": { 26 | "url": "https://github.com/heya/io-node/issues" 27 | }, 28 | "homepage": "https://github.com/heya/io-node#readme", 29 | "devDependencies": { 30 | "body-parser": "^1.19.0", 31 | "compression": "^1.7.4", 32 | "express": "^4.17.1", 33 | "heya-async": "^1.0.1", 34 | "heya-bundler": "^1.1.3", 35 | "heya-unit": "^0.3.0" 36 | }, 37 | "dependencies": { 38 | "heya-io": "^1.9.3" 39 | }, 40 | "files": [ 41 | "/*.js", 42 | "/utils" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {Readable} = require('stream'); 4 | const {IncomingMessage} = require('http'); 5 | 6 | const io = require('./node'); 7 | 8 | // replace errors 9 | class FailedIO extends Error { 10 | constructor(xhr, options, event, message = 'Failed I/O') { 11 | super(message); 12 | this.name = this.constructor.name; 13 | Error.captureStackTrace(this, this.constructor); 14 | this.xhr = xhr; 15 | this.options = options; 16 | this.event = event; 17 | } 18 | getData() { 19 | return io.getData(this.xhr); 20 | } 21 | getHeaders() { 22 | return io.getHeaders(this.xhr); 23 | } 24 | } 25 | io.FailedIO = FailedIO; 26 | class TimedOut extends FailedIO { 27 | constructor(xhr, options, event) { 28 | super(xhr, options, event, 'Timed out I/O'); 29 | } 30 | } 31 | io.TimedOut = TimedOut; 32 | class BadStatus extends FailedIO { 33 | constructor(xhr, options, event) { 34 | super(xhr, options, event, 'Bad status I/O' + (xhr && xhr.status ? ': ' + xhr.status : '')); 35 | } 36 | } 37 | io.BadStatus = BadStatus; 38 | 39 | io.node.attach(); 40 | 41 | const passThrough = (_1, _2, data) => new io.Ignore(data); 42 | 43 | io.dataProcessors.push(Readable, passThrough, Buffer, passThrough, IncomingMessage, passThrough); 44 | 45 | module.exports = io; 46 | -------------------------------------------------------------------------------- /tests/test-bust.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var unit = require('heya-unit'); 4 | var io = require('../main'); 5 | 6 | require('heya-io/bust'); 7 | 8 | 9 | unit.add(module, [ 10 | function test_exist (t) { 11 | eval(t.TEST('typeof io.bustKey == "string"')); 12 | eval(t.TEST('typeof io.generateTimestamp == "function"')); 13 | }, 14 | function test_bust_query (t) { 15 | var x = t.startAsync(); 16 | io.get({url: 'http://localhost:3000/api', bust: true}).then(function (data) { 17 | eval(t.TEST('data.query["io-bust"]')); 18 | x.done(); 19 | }); 20 | }, 21 | function test_custom_bust (t) { 22 | var x = t.startAsync(); 23 | io.get({url: 'http://localhost:3000/api', bust: 'buster'}).then(function (data) { 24 | eval(t.TEST('data.query.buster')); 25 | x.done(); 26 | }); 27 | }, 28 | function test_two_bust_values (t) { 29 | var x = t.startAsync(); 30 | Promise.all([ 31 | io.get({url: 'http://localhost:3000/api', bust: true}), 32 | io.get({url: 'http://localhost:3000/api', bust: true}) 33 | ]).then(function (results) { 34 | eval(t.TEST('results[0].query["io-bust"]')); 35 | eval(t.TEST('results[1].query["io-bust"]')); 36 | eval(t.TEST('results[0].query["io-bust"] !== results[1].query["io-bust"]')); 37 | x.done(); 38 | }); 39 | } 40 | ]); 41 | -------------------------------------------------------------------------------- /tests/test-compression.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const unit = require('heya-unit'); 4 | const io = require('../main'); 5 | 6 | const alphabet = 'abcdefghijklmnopqrstuvwxyz'; 7 | 8 | const rep = (str, n) => { 9 | let buffer = '', t = str; 10 | while (n > 0) { 11 | if (n & 1) { 12 | buffer += t; 13 | } 14 | n >>= 1; 15 | if (n) { 16 | t += t; 17 | } 18 | } 19 | return buffer; 20 | }; 21 | 22 | unit.add(module, [ 23 | function test_compression_get (t) { 24 | const x = t.startAsync(); 25 | io.get({ 26 | url: 'http://localhost:3000/alpha', 27 | returnXHR: true 28 | }, {n: 100}).then(xhr => { 29 | eval(t.TEST('xhr.getResponseHeader("Content-Encoding")')); 30 | eval(t.TEST('xhr.responseText.length === 2600')); 31 | for (let i = 0; i < xhr.responseText.length; i += 26) { 32 | eval(t.TEST('xhr.responseText.substr(i, 26) === alphabet')); 33 | } 34 | x.done(); 35 | }); 36 | }, 37 | function test_compression_post (t) { 38 | const x = t.startAsync(); 39 | io.post({ 40 | url: 'http://localhost:3000/alpha', 41 | headers: { 42 | 'Content-Type': 'plain/text' 43 | } 44 | }, rep(alphabet, 100)).then(data => { 45 | eval(t.TEST('data.n === 2600')); 46 | eval(t.TEST('data.verified')); 47 | x.done(); 48 | }); 49 | }, 50 | function test_compression_force (t) { 51 | const x = t.startAsync(); 52 | io.post({ 53 | url: 'http://localhost:3000/alpha', 54 | headers: { 55 | 'Content-Type': 'plain/text', 56 | '$-Content-Encoding': 'gzip' 57 | } 58 | }, rep(alphabet, 100)).then(data => { 59 | eval(t.TEST('data.n === 2600')); 60 | eval(t.TEST('data.verified')); 61 | x.done(); 62 | }); 63 | } 64 | ]); 65 | -------------------------------------------------------------------------------- /stream.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {Duplex, PassThrough} = require('stream'); 4 | 5 | const io = require('./main'); 6 | 7 | const requestHasNoBody = {GET: 1, HEAD: 1, OPTIONS: 1, DELETE: 1}, 8 | responseHasNoBody = {HEAD: 1, OPTIONS: 1}; 9 | 10 | class IO extends Duplex { 11 | constructor(options, streamOptions) { 12 | super(streamOptions); 13 | this.meta = null; 14 | 15 | if (typeof options == 'string') { 16 | options = {url: options, method: 'GET'}; 17 | } else { 18 | options = Object.create(options); 19 | options.method = (options.method && options.method.toUpperCase()) || 'GET'; 20 | } 21 | options.responseType = '$tream'; 22 | options.returnXHR = true; 23 | 24 | if (requestHasNoBody[options.method] === 1 || 'data' in options) { 25 | this._write = (_1, _2, callback) => callback(null); 26 | this._final = callback => callback(null); // unavailable in Node 6 27 | } else { 28 | this.input = options.data = new PassThrough(); 29 | // this.on('finish', () => this.input.end(null, null)); // for Node 6 30 | } 31 | 32 | io(options) 33 | .then(xhr => { 34 | this.meta = xhr; 35 | this.output = xhr.response; 36 | this.output.on('data', chunk => !this.push(chunk) && this.output.pause()); 37 | this.output.on('end', () => this.push(null)); 38 | if (responseHasNoBody[options.method] === 1) { 39 | this.resume(); 40 | } 41 | }) 42 | .catch(e => this.emit('error', e)); 43 | } 44 | getData() { 45 | return io.getData(this.meta); 46 | } 47 | getHeaders() { 48 | return io.getHeaders(this.meta); 49 | } 50 | _write(chunk, encoding, callback) { 51 | let error = null; 52 | try { 53 | this.input.write(chunk, encoding, e => callback(e || error)); 54 | } catch (e) { 55 | error = e; 56 | } 57 | } 58 | _final(callback) { // unavailable in Node 6 59 | let error = null; 60 | try { 61 | this.input.end(null, null, e => callback(e || error)); 62 | } catch (e) { 63 | error = e; 64 | } 65 | } 66 | _read() { 67 | this.output && this.output.resume(); 68 | } 69 | } 70 | 71 | const mod = {IO}; 72 | 73 | const makeVerb = verb => (url, data) => { 74 | const options = typeof url == 'string' ? {url: url} : Object.create(url); 75 | options.method = verb; 76 | if (data) { 77 | options.data = data; 78 | } 79 | return new IO(options); 80 | }; 81 | 82 | ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].forEach(verb => { 83 | mod[verb.toLowerCase()] = makeVerb(verb); 84 | }); 85 | mod.del = mod.remove = mod['delete']; // alias for simplicity 86 | 87 | module.exports = mod; 88 | -------------------------------------------------------------------------------- /tests/test-node.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {PassThrough} = require('stream'); 4 | 5 | const unit = require('heya-unit'); 6 | const io = require('../main'); 7 | 8 | 9 | const alphabet = 'abcdefghijklmnopqrstuvwxyz'; 10 | 11 | unit.add(module, [ 12 | function test_node_$tream (t) { 13 | const x = t.startAsync(); 14 | io.get({ 15 | url: 'http://localhost:3000/alpha', 16 | responseType: '$tream' 17 | }, {n: 100}).then(res => { 18 | let buffer = null; 19 | res.on('data', chunk => (buffer === null ? (buffer = chunk) : (buffer += chunk))); 20 | res.on('end', () => { 21 | eval(t.TEST('buffer.length === 2600')); 22 | for (let i = 0; i < buffer.length; i += 26) { 23 | eval(t.TEST('buffer.toString("utf8", i, i + 26) === alphabet')); 24 | } 25 | x.done(); 26 | }); 27 | }); 28 | }, 29 | function test_node_fromStream (t) { 30 | const x = t.startAsync(), dataStream = new PassThrough(); 31 | io.post({ 32 | url: 'http://localhost:3000/api', 33 | headers: { 34 | 'Content-Type': 'application/json' 35 | } 36 | }, dataStream).then(data => { 37 | eval(t.TEST('data.method === "POST"')); 38 | eval(t.TEST('data.body === "{\\"a\\":1}"')); 39 | x.done(); 40 | }); 41 | dataStream.end(JSON.stringify({a: 1})); 42 | }, 43 | function test_node_forceCompression (t) { 44 | const x = t.startAsync(), dataStream = new PassThrough(); 45 | io.post({ 46 | url: 'http://localhost:3000/alpha', 47 | headers: { 48 | 'Content-Type': 'plain/text', 49 | '$-Content-Encoding': 'gzip' 50 | } 51 | }, dataStream).then(data => { 52 | eval(t.TEST('data.n === 2626')); 53 | eval(t.TEST('data.verified')); 54 | x.done(); 55 | }); 56 | for (let i = 0; i < 100; ++i) dataStream.write(alphabet); 57 | dataStream.end(alphabet); 58 | }, 59 | function test_node_buffer(t) { 60 | const x = t.startAsync(); 61 | io.post({ 62 | url: 'http://localhost:3000/api', 63 | headers: { 64 | 'Content-Type': 'application/json' 65 | } 66 | }, Buffer.from(JSON.stringify({a: 1}))).then(data => { 67 | eval(t.TEST('data.method === "POST"')); 68 | eval(t.TEST('data.headers["content-type"] === "application/json"')); 69 | eval(t.TEST('data.body === "{\\"a\\":1}"')); 70 | x.done(); 71 | }); 72 | }, 73 | function test_node_redirect (t) { 74 | const x = t.startAsync(), dataStream = new PassThrough(); 75 | io.post({ 76 | url: 'http://localhost:3000/redirect?to=/api', 77 | headers: { 78 | 'Content-Type': 'application/json' 79 | } 80 | }, dataStream).then(data => { 81 | // console.log(data); 82 | eval(t.TEST('data.method === "POST"')); 83 | eval(t.TEST('data.body === "{\\"a\\":1}"')); 84 | x.done(); 85 | }); 86 | dataStream.end(JSON.stringify({a: 1})); 87 | } 88 | ]); 89 | -------------------------------------------------------------------------------- /tests/test-retry.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var unit = require('heya-unit'); 4 | var io = require('../main'); 5 | 6 | require('heya-io/retry'); 7 | 8 | unit.add(module, [ 9 | function test_setup () { 10 | io.retry.attach(); 11 | }, 12 | function test_exist (t) { 13 | eval(t.TEST('typeof io.retry == "object"')); 14 | }, 15 | function test_no_retry (t) { 16 | var x = t.startAsync(); 17 | io('http://localhost:3000/api').then(function (data) { 18 | eval(t.TEST('data.method === "GET"')); 19 | x.done(); 20 | }); 21 | }, 22 | function test_retry_success (t) { 23 | var x = t.startAsync(); 24 | io({ 25 | url: 'http://localhost:3000/api', 26 | retries: 3 27 | }).then(function (data) { 28 | eval(t.TEST('data.method === "GET"')); 29 | x.done(); 30 | }); 31 | }, 32 | function test_retry_failure (t) { 33 | var x = t.startAsync(); 34 | io({ 35 | url: 'http://localhost:3000/xxx', // doesn't exist 36 | retries: 3 37 | }).catch(function (error) { 38 | eval(t.TEST('error.xhr.status === 404')); 39 | x.done(); 40 | }); 41 | }, 42 | function test_cond_retry_counter (t) { 43 | var x = t.startAsync(), counter = 0; 44 | io({ 45 | url: 'http://localhost:3000/xxx', // doesn't exist 46 | retries: 3, 47 | continueRetries: function () { ++counter; return true; } 48 | }).catch(function (error) { 49 | eval(t.TEST('error.xhr.status === 404')); 50 | eval(t.TEST('counter === 3')); 51 | x.done(); 52 | }); 53 | }, 54 | function test_cond_retry_counter_term_by_func (t) { 55 | var x = t.startAsync(), counter = 0; 56 | io({ 57 | url: 'http://localhost:3000/xxx', // doesn't exist 58 | retries: 5, 59 | continueRetries: function (result, retries) { ++counter; return retries < 2; } 60 | }).catch(function (error) { 61 | eval(t.TEST('error.xhr.status === 404')); 62 | eval(t.TEST('counter === 2')); 63 | x.done(); 64 | }); 65 | }, 66 | function test_cond_retry_failure (t) { 67 | var x = t.startAsync(), counter = 0; 68 | io({ 69 | url: 'http://localhost:3000/xxx', // doesn't exist 70 | retries: 0, 71 | continueRetries: function (result, retries) { ++counter; return retries < 2; } 72 | }).catch(function (error) { 73 | eval(t.TEST('error.xhr.status === 404')); 74 | eval(t.TEST('counter === 2')); 75 | x.done(); 76 | }); 77 | }, 78 | function test_retry_local (t) { 79 | var x = t.startAsync(), counter = 0; 80 | io({ 81 | url: 'http://localhost:3000/xxx', // doesn't exist 82 | retries: 3, 83 | initDelay: 20, 84 | nextDelay: function (delay) { ++counter; return 2 * delay; } 85 | }).catch(function (error) { 86 | eval(t.TEST('error.xhr.status === 404')); 87 | eval(t.TEST('counter === 3')); 88 | x.done(); 89 | }); 90 | }, 91 | function test_teardown () { 92 | io.retry.detach(); 93 | } 94 | ]); 95 | -------------------------------------------------------------------------------- /tests/test-mock.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var unit = require('heya-unit'); 4 | var io = require('../main'); 5 | 6 | var timeout = require('heya-async/timeout'); 7 | 8 | require('heya-io/mock'); 9 | 10 | 11 | unit.add(module, [ 12 | function test_setup () { 13 | io.mock.attach(); 14 | }, 15 | function test_exist (t) { 16 | eval(t.TEST('typeof io.mock == "function"')); 17 | }, 18 | { 19 | test: function test_exact (t) { 20 | var x = t.startAsync(); 21 | io.mock('http://localhost:3000/a', function (options) { 22 | var verb = options.method || 'GET'; 23 | t.info('mock callback: ' + verb); 24 | return verb; 25 | }); 26 | io.get('http://localhost:3000/a').then(function (value) { 27 | t.info('got ' + value); 28 | return io.patch('http://localhost:3000/a', null); 29 | }).then(function (value) { 30 | t.info('got ' + value); 31 | io.mock('http://localhost:3000/a', null); 32 | return io.get('http://localhost:3000/a'); 33 | }).then(function () { 34 | t.info('shouldn\'t be here!'); 35 | x.done(); 36 | }, function () { 37 | t.info('error'); 38 | x.done(); 39 | }); 40 | }, 41 | logs: [ 42 | 'mock callback: GET', 43 | 'got GET', 44 | 'mock callback: PATCH', 45 | 'got PATCH', 46 | 'error' 47 | ] 48 | }, 49 | { 50 | test: function test_prefix (t) { 51 | var x = t.startAsync(); 52 | io.mock('http://localhost:3000/aa*', function (options) { 53 | var value = 'aa' + (options.method || 'GET'); 54 | t.info('mock callback: ' + value); 55 | return value; 56 | }); 57 | io.mock('http://localhost:3000/a*', function (options) { 58 | var value = 'a' + (options.method || 'GET'); 59 | t.info('mock callback: ' + value); 60 | return value; 61 | }); 62 | io.mock('http://localhost:3000/aaa*', function (options) { 63 | var value = 'aaa' + (options.method || 'GET'); 64 | t.info('mock callback: ' + value); 65 | return value; 66 | }); 67 | io.get('http://localhost:3000/a/x').then(function (value) { 68 | t.info('got ' + value); 69 | return io.patch('http://localhost:3000/aa', null); 70 | }).then(function (value) { 71 | t.info('got ' + value); 72 | return io.put('http://localhost:3000/ab'); 73 | }).then(function (value) { 74 | t.info('got ' + value); 75 | return io.post('http://localhost:3000/aaa', {z: 1}); 76 | }).then(function (value) { 77 | t.info('got ' + value); 78 | io.mock('http://localhost:3000/a*', null); 79 | io.mock('http://localhost:3000/aa*', null); 80 | io.mock('http://localhost:3000/aaa*', null); 81 | return io.get('http://localhost:3000/aa'); 82 | }).then(function () { 83 | t.info('shouldn\'t be here!'); 84 | x.done(); 85 | }, function () { 86 | t.info('error'); 87 | x.done(); 88 | }); 89 | }, 90 | logs: [ 91 | 'mock callback: aGET', 92 | 'got aGET', 93 | 'mock callback: aaPATCH', 94 | 'got aaPATCH', 95 | 'mock callback: aPUT', 96 | 'got aPUT', 97 | 'mock callback: aaaPOST', 98 | 'got aaaPOST', 99 | 'error' 100 | ] 101 | }, 102 | { 103 | test: function test_xhr (t) { 104 | var x = t.startAsync(); 105 | io.mock('http://localhost:3000/a', function (options) { 106 | return io.mock.makeXHR({ 107 | status: options.data.status, 108 | headers: 'Content-Type: application/json', 109 | responseType: 'json', 110 | responseText: JSON.stringify(options.data) 111 | }); 112 | }); 113 | io.get('http://localhost:3000/a', {status: 200, payload: 1}).then(function (value) { 114 | t.info('payload ' + value.payload); 115 | return io.put('http://localhost:3000/a', {status: 501, payload: 2}); 116 | }).then(function () { 117 | t.info('shouldn\'t be here!'); 118 | x.done(); 119 | }, function (value) { 120 | io.mock('http://localhost:3000/a', null); 121 | t.info('payload ' + value.xhr.response.payload); 122 | t.info('error ' + value.xhr.status); 123 | x.done(); 124 | }); 125 | }, 126 | logs: [ 127 | 'payload 1', 128 | 'payload 2', 129 | 'error 501' 130 | ] 131 | }, 132 | function test_promise (t) { 133 | var x = t.startAsync(); 134 | io.mock('http://localhost:3000/a', function () { 135 | return io.get('http://localhost:3000/api'); 136 | }); 137 | io.get('http://localhost:3000/a').then(function (data) { 138 | eval(t.TEST('typeof data === "object"')); 139 | eval(t.TEST('data.method === "GET"')); 140 | io.mock('http://localhost:3000/a', null); 141 | x.done(); 142 | }); 143 | }, 144 | function test_timeout (t) { 145 | var x = t.startAsync(); 146 | io.mock('http://localhost:3000/a', function () { 147 | return timeout.resolve(20).then(function () { 148 | return 42; 149 | }); 150 | }); 151 | io.get('http://localhost:3000/a').then(function (data) { 152 | eval(t.TEST('data === 42')); 153 | io.mock('http://localhost:3000/a', null); 154 | x.done(); 155 | }); 156 | }, 157 | function test_priority (t) { 158 | var x = t.startAsync(); 159 | io.get('http://localhost:3000/api').then(function (data) { 160 | eval(t.TEST('typeof data === "object"')); 161 | eval(t.TEST('data.method === "GET"')); 162 | io.mock('http://localhost:3000/api', function () { return 42; }); 163 | return io.get('http://localhost:3000/api'); 164 | }).then(function (data) { 165 | eval(t.TEST('data === 42')); 166 | io.mock('http://localhost:3000/api', null); 167 | return io.get('http://localhost:3000/api'); 168 | }).then(function (data) { 169 | eval(t.TEST('typeof data === "object"')); 170 | eval(t.TEST('data.method === "GET"')); 171 | x.done(); 172 | }); 173 | }, 174 | function test_teardown () { 175 | io.mock.detach(); 176 | } 177 | ]); 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `io-node` 2 | 3 | 4 | [![Build status][travis-image]][travis-url] 5 | [![NPM version][npm-image]][npm-url] 6 | 7 | [![Greenkeeper][greenkeeper-image]][greenkeeper-url] 8 | [![Dependencies][deps-image]][deps-url] 9 | [![devDependencies][dev-deps-image]][dev-deps-url] 10 | 11 | 12 | This is a Node-specific transport for [heya-io](https://github.com/heya/io) based on built-in `http` and `https` modules. The main purpose of the module is to provide an ergonomic simple light-weight HTTP I/O on Node leveraging existing customization facilities of `heya-io` where appropriate. 13 | 14 | Following `heya-io` services are supported as is out of the box: 15 | 16 | * `io.track` — tracks I/O requests to eliminate duplicates, register an interest without initiating I/O requests, and much more. 17 | * `io.mock` — mocks responses to I/O requests without writing a special server courtesy of [Mike Wilcox](https://github.com/clubajax). Very useful for rapid prototyping and writing tests. 18 | * `io.bust` — a simple pseudo plugin to generate a randomized query value to bust cache. 19 | * `io.retry` — a flexible way to retry requests, e.g., to deal with unreliable servers or to watch for changing values. 20 | 21 | Additionally it supports: 22 | 23 | * Completely transparent compression/decompression. 24 | * `gzip` and `deflate` are supported out of the box with no extra dependencies using built-in modules. 25 | * More compressions can be easily plugged in. 26 | * [brotli](https://en.wikipedia.org/wiki/Brotli) is automatically supported if an underlying Node has it. 27 | * The compression is supported **both ways**. 28 | * Streaming. 29 | * Both streaming a server request and a server response are supported. 30 | 31 | # Examples 32 | 33 | Plain vanilla GET: 34 | 35 | ```js 36 | const io = require('heya-io-node'); 37 | 38 | io.get('http://example.com/hello').then(function (value) { 39 | console.log(value); 40 | }); 41 | 42 | io.get('/hello', {to: 'world', times: 5}).then(value => { 43 | // GET /hello?to=world×=5 44 | console.log(value); 45 | }); 46 | ``` 47 | 48 | Some other verbs ([REST](https://en.wikipedia.org/wiki/Representational_state_transfer) example): 49 | 50 | ```js 51 | function done() { console.log('done'); } 52 | 53 | io.post('/things', {name: 'Bob', age: 42}).then(done); 54 | io.put('/things/5', {name: 'Alice', age: 33}).then(done); 55 | io.patch('/things/7', {age: 14}).then(done); 56 | io.remove('/things/3').then(done); 57 | ``` 58 | 59 | Streaming (since 1.1.0) + encoding a payload (since 1.2.0): 60 | 61 | ```js 62 | const ios = require('heya-io-node/stream'); 63 | fs.createReadStream('sample.json') 64 | .pipe(ios.post({ 65 | url: 'https://example.com/analyze', 66 | headers: { 67 | 'Content-Type': 'application/json', 68 | '$-Content-Encoding': 'gzip', 69 | 'Accept: plain/text' 70 | } 71 | })) 72 | .pipe(process.stdout); 73 | 74 | // or it can be done more granularly: 75 | 76 | io.post({ 77 | url: 'https://example.com/analyze', 78 | headers: { 79 | 'Content-Type': 'application/json', 80 | '$-Content-Encoding': 'gzip', 81 | 'Accept: plain/text' 82 | }, 83 | responseType: '$tream' 84 | }, fs.createReadStream('sample.json')) 85 | .then(res => res.pipe(process.stdout)); 86 | ``` 87 | 88 | Mock in action: 89 | 90 | ```js 91 | // set up a mock handler 92 | io.mock('/a*', (options, prep) => { 93 | console.log('Got call: ' + options.method + ' ' + prep.url); 94 | return 42; 95 | }); 96 | 97 | // let's make a call 98 | io.get('/a/x').then(value => { 99 | console.log(value); // 42 100 | }); 101 | 102 | // set up a redirect /b => /a/b 103 | io.mock('/b', options => io.get('/a/b', options.query || options.data || null)); 104 | 105 | // let's make another call 106 | io.get('/b', {q: 1}).then(value => console.log(value)); // 42 107 | ``` 108 | 109 | Using `url` template to sanitize URLs (ES6): 110 | 111 | ```js 112 | const url = require('heya-io/url'); 113 | 114 | const client = 'Bob & Jordan & Co'; 115 | io.get(url`/api/${client}/details`).then(value => { 116 | // GET /api/Bob%20%26%20Jordan%20%26%20Co/details 117 | console.log(value); 118 | }); 119 | ``` 120 | 121 | See more examples in [heya-io's Wiki](https://github.com/heya/io/wiki/), [heya-io-node's Wiki](https://github.com/heya/io-node/wiki/), and the cookbooks of `heya-io`: 122 | 123 | * [Cookbook: main](https://github.com/heya/io/wiki/Cookbook:-main) 124 | * Services: 125 | * [Cookbook: bust](https://github.com/heya/io/wiki/Cookbook:-bust) 126 | * [Cookbook: mock](https://github.com/heya/io/wiki/Cookbook:-mock) 127 | * [Cookbook: track](https://github.com/heya/io/wiki/Cookbook:-track) 128 | * [Cookbook: retry](https://github.com/heya/io/wiki/Cookbook:-retry) 129 | 130 | # How to install 131 | 132 | ```bash 133 | npm install --save heya-io-node 134 | # or: yarn add heya-io-node 135 | ``` 136 | 137 | # Documentation 138 | 139 | All documentation can be found in [project's wiki](https://github.com/heya/io-node/wiki). 140 | 141 | # Working on this project 142 | 143 | In order to run tests locally, you should start the test server first: 144 | 145 | ```bash 146 | npm start 147 | ``` 148 | 149 | Then (likely in a different command line window) run tests: 150 | 151 | ```bash 152 | npm test 153 | ``` 154 | 155 | The server runs indefinitely, and can be stopped by Ctrl+C. 156 | 157 | # Versions 158 | 159 | - 1.3.0 *Replaced obsolete `url` module with WHATWG URL.* 160 | - 1.2.0 *Fixed a bug with large UTF-8 documents, updated dependencies.* 161 | - 1.1.7 *Technical release: updated dependencies.* 162 | - 1.1.6 *Technical release: added Greenkeeper and removed `yarn.lock`.* 163 | - 1.1.5 *Updated dependencies and added a test suite for `io.retry`.* 164 | - 1.1.4 *Updated dependencies.* 165 | - 1.1.3 *Added experimental `IncomeMessage` support.* 166 | - 1.1.2 *Exposed `getData()` and `getHeaders()` on stream and error objects.* 167 | - 1.1.1 *Added support for `Buffer`, replaced failure objects with `Error`-based objects.* 168 | - 1.1.0 *Getting rid of `request`, use native `http`/`https`, support compression and streaming.* 169 | - 1.0.3 *Bugfix: custom headers. Thx [Bryan Pease](https://github.com/Akeron972)!* 170 | - 1.0.2 *Added custom body processors.* 171 | - 1.0.1 *New dependencies.* 172 | - 1.0.0 *The initial release.* 173 | 174 | # License 175 | 176 | BSD or AFL — your choice. 177 | 178 | 179 | [npm-image]: https://img.shields.io/npm/v/heya-io-node.svg 180 | [npm-url]: https://npmjs.org/package/heya-io-node 181 | [deps-image]: https://img.shields.io/david/heya/io-node.svg 182 | [deps-url]: https://david-dm.org/heya/io-node 183 | [dev-deps-image]: https://img.shields.io/david/dev/heya/io-node.svg 184 | [dev-deps-url]: https://david-dm.org/heya/io-node?type=dev 185 | [travis-image]: https://img.shields.io/travis/heya/io-node.svg 186 | [travis-url]: https://travis-ci.org/heya/io-node 187 | [greenkeeper-image]: https://badges.greenkeeper.io/heya/io.svg 188 | [greenkeeper-url]: https://greenkeeper.io/ 189 | -------------------------------------------------------------------------------- /tests/test-io.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var unit = require('heya-unit'); 4 | var io = require('../main'); 5 | 6 | var isJson = /^application\/json\b/, 7 | isXml = /^application\/xml\b/, 8 | isOctetStream = /^application\/octet-stream\b/, 9 | isMultiPart = /^multipart\/form-data\b/; 10 | 11 | unit.add(module, [ 12 | function test_exist (t) { 13 | eval(t.TEST('typeof io == "function"')); 14 | eval(t.TEST('typeof io.get == "function"')); 15 | eval(t.TEST('typeof io.put == "function"')); 16 | eval(t.TEST('typeof io.post == "function"')); 17 | eval(t.TEST('typeof io.patch == "function"')); 18 | eval(t.TEST('typeof io.remove == "function"')); 19 | eval(t.TEST('typeof io["delete"] == "function"')); 20 | }, 21 | function test_simple_io (t) { 22 | var x = t.startAsync(); 23 | io('http://localhost:3000/api').then(function (data) { 24 | eval(t.TEST('data.method === "GET"')); 25 | eval(t.TEST('data.body === null')); 26 | x.done(); 27 | }); 28 | }, 29 | function test_io_get (t) { 30 | var x = t.startAsync(); 31 | io.get('http://localhost:3000/api').then(function (data) { 32 | eval(t.TEST('data.method === "GET"')); 33 | eval(t.TEST('data.body === null')); 34 | x.done(); 35 | }); 36 | }, 37 | function test_io_put (t) { 38 | var x = t.startAsync(); 39 | io.put('http://localhost:3000/api', {a: 1}).then(function (data) { 40 | eval(t.TEST('data.method === "PUT"')); 41 | eval(t.TEST('data.body === "{\\"a\\":1}"')); 42 | x.done(); 43 | }); 44 | }, 45 | function test_io_post (t) { 46 | var x = t.startAsync(); 47 | io.post('http://localhost:3000/api', {a: 1}).then(function (data) { 48 | eval(t.TEST('data.method === "POST"')); 49 | eval(t.TEST('data.body === "{\\"a\\":1}"')); 50 | x.done(); 51 | }); 52 | }, 53 | function test_io_patch (t) { 54 | var x = t.startAsync(); 55 | io.patch('http://localhost:3000/api', {a: 1}).then(function (data) { 56 | eval(t.TEST('data.method === "PATCH"')); 57 | eval(t.TEST('data.body === "{\\"a\\":1}"')); 58 | x.done(); 59 | }); 60 | }, 61 | function test_io_remove (t) { 62 | var x = t.startAsync(); 63 | io.remove('http://localhost:3000/api').then(function (data) { 64 | eval(t.TEST('data.method === "DELETE"')); 65 | eval(t.TEST('data.body === null')); 66 | x.done(); 67 | }); 68 | }, 69 | function test_io_get_query (t) { 70 | var x = t.startAsync(); 71 | io.get('http://localhost:3000/api', {a: 1}).then(function (data) { 72 | eval(t.TEST('data.method === "GET"')); 73 | eval(t.TEST('t.unify(data.query, {a: "1"})')); 74 | x.done(); 75 | }); 76 | }, 77 | function test_io_get_error (t) { 78 | var x = t.startAsync(); 79 | io.get('http://localhost:3000/api', {status: 500}).then(function (data) { 80 | t.test(false); // we should not be here 81 | x.done(); 82 | }).catch(function (data) { 83 | eval(t.TEST('data.xhr.status === 500')); 84 | x.done(); 85 | }); 86 | }, 87 | function test_io_get_txt (t) { 88 | var x = t.startAsync(); 89 | io.get('http://localhost:3000/api', {payloadType: 'txt'}).then(function (data) { 90 | eval(t.TEST('typeof data == "string"')); 91 | eval(t.TEST('data == "Hello, world!"')); 92 | x.done(); 93 | }); 94 | }, 95 | function test_io_get_xml (t) { 96 | if (typeof DOMParser == 'undefined') return; 97 | var x = t.startAsync(); 98 | io.get('http://localhost:3000/api', {payloadType: 'xml'}).then(function (data) { 99 | eval(t.TEST('typeof data == "object"')); 100 | eval(t.TEST('data.nodeName == "#document"')); 101 | eval(t.TEST('data.nodeType == 9')); 102 | return io.post('http://localhost:3000/api', data); 103 | }).then(function (data) { 104 | eval(t.TEST('isXml.test(data.headers["content-type"])')); 105 | x.done(); 106 | }); 107 | }, 108 | function test_io_get_xml_as_text_mime (t) { 109 | var x = t.startAsync(); 110 | io.get({ 111 | url: 'http://localhost:3000/api', 112 | mime: 'text/plain' 113 | }, {payloadType: 'xml'}).then(function (data) { 114 | eval(t.TEST('typeof data == "string"')); 115 | eval(t.TEST('data == "
Hello, world!
"')); 116 | x.done(); 117 | }); 118 | }, 119 | function test_io_get_xml_as_text (t) { 120 | var x = t.startAsync(); 121 | io.get({ 122 | url: 'http://localhost:3000/api', 123 | responseType: 'text' 124 | }, {payloadType: 'xml'}).then(function (data) { 125 | eval(t.TEST('typeof data == "string"')); 126 | eval(t.TEST('data == "
Hello, world!
"')); 127 | x.done(); 128 | }); 129 | }, 130 | function test_io_get_xml_as_blob (t) { 131 | if (typeof Blob == 'undefined') return; 132 | var x = t.startAsync(); 133 | io.get({ 134 | url: 'http://localhost:3000/api', 135 | responseType: 'blob' 136 | }, {payloadType: 'xml'}).then(function (data) { 137 | eval(t.TEST('data instanceof Blob')); 138 | return io.post('http://localhost:3000/api', data); 139 | }).then(function (data) { 140 | eval(t.TEST('isXml.test(data.headers["content-type"])')); 141 | x.done(); 142 | }); 143 | }, 144 | function test_io_get_xml_as_array_buffer (t) { 145 | if (typeof ArrayBuffer == 'undefined') return; 146 | var x = t.startAsync(); 147 | io.get({ 148 | url: 'http://localhost:3000/api', 149 | responseType: 'arraybuffer' 150 | }, {payloadType: 'xml'}).then(function (data) { 151 | eval(t.TEST('data instanceof ArrayBuffer')); 152 | // return io.post('http://localhost:3000/api', data); 153 | // }).then(function (data) { 154 | // eval(t.TEST('isOctetStream.test(data.headers["content-type"])')); 155 | x.done(); 156 | }); 157 | }, 158 | function test_io_post_formdata (t) { 159 | if (typeof FormData == 'undefined') return; 160 | var x = t.startAsync(); 161 | var div = document.createElement('div'); 162 | div.innerHTML = '
'; 163 | var data = new FormData(div.firstChild); 164 | data.append('user', 'heh!'); 165 | io.post('http://localhost:3000/api', data).then(function (data) { 166 | eval(t.TEST('isMultiPart.test(data.headers["content-type"])')); 167 | x.done(); 168 | }); 169 | }, 170 | function test_io_custom_headers(t){ 171 | var x = t.startAsync(); 172 | io({ 173 | url:'http://localhost:3000/api', 174 | headers: { 175 | 'Accept':'text/mod+plain', 176 | 'Content-Type':'text/plain' 177 | }, 178 | method: 'POST', 179 | data: 'Some Text' 180 | }).then(function (data) { 181 | eval(t.TEST('data.method === "POST"')); 182 | eval(t.TEST('data.body === "Some Text"')); 183 | eval(t.TEST('data.headers["content-type"] === "text/plain"')); 184 | eval(t.TEST('data.headers["accept"] === "text/mod+plain"')); 185 | x.done(); 186 | }); 187 | }, 188 | function test_io_get_as_xhr (t) { 189 | var x = t.startAsync(); 190 | io.get({ 191 | url: 'http://localhost:3000/api', 192 | returnXHR: true 193 | }).then(function (xhr) { 194 | var data = io.getData(xhr), headers = io.getHeaders(xhr); 195 | eval(t.TEST('xhr.status == 200')); 196 | eval(t.TEST('typeof data == "object"')); 197 | eval(t.TEST('isJson.test(headers["content-type"])')); 198 | x.done(); 199 | }); 200 | } 201 | ]); 202 | -------------------------------------------------------------------------------- /tests/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const http = require('http'); 4 | const path = require('path'); 5 | const url = require('url'); 6 | 7 | const debug = require('debug')('heya-io:server'); 8 | const express = require('express'); 9 | const bodyParser = require('body-parser'); 10 | const compression = require('compression'); 11 | 12 | const bundler = require('heya-bundler'); 13 | 14 | const io = require('../main'); 15 | 16 | // The APP 17 | 18 | const rep = (s, n) => { 19 | if (n <= 0) return ''; 20 | let result = ''; 21 | for (let mask = 1, buffer = s; n; mask <<= 1, buffer += buffer) { 22 | if (!(n & mask)) continue; 23 | result += buffer; 24 | n -= mask; 25 | } 26 | return result; 27 | }; 28 | 29 | const app = express(); 30 | // app.use(compression()); 31 | // app.use(bodyParser.raw({type: '*/*'})); 32 | 33 | const compressionMiddleware = compression(); 34 | app.use((req, res, next) => { 35 | if (req.path === '/redirect') { 36 | return next(); 37 | } 38 | return compressionMiddleware(req, res, next); 39 | }); 40 | 41 | const bodyParserMiddleware = bodyParser.raw({type: '*/*'}); 42 | app.use((req, res, next) => { 43 | if (req.path === '/redirect') { 44 | return next(); 45 | } 46 | return bodyParserMiddleware(req, res, next); 47 | }); 48 | 49 | let counter = 0; 50 | 51 | const alphabet = 'abcdefghijklmnopqrstuvwxyz'; 52 | 53 | app.get('/alpha', function (req, res) { 54 | var n; 55 | if (req.query.n) { 56 | n = +req.query.n; 57 | if (isNaN(n)) { 58 | n = 100; 59 | } 60 | n = Math.min(1000, Math.max(n, 1)); 61 | } 62 | res.set('Content-Type', 'text/plain'); 63 | for (var i = 0; i < n; ++i) { 64 | res.write(alphabet); 65 | } 66 | res.end(); 67 | }); 68 | app.post('/alpha', function (req, res) { 69 | var body = req.body.toString(), 70 | n = body.length, 71 | verified = true; 72 | for (var i = 0; i < n; i += 26) { 73 | if (body.substr(i, 26) !== alphabet) { 74 | verified = false; 75 | break; 76 | } 77 | } 78 | res.jsonp({n: n, verified: verified}); 79 | }); 80 | 81 | const doNotSet = {'content-encoding': 1, 'content-length': 1, etag: 1, connection: 1, 'transfer-encoding': 1}; 82 | 83 | app.all('/redirect', (req, res) => { 84 | const urlTo = new url.URL(req.query.to, 'http://localhost:3000/'); 85 | io({ 86 | method: req.method, 87 | url: urlTo.href, 88 | headers: req.headers, 89 | responseType: '$tream', 90 | returnXHR: true, 91 | query: {}, 92 | data: req 93 | }) 94 | .then(xhr => { 95 | res.status(xhr.status); 96 | const headers = io.getHeaders(xhr); 97 | Object.keys(headers).forEach(key => { 98 | const value = headers[key]; 99 | if (value instanceof Array) { 100 | value.forEach(v => res.set(key, v)); 101 | } else { 102 | !doNotSet[key] && res.set(key, value); 103 | } 104 | }); 105 | xhr.response.pipe(res); 106 | }) 107 | .catch(e => console.error(e)); 108 | }); 109 | 110 | app.all('/api', function (req, res) { 111 | if (req.query.status) { 112 | var status = parseInt(req.query.status, 10); 113 | if (isNaN(status) || status < 100 || status >= 600) { 114 | status = 200; 115 | } 116 | res.status(status); 117 | } 118 | switch (req.query.payloadType) { 119 | case 'txt': 120 | res.set('Content-Type', 'text/plain'); 121 | res.send('Hello, world!'); 122 | return; 123 | case 'xml': 124 | res.set('Content-Type', 'application/xml'); 125 | res.send('
Hello, world!
'); 126 | return; 127 | } 128 | if (req.query.pattern && /^\d+$/.test(req.query.repeat)) { 129 | res.set('Content-Type', 'text/plain; charset=utf-8'); 130 | const data = rep(req.query.pattern, +req.query.repeat); 131 | res.send(data); 132 | return; 133 | } 134 | var data = { 135 | method: req.method, 136 | protocol: req.protocol, 137 | hostname: req.hostname, 138 | url: req.url, 139 | originalUrl: req.originalUrl, 140 | headers: req.headers, 141 | body: (req.body && req.body.length && req.body.toString()) || null, 142 | query: req.query, 143 | now: Date.now(), 144 | counter: counter++ 145 | }; 146 | var timeout = 0; 147 | if (req.query.timeout) { 148 | var timeout = parseInt(req.query.timeout, 10); 149 | if (isNaN(timeout) || timeout < 0 || timeout > 60000) { 150 | timeout = 0; 151 | } 152 | } 153 | if (timeout) { 154 | setTimeout(function () { 155 | res.jsonp(data); 156 | }, timeout); 157 | } else { 158 | res.jsonp(data); 159 | } 160 | }); 161 | 162 | app.put( 163 | '/bundle', 164 | bundler({ 165 | isUrlAcceptable: isUrlAcceptable, 166 | resolveUrl: resolveUrl 167 | }) 168 | ); 169 | 170 | function isUrlAcceptable(uri) { 171 | return typeof uri == 'string' && !/^\/\//.test(uri) && (uri.charAt(0) === '/' || /^http:\/\/localhost:3000\//.test(uri)); 172 | } 173 | 174 | function resolveUrl(uri) { 175 | return uri.charAt(0) === '/' ? 'http://localhost:3000' + uri : uri; 176 | } 177 | 178 | app.use(express.static(path.join(__dirname, '..'))); 179 | 180 | // catch 404 and forward to error handler 181 | app.use(function (req, res, next) { 182 | var err = new Error('Not Found'); 183 | err.status = 404; 184 | next(err); 185 | }); 186 | 187 | // error handlers 188 | 189 | app.use(function (err, req, res, next) { 190 | // for simplicity we don't use fancy HTML formatting opting for a plain text 191 | res.status(err.status || 500); 192 | res.set('Content-Type', 'text/plain'); 193 | res.send('Error (' + err.status + '): ' + err.message + '\n' + err.stack); 194 | debug('Error: ' + err.message + ' (' + err.status + ')'); 195 | }); 196 | 197 | // The SERVER 198 | 199 | /** 200 | * Get port from environment and store in Express. 201 | */ 202 | 203 | var host = process.env.HOST || 'localhost', 204 | port = normalizePort(process.env.PORT || '3000'); 205 | app.set('port', port); 206 | 207 | /** 208 | * Create HTTP server. 209 | */ 210 | 211 | var server = http.createServer(app); 212 | 213 | /** 214 | * Listen on provided port, on provided host, or all network interfaces. 215 | */ 216 | 217 | server.listen(port, host); 218 | server.on('error', onError); 219 | server.on('listening', onListening); 220 | 221 | /** 222 | * Normalize a port into a number, string, or false. 223 | */ 224 | 225 | function normalizePort(val) { 226 | var port = parseInt(val, 10); 227 | 228 | if (isNaN(port)) { 229 | // named pipe 230 | return val; 231 | } 232 | 233 | if (port >= 0) { 234 | // port number 235 | return port; 236 | } 237 | 238 | return false; 239 | } 240 | 241 | /** 242 | * Human-readable port description. 243 | */ 244 | 245 | function portToString(port) { 246 | return typeof port === 'string' ? 'pipe ' + port : 'port ' + port; 247 | } 248 | 249 | /** 250 | * Event listener for HTTP server "error" event. 251 | */ 252 | 253 | function onError(error) { 254 | if (error.syscall !== 'listen') { 255 | throw error; 256 | } 257 | 258 | var bind = portToString(port); 259 | 260 | // handle specific listen errors with friendly messages 261 | switch (error.code) { 262 | case 'EACCES': 263 | console.error('Error: ' + bind + ' requires elevated privileges'); 264 | process.exit(1); 265 | break; 266 | case 'EADDRINUSE': 267 | console.error('Error: ' + bind + ' is already in use'); 268 | process.exit(1); 269 | break; 270 | default: 271 | throw error; 272 | } 273 | } 274 | 275 | /** 276 | * Event listener for HTTP server "listening" event. 277 | */ 278 | 279 | function onListening() { 280 | //var addr = server.address(); 281 | var bind = portToString(port); 282 | debug('Listening on ' + (host || 'all network interfaces') + ' ' + bind); 283 | } 284 | -------------------------------------------------------------------------------- /tests/test-stream.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {Readable, Writable} = require('stream'); 4 | 5 | const unit = require('heya-unit'); 6 | const ios = require('../stream'); 7 | 8 | const isJson = /^application\/json\b/; 9 | // const isXml = /^application\/xml\b/; 10 | 11 | class Collector extends Writable { 12 | constructor(cb) { 13 | super(); 14 | this.cb = cb; 15 | this.buffer = null; 16 | this.on('finish', () => this.cb(this.buffer)); // for Node 6 17 | } 18 | _write(chunk, encoding, callback) { 19 | if (this.buffer === null) { 20 | this.buffer = chunk; 21 | } else { 22 | this.buffer += chunk; 23 | } 24 | callback(null); 25 | } 26 | // _final(callback) { // unavailable in Node 6 27 | // this.cb(this.buffer); 28 | // callback(null); 29 | // } 30 | } 31 | 32 | const collect = cb => new Collector(cb); 33 | const collectJson = cb => collect(buffer => cb(JSON.parse(buffer.toString()))); 34 | 35 | class Pusher extends Readable { 36 | constructor(string) { 37 | super(); 38 | this.string = string; 39 | } 40 | _read(size) { 41 | if (this.string) { 42 | if (this.string.length <= size) { 43 | this.push(this.string); 44 | this.string = ''; 45 | } else { 46 | this.push(this.string.substr(0, size)); 47 | this.string = this.string.substr(size); 48 | } 49 | } else { 50 | this.push(null); 51 | } 52 | } 53 | } 54 | 55 | const push = text => new Pusher(text); 56 | const pushJson = object => push(JSON.stringify(object)); 57 | 58 | unit.add(module, [ 59 | function test_stream_exist(t) { 60 | eval(t.TEST('typeof ios == "object"')); 61 | eval(t.TEST('typeof ios.IO == "function"')); 62 | eval(t.TEST('typeof ios.get == "function"')); 63 | eval(t.TEST('typeof ios.put == "function"')); 64 | eval(t.TEST('typeof ios.post == "function"')); 65 | eval(t.TEST('typeof ios.patch == "function"')); 66 | eval(t.TEST('typeof ios.remove == "function"')); 67 | eval(t.TEST('typeof ios["delete"] == "function"')); 68 | }, 69 | function test_stream_io1(t) { 70 | const x = t.startAsync(); 71 | const s = new ios.IO('http://localhost:3000/api'); 72 | let buffer = null; 73 | s.on('data', chunk => (buffer === null ? (buffer = chunk) : (buffer += chunk))); 74 | s.on('end', () => { 75 | const data = JSON.parse(buffer.toString()); 76 | eval(t.TEST('data.method === "GET"')); 77 | eval(t.TEST('data.body === null')); 78 | x.done(); 79 | }); 80 | }, 81 | function test_stream_io2(t) { 82 | const x = t.startAsync(); 83 | new ios.IO('http://localhost:3000/api').pipe( 84 | collectJson(data => { 85 | eval(t.TEST('data.method === "GET"')); 86 | eval(t.TEST('data.body === null')); 87 | x.done(); 88 | }) 89 | ); 90 | }, 91 | function test_stream_get(t) { 92 | const x = t.startAsync(); 93 | ios.get('http://localhost:3000/api').pipe( 94 | collectJson(data => { 95 | eval(t.TEST('data.method === "GET"')); 96 | eval(t.TEST('data.body === null')); 97 | x.done(); 98 | }) 99 | ); 100 | }, 101 | function test_stream_put(t) { 102 | const x = t.startAsync(); 103 | ios.put('http://localhost:3000/api', {a: 1}).pipe( 104 | collectJson(data => { 105 | eval(t.TEST('data.method === "PUT"')); 106 | eval(t.TEST('data.body === "{\\"a\\":1}"')); 107 | x.done(); 108 | }) 109 | ); 110 | }, 111 | function test_stream_put_pipe(t) { 112 | const x = t.startAsync(); 113 | pushJson({a: 1}) 114 | .pipe( 115 | ios.put({ 116 | url: 'http://localhost:3000/api', 117 | headers: { 118 | 'Content-Type': 'application/json', 119 | '$-Content-Encoding': 'deflate' 120 | } 121 | }) 122 | ) 123 | .pipe( 124 | collectJson(data => { 125 | eval(t.TEST('data.method === "PUT"')); 126 | eval(t.TEST('data.body === "{\\"a\\":1}"')); 127 | x.done(); 128 | }) 129 | ); 130 | }, 131 | function test_stream_post(t) { 132 | const x = t.startAsync(); 133 | ios.post('http://localhost:3000/api', {a: 1}).pipe( 134 | collectJson(data => { 135 | eval(t.TEST('data.method === "POST"')); 136 | eval(t.TEST('data.body === "{\\"a\\":1}"')); 137 | x.done(); 138 | }) 139 | ); 140 | }, 141 | function test_stream_patch(t) { 142 | const x = t.startAsync(); 143 | ios.patch('http://localhost:3000/api', {a: 1}).pipe( 144 | collectJson(data => { 145 | eval(t.TEST('data.method === "PATCH"')); 146 | eval(t.TEST('data.body === "{\\"a\\":1}"')); 147 | x.done(); 148 | }) 149 | ); 150 | }, 151 | function test_stream_remove(t) { 152 | const x = t.startAsync(); 153 | ios.remove('http://localhost:3000/api').pipe( 154 | collectJson(data => { 155 | eval(t.TEST('data.method === "DELETE"')); 156 | eval(t.TEST('data.body === null')); 157 | x.done(); 158 | }) 159 | ); 160 | }, 161 | function test_stream_get_query(t) { 162 | const x = t.startAsync(); 163 | ios.get('http://localhost:3000/api', {a: 1}).pipe( 164 | collectJson(data => { 165 | eval(t.TEST('data.method === "GET"')); 166 | eval(t.TEST('t.unify(data.query, {a: "1"})')); 167 | x.done(); 168 | }) 169 | ); 170 | }, 171 | function test_stream_get_error(t) { 172 | const x = t.startAsync(); 173 | const stream = ios.get('http://localhost:3000/api', {status: 500}); 174 | stream.pipe( 175 | collectJson(data => { 176 | t.test(false); // we should not be here 177 | x.done(); 178 | }) 179 | ); 180 | stream.on('error', e => { 181 | eval(t.TEST('e.xhr.status === 500')); 182 | x.done(); 183 | }); 184 | }, 185 | function test_stream_get_txt(t) { 186 | const x = t.startAsync(); 187 | ios.get('http://localhost:3000/api', {payloadType: 'txt'}).pipe( 188 | collect(buffer => { 189 | const data = buffer.toString(); 190 | eval(t.TEST('typeof data == "string"')); 191 | eval(t.TEST('data == "Hello, world!"')); 192 | x.done(); 193 | }) 194 | ); 195 | }, 196 | // function test_stream_get_xml (t) { 197 | // if (typeof DOMParser == 'undefined') return; 198 | // var x = t.startAsync(); 199 | // io.get('http://localhost:3000/api', {payloadType: 'xml'}).then(function (data) { 200 | // eval(t.TEST('typeof data == "object"')); 201 | // eval(t.TEST('data.nodeName == "#document"')); 202 | // eval(t.TEST('data.nodeType == 9')); 203 | // return io.post('http://localhost:3000/api', data); 204 | // }).then(function (data) { 205 | // eval(t.TEST('isXml.test(data.headers["content-type"])')); 206 | // x.done(); 207 | // }); 208 | // }, 209 | function test_stream_get_xml_as_text(t) { 210 | const x = t.startAsync(); 211 | ios.get( 212 | { 213 | url: 'http://localhost:3000/api', 214 | mime: 'text/plain' 215 | }, 216 | {payloadType: 'xml'} 217 | ).pipe( 218 | collect(buffer => { 219 | const data = buffer.toString(); 220 | eval(t.TEST('typeof data == "string"')); 221 | eval(t.TEST('data == "
Hello, world!
"')); 222 | x.done(); 223 | }) 224 | ); 225 | }, 226 | function test_stream_custom_headers(t) { 227 | const x = t.startAsync(); 228 | const net = new ios.IO({ 229 | url: 'http://localhost:3000/api', 230 | headers: { 231 | Accept: 'text/mod+plain', 232 | 'Content-Type': 'text/plain' 233 | }, 234 | method: 'POST', 235 | data: 'Some Text' 236 | }); 237 | net.pipe( 238 | collectJson(data => { 239 | eval(t.TEST('data.method === "POST"')); 240 | eval(t.TEST('data.body === "Some Text"')); 241 | eval(t.TEST('data.headers["content-type"] === "text/plain"')); 242 | eval(t.TEST('data.headers["accept"] === "text/mod+plain"')); 243 | 244 | eval(t.TEST('net.meta.status == 200')); 245 | 246 | const headers = net.getHeaders(); 247 | eval(t.TEST('isJson.test(headers["content-type"])')); 248 | 249 | x.done(); 250 | }) 251 | ); 252 | } 253 | ]); 254 | -------------------------------------------------------------------------------- /node.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const http = require('http'); 4 | const https = require('https'); 5 | // const http2 = require('http2'); 6 | const zlib = require('zlib'); 7 | const {Readable} = require('stream'); 8 | 9 | const io = require('heya-io/io'); 10 | const FauxXHR = require('heya-io/FauxXHR'); 11 | 12 | // This is a Node-only module. 13 | 14 | const getCharset = /;\scharset=(.+)$/i; 15 | 16 | const makeHeaders = (rawHeaders, mime) => { 17 | if (mime) { 18 | rawHeaders = rawHeaders.filter((_, index, array) => array[(index >> 1) << 1].toLowerCase() != 'content-type'); 19 | rawHeaders.push('Content-Type', mime); 20 | } 21 | return rawHeaders.reduce((acc, value, index) => acc + (index % 2 ? ': ' : index ? '\r\n' : '') + value, ''); 22 | }; 23 | 24 | const returnOutputStream = (res, options) => { 25 | const encoding = res.headers['content-encoding']; 26 | const decoder = encoding && io.node.encoders[encoding] && io.node.encoders[encoding].decode && io.node.encoders[encoding].decode(options); 27 | return decoder ? res.pipe(decoder) : res; 28 | }; 29 | 30 | const returnInputStream = (req, options) => { 31 | let encoding = req.getHeader('$-content-encoding'); 32 | req.removeHeader('$-content-encoding'); 33 | const encoder = io.node.encoders[encoding]; 34 | if (!encoder) return req; 35 | req.setHeader('content-encoding', encoding); 36 | const stream = encoder.encode(options).pipe(req); 37 | if (stream === req) req.removeHeader('content-encoding'); 38 | return stream; 39 | }; 40 | 41 | const requestTransport = (options, prep) => { 42 | // create request options 43 | const urlObject = new URL(prep.url); 44 | const newOptions = { 45 | url: prep.url, 46 | protocol: urlObject.protocol, 47 | hostname: urlObject.hostname, 48 | port: urlObject.port, 49 | path: urlObject.pathname + urlObject.search + urlObject.hash, 50 | method: options.method, 51 | headers: Object.assign({}, options.headers) 52 | }; 53 | if (urlObject.username) { 54 | newOptions.auth = urlObject.username; 55 | if (urlObject.password) { 56 | newOptions.auth += ':' + urlObject.password; 57 | } 58 | } 59 | if (options.timeout) newOptions.timeout = options.timeout; 60 | 61 | // create Accept-Encoding 62 | Object.keys(newOptions.headers) 63 | .filter(key => /^Accept\-Encoding$/i.test(key)) 64 | .forEach(key => delete newOptions.headers[key]); 65 | if (io.node.acceptedEncoding) { 66 | newOptions.headers['Accept-Encoding'] = io.node.acceptedEncoding; 67 | } 68 | 69 | // prepare body 70 | newOptions.body = io.processData( 71 | { 72 | setRequestHeader(key, value) { 73 | newOptions.headers[key] = value; 74 | } 75 | }, 76 | options, 77 | prep.data 78 | ); 79 | 80 | return new Promise((resolve, reject) => { 81 | const opt = io.node.inspectRequest(newOptions); 82 | const proto = opt.protocol && opt.protocol.toLowerCase() === 'https:' ? https : http; 83 | const req = proto.request(opt, res => resolve(res)); 84 | req.on('error', e => reject(e)); 85 | if (opt.body instanceof Readable) { 86 | const stream = req.getHeader('content-type') && req.getHeader('$-content-encoding') ? returnInputStream(req, opt) : req; 87 | opt.body.pipe(stream); 88 | } else if (opt.body instanceof http.IncomingMessage) { 89 | const rawHeaders = opt.body.rawHeaders; 90 | for (let i = 0; i < rawHeaders.length; i += 2) { 91 | req.setHeader(rawHeaders[i], rawHeaders[i + 1]); 92 | } 93 | const stream = req.getHeader('content-type') && req.getHeader('$-content-encoding') ? returnInputStream(req, opt) : req; 94 | opt.body.pipe(stream); 95 | } else { 96 | const stream = 97 | opt.body && 98 | req.getHeader('content-type') && 99 | ((io.node.encodingThreshold && opt.body.length > io.node.encodingThreshold) || req.getHeader('$-content-encoding')) 100 | ? returnInputStream(req, opt) 101 | : req; 102 | stream.end(opt.body); 103 | } 104 | }) 105 | .then(res => { 106 | if (options.responseType === '$tream') { 107 | const xhr = new FauxXHR({ 108 | status: res.statusCode, 109 | statusText: res.statusMessage, 110 | headers: makeHeaders(res.rawHeaders, options.mime), 111 | responseType: options.responseType, 112 | responseText: '' 113 | }); 114 | xhr.response = returnOutputStream(res, options); 115 | return xhr; 116 | } 117 | return new Promise(resolve => { 118 | const dataStream = returnOutputStream(res, options); 119 | let buffer = Buffer.alloc(0); 120 | dataStream.on('data', chunk => (buffer = Buffer.concat([buffer, chunk]))); 121 | dataStream.on('end', () => resolve(buffer)); 122 | }).then(buffer => { 123 | const contentType = res.headers['content-type'], 124 | charsetResult = contentType && getCharset.exec(contentType); 125 | let charset = charsetResult && charsetResult[1]; 126 | charset = !charset || charset === 'utf8' || charset === 'utf-8' ? 'utf8' : 'latin1'; 127 | return new FauxXHR({ 128 | status: res.statusCode, 129 | statusText: res.statusMessage, 130 | headers: makeHeaders(res.rawHeaders, options.mime), 131 | responseType: options.responseType || '', 132 | responseText: buffer.toString(charset), 133 | response: buffer 134 | }); 135 | }); 136 | }) 137 | .then(xhr => io.node.inspectResult(new io.Result(xhr, options))); 138 | }; 139 | 140 | let oldTransport; 141 | 142 | const attach = () => { 143 | if (io.defaultTransport !== requestTransport) { 144 | oldTransport = io.defaultTransport; 145 | io.defaultTransport = requestTransport; 146 | return true; 147 | } 148 | return false; 149 | }; 150 | 151 | const detach = () => { 152 | if (oldTransport && io.defaultTransport === requestTransport) { 153 | io.defaultTransport = oldTransport; 154 | oldTransport = null; 155 | return true; 156 | } 157 | return false; 158 | }; 159 | 160 | const identity = x => x; 161 | 162 | const updateEncodingSettings = () => { 163 | const encoders = io.node.encoders, 164 | keys = Object.keys(encoders); 165 | io.node.acceptedEncoding = keys 166 | .filter(key => encoders[key].decode) 167 | .sort((a, b) => encoders[a].priority - encoders[b].priority) 168 | .join(', '); 169 | io.node.preferredEncoding = keys 170 | .filter(key => encoders[key].encode) 171 | .reduce((last, key) => (!last ? key : encoders[last].priority < encoders[key].priority ? last : key)); 172 | }; 173 | 174 | const addEncoder = (key, object) => { 175 | io.node.encoders[key] = object; 176 | updateEncodingSettings(); 177 | }; 178 | 179 | const removeEncoder = key => { 180 | delete io.node.encoders[key]; 181 | updateEncodingSettings(); 182 | }; 183 | 184 | io.node = { 185 | attach: attach, 186 | detach: detach, 187 | inspectRequest: identity, 188 | inspectResult: identity, 189 | encodingThreshold: 0, 190 | encoders: { 191 | gzip: { 192 | priority: 10, 193 | encode: options => zlib.createGzip(options && options.compressor), 194 | decode: options => zlib.createGunzip(options && options.decompressor) 195 | }, 196 | deflate: { 197 | priority: 20, 198 | encode: options => zlib.createDeflate(options && options.compressor), 199 | decode: options => zlib.createInflate(options && options.decompressor) 200 | } 201 | }, 202 | preferredEncoding: '', 203 | acceptedEncoding: '', 204 | addEncoder: addEncoder, 205 | removeEncoder: removeEncoder 206 | }; 207 | 208 | updateEncodingSettings(); 209 | 210 | if (zlib.createBrotliCompress && zlib.createBrotliDecompress) { 211 | io.node.addEncoder('br', { 212 | priority: 30, 213 | encode: options => zlib.createBrotliCompress(options && options.compressor), 214 | decode: options => zlib.createBrotliDecompress(options && options.decompressor) 215 | }); 216 | } 217 | 218 | module.exports = io; 219 | --------------------------------------------------------------------------------