├── .babelrc ├── .eslintrc.js ├── .gitattributes ├── .github └── FUNDING.yml ├── .gitignore ├── .npmignore ├── .stylelintrc ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── appveyor.yml ├── bin └── uninstall.js ├── docs ├── Docker.md └── README.md ├── nodemon.json ├── package-lock.json ├── package.json ├── src ├── app │ ├── Store.ts │ ├── api.ts │ ├── components │ │ ├── App │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── Content │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── Link │ │ │ └── index.tsx │ │ ├── Nav │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── Splash │ │ │ ├── index.css │ │ │ └── index.tsx │ │ └── Switch │ │ │ ├── index.css │ │ │ └── index.tsx │ ├── formatter.ts │ ├── global.d.ts │ ├── index.html │ └── index.tsx ├── cli │ ├── bin.js │ ├── daemon.js │ ├── index.js │ ├── run.js │ └── servers.js ├── common.js ├── conf.js ├── daemon │ ├── app.js │ ├── group.js │ ├── index.js │ ├── loader.js │ ├── log.js │ ├── pem.js │ ├── public │ │ ├── error.css │ │ └── favicon.png │ ├── routers │ │ ├── api │ │ │ ├── events.js │ │ │ ├── index.js │ │ │ └── servers.js │ │ └── index.js │ ├── tcp-proxy.js │ ├── vhosts │ │ └── tld.js │ └── views │ │ ├── _error.pug │ │ ├── proxy-pac-with-proxy.pug │ │ ├── proxy-pac.pug │ │ ├── server-error.pug │ │ └── target-error.pug ├── get-cmd.js ├── pid-file.js └── scripts │ └── uninstall.js ├── test ├── _setup.js ├── cli │ ├── daemon.js │ ├── run.js │ └── servers.js ├── daemon │ ├── app.js │ ├── group.js │ └── pem.js └── fixtures │ ├── app │ └── index.js │ └── verbose │ └── index.js ├── tsconfig.json ├── tslint.json ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "targets": { 5 | "node": "6" 6 | } 7 | }] 8 | ], 9 | "plugins": [ 10 | "transform-object-rest-spread" 11 | ] 12 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['standard', 'prettier'], 3 | plugins: ['prettier'], 4 | rules: { 5 | 'prettier/prettier': [ 6 | 'error', 7 | { 8 | singleQuote: true, 9 | semi: false, 10 | }, 11 | ] 12 | }, 13 | env: { mocha: true } 14 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: typicode 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | node_modules 4 | lib 5 | dist 6 | static 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "stylelint-config-standard", "stylelint-config-recess-order" ] 3 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "stable" 4 | - "6" 5 | script: 6 | - npm run build 7 | - npm test 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 0.8.7 4 | 5 | * Fix UI menu overflow 6 | 7 | ## 0.8.6 8 | 9 | * Fix `The listener must be a function` error 10 | 11 | ## 0.8.5 12 | 13 | * Fix colors in output 14 | 15 | ## 0.8.4 16 | 17 | * Fix UI crash 18 | 19 | ## 0.8.3 20 | 21 | * Fix error in Edge 22 | * Improve bundle size 23 | 24 | ## 0.8.2 25 | 26 | * UI 27 | 28 | ## 0.8.1 29 | 30 | * Fix error page 31 | 32 | ## 0.8.0 33 | 34 | * Create empty `conf.json` if it doesn't exist 35 | * Update UI 36 | * New 2018 style 🎉 37 | * Links now open in new tabs (should improve integration with third-party tools) 38 | * Update all dependencies 39 | 40 | __Breaking__ 41 | 42 | * Drop Internet Explorer 11 support for the UI 43 | * Drop Node 4 support 44 | * Self-signed certicate is now generated locally and can be found in `~/.hotel`. Since it's going to be a new one, you'll need to "trust" it again to be able to use `https` 45 | * __`.localhost` is now the default domain and replaces `.dev` domains__ (if present, remove `"tld": "dev"` from `~/.hotel/conf.json` to use the new default value, then run `hotel stop && hotel start`) 46 | 47 | ## 0.7.6 48 | 49 | * Fix `package.json` not found error 50 | 51 | ## 0.7.5 52 | 53 | * Add [please-upgrade-node](https://github.com/typicode/please-upgrade-node) 54 | * Chore: update all dependencies 55 | 56 | ## 0.7.4 57 | 58 | * Remove `util.log` which has been deprecated in Node 6 59 | 60 | ## 0.7.3 61 | 62 | * Prevent `hotel ls` from crashing when listing malformed files [#190](https://github.com/typicode/hotel/pull/190) 63 | 64 | ## 0.7.2 65 | 66 | * Update error page UI 67 | * Update Self-Signed SSL Certificate (__you may need to add an exception again__) 68 | * Fix Vue warning message in UI 69 | 70 | ## 0.7.1 71 | 72 | * Fix daemon error 73 | 74 | ## 0.7.0 75 | 76 | * Add `run` command 77 | * Add `http-proxy-env` flag to `hotel add` 78 | * Drop Node `0.12` support 79 | 80 | __Breaking__ 81 | 82 | * By default no `HTTP_PROXY` env will be passed to servers. To pass `HTTP_PROXY` you need to set it in your server configuration or use the flag `http-proxy-env` when adding your server. 83 | 84 | ## 0.6.1 85 | 86 | * Prevent using unsupported characters with `hotel add --name` [#100](https://github.com/typicode/hotel/issues/100) 87 | 88 | ## 0.6.0 89 | 90 | * Add `--xfwd` and `--change-origin` flags to `hotel add` command 91 | * Log proxy errors 92 | 93 | __Breaking__ 94 | 95 | * If you want hotel to add `X-Forwarded-*` headers to requests, you need now to explicitly pass `-x/--xfwd` flags when adding a server. 96 | 97 | ## 0.5.13 98 | 99 | * Fix `hotel add` CLI bug 100 | 101 | ## 0.5.12 102 | 103 | * Add dark theme 104 | * Update `X-Forwarded-Port` header 105 | * Improve `ember-cli` and `livereload` support 106 | 107 | ## 0.5.11 108 | 109 | * Add more `X-Forwarded-*` headers 110 | 111 | ## 0.5.10 112 | 113 | * Pass `HTTP_PROXY` env to servers started by hotel 114 | 115 | ## 0.5.9 116 | 117 | * UI bug fix 118 | 119 | ## 0.5.8 120 | 121 | * Add `favicon` 122 | * Fix Safari and IE bug 123 | 124 | ## 0.5.6 125 | 126 | * Fix Safari bug 127 | 128 | ## 0.5.5 129 | 130 | * Add `X-Forwarded-Proto` header for ssl proxy 131 | * Support an array of environment variables for the CLI option `--env` 132 | * UI enhancements 133 | 134 | ## 0.5.4 135 | 136 | * Fix Node 0.12 issue 137 | 138 | ## 0.5.3 139 | 140 | * UI tweaks 141 | 142 | ## 0.5.2 143 | 144 | * Fix option alias issue [#109](https://github.com/typicode/hotel/issues/109) 145 | 146 | ## 0.5.1 147 | 148 | * Fix conf issue 149 | 150 | ## 0.5.0 151 | 152 | * Various UI improvements 153 | * Add URL mapping support, for example `hotel add http://192.168.1.10 --name remote-server` 154 | * Change `hotel rm` options 155 | 156 | ## 0.4.22 157 | 158 | * UI tweaks 159 | 160 | ## 0.4.21 161 | 162 | * Fix UI issue with IE 163 | 164 | ## 0.4.20 165 | 166 | * Fix UI issue with Safari 9 167 | 168 | ## 0.4.19 169 | 170 | * Support ANSI colors in the browser 171 | 172 | ## 0.4.18 173 | 174 | * Bug fix 175 | 176 | ## 0.4.17 177 | 178 | * Add `proxy` conf, use it if you're behind a corporate proxy. 179 | * Bug fix 180 | 181 | ## 0.4.16 182 | 183 | * Fix issue with project names containing characters not allowed for a domain name. By default, `hotel add` will now convert name to lower case and will replace space and `_` characters. However, you can still use `-n` to force a specific name or specific characters. 184 | 185 | ## 0.4.15 186 | 187 | * Fix blank page issue in `v0.4.14`. 188 | 189 | ## 0.4.14 190 | 191 | * Fix UI issues. 192 | 193 | ## 0.4.13 194 | 195 | * Fix issue with Node 0.12. 196 | 197 | ## 0.4.12 198 | 199 | * Add wildcard subdomains `http://*.app.localhost`. 200 | 201 | ## 0.4.11 202 | 203 | * Strip ANSI when viewing logs in the browser. 204 | 205 | ## 0.4.10 206 | 207 | * Fix IE and Safari issue (added fetch polyfill). 208 | 209 | ## 0.4.9 210 | 211 | * Add server logs in the browser. 212 | * Bundle icons to make them available without network access. 213 | * Bug fixes. 214 | 215 | ## 0.4.8 216 | 217 | * Bug fix 218 | 219 | ## 0.4.7 220 | 221 | * Bundle front-end dependencies to make homepage work without network access. 222 | * Support subdomains `http://sub.app.localhost`. 223 | * Support `https` and `wss`. 224 | 225 | ## 0.4.6 226 | 227 | * Bug fixes (0.4.3 to 0.4.5 deprecated). 228 | * Added `~/.hotel/daemon.pid` file. 229 | 230 | ## 0.4.3 231 | 232 | * UI update. 233 | * Added top-level domain configuration option `tld`. 234 | * Added IE support. 235 | 236 | ## 0.4.2 237 | 238 | * Removed `socket.io` dependency. 239 | 240 | ## 0.4.1 241 | 242 | * Added WebSocket support for projects being accessed using local `.localhost` domain. 243 | 244 | ## 0.4.0 245 | 246 | * Added Local `.localhost` domain support for HTTP requests. 247 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-present 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. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hotel [![](https://badge.fury.io/js/hotel.svg)](https://www.npmjs.com/package/hotel) 2 | 3 | > Start apps from your browser and use local domains/https automatically 4 | 5 | ![](https://i.imgur.com/eDLgWMj.png) 6 | 7 | _Tip: if you don't enable local domains, hotel can still be used as a **catalog of local servers**._ 8 | 9 | Hotel works great on any OS (macOS, Linux, Windows) and with __all servers :heart:__ 10 | * Node (Express, Webpack) 11 | * PHP (Laravel, Symfony) 12 | * Ruby (Rails, Sinatra, Jekyll) 13 | * Python (Django) 14 | * Docker 15 | * Go 16 | * Apache, Nginx 17 | * ... 18 | 19 | _To all the amazing people who have answered the Hotel survey, thanks so much <3 !_ 20 | 21 | ## v0.8.0 upgrade 22 | 23 | `.localhost` replaces `.dev` local domain and is the new default. See https://ma.ttias.be/chrome-force-dev-domains-https-via-preloaded-hsts/ for context. 24 | 25 | If you're upgrading, please be sure to: 26 | 1. Remove `"tld": "dev"` from your `~/.hotel/conf.json` file 27 | 2. Run `hotel stop && hotel start` 28 | 3. Refresh your network settings 29 | 30 | ## Support 31 | 32 | If you are benefiting from hotel, you can support its development on [Patreon](https://patreon.com/typicode). 33 | 34 | You can view the list of Supporters here https://thanks.typicode.com. 35 | 36 | ## Video 37 | 38 | * [Starting apps with Hotel - Spacedojo Code Kata by Josh Owens](https://www.youtube.com/watch?v=BHW4tzctQ0k) 39 | 40 | ## Features 41 | 42 | * __Local domains__ - `http://project.localhost` 43 | * __HTTPS via local self-signed SSL certificate__ - `https://project.localhost` 44 | * __Wildcard subdomains__ - `http://*.project.localhost` 45 | * __Works everywhere__ - macOS, Linux and Windows 46 | * __Works with any server__ - Node, Ruby, PHP, ... 47 | * __Proxy__ - Map local domains to remote servers 48 | * __System-friendly__ - No messing with `port 80`, `/etc/hosts`, `sudo` or additional software 49 | * Fallback URL - `http://localhost:2000/project` 50 | * Servers are only started when you access them 51 | * Plays nice with other servers (Apache, Nginx, ...) 52 | * Random or fixed ports 53 | 54 | ## Install 55 | 56 | ```sh 57 | npm install -g hotel && hotel start 58 | ``` 59 | 60 | Hotel requires Node to be installed, if you don't have it, you can simply install it using one of the following method: 61 | 62 | * https://github.com/creationix/nvm `nvm install stable` 63 | * https://brew.sh `brew install node` 64 | 65 | You can also visit https://nodejs.org. 66 | 67 | ## Quick start 68 | 69 | ### Local domains (optional) 70 | 71 | To use local `.localhost` domains, you need to configure your network or browser to use hotel's proxy auto-config file or you can skip this step for the moment and go directly to http://localhost:2000 72 | 73 | [__See instructions here__](https://github.com/typicode/hotel/blob/master/docs/README.md). 74 | 75 | ### Add your servers 76 | 77 | ```sh 78 | # Add your server to hotel 79 | ~/projects/one$ hotel add 'npm start' 80 | # Or start your server in the terminal as usual and get a temporary local domain 81 | ~/projects/two$ hotel run 'npm start' 82 | ``` 83 | 84 | Visit [localhost:2000](http://localhost:2000) or [http(s)://hotel.localhost](http://hotel.localhost). 85 | 86 | Alternatively you can directly go to 87 | 88 | ``` 89 | http://localhost:2000/one 90 | http://localhost:2000/two 91 | ``` 92 | 93 | ``` 94 | http(s)://one.localhost 95 | http(s)://two.localhost 96 | ``` 97 | 98 | #### Popular servers examples 99 | 100 | Using other servers? Here are some examples to get you started :) 101 | 102 | ```sh 103 | hotel add 'ember server' # Ember 104 | hotel add 'jekyll serve --port $PORT' # Jekyll 105 | hotel add 'rails server -p $PORT -b 127.0.0.1' # Rails 106 | hotel add 'python -m SimpleHTTPServer $PORT' # static file server (Python) 107 | hotel add 'php -S 127.0.0.1:$PORT' # PHP 108 | hotel add 'docker-compose up' # docker-compose 109 | hotel add 'python manage.py runserver 127.0.0.1:$PORT' # Django 110 | # ... 111 | ``` 112 | 113 | On __Windows__ use `"%PORT%"` instead of `'$PORT'` 114 | 115 | [__See a Docker example here.__](https://github.com/typicode/hotel/blob/master/docs/Docker.md). 116 | 117 | ### Proxy requests to remote servers 118 | 119 | Add your remote servers 120 | 121 | ```sh 122 | ~$ hotel add http://192.168.1.12:1337 --name aliased-address 123 | ~$ hotel add http://google.com --name aliased-domain 124 | ``` 125 | 126 | You can now access them using 127 | 128 | ```sh 129 | http://aliased-address.localhost # will proxy requests to http://192.168.1.12:1337 130 | http://aliased-domain.localhost # will proxy requests to http://google.com 131 | ``` 132 | 133 | ## CLI usage and options 134 | 135 | ```sh 136 | hotel add [opts] 137 | hotel run [opts] 138 | 139 | # Examples 140 | 141 | hotel add 'nodemon app.js' --out dev.log # Set output file (default: none) 142 | hotel add 'nodemon app.js' --name name # Set custom name (default: current dir name) 143 | hotel add 'nodemon app.js' --port 3000 # Set a fixed port (default: random port) 144 | hotel add 'nodemon app.js' --env PATH # Store PATH environment variable in server config 145 | hotel add http://192.168.1.10 --name app # map local domain to URL 146 | 147 | hotel run 'nodemon app.js' # Run server and get a temporary local domain 148 | 149 | # Other commands 150 | 151 | hotel ls # List servers 152 | hotel rm # Remove server 153 | hotel start # Start hotel daemon 154 | hotel stop # Stop hotel daemon 155 | ``` 156 | 157 | To get help 158 | 159 | ```sh 160 | hotel --help 161 | hotel --help 162 | ``` 163 | 164 | ## Port 165 | 166 | For `hotel` to work, your servers need to listen on the PORT environment variable. 167 | Here are some examples showing how you can do it from your code or the command-line: 168 | 169 | ```js 170 | var port = process.env.PORT || 3000 171 | server.listen(port) 172 | ``` 173 | 174 | ```sh 175 | hotel add 'cmd -p $PORT' # OS X, Linux 176 | hotel add "cmd -p %PORT%" # Windows 177 | ``` 178 | 179 | ## Fallback URL 180 | 181 | If you're offline or can't configure your browser to use `.localhost` domains, you can __always__ access your local servers by going to [localhost:2000](http://localhost:2000). 182 | 183 | ## Configurations, logs and self-signed SSL certificate 184 | 185 | You can find hotel related files in `~/.hotel` : 186 | 187 | ```sh 188 | ~/.hotel/conf.json 189 | ~/.hotel/daemon.log 190 | ~/.hotel/daemon.pid 191 | ~/.hotel/key.pem 192 | ~/.hotel/cert.pem 193 | ~/.hotel/servers/.json 194 | ``` 195 | 196 | By default, `hotel` uses the following configuration values: 197 | 198 | ```js 199 | { 200 | "port": 2000, 201 | "host": '127.0.0.1', 202 | 203 | // Timeout when proxying requests to local domains 204 | "timeout": 5000, 205 | 206 | // Change this if you want to use another tld than .localhost 207 | "tld": 'localhost', 208 | 209 | // If you're behind a corporate proxy, replace this with your network proxy IP (example: "1.2.3.4:5000") 210 | "proxy": false 211 | } 212 | ``` 213 | 214 | To override a value, simply add it to `~/.hotel/conf.json` and run `hotel stop && hotel start` 215 | 216 | ## Third-party tools 217 | 218 | * [Hotelier](https://github.com/macav/hotelier) Hotelier (Mac & Windows Tray App) 219 | * [Hotel Clerk](https://github.com/therealklanni/hotel-clerk) OS X menubar 220 | * [HotelX](https://github.com/djyde/HotelX) Another OS X menubar (only 1.6MB) 221 | * [alfred-hotel](https://github.com/exah/alfred-hotel) Alfred 3 workflow 222 | * [Hotel Manager](https://github.com/hardpixel/hotel-manager) Gnome Shell extension 223 | 224 | ## FAQ 225 | 226 | #### Setting a fixed port 227 | 228 | ```sh 229 | hotel add --port 3000 'server-cmd $PORT' 230 | ``` 231 | 232 | #### Adding `X-Forwarded-*` headers to requests 233 | 234 | ```sh 235 | hotel add --xfwd 'server-cmd' 236 | ``` 237 | 238 | #### Setting `HTTP_PROXY` env 239 | 240 | Use `--http-proxy-env` flag when adding your server or edit your server configuration in `~/.hotel/servers` 241 | 242 | ```sh 243 | hotel add --http-proxy-env 'server-cmd' 244 | ``` 245 | 246 | #### Proxying requests to a remote `https` server 247 | 248 | ```sh 249 | hotel add --change-origin 'https://jsonplaceholder.typicode.com' 250 | ``` 251 | 252 | _When proxying to a `https` server, you may get an error because your `.localhost` domain doesn't match the host defined in the server certificate. With this flag, `host` header is changed to match the target URL._ 253 | 254 | #### `ENOSPC` and `EACCES` errors 255 | 256 | If you're seeing one of these errors in `~/.hotel/daemon.log`, this usually means that there's some permissions issues. `hotel` daemon should be started without `sudo` and `~/.hotel` should belong to `$USER`. 257 | 258 | ```sh 259 | # to fix permissions 260 | sudo chown -R $USER: $HOME/.hotel 261 | ``` 262 | 263 | See also, https://docs.npmjs.com/getting-started/fixing-npm-permissions 264 | 265 | #### Configuring a network proxy IP 266 | 267 | If you're behind a corporate proxy, replace `"proxy"` with your network proxy IP in `~/.hotel/conf.json`. For example: 268 | 269 | ```json 270 | { 271 | "proxy": "1.2.3.4:5000" 272 | } 273 | ``` 274 | 275 | ## License 276 | 277 | MIT 278 | 279 | [Patreon](https://www.patreon.com/typicode) - [Supporters](https://thanks.typicode.com) ✨ 280 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | nodejs_version: '6' 3 | 4 | install: 5 | - ps: Install-Product node $env:nodejs_version 6 | - npm install --ignore-scripts 7 | 8 | test_script: 9 | - node --version 10 | - npm --version 11 | - npm test 12 | 13 | build_script: npm run build 14 | -------------------------------------------------------------------------------- /bin/uninstall.js: -------------------------------------------------------------------------------- 1 | require('../lib/scripts/uninstall')() 2 | -------------------------------------------------------------------------------- /docs/Docker.md: -------------------------------------------------------------------------------- 1 | # Docker 2 | 3 | [Docker](https://www.docker.com/) is a software container platform that integrates easily into Hotel. 4 | 5 | ### Dockerfile 6 | 7 | A [Dockerfile](https://docs.docker.com/engine/reference/builder/) is used to create a single container. Here is an example: 8 | 9 | ``` 10 | FROM httpd:2.4 11 | COPY ./public-html/ /usr/local/apache2/htdocs/ 12 | ``` 13 | 14 | To build this image, run the following in the application directory: 15 | 16 | ``` 17 | docker build -t my-apache2 . 18 | ``` 19 | 20 | To use Hotel for this example, run the following in the application directory: 21 | 22 | ``` 23 | hotel add 'docker run -dit --name my-running-app my-apache2' 24 | ``` 25 | 26 | ### Docker Compose 27 | 28 | [Compose](https://docs.docker.com/compose/) is a tool for defining and running multi-container Docker applications. Here is a simple example: 29 | 30 | ``` 31 | version: '3' 32 | services: 33 | web: 34 | build: . 35 | ports: 36 | - "5000:5000" 37 | ``` 38 | 39 | This binds the internal port 5000 on the container to port 5000 on the host machine. To build this image, run the following: 40 | 41 | `docker-compose build` 42 | 43 | To use Hotel for this, run the following in the application directory: 44 | 45 | ``` 46 | hotel add 'docker-compose up' -p 5000 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Configuring local .localhost domains 2 | 3 | _This step is totally optional and you can use hotel without it._ 4 | 5 | To use local `.localhost` domain, you need to configure your browser or network to use hotel's proxy auto-config file which is available at `http://localhost:2000/proxy.pac` [[view file content](../src/daemon/views/proxy-pac.pug)]. 6 | 7 | __Important__ hotel MUST be running before configuring your network or browser so that `http://localhost:2000/proxy.pac` is available. If hotel is started after and you can't access `.localhost` domains, simply disable/enable network or restart browser. 8 | 9 | ## Configuring another .tld 10 | 11 | You can edit `~/.hotel/conf.json` to use another Top-level Domain than `.localhost`. 12 | 13 | ```json 14 | { 15 | "tld": "test" 16 | } 17 | ``` 18 | 19 | __Important__ Don't forget to restart hotel and reload network or browser configuration. 20 | 21 | ## System configuration (recommended) 22 | 23 | ##### macOS 24 | 25 | `Network Preferences > Advanced > Proxies > Automatic Proxy Configuration` 26 | 27 | ##### Windows 28 | 29 | `Settings > Network and Internet > Proxy > Use setup script` 30 | 31 | ##### Linux 32 | 33 | On Ubuntu 34 | 35 | `System Settings > Network > Network Proxy > Automatic` 36 | 37 | For other distributions, check your network manager and look for proxy configuration. Use browser configuration as an alternative. 38 | 39 | ## Browser configuration 40 | 41 | Browsers can be configured to use a specific proxy. Use this method as an alternative to system-wide configuration. 42 | 43 | ##### Chrome 44 | 45 | Exit Chrome and start it using the following option: 46 | 47 | ```sh 48 | # Linux 49 | $ google-chrome --proxy-pac-url=http://localhost:2000/proxy.pac 50 | 51 | # macOS 52 | $ open -a "Google Chrome" --args --proxy-pac-url=http://localhost:2000/proxy.pac 53 | ``` 54 | 55 | ##### Firefox 56 | 57 | `Preferences > Advanced > Network > Connection > Settings > Automatic proxy URL configuration` 58 | 59 | ##### Internet Explorer 60 | 61 | Uses system network configuration. 62 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "exec": "babel-node", 3 | "ignore": [ 4 | "src/app/**/*", 5 | "src/daemon/public/**/*" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hotel", 3 | "version": "1.0.0", 4 | "description": "Local domains for everyone and more! ", 5 | "main": "lib", 6 | "bin": "lib/cli/bin.js", 7 | "engines": { 8 | "node": ">=6" 9 | }, 10 | "scripts": { 11 | "test": "ava && npm run lint", 12 | "lint": "eslint . --ignore-path .gitignore && stylelint './src/app/**/*.css'", 13 | "fix": "eslint . --ignore-path .gitignore --fix && stylelint './src/app/**/*.css' --fix", 14 | "start": "run-p start:*", 15 | "start:webpack": "webpack-dev-server --config webpack.dev.js --open", 16 | "start:nodemon": "nodemon -- src/daemon", 17 | "prepublishOnly": "npm run build && pkg-ok", 18 | "uninstall": "node bin/uninstall.js", 19 | "build": "run-s build:*", 20 | "build:webpack": "rimraf dist && webpack --config webpack.prod.js", 21 | "build:babel": "rimraf lib && babel src -d lib --copy-files --ignore src/app", 22 | "addFixtures": "node src/cli/bin add \"node index\" --dir ./test/fixtures/app && node src/cli/bin add \"node index\" --dir ./test/fixtures/verbose && node src/cli/bin add http://some-domain.com --name local-domain " 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/typicode/hotel.git" 27 | }, 28 | "keywords": [ 29 | "dev", 30 | "devtool", 31 | "domain", 32 | "host", 33 | "https", 34 | "local", 35 | "localhost", 36 | "manager", 37 | "process", 38 | "proxy", 39 | "server" 40 | ], 41 | "author": "Typicode ", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/typicode/hotel/issues" 45 | }, 46 | "homepage": "https://github.com/typicode/hotel", 47 | "dependencies": { 48 | "after-all": "^2.0.2", 49 | "ansi2html": "0.0.1", 50 | "chalk": "^2.3.1", 51 | "chokidar": "^2.0.2", 52 | "connect-sse": "^1.2.0", 53 | "exit-hook": "^1.1.1", 54 | "express": "^4.16.2", 55 | "get-port": "^3.2.0", 56 | "http-proxy": "^1.17.0", 57 | "matcher": "^1.1.0", 58 | "mkdirp": "^0.5.1", 59 | "once": "^1.3.2", 60 | "please-upgrade-node": "^3.0.2", 61 | "pug": "^2.0.0-rc.4", 62 | "respawn": "^2.4.1", 63 | "selfsigned": "^1.10.2", 64 | "server-ready": "^0.3.1", 65 | "sudo-block": "^2.0.0", 66 | "tildify": "^1.1.2", 67 | "tinydate": "^1.0.0", 68 | "unquote": "^1.1.1", 69 | "untildify": "^3.0.2", 70 | "update-notifier": "^2.3.0", 71 | "user-startup": "^0.2.1", 72 | "vhost": "^3.0.2", 73 | "yargs": "^10.1.2" 74 | }, 75 | "devDependencies": { 76 | "@types/classnames": "^2.2.3", 77 | "@types/escape-html": "0.0.20", 78 | "@types/react": "^16.0.38", 79 | "@types/react-dom": "^16.0.4", 80 | "@types/react-icons": "^2.2.5", 81 | "ava": "^0.25.0", 82 | "babel-cli": "^6.26.0", 83 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 84 | "babel-preset-env": "^1.6.1", 85 | "classnames": "^2.2.5", 86 | "css-loader": "^0.28.10", 87 | "escape-html": "^1.0.3", 88 | "eslint": "^4.18.1", 89 | "eslint-config-prettier": "^2.9.0", 90 | "eslint-config-standard": "^11.0.0", 91 | "eslint-plugin-import": "^2.9.0", 92 | "eslint-plugin-node": "^5.2.1", 93 | "eslint-plugin-prettier": "^2.6.0", 94 | "eslint-plugin-promise": "^3.6.0", 95 | "eslint-plugin-standard": "^3.0.1", 96 | "html-webpack-plugin": "^2.30.1", 97 | "husky": "^0.15.0-rc.8", 98 | "lodash.uniqueid": "^4.0.1", 99 | "mobx": "^3.5.1", 100 | "mobx-react": "^4.4.2", 101 | "nodemon": "^1.15.1", 102 | "npm-run-all": "^4.1.2", 103 | "pkg-ok": "^2.1.0", 104 | "prettier": "^1.10.2", 105 | "react": "^16.2.0", 106 | "react-dom": "^16.2.0", 107 | "react-icons": "^2.2.7", 108 | "rimraf": "^2.6.2", 109 | "sinon": "^4.4.2", 110 | "style-loader": "^0.19.1", 111 | "stylelint": "^8.4.0", 112 | "stylelint-config-recess-order": "^1.2.3", 113 | "stylelint-config-standard": "^18.1.0", 114 | "supertest": "^3.0.0", 115 | "tempy": "^0.2.0", 116 | "ts-loader": "^3.5.0", 117 | "tslint": "^5.9.1", 118 | "tslint-config-prettier": "^1.9.0", 119 | "tslint-plugin-prettier": "^1.3.0", 120 | "typescript": "^2.7.2", 121 | "uglifyjs-webpack-plugin": "^1.2.2", 122 | "webpack": "^3.11.0", 123 | "webpack-dev-server": "^2.11.1", 124 | "webpack-merge": "^4.1.2" 125 | }, 126 | "ava": { 127 | "serial": true, 128 | "verbose": true, 129 | "require": [ 130 | "babel-register", 131 | "./test/_setup" 132 | ], 133 | "babel": "inherit" 134 | }, 135 | "husky": { 136 | "hooks": { 137 | "pre-commit": "npm test" 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/app/Store.ts: -------------------------------------------------------------------------------- 1 | import * as uniqueId from 'lodash.uniqueid' 2 | import { action, computed, observable } from 'mobx' 3 | import * as api from './api' 4 | import { formatLines } from './formatter' 5 | 6 | export interface IProxy { 7 | target: string 8 | } 9 | 10 | export interface ILine { 11 | id: string 12 | html: string 13 | } 14 | 15 | export interface IMonitor { 16 | cwd: string 17 | command: string[] 18 | status: string 19 | output: ILine[] 20 | started: Date 21 | pid: number 22 | } 23 | 24 | export const RUNNING = 'running' 25 | export const STOPPED = 'stopped' 26 | const MAX_OUTPUT_LENGTH = 1000 27 | 28 | function clear(servers: Map, data: any) { 29 | servers.forEach((server, id) => { 30 | if (!data.hasOwnProperty(id)) { 31 | servers.delete(id) 32 | } 33 | }) 34 | } 35 | 36 | export default class Store { 37 | @observable public isLoading: boolean = true 38 | @observable public selectedMonitorId: string = '' 39 | @observable public monitors: Map = new Map() 40 | @observable public proxies: Map = new Map() 41 | 42 | constructor() { 43 | this.watchServers() 44 | this.watchOutput() 45 | } 46 | 47 | @action 48 | public watchServers() { 49 | api.watchServers(data => { 50 | // Delete servers that do not exist anymore in Hotel 51 | clear(this.monitors, data) 52 | clear(this.proxies, data) 53 | 54 | // Create or update servers 55 | Object.keys(data).forEach(id => { 56 | const server = data[id] 57 | if (this.monitors.has(id) || this.proxies.has(id)) { 58 | // Update server state 59 | if (server.hasOwnProperty('status')) { 60 | Object.assign(this.monitors.get(id), server) 61 | } else { 62 | Object.assign(this.proxies.get(id), server) 63 | } 64 | } else { 65 | // Create new server 66 | if (server.hasOwnProperty('status')) { 67 | server.output = [] 68 | this.monitors.set(id, server) 69 | } else { 70 | this.proxies.set(id, server) 71 | } 72 | } 73 | }) 74 | 75 | // Initial data has been loaded 76 | this.isLoading = false 77 | }) 78 | } 79 | 80 | @action 81 | public watchOutput() { 82 | api.watchOutput(data => { 83 | const { id, output } = data 84 | const lines = formatLines(output).map(html => ({ 85 | html, 86 | id: uniqueId() 87 | })) 88 | 89 | lines.forEach(line => { 90 | const monitor = this.monitors.get(id) 91 | if (monitor) { 92 | monitor.output.push(line) 93 | 94 | if (monitor.output.length > MAX_OUTPUT_LENGTH) { 95 | monitor.output.shift() 96 | } 97 | } 98 | }) 99 | }) 100 | } 101 | 102 | @action 103 | public selectMonitor(monitorId: string) { 104 | this.selectedMonitorId = 105 | this.selectedMonitorId === monitorId ? '' : monitorId 106 | } 107 | 108 | @action 109 | public toggleMonitor(monitorId: string) { 110 | const monitor = this.monitors.get(monitorId) 111 | 112 | if (monitor) { 113 | if (monitor.status === RUNNING) { 114 | api.stopMonitor(monitorId) 115 | monitor.status = STOPPED // optimistic update 116 | } else { 117 | api.startMonitor(monitorId) 118 | monitor.status = RUNNING 119 | } 120 | } 121 | } 122 | 123 | @action 124 | public clearOutput(monitorId: string) { 125 | const monitor = this.monitors.get(monitorId) 126 | if (monitor) { 127 | monitor.output = [] 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/app/api.ts: -------------------------------------------------------------------------------- 1 | interface IEvent { 2 | data: string 3 | } 4 | 5 | export function fetchServers() { 6 | return window.fetch('/_/servers').then(response => response.json()) 7 | } 8 | 9 | export function watchServers(cb: (data: any) => void) { 10 | if (window.EventSource) { 11 | new window.EventSource('/_/events').onmessage = (event: IEvent) => { 12 | const data = JSON.parse(event.data) 13 | cb(data) 14 | } 15 | } else { 16 | setInterval(() => { 17 | window 18 | .fetch('/_/servers') 19 | .then(response => response.json()) 20 | .then(data => cb(data)) 21 | }, 1000) 22 | } 23 | } 24 | 25 | export function watchOutput(cb: (data: any) => void) { 26 | if (window.EventSource) { 27 | new window.EventSource('/_/events/output').onmessage = (event: IEvent) => { 28 | const data = JSON.parse(event.data) 29 | cb(data) 30 | } 31 | } else { 32 | window.alert("Sorry, server logs aren't supported on this browser :(") 33 | } 34 | } 35 | 36 | export function startMonitor(id: string) { 37 | return window.fetch(`/_/servers/${id}/start`, { method: 'POST' }) 38 | } 39 | 40 | export function stopMonitor(id: string) { 41 | return window.fetch(`/_/servers/${id}/stop`, { method: 'POST' }) 42 | } 43 | -------------------------------------------------------------------------------- /src/app/components/App/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | :root { 6 | --primary-light: #484848; 7 | --primary: #212121; 8 | --primary: #282c2f; 9 | --primary-dark: #000; 10 | --accent: #4c9e97; 11 | } 12 | 13 | body { 14 | padding: 0; 15 | margin: 0; 16 | font-family: 'Roboto', sans-serif; 17 | color: white; 18 | background-color: var(--primary-dark); 19 | text-rendering: optimizeLegibility; 20 | -webkit-font-smoothing: antialiased; 21 | -moz-osx-font-smoothing: grayscale; 22 | } 23 | 24 | a { 25 | color: #6c6c6f; 26 | text-decoration: none; 27 | } 28 | 29 | a:hover { 30 | color: white; 31 | text-decoration: underline; 32 | } 33 | 34 | .nav { 35 | height: 100vh; 36 | } 37 | 38 | /* 640px */ 39 | @media (min-width: 40rem) { 40 | .container { 41 | display: grid; 42 | grid-template-columns: 1fr 3fr; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/components/App/index.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react' 2 | import * as React from 'react' 3 | import Store from '../../Store' 4 | import Content from '../Content' 5 | import Nav from '../Nav' 6 | import Splash from '../Splash' 7 | import './index.css' 8 | 9 | export interface IProps { 10 | store: Store 11 | } 12 | 13 | function App({ store }: IProps) { 14 | return ( 15 |
16 |
19 | ) 20 | } 21 | 22 | export default observer(App) 23 | -------------------------------------------------------------------------------- /src/app/components/Content/index.css: -------------------------------------------------------------------------------- 1 | .content { 2 | height: 100vh; 3 | overflow-y: scroll; 4 | background-color: var(--primary); 5 | } 6 | 7 | .content a { 8 | color: white; 9 | } 10 | 11 | .content-bar { 12 | position: sticky; 13 | top: 0; 14 | left: 0; 15 | display: flex; 16 | justify-content: space-between; 17 | padding: 0 0 0 1rem; 18 | background-color: var(--primary-dark); 19 | } 20 | 21 | .content-bar > span { 22 | align-self: center; 23 | } 24 | 25 | pre { 26 | padding: 1rem; 27 | margin: 0; 28 | word-break: break-all; 29 | } 30 | 31 | button { 32 | padding: 1rem; 33 | color: white; 34 | cursor: pointer; 35 | background: none; 36 | border: none; 37 | outline: none; 38 | } 39 | 40 | button:hover { 41 | background: var(--primary); 42 | } 43 | -------------------------------------------------------------------------------- /src/app/components/Content/index.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react' 2 | import * as React from 'react' 3 | import * as MdArrowDownward from 'react-icons/lib/md/arrow-downward' 4 | import * as MdClearAll from 'react-icons/lib/md/clear-all' 5 | import Link from '../Link' 6 | 7 | import Store from '../../Store' 8 | import './index.css' 9 | 10 | export interface IProps { 11 | store: Store 12 | } 13 | 14 | @observer 15 | class Content extends React.Component { 16 | private el: HTMLDivElement | null = null 17 | private atBottom: boolean = true 18 | 19 | public componentWillUpdate() { 20 | if (this.el) { 21 | this.atBottom = this.isAtBottom() 22 | } 23 | } 24 | 25 | public componentDidUpdate() { 26 | if (this.atBottom) { 27 | this.scrollToBottom() 28 | } 29 | } 30 | 31 | public isAtBottom() { 32 | if (this.el) { 33 | const { scrollHeight, scrollTop, clientHeight } = this.el 34 | return scrollHeight - scrollTop === clientHeight 35 | } else { 36 | return true 37 | } 38 | } 39 | 40 | public scrollToBottom() { 41 | if (this.el) { 42 | this.el.scrollTop = this.el.scrollHeight 43 | } 44 | } 45 | 46 | public onScroll() { 47 | this.atBottom = this.isAtBottom() 48 | } 49 | 50 | public render() { 51 | const { store } = this.props 52 | const monitor = store.monitors.get(store.selectedMonitorId) 53 | return ( 54 |
this.onScroll()} 57 | ref={el => { 58 | this.el = el 59 | }} 60 | > 61 |
62 | 63 | 64 | 65 | 66 | 72 | 78 | 79 |
80 |
81 |           {monitor &&
82 |             monitor.output.map(line => (
83 |               
87 | ))} 88 |
89 |
90 | ) 91 | } 92 | } 93 | 94 | export default Content 95 | -------------------------------------------------------------------------------- /src/app/components/Link/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { IMonitor, IProxy } from '../../Store' 3 | 4 | function href(id: string) { 5 | const { protocol, hostname } = window.location 6 | if (/hotel\./.test(hostname)) { 7 | // Accessed using hotel.tld 8 | const tld = hostname.split('.').slice(-1)[0] 9 | return `${protocol}//${id}.${tld}` 10 | } else { 11 | // Accessed using localhost 12 | return `/${id}` 13 | } 14 | } 15 | 16 | interface IProps { 17 | id: string 18 | } 19 | 20 | function Link({ id }: IProps) { 21 | return ( 22 | e.stopPropagation()}> 23 | {id} 24 | 25 | ) 26 | } 27 | 28 | export default Link 29 | -------------------------------------------------------------------------------- /src/app/components/Nav/index.css: -------------------------------------------------------------------------------- 1 | .nav { 2 | display: flex; 3 | flex-direction: column; 4 | min-height: 100vh; 5 | border-right: 1px solid var(--primary); 6 | } 7 | 8 | header { 9 | padding: 1rem; 10 | font-size: 1rem; 11 | line-height: 1rem; 12 | text-transform: capitalize; 13 | border-bottom: 1px solid var(--primary); 14 | } 15 | 16 | .menu { 17 | flex: 1; 18 | overflow-y: scroll; 19 | } 20 | 21 | .menu.hidden { 22 | visibility: hidden; 23 | } 24 | 25 | footer { 26 | padding: 1rem; 27 | } 28 | 29 | h2 { 30 | padding: 1rem; 31 | margin: 0; 32 | font-size: 1rem; 33 | font-weight: normal; 34 | text-transform: uppercase; 35 | } 36 | 37 | ul { 38 | padding: 0; 39 | margin: 0 0 2rem 0; 40 | list-style: none; 41 | } 42 | 43 | li { 44 | display: flex; 45 | justify-content: space-between; 46 | height: 3rem; 47 | color: var(--primary-light); 48 | transition: border-color 0.2s; 49 | } 50 | 51 | li:focus { 52 | outline: none; 53 | } 54 | 55 | li.running * { 56 | color: white; 57 | } 58 | 59 | li.monitor:hover { 60 | cursor: pointer; 61 | background: var(--primary); 62 | } 63 | 64 | li.selected { 65 | background: var(--primary); 66 | } 67 | 68 | li > span { 69 | align-self: center; 70 | padding: 0 1rem; 71 | } 72 | 73 | /* Align Switch vertically */ 74 | li > span:nth-last-child() { 75 | line-height: 0; 76 | } 77 | 78 | p { 79 | padding: 1rem; 80 | } 81 | -------------------------------------------------------------------------------- /src/app/components/Nav/index.tsx: -------------------------------------------------------------------------------- 1 | import * as classNames from 'classnames' 2 | import { observer } from 'mobx-react' 3 | import * as React from 'react' 4 | import Store, { RUNNING } from '../../Store' 5 | import Link from '../Link' 6 | import Switch from '../Switch' 7 | import './index.css' 8 | 9 | const examples = `~/app$ hotel add 'cmd' 10 | ~/app$ hotel add 'cmd -p $PORT' 11 | ~/app$ hotel add http://192.16.1.2:3000` 12 | 13 | export interface IProps { 14 | store: Store 15 | } 16 | 17 | function Nav({ store }: IProps) { 18 | const { isLoading, selectedMonitorId, monitors, proxies } = store 19 | return ( 20 |
21 |
hotel
22 |
23 | {monitors.size === 0 && 24 | proxies.size === 0 && ( 25 |
26 |

To add a server, use hotel add

27 |
28 |                 {examples}
29 |               
30 |
31 | )} 32 | 33 | {monitors.size > 0 && ( 34 |
35 |

monitors

36 |
    37 | {Array.from(monitors).map(([id, monitor]) => { 38 | return ( 39 |
  • store.selectMonitor(id)} 46 | > 47 | 48 | 49 | 50 | 51 | store.toggleMonitor(id)} 53 | checked={monitor.status === RUNNING} 54 | /> 55 | 56 |
  • 57 | ) 58 | })} 59 |
60 |
61 | )} 62 | 63 | {proxies.size > 0 && ( 64 |
65 |

proxies

66 |
    67 | {Array.from(proxies).map(([id, proxy]) => { 68 | return ( 69 |
  • 70 | 71 | 72 | 73 |
  • 74 | ) 75 | })} 76 |
77 |
78 | )} 79 |
80 | 85 |
86 | ) 87 | } 88 | 89 | export default observer(Nav) 90 | -------------------------------------------------------------------------------- /src/app/components/Splash/index.css: -------------------------------------------------------------------------------- 1 | .splash { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | height: 100vh; 6 | } 7 | -------------------------------------------------------------------------------- /src/app/components/Splash/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import './index.css' 3 | 4 | function Splash() { 5 | return
{/* Hotel ホテル */}
6 | } 7 | 8 | export default Splash 9 | -------------------------------------------------------------------------------- /src/app/components/Switch/index.css: -------------------------------------------------------------------------------- 1 | .switch { 2 | position: relative; 3 | display: inline-block; 4 | width: 30px; 5 | height: 17px; 6 | } 7 | 8 | .switch input { 9 | display: none; 10 | } 11 | 12 | .slider { 13 | position: absolute; 14 | top: 0; 15 | right: 0; 16 | bottom: 0; 17 | left: 0; 18 | cursor: pointer; 19 | background-color: #ccc; 20 | transition: 0.4s; 21 | } 22 | 23 | .slider::before { 24 | position: absolute; 25 | bottom: 2px; 26 | left: 2px; 27 | width: 13px; 28 | height: 13px; 29 | content: ""; 30 | background-color: white; 31 | transition: 0.4s; 32 | } 33 | 34 | input:checked + .slider { 35 | background-color: var(--accent); 36 | } 37 | 38 | input:focus + .slider { 39 | box-shadow: 0 0 1px var(--primary-dark); /* works ? */ 40 | } 41 | 42 | input:checked + .slider::before { 43 | transform: translateX(13px); 44 | } 45 | 46 | /* Rounded sliders */ 47 | .slider.round { 48 | border-radius: 17px; 49 | } 50 | 51 | .slider.round::before { 52 | border-radius: 50%; 53 | } 54 | -------------------------------------------------------------------------------- /src/app/components/Switch/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import './index.css' 3 | 4 | export interface IProps { 5 | onClick?: () => void 6 | checked?: boolean 7 | } 8 | 9 | function Switch({ onClick = () => null, checked }: IProps) { 10 | return ( 11 | 22 | ) 23 | } 24 | 25 | export default Switch 26 | -------------------------------------------------------------------------------- /src/app/formatter.ts: -------------------------------------------------------------------------------- 1 | import * as ansi2HTML from 'ansi2html' 2 | import * as escapeHTML from 'escape-html' 3 | import { IMonitor } from './Store' 4 | 5 | function blankLine(val: string) { 6 | return val.trim() === '' ? ' ' : val 7 | } 8 | 9 | export function formatLines(str: string): string[] { 10 | return str 11 | .replace(/\n$/, '') 12 | .split('\n') 13 | .map(escapeHTML) 14 | .map(blankLine) 15 | .map(ansi2HTML) 16 | } 17 | 18 | export function statusTitle(monitor: IMonitor) { 19 | return monitor.pid 20 | ? `PID ${monitor.pid}\nStarted since ${new Date( 21 | monitor.started 22 | ).toLocaleString()}` 23 | : '' 24 | } 25 | -------------------------------------------------------------------------------- /src/app/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'ansi2html' 2 | declare module 'lodash.uniqueid' 3 | declare module 'react-icons/lib/md/arrow-downward' 4 | declare module 'react-icons/lib/md/clear-all' 5 | interface Window { EventSource: any } 6 | -------------------------------------------------------------------------------- /src/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hotel 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | -------------------------------------------------------------------------------- /src/app/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as ReactDOM from 'react-dom' 3 | import App from './components/App' 4 | import Store from './Store' 5 | 6 | const store = new Store() 7 | ReactDOM.render(, document.getElementById('root')) 8 | -------------------------------------------------------------------------------- /src/cli/bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const pkg = require('../../package.json') 3 | require('please-upgrade-node')(pkg) 4 | 5 | const updateNotifier = require('update-notifier') 6 | const sudoBlock = require('sudo-block') 7 | 8 | sudoBlock('\nShould not be run as root, please retry without sudo.\n') 9 | updateNotifier({ pkg }).notify() 10 | require('./')(process.argv) 11 | -------------------------------------------------------------------------------- /src/cli/daemon.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const mkdirp = require('mkdirp') 4 | const startup = require('user-startup') 5 | const common = require('../common') 6 | const conf = require('../conf') 7 | const uninstall = require('../scripts/uninstall') 8 | 9 | module.exports = { 10 | start, 11 | stop 12 | } 13 | 14 | // Start daemon in background 15 | function start() { 16 | const node = process.execPath 17 | const daemonFile = path.join(__dirname, '../daemon') 18 | const startupFile = startup.getFile('hotel') 19 | 20 | startup.create('hotel', node, [daemonFile], common.logFile) 21 | 22 | // Save startup file path in ~/.hotel 23 | // Will be used later by uninstall script 24 | mkdirp.sync(common.hotelDir) 25 | fs.writeFileSync(common.startupFile, startupFile) 26 | 27 | console.log(`Started http://localhost:${conf.port}`) 28 | } 29 | 30 | // Stop daemon 31 | function stop() { 32 | startup.remove('hotel') 33 | // kills process and clean stuff in ~/.hotel 34 | uninstall() 35 | console.log('Stopped') 36 | } 37 | -------------------------------------------------------------------------------- /src/cli/index.js: -------------------------------------------------------------------------------- 1 | const yargs = require('yargs') 2 | const servers = require('./servers') 3 | const run = require('./run') 4 | const daemon = require('./daemon') 5 | const pkg = require('../../package.json') 6 | 7 | const addOptions = { 8 | name: { 9 | alias: 'n', 10 | describe: 'Server name' 11 | }, 12 | port: { 13 | alias: 'p', 14 | describe: 'Set PORT environment variable', 15 | number: true 16 | }, 17 | out: { 18 | alias: 'o', 19 | describe: 'Output file' 20 | }, 21 | env: { 22 | alias: 'e', 23 | describe: 'Additional environment variables', 24 | array: true 25 | }, 26 | xfwd: { 27 | alias: 'x', 28 | describe: 'Adds x-forward headers', 29 | default: false, 30 | boolean: true 31 | }, 32 | 'change-origin': { 33 | alias: 'co', 34 | describe: 'Changes the origin of the host header to the target URL', 35 | default: false, 36 | boolean: true 37 | }, 38 | 'http-proxy-env': { 39 | describe: 'Adds HTTP_PROXY environment variable', 40 | default: false, 41 | boolean: true 42 | }, 43 | dir: { 44 | describe: 'Server directory', 45 | string: true 46 | } 47 | } 48 | 49 | module.exports = processArgv => 50 | yargs(processArgv.slice(2)) 51 | .version(pkg.version) 52 | .alias('v', 'version') 53 | .help('h') 54 | .alias('h', 'help') 55 | .command( 56 | 'add [options]', 57 | 'Add server or proxy', 58 | yargs => yargs.options(addOptions), 59 | // .demand(1), 60 | argv => servers.add(argv['cmd_or_url'], argv) 61 | ) 62 | .command( 63 | 'run [options]', 64 | 'Run server and get a temporary local domain', 65 | yargs => { 66 | const runOptions = { ...addOptions } 67 | delete runOptions['out'] 68 | return yargs.options(runOptions) 69 | // TODO demand(1) ? 70 | }, 71 | argv => run.spawn(argv['cmd'], argv) 72 | ) 73 | .command( 74 | 'rm [options]', 75 | 'Remove server or proxy', 76 | yargs => { 77 | yargs.option('name', { 78 | alias: 'n', 79 | describe: 'Name' 80 | }) 81 | }, 82 | argv => servers.rm(argv) 83 | ) 84 | .command('ls', 'List servers', {}, argv => servers.ls(argv)) 85 | .command('start', 'Start daemon', {}, () => daemon.start()) 86 | .command('stop', 'Stop daemon', {}, () => daemon.stop()) 87 | .example('$0 add --help') 88 | .example('$0 add nodemon') 89 | .example('$0 add npm start') 90 | .example("$0 add 'cmd -p $PORT'") 91 | .example("$0 add 'cmd -p $PORT' --port 4000") 92 | .example("$0 add 'cmd -p $PORT' --out app.log") 93 | .example("$0 add 'cmd -p $PORT' --name app") 94 | .example("$0 add 'cmd -p $PORT' --env PATH") 95 | .example('$0 add http://192.168.1.10 -n app ') 96 | .example('$0 rm') 97 | .example('$0 rm -n app') 98 | .epilog('https://github.com/typicode/hotel') 99 | .demand(1) 100 | .strict() 101 | .help().argv 102 | -------------------------------------------------------------------------------- /src/cli/run.js: -------------------------------------------------------------------------------- 1 | const cp = require('child_process') 2 | const getPort = require('get-port') 3 | const servers = require('./servers') 4 | const getCmd = require('../get-cmd') 5 | 6 | const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'] 7 | 8 | module.exports = { 9 | // For testing purpose, allows stubbing cp.spawnSync 10 | _spawnSync(...args) { 11 | cp.spawnSync(...args) 12 | }, 13 | 14 | // For testing purpose, allows stubbing process.exit 15 | _exit(...args) { 16 | process.exit(...args) 17 | }, 18 | 19 | spawn(cmd, opts = {}) { 20 | const cleanAndExit = (code = 0) => { 21 | servers.rm(opts) 22 | this._exit(code) 23 | } 24 | 25 | const startServer = port => { 26 | const serverAddress = `http://localhost:${port}` 27 | 28 | process.env.PORT = port 29 | servers.add(serverAddress, opts) 30 | 31 | signals.forEach(signal => process.on(signal, cleanAndExit)) 32 | 33 | const [command, ...args] = getCmd(cmd) 34 | const { status, error } = this._spawnSync(command, args, { 35 | stdio: 'inherit', 36 | cwd: process.cwd() 37 | }) 38 | 39 | if (error) throw error 40 | cleanAndExit(status) 41 | } 42 | 43 | if (opts.port) { 44 | startServer(opts.port) 45 | } else { 46 | getPort() 47 | .then(startServer) 48 | .catch(err => { 49 | throw err 50 | }) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/cli/servers.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const chalk = require('chalk') 4 | const tildify = require('tildify') 5 | const mkdirp = require('mkdirp') 6 | const common = require('../common') 7 | 8 | const serversDir = common.serversDir 9 | 10 | module.exports = { 11 | add, 12 | rm, 13 | ls 14 | } 15 | 16 | function isUrl(str) { 17 | return /^(http|https):/.test(str) 18 | } 19 | 20 | // Converts '_-Some Project_Name--' to 'some-project-name' 21 | function domainify(str) { 22 | return ( 23 | str 24 | .toLowerCase() 25 | // Replace all _ and spaces with - 26 | .replace(/(_| )/g, '-') 27 | // Trim - characters 28 | .replace(/(^-*|-*$)/g, '') 29 | ) 30 | } 31 | 32 | function getId(cwd) { 33 | return domainify(path.basename(cwd)) 34 | } 35 | 36 | function getServerFile(id) { 37 | return `${serversDir}/${id}.json` 38 | } 39 | 40 | function add(param, opts = {}) { 41 | mkdirp.sync(serversDir) 42 | 43 | const cwd = opts.dir || process.cwd() 44 | const id = opts.name ? domainify(opts.name) : getId(cwd) 45 | 46 | const file = getServerFile(id) 47 | 48 | let conf = {} 49 | 50 | if (opts.xfwd) { 51 | conf.xfwd = opts.xfwd 52 | } 53 | 54 | if (opts.changeOrigin) { 55 | conf.changeOrigin = opts.changeOrigin 56 | } 57 | 58 | if (opts.httpProxyEnv) { 59 | conf.httpProxyEnv = opts.httpProxyEnv 60 | } 61 | 62 | if (isUrl(param)) { 63 | conf = { 64 | target: param, 65 | ...conf 66 | } 67 | } else { 68 | conf = { 69 | cwd, 70 | cmd: param, 71 | ...conf 72 | } 73 | 74 | if (opts.o) conf.out = opts.o 75 | 76 | conf.env = {} 77 | 78 | // By default, save PATH env for version managers users 79 | conf.env.PATH = process.env.PATH 80 | 81 | // Copy other env option 82 | if (opts.env) { 83 | opts.env.forEach(key => { 84 | const value = process.env[key] 85 | if (value) { 86 | conf.env[key] = value 87 | } 88 | }) 89 | } 90 | 91 | // Copy port option 92 | if (opts.port) { 93 | conf.env.PORT = opts.port 94 | } 95 | } 96 | 97 | const data = JSON.stringify(conf, null, 2) 98 | 99 | console.log(`Create ${tildify(file)}`) 100 | fs.writeFileSync(file, data) 101 | 102 | // if we're mapping a domain to a URL there's no additional info to output 103 | if (conf.target) return 104 | 105 | // if we're mapping a domain to a local server add some info 106 | if (conf.out) { 107 | const logFile = tildify(path.resolve(conf.out)) 108 | console.log(`Output ${logFile}`) 109 | } else { 110 | console.log("Output No log file specified (use '-o app.log')") 111 | } 112 | 113 | if (!opts.p) { 114 | console.log("Port Random port (use '-p 1337' to set a fixed port)") 115 | } 116 | } 117 | 118 | function rm(opts = {}) { 119 | const cwd = process.cwd() 120 | const id = opts.n || getId(cwd) 121 | const file = getServerFile(id) 122 | 123 | console.log(`Remove ${tildify(file)}`) 124 | if (fs.existsSync(file)) { 125 | fs.unlinkSync(file) 126 | console.log('Removed') 127 | } else { 128 | console.log('No such file') 129 | } 130 | } 131 | 132 | function ls() { 133 | mkdirp.sync(serversDir) 134 | 135 | const list = fs 136 | .readdirSync(serversDir) 137 | .map(file => { 138 | const id = path.basename(file, '.json') 139 | const serverFile = getServerFile(id) 140 | let server 141 | 142 | try { 143 | server = JSON.parse(fs.readFileSync(serverFile)) 144 | } catch (error) { 145 | // Ignore mis-named or malformed files 146 | return 147 | } 148 | 149 | if (server.cmd) { 150 | return `${id}\n${chalk.gray(tildify(server.cwd))}\n${chalk.gray( 151 | server.cmd 152 | )}` 153 | } else { 154 | return `${id}\n${chalk.gray(server.target)}` 155 | } 156 | }) 157 | .filter(item => item) 158 | .join('\n\n') 159 | 160 | console.log(list) 161 | } 162 | -------------------------------------------------------------------------------- /src/common.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const homedir = require('os').homedir() 3 | 4 | const hotelDir = path.join(homedir, '.hotel') 5 | 6 | module.exports = { 7 | hotelDir, 8 | confFile: path.join(hotelDir, 'conf.json'), 9 | serversDir: path.join(hotelDir, 'servers'), 10 | pidFile: path.join(hotelDir, 'daemon.pid'), 11 | logFile: path.join(hotelDir, 'daemon.log'), 12 | startupFile: path.join(hotelDir, 'startup') 13 | } 14 | -------------------------------------------------------------------------------- /src/conf.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const mkdirp = require('mkdirp') 3 | const { hotelDir, confFile } = require('./common') 4 | 5 | // Create dir 6 | mkdirp.sync(hotelDir) 7 | 8 | // Defaults 9 | const defaults = { 10 | port: 2000, 11 | host: '127.0.0.1', 12 | timeout: 5000, 13 | tld: 'localhost', 14 | // Replace with your network proxy IP (1.2.3.4:5000) if any 15 | // For example, if you're behind a corporate proxy 16 | proxy: false 17 | } 18 | 19 | // Create empty conf it it doesn't exist 20 | if (!fs.existsSync(confFile)) fs.writeFileSync(confFile, '{}') 21 | 22 | // Read file 23 | const conf = JSON.parse(fs.readFileSync(confFile)) 24 | 25 | // Assign defaults and export 26 | module.exports = { ...defaults, ...conf } 27 | -------------------------------------------------------------------------------- /src/daemon/app.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const http = require('http') 3 | const express = require('express') 4 | const vhost = require('vhost') 5 | const serverReady = require('server-ready') 6 | const conf = require('../conf') 7 | 8 | // Require routes 9 | const IndexRouter = require('./routers') 10 | const APIRouter = require('./routers/api') 11 | const TLDHost = require('./vhosts/tld') 12 | 13 | module.exports = group => { 14 | const app = express() 15 | const server = http.createServer(app) 16 | 17 | // Initialize routes 18 | const indexRouter = IndexRouter(group) 19 | const api = APIRouter(group) 20 | const tldHost = TLDHost(group) 21 | 22 | // requests timeout 23 | serverReady.timeout = conf.timeout 24 | 25 | // Templates 26 | app.set('views', path.join(__dirname, 'views')) 27 | app.set('view engine', 'pug') 28 | app.locals.pretty = true 29 | 30 | // API 31 | app.use('/_', api) 32 | 33 | // .tld host 34 | app.use(vhost(new RegExp(`.*.${conf.tld}`), tldHost)) 35 | 36 | // app.get('/', (req, res) => res.render('index')) 37 | 38 | // Static files 39 | // vendors, etc... 40 | app.use(express.static(path.join(__dirname, 'public'))) 41 | // front files 42 | app.use(express.static(path.join(__dirname, '../../dist'))) 43 | 44 | // localhost router 45 | app.use(indexRouter) 46 | 47 | // Handle CONNECT, used by WebSockets and https when accessing .localhost domains 48 | server.on('connect', (req, socket, head) => { 49 | group.handleConnect(req, socket, head) 50 | }) 51 | 52 | server.on('upgrade', (req, socket, head) => { 53 | group.handleUpgrade(req, socket, head) 54 | }) 55 | 56 | return server 57 | } 58 | -------------------------------------------------------------------------------- /src/daemon/group.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const EventEmitter = require('events') 4 | const url = require('url') 5 | const once = require('once') 6 | const getPort = require('get-port') 7 | const matcher = require('matcher') 8 | const respawn = require('respawn') 9 | const afterAll = require('after-all') 10 | const httpProxy = require('http-proxy') 11 | const serverReady = require('server-ready') 12 | const log = require('./log') 13 | const tcpProxy = require('./tcp-proxy') 14 | const daemonConf = require('../conf') 15 | const getCmd = require('../get-cmd') 16 | 17 | module.exports = () => new Group() 18 | 19 | class Group extends EventEmitter { 20 | constructor() { 21 | super() 22 | 23 | this._list = {} 24 | this._proxy = httpProxy.createProxyServer({ 25 | xfwd: true 26 | }) 27 | } 28 | 29 | _output(id, data) { 30 | this.emit('output', id, data) 31 | } 32 | 33 | _log(mon, logFile, data) { 34 | mon.tail = mon.tail 35 | .concat(data) 36 | .split('\n') 37 | .slice(-100) 38 | .join('\n') 39 | 40 | if (logFile) { 41 | fs.appendFile(logFile, data, err => { 42 | if (err) log(err.message) 43 | }) 44 | } 45 | } 46 | 47 | _change() { 48 | this.emit('change', this._list) 49 | } 50 | 51 | // 52 | // Conf 53 | // 54 | 55 | list() { 56 | return this._list 57 | } 58 | 59 | find(id) { 60 | return this._list[id] 61 | } 62 | 63 | add(id, conf) { 64 | if (conf.target) { 65 | log(`Add target ${id}`) 66 | this._list[id] = conf 67 | this._change() 68 | return 69 | } 70 | 71 | log(`Add server ${id}`) 72 | 73 | const HTTP_PROXY = `http://127.0.0.1:${daemonConf.port}/proxy.pac` 74 | 75 | conf.env = { 76 | ...process.env, 77 | ...conf.env 78 | } 79 | 80 | if (conf.httpProxyEnv) { 81 | conf.env = { 82 | HTTP_PROXY, 83 | HTTPS_PROXY: HTTP_PROXY, 84 | http_proxy: HTTP_PROXY, 85 | https_proxy: HTTP_PROXY, 86 | ...conf.env 87 | } 88 | } 89 | 90 | let logFile 91 | if (conf.out) { 92 | logFile = path.resolve(conf.cwd, conf.out) 93 | } 94 | 95 | const command = getCmd(conf.cmd) 96 | 97 | const mon = respawn(command, { 98 | ...conf, 99 | maxRestarts: 0 100 | }) 101 | 102 | this._list[id] = mon 103 | 104 | // Add proxy config 105 | mon.xfwd = conf.xfwd || false 106 | mon.changeOrigin = conf.changeOrigin || false 107 | 108 | // Emit output 109 | mon.on('stdout', data => this._output(id, data)) 110 | mon.on('stderr', data => this._output(id, data)) 111 | mon.on('warn', data => this._output(id, data)) 112 | 113 | // Emit change 114 | mon.on('start', () => this._change()) 115 | mon.on('stop', () => this._change()) 116 | mon.on('crash', () => this._change()) 117 | mon.on('sleep', () => this._change()) 118 | mon.on('exit', () => this._change()) 119 | 120 | // Log status 121 | mon.on('start', () => log(id, 'has started')) 122 | mon.on('stop', () => log(id, 'has stopped')) 123 | mon.on('crash', () => log(id, 'has crashed')) 124 | mon.on('sleep', () => log(id, 'is sleeping')) 125 | mon.on('exit', () => log(id, 'child process has exited')) 126 | 127 | // Handle logs 128 | mon.tail = '' 129 | 130 | mon.on('stdout', data => this._log(mon, logFile, data)) 131 | mon.on('stderr', data => this._log(mon, logFile, data)) 132 | mon.on('warn', data => this._log(mon, logFile, data)) 133 | 134 | mon.on('start', () => { 135 | mon.tail = '' 136 | 137 | if (logFile) { 138 | fs.unlink(logFile, err => { 139 | if (err) log(err.message) 140 | }) 141 | } 142 | }) 143 | 144 | this._change() 145 | } 146 | 147 | remove(id, cb) { 148 | const item = this.find(id) 149 | if (item) { 150 | delete this._list[id] 151 | this._change() 152 | 153 | if (item.stop) { 154 | item.stop(cb) 155 | item.removeAllListeners() 156 | return 157 | } 158 | } 159 | 160 | cb && cb() 161 | } 162 | 163 | stopAll(cb) { 164 | const next = afterAll(cb) 165 | 166 | Object.keys(this._list).forEach(key => { 167 | if (this._list[key].stop) { 168 | this._list[key].stop(next()) 169 | } 170 | }) 171 | } 172 | 173 | update(id, conf) { 174 | this.remove(id, () => this.add(id, conf)) 175 | } 176 | 177 | // 178 | // Hostname resolver 179 | // 180 | 181 | resolve(str) { 182 | log(`Resolve ${str}`) 183 | const arr = Object.keys(this._list) 184 | .sort() 185 | .reverse() 186 | .map(h => ({ 187 | host: h, 188 | isStrictMatch: matcher.isMatch(str, h), 189 | isWildcardMatch: matcher.isMatch(str, `*.${h}`) 190 | })) 191 | 192 | const strictMatch = arr.find(h => h.isStrictMatch) 193 | const wildcardMatch = arr.find(h => h.isWildcardMatch) 194 | 195 | if (strictMatch) return strictMatch.host 196 | if (wildcardMatch) return wildcardMatch.host 197 | } 198 | 199 | // 200 | // Middlewares 201 | // 202 | 203 | exists(req, res, next) { 204 | // Resolve using either hostname `app.tld` 205 | // or id param `http://localhost:2000/app` 206 | const tld = new RegExp(`.${daemonConf.tld}$`) 207 | const id = req.params.id 208 | ? this.resolve(req.params.id) 209 | : this.resolve(req.hostname.replace(tld, '')) 210 | 211 | // Find item 212 | const item = this.find(id) 213 | 214 | // Not found 215 | if (!id || !item) { 216 | const msg = `Can't find server id: ${id}` 217 | log(msg) 218 | return res.status(404).send(msg) 219 | } 220 | 221 | req.hotel = { 222 | id, 223 | item 224 | } 225 | 226 | next() 227 | } 228 | 229 | start(req, res, next) { 230 | const { item } = req.hotel 231 | 232 | if (item.start) { 233 | if (item.env.PORT) { 234 | item.start() 235 | next() 236 | } else { 237 | getPort() 238 | .then(port => { 239 | item.env.PORT = port 240 | item.start() 241 | next() 242 | }) 243 | .catch(error => { 244 | next(error) 245 | }) 246 | } 247 | } else { 248 | next() 249 | } 250 | } 251 | 252 | stop(req, res, next) { 253 | const { item } = req.hotel 254 | 255 | if (item.stop) { 256 | item.stop() 257 | } 258 | 259 | next() 260 | } 261 | 262 | proxyWeb(req, res, target) { 263 | const { xfwd, changeOrigin } = req.hotel.item 264 | 265 | this._proxy.web( 266 | req, 267 | res, 268 | { 269 | target, 270 | xfwd, 271 | changeOrigin 272 | }, 273 | err => { 274 | log('Proxy - Error', err.message) 275 | const server = req.hotel.item 276 | const view = server.start ? 'server-error' : 'target-error' 277 | res.status(502).render(view, { 278 | err, 279 | serverReady, 280 | server 281 | }) 282 | } 283 | ) 284 | } 285 | 286 | proxy(req, res) { 287 | const [hostname, port] = req.headers.host && req.headers.host.split(':') 288 | const { item } = req.hotel 289 | 290 | // Handle case where port is set 291 | // http://app.localhost:5000 should proxy to http://localhost:5000 292 | if (port) { 293 | const target = `http://127.0.0.1:${port}` 294 | 295 | log(`Proxy - http://${req.headers.host} → ${target}`) 296 | return this.proxyWeb(req, res, target) 297 | } 298 | 299 | // Make sure to send only one response 300 | const send = once(() => { 301 | const { target } = item 302 | 303 | log(`Proxy - http://${hostname} → ${target}`) 304 | this.proxyWeb(req, res, target) 305 | }) 306 | 307 | if (item.start) { 308 | // Set target 309 | item.target = `http://localhost:${item.env.PORT}` 310 | 311 | // If server stops, no need to wait for timeout 312 | item.once('stop', send) 313 | 314 | // When PORT is open, proxy 315 | serverReady(item.env.PORT, send) 316 | } else { 317 | // Send immediatly if item is not a server started by a command 318 | send() 319 | } 320 | } 321 | 322 | redirect(req, res) { 323 | const { id } = req.params 324 | const { item } = req.hotel 325 | 326 | // Make sure to send only one response 327 | const send = once(() => { 328 | log(`Redirect - ${id} → ${item.target}`) 329 | res.redirect(item.target) 330 | }) 331 | 332 | if (item.start) { 333 | // Set target 334 | item.target = `http://${req.hostname}:${item.env.PORT}` 335 | 336 | // If server stops, no need to wait for timeout 337 | item.once('stop', send) 338 | 339 | // When PORT is open, redirect 340 | serverReady(item.env.PORT, send) 341 | } else { 342 | // Send immediatly if item is not a server started by a command 343 | send() 344 | } 345 | } 346 | 347 | parseHost(host) { 348 | const [hostname, port] = host.split(':') 349 | const tld = new RegExp(`.${daemonConf.tld}$`) 350 | const id = this.resolve(hostname.replace(tld, '')) 351 | return { id, hostname, port } 352 | } 353 | 354 | // Needed to proxy WebSocket from CONNECT 355 | handleUpgrade(req, socket, head) { 356 | if (req.headers.host) { 357 | const { host } = req.headers 358 | const { id, port } = this.parseHost(host) 359 | const item = this.find(id) 360 | 361 | if (item) { 362 | let target 363 | if (port && port !== '80') { 364 | target = `ws://127.0.0.1:${port}` 365 | } else if (item.start) { 366 | target = `ws://127.0.0.1:${item.env.PORT}` 367 | } else { 368 | const { hostname } = url.parse(item.target) 369 | target = `ws://${hostname}` 370 | } 371 | log(`WebSocket - ${host} → ${target}`) 372 | this._proxy.ws(req, socket, head, { target }, err => { 373 | log('WebSocket - Error', err.message) 374 | }) 375 | } else { 376 | log(`WebSocket - No server matching ${id}`) 377 | } 378 | } else { 379 | log('WebSocket - No host header found') 380 | } 381 | } 382 | 383 | // Handle CONNECT, used by WebSockets and https when accessing .localhost domains 384 | handleConnect(req, socket, head) { 385 | if (req.headers.host) { 386 | const { host } = req.headers 387 | const { id, hostname, port } = this.parseHost(host) 388 | 389 | // If https make socket go through https proxy on 2001 390 | // TODO find a way to detect https and wss without relying on port number 391 | if (port === '443') { 392 | return tcpProxy.proxy(socket, daemonConf.port + 1, hostname) 393 | } 394 | 395 | const item = this.find(id) 396 | 397 | if (item) { 398 | if (port && port !== '80') { 399 | log(`Connect - ${host} → ${port}`) 400 | tcpProxy.proxy(socket, port) 401 | } else if (item.start) { 402 | const { PORT } = item.env 403 | log(`Connect - ${host} → ${PORT}`) 404 | tcpProxy.proxy(socket, PORT) 405 | } else { 406 | const { hostname, port } = url.parse(item.target) 407 | const targetPort = port || 80 408 | log(`Connect - ${host} → ${hostname}:${port}`) 409 | tcpProxy.proxy(socket, targetPort, hostname) 410 | } 411 | } else { 412 | log(`Connect - Can't find server for ${id}`) 413 | socket.end() 414 | } 415 | } else { 416 | log('Connect - No host header found') 417 | } 418 | } 419 | } 420 | -------------------------------------------------------------------------------- /src/daemon/index.js: -------------------------------------------------------------------------------- 1 | const exitHook = require('exit-hook') 2 | const httpProxy = require('http-proxy') 3 | const conf = require('../conf') 4 | const pidFile = require('../pid-file') 5 | const pem = require('./pem') 6 | const log = require('./log') 7 | const Group = require('./group') 8 | const Loader = require('./loader') 9 | const App = require('./app') 10 | 11 | const group = Group() 12 | const app = App(group) 13 | 14 | // Load and watch files 15 | Loader(group) 16 | 17 | // Create pid file 18 | pidFile.create() 19 | 20 | // Clean exit 21 | exitHook(() => { 22 | console.log('Exiting') 23 | console.log('Stop daemon') 24 | proxy.close() 25 | app.close() 26 | group.stopAll() 27 | 28 | console.log('Remove pid file') 29 | pidFile.remove() 30 | }) 31 | 32 | // HTTPS proxy 33 | const proxy = httpProxy.createServer({ 34 | target: { 35 | host: '127.0.0.1', 36 | port: conf.port 37 | }, 38 | ssl: pem.generate(), 39 | ws: true, 40 | xfwd: true 41 | }) 42 | 43 | // Start HTTPS proxy and HTTP server 44 | proxy.listen(conf.port + 1) 45 | 46 | app.listen(conf.port, conf.host, function() { 47 | log(`Server listening on port ${conf.host}:${conf.port}`) 48 | }) 49 | -------------------------------------------------------------------------------- /src/daemon/loader.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const mkdirp = require('mkdirp') 4 | const chokidar = require('chokidar') 5 | const log = require('./log') 6 | const common = require('../common') 7 | 8 | function getId(file) { 9 | return path.basename(file, '.json') 10 | } 11 | 12 | function handleAdd(group, file) { 13 | log(`${file} added`) 14 | const id = getId(file) 15 | 16 | try { 17 | const conf = JSON.parse(fs.readFileSync(file, 'utf8')) 18 | group.add(id, conf) 19 | } catch (err) { 20 | log(`Error: Failed to parse ${file}`, err) 21 | } 22 | } 23 | 24 | function handleUnlink(group, file, cb) { 25 | log(`${file} unlinked`) 26 | const id = getId(file) 27 | group.remove(id, cb) 28 | } 29 | 30 | function handleChange(group, file) { 31 | log(`${file} changed`) 32 | handleUnlink(group, file, () => { 33 | handleAdd(group, file) 34 | }) 35 | } 36 | 37 | module.exports = (group, opts = { watch: true }) => { 38 | const dir = common.serversDir 39 | 40 | // Ensure directory exists 41 | mkdirp.sync(dir) 42 | 43 | // Watch ~/.hotel/servers 44 | if (opts.watch) { 45 | log(`Watching ${dir}`) 46 | chokidar 47 | .watch(dir) 48 | .on('add', file => handleAdd(group, file)) 49 | .on('change', file => handleChange(group, file)) 50 | .on('unlink', file => handleUnlink(group, file)) 51 | } 52 | 53 | // Bootstrap 54 | fs.readdirSync(dir).forEach(file => { 55 | handleAdd(group, path.resolve(dir, file)) 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /src/daemon/log.js: -------------------------------------------------------------------------------- 1 | const tinydate = require('tinydate') 2 | const stamp = tinydate('{HH}:{mm}:{ss}') 3 | 4 | module.exports = function log(...args) { 5 | console.log(stamp(), '-', ...args) 6 | } 7 | -------------------------------------------------------------------------------- /src/daemon/pem.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const tildify = require('tildify') 4 | const selfsigned = require('selfsigned') 5 | const log = require('./log') 6 | const { hotelDir } = require('../common') 7 | 8 | const KEY_FILE = path.join(hotelDir, 'key.pem') 9 | const CERT_FILE = path.join(hotelDir, 'cert.pem') 10 | 11 | function generate() { 12 | if (fs.existsSync(KEY_FILE) && fs.existsSync(CERT_FILE)) { 13 | log(`Reading self-signed certificate in ${tildify(hotelDir)}`) 14 | return { 15 | key: fs.readFileSync(KEY_FILE, 'utf-8'), 16 | cert: fs.readFileSync(CERT_FILE, 'utf-8') 17 | } 18 | } else { 19 | log(`Generating self-signed certificate in ${tildify(hotelDir)}`) 20 | const pems = selfsigned.generate([{ name: 'commonName', value: 'hotel' }], { 21 | days: 365 22 | }) 23 | fs.writeFileSync(KEY_FILE, pems.private, 'utf-8') 24 | fs.writeFileSync(CERT_FILE, pems.cert, 'utf-8') 25 | 26 | return { key: pems.private, cert: pems.cert } 27 | } 28 | } 29 | 30 | module.exports = { 31 | KEY_FILE, 32 | CERT_FILE, 33 | generate 34 | } 35 | -------------------------------------------------------------------------------- /src/daemon/public/error.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: -apple-system, BlinkMacSystemFont, 3 | "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", 5 | "Droid Sans", "Helvetica Neue", sans-serif; 6 | color: #212121; 7 | padding: 0; 8 | margin: 0 auto; 9 | display: flex; 10 | max-width: 960px; 11 | min-height: 100vh; 12 | flex-direction: column; 13 | } 14 | 15 | h1, h2 { 16 | margin-top: 60px; 17 | } 18 | 19 | li { 20 | list-style: square; 21 | } 22 | 23 | pre { 24 | background: #F9F9F9; 25 | padding: 20px; 26 | } 27 | 28 | a { 29 | color: inherit; 30 | } 31 | 32 | main { 33 | flex: 1; 34 | } 35 | 36 | footer { 37 | padding: 20px 0; 38 | } 39 | -------------------------------------------------------------------------------- /src/daemon/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typicode/hotel/bcbdade4a5ce5f9f8ac0d66def7ba0a4efec2366/src/daemon/public/favicon.png -------------------------------------------------------------------------------- /src/daemon/routers/api/events.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const connectSSE = require('connect-sse') 3 | const sse = connectSSE() 4 | 5 | function listen(res, group, groupEvent, handler) { 6 | function removeListener() { 7 | // Remove group handler 8 | group.removeListener(groupEvent, handler) 9 | 10 | // Remove self 11 | res.removeListener('close', removeListener) 12 | res.removeListener('finish', removeListener) 13 | } 14 | 15 | group.on(groupEvent, handler) 16 | 17 | res.on('close', removeListener) 18 | res.on('finish', removeListener) 19 | } 20 | 21 | module.exports = group => { 22 | const router = express.Router() 23 | 24 | router.get('/', sse, (req, res) => { 25 | // Handler 26 | function sendState() { 27 | res.json(group.list()) 28 | } 29 | 30 | // Bootstrap 31 | sendState() 32 | 33 | // Listen 34 | listen(res, group, 'change', sendState) 35 | }) 36 | 37 | router.get('/output', sse, (req, res) => { 38 | function sendOutput(id, data) { 39 | res.json({ 40 | id, 41 | output: data.toString() 42 | }) 43 | } 44 | 45 | // Bootstrap 46 | const list = group.list() 47 | Object.keys(list).forEach(id => { 48 | var mon = list[id] 49 | if (mon && mon.tail) { 50 | sendOutput(id, mon.tail) 51 | } 52 | }) 53 | 54 | // Listen 55 | listen(res, group, 'output', sendOutput) 56 | }) 57 | 58 | return router 59 | } 60 | -------------------------------------------------------------------------------- /src/daemon/routers/api/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | 3 | const ServerRouter = require('./servers') 4 | const EventRouter = require('./events') 5 | 6 | module.exports = group => { 7 | const router = express.Router() 8 | 9 | const servers = ServerRouter(group) 10 | const events = EventRouter(group) 11 | 12 | router.use('/servers', servers) 13 | router.use('/events', events) 14 | 15 | return router 16 | } 17 | -------------------------------------------------------------------------------- /src/daemon/routers/api/servers.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | 3 | module.exports = group => { 4 | const router = express.Router() 5 | 6 | router.get('/', (req, res) => { 7 | res.json(group.list()) 8 | }) 9 | 10 | router.post( 11 | '/:id/start', 12 | group.exists.bind(group), 13 | group.start.bind(group), 14 | (req, res) => res.end() 15 | ) 16 | 17 | router.post( 18 | '/:id/stop', 19 | group.exists.bind(group), 20 | group.stop.bind(group), 21 | (req, res) => res.end() 22 | ) 23 | 24 | return router 25 | } 26 | -------------------------------------------------------------------------------- /src/daemon/routers/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const conf = require('../../conf') 3 | const log = require('../log') 4 | 5 | module.exports = function(group) { 6 | const router = express.Router() 7 | 8 | function pac(req, res) { 9 | log('Serve proxy.pac') 10 | if (conf.proxy) { 11 | res.render('proxy-pac-with-proxy', { conf }) 12 | } else { 13 | res.render('proxy-pac', { conf }) 14 | } 15 | } 16 | 17 | router 18 | .get('/proxy.pac', pac) 19 | .get( 20 | '/:id', 21 | group.exists.bind(group), 22 | group.start.bind(group), 23 | group.redirect.bind(group) 24 | ) 25 | 26 | return router 27 | } 28 | -------------------------------------------------------------------------------- /src/daemon/tcp-proxy.js: -------------------------------------------------------------------------------- 1 | const net = require('net') 2 | const log = require('./log') 3 | 4 | module.exports = { 5 | proxy 6 | } 7 | 8 | function proxy(source, targetPort, targetHost) { 9 | const target = net.connect(targetPort) 10 | source.pipe(target).pipe(source) 11 | 12 | const handleError = err => { 13 | log('TCP Proxy - Error', err) 14 | source.destroy() 15 | target.destroy() 16 | } 17 | 18 | source.on('error', handleError) 19 | target.on('error', handleError) 20 | 21 | source.write( 22 | 'HTTP/1.1 200 Connection Established\r\n' + 23 | 'Proxy-agent: Hotel\r\n' + 24 | '\r\n' 25 | ) 26 | 27 | return target 28 | } 29 | -------------------------------------------------------------------------------- /src/daemon/vhosts/tld.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const conf = require('../../conf') 3 | const log = require('../log') 4 | 5 | // *.tld vhost 6 | module.exports = group => { 7 | const app = express.Router() 8 | const hotelRegExp = new RegExp(`hotel.${conf.tld}$`) 9 | 10 | app.use((req, res, next) => { 11 | const { hostname } = req 12 | 13 | // Skip hotel.tld 14 | if (hotelRegExp.test(hostname)) { 15 | log(`hotel.${conf.tld}`) 16 | return next() 17 | } 18 | 19 | // If hostname is resolved proxy request 20 | group.exists(req, res, () => { 21 | group.start(req, res, () => { 22 | group.proxy(req, res) 23 | }) 24 | }) 25 | }) 26 | 27 | return app 28 | } 29 | -------------------------------------------------------------------------------- /src/daemon/views/_error.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | head 3 | title Error 4 | meta(charset='utf-8') 5 | meta(name='viewport', content='width=device-width, initial-scale=1') 6 | link(rel='icon', type='image/png', href='favicon.png') 7 | style 8 | include ../public/error.css 9 | body 10 | h1 Error 11 | main 12 | block content 13 | 14 | footer 15 | a(href='https://github.com/typicode/hotel') readme 16 | -------------------------------------------------------------------------------- /src/daemon/views/proxy-pac-with-proxy.pug: -------------------------------------------------------------------------------- 1 | . 2 | // Set conf.proxy in ~/.hotel/conf.json to your proxy address and port. 3 | // For example: { "proxy": "1.2.3.4:5000" } 4 | // 5 | // See also https://en.wikipedia.org/wiki/Private_network 6 | function FindProxyForURL (url, host) { 7 | if (dnsDomainIs(host, '.#{conf.tld}')) { 8 | return 'PROXY 127.0.0.1:#{conf.port}'; 9 | } 10 | 11 | var address = dnsResolve(host); 12 | if ( 13 | isPlainHostName(host) || 14 | dnsDomainIs(host, '.local') || 15 | isInNet(address, '10.0.0.0', '255.0.0.0') || 16 | isInNet(address, '172.16.0.0', '255.240.0.0') || 17 | isInNet(address, '192.168.0.0', '255.255.0.0') || 18 | isInNet(address, '127.0.0.0', '255.255.255.0') 19 | ) { 20 | return 'DIRECT'; 21 | } 22 | 23 | return 'PROXY #{conf.proxy}'; 24 | } 25 | -------------------------------------------------------------------------------- /src/daemon/views/proxy-pac.pug: -------------------------------------------------------------------------------- 1 | . 2 | // Proxy only *.#{conf.tld} requests to hotel 3 | // Configuration file can be found in ~/.hotel 4 | function FindProxyForURL (url, host) { 5 | if (dnsDomainIs(host, '.#{conf.tld}')) { 6 | return 'PROXY 127.0.0.1:#{conf.port}'; 7 | } 8 | 9 | return 'DIRECT'; 10 | } 11 | -------------------------------------------------------------------------------- /src/daemon/views/server-error.pug: -------------------------------------------------------------------------------- 1 | extends _error.pug 2 | 3 | block content 4 | p Can't connect to server on PORT=#{server.env.PORT} 5 | p: a(href=`http://localhost:${server.env.PORT}`) http://localhost:#{server.env.PORT} 6 | 7 | h2 Possible causes 8 | ul 9 | li Server crashed or timeout of #{serverReady.timeout}ms exceeded. 10 | li Server is not listening on PORT environment variable. 11 | 12 | p Try to reload or check logs. 13 | 14 | h2 Logs 15 | pre: code= server.command.join(' ') 16 | pre: code= server.tail 17 | -------------------------------------------------------------------------------- /src/daemon/views/target-error.pug: -------------------------------------------------------------------------------- 1 | extends _error.pug 2 | 3 | block content 4 | p Cannot proxy request to 5 | a(href=server.target)= server.target 6 | 7 | pre: code=err.message 8 | -------------------------------------------------------------------------------- /src/get-cmd.js: -------------------------------------------------------------------------------- 1 | const os = require('os') 2 | const unquote = require('unquote') 3 | 4 | module.exports = cmd => 5 | os.platform() === 'win32' 6 | ? ['cmd', '/c'].concat(cmd.split(' ')) 7 | : ['sh', '-c'].concat(unquote(cmd)) 8 | -------------------------------------------------------------------------------- /src/pid-file.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const { pidFile } = require('./common') 3 | 4 | module.exports = { 5 | create, 6 | read, 7 | remove 8 | } 9 | 10 | function create() { 11 | console.log('create', pidFile, process.pid) 12 | return fs.writeFileSync(pidFile, process.pid.toString()) 13 | } 14 | 15 | function read() { 16 | if (fs.existsSync(pidFile)) { 17 | return fs.readFileSync(pidFile, 'utf-8') 18 | } 19 | } 20 | 21 | function remove() { 22 | if (fs.existsSync(pidFile)) { 23 | fs.unlinkSync(pidFile) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/scripts/uninstall.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const { startupFile, pidFile } = require('../common') 3 | 4 | function killProcess() { 5 | if (!fs.existsSync(pidFile)) return 6 | 7 | const pid = fs.readFileSync(pidFile, 'utf-8') 8 | try { 9 | process.kill(pid) 10 | } catch (err) {} 11 | 12 | fs.unlinkSync(pidFile) 13 | } 14 | 15 | function removeStartup() { 16 | if (!fs.existsSync(startupFile)) return 17 | const startupFilePath = fs.readFileSync(startupFile, 'utf-8') 18 | fs.unlinkSync(startupFile) 19 | 20 | if (!fs.existsSync(startupFilePath)) return 21 | fs.unlinkSync(startupFilePath) 22 | } 23 | 24 | module.exports = () => { 25 | killProcess() 26 | removeStartup() 27 | } 28 | -------------------------------------------------------------------------------- /test/_setup.js: -------------------------------------------------------------------------------- 1 | const os = require('os') 2 | const sinon = require('sinon') 3 | const tempy = require('tempy') 4 | 5 | // Required by AVA, see package.json 6 | sinon.stub(os, 'homedir').returns(tempy.directory()) 7 | -------------------------------------------------------------------------------- /test/cli/daemon.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const test = require('ava') 4 | const sinon = require('sinon') 5 | const untildify = require('untildify') 6 | const userStartup = require('user-startup') 7 | const common = require('../../src/common') 8 | const cli = require('../../src/cli') 9 | 10 | test.before(() => { 11 | sinon.stub(userStartup, 'create') 12 | sinon.stub(userStartup, 'remove') 13 | sinon.stub(process, 'kill') 14 | }) 15 | 16 | test('start should start daemon', t => { 17 | const node = process.execPath 18 | const daemonFile = path.join(__dirname, '../../src/daemon') 19 | const daemonLog = path.resolve(untildify('~/.hotel/daemon.log')) 20 | 21 | cli(['', '', 'start']) 22 | 23 | sinon.assert.calledWithExactly( 24 | userStartup.create, 25 | 'hotel', 26 | node, 27 | [daemonFile], 28 | daemonLog 29 | ) 30 | 31 | t.is( 32 | fs.readFileSync(common.startupFile, 'utf-8'), 33 | userStartup.getFile('hotel'), 34 | 'startupFile should point to startup file path' 35 | ) 36 | 37 | t.pass() 38 | }) 39 | 40 | test('stop should stop daemon', t => { 41 | fs.writeFileSync(common.pidFile, '1234') 42 | 43 | cli(['', '', 'stop']) 44 | 45 | sinon.assert.calledWithExactly(userStartup.remove, 'hotel') 46 | sinon.assert.calledWithExactly(process.kill, '1234') 47 | t.pass() 48 | }) 49 | -------------------------------------------------------------------------------- /test/cli/run.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const test = require('ava') 3 | const sinon = require('sinon') 4 | const cli = require('../../src/cli') 5 | const servers = require('../../src/cli/servers') 6 | const run = require('../../src/cli/run') 7 | 8 | const appDir = path.join(__dirname, '../fixtures/app') 9 | 10 | test('spawn with port', t => { 11 | const status = 1 12 | 13 | sinon.spy(servers, 'add') 14 | sinon.spy(servers, 'rm') 15 | 16 | // Stub _exit to avoid messing with process.exit 17 | sinon.stub(run, '_exit') 18 | // Stub _spawnSync to immediately return avoid messing with child_process 19 | sinon.stub(run, '_spawnSync').callsFake(() => ({ status })) 20 | 21 | process.chdir(appDir) 22 | 23 | const opts = { port: 5000 } 24 | 25 | run.spawn('node index.js', opts) 26 | 27 | // test that everything was called correctly 28 | t.true(servers.add.called) 29 | t.regex( 30 | servers.add.firstCall.args[0], 31 | /http:\/\/localhost:/, 32 | 'should add a target' 33 | ) 34 | 35 | t.is(servers.add.firstCall.args[1], opts, 'should pass options to add') 36 | 37 | t.true(servers.rm.called) 38 | t.is(servers.rm.firstCall.args[0], opts, 'should use same options to remove') 39 | 40 | t.true(run._exit.called) 41 | t.is(run._exit.firstCall.args[0], status, 'should exit') 42 | }) 43 | 44 | test('cli run should call run.spawn', t => { 45 | sinon.stub(run, 'spawn') 46 | cli(['', '', 'run', 'node index.js']) 47 | 48 | t.is(run.spawn.firstCall.args[0], 'node index.js') 49 | }) 50 | -------------------------------------------------------------------------------- /test/cli/servers.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const test = require('ava') 4 | const sinon = require('sinon') 5 | const servers = require('../../src/cli/servers') 6 | const cli = require('../../src/cli') 7 | const { serversDir } = require('../../src/common') 8 | 9 | const appDir = path.join(__dirname, '../fixtures/app') 10 | 11 | test('add should create file', t => { 12 | process.chdir(appDir) 13 | cli(['', '', 'add', 'node index.js']) 14 | 15 | const file = path.join(serversDir, 'app.json') 16 | const conf = { 17 | cmd: 'node index.js', 18 | cwd: process.cwd(), 19 | env: { 20 | PATH: process.env.PATH 21 | } 22 | } 23 | 24 | const actual = JSON.parse(fs.readFileSync(file)) 25 | t.deepEqual(actual, conf) 26 | }) 27 | 28 | test('add should create file with URL safe characters by defaults', t => { 29 | cli(['', '', 'add', 'node index.js', '--dir', '/_-Some Project_Name--']) 30 | 31 | const file = path.join(serversDir, 'some-project-name.json') 32 | 33 | t.true(fs.existsSync(file)) 34 | }) 35 | 36 | test('add should create file with URL safe characters by defaults', t => { 37 | cli(['', '', 'add', 'node index.js', '--name', '/_-Some Project_Name--']) 38 | 39 | const file = path.join(serversDir, 'some-project-name.json') 40 | 41 | t.true(fs.existsSync(file)) 42 | }) 43 | 44 | test('add should support options', t => { 45 | process.env.FOO = 'FOO_VALUE' 46 | process.env.BAR = 'BAR_VALUE' 47 | const cmd = 'node index.js' 48 | const name = 'project' 49 | const port = 3000 50 | const out = '/some/path/out.log' 51 | const env = ['FOO', 'BAR'] 52 | 53 | cli([ 54 | '', 55 | '', 56 | 'add', 57 | cmd, 58 | '-n', 59 | name, 60 | '-p', 61 | port, 62 | '-o', 63 | out, 64 | '-e', 65 | env[0], 66 | env[1], 67 | '-x', 68 | '--co', 69 | '--http-proxy-env' 70 | ]) 71 | 72 | const file = path.join(serversDir, 'project.json') 73 | const conf = { 74 | cmd: 'node index.js', 75 | cwd: process.cwd(), 76 | out: out, 77 | env: { 78 | PATH: process.env.PATH, 79 | FOO: process.env.FOO, 80 | BAR: process.env.BAR, 81 | PORT: port 82 | }, 83 | xfwd: true, 84 | changeOrigin: true, 85 | httpProxyEnv: true 86 | } 87 | 88 | const actual = JSON.parse(fs.readFileSync(file)) 89 | t.deepEqual(actual, conf) 90 | }) 91 | 92 | test('add should support option aliases', t => { 93 | process.env.FOO = 'FOO' 94 | const cmd = 'node index.js' 95 | const name = 'alias-test' 96 | 97 | cli(['', '', 'add', cmd, '-n', name]) 98 | 99 | const file = path.join(serversDir, 'alias-test.json') 100 | t.true(fs.existsSync(file)) 101 | }) 102 | 103 | test('add should support URL', t => { 104 | cli(['', '', 'add', 'http://1.2.3.4', '-n', 'proxy']) 105 | 106 | const file = path.join(serversDir, 'proxy.json') 107 | const conf = { 108 | target: 'http://1.2.3.4' 109 | } 110 | 111 | const actual = JSON.parse(fs.readFileSync(file)) 112 | t.deepEqual(actual, conf) 113 | }) 114 | 115 | test('add should support URL and options', t => { 116 | cli(['', '', 'add', 'http://1.2.3.4', '-n', 'proxy', '-x', '--co']) 117 | 118 | const file = path.join(serversDir, 'proxy.json') 119 | const conf = { 120 | target: 'http://1.2.3.4', 121 | xfwd: true, 122 | changeOrigin: true 123 | } 124 | 125 | const actual = JSON.parse(fs.readFileSync(file)) 126 | t.deepEqual(actual, conf) 127 | }) 128 | 129 | /* 130 | FIXME fails for an unknown reason only in CI, process.chdir doesn't seem to change dir 131 | test('rm should remove file', (t) => { 132 | const file = path.join(serversDir, 'other-app.json') 133 | fs.writeFileSync(file, '') 134 | 135 | process.chdir(otherAppDir) 136 | cli(['', '', 'rm']) 137 | t.true(!fs.existsSync(file)) 138 | }) 139 | */ 140 | 141 | test('rm should remove file using name', t => { 142 | const name = 'some-other-app' 143 | const file = path.join(serversDir, `${name}.json`) 144 | 145 | fs.writeFileSync(file, '') 146 | cli(['', '', 'rm', '-n', name]) 147 | t.true(!fs.existsSync(file)) 148 | }) 149 | 150 | test('ls', t => { 151 | sinon.spy(servers, 'ls') 152 | cli(['', '', 'ls']) 153 | sinon.assert.calledOnce(servers.ls) 154 | t.pass() 155 | }) 156 | 157 | test('ls should ignore non-json files', t => { 158 | const name = '.DS_Store' 159 | const file = path.join(serversDir, `${name}`) 160 | fs.writeFileSync(file, '') 161 | 162 | t.notThrows(() => { 163 | cli(['', '', 'ls']) 164 | }) 165 | }) 166 | -------------------------------------------------------------------------------- /test/daemon/app.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const http = require('http') 4 | const test = require('ava') 5 | const request = require('supertest') 6 | const conf = require('../../src/conf') 7 | const App = require('../../src/daemon/app') 8 | const Group = require('../../src/daemon/group') 9 | const Loader = require('../../src/daemon/loader') 10 | const servers = require('../../src/cli/servers') 11 | 12 | const { tld } = conf 13 | let app 14 | 15 | function ensureDistExists(t) { 16 | const exists = fs.existsSync(path.join(__dirname, '../../dist')) 17 | t.true(exists, 'dist directory must exist (try to run `npm run build`)') 18 | } 19 | 20 | test.before(() => { 21 | // Set request timeout to 20 seconds instead of 5 seconds for slower CI servers 22 | conf.timeout = 20000 23 | 24 | // Fake server to respond to URL 25 | http 26 | .createServer((req, res) => { 27 | res.statusCode = 200 28 | res.end(`ok - host: ${req.headers.host}`) 29 | }) 30 | .listen(4000) 31 | 32 | // Add server 33 | servers.add('node index.js', { 34 | name: 'node', 35 | port: 51234, 36 | dir: path.join(__dirname, '../fixtures/app'), 37 | out: '/tmp/logs/app.log', 38 | xfwd: true 39 | }) 40 | 41 | // Add server with subdomain 42 | servers.add('node index.js', { 43 | name: 'subdomain.node', 44 | port: 51235, 45 | dir: path.join(__dirname, '../fixtures/app'), 46 | out: '/tmp/logs/app.log' 47 | }) 48 | 49 | // Add server with custom env 50 | process.env.FOO = 'FOO_VALUE' 51 | servers.add('node index.js', { 52 | name: 'custom-env', 53 | port: 51236, 54 | dir: path.join(__dirname, '../fixtures/app'), 55 | out: '/tmp/logs/app.log', 56 | env: ['FOO'], 57 | httpProxyEnv: true 58 | }) 59 | 60 | // Add failing server 61 | servers.add('unknown-cmd', { name: 'failing' }) 62 | 63 | // Add server and proxy for testing removal 64 | servers.add('unknown-cmd', { name: 'server-to-remove' }) 65 | servers.add('http://example.com', { name: 'proxy-to-remove' }) 66 | 67 | // Add URL 68 | servers.add('http://localhost:4000', { name: 'proxy' }) 69 | 70 | // Add https URL 71 | servers.add('https://jsonplaceholder.typicode.com', { 72 | name: 'working-proxy-with-https-target', 73 | changeOrigin: true 74 | }) 75 | 76 | servers.add('https://jsonplaceholder.typicode.com', { 77 | name: 'failing-proxy-with-https-target' 78 | }) 79 | 80 | // Add unavailable URL 81 | servers.add('http://localhost:4100', { name: 'unavailable-proxy' }) 82 | 83 | const group = Group() 84 | app = App(group) 85 | app.group = group 86 | Loader(group, { watch: false }) 87 | }) 88 | 89 | test.cb.after(t => app.group.stopAll(t.end)) 90 | 91 | // 92 | // Test daemon/vhosts/tld.js 93 | // 94 | 95 | test.cb('GET http://hotel.tld should return 200', t => { 96 | ensureDistExists(t) 97 | request(app) 98 | .get('/') 99 | .set('Host', `hotel.${tld}`) 100 | .expect(200, t.end) 101 | }) 102 | 103 | test.cb( 104 | 'GET http://node.tld should proxy request and host should be node.tld', 105 | t => { 106 | request(app) 107 | .get('/') 108 | .set('Host', `node.${tld}`) 109 | .expect(new RegExp(`host: node.${tld}`)) 110 | .expect(200, /Hello World/, (err, res) => { 111 | if (err) return t.end(err) 112 | t.notRegex( 113 | res.text, 114 | /http:\/\/127.0.0.1:2000\/proxy.pac/, 115 | `shouldn't be started with HTTP_PROXY env set` 116 | ) 117 | return t.end() 118 | }) 119 | } 120 | ) 121 | 122 | test.cb('GET http://subdomain.node.tld should proxy request', t => { 123 | request(app) 124 | .get('/') 125 | .set('Host', `subdomain.node.${tld}`) 126 | .expect(200, /Hello World/, t.end) 127 | }) 128 | 129 | test.cb('GET http://any.node.tld should proxy request', t => { 130 | request(app) 131 | .get('/') 132 | .set('Host', `any.node.${tld}`) 133 | .expect(200, /Hello World/, t.end) 134 | }) 135 | 136 | test.cb('GET http://unknown.tld should return 404', t => { 137 | request(app) 138 | .get('/') 139 | .set('Host', `unknown.${tld}`) 140 | .expect(404, t.end) 141 | }) 142 | 143 | test.cb('GET http://failing.tld should return 502', t => { 144 | request(app) 145 | .get('/') 146 | .set('Host', `failing.${tld}`) 147 | .expect(502, t.end) 148 | }) 149 | 150 | test.cb( 151 | 'GET http://proxy.tld should return 200 and host should be proxy.localhost', 152 | t => { 153 | request(app) 154 | .get('/') 155 | .set('Host', `proxy.${tld}`) 156 | .expect(200, new RegExp(`host: proxy.${tld}`), t.end) 157 | } 158 | ) 159 | 160 | test.cb('GET http://node.tld:4000 should proxy to localhost:4000', t => { 161 | request(app) 162 | .get('/') 163 | .set('Host', `node.${tld}:4000`) 164 | .expect(200, /ok/, t.end) 165 | }) 166 | 167 | // 168 | // Test proxy to URLs 169 | // 170 | 171 | test.cb( 172 | 'GET http://working-proxy-with-https-target.tld should return 200', 173 | t => { 174 | request(app) 175 | .get('/') 176 | .set('Host', `working-proxy-with-https-target.${tld}`) 177 | .expect(200, t.end) 178 | } 179 | ) 180 | 181 | test.cb( 182 | 'GET http://failing-proxy-with-https-target.tld should return 502', 183 | t => { 184 | request(app) 185 | .get('/') 186 | .set('Host', `failing-proxy-with-https-target.${tld}`) 187 | .expect(502, t.end) 188 | } 189 | ) 190 | 191 | test.cb('GET http://unavailable-proxy.tld should return 502', t => { 192 | request(app) 193 | .get('/') 194 | .set('Host', `unavailable-proxy.${tld}`) 195 | .expect(502, t.end) 196 | }) 197 | 198 | // 199 | // TEST daemon/routers/api.js 200 | // 201 | 202 | test.cb('GET /_/servers', t => { 203 | request(app) 204 | .get('/_/servers') 205 | .expect(200, (err, res) => { 206 | if (err) return t.end(err) 207 | t.is(Object.keys(res.body).length, 10, 'got wrong number of servers') 208 | t.end() 209 | }) 210 | }) 211 | 212 | test.cb('POST /_/servers/:id/start', t => { 213 | request(app) 214 | .post('/_/servers/node/start') 215 | .expect(200, err => { 216 | if (err) return t.end(err) 217 | t.is(app.group.find('node').status, 'running') 218 | t.end() 219 | }) 220 | }) 221 | 222 | test.cb('POST /_/servers/:id/stop', t => { 223 | request(app) 224 | .post('/_/servers/node/stop') 225 | .expect(200, err => { 226 | if (err) return t.end(err) 227 | t.not(app.group.find('node').status, 'running') 228 | t.end() 229 | }) 230 | }) 231 | 232 | // 233 | // TEST daemon/routers/index.js 234 | // 235 | 236 | test.cb('GET /proxy.pac should serve /proxy.pac', t => { 237 | request(app) 238 | .get('/proxy.pac') 239 | .expect(200, t.end) 240 | }) 241 | 242 | test.cb('GET http://localhost:2000/node should redirect to node server', t => { 243 | if (process.env.APPVEYOR) return t.end() 244 | request(app) 245 | .get('/node') 246 | .set('Host', 'localhost') 247 | .expect('location', /http:\/\/localhost:51234/) 248 | .expect(302, t.end) 249 | }) 250 | 251 | test.cb( 252 | 'GET http://127.0.0.1:2000/node should use the same hostname to redirect', 253 | t => { 254 | // temporary disable this test on AppVeyor 255 | // Randomly fails 256 | if (process.env.APPVEYOR) return t.end() 257 | request(app) 258 | .get('/node') 259 | .expect('location', /http:\/\/127.0.0.1:51234/) 260 | .expect(302, t.end) 261 | } 262 | ) 263 | 264 | test.cb('GET http://localhost:2000/proxy should redirect to target', t => { 265 | if (process.env.APPVEYOR) return t.end() 266 | request(app) 267 | .get('/proxy') 268 | .set('Host', 'localhost') 269 | .expect('location', /http:\/\/localhost:4000/) 270 | .expect(302, t.end) 271 | }) 272 | 273 | // 274 | // Test daemon/app.js 275 | // 276 | 277 | test.cb('GET / should render index.html', t => { 278 | ensureDistExists(t) 279 | request(app) 280 | .get('/') 281 | .expect(200, t.end) 282 | }) 283 | 284 | // 285 | // Test env variables 286 | // 287 | 288 | test.cb('GET / should contain custom env values', t => { 289 | request(app) 290 | .get('/') 291 | .set('Host', `custom-env.${tld}`) 292 | .expect(200, /FOO_VALUE/, t.end) 293 | }) 294 | 295 | test.cb('GET / should contain proxy env values', t => { 296 | request(app) 297 | .get('/') 298 | .set('Host', `custom-env.${tld}`) 299 | .expect(200, /http:\/\/127.0.0.1:2000\/proxy.pac/, t.end) 300 | }) 301 | 302 | // 303 | // Test headers 304 | // 305 | 306 | test.cb('GET node.tld/ should contain X-FORWARD headers', t => { 307 | request(app) 308 | .get('/') 309 | .set('Host', `node.${tld}`) 310 | .expect(200, new RegExp(`x-forwarded-host: node.${tld}`), t.end) 311 | }) 312 | 313 | test.cb('GET subdomain.node.tld/ should not contain X-FORWARD headers', t => { 314 | request(app) 315 | .get('/') 316 | .set('Host', `subdomain.node.${tld}`) 317 | .expect(200, /x-forwarded-host: undefined/, t.end) 318 | }) 319 | 320 | // 321 | // Test remove 322 | // 323 | 324 | test.cb('Removing a server should make it unavailable', t => { 325 | t.truthy(app.group.find('server-to-remove')) 326 | app.group.remove('server-to-remove', () => { 327 | request(app) 328 | .get('/') 329 | .set('Host', `server-to-remove.${tld}`) 330 | .expect(404, t.end) 331 | }) 332 | }) 333 | 334 | test.cb('Removing a proxy should make it unavailable', t => { 335 | t.truthy(app.group.find('proxy-to-remove')) 336 | app.group.remove('proxy-to-remove', () => { 337 | request(app) 338 | .get('/') 339 | .set('Host', `proxy-to-remove.${tld}`) 340 | .expect(404, t.end) 341 | }) 342 | }) 343 | -------------------------------------------------------------------------------- /test/daemon/group.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const sinon = require('sinon') 3 | const Group = require('../../src/daemon/group') 4 | const tcpProxy = require('../../src/daemon/tcp-proxy') 5 | const conf = require('../../src/conf') 6 | 7 | const { tld } = conf 8 | sinon.stub(tcpProxy, 'proxy') 9 | 10 | test('group.resolve should find the correct server or target id', t => { 11 | const group = Group() 12 | const conf = { target: 'http://example.com' } 13 | group.add('app', conf) 14 | group.add('foo.app', conf) 15 | 16 | t.is(group.resolve('app'), 'app') 17 | t.is(group.resolve('bar.app'), 'app') 18 | t.is(group.resolve('foo.app'), 'foo.app') 19 | t.is(group.resolve('baz.foo.app'), 'foo.app') 20 | }) 21 | 22 | test('group.handleUpgrade with proxy', t => { 23 | const group = Group() 24 | const target = 'example.com' 25 | const req = { 26 | headers: { 27 | host: `proxy.${tld}:80` 28 | } 29 | } 30 | const head = {} 31 | const socket = {} 32 | 33 | sinon.stub(group._proxy, 'ws') 34 | 35 | group.add('proxy', { target: `http://${target}` }) 36 | group.handleUpgrade(req, head, socket) 37 | 38 | sinon.assert.calledWith(group._proxy.ws, req, head, socket, { 39 | target: `ws://${target}` 40 | }) 41 | t.pass() 42 | }) 43 | 44 | test('group.handleUpgrade with app', t => { 45 | const group = Group() 46 | const PORT = '9000' 47 | const req = { 48 | headers: { 49 | host: `app.${tld}:80` 50 | } 51 | } 52 | const head = {} 53 | const socket = {} 54 | 55 | sinon.stub(group._proxy, 'ws') 56 | 57 | group.add('app', { 58 | cmd: 'cmd', 59 | cwd: '/some/path', 60 | env: { 61 | PORT 62 | } 63 | }) 64 | group.handleUpgrade(req, head, socket) 65 | 66 | sinon.assert.calledWith(group._proxy.ws, req, head, socket, { 67 | target: `ws://127.0.0.1:${PORT}` 68 | }) 69 | t.pass() 70 | }) 71 | 72 | test('group.handleUpgrade with app and port, port should take precedence', t => { 73 | const port = 5000 74 | const group = Group() 75 | const req = { 76 | headers: { 77 | host: `app.${tld}:${port}` 78 | } 79 | } 80 | const head = {} 81 | const socket = {} 82 | 83 | sinon.stub(group._proxy, 'ws') 84 | 85 | group.add('app', { 86 | cmd: 'cmd', 87 | cwd: '/some/path' 88 | }) 89 | group.handleUpgrade(req, head, socket) 90 | 91 | sinon.assert.calledWith(group._proxy.ws, req, head, socket, { 92 | target: `ws://127.0.0.1:${port}` 93 | }) 94 | t.pass() 95 | }) 96 | 97 | test('group.handleConnect with proxy', t => { 98 | const group = Group() 99 | const target = 'example.com' 100 | const req = { 101 | headers: { 102 | host: `proxy.${tld}:80` 103 | } 104 | } 105 | const head = {} 106 | const socket = {} 107 | 108 | tcpProxy.proxy.reset() 109 | 110 | group.add('proxy', { target: `http://${target}` }) 111 | group.handleConnect(req, head, socket) 112 | 113 | sinon.assert.calledWith(tcpProxy.proxy, socket, 80, 'example.com') 114 | t.pass() 115 | }) 116 | 117 | test('group.handleConnect with app', t => { 118 | const group = Group() 119 | const PORT = '9000' 120 | const req = { 121 | headers: { 122 | host: `app.${tld}:80` 123 | } 124 | } 125 | const head = {} 126 | const socket = {} 127 | 128 | tcpProxy.proxy.reset() 129 | 130 | group.add('app', { 131 | cmd: 'cmd', 132 | cwd: '/some/path', 133 | env: { 134 | PORT 135 | } 136 | }) 137 | group.handleConnect(req, head, socket) 138 | 139 | sinon.assert.calledWith(tcpProxy.proxy, socket, PORT) 140 | t.pass() 141 | }) 142 | 143 | test('group.handleConnect on port 443', t => { 144 | const group = Group() 145 | const req = { 146 | headers: { 147 | host: `anything.${tld}:443` 148 | } 149 | } 150 | const head = {} 151 | const socket = {} 152 | 153 | tcpProxy.proxy.reset() 154 | group.handleConnect(req, head, socket) 155 | 156 | sinon.assert.calledWith(tcpProxy.proxy, socket, conf.port + 1) 157 | t.pass() 158 | }) 159 | -------------------------------------------------------------------------------- /test/daemon/pem.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const test = require('ava') 3 | // TODO rename to KEY_NAME 4 | const { KEY_FILE, CERT_FILE, generate } = require('../../src/daemon/pem') 5 | const { hotelDir } = require('../../src/common') 6 | 7 | test.before(() => { 8 | fs.mkdirSync(hotelDir) 9 | }) 10 | 11 | test("should create cert files if they don't exist", t => { 12 | const { key, cert } = generate() 13 | t.true(fs.existsSync(KEY_FILE)) 14 | t.true(fs.existsSync(CERT_FILE)) 15 | t.is(key, fs.readFileSync(KEY_FILE, 'utf-8')) 16 | t.is(cert, fs.readFileSync(CERT_FILE, 'utf-8')) 17 | }) 18 | 19 | test('should read cert files if they exist', t => { 20 | fs.writeFileSync(KEY_FILE, 'foo') 21 | fs.writeFileSync(CERT_FILE, 'bar') 22 | const { key, cert } = generate() 23 | t.is('foo', key) 24 | t.is('bar', cert) 25 | }) 26 | -------------------------------------------------------------------------------- /test/fixtures/app/index.js: -------------------------------------------------------------------------------- 1 | var http = require('http') 2 | 3 | http 4 | .createServer(function(req, res) { 5 | console.log(req.headers) 6 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 7 | res.end( 8 | [ 9 | 'Hello World', 10 | process.env.FOO, 11 | process.env.HTTP_PROXY, 12 | 'x-forwarded-host: ' + req.headers['x-forwarded-host'], 13 | 'host: ' + req.headers.host 14 | ].join(' ') 15 | ) 16 | }) 17 | .listen(process.env.PORT, '127.0.0.1') 18 | 19 | console.log('Server listening on port', process.env.PORT) 20 | -------------------------------------------------------------------------------- /test/fixtures/verbose/index.js: -------------------------------------------------------------------------------- 1 | setInterval( 2 | () => 3 | console.log('[32m green text with blank line - ' + Math.random() + '\n'), 4 | 200 5 | ) 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "sourceMap": true, 5 | "target": "es6", 6 | "module": "commonjs", 7 | "jsx": "react", 8 | "strict": true, 9 | "experimentalDecorators": true 10 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 11 | } 12 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint-config-prettier" 6 | ], 7 | "jsRules": {}, 8 | "rulesDirectory": [ 9 | "tslint-plugin-prettier" 10 | ], 11 | "rules": { 12 | "prettier": [ 13 | true, 14 | { 15 | "semi": false, 16 | "singleQuote": true 17 | } 18 | ] 19 | } 20 | } -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | 4 | module.exports = { 5 | entry: './src/app/index.tsx', 6 | module: { 7 | rules: [ 8 | { 9 | test: /\.css$/, 10 | use: ['style-loader', 'css-loader'] 11 | }, 12 | { 13 | test: /\.tsx?$/, 14 | use: 'ts-loader', 15 | exclude: /node_modules/ 16 | } 17 | ] 18 | }, 19 | resolve: { 20 | extensions: ['.tsx', '.ts', '.js'] 21 | }, 22 | output: { 23 | filename: 'bundle.js', 24 | path: path.resolve(__dirname, 'dist') 25 | }, 26 | plugins: [ 27 | new HtmlWebpackPlugin({ 28 | template: 'src/app/index.html' 29 | }) 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge') 2 | const common = require('./webpack.common.js') 3 | 4 | module.exports = merge(common, { 5 | devtool: 'inline-source-map', 6 | devServer: { 7 | contentBase: './dist', 8 | proxy: { 9 | '/_': 'http://localhost:2000' 10 | } 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const merge = require('webpack-merge') 3 | const UglifyJSPlugin = require('uglifyjs-webpack-plugin') 4 | const common = require('./webpack.common.js') 5 | 6 | module.exports = merge(common, { 7 | plugins: [ 8 | new UglifyJSPlugin({ 9 | sourceMap: true 10 | }), 11 | new webpack.DefinePlugin({ 12 | 'process.env.NODE_ENV': JSON.stringify('production') 13 | }) 14 | ] 15 | }) 16 | --------------------------------------------------------------------------------