Repository: typicode/hotel Branch: master Commit: bcbdade4a5ce Files: 76 Total size: 86.2 KB Directory structure: gitextract_2x2tk0um/ ├── .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.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 │ │ ├── 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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "presets": [ ["env", { "targets": { "node": "6" } }] ], "plugins": [ "transform-object-rest-spread" ] } ================================================ FILE: .eslintrc.js ================================================ module.exports = { extends: ['standard', 'prettier'], plugins: ['prettier'], rules: { 'prettier/prettier': [ 'error', { singleQuote: true, semi: false, }, ] }, env: { mocha: true } } ================================================ FILE: .gitattributes ================================================ * text=auto ================================================ FILE: .github/FUNDING.yml ================================================ github: typicode ================================================ FILE: .gitignore ================================================ .DS_Store *.log node_modules lib dist static ================================================ FILE: .npmignore ================================================ src ================================================ FILE: .stylelintrc ================================================ { "extends": [ "stylelint-config-standard", "stylelint-config-recess-order" ] } ================================================ FILE: .travis.yml ================================================ language: node_js node_js: - "stable" - "6" script: - npm run build - npm test ================================================ FILE: CHANGELOG.md ================================================ # Change Log ## 0.8.7 * Fix UI menu overflow ## 0.8.6 * Fix `The listener must be a function` error ## 0.8.5 * Fix colors in output ## 0.8.4 * Fix UI crash ## 0.8.3 * Fix error in Edge * Improve bundle size ## 0.8.2 * UI ## 0.8.1 * Fix error page ## 0.8.0 * Create empty `conf.json` if it doesn't exist * Update UI * New 2018 style 🎉 * Links now open in new tabs (should improve integration with third-party tools) * Update all dependencies __Breaking__ * Drop Internet Explorer 11 support for the UI * Drop Node 4 support * 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` * __`.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`) ## 0.7.6 * Fix `package.json` not found error ## 0.7.5 * Add [please-upgrade-node](https://github.com/typicode/please-upgrade-node) * Chore: update all dependencies ## 0.7.4 * Remove `util.log` which has been deprecated in Node 6 ## 0.7.3 * Prevent `hotel ls` from crashing when listing malformed files [#190](https://github.com/typicode/hotel/pull/190) ## 0.7.2 * Update error page UI * Update Self-Signed SSL Certificate (__you may need to add an exception again__) * Fix Vue warning message in UI ## 0.7.1 * Fix daemon error ## 0.7.0 * Add `run` command * Add `http-proxy-env` flag to `hotel add` * Drop Node `0.12` support __Breaking__ * 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. ## 0.6.1 * Prevent using unsupported characters with `hotel add --name` [#100](https://github.com/typicode/hotel/issues/100) ## 0.6.0 * Add `--xfwd` and `--change-origin` flags to `hotel add` command * Log proxy errors __Breaking__ * If you want hotel to add `X-Forwarded-*` headers to requests, you need now to explicitly pass `-x/--xfwd` flags when adding a server. ## 0.5.13 * Fix `hotel add` CLI bug ## 0.5.12 * Add dark theme * Update `X-Forwarded-Port` header * Improve `ember-cli` and `livereload` support ## 0.5.11 * Add more `X-Forwarded-*` headers ## 0.5.10 * Pass `HTTP_PROXY` env to servers started by hotel ## 0.5.9 * UI bug fix ## 0.5.8 * Add `favicon` * Fix Safari and IE bug ## 0.5.6 * Fix Safari bug ## 0.5.5 * Add `X-Forwarded-Proto` header for ssl proxy * Support an array of environment variables for the CLI option `--env` * UI enhancements ## 0.5.4 * Fix Node 0.12 issue ## 0.5.3 * UI tweaks ## 0.5.2 * Fix option alias issue [#109](https://github.com/typicode/hotel/issues/109) ## 0.5.1 * Fix conf issue ## 0.5.0 * Various UI improvements * Add URL mapping support, for example `hotel add http://192.168.1.10 --name remote-server` * Change `hotel rm` options ## 0.4.22 * UI tweaks ## 0.4.21 * Fix UI issue with IE ## 0.4.20 * Fix UI issue with Safari 9 ## 0.4.19 * Support ANSI colors in the browser ## 0.4.18 * Bug fix ## 0.4.17 * Add `proxy` conf, use it if you're behind a corporate proxy. * Bug fix ## 0.4.16 * 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. ## 0.4.15 * Fix blank page issue in `v0.4.14`. ## 0.4.14 * Fix UI issues. ## 0.4.13 * Fix issue with Node 0.12. ## 0.4.12 * Add wildcard subdomains `http://*.app.localhost`. ## 0.4.11 * Strip ANSI when viewing logs in the browser. ## 0.4.10 * Fix IE and Safari issue (added fetch polyfill). ## 0.4.9 * Add server logs in the browser. * Bundle icons to make them available without network access. * Bug fixes. ## 0.4.8 * Bug fix ## 0.4.7 * Bundle front-end dependencies to make homepage work without network access. * Support subdomains `http://sub.app.localhost`. * Support `https` and `wss`. ## 0.4.6 * Bug fixes (0.4.3 to 0.4.5 deprecated). * Added `~/.hotel/daemon.pid` file. ## 0.4.3 * UI update. * Added top-level domain configuration option `tld`. * Added IE support. ## 0.4.2 * Removed `socket.io` dependency. ## 0.4.1 * Added WebSocket support for projects being accessed using local `.localhost` domain. ## 0.4.0 * Added Local `.localhost` domain support for HTTP requests. ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2015-present Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # hotel [![](https://badge.fury.io/js/hotel.svg)](https://www.npmjs.com/package/hotel) > Start apps from your browser and use local domains/https automatically ![](https://i.imgur.com/eDLgWMj.png) _Tip: if you don't enable local domains, hotel can still be used as a **catalog of local servers**._ Hotel works great on any OS (macOS, Linux, Windows) and with __all servers :heart:__ * Node (Express, Webpack) * PHP (Laravel, Symfony) * Ruby (Rails, Sinatra, Jekyll) * Python (Django) * Docker * Go * Apache, Nginx * ... _To all the amazing people who have answered the Hotel survey, thanks so much <3 !_ ## v0.8.0 upgrade `.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. If you're upgrading, please be sure to: 1. Remove `"tld": "dev"` from your `~/.hotel/conf.json` file 2. Run `hotel stop && hotel start` 3. Refresh your network settings ## Support If you are benefiting from hotel, you can support its development on [Patreon](https://patreon.com/typicode). You can view the list of Supporters here https://thanks.typicode.com. ## Video * [Starting apps with Hotel - Spacedojo Code Kata by Josh Owens](https://www.youtube.com/watch?v=BHW4tzctQ0k) ## Features * __Local domains__ - `http://project.localhost` * __HTTPS via local self-signed SSL certificate__ - `https://project.localhost` * __Wildcard subdomains__ - `http://*.project.localhost` * __Works everywhere__ - macOS, Linux and Windows * __Works with any server__ - Node, Ruby, PHP, ... * __Proxy__ - Map local domains to remote servers * __System-friendly__ - No messing with `port 80`, `/etc/hosts`, `sudo` or additional software * Fallback URL - `http://localhost:2000/project` * Servers are only started when you access them * Plays nice with other servers (Apache, Nginx, ...) * Random or fixed ports ## Install ```sh npm install -g hotel && hotel start ``` Hotel requires Node to be installed, if you don't have it, you can simply install it using one of the following method: * https://github.com/creationix/nvm `nvm install stable` * https://brew.sh `brew install node` You can also visit https://nodejs.org. ## Quick start ### Local domains (optional) 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 [__See instructions here__](https://github.com/typicode/hotel/blob/master/docs/README.md). ### Add your servers ```sh # Add your server to hotel ~/projects/one$ hotel add 'npm start' # Or start your server in the terminal as usual and get a temporary local domain ~/projects/two$ hotel run 'npm start' ``` Visit [localhost:2000](http://localhost:2000) or [http(s)://hotel.localhost](http://hotel.localhost). Alternatively you can directly go to ``` http://localhost:2000/one http://localhost:2000/two ``` ``` http(s)://one.localhost http(s)://two.localhost ``` #### Popular servers examples Using other servers? Here are some examples to get you started :) ```sh hotel add 'ember server' # Ember hotel add 'jekyll serve --port $PORT' # Jekyll hotel add 'rails server -p $PORT -b 127.0.0.1' # Rails hotel add 'python -m SimpleHTTPServer $PORT' # static file server (Python) hotel add 'php -S 127.0.0.1:$PORT' # PHP hotel add 'docker-compose up' # docker-compose hotel add 'python manage.py runserver 127.0.0.1:$PORT' # Django # ... ``` On __Windows__ use `"%PORT%"` instead of `'$PORT'` [__See a Docker example here.__](https://github.com/typicode/hotel/blob/master/docs/Docker.md). ### Proxy requests to remote servers Add your remote servers ```sh ~$ hotel add http://192.168.1.12:1337 --name aliased-address ~$ hotel add http://google.com --name aliased-domain ``` You can now access them using ```sh http://aliased-address.localhost # will proxy requests to http://192.168.1.12:1337 http://aliased-domain.localhost # will proxy requests to http://google.com ``` ## CLI usage and options ```sh hotel add [opts] hotel run [opts] # Examples hotel add 'nodemon app.js' --out dev.log # Set output file (default: none) hotel add 'nodemon app.js' --name name # Set custom name (default: current dir name) hotel add 'nodemon app.js' --port 3000 # Set a fixed port (default: random port) hotel add 'nodemon app.js' --env PATH # Store PATH environment variable in server config hotel add http://192.168.1.10 --name app # map local domain to URL hotel run 'nodemon app.js' # Run server and get a temporary local domain # Other commands hotel ls # List servers hotel rm # Remove server hotel start # Start hotel daemon hotel stop # Stop hotel daemon ``` To get help ```sh hotel --help hotel --help ``` ## Port For `hotel` to work, your servers need to listen on the PORT environment variable. Here are some examples showing how you can do it from your code or the command-line: ```js var port = process.env.PORT || 3000 server.listen(port) ``` ```sh hotel add 'cmd -p $PORT' # OS X, Linux hotel add "cmd -p %PORT%" # Windows ``` ## Fallback URL 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). ## Configurations, logs and self-signed SSL certificate You can find hotel related files in `~/.hotel` : ```sh ~/.hotel/conf.json ~/.hotel/daemon.log ~/.hotel/daemon.pid ~/.hotel/key.pem ~/.hotel/cert.pem ~/.hotel/servers/.json ``` By default, `hotel` uses the following configuration values: ```js { "port": 2000, "host": '127.0.0.1', // Timeout when proxying requests to local domains "timeout": 5000, // Change this if you want to use another tld than .localhost "tld": 'localhost', // If you're behind a corporate proxy, replace this with your network proxy IP (example: "1.2.3.4:5000") "proxy": false } ``` To override a value, simply add it to `~/.hotel/conf.json` and run `hotel stop && hotel start` ## Third-party tools * [Hotelier](https://github.com/macav/hotelier) Hotelier (Mac & Windows Tray App) * [Hotel Clerk](https://github.com/therealklanni/hotel-clerk) OS X menubar * [HotelX](https://github.com/djyde/HotelX) Another OS X menubar (only 1.6MB) * [alfred-hotel](https://github.com/exah/alfred-hotel) Alfred 3 workflow * [Hotel Manager](https://github.com/hardpixel/hotel-manager) Gnome Shell extension ## FAQ #### Setting a fixed port ```sh hotel add --port 3000 'server-cmd $PORT' ``` #### Adding `X-Forwarded-*` headers to requests ```sh hotel add --xfwd 'server-cmd' ``` #### Setting `HTTP_PROXY` env Use `--http-proxy-env` flag when adding your server or edit your server configuration in `~/.hotel/servers` ```sh hotel add --http-proxy-env 'server-cmd' ``` #### Proxying requests to a remote `https` server ```sh hotel add --change-origin 'https://jsonplaceholder.typicode.com' ``` _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._ #### `ENOSPC` and `EACCES` errors 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`. ```sh # to fix permissions sudo chown -R $USER: $HOME/.hotel ``` See also, https://docs.npmjs.com/getting-started/fixing-npm-permissions #### Configuring a network proxy IP If you're behind a corporate proxy, replace `"proxy"` with your network proxy IP in `~/.hotel/conf.json`. For example: ```json { "proxy": "1.2.3.4:5000" } ``` ## License MIT [Patreon](https://www.patreon.com/typicode) - [Supporters](https://thanks.typicode.com) ✨ ================================================ FILE: appveyor.yml ================================================ environment: nodejs_version: '6' install: - ps: Install-Product node $env:nodejs_version - npm install --ignore-scripts test_script: - node --version - npm --version - npm test build_script: npm run build ================================================ FILE: bin/uninstall.js ================================================ require('../lib/scripts/uninstall')() ================================================ FILE: docs/Docker.md ================================================ # Docker [Docker](https://www.docker.com/) is a software container platform that integrates easily into Hotel. ### Dockerfile A [Dockerfile](https://docs.docker.com/engine/reference/builder/) is used to create a single container. Here is an example: ``` FROM httpd:2.4 COPY ./public-html/ /usr/local/apache2/htdocs/ ``` To build this image, run the following in the application directory: ``` docker build -t my-apache2 . ``` To use Hotel for this example, run the following in the application directory: ``` hotel add 'docker run -dit --name my-running-app my-apache2' ``` ### Docker Compose [Compose](https://docs.docker.com/compose/) is a tool for defining and running multi-container Docker applications. Here is a simple example: ``` version: '3' services: web: build: . ports: - "5000:5000" ``` This binds the internal port 5000 on the container to port 5000 on the host machine. To build this image, run the following: `docker-compose build` To use Hotel for this, run the following in the application directory: ``` hotel add 'docker-compose up' -p 5000 ``` ================================================ FILE: docs/README.md ================================================ # Configuring local .localhost domains _This step is totally optional and you can use hotel without it._ 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)]. __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. ## Configuring another .tld You can edit `~/.hotel/conf.json` to use another Top-level Domain than `.localhost`. ```json { "tld": "test" } ``` __Important__ Don't forget to restart hotel and reload network or browser configuration. ## System configuration (recommended) ##### macOS `Network Preferences > Advanced > Proxies > Automatic Proxy Configuration` ##### Windows `Settings > Network and Internet > Proxy > Use setup script` ##### Linux On Ubuntu `System Settings > Network > Network Proxy > Automatic` For other distributions, check your network manager and look for proxy configuration. Use browser configuration as an alternative. ## Browser configuration Browsers can be configured to use a specific proxy. Use this method as an alternative to system-wide configuration. ##### Chrome Exit Chrome and start it using the following option: ```sh # Linux $ google-chrome --proxy-pac-url=http://localhost:2000/proxy.pac # macOS $ open -a "Google Chrome" --args --proxy-pac-url=http://localhost:2000/proxy.pac ``` ##### Firefox `Preferences > Advanced > Network > Connection > Settings > Automatic proxy URL configuration` ##### Internet Explorer Uses system network configuration. ================================================ FILE: nodemon.json ================================================ { "exec": "babel-node", "ignore": [ "src/app/**/*", "src/daemon/public/**/*" ] } ================================================ FILE: package.json ================================================ { "name": "hotel", "version": "1.0.0", "description": "Local domains for everyone and more! ", "main": "lib", "bin": "lib/cli/bin.js", "engines": { "node": ">=6" }, "scripts": { "test": "ava && npm run lint", "lint": "eslint . --ignore-path .gitignore && stylelint './src/app/**/*.css'", "fix": "eslint . --ignore-path .gitignore --fix && stylelint './src/app/**/*.css' --fix", "start": "run-p start:*", "start:webpack": "webpack-dev-server --config webpack.dev.js --open", "start:nodemon": "nodemon -- src/daemon", "prepublishOnly": "npm run build && pkg-ok", "uninstall": "node bin/uninstall.js", "build": "run-s build:*", "build:webpack": "rimraf dist && webpack --config webpack.prod.js", "build:babel": "rimraf lib && babel src -d lib --copy-files --ignore src/app", "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 " }, "repository": { "type": "git", "url": "https://github.com/typicode/hotel.git" }, "keywords": [ "dev", "devtool", "domain", "host", "https", "local", "localhost", "manager", "process", "proxy", "server" ], "author": "Typicode ", "license": "MIT", "bugs": { "url": "https://github.com/typicode/hotel/issues" }, "homepage": "https://github.com/typicode/hotel", "dependencies": { "after-all": "^2.0.2", "ansi2html": "0.0.1", "chalk": "^2.3.1", "chokidar": "^2.0.2", "connect-sse": "^1.2.0", "exit-hook": "^1.1.1", "express": "^4.16.2", "get-port": "^3.2.0", "http-proxy": "^1.17.0", "matcher": "^1.1.0", "mkdirp": "^0.5.1", "once": "^1.3.2", "please-upgrade-node": "^3.0.2", "pug": "^2.0.0-rc.4", "respawn": "^2.4.1", "selfsigned": "^1.10.2", "server-ready": "^0.3.1", "sudo-block": "^2.0.0", "tildify": "^1.1.2", "tinydate": "^1.0.0", "unquote": "^1.1.1", "untildify": "^3.0.2", "update-notifier": "^2.3.0", "user-startup": "^0.2.1", "vhost": "^3.0.2", "yargs": "^10.1.2" }, "devDependencies": { "@types/classnames": "^2.2.3", "@types/escape-html": "0.0.20", "@types/react": "^16.0.38", "@types/react-dom": "^16.0.4", "@types/react-icons": "^2.2.5", "ava": "^0.25.0", "babel-cli": "^6.26.0", "babel-plugin-transform-object-rest-spread": "^6.26.0", "babel-preset-env": "^1.6.1", "classnames": "^2.2.5", "css-loader": "^0.28.10", "escape-html": "^1.0.3", "eslint": "^4.18.1", "eslint-config-prettier": "^2.9.0", "eslint-config-standard": "^11.0.0", "eslint-plugin-import": "^2.9.0", "eslint-plugin-node": "^5.2.1", "eslint-plugin-prettier": "^2.6.0", "eslint-plugin-promise": "^3.6.0", "eslint-plugin-standard": "^3.0.1", "html-webpack-plugin": "^2.30.1", "husky": "^0.15.0-rc.8", "lodash.uniqueid": "^4.0.1", "mobx": "^3.5.1", "mobx-react": "^4.4.2", "nodemon": "^1.15.1", "npm-run-all": "^4.1.2", "pkg-ok": "^2.1.0", "prettier": "^1.10.2", "react": "^16.2.0", "react-dom": "^16.2.0", "react-icons": "^2.2.7", "rimraf": "^2.6.2", "sinon": "^4.4.2", "style-loader": "^0.19.1", "stylelint": "^8.4.0", "stylelint-config-recess-order": "^1.2.3", "stylelint-config-standard": "^18.1.0", "supertest": "^3.0.0", "tempy": "^0.2.0", "ts-loader": "^3.5.0", "tslint": "^5.9.1", "tslint-config-prettier": "^1.9.0", "tslint-plugin-prettier": "^1.3.0", "typescript": "^2.7.2", "uglifyjs-webpack-plugin": "^1.2.2", "webpack": "^3.11.0", "webpack-dev-server": "^2.11.1", "webpack-merge": "^4.1.2" }, "ava": { "serial": true, "verbose": true, "require": [ "babel-register", "./test/_setup" ], "babel": "inherit" }, "husky": { "hooks": { "pre-commit": "npm test" } } } ================================================ FILE: src/app/Store.ts ================================================ import * as uniqueId from 'lodash.uniqueid' import { action, computed, observable } from 'mobx' import * as api from './api' import { formatLines } from './formatter' export interface IProxy { target: string } export interface ILine { id: string html: string } export interface IMonitor { cwd: string command: string[] status: string output: ILine[] started: Date pid: number } export const RUNNING = 'running' export const STOPPED = 'stopped' const MAX_OUTPUT_LENGTH = 1000 function clear(servers: Map, data: any) { servers.forEach((server, id) => { if (!data.hasOwnProperty(id)) { servers.delete(id) } }) } export default class Store { @observable public isLoading: boolean = true @observable public selectedMonitorId: string = '' @observable public monitors: Map = new Map() @observable public proxies: Map = new Map() constructor() { this.watchServers() this.watchOutput() } @action public watchServers() { api.watchServers(data => { // Delete servers that do not exist anymore in Hotel clear(this.monitors, data) clear(this.proxies, data) // Create or update servers Object.keys(data).forEach(id => { const server = data[id] if (this.monitors.has(id) || this.proxies.has(id)) { // Update server state if (server.hasOwnProperty('status')) { Object.assign(this.monitors.get(id), server) } else { Object.assign(this.proxies.get(id), server) } } else { // Create new server if (server.hasOwnProperty('status')) { server.output = [] this.monitors.set(id, server) } else { this.proxies.set(id, server) } } }) // Initial data has been loaded this.isLoading = false }) } @action public watchOutput() { api.watchOutput(data => { const { id, output } = data const lines = formatLines(output).map(html => ({ html, id: uniqueId() })) lines.forEach(line => { const monitor = this.monitors.get(id) if (monitor) { monitor.output.push(line) if (monitor.output.length > MAX_OUTPUT_LENGTH) { monitor.output.shift() } } }) }) } @action public selectMonitor(monitorId: string) { this.selectedMonitorId = this.selectedMonitorId === monitorId ? '' : monitorId } @action public toggleMonitor(monitorId: string) { const monitor = this.monitors.get(monitorId) if (monitor) { if (monitor.status === RUNNING) { api.stopMonitor(monitorId) monitor.status = STOPPED // optimistic update } else { api.startMonitor(monitorId) monitor.status = RUNNING } } } @action public clearOutput(monitorId: string) { const monitor = this.monitors.get(monitorId) if (monitor) { monitor.output = [] } } } ================================================ FILE: src/app/api.ts ================================================ interface IEvent { data: string } export function fetchServers() { return window.fetch('/_/servers').then(response => response.json()) } export function watchServers(cb: (data: any) => void) { if (window.EventSource) { new window.EventSource('/_/events').onmessage = (event: IEvent) => { const data = JSON.parse(event.data) cb(data) } } else { setInterval(() => { window .fetch('/_/servers') .then(response => response.json()) .then(data => cb(data)) }, 1000) } } export function watchOutput(cb: (data: any) => void) { if (window.EventSource) { new window.EventSource('/_/events/output').onmessage = (event: IEvent) => { const data = JSON.parse(event.data) cb(data) } } else { window.alert("Sorry, server logs aren't supported on this browser :(") } } export function startMonitor(id: string) { return window.fetch(`/_/servers/${id}/start`, { method: 'POST' }) } export function stopMonitor(id: string) { return window.fetch(`/_/servers/${id}/stop`, { method: 'POST' }) } ================================================ FILE: src/app/components/App/index.css ================================================ * { box-sizing: border-box; } :root { --primary-light: #484848; --primary: #212121; --primary: #282c2f; --primary-dark: #000; --accent: #4c9e97; } body { padding: 0; margin: 0; font-family: 'Roboto', sans-serif; color: white; background-color: var(--primary-dark); text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } a { color: #6c6c6f; text-decoration: none; } a:hover { color: white; text-decoration: underline; } .nav { height: 100vh; } /* 640px */ @media (min-width: 40rem) { .container { display: grid; grid-template-columns: 1fr 3fr; } } ================================================ FILE: src/app/components/App/index.tsx ================================================ import { observer } from 'mobx-react' import * as React from 'react' import Store from '../../Store' import Content from '../Content' import Nav from '../Nav' import Splash from '../Splash' import './index.css' export interface IProps { store: Store } function App({ store }: IProps) { return (
) } export default observer(App) ================================================ FILE: src/app/components/Content/index.css ================================================ .content { height: 100vh; overflow-y: scroll; background-color: var(--primary); } .content a { color: white; } .content-bar { position: sticky; top: 0; left: 0; display: flex; justify-content: space-between; padding: 0 0 0 1rem; background-color: var(--primary-dark); } .content-bar > span { align-self: center; } pre { padding: 1rem; margin: 0; word-break: break-all; } button { padding: 1rem; color: white; cursor: pointer; background: none; border: none; outline: none; } button:hover { background: var(--primary); } ================================================ FILE: src/app/components/Content/index.tsx ================================================ import { observer } from 'mobx-react' import * as React from 'react' import * as MdArrowDownward from 'react-icons/lib/md/arrow-downward' import * as MdClearAll from 'react-icons/lib/md/clear-all' import Link from '../Link' import Store from '../../Store' import './index.css' export interface IProps { store: Store } @observer class Content extends React.Component { private el: HTMLDivElement | null = null private atBottom: boolean = true public componentWillUpdate() { if (this.el) { this.atBottom = this.isAtBottom() } } public componentDidUpdate() { if (this.atBottom) { this.scrollToBottom() } } public isAtBottom() { if (this.el) { const { scrollHeight, scrollTop, clientHeight } = this.el return scrollHeight - scrollTop === clientHeight } else { return true } } public scrollToBottom() { if (this.el) { this.el.scrollTop = this.el.scrollHeight } } public onScroll() { this.atBottom = this.isAtBottom() } public render() { const { store } = this.props const monitor = store.monitors.get(store.selectedMonitorId) return (
this.onScroll()} ref={el => { this.el = el }} >
          {monitor &&
            monitor.output.map(line => (
              
))}
) } } export default Content ================================================ FILE: src/app/components/Link/index.tsx ================================================ import * as React from 'react' import { IMonitor, IProxy } from '../../Store' function href(id: string) { const { protocol, hostname } = window.location if (/hotel\./.test(hostname)) { // Accessed using hotel.tld const tld = hostname.split('.').slice(-1)[0] return `${protocol}//${id}.${tld}` } else { // Accessed using localhost return `/${id}` } } interface IProps { id: string } function Link({ id }: IProps) { return ( e.stopPropagation()}> {id} ) } export default Link ================================================ FILE: src/app/components/Nav/index.css ================================================ .nav { display: flex; flex-direction: column; min-height: 100vh; border-right: 1px solid var(--primary); } header { padding: 1rem; font-size: 1rem; line-height: 1rem; text-transform: capitalize; border-bottom: 1px solid var(--primary); } .menu { flex: 1; overflow-y: scroll; } .menu.hidden { visibility: hidden; } footer { padding: 1rem; } h2 { padding: 1rem; margin: 0; font-size: 1rem; font-weight: normal; text-transform: uppercase; } ul { padding: 0; margin: 0 0 2rem 0; list-style: none; } li { display: flex; justify-content: space-between; height: 3rem; color: var(--primary-light); transition: border-color 0.2s; } li:focus { outline: none; } li.running * { color: white; } li.monitor:hover { cursor: pointer; background: var(--primary); } li.selected { background: var(--primary); } li > span { align-self: center; padding: 0 1rem; } /* Align Switch vertically */ li > span:nth-last-child() { line-height: 0; } p { padding: 1rem; } ================================================ FILE: src/app/components/Nav/index.tsx ================================================ import * as classNames from 'classnames' import { observer } from 'mobx-react' import * as React from 'react' import Store, { RUNNING } from '../../Store' import Link from '../Link' import Switch from '../Switch' import './index.css' const examples = `~/app$ hotel add 'cmd' ~/app$ hotel add 'cmd -p $PORT' ~/app$ hotel add http://192.16.1.2:3000` export interface IProps { store: Store } function Nav({ store }: IProps) { const { isLoading, selectedMonitorId, monitors, proxies } = store return (
hotel
{monitors.size === 0 && proxies.size === 0 && (

To add a server, use hotel add

                {examples}
              
)} {monitors.size > 0 && (

monitors

    {Array.from(monitors).map(([id, monitor]) => { return (
  • store.selectMonitor(id)} > store.toggleMonitor(id)} checked={monitor.status === RUNNING} />
  • ) })}
)} {proxies.size > 0 && (

proxies

    {Array.from(proxies).map(([id, proxy]) => { return (
  • ) })}
)}
) } export default observer(Nav) ================================================ FILE: src/app/components/Splash/index.css ================================================ .splash { display: flex; align-items: center; justify-content: center; height: 100vh; } ================================================ FILE: src/app/components/Splash/index.tsx ================================================ import * as React from 'react' import './index.css' function Splash() { return
{/* Hotel ホテル */}
} export default Splash ================================================ FILE: src/app/components/Switch/index.css ================================================ .switch { position: relative; display: inline-block; width: 30px; height: 17px; } .switch input { display: none; } .slider { position: absolute; top: 0; right: 0; bottom: 0; left: 0; cursor: pointer; background-color: #ccc; transition: 0.4s; } .slider::before { position: absolute; bottom: 2px; left: 2px; width: 13px; height: 13px; content: ""; background-color: white; transition: 0.4s; } input:checked + .slider { background-color: var(--accent); } input:focus + .slider { box-shadow: 0 0 1px var(--primary-dark); /* works ? */ } input:checked + .slider::before { transform: translateX(13px); } /* Rounded sliders */ .slider.round { border-radius: 17px; } .slider.round::before { border-radius: 50%; } ================================================ FILE: src/app/components/Switch/index.tsx ================================================ import * as React from 'react' import './index.css' export interface IProps { onClick?: () => void checked?: boolean } function Switch({ onClick = () => null, checked }: IProps) { return ( ) } export default Switch ================================================ FILE: src/app/formatter.ts ================================================ import * as ansi2HTML from 'ansi2html' import * as escapeHTML from 'escape-html' import { IMonitor } from './Store' function blankLine(val: string) { return val.trim() === '' ? ' ' : val } export function formatLines(str: string): string[] { return str .replace(/\n$/, '') .split('\n') .map(escapeHTML) .map(blankLine) .map(ansi2HTML) } export function statusTitle(monitor: IMonitor) { return monitor.pid ? `PID ${monitor.pid}\nStarted since ${new Date( monitor.started ).toLocaleString()}` : '' } ================================================ FILE: src/app/global.d.ts ================================================ declare module 'ansi2html' declare module 'lodash.uniqueid' declare module 'react-icons/lib/md/arrow-downward' declare module 'react-icons/lib/md/clear-all' interface Window { EventSource: any } ================================================ FILE: src/app/index.html ================================================ Hotel
================================================ FILE: src/app/index.tsx ================================================ import * as React from 'react' import * as ReactDOM from 'react-dom' import App from './components/App' import Store from './Store' const store = new Store() ReactDOM.render(, document.getElementById('root')) ================================================ FILE: src/cli/bin.js ================================================ #!/usr/bin/env node const pkg = require('../../package.json') require('please-upgrade-node')(pkg) const updateNotifier = require('update-notifier') const sudoBlock = require('sudo-block') sudoBlock('\nShould not be run as root, please retry without sudo.\n') updateNotifier({ pkg }).notify() require('./')(process.argv) ================================================ FILE: src/cli/daemon.js ================================================ const fs = require('fs') const path = require('path') const mkdirp = require('mkdirp') const startup = require('user-startup') const common = require('../common') const conf = require('../conf') const uninstall = require('../scripts/uninstall') module.exports = { start, stop } // Start daemon in background function start() { const node = process.execPath const daemonFile = path.join(__dirname, '../daemon') const startupFile = startup.getFile('hotel') startup.create('hotel', node, [daemonFile], common.logFile) // Save startup file path in ~/.hotel // Will be used later by uninstall script mkdirp.sync(common.hotelDir) fs.writeFileSync(common.startupFile, startupFile) console.log(`Started http://localhost:${conf.port}`) } // Stop daemon function stop() { startup.remove('hotel') // kills process and clean stuff in ~/.hotel uninstall() console.log('Stopped') } ================================================ FILE: src/cli/index.js ================================================ const yargs = require('yargs') const servers = require('./servers') const run = require('./run') const daemon = require('./daemon') const pkg = require('../../package.json') const addOptions = { name: { alias: 'n', describe: 'Server name' }, port: { alias: 'p', describe: 'Set PORT environment variable', number: true }, out: { alias: 'o', describe: 'Output file' }, env: { alias: 'e', describe: 'Additional environment variables', array: true }, xfwd: { alias: 'x', describe: 'Adds x-forward headers', default: false, boolean: true }, 'change-origin': { alias: 'co', describe: 'Changes the origin of the host header to the target URL', default: false, boolean: true }, 'http-proxy-env': { describe: 'Adds HTTP_PROXY environment variable', default: false, boolean: true }, dir: { describe: 'Server directory', string: true } } module.exports = processArgv => yargs(processArgv.slice(2)) .version(pkg.version) .alias('v', 'version') .help('h') .alias('h', 'help') .command( 'add [options]', 'Add server or proxy', yargs => yargs.options(addOptions), // .demand(1), argv => servers.add(argv['cmd_or_url'], argv) ) .command( 'run [options]', 'Run server and get a temporary local domain', yargs => { const runOptions = { ...addOptions } delete runOptions['out'] return yargs.options(runOptions) // TODO demand(1) ? }, argv => run.spawn(argv['cmd'], argv) ) .command( 'rm [options]', 'Remove server or proxy', yargs => { yargs.option('name', { alias: 'n', describe: 'Name' }) }, argv => servers.rm(argv) ) .command('ls', 'List servers', {}, argv => servers.ls(argv)) .command('start', 'Start daemon', {}, () => daemon.start()) .command('stop', 'Stop daemon', {}, () => daemon.stop()) .example('$0 add --help') .example('$0 add nodemon') .example('$0 add npm start') .example("$0 add 'cmd -p $PORT'") .example("$0 add 'cmd -p $PORT' --port 4000") .example("$0 add 'cmd -p $PORT' --out app.log") .example("$0 add 'cmd -p $PORT' --name app") .example("$0 add 'cmd -p $PORT' --env PATH") .example('$0 add http://192.168.1.10 -n app ') .example('$0 rm') .example('$0 rm -n app') .epilog('https://github.com/typicode/hotel') .demand(1) .strict() .help().argv ================================================ FILE: src/cli/run.js ================================================ const cp = require('child_process') const getPort = require('get-port') const servers = require('./servers') const getCmd = require('../get-cmd') const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'] module.exports = { // For testing purpose, allows stubbing cp.spawnSync _spawnSync(...args) { cp.spawnSync(...args) }, // For testing purpose, allows stubbing process.exit _exit(...args) { process.exit(...args) }, spawn(cmd, opts = {}) { const cleanAndExit = (code = 0) => { servers.rm(opts) this._exit(code) } const startServer = port => { const serverAddress = `http://localhost:${port}` process.env.PORT = port servers.add(serverAddress, opts) signals.forEach(signal => process.on(signal, cleanAndExit)) const [command, ...args] = getCmd(cmd) const { status, error } = this._spawnSync(command, args, { stdio: 'inherit', cwd: process.cwd() }) if (error) throw error cleanAndExit(status) } if (opts.port) { startServer(opts.port) } else { getPort() .then(startServer) .catch(err => { throw err }) } } } ================================================ FILE: src/cli/servers.js ================================================ const fs = require('fs') const path = require('path') const chalk = require('chalk') const tildify = require('tildify') const mkdirp = require('mkdirp') const common = require('../common') const serversDir = common.serversDir module.exports = { add, rm, ls } function isUrl(str) { return /^(http|https):/.test(str) } // Converts '_-Some Project_Name--' to 'some-project-name' function domainify(str) { return ( str .toLowerCase() // Replace all _ and spaces with - .replace(/(_| )/g, '-') // Trim - characters .replace(/(^-*|-*$)/g, '') ) } function getId(cwd) { return domainify(path.basename(cwd)) } function getServerFile(id) { return `${serversDir}/${id}.json` } function add(param, opts = {}) { mkdirp.sync(serversDir) const cwd = opts.dir || process.cwd() const id = opts.name ? domainify(opts.name) : getId(cwd) const file = getServerFile(id) let conf = {} if (opts.xfwd) { conf.xfwd = opts.xfwd } if (opts.changeOrigin) { conf.changeOrigin = opts.changeOrigin } if (opts.httpProxyEnv) { conf.httpProxyEnv = opts.httpProxyEnv } if (isUrl(param)) { conf = { target: param, ...conf } } else { conf = { cwd, cmd: param, ...conf } if (opts.o) conf.out = opts.o conf.env = {} // By default, save PATH env for version managers users conf.env.PATH = process.env.PATH // Copy other env option if (opts.env) { opts.env.forEach(key => { const value = process.env[key] if (value) { conf.env[key] = value } }) } // Copy port option if (opts.port) { conf.env.PORT = opts.port } } const data = JSON.stringify(conf, null, 2) console.log(`Create ${tildify(file)}`) fs.writeFileSync(file, data) // if we're mapping a domain to a URL there's no additional info to output if (conf.target) return // if we're mapping a domain to a local server add some info if (conf.out) { const logFile = tildify(path.resolve(conf.out)) console.log(`Output ${logFile}`) } else { console.log("Output No log file specified (use '-o app.log')") } if (!opts.p) { console.log("Port Random port (use '-p 1337' to set a fixed port)") } } function rm(opts = {}) { const cwd = process.cwd() const id = opts.n || getId(cwd) const file = getServerFile(id) console.log(`Remove ${tildify(file)}`) if (fs.existsSync(file)) { fs.unlinkSync(file) console.log('Removed') } else { console.log('No such file') } } function ls() { mkdirp.sync(serversDir) const list = fs .readdirSync(serversDir) .map(file => { const id = path.basename(file, '.json') const serverFile = getServerFile(id) let server try { server = JSON.parse(fs.readFileSync(serverFile)) } catch (error) { // Ignore mis-named or malformed files return } if (server.cmd) { return `${id}\n${chalk.gray(tildify(server.cwd))}\n${chalk.gray( server.cmd )}` } else { return `${id}\n${chalk.gray(server.target)}` } }) .filter(item => item) .join('\n\n') console.log(list) } ================================================ FILE: src/common.js ================================================ const path = require('path') const homedir = require('os').homedir() const hotelDir = path.join(homedir, '.hotel') module.exports = { hotelDir, confFile: path.join(hotelDir, 'conf.json'), serversDir: path.join(hotelDir, 'servers'), pidFile: path.join(hotelDir, 'daemon.pid'), logFile: path.join(hotelDir, 'daemon.log'), startupFile: path.join(hotelDir, 'startup') } ================================================ FILE: src/conf.js ================================================ const fs = require('fs') const mkdirp = require('mkdirp') const { hotelDir, confFile } = require('./common') // Create dir mkdirp.sync(hotelDir) // Defaults const defaults = { port: 2000, host: '127.0.0.1', timeout: 5000, tld: 'localhost', // Replace with your network proxy IP (1.2.3.4:5000) if any // For example, if you're behind a corporate proxy proxy: false } // Create empty conf it it doesn't exist if (!fs.existsSync(confFile)) fs.writeFileSync(confFile, '{}') // Read file const conf = JSON.parse(fs.readFileSync(confFile)) // Assign defaults and export module.exports = { ...defaults, ...conf } ================================================ FILE: src/daemon/app.js ================================================ const path = require('path') const http = require('http') const express = require('express') const vhost = require('vhost') const serverReady = require('server-ready') const conf = require('../conf') // Require routes const IndexRouter = require('./routers') const APIRouter = require('./routers/api') const TLDHost = require('./vhosts/tld') module.exports = group => { const app = express() const server = http.createServer(app) // Initialize routes const indexRouter = IndexRouter(group) const api = APIRouter(group) const tldHost = TLDHost(group) // requests timeout serverReady.timeout = conf.timeout // Templates app.set('views', path.join(__dirname, 'views')) app.set('view engine', 'pug') app.locals.pretty = true // API app.use('/_', api) // .tld host app.use(vhost(new RegExp(`.*.${conf.tld}`), tldHost)) // app.get('/', (req, res) => res.render('index')) // Static files // vendors, etc... app.use(express.static(path.join(__dirname, 'public'))) // front files app.use(express.static(path.join(__dirname, '../../dist'))) // localhost router app.use(indexRouter) // Handle CONNECT, used by WebSockets and https when accessing .localhost domains server.on('connect', (req, socket, head) => { group.handleConnect(req, socket, head) }) server.on('upgrade', (req, socket, head) => { group.handleUpgrade(req, socket, head) }) return server } ================================================ FILE: src/daemon/group.js ================================================ const fs = require('fs') const path = require('path') const EventEmitter = require('events') const url = require('url') const once = require('once') const getPort = require('get-port') const matcher = require('matcher') const respawn = require('respawn') const afterAll = require('after-all') const httpProxy = require('http-proxy') const serverReady = require('server-ready') const log = require('./log') const tcpProxy = require('./tcp-proxy') const daemonConf = require('../conf') const getCmd = require('../get-cmd') module.exports = () => new Group() class Group extends EventEmitter { constructor() { super() this._list = {} this._proxy = httpProxy.createProxyServer({ xfwd: true }) } _output(id, data) { this.emit('output', id, data) } _log(mon, logFile, data) { mon.tail = mon.tail .concat(data) .split('\n') .slice(-100) .join('\n') if (logFile) { fs.appendFile(logFile, data, err => { if (err) log(err.message) }) } } _change() { this.emit('change', this._list) } // // Conf // list() { return this._list } find(id) { return this._list[id] } add(id, conf) { if (conf.target) { log(`Add target ${id}`) this._list[id] = conf this._change() return } log(`Add server ${id}`) const HTTP_PROXY = `http://127.0.0.1:${daemonConf.port}/proxy.pac` conf.env = { ...process.env, ...conf.env } if (conf.httpProxyEnv) { conf.env = { HTTP_PROXY, HTTPS_PROXY: HTTP_PROXY, http_proxy: HTTP_PROXY, https_proxy: HTTP_PROXY, ...conf.env } } let logFile if (conf.out) { logFile = path.resolve(conf.cwd, conf.out) } const command = getCmd(conf.cmd) const mon = respawn(command, { ...conf, maxRestarts: 0 }) this._list[id] = mon // Add proxy config mon.xfwd = conf.xfwd || false mon.changeOrigin = conf.changeOrigin || false // Emit output mon.on('stdout', data => this._output(id, data)) mon.on('stderr', data => this._output(id, data)) mon.on('warn', data => this._output(id, data)) // Emit change mon.on('start', () => this._change()) mon.on('stop', () => this._change()) mon.on('crash', () => this._change()) mon.on('sleep', () => this._change()) mon.on('exit', () => this._change()) // Log status mon.on('start', () => log(id, 'has started')) mon.on('stop', () => log(id, 'has stopped')) mon.on('crash', () => log(id, 'has crashed')) mon.on('sleep', () => log(id, 'is sleeping')) mon.on('exit', () => log(id, 'child process has exited')) // Handle logs mon.tail = '' mon.on('stdout', data => this._log(mon, logFile, data)) mon.on('stderr', data => this._log(mon, logFile, data)) mon.on('warn', data => this._log(mon, logFile, data)) mon.on('start', () => { mon.tail = '' if (logFile) { fs.unlink(logFile, err => { if (err) log(err.message) }) } }) this._change() } remove(id, cb) { const item = this.find(id) if (item) { delete this._list[id] this._change() if (item.stop) { item.stop(cb) item.removeAllListeners() return } } cb && cb() } stopAll(cb) { const next = afterAll(cb) Object.keys(this._list).forEach(key => { if (this._list[key].stop) { this._list[key].stop(next()) } }) } update(id, conf) { this.remove(id, () => this.add(id, conf)) } // // Hostname resolver // resolve(str) { log(`Resolve ${str}`) const arr = Object.keys(this._list) .sort() .reverse() .map(h => ({ host: h, isStrictMatch: matcher.isMatch(str, h), isWildcardMatch: matcher.isMatch(str, `*.${h}`) })) const strictMatch = arr.find(h => h.isStrictMatch) const wildcardMatch = arr.find(h => h.isWildcardMatch) if (strictMatch) return strictMatch.host if (wildcardMatch) return wildcardMatch.host } // // Middlewares // exists(req, res, next) { // Resolve using either hostname `app.tld` // or id param `http://localhost:2000/app` const tld = new RegExp(`.${daemonConf.tld}$`) const id = req.params.id ? this.resolve(req.params.id) : this.resolve(req.hostname.replace(tld, '')) // Find item const item = this.find(id) // Not found if (!id || !item) { const msg = `Can't find server id: ${id}` log(msg) return res.status(404).send(msg) } req.hotel = { id, item } next() } start(req, res, next) { const { item } = req.hotel if (item.start) { if (item.env.PORT) { item.start() next() } else { getPort() .then(port => { item.env.PORT = port item.start() next() }) .catch(error => { next(error) }) } } else { next() } } stop(req, res, next) { const { item } = req.hotel if (item.stop) { item.stop() } next() } proxyWeb(req, res, target) { const { xfwd, changeOrigin } = req.hotel.item this._proxy.web( req, res, { target, xfwd, changeOrigin }, err => { log('Proxy - Error', err.message) const server = req.hotel.item const view = server.start ? 'server-error' : 'target-error' res.status(502).render(view, { err, serverReady, server }) } ) } proxy(req, res) { const [hostname, port] = req.headers.host && req.headers.host.split(':') const { item } = req.hotel // Handle case where port is set // http://app.localhost:5000 should proxy to http://localhost:5000 if (port) { const target = `http://127.0.0.1:${port}` log(`Proxy - http://${req.headers.host} → ${target}`) return this.proxyWeb(req, res, target) } // Make sure to send only one response const send = once(() => { const { target } = item log(`Proxy - http://${hostname} → ${target}`) this.proxyWeb(req, res, target) }) if (item.start) { // Set target item.target = `http://localhost:${item.env.PORT}` // If server stops, no need to wait for timeout item.once('stop', send) // When PORT is open, proxy serverReady(item.env.PORT, send) } else { // Send immediatly if item is not a server started by a command send() } } redirect(req, res) { const { id } = req.params const { item } = req.hotel // Make sure to send only one response const send = once(() => { log(`Redirect - ${id} → ${item.target}`) res.redirect(item.target) }) if (item.start) { // Set target item.target = `http://${req.hostname}:${item.env.PORT}` // If server stops, no need to wait for timeout item.once('stop', send) // When PORT is open, redirect serverReady(item.env.PORT, send) } else { // Send immediatly if item is not a server started by a command send() } } parseHost(host) { const [hostname, port] = host.split(':') const tld = new RegExp(`.${daemonConf.tld}$`) const id = this.resolve(hostname.replace(tld, '')) return { id, hostname, port } } // Needed to proxy WebSocket from CONNECT handleUpgrade(req, socket, head) { if (req.headers.host) { const { host } = req.headers const { id, port } = this.parseHost(host) const item = this.find(id) if (item) { let target if (port && port !== '80') { target = `ws://127.0.0.1:${port}` } else if (item.start) { target = `ws://127.0.0.1:${item.env.PORT}` } else { const { hostname } = url.parse(item.target) target = `ws://${hostname}` } log(`WebSocket - ${host} → ${target}`) this._proxy.ws(req, socket, head, { target }, err => { log('WebSocket - Error', err.message) }) } else { log(`WebSocket - No server matching ${id}`) } } else { log('WebSocket - No host header found') } } // Handle CONNECT, used by WebSockets and https when accessing .localhost domains handleConnect(req, socket, head) { if (req.headers.host) { const { host } = req.headers const { id, hostname, port } = this.parseHost(host) // If https make socket go through https proxy on 2001 // TODO find a way to detect https and wss without relying on port number if (port === '443') { return tcpProxy.proxy(socket, daemonConf.port + 1, hostname) } const item = this.find(id) if (item) { if (port && port !== '80') { log(`Connect - ${host} → ${port}`) tcpProxy.proxy(socket, port) } else if (item.start) { const { PORT } = item.env log(`Connect - ${host} → ${PORT}`) tcpProxy.proxy(socket, PORT) } else { const { hostname, port } = url.parse(item.target) const targetPort = port || 80 log(`Connect - ${host} → ${hostname}:${port}`) tcpProxy.proxy(socket, targetPort, hostname) } } else { log(`Connect - Can't find server for ${id}`) socket.end() } } else { log('Connect - No host header found') } } } ================================================ FILE: src/daemon/index.js ================================================ const exitHook = require('exit-hook') const httpProxy = require('http-proxy') const conf = require('../conf') const pidFile = require('../pid-file') const pem = require('./pem') const log = require('./log') const Group = require('./group') const Loader = require('./loader') const App = require('./app') const group = Group() const app = App(group) // Load and watch files Loader(group) // Create pid file pidFile.create() // Clean exit exitHook(() => { console.log('Exiting') console.log('Stop daemon') proxy.close() app.close() group.stopAll() console.log('Remove pid file') pidFile.remove() }) // HTTPS proxy const proxy = httpProxy.createServer({ target: { host: '127.0.0.1', port: conf.port }, ssl: pem.generate(), ws: true, xfwd: true }) // Start HTTPS proxy and HTTP server proxy.listen(conf.port + 1) app.listen(conf.port, conf.host, function() { log(`Server listening on port ${conf.host}:${conf.port}`) }) ================================================ FILE: src/daemon/loader.js ================================================ const fs = require('fs') const path = require('path') const mkdirp = require('mkdirp') const chokidar = require('chokidar') const log = require('./log') const common = require('../common') function getId(file) { return path.basename(file, '.json') } function handleAdd(group, file) { log(`${file} added`) const id = getId(file) try { const conf = JSON.parse(fs.readFileSync(file, 'utf8')) group.add(id, conf) } catch (err) { log(`Error: Failed to parse ${file}`, err) } } function handleUnlink(group, file, cb) { log(`${file} unlinked`) const id = getId(file) group.remove(id, cb) } function handleChange(group, file) { log(`${file} changed`) handleUnlink(group, file, () => { handleAdd(group, file) }) } module.exports = (group, opts = { watch: true }) => { const dir = common.serversDir // Ensure directory exists mkdirp.sync(dir) // Watch ~/.hotel/servers if (opts.watch) { log(`Watching ${dir}`) chokidar .watch(dir) .on('add', file => handleAdd(group, file)) .on('change', file => handleChange(group, file)) .on('unlink', file => handleUnlink(group, file)) } // Bootstrap fs.readdirSync(dir).forEach(file => { handleAdd(group, path.resolve(dir, file)) }) } ================================================ FILE: src/daemon/log.js ================================================ const tinydate = require('tinydate') const stamp = tinydate('{HH}:{mm}:{ss}') module.exports = function log(...args) { console.log(stamp(), '-', ...args) } ================================================ FILE: src/daemon/pem.js ================================================ const fs = require('fs') const path = require('path') const tildify = require('tildify') const selfsigned = require('selfsigned') const log = require('./log') const { hotelDir } = require('../common') const KEY_FILE = path.join(hotelDir, 'key.pem') const CERT_FILE = path.join(hotelDir, 'cert.pem') function generate() { if (fs.existsSync(KEY_FILE) && fs.existsSync(CERT_FILE)) { log(`Reading self-signed certificate in ${tildify(hotelDir)}`) return { key: fs.readFileSync(KEY_FILE, 'utf-8'), cert: fs.readFileSync(CERT_FILE, 'utf-8') } } else { log(`Generating self-signed certificate in ${tildify(hotelDir)}`) const pems = selfsigned.generate([{ name: 'commonName', value: 'hotel' }], { days: 365 }) fs.writeFileSync(KEY_FILE, pems.private, 'utf-8') fs.writeFileSync(CERT_FILE, pems.cert, 'utf-8') return { key: pems.private, cert: pems.cert } } } module.exports = { KEY_FILE, CERT_FILE, generate } ================================================ FILE: src/daemon/public/error.css ================================================ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; color: #212121; padding: 0; margin: 0 auto; display: flex; max-width: 960px; min-height: 100vh; flex-direction: column; } h1, h2 { margin-top: 60px; } li { list-style: square; } pre { background: #F9F9F9; padding: 20px; } a { color: inherit; } main { flex: 1; } footer { padding: 20px 0; } ================================================ FILE: src/daemon/routers/api/events.js ================================================ const express = require('express') const connectSSE = require('connect-sse') const sse = connectSSE() function listen(res, group, groupEvent, handler) { function removeListener() { // Remove group handler group.removeListener(groupEvent, handler) // Remove self res.removeListener('close', removeListener) res.removeListener('finish', removeListener) } group.on(groupEvent, handler) res.on('close', removeListener) res.on('finish', removeListener) } module.exports = group => { const router = express.Router() router.get('/', sse, (req, res) => { // Handler function sendState() { res.json(group.list()) } // Bootstrap sendState() // Listen listen(res, group, 'change', sendState) }) router.get('/output', sse, (req, res) => { function sendOutput(id, data) { res.json({ id, output: data.toString() }) } // Bootstrap const list = group.list() Object.keys(list).forEach(id => { var mon = list[id] if (mon && mon.tail) { sendOutput(id, mon.tail) } }) // Listen listen(res, group, 'output', sendOutput) }) return router } ================================================ FILE: src/daemon/routers/api/index.js ================================================ const express = require('express') const ServerRouter = require('./servers') const EventRouter = require('./events') module.exports = group => { const router = express.Router() const servers = ServerRouter(group) const events = EventRouter(group) router.use('/servers', servers) router.use('/events', events) return router } ================================================ FILE: src/daemon/routers/api/servers.js ================================================ const express = require('express') module.exports = group => { const router = express.Router() router.get('/', (req, res) => { res.json(group.list()) }) router.post( '/:id/start', group.exists.bind(group), group.start.bind(group), (req, res) => res.end() ) router.post( '/:id/stop', group.exists.bind(group), group.stop.bind(group), (req, res) => res.end() ) return router } ================================================ FILE: src/daemon/routers/index.js ================================================ const express = require('express') const conf = require('../../conf') const log = require('../log') module.exports = function(group) { const router = express.Router() function pac(req, res) { log('Serve proxy.pac') if (conf.proxy) { res.render('proxy-pac-with-proxy', { conf }) } else { res.render('proxy-pac', { conf }) } } router .get('/proxy.pac', pac) .get( '/:id', group.exists.bind(group), group.start.bind(group), group.redirect.bind(group) ) return router } ================================================ FILE: src/daemon/tcp-proxy.js ================================================ const net = require('net') const log = require('./log') module.exports = { proxy } function proxy(source, targetPort, targetHost) { const target = net.connect(targetPort) source.pipe(target).pipe(source) const handleError = err => { log('TCP Proxy - Error', err) source.destroy() target.destroy() } source.on('error', handleError) target.on('error', handleError) source.write( 'HTTP/1.1 200 Connection Established\r\n' + 'Proxy-agent: Hotel\r\n' + '\r\n' ) return target } ================================================ FILE: src/daemon/vhosts/tld.js ================================================ const express = require('express') const conf = require('../../conf') const log = require('../log') // *.tld vhost module.exports = group => { const app = express.Router() const hotelRegExp = new RegExp(`hotel.${conf.tld}$`) app.use((req, res, next) => { const { hostname } = req // Skip hotel.tld if (hotelRegExp.test(hostname)) { log(`hotel.${conf.tld}`) return next() } // If hostname is resolved proxy request group.exists(req, res, () => { group.start(req, res, () => { group.proxy(req, res) }) }) }) return app } ================================================ FILE: src/daemon/views/_error.pug ================================================ doctype html head title Error meta(charset='utf-8') meta(name='viewport', content='width=device-width, initial-scale=1') link(rel='icon', type='image/png', href='favicon.png') style include ../public/error.css body h1 Error main block content footer a(href='https://github.com/typicode/hotel') readme ================================================ FILE: src/daemon/views/proxy-pac-with-proxy.pug ================================================ . // Set conf.proxy in ~/.hotel/conf.json to your proxy address and port. // For example: { "proxy": "1.2.3.4:5000" } // // See also https://en.wikipedia.org/wiki/Private_network function FindProxyForURL (url, host) { if (dnsDomainIs(host, '.#{conf.tld}')) { return 'PROXY 127.0.0.1:#{conf.port}'; } var address = dnsResolve(host); if ( isPlainHostName(host) || dnsDomainIs(host, '.local') || isInNet(address, '10.0.0.0', '255.0.0.0') || isInNet(address, '172.16.0.0', '255.240.0.0') || isInNet(address, '192.168.0.0', '255.255.0.0') || isInNet(address, '127.0.0.0', '255.255.255.0') ) { return 'DIRECT'; } return 'PROXY #{conf.proxy}'; } ================================================ FILE: src/daemon/views/proxy-pac.pug ================================================ . // Proxy only *.#{conf.tld} requests to hotel // Configuration file can be found in ~/.hotel function FindProxyForURL (url, host) { if (dnsDomainIs(host, '.#{conf.tld}')) { return 'PROXY 127.0.0.1:#{conf.port}'; } return 'DIRECT'; } ================================================ FILE: src/daemon/views/server-error.pug ================================================ extends _error.pug block content p Can't connect to server on PORT=#{server.env.PORT} p: a(href=`http://localhost:${server.env.PORT}`) http://localhost:#{server.env.PORT} h2 Possible causes ul li Server crashed or timeout of #{serverReady.timeout}ms exceeded. li Server is not listening on PORT environment variable. p Try to reload or check logs. h2 Logs pre: code= server.command.join(' ') pre: code= server.tail ================================================ FILE: src/daemon/views/target-error.pug ================================================ extends _error.pug block content p Cannot proxy request to a(href=server.target)= server.target pre: code=err.message ================================================ FILE: src/get-cmd.js ================================================ const os = require('os') const unquote = require('unquote') module.exports = cmd => os.platform() === 'win32' ? ['cmd', '/c'].concat(cmd.split(' ')) : ['sh', '-c'].concat(unquote(cmd)) ================================================ FILE: src/pid-file.js ================================================ const fs = require('fs') const { pidFile } = require('./common') module.exports = { create, read, remove } function create() { console.log('create', pidFile, process.pid) return fs.writeFileSync(pidFile, process.pid.toString()) } function read() { if (fs.existsSync(pidFile)) { return fs.readFileSync(pidFile, 'utf-8') } } function remove() { if (fs.existsSync(pidFile)) { fs.unlinkSync(pidFile) } } ================================================ FILE: src/scripts/uninstall.js ================================================ const fs = require('fs') const { startupFile, pidFile } = require('../common') function killProcess() { if (!fs.existsSync(pidFile)) return const pid = fs.readFileSync(pidFile, 'utf-8') try { process.kill(pid) } catch (err) {} fs.unlinkSync(pidFile) } function removeStartup() { if (!fs.existsSync(startupFile)) return const startupFilePath = fs.readFileSync(startupFile, 'utf-8') fs.unlinkSync(startupFile) if (!fs.existsSync(startupFilePath)) return fs.unlinkSync(startupFilePath) } module.exports = () => { killProcess() removeStartup() } ================================================ FILE: test/_setup.js ================================================ const os = require('os') const sinon = require('sinon') const tempy = require('tempy') // Required by AVA, see package.json sinon.stub(os, 'homedir').returns(tempy.directory()) ================================================ FILE: test/cli/daemon.js ================================================ const fs = require('fs') const path = require('path') const test = require('ava') const sinon = require('sinon') const untildify = require('untildify') const userStartup = require('user-startup') const common = require('../../src/common') const cli = require('../../src/cli') test.before(() => { sinon.stub(userStartup, 'create') sinon.stub(userStartup, 'remove') sinon.stub(process, 'kill') }) test('start should start daemon', t => { const node = process.execPath const daemonFile = path.join(__dirname, '../../src/daemon') const daemonLog = path.resolve(untildify('~/.hotel/daemon.log')) cli(['', '', 'start']) sinon.assert.calledWithExactly( userStartup.create, 'hotel', node, [daemonFile], daemonLog ) t.is( fs.readFileSync(common.startupFile, 'utf-8'), userStartup.getFile('hotel'), 'startupFile should point to startup file path' ) t.pass() }) test('stop should stop daemon', t => { fs.writeFileSync(common.pidFile, '1234') cli(['', '', 'stop']) sinon.assert.calledWithExactly(userStartup.remove, 'hotel') sinon.assert.calledWithExactly(process.kill, '1234') t.pass() }) ================================================ FILE: test/cli/run.js ================================================ const path = require('path') const test = require('ava') const sinon = require('sinon') const cli = require('../../src/cli') const servers = require('../../src/cli/servers') const run = require('../../src/cli/run') const appDir = path.join(__dirname, '../fixtures/app') test('spawn with port', t => { const status = 1 sinon.spy(servers, 'add') sinon.spy(servers, 'rm') // Stub _exit to avoid messing with process.exit sinon.stub(run, '_exit') // Stub _spawnSync to immediately return avoid messing with child_process sinon.stub(run, '_spawnSync').callsFake(() => ({ status })) process.chdir(appDir) const opts = { port: 5000 } run.spawn('node index.js', opts) // test that everything was called correctly t.true(servers.add.called) t.regex( servers.add.firstCall.args[0], /http:\/\/localhost:/, 'should add a target' ) t.is(servers.add.firstCall.args[1], opts, 'should pass options to add') t.true(servers.rm.called) t.is(servers.rm.firstCall.args[0], opts, 'should use same options to remove') t.true(run._exit.called) t.is(run._exit.firstCall.args[0], status, 'should exit') }) test('cli run should call run.spawn', t => { sinon.stub(run, 'spawn') cli(['', '', 'run', 'node index.js']) t.is(run.spawn.firstCall.args[0], 'node index.js') }) ================================================ FILE: test/cli/servers.js ================================================ const fs = require('fs') const path = require('path') const test = require('ava') const sinon = require('sinon') const servers = require('../../src/cli/servers') const cli = require('../../src/cli') const { serversDir } = require('../../src/common') const appDir = path.join(__dirname, '../fixtures/app') test('add should create file', t => { process.chdir(appDir) cli(['', '', 'add', 'node index.js']) const file = path.join(serversDir, 'app.json') const conf = { cmd: 'node index.js', cwd: process.cwd(), env: { PATH: process.env.PATH } } const actual = JSON.parse(fs.readFileSync(file)) t.deepEqual(actual, conf) }) test('add should create file with URL safe characters by defaults', t => { cli(['', '', 'add', 'node index.js', '--dir', '/_-Some Project_Name--']) const file = path.join(serversDir, 'some-project-name.json') t.true(fs.existsSync(file)) }) test('add should create file with URL safe characters by defaults', t => { cli(['', '', 'add', 'node index.js', '--name', '/_-Some Project_Name--']) const file = path.join(serversDir, 'some-project-name.json') t.true(fs.existsSync(file)) }) test('add should support options', t => { process.env.FOO = 'FOO_VALUE' process.env.BAR = 'BAR_VALUE' const cmd = 'node index.js' const name = 'project' const port = 3000 const out = '/some/path/out.log' const env = ['FOO', 'BAR'] cli([ '', '', 'add', cmd, '-n', name, '-p', port, '-o', out, '-e', env[0], env[1], '-x', '--co', '--http-proxy-env' ]) const file = path.join(serversDir, 'project.json') const conf = { cmd: 'node index.js', cwd: process.cwd(), out: out, env: { PATH: process.env.PATH, FOO: process.env.FOO, BAR: process.env.BAR, PORT: port }, xfwd: true, changeOrigin: true, httpProxyEnv: true } const actual = JSON.parse(fs.readFileSync(file)) t.deepEqual(actual, conf) }) test('add should support option aliases', t => { process.env.FOO = 'FOO' const cmd = 'node index.js' const name = 'alias-test' cli(['', '', 'add', cmd, '-n', name]) const file = path.join(serversDir, 'alias-test.json') t.true(fs.existsSync(file)) }) test('add should support URL', t => { cli(['', '', 'add', 'http://1.2.3.4', '-n', 'proxy']) const file = path.join(serversDir, 'proxy.json') const conf = { target: 'http://1.2.3.4' } const actual = JSON.parse(fs.readFileSync(file)) t.deepEqual(actual, conf) }) test('add should support URL and options', t => { cli(['', '', 'add', 'http://1.2.3.4', '-n', 'proxy', '-x', '--co']) const file = path.join(serversDir, 'proxy.json') const conf = { target: 'http://1.2.3.4', xfwd: true, changeOrigin: true } const actual = JSON.parse(fs.readFileSync(file)) t.deepEqual(actual, conf) }) /* FIXME fails for an unknown reason only in CI, process.chdir doesn't seem to change dir test('rm should remove file', (t) => { const file = path.join(serversDir, 'other-app.json') fs.writeFileSync(file, '') process.chdir(otherAppDir) cli(['', '', 'rm']) t.true(!fs.existsSync(file)) }) */ test('rm should remove file using name', t => { const name = 'some-other-app' const file = path.join(serversDir, `${name}.json`) fs.writeFileSync(file, '') cli(['', '', 'rm', '-n', name]) t.true(!fs.existsSync(file)) }) test('ls', t => { sinon.spy(servers, 'ls') cli(['', '', 'ls']) sinon.assert.calledOnce(servers.ls) t.pass() }) test('ls should ignore non-json files', t => { const name = '.DS_Store' const file = path.join(serversDir, `${name}`) fs.writeFileSync(file, '') t.notThrows(() => { cli(['', '', 'ls']) }) }) ================================================ FILE: test/daemon/app.js ================================================ const fs = require('fs') const path = require('path') const http = require('http') const test = require('ava') const request = require('supertest') const conf = require('../../src/conf') const App = require('../../src/daemon/app') const Group = require('../../src/daemon/group') const Loader = require('../../src/daemon/loader') const servers = require('../../src/cli/servers') const { tld } = conf let app function ensureDistExists(t) { const exists = fs.existsSync(path.join(__dirname, '../../dist')) t.true(exists, 'dist directory must exist (try to run `npm run build`)') } test.before(() => { // Set request timeout to 20 seconds instead of 5 seconds for slower CI servers conf.timeout = 20000 // Fake server to respond to URL http .createServer((req, res) => { res.statusCode = 200 res.end(`ok - host: ${req.headers.host}`) }) .listen(4000) // Add server servers.add('node index.js', { name: 'node', port: 51234, dir: path.join(__dirname, '../fixtures/app'), out: '/tmp/logs/app.log', xfwd: true }) // Add server with subdomain servers.add('node index.js', { name: 'subdomain.node', port: 51235, dir: path.join(__dirname, '../fixtures/app'), out: '/tmp/logs/app.log' }) // Add server with custom env process.env.FOO = 'FOO_VALUE' servers.add('node index.js', { name: 'custom-env', port: 51236, dir: path.join(__dirname, '../fixtures/app'), out: '/tmp/logs/app.log', env: ['FOO'], httpProxyEnv: true }) // Add failing server servers.add('unknown-cmd', { name: 'failing' }) // Add server and proxy for testing removal servers.add('unknown-cmd', { name: 'server-to-remove' }) servers.add('http://example.com', { name: 'proxy-to-remove' }) // Add URL servers.add('http://localhost:4000', { name: 'proxy' }) // Add https URL servers.add('https://jsonplaceholder.typicode.com', { name: 'working-proxy-with-https-target', changeOrigin: true }) servers.add('https://jsonplaceholder.typicode.com', { name: 'failing-proxy-with-https-target' }) // Add unavailable URL servers.add('http://localhost:4100', { name: 'unavailable-proxy' }) const group = Group() app = App(group) app.group = group Loader(group, { watch: false }) }) test.cb.after(t => app.group.stopAll(t.end)) // // Test daemon/vhosts/tld.js // test.cb('GET http://hotel.tld should return 200', t => { ensureDistExists(t) request(app) .get('/') .set('Host', `hotel.${tld}`) .expect(200, t.end) }) test.cb( 'GET http://node.tld should proxy request and host should be node.tld', t => { request(app) .get('/') .set('Host', `node.${tld}`) .expect(new RegExp(`host: node.${tld}`)) .expect(200, /Hello World/, (err, res) => { if (err) return t.end(err) t.notRegex( res.text, /http:\/\/127.0.0.1:2000\/proxy.pac/, `shouldn't be started with HTTP_PROXY env set` ) return t.end() }) } ) test.cb('GET http://subdomain.node.tld should proxy request', t => { request(app) .get('/') .set('Host', `subdomain.node.${tld}`) .expect(200, /Hello World/, t.end) }) test.cb('GET http://any.node.tld should proxy request', t => { request(app) .get('/') .set('Host', `any.node.${tld}`) .expect(200, /Hello World/, t.end) }) test.cb('GET http://unknown.tld should return 404', t => { request(app) .get('/') .set('Host', `unknown.${tld}`) .expect(404, t.end) }) test.cb('GET http://failing.tld should return 502', t => { request(app) .get('/') .set('Host', `failing.${tld}`) .expect(502, t.end) }) test.cb( 'GET http://proxy.tld should return 200 and host should be proxy.localhost', t => { request(app) .get('/') .set('Host', `proxy.${tld}`) .expect(200, new RegExp(`host: proxy.${tld}`), t.end) } ) test.cb('GET http://node.tld:4000 should proxy to localhost:4000', t => { request(app) .get('/') .set('Host', `node.${tld}:4000`) .expect(200, /ok/, t.end) }) // // Test proxy to URLs // test.cb( 'GET http://working-proxy-with-https-target.tld should return 200', t => { request(app) .get('/') .set('Host', `working-proxy-with-https-target.${tld}`) .expect(200, t.end) } ) test.cb( 'GET http://failing-proxy-with-https-target.tld should return 502', t => { request(app) .get('/') .set('Host', `failing-proxy-with-https-target.${tld}`) .expect(502, t.end) } ) test.cb('GET http://unavailable-proxy.tld should return 502', t => { request(app) .get('/') .set('Host', `unavailable-proxy.${tld}`) .expect(502, t.end) }) // // TEST daemon/routers/api.js // test.cb('GET /_/servers', t => { request(app) .get('/_/servers') .expect(200, (err, res) => { if (err) return t.end(err) t.is(Object.keys(res.body).length, 10, 'got wrong number of servers') t.end() }) }) test.cb('POST /_/servers/:id/start', t => { request(app) .post('/_/servers/node/start') .expect(200, err => { if (err) return t.end(err) t.is(app.group.find('node').status, 'running') t.end() }) }) test.cb('POST /_/servers/:id/stop', t => { request(app) .post('/_/servers/node/stop') .expect(200, err => { if (err) return t.end(err) t.not(app.group.find('node').status, 'running') t.end() }) }) // // TEST daemon/routers/index.js // test.cb('GET /proxy.pac should serve /proxy.pac', t => { request(app) .get('/proxy.pac') .expect(200, t.end) }) test.cb('GET http://localhost:2000/node should redirect to node server', t => { if (process.env.APPVEYOR) return t.end() request(app) .get('/node') .set('Host', 'localhost') .expect('location', /http:\/\/localhost:51234/) .expect(302, t.end) }) test.cb( 'GET http://127.0.0.1:2000/node should use the same hostname to redirect', t => { // temporary disable this test on AppVeyor // Randomly fails if (process.env.APPVEYOR) return t.end() request(app) .get('/node') .expect('location', /http:\/\/127.0.0.1:51234/) .expect(302, t.end) } ) test.cb('GET http://localhost:2000/proxy should redirect to target', t => { if (process.env.APPVEYOR) return t.end() request(app) .get('/proxy') .set('Host', 'localhost') .expect('location', /http:\/\/localhost:4000/) .expect(302, t.end) }) // // Test daemon/app.js // test.cb('GET / should render index.html', t => { ensureDistExists(t) request(app) .get('/') .expect(200, t.end) }) // // Test env variables // test.cb('GET / should contain custom env values', t => { request(app) .get('/') .set('Host', `custom-env.${tld}`) .expect(200, /FOO_VALUE/, t.end) }) test.cb('GET / should contain proxy env values', t => { request(app) .get('/') .set('Host', `custom-env.${tld}`) .expect(200, /http:\/\/127.0.0.1:2000\/proxy.pac/, t.end) }) // // Test headers // test.cb('GET node.tld/ should contain X-FORWARD headers', t => { request(app) .get('/') .set('Host', `node.${tld}`) .expect(200, new RegExp(`x-forwarded-host: node.${tld}`), t.end) }) test.cb('GET subdomain.node.tld/ should not contain X-FORWARD headers', t => { request(app) .get('/') .set('Host', `subdomain.node.${tld}`) .expect(200, /x-forwarded-host: undefined/, t.end) }) // // Test remove // test.cb('Removing a server should make it unavailable', t => { t.truthy(app.group.find('server-to-remove')) app.group.remove('server-to-remove', () => { request(app) .get('/') .set('Host', `server-to-remove.${tld}`) .expect(404, t.end) }) }) test.cb('Removing a proxy should make it unavailable', t => { t.truthy(app.group.find('proxy-to-remove')) app.group.remove('proxy-to-remove', () => { request(app) .get('/') .set('Host', `proxy-to-remove.${tld}`) .expect(404, t.end) }) }) ================================================ FILE: test/daemon/group.js ================================================ const test = require('ava') const sinon = require('sinon') const Group = require('../../src/daemon/group') const tcpProxy = require('../../src/daemon/tcp-proxy') const conf = require('../../src/conf') const { tld } = conf sinon.stub(tcpProxy, 'proxy') test('group.resolve should find the correct server or target id', t => { const group = Group() const conf = { target: 'http://example.com' } group.add('app', conf) group.add('foo.app', conf) t.is(group.resolve('app'), 'app') t.is(group.resolve('bar.app'), 'app') t.is(group.resolve('foo.app'), 'foo.app') t.is(group.resolve('baz.foo.app'), 'foo.app') }) test('group.handleUpgrade with proxy', t => { const group = Group() const target = 'example.com' const req = { headers: { host: `proxy.${tld}:80` } } const head = {} const socket = {} sinon.stub(group._proxy, 'ws') group.add('proxy', { target: `http://${target}` }) group.handleUpgrade(req, head, socket) sinon.assert.calledWith(group._proxy.ws, req, head, socket, { target: `ws://${target}` }) t.pass() }) test('group.handleUpgrade with app', t => { const group = Group() const PORT = '9000' const req = { headers: { host: `app.${tld}:80` } } const head = {} const socket = {} sinon.stub(group._proxy, 'ws') group.add('app', { cmd: 'cmd', cwd: '/some/path', env: { PORT } }) group.handleUpgrade(req, head, socket) sinon.assert.calledWith(group._proxy.ws, req, head, socket, { target: `ws://127.0.0.1:${PORT}` }) t.pass() }) test('group.handleUpgrade with app and port, port should take precedence', t => { const port = 5000 const group = Group() const req = { headers: { host: `app.${tld}:${port}` } } const head = {} const socket = {} sinon.stub(group._proxy, 'ws') group.add('app', { cmd: 'cmd', cwd: '/some/path' }) group.handleUpgrade(req, head, socket) sinon.assert.calledWith(group._proxy.ws, req, head, socket, { target: `ws://127.0.0.1:${port}` }) t.pass() }) test('group.handleConnect with proxy', t => { const group = Group() const target = 'example.com' const req = { headers: { host: `proxy.${tld}:80` } } const head = {} const socket = {} tcpProxy.proxy.reset() group.add('proxy', { target: `http://${target}` }) group.handleConnect(req, head, socket) sinon.assert.calledWith(tcpProxy.proxy, socket, 80, 'example.com') t.pass() }) test('group.handleConnect with app', t => { const group = Group() const PORT = '9000' const req = { headers: { host: `app.${tld}:80` } } const head = {} const socket = {} tcpProxy.proxy.reset() group.add('app', { cmd: 'cmd', cwd: '/some/path', env: { PORT } }) group.handleConnect(req, head, socket) sinon.assert.calledWith(tcpProxy.proxy, socket, PORT) t.pass() }) test('group.handleConnect on port 443', t => { const group = Group() const req = { headers: { host: `anything.${tld}:443` } } const head = {} const socket = {} tcpProxy.proxy.reset() group.handleConnect(req, head, socket) sinon.assert.calledWith(tcpProxy.proxy, socket, conf.port + 1) t.pass() }) ================================================ FILE: test/daemon/pem.js ================================================ const fs = require('fs') const test = require('ava') // TODO rename to KEY_NAME const { KEY_FILE, CERT_FILE, generate } = require('../../src/daemon/pem') const { hotelDir } = require('../../src/common') test.before(() => { fs.mkdirSync(hotelDir) }) test("should create cert files if they don't exist", t => { const { key, cert } = generate() t.true(fs.existsSync(KEY_FILE)) t.true(fs.existsSync(CERT_FILE)) t.is(key, fs.readFileSync(KEY_FILE, 'utf-8')) t.is(cert, fs.readFileSync(CERT_FILE, 'utf-8')) }) test('should read cert files if they exist', t => { fs.writeFileSync(KEY_FILE, 'foo') fs.writeFileSync(CERT_FILE, 'bar') const { key, cert } = generate() t.is('foo', key) t.is('bar', cert) }) ================================================ FILE: test/fixtures/app/index.js ================================================ var http = require('http') http .createServer(function(req, res) { console.log(req.headers) res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end( [ 'Hello World', process.env.FOO, process.env.HTTP_PROXY, 'x-forwarded-host: ' + req.headers['x-forwarded-host'], 'host: ' + req.headers.host ].join(' ') ) }) .listen(process.env.PORT, '127.0.0.1') console.log('Server listening on port', process.env.PORT) ================================================ FILE: test/fixtures/verbose/index.js ================================================ setInterval( () => console.log('[32m green text with blank line - ' + Math.random() + '\n'), 200 ) ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "outDir": "./dist/", "sourceMap": true, "target": "es6", "module": "commonjs", "jsx": "react", "strict": true, "experimentalDecorators": true // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ } } ================================================ FILE: tslint.json ================================================ { "defaultSeverity": "error", "extends": [ "tslint:recommended", "tslint-config-prettier" ], "jsRules": {}, "rulesDirectory": [ "tslint-plugin-prettier" ], "rules": { "prettier": [ true, { "semi": false, "singleQuote": true } ] } } ================================================ FILE: webpack.common.js ================================================ const path = require('path') const HtmlWebpackPlugin = require('html-webpack-plugin') module.exports = { entry: './src/app/index.tsx', module: { rules: [ { test: /\.css$/, use: ['style-loader', 'css-loader'] }, { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ } ] }, resolve: { extensions: ['.tsx', '.ts', '.js'] }, output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist') }, plugins: [ new HtmlWebpackPlugin({ template: 'src/app/index.html' }) ] } ================================================ FILE: webpack.dev.js ================================================ const merge = require('webpack-merge') const common = require('./webpack.common.js') module.exports = merge(common, { devtool: 'inline-source-map', devServer: { contentBase: './dist', proxy: { '/_': 'http://localhost:2000' } } }) ================================================ FILE: webpack.prod.js ================================================ const webpack = require('webpack') const merge = require('webpack-merge') const UglifyJSPlugin = require('uglifyjs-webpack-plugin') const common = require('./webpack.common.js') module.exports = merge(common, { plugins: [ new UglifyJSPlugin({ sourceMap: true }), new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify('production') }) ] })