├── .gitattributes ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── example.js ├── lib ├── index.js ├── matches.js └── matches.test.js ├── package-lock.json ├── package.json └── test.js /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # tap 64 | .tap/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | os: 5 | - linux 6 | addons: 7 | hosts: 8 | - example.com 9 | - test.example.com 10 | - test2.example.com -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2025 Patrick Pissurno 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastify-vhost 2 | [![npm-version](https://img.shields.io/npm/v/fastify-vhost.svg)](https://www.npmjs.com/package/fastify-vhost) 3 | [![coverage status](https://coveralls.io/repos/github/patrickpissurno/fastify-vhost/badge.svg?branch=master)](https://coveralls.io/github/patrickpissurno/fastify-vhost?branch=master) 4 | [![known vulnerabilities](https://snyk.io/test/github/patrickpissurno/fastify-vhost/badge.svg)](https://snyk.io/test/github/patrickpissurno/fastify-vhost) 5 | [![downloads](https://img.shields.io/npm/dt/fastify-vhost.svg)](https://www.npmjs.com/package/fastify-vhost) 6 | [![license](https://img.shields.io/github/license/patrickpissurno/fastify-vhost.svg?maxAge=1800)](https://github.com/patrickpissurno/fastify-vhost/blob/master/LICENSE) 7 | 8 | Proxy subdomain http requests to another server. 9 | This [`fastify`](https://www.fastify.io) plugin forwards all the requests 10 | received with a given subdomain to an upstream. 11 | 12 | `fastify-vhost` is powered by the popular Nodejitsu [`http-proxy`](https://github.com/nodejitsu/node-http-proxy). [![GitHub stars](https://img.shields.io/github/stars/nodejitsu/node-http-proxy.svg?style=social&label=Star)](https://github.com/nodejitsu/node-http-proxy) 13 | 14 | This plugin can be used if you want to point multiple (sub)domains to the same IP address, while running different servers on the same machine. 15 | 16 | ## Fastify support 17 | Prior to `fastify-vhost@1.1.3` we only supported `fastify@1.x.x`. We are proud to announce that `fastify-vhost` now supports both v1, v2 and v3! 18 | 19 | ## Install 20 | 21 | ``` 22 | npm i fastify-vhost fastify 23 | ``` 24 | 25 | ## Example 26 | 27 | ```js 28 | const Fastify = require('fastify') 29 | const server = Fastify() 30 | 31 | server.register(require('fastify-vhost'), { 32 | upstream: 'http://localhost:3000', 33 | host: 'test.example.com' 34 | }) 35 | 36 | server.listen(80) 37 | ``` 38 | 39 | This will proxy any request to the `test` subdomain to the server running at `http://localhost:3000`. For instance `http://test.example.com/users` will be proxied to `http://localhost:3000/users`. 40 | 41 | If you want to have different vhosts for different subdomains you can register multiple instances of the plugin as shown in the following snippet: 42 | 43 | ```js 44 | const Fastify = require('fastify') 45 | const server = Fastify() 46 | const vhost = require('fastify-vhost') 47 | 48 | server.register(vhost, { 49 | upstream: 'http://localhost:3000', 50 | host: 'test.example.com' 51 | }) 52 | 53 | server.register(vhost, { 54 | upstream: 'http://localhost:3001', 55 | host: 'other.example.com' 56 | }) 57 | 58 | server.listen(80) 59 | ``` 60 | 61 | You can also specify multiple aliases for each vhost with the `hosts` option: 62 | 63 | ```js 64 | const Fastify = require('fastify') 65 | const server = Fastify() 66 | const vhost = require('fastify-vhost') 67 | 68 | server.register(vhost, { 69 | upstream: 'http://localhost:3000', 70 | hosts: ['test.example.com', 'test2.example.com'] 71 | }) 72 | 73 | server.register(vhost, { 74 | upstream: 'http://localhost:3001', 75 | host: 'other.example.com' 76 | }) 77 | 78 | server.listen(80) 79 | ``` 80 | 81 | The above example would behave the same as the following: 82 | 83 | ```js 84 | const Fastify = require('fastify') 85 | const server = Fastify() 86 | const vhost = require('fastify-vhost') 87 | 88 | server.register(vhost, { 89 | upstream: 'http://localhost:3000', 90 | host: 'test.example.com' 91 | }) 92 | 93 | server.register(vhost, { 94 | upstream: 'http://localhost:3000', 95 | host: 'test2.example.com' 96 | }) 97 | 98 | server.register(vhost, { 99 | upstream: 'http://localhost:3001', 100 | host: 'other.example.com' 101 | }) 102 | 103 | server.listen(80) 104 | ``` 105 | 106 | But in a much neater way. 107 | 108 | Notice that it is **CRITICAL** to provide the full `host` (subdomain + domain) so that it properly routes the requests across different upstreams. 109 | 110 | For other examples, see `example.js`. 111 | 112 | ## Options 113 | 114 | This `fastify` plugin supports the following options. 115 | 116 | *Note that this plugin is fully encapsulated and payloads will be streamed directly to the destination.* 117 | 118 | ### upstream 119 | 120 | An URL (including protocol) that the requests will be forwarded to (eg. http://localhost:3000). 121 | 122 | ### host 123 | 124 | The host to mount this plugin on. All the requests to the current server where the `host` header matches this string will be proxied to the provided upstream. 125 | 126 | ### hosts 127 | 128 | Equivalent to the `host` option, but an array of strings. All the requests to the current server where the `host` header matches any of the strings will be proxied to the provided upstream. 129 | 130 | ### strict 131 | 132 | ```Default: false```. When strict mode is enabled, the host header has to be an exact match. When disabled, 'EXAMPLE.COM', 'example.com' and 'example.com:3000' will match 'example.com'. 133 | 134 | ### timeout 135 | 136 | ```Default: 30000```. Timeout in milliseconds for the proxy to return a ```504 Gateway Timeout```. 137 | 138 | ## Benchmarks 139 | 140 | None yet. But you're welcome to open a PR. 141 | 142 | ## TODO 143 | 144 | * [x] Add unit tests 145 | * [x] Add integration tests 146 | * [x] Coverage 100% 147 | * [ ] Add benchmarks 148 | 149 | ## License 150 | 151 | MIT 152 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | const fastify = require('fastify'); 2 | const vhost = require('.'); 3 | 4 | const fastifyA = fastify(); 5 | const fastifyB = fastify(); 6 | 7 | fastifyA.get('/', async (req, reply) => 'Hi from example.com'); 8 | fastifyB.get('/', async (req, reply) => 'Hi from test.example.com'); 9 | 10 | fastifyA.register(vhost, { 11 | upstream: 'http://localhost:3001', 12 | host: 'test.example.com' 13 | }); 14 | 15 | fastifyA.listen(3000, '0.0.0.0', () => console.log('A running')); 16 | fastifyB.listen(3001, '0.0.0.0', () => console.log('B running')); -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const fp = require('fastify-plugin'); 2 | const httpProxy = require('http-proxy'); 3 | const matches = require('./matches'); 4 | 5 | module.exports = fp(function (fastify, opts, done) { 6 | if (!opts.upstream) 7 | throw new Error('upstream must be specified'); 8 | 9 | if(opts.subdomain) 10 | throw new Error('"subdomain" option was removed in version 1.1.x'); 11 | 12 | if(opts.host && opts.hosts) 13 | throw new Error('you should either provide host or hosts, but not both'); 14 | 15 | if(!opts.hosts){ 16 | if(!opts.host){ 17 | if(opts.fullHost){ 18 | console.warn('Deprecation notice: "fullHost" was renamed to "host" in version 1.1.x and may me removed in future versions'); 19 | opts.host = opts.fullHost; 20 | } 21 | else 22 | throw new Error('either host or hosts must be specified'); 23 | } 24 | 25 | opts.hosts = [ opts.host ]; 26 | } 27 | 28 | if(opts.hosts.length < 1) 29 | throw new Error('either host or hosts must be specified'); 30 | 31 | for(let host of opts.hosts){ 32 | if(typeof(host) !== 'string') 33 | throw new Error('host must be string'); 34 | 35 | if(host.indexOf('.') === -1) 36 | throw new Error('host must contain the TLD (eg. should be "example.com" instead of "example"). Please refer to the docs for further information'); 37 | } 38 | 39 | let timeout = 30 * 1000; 40 | if(opts.timeout !== undefined){ 41 | if(typeof(opts.timeout) !== 'number' || isNaN(opts.timeout)) 42 | throw new Error('timeout should be a valid number'); 43 | if(opts.timeout <= 0) 44 | throw new Error('timeout should be greater than 0'); 45 | timeout = opts.timeout; 46 | } 47 | 48 | const strict = opts.strict === true; 49 | 50 | const proxy = httpProxy.createProxyServer({ target: opts.upstream, proxyTimeout: timeout }); 51 | proxy.on('error', (err, req, res) => { 52 | let status; 53 | let message; 54 | 55 | if(err.code === 'ECONNREFUSED'){ 56 | status = 503; 57 | message = 'Service Unavailable'; 58 | } 59 | else /* istanbul ignore next */ if(err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT'){ 60 | status = 504; 61 | message = 'Gateway Timeout'; 62 | } 63 | else /* istanbul ignore next */ { 64 | status = 502; 65 | message = 'Bad Gateway' 66 | } 67 | 68 | res.writeHead(status, { 'Content-Type': 'application/json' }); 69 | res.end(JSON.stringify({ 70 | statusCode: status, 71 | error: message, 72 | message: message 73 | })); 74 | }); 75 | 76 | fastify.addHook('onRequest', (req, res, next) => { 77 | 78 | /* istanbul ignore next */ 79 | if(req.raw != null) //fastify 3.x.x 80 | handleRequest(req.raw, res.raw, next); 81 | else if(req.req != null) //fastify 2.x.x 82 | handleRequest(req.req, res.res, next); 83 | else //fastify 1.x.x 84 | handleRequest(req, res, next); 85 | 86 | }); 87 | 88 | function handleRequest(req, res, next){ 89 | let handled = false; 90 | 91 | for(let host of opts.hosts){ 92 | if(matches(req.headers, host, strict)){ 93 | proxy.web(req, res); 94 | handled = true; 95 | break; 96 | } 97 | } 98 | 99 | if(!handled) 100 | next(); 101 | } 102 | 103 | done(); 104 | }); -------------------------------------------------------------------------------- /lib/matches.js: -------------------------------------------------------------------------------- 1 | module.exports = function matches(headers, expectedHost, strict){ 2 | if(headers == null || headers.host == null || typeof(headers.host) !== 'string') 3 | return null; 4 | 5 | if(expectedHost != null) 6 | return matchesHost(headers.host, expectedHost, strict); 7 | 8 | return null; 9 | } 10 | 11 | function matchesHost(host, expected, strict){ 12 | if(strict) 13 | return host === expected; 14 | 15 | host = host.split(':')[0]; 16 | host = host.trim(); 17 | return host.toLowerCase() == expected.toLowerCase(); 18 | } -------------------------------------------------------------------------------- /lib/matches.test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap'); 2 | const matches = require('./matches'); 3 | 4 | tap.equal(matches(null, null), null, 'null returns null'); 5 | tap.equal(matches({}, null), null, 'missing header returns null'); 6 | tap.equal(matches({ host: null }, 'test.com'), null, 'null header returns null'); 7 | tap.equal(matches({ host: 12.4 }, 'test.com'), null, 'invalid header returns null'); 8 | tap.equal(matches({ host: undefined }, 'test.com'), null, 'undefined header returns null'); 9 | tap.equal(matches({ host: 'test.com' }, null), null, 'null expected returns null'); 10 | 11 | tap.equal(matches({ host: 'example.com' }, 'test.com'), false, 'root domain should not match'); 12 | tap.equal(matches({ host: 'test.example.com' }, 'test.example.com'), true, 'test subdomain should match'); 13 | tap.equal(matches({ host: 'test.com' }, 'test.test.com'), false, 'root domain should not match'); 14 | tap.equal(matches({ host: 'test.com:3000' }, 'test.com'), true, 'host + port should match'); 15 | tap.equal(matches({ host: 'TEST.com' }, 'test.com'), true, 'case insensitive should match'); 16 | tap.equal(matches({ host: ' test.com ' }, 'test.com'), true, 'host with whitespaces should match'); 17 | 18 | tap.equal(matches({ host: 'test.com:3000' }, 'test.com', true), false, '[strict] host + port should not match'); 19 | tap.equal(matches({ host: 'TEST.com' }, 'test.com', true), false, '[strict] case insensitive should not match'); 20 | tap.equal(matches({ host: ' test.com ' }, 'test.com', true), false, '[strict] host containing whitespaces should not match'); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastify-vhost", 3 | "version": "1.3.6", 4 | "description": "Proxy subdomain http requests to another server", 5 | "main": "./lib", 6 | "scripts": { 7 | "example": "node example.js", 8 | "test": "tap test.js lib/*.test.js --coverage-report=lcov --no-browser --show-full-coverage" 9 | }, 10 | "author": "Patrick Pissurno", 11 | "license": "MIT", 12 | "dependencies": { 13 | "fastify": "^3.29.4", 14 | "fastify-plugin": "^3.0.1", 15 | "http-proxy": "^1.18.1" 16 | }, 17 | "devDependencies": { 18 | "fastify-multipart": "^5.4.0", 19 | "pump": "^3.0.2", 20 | "request": "^2.88.2", 21 | "request-promise-native": "^1.0.9", 22 | "tap": "^21.0.1" 23 | }, 24 | "overrides": { 25 | "cookie": "0.7.2", 26 | "tough-cookie": "4.1.3" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/patrickpissurno/fastify-vhost.git" 31 | }, 32 | "keywords": [ 33 | "fastify", 34 | "vhost", 35 | "subdomain", 36 | "proxy", 37 | "plugin" 38 | ], 39 | "bugs": { 40 | "url": "https://github.com/patrickpissurno/fastify-vhost/issues" 41 | }, 42 | "homepage": "https://github.com/patrickpissurno/fastify-vhost#readme" 43 | } 44 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | const fastify = require('fastify'); 2 | const vhost = require('.'); 3 | const noop = () => {}; 4 | 5 | const tap = require('tap'); 6 | const rp = require('request-promise-native'); 7 | const pump = require('pump'); 8 | const fs = require('fs'); 9 | 10 | const fastifyA = fastify(); 11 | const fastifyB = fastify(); 12 | 13 | async function stop(){ 14 | await fastifyA.close(); 15 | await fastifyB.close(); 16 | } 17 | 18 | const deleteSchema = { 19 | body: { 20 | type: 'object', 21 | properties: { 22 | test: { 23 | type: 'number', 24 | const: 123 25 | } 26 | }, 27 | required: ['test'], 28 | additionalProperties: false 29 | } 30 | } 31 | 32 | async function listen(){ 33 | fastifyA.get('/', async (req, reply) => 'Hi from example.com'); 34 | fastifyB.get('/', async (req, reply) => 'Hi from test.example.com'); 35 | fastifyA.get('/headers', async (req, reply) => req.headers); 36 | fastifyB.get('/headers', async (req, reply) => req.headers); 37 | fastifyB.get('/timeout', async (req, reply) => { }); 38 | fastifyB.get('/error500', (req, reply) => reply.status(500).send('Internal Server Error')); 39 | fastifyB.get('/error400', (req, reply) => reply.status(400).send('Bad Request')); 40 | 41 | fastifyA.delete('/', { schema: deleteSchema }, async (req, reply) => 'DELETE example.com'); 42 | fastifyB.delete('/', { schema: deleteSchema }, async (req, reply) => 'DELETE test.example.com'); 43 | fastifyB.delete('/nobody', async (req, reply) => 'DELETE test.example.com/nobody'); 44 | fastifyB.delete('/timeout', { schema: deleteSchema }, async (req, reply) => { }); 45 | fastifyB.delete('/error500', { schema: deleteSchema }, (req, reply) => reply.status(500).send('Internal Server Error')); 46 | fastifyB.delete('/error400', { schema: deleteSchema }, (req, reply) => reply.status(400).send('Bad Request')); 47 | 48 | fastifyA.post('/multipart', (req, reply) => { 49 | function handler (field, file, filename, encoding, mimetype) { 50 | let stream = fs.createWriteStream('test1-new-fastifyA.txt'); 51 | pump(file, stream); 52 | 53 | stream.once('close', () => { 54 | reply.send(fs.readFileSync('test1-new-fastifyA.txt').toString()); 55 | fs.unlinkSync('test1-new-fastifyA.txt'); 56 | }); 57 | } 58 | req.multipart(handler, err => { 59 | if(err) 60 | throw err; 61 | }); 62 | }); 63 | fastifyB.post('/multipart', (req, reply) => { 64 | function handler (field, file, filename, encoding, mimetype) { 65 | let stream = fs.createWriteStream('test1-new-fastifyB.txt'); 66 | pump(file, stream); 67 | 68 | stream.once('close', () => { 69 | reply.send(fs.readFileSync('test1-new-fastifyB.txt').toString()); 70 | fs.unlinkSync('test1-new-fastifyB.txt'); 71 | }); 72 | } 73 | req.multipart(handler, err => { 74 | if(err) 75 | throw err; 76 | }); 77 | }); 78 | 79 | fastifyA.register(vhost, { 80 | upstream: 'http://localhost:3001', 81 | host: 'test.example.com', 82 | timeout: 3000 83 | }); 84 | 85 | fastifyA.register(vhost, { 86 | upstream: 'http://localhost:3002', 87 | host: 'test2.example.com', 88 | timeout: 1000 89 | }); 90 | 91 | fastifyA.register(require('fastify-multipart')); 92 | fastifyB.register(require('fastify-multipart')); 93 | 94 | 95 | await fastifyA.listen(3000, '0.0.0.0'); 96 | await fastifyB.listen(3001, '0.0.0.0'); 97 | } 98 | 99 | async function testDELETE(){ 100 | const body = { test: 123 }; 101 | await tap.test('DELETE domain', async () => { 102 | let r = await rp('http://example.com:3000', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); 103 | tap.equal(r, 'DELETE example.com'); 104 | }); 105 | await tap.test('DELETE subdomain', async () => { 106 | let r = await rp('http://test.example.com:3000', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); 107 | tap.equal(r, 'DELETE test.example.com'); 108 | }); 109 | await tap.test('DELETE subdomain without body', async () => { 110 | let r = await (async () => { 111 | try { 112 | await rp('http://test.example.com:3000/nobody', { method: 'DELETE', headers: { 'content-type': 'application/json' } }); 113 | return 200; 114 | } 115 | catch(ex){ 116 | if(ex && ex.response) 117 | return ex.response.statusCode; 118 | return 500; 119 | } 120 | })(); 121 | tap.equal(r, 400, 'upstream error messages should be forwarded'); 122 | }); 123 | await tap.test('DELETE subdomain (offline upstream)', async () => { 124 | let r = await (async () => { 125 | try { 126 | await rp('http://test2.example.com:3000', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); 127 | return 200; 128 | } 129 | catch(ex){ 130 | if(ex && ex.response) 131 | return ex.response.statusCode; 132 | return 500; 133 | } 134 | })(); 135 | tap.equal(r, 503, 'offline upstream should return Service Unavailable (503)'); 136 | }); 137 | await tap.test('DELETE subdomain (timeout upstream)', async () => { 138 | let r = await (async () => { 139 | try { 140 | await rp('http://test.example.com:3000/timeout', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); 141 | return 200; 142 | } 143 | catch(ex){ 144 | if(ex && ex.response) 145 | return ex.response.statusCode; 146 | return 500; 147 | } 148 | })(); 149 | tap.equal(r, 504, 'timeout upstream should return Gateway Timeout (504)'); 150 | }); 151 | await tap.test('DELETE subdomain (500 error upstream)', async () => { 152 | let r = await (async () => { 153 | try { 154 | await rp('http://test.example.com:3000/error500', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); 155 | return 200; 156 | } 157 | catch(ex){ 158 | if(ex && ex.response) 159 | return ex.response.statusCode; 160 | return -1; 161 | } 162 | })(); 163 | tap.equal(r, 500, '500 error upstream should be forwarded'); 164 | }); 165 | await tap.test('DELETE subdomain (400 error upstream)', async () => { 166 | let r = await (async () => { 167 | try { 168 | await rp('http://test.example.com:3000/error400', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); 169 | return 200; 170 | } 171 | catch(ex){ 172 | if(ex && ex.response) 173 | return ex.response.statusCode; 174 | return 500; 175 | } 176 | })(); 177 | tap.equal(r, 400, '400 error upstream should be forwarded'); 178 | }); 179 | } 180 | 181 | async function testGET(){ 182 | await tap.test('GET domain', async () => { 183 | let r = await rp('http://example.com:3000'); 184 | tap.equal(r, 'Hi from example.com'); 185 | }); 186 | await tap.test('GET subdomain', async () => { 187 | let r = await rp('http://test.example.com:3000'); 188 | tap.equal(r, 'Hi from test.example.com'); 189 | }); 190 | await tap.test('GET subdomain (offline upstream)', async () => { 191 | let r = await (async () => { 192 | try { 193 | await rp('http://test2.example.com:3000'); 194 | return 200; 195 | } 196 | catch(ex){ 197 | if(ex && ex.response) 198 | return ex.response.statusCode; 199 | return 500; 200 | } 201 | })(); 202 | tap.equal(r, 503, 'offline upstream should return Service Unavailable (503)'); 203 | }); 204 | await tap.test('GET subdomain (timeout upstream)', async () => { 205 | let r = await (async () => { 206 | try { 207 | await rp('http://test.example.com:3000/timeout'); 208 | return 200; 209 | } 210 | catch(ex){ 211 | if(ex && ex.response) 212 | return ex.response.statusCode; 213 | return 500; 214 | } 215 | })(); 216 | tap.equal(r, 504, 'timeout upstream should return Gateway Timeout (504)'); 217 | }); 218 | await tap.test('GET subdomain (500 error upstream)', async () => { 219 | let r = await (async () => { 220 | try { 221 | await rp('http://test.example.com:3000/error500'); 222 | return 200; 223 | } 224 | catch(ex){ 225 | if(ex && ex.response) 226 | return ex.response.statusCode; 227 | return -1; 228 | } 229 | })(); 230 | tap.equal(r, 500, '500 error upstream should be forwarded'); 231 | }); 232 | await tap.test('GET subdomain (400 error upstream)', async () => { 233 | let r = await (async () => { 234 | try { 235 | await rp('http://test.example.com:3000/error400'); 236 | return 200; 237 | } 238 | catch(ex){ 239 | if(ex && ex.response) 240 | return ex.response.statusCode; 241 | return 500; 242 | } 243 | })(); 244 | tap.equal(r, 400, '400 error upstream should be forwarded'); 245 | }); 246 | } 247 | 248 | async function testHeaders(){ 249 | const opt = { 250 | headers: { 251 | 'Authorization': '123' 252 | } 253 | }; 254 | await tap.test('Headers domain', async () => { 255 | let r = JSON.parse(await rp('http://example.com:3000/headers', opt)); 256 | for(let key in opt.headers) 257 | tap.equal(r[key.toLowerCase()], opt.headers[key]); 258 | tap.equal(r.host, 'example.com:3000'); 259 | }); 260 | await tap.test('Headers subdomain', async () => { 261 | let r = JSON.parse(await rp('http://test.example.com:3000/headers', opt)); 262 | for(let key in opt.headers) 263 | tap.equal(r[key.toLowerCase()], opt.headers[key]); 264 | tap.equal(r.host, 'test.example.com:3000'); 265 | }); 266 | await tap.test('Headers domain & subdomain', async () => { 267 | let r1 = JSON.parse(await rp('http://example.com:3000/headers', opt)); 268 | let r2 = JSON.parse(await rp('http://test.example.com:3000/headers', opt)); 269 | for(let key in r1) 270 | if(key !== 'host') 271 | tap.equal(r2[key], r1[key]); 272 | }); 273 | } 274 | async function testMultipart(){ 275 | fs.writeFileSync('test1-original.txt', '123'); 276 | 277 | const opt = { 278 | method: 'POST', 279 | headers: { 280 | 'Authorization': '123' 281 | } 282 | }; 283 | 284 | opt.formData = { 285 | file: fs.createReadStream('test1-original.txt') 286 | }; 287 | 288 | await tap.test('Multipart domain', async () => { 289 | let r = await rp('http://example.com:3000/multipart', opt); 290 | tap.equal(r, fs.readFileSync('test1-original.txt').toString()); 291 | }); 292 | 293 | opt.formData = { 294 | file: fs.createReadStream('test1-original.txt') 295 | }; 296 | 297 | await tap.test('Multipart subdomain', async () => { 298 | let r = await rp('http://test.example.com:3000/multipart', opt); 299 | tap.equal(r, fs.readFileSync('test1-original.txt').toString()); 300 | }); 301 | 302 | fs.unlinkSync('test1-original.txt'); 303 | } 304 | 305 | function testOptions(){ 306 | tap.throws(() => vhost(null, null), {}, 'null options should throw'); 307 | tap.throws(() => vhost(null, { host: 'test.com' }), {}, 'options missing upstream should throw'); 308 | tap.throws(() => vhost(null, { upstream: 'localhost' }), {}, 'options missing host should throw'); 309 | tap.throws(() => vhost(null, { upstream: 'localhost', subdomain: 'test' }), {}, 'options containing subdomain should throw'); 310 | tap.throws(() => vhost(null, { upstream: 'localhost', host: 123 }), {}, 'non-string host should throw'); 311 | tap.throws(() => vhost(null, { upstream: 'localhost', host: 'test' }), {}, 'tld-missing host should throw'); 312 | tap.throws(() => vhost(null, { upstream: 'localhost', host: 'test.com', timeout: 'invalid' }), {}, 'invalid timeout should throw'); 313 | tap.throws(() => vhost(null, { upstream: 'localhost', host: 'test.com', timeout: -1 }), {}, 'negative timeout should throw'); 314 | tap.throws(() => vhost(null, { upstream: 'localhost', host: 'test.com', timeout: 0 }), {}, 'timeout=0 should throw'); 315 | tap.throws(() => vhost(null, { upstream: 'localhost', host: 'test.com', hosts: [ ] }), {}, 'with both host and hosts should throw'); 316 | tap.throws(() => vhost(null, { upstream: 'localhost', hosts: [ ] }), {}, 'empty hosts should throw'); 317 | 318 | tap.doesNotThrow(() => vhost(fastify(), { upstream: 'localhost', fullHost: 'test.com' }, noop), {}, 'should support "fullHost" alias for "host"'); 319 | } 320 | 321 | async function tests(){ 322 | testOptions(); 323 | 324 | await tap.test('listen', async (t) => listen()); 325 | await testGET(); 326 | await testDELETE(); 327 | await testHeaders(); 328 | await testMultipart(); 329 | await stop(); 330 | } 331 | tests(); --------------------------------------------------------------------------------