Repository: abhinavsingh/proxy.py

Issue:
No module named 'typing_extensions'
python3.6 proxy.py

```Traceback (most recent call last):
  File "proxy.py", line 37, in <module>
    from typing_extensions import Protocol
ModuleNotFoundError: No module named 'typing_extensions'
```

for develop  and app branch

Discussion:

Patch:
diff --git a/.github/workflows/python-package.yml b/.github/workflows/testing.yml
similarity index 51%
rename from .github/workflows/python-package.yml
rename to .github/workflows/testing.yml
index 13b07dc..6bab40e 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/testing.yml
@@ -4,19 +4,20 @@ on: [push]
 
 jobs:
   build:
-    runs-on: ${{ matrix.os }}
+    runs-on: ${{ matrix.os }}-latest
+    name: Python ${{ matrix.python }} on ${{ matrix.os }}
     strategy:
       matrix:
-        os: [macOS-latest, ubuntu-latest, windows-latest]
+        os: [macOS, ubuntu, windows]
         python: [3.6, 3.7]
       max-parallel: 4
       fail-fast: false
     steps:
       - uses: actions/checkout@v1
-      - name: Set up Python
+      - name: Setup Python
         uses: actions/setup-python@v1
         with:
-          python-version: ${{ matrix.python }}
+          python-version: ${{ matrix.python }}-dev
           architecture: x64
       - name: Install dependencies
         run: |
@@ -24,12 +25,10 @@ jobs:
           pip install -r requirements-testing.txt
       - name: Quality Check
         run: |
-          # stop the build if there are Python syntax errors or undefined names
-          flake8 proxy.py tests.py --count --select=E9,F63,F7,F82 --show-source --statistics
-          # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
-          flake8 proxy.py tests.py --count --exit-zero --max-line-length=127 --statistics
+          # The GitHub editor is 127 chars wide
+          # W504 screams for line break after binary operators
+          flake8 --ignore=W504 --max-line-length=127 proxy.py tests.py
           # mypy compliance check
           mypy --strict --ignore-missing-imports proxy.py plugin_examples.py tests.py
       - name: Run Tests
-        run: |
-          pytest tests.py
+        run: pytest tests.py
diff --git a/.gitignore b/.gitignore
index 4f17639..8c5ab62 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,11 @@
 .coverage
 .idea
+.vscode
 .project
 .pydevproject
 .settings
 .mypy_cache
+node_modules
 venv
 cover
 htmlcov
@@ -13,3 +15,4 @@ proxy.py.egg-info
 proxy.py.iml
 *.pyc
 *.pem
+benchmark.py
diff --git a/.travis.yml b/.travis.yml
index 8fbe904..ed96ea4 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -21,6 +21,6 @@ matrix:
         - TESTING_ON_TRAVIS=1
 install:
   - pip3 install -r requirements-testing.txt
-script: python3 -m coverage run tests.py || python -m coverage run tests.py
+script: python3 -m coverage run --source=proxy tests.py || python -m coverage run --source=proxy tests.py
 after_success:
   - coveralls
diff --git a/Dockerfile b/Dockerfile
index a6acf99..d55321e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,10 +1,12 @@
 FROM python:3-alpine
 LABEL com.abhinavsingh.name="abhinavsingh/proxy.py" \
-      com.abhinavsingh.description="Lightweight Programmable HTTP, HTTPS, WebSockets Proxy Server in a single Python file." \
+      com.abhinavsingh.description="⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file" \
       com.abhinavsingh.url="https://github.com/abhinavsingh/proxy.py" \
       com.abhinavsingh.vcs-url="https://github.com/abhinavsingh/proxy.py" \
       com.abhinavsingh.docker.cmd="docker run -it --rm -p 8899:8899 abhinavsingh/proxy.py"
 
+RUN pip install --upgrade pip && pip install typing-extensions==3.7.4
+
 COPY proxy.py /app/
 EXPOSE 8899/tcp
 
diff --git a/Makefile b/Makefile
index 0ff3f2f..2cbea1f 100644
--- a/Makefile
+++ b/Makefile
@@ -15,6 +15,7 @@ CA_SIGNING_KEY_FILE_PATH := ca-signing-key.pem
 
 .PHONY: all clean test package test-release release coverage lint autopep8
 .PHONY: container run-container release-container https-certificates ca-certificates
+.PHONY: profile
 
 all: clean test
 
@@ -23,7 +24,7 @@ clean:
 	find . -name '*.pyo' -exec rm -f {} +
 	find . -name '*~' -exec rm -f {} +
 	rm -f .coverage
-	rm -rf htmlcov dist build
+	rm -rf htmlcov dist build .pytest_cache proxy.py.egg-info
 
 test:
 	python -m unittest tests
@@ -38,14 +39,13 @@ release: package
 	twine upload dist/*
 
 coverage:
-	coverage3 run tests.py
+	coverage3 run --source=proxy tests.py
 	coverage3 html
 	open htmlcov/index.html
 
 lint:
+	flake8 --ignore=W504 --max-line-length=127 proxy.py tests.py
 	mypy --strict --ignore-missing-imports proxy.py plugin_examples.py tests.py
-	flake8 --ignore=E501,W504 proxy.py
-	flake8 --ignore=E501,W504 tests.py
 
 autopep8:
 	autopep8 --recursive --in-place --aggressive proxy.py
@@ -75,3 +75,6 @@ ca-certificates:
 	# Generate key that will be used to generate domain certificates on the fly
 	# Generated certificates are then signed with CA certificate / key generated above
 	openssl genrsa -out $(CA_SIGNING_KEY_FILE_PATH) 2048
+
+profile:
+	sudo py-spy -F -f profile.svg -d 3600 proxy.py
diff --git a/README.md b/README.md
index 7994466..2b14ecc 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-[![Proxy.Py](ProxyPy.png)](https://github.com/abhinavsingh/proxy.py)
+[![Proxy.Py](https://raw.githubusercontent.com/abhinavsingh/proxy.py/develop/ProxyPy.png)](https://github.com/abhinavsingh/proxy.py)
 
 [![License](https://img.shields.io/github/license/abhinavsingh/proxy.py.svg)](https://opensource.org/licenses/BSD-3-Clause)
 [![PyPi Downloads](https://img.shields.io/pypi/dm/proxy.py.svg?color=green)](https://pypi.org/project/proxy.py/)
@@ -7,24 +7,25 @@
 [![No Dependencies](https://img.shields.io/static/v1?label=dependencies&message=none&color=green)](https://github.com/abhinavsingh/proxy.py)
 [![Coverage](https://coveralls.io/repos/github/abhinavsingh/proxy.py/badge.svg?branch=develop)](https://coveralls.io/github/abhinavsingh/proxy.py?branch=develop)
 
+[![Tested With MacOS](https://img.shields.io/static/v1?label=tested%20with&message=mac%20OS%20%F0%9F%92%BB&color=brightgreen)](https://developer.apple.com/library/archive/documentation/IDEs/Conceptual/iOS_Simulator_Guide/Introduction/Introduction.html)
+[![Tested With Ubuntu](https://img.shields.io/static/v1?label=tested%20with&message=Ubuntu%20%F0%9F%96%A5&color=brightgreen)](https://developer.apple.com/library/archive/documentation/IDEs/Conceptual/iOS_Simulator_Guide/Introduction/Introduction.html)
+[![Tested With Windows](https://img.shields.io/static/v1?label=tested%20with&message=Windows%20%F0%9F%92%BB&color=brightgreen)](https://developer.apple.com/library/archive/documentation/IDEs/Conceptual/iOS_Simulator_Guide/Introduction/Introduction.html)
 [![Tested With Android](https://img.shields.io/static/v1?label=tested%20with&message=Android%20%F0%9F%93%B1&color=brightgreen)](https://www.android.com/)
 [![Tested With Android Emulator](https://img.shields.io/static/v1?label=tested%20with&message=Android%20Emulator%20%F0%9F%93%B1&color=brightgreen)](https://developer.android.com/studio/run/emulator-networking.html#proxy)
 [![Tested With iOS](https://img.shields.io/static/v1?label=tested%20with&message=iOS%20%F0%9F%93%B1&color=brightgreen)](https://developer.apple.com/library/archive/documentation/IDEs/Conceptual/iOS_Simulator_Guide/Introduction/Introduction.html)
 [![Tested With iOS Simulator](https://img.shields.io/static/v1?label=tested%20with&message=iOS%20Simulator%20%F0%9F%93%B1&color=brightgreen)](https://developer.apple.com/library/archive/documentation/IDEs/Conceptual/iOS_Simulator_Guide/Introduction/Introduction.html)
-[![Tested With MacOS](https://img.shields.io/static/v1?label=tested%20with&message=mac%20OS%20%F0%9F%92%BB&color=brightgreen)](https://developer.apple.com/library/archive/documentation/IDEs/Conceptual/iOS_Simulator_Guide/Introduction/Introduction.html)
-[![Tested With Ubuntu](https://img.shields.io/static/v1?label=tested%20with&message=Ubuntu%20%F0%9F%96%A5&color=brightgreen)](https://developer.apple.com/library/archive/documentation/IDEs/Conceptual/iOS_Simulator_Guide/Introduction/Introduction.html)
-[![Tested With Windows](https://img.shields.io/static/v1?label=tested%20with&message=Windows%20%F0%9F%92%BB&color=brightgreen)](https://developer.apple.com/library/archive/documentation/IDEs/Conceptual/iOS_Simulator_Guide/Introduction/Introduction.html)
 
-[![Gitter](https://badges.gitter.im/proxy-py/community.svg)](https://gitter.im/proxy-py/community)
 [![Maintenance](https://img.shields.io/static/v1?label=maintained%3F&message=yes&color=green)](https://gitHub.com/abhinavsingh/proxy.py/graphs/commit-activity)
 [![Ask Me Anything](https://img.shields.io/static/v1?label=need%20help%3F&message=ask&color=green)](https://twitter.com/imoracle)
 [![Contributions Welcome](https://img.shields.io/static/v1?label=contributions&message=welcome%20%F0%9F%91%8D&color=green)](https://github.com/abhinavsingh/proxy.py/issues)
+[![Gitter](https://badges.gitter.im/proxy-py/community.svg)](https://gitter.im/proxy-py/community)
 
-[![Python 3.5](https://img.shields.io/badge/python-3.5-blue.svg)](https://www.python.org/downloads/release/python-350/)
 [![Python 3.6](https://img.shields.io/badge/python-3.6-blue.svg)](https://www.python.org/downloads/release/python-360/)
 [![Python 3.7](https://img.shields.io/badge/python-3.7-blue.svg)](https://www.python.org/downloads/release/python-370/)
 [![Checked with mypy](https://img.shields.io/static/v1?label=mypy&message=checked&color=blue)](http://mypy-lang.org/)
 
+[![Become a Backer](https://opencollective.com/proxypy/tiers/backer.svg?avatarHeight=36)](https://opencollective.com/proxypy)
+
 Table of Contents
 =================
 
@@ -44,10 +45,21 @@ Table of Contents
     * [Plugin Ordering](#plugin-ordering)
 * [End-to-End Encryption](#end-to-end-encryption)
 * [TLS Interception](#tls-interception)
+* [import proxy.py](#import-proxypy)
+    * [proxy.new_socket_connection](#proxynew_socket_connection)
+    * [proxy.socket_connection](#proxysocket_connection)
+    * [proxy.build_http_request](#proxybuild_http_request)
 * [Plugin Developer and Contributor Guide](#plugin-developer-and-contributor-guide)
     * [Everything is a plugin](#everything-is-a-plugin)
-    * [proxy.py Internals](#proxypy-internals)
-    * [Pull Request](#pull-request)
+    * [Internal Architecture](#internal-architecture)
+    * [Internal Documentation](#internal-documentation)
+    * [Sending a Pull Request](#sending-a-pull-request)
+* [Frequently Asked Questions](#frequently-asked-questions)
+    * [Unable to connect with proxy.py from remote host](#unable-to-connect-with-proxypy-from-remote-host)
+    * [Basic auth not working with a browser](#basic-auth-not-working-with-a-browser)
+    * [Docker image not working on MacOS](#docker-image-not-working-on-macos)
+    * [Unable to load custom plugins](#unable-to-load-custom-plugins)
+    * [ValueError: filedescriptor out of range in select](#valueerror-filedescriptor-out-of-range-in-select)
 * [Flags](#flags)
 
 Features
@@ -84,8 +96,19 @@ Install
 
 ## Stable version
 
+Install from `PyPi`
+
 	$ pip install --upgrade proxy.py
 
+or from GitHub `master` branch
+
+    $ pip install git+https://github.com/abhinavsingh/proxy.py.git@master
+
+or download from here [proxy.py](https://raw.githubusercontent.com/abhinavsingh/proxy.py/master/proxy.py)
+or simply `wget` it:
+
+    $ wget -q https://raw.githubusercontent.com/abhinavsingh/proxy.py/master/proxy.py
+
 ## Development version
 
     $ pip install git+https://github.com/abhinavsingh/proxy.py.git@develop
@@ -498,8 +521,91 @@ Connection: keep-alive
 Viola!!!  If you remove CA flags, encrypted data will be found in the
 cached file instead of plain text.
 
-Now use CA flags other 
-[plugin examples](#plugin-examples) to make them work for `https` traffic.
+Now use CA flags with other 
+[plugin examples](#plugin-examples) to see them work with `https` traffic.
+
+import proxy.py
+===============
+
+You can directly import `proxy.py` into your `Python` code.  Example:
+
+```
+$ python
+>>> import proxy
+>>>
+```
+
+## proxy.new_socket_connection
+
+Attempts to create an IPv4 connection, then IPv6 and
+finally a dual stack connection to provided address.
+
+```
+>>> conn = proxy.new_socket_connection(('httpbin.org', 80))
+>>> ...[ use connection ]...
+>>> conn.close()
+```
+
+## proxy.socket_connection
+
+`socket_connection` is a convenient decorator + context manager
+around `new_socket_connection` which ensures `conn.close` is implicit.
+
+As a context manager:
+
+```
+>>> with proxy.new_socket_connection(('httpbin.org', 80)) as conn:
+>>>   ... [ use connection ] ...
+```
+
+As a decorator:
+
+```
+>>> @proxy.new_socket_connection(('httpbin.org', 80))
+>>> def my_api_call(conn, *args, **kwargs):
+>>>   ... [ use connection ] ...
+```
+
+## proxy.build_http_request
+
+#### Generate HTTP GET request
+
+```
+>>> proxy.build_http_request(b'GET', b'/')
+b'GET / HTTP/1.1\r\n\r\n'
+>>>
+```
+
+#### Generate HTTP GET request with headers
+
+```
+>>> proxy.build_http_request(b'GET', b'/', 
+        headers={b'Connection': b'close'})
+b'GET / HTTP/1.1\r\nConnection: close\r\n\r\n'
+>>>
+```
+
+#### Generate HTTP POST request with headers and body
+
+```
+>>> import json
+>>> proxy.build_http_request(b'POST', b'/form', 
+        headers={b'Content-type': b'application/json'}, 
+        body=proxy.bytes_(json.dumps({'email': 'hello@world.com'})))
+    b'POST /form HTTP/1.1\r\nContent-type: application/json\r\n\r\n{"email": "hello@world.com"}'
+```
+
+To start `proxy.py` server from imported `proxy.py` module, simply do:
+
+```
+import proxy
+
+if __name__ == '__main__':
+  proxy.main(['--hostname', '::1', '--port', 8899])
+```
+
+See [Internal Documentation](#internal-documentation)
+for all available classes and utility methods.
 
 Plugin Developer and Contributor Guide
 ======================================
@@ -528,7 +634,7 @@ As you might have guessed by now, in `proxy.py` everything is a plugin.
   http(s) server. [HttpProxyPlugin](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L941-L1182) 
   also implements `ProtocolHandlerPlugin`.
 
-## proxy.py Internals
+## Internal Architecture
 
 - [ProtocolHandler](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L1263-L1440) 
 thread is started with the accepted [TcpClientConnection](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L230-L237).
@@ -544,28 +650,174 @@ and invoke `HttpProxyBasePlugin` lifecycle hooks.
   processes.
 
 - `--num-workers` `Worker` processes are started by 
-  [WorkerPool](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L368-L421) 
-  on start-up.  `Worker` processes receives `TcpClientConnection` over a pipe from `WorkerPool`.
+  [AcceptorPool](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L368-L421) 
+  on start-up.
+
+- `AcceptorPool` listens on server socket and pass the handler to `Worker` processes.
+  Workers are responsible for accepting new client connections and starting
+  `ProtocolHandler` thread.
+
+## Sending a Pull Request
 
-- `WorkerPool` implements [TcpServer](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L240-L302) 
-  abstract class. `TcpServer` accepts `TcpClientConnection`. `WorkerPool` 
-  ensures full utilization of available CPU cores, for which it dispatches 
-  accepted `TcpClientConnection` to `Worker` processes in a round-robin fashion.
+Install dependencies for local development testing:
 
-## Pull Request
+`$ pip install -r requirements-testing.txt`
 
 Every pull request goes through set of tests which must pass:
 
 - `mypy`: Run `make lint` locally for compliance check. 
   Fix all warnings and errors before sending out a PR.
 
-- `coverage`: Run `make coverage` for coverage report.
+- `coverage`: Run `make coverage` locally for coverage report.
   Its ideal to add tests for any critical change. Depending upon
   the change, it's ok if test coverage falls by `<0.5%`.
 
 - `formatting`: Run `make autopep8` locally to format the code in-place.
   `autopep8` is run with `--aggresive` flag.  Sometimes it _may_ result in
-  weird formatting.
+  weird formatting.  But let's stick to one consistent formatting tool.
+  I am open to flag changes for `autopep8`.
+
+## Internal Documentation
+
+Browse through internal class hierarchy and documentation using `pydoc3`.
+Example:
+
+```
+$ pydoc3 proxy
+Help on module proxy:
+
+NAME
+    proxy
+
+DESCRIPTION
+    proxy.py
+    ~~~~~~~~
+    Lightweight, Programmable, TLS interceptor Proxy for HTTP(S), HTTP2, WebSockets protocols in a single Python file.
+    
+    :copyright: (c) 2013-present by Abhinav Singh and contributors.
+    :license: BSD, see LICENSE for more details.
+
+CLASSES
+    abc.ABC(builtins.object)
+        HttpProxyBasePlugin
+        HttpWebServerBasePlugin
+            DevtoolsFrontendPlugin
+            HttpWebServerPacFilePlugin
+        ProtocolHandlerPlugin
+            DevtoolsEventGeneratorPlugin
+            HttpProxyPlugin
+            HttpWebServerPlugin
+        TcpConnection
+            TcpClientConnection
+            TcpServerConnection
+            WebsocketClient
+    builtins.Exception(builtins.BaseException)
+        ProtocolException
+            HttpRequestRejected
+            ProxyAuthenticationFailed
+            ProxyConnectionFailed
+        TcpConnectionUninitializedException
+    builtins.object
+        AcceptorPool
+        ChunkParser
+        HttpParser
+        ProtocolConfig
+        WebsocketFrame
+    builtins.tuple(builtins.object)
+        ChunkParserStates
+        HttpParserStates
+        HttpParserTypes
+        HttpProtocolTypes
+        TcpConnectionTypes
+        WebsocketOpcodes
+    contextlib.ContextDecorator(builtins.object)
+        socket_connection
+    multiprocessing.context.Process(multiprocessing.process.BaseProcess)
+        Worker
+    threading.Thread(builtins.object)
+        ProtocolHandler
+```
+
+Frequently Asked Questions
+==========================
+
+## Unable to connect with proxy.py from remote host
+
+Make sure `proxy.py` is listening on correct network interface.
+Try following flags:
+
+- For IPv6 `--hostname ::`
+- For IPv4 `--hostname 0.0.0.0`
+
+## Basic auth not working with a browser
+
+Most likely it's a browser integration issue with system keychain.
+
+- First verify that basic auth is working using `curl`
+
+    `curl -v -x username:password@localhost:8899 https://httpbin.org/get`
+
+- See [this thread](https://github.com/abhinavsingh/proxy.py/issues/89#issuecomment-534845710)
+  for further details.
+
+## Docker image not working on macOS
+
+It's a compatibility issue with `vpnkit`.
+
+See [moby/vpnkit exhausts docker resources](https://github.com/abhinavsingh/proxy.py/issues/43)
+and [Connection refused: The proxy could not connect](https://github.com/moby/vpnkit/issues/469)
+for some background.
+
+## Unable to load custom plugins
+
+Make sure your plugin modules are discoverable by adding them to `PYTHONPATH`.  Example:
+
+`PYTHONPATH=/path/to/my/app proxy.py --plugins my_app.proxyPlugin`
+
+```
+...[redacted]... - Loaded plugin proxy.HttpProxyPlugin
+...[redacted]... - Loaded plugin my_app.proxyPlugin
+```
+
+## GCE log viewer integration for proxy.py
+
+A starter [fluentd.conf](https://github.com/abhinavsingh/proxy.py/blob/develop/fluentd.conf)
+template is available.
+
+1. Copy this configuration file as `proxy.py.conf` under
+   `/etc/google-fluentd/config.d/`
+
+2. Update `path` field to log file path as used with `--log-file` flag.
+   By default `/tmp/proxy.log` path is tailed.
+
+3. Reload `google-fluentd`:
+
+   `sudo service google-fluentd restart`
+
+Now `proxy.py` logs can be browsed using
+[GCE log viewer](https://console.cloud.google.com/logs/viewer).
+
+## ValueError: filedescriptor out of range in select
+
+`proxy.py` is made to handle thousands of connections per second.
+
+1. Make use of `--open-file-limit` flag to customize `ulimit -n`.
+    - To set a value upper than the hard limit, run as root.
+2. Make sure to adjust `--backlog` flag for higher concurrency.
+
+If nothing helps, [open an issue](https://github.com/abhinavsingh/proxy.py/issues/new)
+with `requests per second` sent and output of following debug script:
+
+```
+# PID of proxy.py
+PROXY_PY_PID=<... Put value here or use --pid-file option ...>;
+
+# Prints number of open files by main process
+lsof -p $PROXY_PY_PID | wc -l;
+
+# Prints number of open files per worker process
+pgrep -P $PROXY_PY_PID | while read pid; do lsof -p $pid | wc -l; done;
+```
 
 Flags
 =====
@@ -578,7 +830,9 @@ usage: proxy.py [-h] [--backlog BACKLOG] [--basic-auth BASIC_AUTH]
                 [--ca-signing-key-file CA_SIGNING_KEY_FILE]
                 [--cert-file CERT_FILE]
                 [--client-recvbuf-size CLIENT_RECVBUF_SIZE]
+                [--devtools-ws-path DEVTOOLS_WS_PATH]
                 [--disable-headers DISABLE_HEADERS] [--disable-http-proxy]
+                [--enable-devtools] [--enable-static-server]
                 [--enable-web-server] [--hostname HOSTNAME]
                 [--key-file KEY_FILE] [--log-level LOG_LEVEL]
                 [--log-file LOG_FILE] [--log-format LOG_FORMAT]
@@ -586,9 +840,10 @@ usage: proxy.py [-h] [--backlog BACKLOG] [--basic-auth BASIC_AUTH]
                 [--open-file-limit OPEN_FILE_LIMIT] [--pac-file PAC_FILE]
                 [--pac-file-url-path PAC_FILE_URL_PATH] [--pid-file PID_FILE]
                 [--plugins PLUGINS] [--port PORT]
-                [--server-recvbuf-size SERVER_RECVBUF_SIZE] [--version]
+                [--server-recvbuf-size SERVER_RECVBUF_SIZE]
+                [--static-server-dir STATIC_SERVER_DIR] [--version]
 
-proxy.py v1.0.0
+proxy.py v1.1.0
 
 optional arguments:
   -h, --help            show this help message and exit
@@ -622,12 +877,22 @@ optional arguments:
                         the client in a single recv() operation. Bump this
                         value for faster uploads at the expense of increased
                         RAM.
+  --devtools-ws-path DEVTOOLS_WS_PATH
+                        Default: /devtools. Only applicable if --enable-
+                        devtools is used.
   --disable-headers DISABLE_HEADERS
                         Default: None. Comma separated list of headers to
                         remove before dispatching client request to upstream
                         server.
   --disable-http-proxy  Default: False. Whether to disable
                         proxy.HttpProxyPlugin.
+  --enable-devtools     Default: False. Enables integration with Chrome
+                        Devtool Frontend.
+  --enable-static-server
+                        Default: False. Enable inbuilt static file server.
+                        Optionally, also use --static-server-dir to serve
+                        static content from custom directory. By default,
+                        static file server serves from public folder.
   --enable-web-server   Default: False. Whether to enable
                         proxy.HttpWebServerPlugin.
   --hostname HOSTNAME   Default: ::1. Server IP address.
@@ -660,6 +925,11 @@ optional arguments:
                         the server in a single recv() operation. Bump this
                         value for faster downloads at the expense of increased
                         RAM.
+  --static-server-dir STATIC_SERVER_DIR
+                        Default: /Users/abhinav/Dev/proxy.py/public. Static
+                        server root directory. This option is only applicable
+                        when static server is also enabled. See --enable-
+                        static-server.
   --version, -v         Prints proxy.py version.
 
 Proxy.py not working? Report at:
diff --git a/fluentd.conf b/fluentd.conf
new file mode 100644
index 0000000..13e6ed3
--- /dev/null
+++ b/fluentd.conf
@@ -0,0 +1,21 @@
+# google-fluentd (Stackdriver) log input configuration file
+#
+# 1. Copy this configuration file as proxy.py.conf under:
+#       /etc/google-fluentd/config.d/
+# 2. Update `path` field to log file path as used with
+#    --log-file flag.  By default `/tmp/proxy.log` path is tailed.
+# 3. Reload google-fluentd:
+#       sudo service google-fluentd restart
+# 4. Now `proxy.py` logs can be browsed using GCE log viewer:
+#       https://console.cloud.google.com/logs/viewer
+#
+# See https://cloud.google.com/logging/docs/agent/configuration#third-party_application_log_input_configuration
+# for more options.
+<source>
+    @type tail
+    format none
+    path /tmp/proxy.log
+    pos_file /var/lib/google-fluentd/pos/proxy-py.pos
+    read_from_head true
+    tag proxy.py
+</source>
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..ed5e6f6
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,51 @@
+{
+  "name": "proxy.py",
+  "version": "1.0.1",
+  "lockfileVersion": 1,
+  "requires": true,
+  "dependencies": {
+    "async-limiter": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz",
+      "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==",
+      "dev": true
+    },
+    "chrome-devtools-frontend": {
+      "version": "1.0.702145",
+      "resolved": "https://registry.npmjs.org/chrome-devtools-frontend/-/chrome-devtools-frontend-1.0.702145.tgz",
+      "integrity": "sha512-/GtRYVUWETCifkvlfYhrJkPg/SrTVcMwNNj7Q2Ax0LELwCil7AUF5QYY/jMMjv5QYLHSgSzWX0yICK3qqUDkhw==",
+      "dev": true
+    },
+    "chrome-remote-interface": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.28.0.tgz",
+      "integrity": "sha512-md2qSn6rc/fADlN+Blk2UWNg0SGPYjH2s68piaPN9e62HItKm6uWeXXHh0+28Bq10oaWw8fzNAm1itDFJ+nS4w==",
+      "dev": true,
+      "requires": {
+        "commander": "2.11.x",
+        "ws": "^6.1.0"
+      }
+    },
+    "commander": {
+      "version": "2.11.0",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz",
+      "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==",
+      "dev": true
+    },
+    "ncp": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz",
+      "integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=",
+      "dev": true
+    },
+    "ws": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz",
+      "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==",
+      "dev": true,
+      "requires": {
+        "async-limiter": "~1.0.0"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..890a808
--- /dev/null
+++ b/package.json
@@ -0,0 +1,25 @@
+{
+  "name": "proxy.py",
+  "version": "1.0.1",
+  "description": "Lightweight, Programmable, TLS interceptor Proxy for HTTP(S), HTTP2, WebSockets protocols in a single Python file.",
+  "main": "proxy.js",
+  "scripts": {
+    "start": "node proxy.js",
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/abhinavsingh/proxy.py.git"
+  },
+  "author": "Abhinav Singh",
+  "license": "BSD-3-Clause",
+  "bugs": {
+    "url": "https://github.com/abhinavsingh/proxy.py/issues"
+  },
+  "homepage": "https://github.com/abhinavsingh/proxy.py#readme",
+  "devDependencies": {
+    "chrome-devtools-frontend": "^1.0.702145",
+    "chrome-remote-interface": "^0.28.0",
+    "ncp": "^2.0.0"
+  }
+}
diff --git a/plugin_examples.py b/plugin_examples.py
index 17b1207..a9af97e 100644
--- a/plugin_examples.py
+++ b/plugin_examples.py
@@ -1,25 +1,42 @@
 """
     proxy.py
     ~~~~~~~~
-    Lightweight, Programmable, TLS interceptor Proxy for HTTP(S), HTTP2, WebSockets protocols in a single Python file.
+    ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file.
 
-    :copyright: (c) 2013-present by Abhinav Singh.
+    :copyright: (c) 2013-present by Abhinav Singh and contributors.
     :license: BSD, see LICENSE for more details.
 """
 import json
 import os
 import tempfile
 import time
-from typing import Optional, BinaryIO
+from typing import Optional, BinaryIO, List, Tuple
 from urllib import parse as urlparse
 
 import proxy
 
 
-@proxy.route(b'/hello-world')
-def hello_world(_request: proxy.HttpParser) -> bytes:
-    """A HttpWebServerRoutePlugin plugin for inbuilt web server."""
-    return proxy.HttpParser.build_response(200, body=b'Hello World')
+class ModifyPostDataPlugin(proxy.HttpProxyBasePlugin):
+    """Modify POST request body before sending to upstream server."""
+
+    MODIFIED_BODY = b'{"key": "modified"}'
+
+    def before_upstream_connection(self) -> bool:
+        if self.request.method == proxy.httpMethods.POST:
+            self.request.body = ModifyPostDataPlugin.MODIFIED_BODY
+            # Update Content-Length header only when request is NOT chunked encoded
+            if not self.request.is_chunked_encoded():
+                self.request.add_header(b'Content-Length', proxy.bytes_(len(self.request.body)))
+        return False
+
+    def on_upstream_connection(self) -> None:
+        pass
+
+    def handle_upstream_response(self, raw: bytes) -> bytes:
+        return raw
+
+    def on_upstream_connection_close(self) -> None:
+        pass
 
 
 class ProposedRestApiPlugin(proxy.HttpProxyBasePlugin):
@@ -59,13 +76,14 @@ class ProposedRestApiPlugin(proxy.HttpProxyBasePlugin):
         before connecting to upstream server."""
         if self.request.host == self.API_SERVER and self.request.url:
             if self.request.url.path in self.REST_API_SPEC:
-                self.client.send(proxy.HttpParser.build_response(
+                self.client.send(proxy.build_http_response(
                     200, reason=b'OK',
                     headers={b'Content-Type': b'application/json'},
-                    body=proxy.bytes_(json.dumps(self.REST_API_SPEC[self.request.url.path]))
+                    body=proxy.bytes_(json.dumps(
+                        self.REST_API_SPEC[self.request.url.path]))
                 ))
             else:
-                self.client.send(proxy.HttpParser.build_response(
+                self.client.send(proxy.build_http_response(
                     404, reason=b'NOT FOUND', body=b'Not Found'
                 ))
             return True
@@ -88,7 +106,7 @@ class RedirectToCustomServerPlugin(proxy.HttpProxyBasePlugin):
 
     def before_upstream_connection(self) -> bool:
         # Redirect all non-https requests to inbuilt WebServer.
-        if self.request.method != b'CONNECT':
+        if self.request.method != proxy.httpMethods.CONNECT:
             self.request.url = urlparse.urlsplit(self.UPSTREAM_SERVER)
             self.request.set_host_port()
         return False
@@ -165,7 +183,35 @@ class ManInTheMiddlePlugin(proxy.HttpProxyBasePlugin):
         pass
 
     def handle_upstream_response(self, raw: bytes) -> bytes:
-        return proxy.HttpParser.build_response(200, reason=b'OK', body=b'Hello from man in the middle')
+        return proxy.build_http_response(
+            200, reason=b'OK', body=b'Hello from man in the middle')
 
     def on_upstream_connection_close(self) -> None:
         pass
+
+
+class WebServerPlugin(proxy.HttpWebServerBasePlugin):
+    """Demonstration of inbuilt web server routing via plugin."""
+
+    def routes(self) -> List[Tuple[int, bytes]]:
+        return [
+            (proxy.httpProtocolTypes.HTTP, b'/http-route-example'),
+            (proxy.httpProtocolTypes.HTTPS, b'/https-route-example'),
+            (proxy.httpProtocolTypes.WEBSOCKET, b'/ws-route-example'),
+        ]
+
+    def handle_request(self, request: proxy.HttpParser) -> None:
+        path = request.build_url()
+        if path == b'/http-route-example':
+            self.client.queue(proxy.build_http_response(200, body=b'HTTP route response'))
+        elif path == b'/https-route-example':
+            self.client.queue(proxy.build_http_response(200, body=b'HTTPS route response'))
+
+    def on_websocket_open(self) -> None:
+        proxy.logger.info('Websocket open')
+
+    def on_websocket_message(self, frame: proxy.WebsocketFrame) -> None:
+        proxy.logger.info(frame.data)
+
+    def on_websocket_close(self) -> None:
+        proxy.logger.info('Websocket close')
diff --git a/proxy.js b/proxy.js
new file mode 100644
index 0000000..6a0a8f3
--- /dev/null
+++ b/proxy.js
@@ -0,0 +1,37 @@
+/*
+    proxy.py
+    ~~~~~~~~
+    ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file.
+
+    :copyright: (c) 2013-present by Abhinav Singh and contributors.
+    :license: BSD, see LICENSE for more details.
+*/
+const path = require('path');
+const fs = require('fs');
+const ncp = require('ncp').ncp;
+ncp.limit = 16;
+
+const publicFolderPath = path.join(__dirname, 'public');
+const destinationFolderPath = path.join(publicFolderPath, 'devtools');
+
+const publicFolderExists = fs.existsSync(publicFolderPath);
+if (!publicFolderExists) {
+    console.error(publicFolderPath + ' folder doesn\'t exist, make sure you are in the right directory.');
+    process.exit(1);
+}
+
+const destinationFolderExists = fs.existsSync(destinationFolderPath);
+if (!destinationFolderExists) {
+    console.error(destinationFolderPath + ' folder doesn\'t exist, make sure you are in the right directory.');
+    process.exit(1);
+}
+
+const chromeDevTools = path.dirname(require.resolve('chrome-devtools-frontend/front_end/inspector.html'));
+
+console.log(chromeDevTools + ' ---> ' + destinationFolderPath);
+ncp(chromeDevTools, destinationFolderPath, (err) => {
+    if (err) {
+        return console.error(err);
+    }
+    console.log('Copy successful!!!');
+});
diff --git a/proxy.py b/proxy.py
index ae04cc8..c8ef6f9 100755
--- a/proxy.py
+++ b/proxy.py
@@ -3,24 +3,34 @@
 """
     proxy.py
     ~~~~~~~~
-    Lightweight, Programmable, TLS interceptor Proxy for HTTP(S), HTTP2, WebSockets protocols in a single Python file.
+    ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file.
 
-    :copyright: (c) 2013-present by Abhinav Singh.
+    :copyright: (c) 2013-present by Abhinav Singh and contributors.
     :license: BSD, see LICENSE for more details.
 """
 import argparse
 import base64
+import contextlib
 import datetime
 import errno
+import functools
+import hashlib
 import importlib
 import inspect
+import io
 import ipaddress
+import json
 import logging
+import mimetypes
 import multiprocessing
 import os
 import pathlib
+import queue
+import secrets
+import selectors
 import socket
 import ssl
+import struct
 import subprocess
 import sys
 import threading
@@ -28,15 +38,19 @@ import time
 from abc import ABC, abstractmethod
 from multiprocessing import connection
 from multiprocessing.reduction import send_handle, recv_handle
-from typing import Any, Dict, List, Tuple, Optional, Union, NamedTuple, Type, Callable
+from typing import Any, Dict, List, Tuple, Optional, Union, NamedTuple, Callable, TYPE_CHECKING, Type
+from types import TracebackType
 from urllib import parse as urlparse
 
-import select
+from typing_extensions import Protocol
 
 if os.name != 'nt':
     import resource
 
-VERSION = (1, 0, 0)
+PROXY_PY_DIR = os.path.dirname(os.path.realpath(__file__))
+PROXY_PY_START_TIME = time.time()
+
+VERSION = (1, 1, 0)
 __version__ = '.'.join(map(str, VERSION[0:3]))
 __description__ = 'Lightweight, Programmable, TLS interceptor Proxy for HTTP(S), HTTP2, ' \
                   'WebSockets protocols in a single Python file.'
@@ -46,19 +60,26 @@ __homepage__ = 'https://github.com/abhinavsingh/proxy.py'
 __download_url__ = '%s/archive/master.zip' % __homepage__
 __license__ = 'BSD'
 
-logger = logging.getLogger(__name__)
-
 # Defaults
 DEFAULT_BACKLOG = 100
 DEFAULT_BASIC_AUTH = None
+DEFAULT_CA_KEY_FILE = None
+DEFAULT_CA_CERT_DIR = None
+DEFAULT_CA_CERT_FILE = None
+DEFAULT_CA_SIGNING_KEY_FILE = None
+DEFAULT_CERT_FILE = None
 DEFAULT_BUFFER_SIZE = 1024 * 1024
 DEFAULT_CLIENT_RECVBUF_SIZE = DEFAULT_BUFFER_SIZE
 DEFAULT_SERVER_RECVBUF_SIZE = DEFAULT_BUFFER_SIZE
 DEFAULT_DISABLE_HEADERS: List[bytes] = []
 DEFAULT_IPV4_HOSTNAME = ipaddress.IPv4Address('127.0.0.1')
 DEFAULT_IPV6_HOSTNAME = ipaddress.IPv6Address('::1')
+DEFAULT_KEY_FILE = None
 DEFAULT_PORT = 8899
 DEFAULT_DISABLE_HTTP_PROXY = False
+DEFAULT_ENABLE_DEVTOOLS = False
+DEFAULT_DEVTOOLS_WS_PATH = b'/devtools'
+DEFAULT_ENABLE_STATIC_SERVER = False
 DEFAULT_ENABLE_WEB_SERVER = False
 DEFAULT_LOG_LEVEL = 'INFO'
 DEFAULT_OPEN_FILE_LIMIT = 1024
@@ -66,20 +87,23 @@ DEFAULT_PAC_FILE = None
 DEFAULT_PAC_FILE_URL_PATH = b'/'
 DEFAULT_PID_FILE = None
 DEFAULT_NUM_WORKERS = 0
-DEFAULT_PLUGINS = ''
+DEFAULT_PLUGINS = ''    # Comma separated list of plugins
+DEFAULT_STATIC_SERVER_DIR = os.path.join(PROXY_PY_DIR, 'public')
 DEFAULT_VERSION = False
-DEFAULT_LOG_FORMAT = '%(asctime)s - %(levelname)s - pid:%(process)d - %(funcName)s:%(lineno)d - %(message)s'
+DEFAULT_LOG_FORMAT = '%(asctime)s - pid:%(process)d [%(levelname)-.1s] %(funcName)s:%(lineno)d - %(message)s'
 DEFAULT_LOG_FILE = None
+UNDER_TEST = False  # Set to True if under test
 
-# Set to True if under test
-UNDER_TEST = False
+logger = logging.getLogger(__name__)
 
 
 def text_(s: Any, encoding: str = 'utf-8', errors: str = 'strict') -> Any:
     """Utility to ensure text-like usability.
 
-    If ``s`` is an instance of ``binary_type``, return
-    ``s.decode(encoding, errors)``, otherwise return ``s``"""
+    If s is of type bytes or int, return s.decode(encoding, errors),
+    otherwise return s as it is."""
+    if isinstance(s, int):
+        return str(s)
     if isinstance(s, bytes):
         return s.decode(encoding, errors)
     return s
@@ -88,8 +112,10 @@ def text_(s: Any, encoding: str = 'utf-8', errors: str = 'strict') -> Any:
 def bytes_(s: Any, encoding: str = 'utf-8', errors: str = 'strict') -> Any:
     """Utility to ensure binary-like usability.
 
-    If ``s`` is an instance of ``text_type``, return
-    ``s.encode(encoding, errors)``, otherwise return ``s``"""
+    If s is type str or int, return s.encode(encoding, errors),
+    otherwise return s as it is."""
+    if isinstance(s, int):
+        s = str(s)
     if isinstance(s, str):
         return s.encode(encoding, errors)
     return s
@@ -99,15 +125,8 @@ version = bytes_(__version__)
 CRLF, COLON, WHITESPACE, COMMA, DOT = b'\r\n', b':', b' ', b',', b'.'
 PROXY_AGENT_HEADER_KEY = b'Proxy-agent'
 PROXY_AGENT_HEADER_VALUE = b'proxy.py v' + version
-PROXY_AGENT_HEADER = PROXY_AGENT_HEADER_KEY + COLON + WHITESPACE + PROXY_AGENT_HEADER_VALUE
-
-##
-# Various NamedTuples
-#
-# collections.namedtuple were replaced with typing.NamedTuple
-# for mypy compliance. Unfortunately, we can't seem to use
-# a NamedTuple as a type.
-##
+PROXY_AGENT_HEADER = PROXY_AGENT_HEADER_KEY + \
+    COLON + WHITESPACE + PROXY_AGENT_HEADER_VALUE
 
 TcpConnectionTypes = NamedTuple('TcpConnectionTypes', [
     ('SERVER', int),
@@ -115,12 +134,6 @@ TcpConnectionTypes = NamedTuple('TcpConnectionTypes', [
 ])
 tcpConnectionTypes = TcpConnectionTypes(1, 2)
 
-WorkerOperations = NamedTuple('WorkerOperations', [
-    ('HTTP_PROTOCOL', int),
-    ('SHUTDOWN', int),
-])
-workerOperations = WorkerOperations(1, 2)
-
 ChunkParserStates = NamedTuple('ChunkParserStates', [
     ('WAITING_FOR_SIZE', int),
     ('WAITING_FOR_DATA', int),
@@ -128,6 +141,23 @@ ChunkParserStates = NamedTuple('ChunkParserStates', [
 ])
 chunkParserStates = ChunkParserStates(1, 2, 3)
 
+HttpMethods = NamedTuple('HttpMethods', [
+    ('GET', bytes),
+    ('PUT', bytes),
+    ('POST', bytes),
+    ('CONNECT', bytes),
+    ('HEAD', bytes),
+    ('DELETE', bytes),
+])
+httpMethods = HttpMethods(
+    b'GET',
+    b'PUT',
+    b'POST',
+    b'CONNECT',
+    b'HEAD',
+    b'DELETE',
+)
+
 HttpParserStates = NamedTuple('HttpParserStates', [
     ('INITIALIZED', int),
     ('LINE_RCVD', int),
@@ -147,8 +177,168 @@ httpParserTypes = HttpParserTypes(1, 2)
 HttpProtocolTypes = NamedTuple('HttpProtocolTypes', [
     ('HTTP', int),
     ('HTTPS', int),
+    ('WEBSOCKET', int),
 ])
-httpProtocolTypes = HttpProtocolTypes(1, 2)
+httpProtocolTypes = HttpProtocolTypes(1, 2, 3)
+
+WebsocketOpcodes = NamedTuple('WebsocketOpcodes', [
+    ('CONTINUATION_FRAME', int),
+    ('TEXT_FRAME', int),
+    ('BINARY_FRAME', int),
+    ('CONNECTION_CLOSE', int),
+    ('PING', int),
+    ('PONG', int),
+])
+websocketOpcodes = WebsocketOpcodes(0x0, 0x1, 0x2, 0x8, 0x9, 0xA)
+
+
+def build_http_request(method: bytes, url: bytes,
+                       protocol_version: bytes = b'HTTP/1.1',
+                       headers: Optional[Dict[bytes, bytes]] = None,
+                       body: Optional[bytes] = None) -> bytes:
+    """Build and returns a HTTP request packet."""
+    if headers is None:
+        headers = {}
+    return build_http_pkt(
+        [method, url, protocol_version], headers, body)
+
+
+def build_http_response(status_code: int,
+                        protocol_version: bytes = b'HTTP/1.1',
+                        reason: Optional[bytes] = None,
+                        headers: Optional[Dict[bytes, bytes]] = None,
+                        body: Optional[bytes] = None) -> bytes:
+    """Build and returns a HTTP response packet."""
+    line = [protocol_version, bytes_(status_code)]
+    if reason:
+        line.append(reason)
+    if headers is None:
+        headers = {}
+    if body is not None and not any(
+            k.lower() == b'content-length' for k in headers):
+        headers[b'Content-Length'] = bytes_(len(body))
+    return build_http_pkt(line, headers, body)
+
+
+def build_http_header(k: bytes, v: bytes) -> bytes:
+    """Build and return a HTTP header line for use in raw packet."""
+    return k + COLON + WHITESPACE + v
+
+
+def build_http_pkt(line: List[bytes],
+                   headers: Optional[Dict[bytes, bytes]] = None,
+                   body: Optional[bytes] = None) -> bytes:
+    """Build and returns a HTTP request or response packet."""
+    req = WHITESPACE.join(line) + CRLF
+    if headers is not None:
+        for k in headers:
+            req += build_http_header(k, headers[k]) + CRLF
+    req += CRLF
+    if body:
+        req += body
+    return req
+
+
+def build_websocket_handshake_request(
+        key: bytes,
+        method: bytes = b'GET',
+        url: bytes = b'/') -> bytes:
+    """
+    Build and returns a Websocket handshake request packet.
+
+    :param key: Sec-WebSocket-Key header value.
+    :param method: HTTP method.
+    :param url: Websocket request path.
+    """
+    return build_http_request(
+        method, url,
+        headers={
+            b'Connection': b'upgrade',
+            b'Upgrade': b'websocket',
+            b'Sec-WebSocket-Key': key,
+            b'Sec-WebSocket-Version': b'13',
+        }
+    )
+
+
+def build_websocket_handshake_response(accept: bytes) -> bytes:
+    """
+    Build and returns a Websocket handshake response packet.
+
+    :param accept: Sec-WebSocket-Accept header value
+    """
+    return build_http_response(
+        101, reason=b'Switching Protocols',
+        headers={
+            b'Upgrade': b'websocket',
+            b'Connection': b'Upgrade',
+            b'Sec-WebSocket-Accept': accept
+        }
+    )
+
+
+def find_http_line(raw: bytes) -> Tuple[Optional[bytes], bytes]:
+    """Find and returns first line ending in CRLF along with following buffer.
+
+    If no ending CRLF is found, line is None."""
+    pos = raw.find(CRLF)
+    if pos == -1:
+        return None, raw
+    line = raw[:pos]
+    rest = raw[pos + len(CRLF):]
+    return line, rest
+
+
+def new_socket_connection(addr: Tuple[str, int]) -> socket.socket:
+    try:
+        ip = ipaddress.ip_address(addr[0])
+        if ip.version == 4:
+            conn = socket.socket(
+                socket.AF_INET, socket.SOCK_STREAM, 0)
+            conn.connect(addr)
+        else:
+            conn = socket.socket(
+                socket.AF_INET6, socket.SOCK_STREAM, 0)
+            conn.connect((addr[0], addr[1], 0, 0))
+    except ValueError:
+        # Not a valid IP address, most likely its a domain name,
+        # try to establish dual stack IPv4/IPv6 connection.
+        conn = socket.create_connection(addr)
+    return conn
+
+
+class socket_connection(contextlib.ContextDecorator):
+    """Same as new_socket_connection but as a context manager and decorator."""
+
+    def __init__(self, addr: Tuple[str, int]):
+        self.addr: Tuple[str, int] = addr
+        self.conn: Optional[socket.socket] = None
+        super().__init__()
+
+    def __enter__(self) -> socket.socket:
+        self.conn = new_socket_connection(self.addr)
+        return self.conn
+
+    def __exit__(
+            self,
+            exc_type: Optional[Type[BaseException]],
+            exc_val: Optional[BaseException],
+            exc_tb: Optional[TracebackType]) -> bool:
+        if self.conn:
+            self.conn.close()
+        return False
+
+    def __call__(self, func: Callable[..., Any]) -> Callable[[socket.socket], Any]:
+        @functools.wraps(func)
+        def decorated(*args: Any, **kwargs: Any) -> Any:
+            with self as conn:
+                return func(conn, *args, **kwargs)
+        return decorated
+
+
+class _HasFileno(Protocol):
+    def fileno(self) -> int:
+        ...     # pragma: no cover
 
 
 class TcpConnectionUninitializedException(Exception):
@@ -156,7 +346,13 @@ class TcpConnectionUninitializedException(Exception):
 
 
 class TcpConnection(ABC):
-    """TCP server/client connection abstraction."""
+    """TCP server/client connection abstraction.
+
+    Main motivation of this class is to provide a buffer management
+    when reading and writing into the socket.
+
+    Implement the connection property abstract method to return
+    a socket connection object."""
 
     def __init__(self, tag: int):
         self.buffer: bytes = b''
@@ -167,7 +363,7 @@ class TcpConnection(ABC):
     @abstractmethod
     def connection(self) -> Union[ssl.SSLSocket, socket.socket]:
         """Must return the socket connection to use in this class."""
-        raise TcpConnectionUninitializedException()
+        raise TcpConnectionUninitializedException()     # pragma: no cover
 
     def send(self, data: bytes) -> int:
         """Users must handle BrokenPipeError exceptions"""
@@ -207,6 +403,10 @@ class TcpConnection(ABC):
         return len(data)
 
     def flush(self) -> int:
+        if self.buffer_size() == 0:
+            return 0
+        if self.closed:
+            raise BrokenPipeError()
         sent: int = self.send(self.buffer)
         self.buffer = self.buffer[sent:]
         logger.debug('flushed %d bytes to %s' % (sent, self.tag))
@@ -218,8 +418,8 @@ class TcpServerConnection(TcpConnection):
 
     def __init__(self, host: str, port: int):
         super().__init__(tcpConnectionTypes.SERVER)
-        self.addr: Tuple[str, int] = (host, int(port))
         self._conn: Optional[Union[ssl.SSLSocket, socket.socket]] = None
+        self.addr: Tuple[str, int] = (host, int(port))
 
     @property
     def connection(self) -> Union[ssl.SSLSocket, socket.socket]:
@@ -230,28 +430,15 @@ class TcpServerConnection(TcpConnection):
     def connect(self) -> None:
         if self._conn is not None:
             return
-
-        try:
-            ip = ipaddress.ip_address(text_(self.addr[0]))
-            if ip.version == 4:
-                self._conn = socket.socket(
-                    socket.AF_INET, socket.SOCK_STREAM, 0)
-                self._conn.connect((self.addr[0], self.addr[1]))
-            else:
-                self._conn = socket.socket(
-                    socket.AF_INET6, socket.SOCK_STREAM, 0)
-                self._conn.connect((self.addr[0], self.addr[1], 0, 0))
-        except ValueError:
-            # Not a valid IP address, most likely its a domain name,
-            # try to establish dual stack IPv4/IPv6 connection.
-            self._conn = socket.create_connection((self.addr[0], self.addr[1]))
+        self._conn = new_socket_connection(self.addr)
 
 
 class TcpClientConnection(TcpConnection):
-    """Accepted client connection."""
+    """An accepted client connection request."""
 
-    def __init__(self, conn: Union[ssl.SSLSocket,
-                                   socket.socket], addr: Tuple[str, int]):
+    def __init__(self,
+                 conn: Union[ssl.SSLSocket, socket.socket],
+                 addr: Tuple[str, int]):
         super().__init__(tcpConnectionTypes.CLIENT)
         self._conn: Optional[Union[ssl.SSLSocket, socket.socket]] = conn
         self.addr: Tuple[str, int] = addr
@@ -263,175 +450,6 @@ class TcpClientConnection(TcpConnection):
         return self._conn
 
 
-class TcpServer(ABC):
-    """TcpServer server implementation.
-
-    Inheritor MUST implement `handle` method. It accepts an instance of `TcpClientConnection`.
-    Optionally, can also implement `setup` and `shutdown` methods for custom bootstrapping and tearing
-    down internal state.
-    """
-
-    def __init__(self,
-                 hostname: Union[ipaddress.IPv4Address,
-                                 ipaddress.IPv6Address] = DEFAULT_IPV6_HOSTNAME,
-                 port: int = DEFAULT_PORT,
-                 backlog: int = DEFAULT_BACKLOG):
-        self.port: int = port
-        self.backlog: int = backlog
-        self.socket: Optional[socket.socket] = None
-        self.running: bool = False
-        self.family: socket.AddressFamily = socket.AF_INET6 if hostname.version == 6 else socket.AF_INET
-        self.hostname: Union[ipaddress.IPv4Address,
-                             ipaddress.IPv6Address] = hostname
-
-    @abstractmethod
-    def setup(self) -> None:
-        pass  # pragma: no cover
-
-    @abstractmethod
-    def handle(self, client: TcpClientConnection) -> None:
-        raise NotImplementedError()  # pragma: no cover
-
-    @abstractmethod
-    def shutdown(self) -> None:
-        pass  # pragma: no cover
-
-    def stop(self) -> None:
-        self.running = False
-
-    def run_once(self) -> None:
-        if self.socket:
-            r, w, x = select.select([self.socket], [], [], 1)
-            if self.socket in r:
-                try:
-                    conn, addr = self.socket.accept()
-                    client = TcpClientConnection(conn, addr)
-                    self.handle(client)
-                except ssl.SSLError as e:
-                    logger.exception('SSLError encountered', exc_info=e)
-
-    def run(self) -> None:
-        self.running = True
-        self.setup()
-        try:
-            self.socket = socket.socket(self.family, socket.SOCK_STREAM)
-            self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-            self.socket.bind((str(self.hostname), self.port))
-            self.socket.listen(self.backlog)
-            logger.info('Started server on %s:%d' % (self.hostname, self.port))
-            while self.running:
-                self.run_once()
-        except Exception as e:
-            logger.exception('Unexpected error, tearing down server.', exc_info=e)
-        finally:
-            self.shutdown()
-            logger.info('Closing server socket')
-            if self.socket:
-                self.socket.close()
-
-
-class WorkerPool(TcpServer):
-    """WorkerPool.
-
-    Pre-spawns worker process to utilize all cores available on the system.  Accepted `TcpClientConnection` is
-    dispatched over a queue to workers.  One of the worker picks up the work and starts a new thread to handle the
-    client request.
-    """
-
-    def __init__(self, hostname: Union[ipaddress.IPv4Address,
-                                       ipaddress.IPv6Address],
-                 port: int, backlog: int, num_workers: int,
-                 work_klass: type, **kwargs: Any) -> None:
-        super().__init__(hostname=hostname, port=port, backlog=backlog)
-        self.num_workers = num_workers
-
-        self.workers: List[Worker] = []
-        self.work_queues: List[Tuple[connection.Connection,
-                                     connection.Connection]] = []
-        self.current_worker_id = 0
-
-        self.work_klass = work_klass
-        self.kwargs = kwargs
-
-    def setup(self) -> None:
-        for worker_id in range(self.num_workers):
-            work_queue = multiprocessing.Pipe()
-
-            worker = Worker(work_queue[1], self.work_klass, **self.kwargs)
-            worker.daemon = True
-            worker.start()
-
-            self.workers.append(worker)
-            self.work_queues.append(work_queue)
-        logger.info('Started %d workers' % self.num_workers)
-
-    def handle(self, client: TcpClientConnection) -> None:
-        # Dispatch in round robin fashion
-        work_queue = self.work_queues[self.current_worker_id]
-        logger.debug(
-            'Dispatched client request to worker id %d',
-            self.current_worker_id)
-        # Dispatch non-socket data first, followed by fileno using reduction
-        work_queue[0].send((workerOperations.HTTP_PROTOCOL, client.addr))
-        send_handle(work_queue[0], client.connection.fileno(),
-                    self.workers[self.current_worker_id].pid)
-        # Close parent handler
-        client.close()
-        self.current_worker_id += 1
-        self.current_worker_id %= self.num_workers
-
-    def shutdown(self) -> None:
-        logger.info('Shutting down %d workers' % self.num_workers)
-        for work_queue in self.work_queues:
-            work_queue[0].send((workerOperations.SHUTDOWN, None))
-            work_queue[0].close()
-        for worker in self.workers:
-            worker.join()
-
-
-class Worker(multiprocessing.Process):
-    """Generic worker class implementation.
-
-    Worker instance accepts (operation, payload) over work queue and
-    depending upon requested operation starts a new thread to handle the work.
-    """
-
-    def __init__(
-            self,
-            work_queue: connection.Connection,
-            work_klass: type,
-            **kwargs: Any):
-        super().__init__()
-        self.work_queue: connection.Connection = work_queue
-        self.work_klass = work_klass
-        self.kwargs = kwargs
-
-    def run_once(self) -> bool:
-        try:
-            op, payload = self.work_queue.recv()
-            if op == workerOperations.HTTP_PROTOCOL:
-                fileno = recv_handle(self.work_queue)
-                proxy = self.work_klass(
-                    fileno=fileno, addr=payload, **self.kwargs)
-                proxy.setDaemon(True)
-                proxy.start()
-            elif op == workerOperations.SHUTDOWN:
-                logger.debug('Worker shutting down....')
-                self.work_queue.close()
-                return True
-        except ConnectionRefusedError:
-            return False
-        except KeyboardInterrupt:  # pragma: no cover
-            return True
-        return False
-
-    def run(self) -> None:
-        while True:
-            teardown = self.run_once()
-            if teardown:
-                break
-
-
 class ChunkParser:
     """HTTP chunked encoding response parser."""
 
@@ -454,7 +472,7 @@ class ChunkParser:
             raw = self.chunk + raw
             self.chunk = b''
             # Extract following chunk data size
-            line, raw = HttpParser.find_line(raw)
+            line, raw = find_http_line(raw)
             # CRLF not received or Blank line was received.
             if line is None or line.strip() == b'':
                 self.chunk = raw
@@ -478,6 +496,17 @@ class ChunkParser:
                 self.size = None
         return len(raw) > 0, raw
 
+    @staticmethod
+    def to_chunks(raw: bytes, chunk_size: int = DEFAULT_BUFFER_SIZE) -> bytes:
+        chunks: List[bytes] = []
+        for i in range(0, len(raw), chunk_size):
+            chunk = raw[i: i + chunk_size]
+            chunks.append(bytes_('{:x}'.format(len(chunk))))
+            chunks.append(chunk)
+        chunks.append(bytes_('{:x}'.format(0)))
+        chunks.append(b'')
+        return CRLF.join(chunks) + CRLF
+
 
 class HttpParser:
     """HTTP request/response parser."""
@@ -494,8 +523,6 @@ class HttpParser:
         self.buffer: bytes = b''
 
         self.headers: Dict[bytes, Tuple[bytes, bytes]] = dict()
-
-        # Can simply be b'', then set type as bytes?
         self.body: Optional[bytes] = None
 
         self.method: Optional[bytes] = None
@@ -512,6 +539,14 @@ class HttpParser:
         self.host: Optional[bytes] = None
         self.port: Optional[int] = None
 
+    def header(self, key: bytes) -> bytes:
+        if key.lower() not in self.headers:
+            raise KeyError('%s not found in headers', text_(key))
+        return self.headers[key.lower()][1]
+
+    def has_header(self, key: bytes) -> bool:
+        return key.lower() in self.headers
+
     def add_header(self, key: bytes, value: bytes) -> None:
         self.headers[key.lower()] = (key, value)
 
@@ -529,17 +564,17 @@ class HttpParser:
 
     def set_host_port(self) -> None:
         if self.type == httpParserTypes.REQUEST_PARSER:
-            if self.method == b'CONNECT' and self.url:
+            if self.method == httpMethods.CONNECT and self.url:
                 u = urlparse.urlsplit(b'//' + self.url.path)
                 self.host, self.port = u.hostname, u.port
             elif self.url:
                 self.host, self.port = self.url.hostname, self.url.port \
                     if self.url.port else 80
             else:
-                raise Exception('Invalid request\n%s' % self.bytes)
+                raise KeyError('Invalid request\n%s' % self.bytes)
 
-    def is_chunked_encoded_response(self) -> bool:
-        return self.type == httpParserTypes.RESPONSE_PARSER and b'transfer-encoding' in self.headers and \
+    def is_chunked_encoded(self) -> bool:
+        return b'transfer-encoding' in self.headers and \
             self.headers[b'transfer-encoding'][1].lower() == b'chunked'
 
     def parse(self, raw: bytes) -> None:
@@ -558,25 +593,22 @@ class HttpParser:
             if self.state in (
                     httpParserStates.HEADERS_COMPLETE,
                     httpParserStates.RCVING_BODY,
-                    httpParserStates.COMPLETE) and (
-                    self.method == b'POST' or self.type == httpParserTypes.RESPONSE_PARSER):
-                if not self.body:
-                    self.body = b''
-
+                    httpParserStates.COMPLETE):
                 if b'content-length' in self.headers:
                     self.state = httpParserStates.RCVING_BODY
+                    if self.body is None:
+                        self.body = b''
                     self.body += raw
                     if self.body and \
-                            len(self.body) >= int(self.headers[b'content-length'][1]):
+                            len(self.body) >= int(self.header(b'content-length')):
                         self.state = httpParserStates.COMPLETE
-                elif self.is_chunked_encoded_response():
+                elif self.is_chunked_encoded():
                     if not self.chunk_parser:
                         self.chunk_parser = ChunkParser()
                     self.chunk_parser.parse(raw)
                     if self.chunk_parser.state == chunkParserStates.COMPLETE:
                         self.body = self.chunk_parser.body
                         self.state = httpParserStates.COMPLETE
-
                 more, raw = False, b''
             else:
                 more, raw = self.process(raw)
@@ -584,7 +616,7 @@ class HttpParser:
 
     def process(self, raw: bytes) -> Tuple[bool, bytes]:
         """Returns False when no CRLF could be found in received bytes."""
-        line, raw = HttpParser.find_line(raw)
+        line, raw = find_http_line(raw)
         if line is None:
             return False, raw
 
@@ -613,12 +645,12 @@ class HttpParser:
         # `TestHttpParser.test_response_parse_without_content_length` for details
         elif self.state == httpParserStates.HEADERS_COMPLETE and \
                 self.type == httpParserTypes.REQUEST_PARSER and \
-                self.method != b'POST' and \
+                self.method != httpMethods.POST and \
                 self.bytes.endswith(CRLF * 2):
             self.state = httpParserStates.COMPLETE
         elif self.state == httpParserStates.HEADERS_COMPLETE and \
                 self.type == httpParserTypes.REQUEST_PARSER and \
-                self.method == b'POST' and \
+                self.method == httpMethods.POST and \
                 (b'content-length' not in self.headers or
                  (b'content-length' in self.headers and
                   int(self.headers[b'content-length'][1]) == 0)) and \
@@ -662,75 +694,147 @@ class HttpParser:
         assert self.method and self.version
         if disable_headers is None:
             disable_headers = DEFAULT_DISABLE_HEADERS
-        return HttpParser.build_request(
+        body: Optional[bytes] = None
+        if self.is_chunked_encoded() and self.body:
+            body = ChunkParser.to_chunks(self.body)
+        else:
+            body = self.body
+        return build_http_request(
             self.method, self.build_url(), self.version,
             headers={} if not self.headers else {self.headers[k][0]: self.headers[k][1] for k in self.headers if
                                                  k.lower() not in disable_headers},
-            body=self.body
+            body=body
         )
 
-    @staticmethod
-    def build_request(method: bytes, url: bytes, protocol_version: bytes,
-                      headers: Optional[Dict[bytes, bytes]] = None,
-                      body: Optional[bytes] = None) -> bytes:
-        if headers is None:
-            headers = {}
-        return HttpParser.build_pkt([method, url, protocol_version], headers, body)
+    def has_upstream_server(self) -> bool:
+        """Host field SHOULD be None for incoming local WebServer requests."""
+        return True if self.host is not None else False
 
-    @staticmethod
-    def build_response(status_code: int,
-                       protocol_version: bytes = b'HTTP/1.1',
-                       reason: Optional[bytes] = None,
-                       headers: Optional[Dict[bytes, bytes]] = None,
-                       body: Optional[bytes] = None) -> bytes:
-        line = [protocol_version, bytes_(str(status_code))]
-        if reason:
-            line.append(reason)
-        if headers is None:
-            headers = {}
-        if body is not None and not any(k.lower() == b'content-length' for k in headers):
-            headers[b'Content-Length'] = bytes_(str(len(body)))
-        return HttpParser.build_pkt(line, headers, body)
 
-    @staticmethod
-    def build_pkt(line: List[bytes],
-                  headers: Optional[Dict[bytes, bytes]] = None,
-                  body: Optional[bytes] = None) -> bytes:
-        req = WHITESPACE.join(line) + CRLF
-        if headers is not None:
-            for k in headers:
-                req += HttpParser.build_header(k, headers[k]) + CRLF
-        req += CRLF
-        if body:
-            req += body
-        return req
+class AcceptorPool:
+    """AcceptorPool.
 
-    @staticmethod
-    def build_header(k: bytes, v: bytes) -> bytes:
-        return k + COLON + WHITESPACE + v
+    Pre-spawns worker processes to utilize all cores available on the system.  Server socket connection is
+    dispatched over a pipe to workers.  Each worker accepts incoming client request and spawns a
+    separate thread to handle the client request.
+    """
 
-    @staticmethod
-    def find_line(raw: bytes) -> Tuple[Optional[bytes], bytes]:
-        """Finds first line of request ending in CRLF.
-
-        If no CRLF is found, line is None.
-        Also returns pending buffer after received line of request."""
-        pos = raw.find(CRLF)
-        if pos == -1:
-            return None, raw
-        line = raw[:pos]
-        rest = raw[pos + len(CRLF):]
-        return line, rest
-
-    ##########################################################################
-    # HttpParser was originally written to parse the incoming raw Http requests.
-    # Since request / response objects passed to ProtocolHandlerPlugin methods
-    # are also HttpParser objects, methods below were added to simplify developer API.
-    ##########################################################################
+    def __init__(self,
+                 hostname: Union[ipaddress.IPv4Address,
+                                 ipaddress.IPv6Address],
+                 port: int, backlog: int, num_workers: int,
+                 work_klass: type, **kwargs: Any) -> None:
+        self.running: bool = False
 
-    def has_upstream_server(self) -> bool:
-        """Host field SHOULD be None for incoming local WebServer requests."""
-        return True if self.host is not None else False
+        self.hostname: Union[ipaddress.IPv4Address,
+                             ipaddress.IPv6Address] = hostname
+        self.port: int = port
+        self.family: socket.AddressFamily = socket.AF_INET6 if hostname.version == 6 else socket.AF_INET
+        self.backlog: int = backlog
+        self.socket: Optional[socket.socket] = None
+
+        self.current_worker_id = 0
+        self.num_workers = num_workers
+        self.workers: List[Worker] = []
+        self.work_queues: List[Tuple[connection.Connection,
+                                     connection.Connection]] = []
+
+        self.work_klass = work_klass
+        self.kwargs = kwargs
+
+    def listen(self) -> None:
+        self.socket = socket.socket(self.family, socket.SOCK_STREAM)
+        self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+        self.socket.bind((str(self.hostname), self.port))
+        self.socket.listen(self.backlog)
+        self.socket.setblocking(False)
+        self.socket.settimeout(0)
+        logger.info('Listening on %s:%d' % (self.hostname, self.port))
+
+    def start_workers(self) -> None:
+        """Start worker processes."""
+        for worker_id in range(self.num_workers):
+            work_queue = multiprocessing.Pipe()
+
+            worker = Worker(work_queue[1], self.work_klass, **self.kwargs)
+            worker.daemon = True
+            worker.start()
+
+            self.workers.append(worker)
+            self.work_queues.append(work_queue)
+        logger.info('Started %d workers' % self.num_workers)
+
+    def shutdown(self) -> None:
+        logger.info('Shutting down %d workers' % self.num_workers)
+        for worker in self.workers:
+            worker.join()
+
+    def setup(self) -> None:
+        """Listen on port, setup workers and pass server socket to workers."""
+        self.running = True
+        self.listen()
+        self.start_workers()
+
+        # Send server socket to workers.
+        assert self.socket is not None
+        for work_queue in self.work_queues:
+            work_queue[0].send(self.family)
+            send_handle(work_queue[0], self.socket.fileno(),
+                        self.workers[self.current_worker_id].pid)
+        self.socket.close()
+
+
+class Worker(multiprocessing.Process):
+    """Socket client acceptor.
+
+    Accepts client connection over received server socket handle and
+    starts a new work thread.
+    """
+
+    lock = multiprocessing.Lock()
+
+    def __init__(
+            self,
+            work_queue: connection.Connection,
+            work_klass: type,
+            **kwargs: Any):
+        super().__init__()
+        self.work_queue: connection.Connection = work_queue
+        self.work_klass = work_klass
+        self.kwargs = kwargs
+        self.running = True
+
+    def run(self) -> None:
+        family = self.work_queue.recv()
+        sock = socket.fromfd(
+            recv_handle(self.work_queue),
+            family=family,
+            type=socket.SOCK_STREAM
+        )
+        selector = selectors.DefaultSelector()
+        try:
+            while self.running:
+                with self.lock:
+                    selector.register(sock, selectors.EVENT_READ)
+                    events = selector.select(timeout=1)
+                    selector.unregister(sock)
+                    if len(events) == 0:
+                        continue
+                try:
+                    conn, addr = sock.accept()
+                except BlockingIOError:  # as e:
+                    # logger.exception('BlockingIOError', exc_info=e)
+                    continue
+                work = self.work_klass(
+                    fileno=conn.fileno(),
+                    addr=addr,
+                    **self.kwargs)
+                work.setDaemon(True)
+                work.start()
+        except KeyboardInterrupt:
+            pass
+        finally:
+            sock.close()
 
 
 class ProtocolException(Exception):
@@ -741,7 +845,7 @@ class ProtocolException(Exception):
     to optionally return custom response to client."""
 
     def response(self, request: HttpParser) -> Optional[bytes]:
-        pass  # pragma: no cover
+        return None  # pragma: no cover
 
 
 class HttpRequestRejected(ProtocolException):
@@ -754,7 +858,6 @@ class HttpRequestRejected(ProtocolException):
                  status_code: Optional[int] = None,
                  reason: Optional[bytes] = None,
                  body: Optional[bytes] = None):
-        super().__init__()
         self.status_code: Optional[int] = status_code
         self.reason: Optional[bytes] = reason
         self.body: Optional[bytes] = body
@@ -762,13 +865,13 @@ class HttpRequestRejected(ProtocolException):
     def response(self, _request: HttpParser) -> Optional[bytes]:
         pkt = []
         if self.status_code is not None:
-            line = b'HTTP/1.1 ' + bytes_(str(self.status_code))
+            line = b'HTTP/1.1 ' + bytes_(self.status_code)
             if self.reason:
                 line += WHITESPACE + self.reason
             pkt.append(line)
             pkt.append(PROXY_AGENT_HEADER)
         if self.body:
-            pkt.append(b'Content-Length: ' + bytes_(str(len(self.body))))
+            pkt.append(b'Content-Length: ' + bytes_(len(self.body)))
             pkt.append(CRLF)
             pkt.append(self.body)
         else:
@@ -777,6 +880,46 @@ class HttpRequestRejected(ProtocolException):
         return CRLF.join(pkt) if len(pkt) > 0 else None
 
 
+class ProxyConnectionFailed(ProtocolException):
+    """Exception raised when HttpProxyPlugin is unable to establish connection to upstream server."""
+
+    RESPONSE_PKT = build_http_response(
+        502, reason=b'Bad Gateway',
+        headers={PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE,
+                 b'Connection': b'close'},
+        body=b'Bad Gateway'
+    )
+
+    def __init__(self, host: str, port: int, reason: str):
+        self.host: str = host
+        self.port: int = port
+        self.reason: str = reason
+
+    def response(self, _request: HttpParser) -> bytes:
+        return self.RESPONSE_PKT
+
+
+class ProxyAuthenticationFailed(ProtocolException):
+    """Exception raised when Http Proxy auth is enabled and
+    incoming request doesn't present necessary credentials."""
+
+    RESPONSE_PKT = build_http_response(
+        407, reason=b'Proxy Authentication Required',
+        headers={PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE,
+                 b'Connection': b'close',
+                 b'Proxy-Authenticate': b'Basic'},
+        body=b'Proxy Authentication Required')
+
+    def response(self, _request: HttpParser) -> bytes:
+        return self.RESPONSE_PKT
+
+
+if TYPE_CHECKING:
+    DevtoolsEventQueueType = queue.Queue[Dict[str, Any]]    # pragma: no cover
+else:
+    DevtoolsEventQueueType = queue.Queue
+
+
 class ProtocolConfig:
     """Holds various configuration values applicable to ProtocolHandler.
 
@@ -805,7 +948,11 @@ class ProtocolConfig:
             hostname: Union[ipaddress.IPv4Address,
                             ipaddress.IPv6Address] = DEFAULT_IPV6_HOSTNAME,
             port: int = DEFAULT_PORT,
-            backlog: int = DEFAULT_BACKLOG) -> None:
+            backlog: int = DEFAULT_BACKLOG,
+            static_server_dir: str = DEFAULT_STATIC_SERVER_DIR,
+            enable_static_server: bool = DEFAULT_ENABLE_STATIC_SERVER,
+            devtools_event_queue: Optional[DevtoolsEventQueueType] = None,
+            devtools_ws_path: bytes = DEFAULT_DEVTOOLS_WS_PATH) -> None:
         self.auth_code = auth_code
         self.server_recvbuf_size = server_recvbuf_size
         self.client_recvbuf_size = client_recvbuf_size
@@ -828,6 +975,11 @@ class ProtocolConfig:
         self.port: int = port
         self.backlog: int = backlog
 
+        self.enable_static_server: bool = enable_static_server
+        self.static_server_dir: str = static_server_dir
+        self.devtools_event_queue: Optional[DevtoolsEventQueueType] = devtools_event_queue
+        self.devtools_ws_path: bytes = devtools_ws_path
+
         self.proxy_py_data_dir = os.path.join(
             str(pathlib.Path.home()), self.ROOT_DATA_DIR_NAME)
         os.makedirs(self.proxy_py_data_dir, exist_ok=True)
@@ -842,7 +994,24 @@ class ProtocolConfig:
 class ProtocolHandlerPlugin(ABC):
     """Base ProtocolHandler Plugin class.
 
-    Implement various lifecycle event methods to customize behavior."""
+    NOTE: This is an internal plugin and in most cases only useful for core contributors.
+    If you are looking for proxy server plugins see `<proxy.HttpProxyBasePlugin>`.
+
+    Implements various lifecycle events for an accepted client connection.
+    Following events are of interest:
+
+    1. Client Connection Accepted
+       A new plugin instance is created per accepted client connection.
+       Add your logic within __init__ constructor for any per connection setup.
+    2. Client Request Chunk Received
+       on_client_data is called for every chunk of data sent by the client.
+    3. Client Request Complete
+       on_request_complete is called once client request has completed.
+    4. Server Response Chunk Received
+       on_response_chunk is called for every chunk received from the server.
+    5. Client Connection Closed
+       Add your logic within `on_client_connection_close` for any per connection teardown.
+    """
 
     def __init__(
             self,
@@ -863,15 +1032,15 @@ class ProtocolHandlerPlugin(ABC):
 
     @abstractmethod
     def get_descriptors(
-            self) -> Tuple[List[socket.socket], List[socket.socket], List[socket.socket]]:
-        return [], [], []  # pragma: no cover
+            self) -> Tuple[List[socket.socket], List[socket.socket]]:
+        return [], []  # pragma: no cover
 
     @abstractmethod
-    def flush_to_descriptors(self, w: List[socket.socket]) -> bool:
+    def write_to_descriptors(self, w: List[Union[int, _HasFileno]]) -> bool:
         pass  # pragma: no cover
 
     @abstractmethod
-    def read_from_descriptors(self, r: List[socket.socket]) -> bool:
+    def read_from_descriptors(self, r: List[Union[int, _HasFileno]]) -> bool:
         pass  # pragma: no cover
 
     @abstractmethod
@@ -884,59 +1053,17 @@ class ProtocolHandlerPlugin(ABC):
         pass  # pragma: no cover
 
     @abstractmethod
-    def handle_response_chunk(self, chunk: bytes) -> bytes:
+    def on_response_chunk(self, chunk: bytes) -> bytes:
         """Handle data chunks as received from the server.
 
         Return optionally modified chunk to return back to client."""
         return chunk  # pragma: no cover
 
-    @abstractmethod
-    def access_log(self) -> None:
-        pass  # pragma: no cover
-
     @abstractmethod
     def on_client_connection_close(self) -> None:
         pass  # pragma: no cover
 
 
-class ProxyConnectionFailed(ProtocolException):
-    """Exception raised when HttpProxyPlugin is unable to establish connection to upstream server."""
-
-    RESPONSE_PKT = HttpParser.build_response(
-        502, reason=b'Bad Gateway',
-        headers={PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE,
-                 b'Connection': b'close'},
-        body=b'Bad Gateway'
-    )
-
-    def __init__(self, host: str, port: int, reason: str):
-        self.host: str = host
-        self.port: int = port
-        self.reason: str = reason
-
-    def response(self, _request: HttpParser) -> bytes:
-        return self.RESPONSE_PKT
-
-    def __str__(self) -> str:
-        return '<ProxyConnectionFailed - %s:%s - %s>' % (
-            self.host, self.port, self.reason)
-
-
-class ProxyAuthenticationFailed(ProtocolException):
-    """Exception raised when Http Proxy auth is enabled and
-    incoming request doesn't present necessary credentials."""
-
-    RESPONSE_PKT = HttpParser.build_response(
-        407, reason=b'Proxy Authentication Required',
-        headers={PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE,
-                 b'Connection': b'close',
-                 b'Proxy-Authenticate': b'Basic'},
-        body=b'Proxy Authentication Required')
-
-    def response(self, _request: HttpParser) -> bytes:
-        return self.RESPONSE_PKT
-
-
 class HttpProxyBasePlugin(ABC):
     """Base HttpProxyPlugin Plugin class.
 
@@ -947,16 +1074,16 @@ class HttpProxyBasePlugin(ABC):
             config: ProtocolConfig,
             client: TcpClientConnection,
             request: HttpParser):
-        self.config = config
-        self.client = client
-        self.request = request
+        self.config = config        # pragma: no cover
+        self.client = client        # pragma: no cover
+        self.request = request      # pragma: no cover
 
     def name(self) -> str:
         """A unique name for your plugin.
 
         Defaults to name of the class. This helps plugin developers to directly
         access a specific plugin by its name."""
-        return self.__class__.__name__
+        return self.__class__.__name__      # pragma: no cover
 
     @abstractmethod
     def before_upstream_connection(self) -> bool:
@@ -987,7 +1114,7 @@ class HttpProxyBasePlugin(ABC):
 class HttpProxyPlugin(ProtocolHandlerPlugin):
     """ProtocolHandler plugin which implements HttpProxy specifications."""
 
-    PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT = HttpParser.build_response(
+    PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT = build_http_response(
         200, reason=b'Connection established'
     )
 
@@ -1011,21 +1138,24 @@ class HttpProxyPlugin(ProtocolHandlerPlugin):
                 self.plugins[instance.name()] = instance
 
     def get_descriptors(
-            self) -> Tuple[List[socket.socket], List[socket.socket], List[socket.socket]]:
+            self) -> Tuple[List[socket.socket], List[socket.socket]]:
         if not self.request.has_upstream_server():
-            return [], [], []
+            return [], []
 
         r: List[socket.socket] = []
         w: List[socket.socket] = []
         if self.server and not self.server.closed and self.server.connection:
             r.append(self.server.connection)
-        if self.server and not self.server.closed and self.server.has_buffer() and self.server.connection:
+        if self.server and not self.server.closed and \
+                self.server.has_buffer() and self.server.connection:
             w.append(self.server.connection)
-        return r, w, []
+        return r, w
 
-    def flush_to_descriptors(self, w: List[socket.socket]) -> bool:
+    def write_to_descriptors(self, w: List[Union[int, _HasFileno]]) -> bool:
         if self.request.has_upstream_server() and \
-                self.server and not self.server.closed and self.server.connection in w:
+                self.server and not self.server.closed and \
+                self.server.buffer_size() > 0 and \
+                self.server.connection in w:
             logger.debug('Server is write ready, flushing buffer')
             try:
                 self.server.flush()
@@ -1035,7 +1165,7 @@ class HttpProxyPlugin(ProtocolHandlerPlugin):
                 return True
         return False
 
-    def read_from_descriptors(self, r: List[socket.socket]) -> bool:
+    def read_from_descriptors(self, r: List[Union[int, _HasFileno]]) -> bool:
         if self.request.has_upstream_server(
         ) and self.server and not self.server.closed and self.server.connection in r:
             logger.debug('Server is ready for reads, reading')
@@ -1050,7 +1180,7 @@ class HttpProxyPlugin(ProtocolHandlerPlugin):
 
             # parse incoming response packet
             # only for non-https requests
-            if not self.request.method == b'CONNECT':
+            if not self.request.method == httpMethods.CONNECT:
                 self.response.parse(raw)
             else:
                 self.response.total_size += len(raw)
@@ -1058,18 +1188,52 @@ class HttpProxyPlugin(ProtocolHandlerPlugin):
             self.client.queue(raw)
         return False
 
+    def access_log(self) -> None:
+        server_host, server_port = self.server.addr if self.server else (
+            None, None)
+        if self.request.method == b'CONNECT':
+            logger.info(
+                '%s:%s - %s %s:%s - %s bytes' %
+                (self.client.addr[0],
+                 self.client.addr[1],
+                 text_(
+                     self.request.method),
+                 text_(server_host),
+                 text_(server_port),
+                 self.response.total_size))
+        elif self.request.method:
+            logger.info(
+                '%s:%s - %s %s:%s%s - %s %s - %s bytes' %
+                (self.client.addr[0], self.client.addr[1],
+                 text_(self.request.method),
+                 text_(server_host), server_port,
+                 text_(self.request.build_url()),
+                 text_(self.response.code),
+                 text_(self.response.reason),
+                 self.response.total_size))
+
     def on_client_connection_close(self) -> None:
-        if self.request.has_upstream_server() and self.server:
+        if not self.request.has_upstream_server():
+            return
+        self.access_log()
+        # Invoke plugin.on_upstream_connection_close
+        if self.server and not self.server.closed:
+            for plugin in self.plugins.values():
+                plugin.on_upstream_connection_close()
+            self.server.close()
             logger.debug(
                 'Closed server connection with pending server buffer size %d bytes' %
                 self.server.buffer_size())
-            if not self.server.closed:
-                # Invoke plugin.on_upstream_connection_close
-                for plugin in self.plugins.values():
-                    plugin.on_upstream_connection_close()
-                self.server.close()
 
-    def handle_response_chunk(self, chunk: bytes) -> bytes:
+    def on_response_chunk(self, chunk: bytes) -> bytes:
+        # TODO: Allow to output multiple access_log lines
+        # for each request over a pipelined HTTP connection (not for HTTPS).
+        # However, this must also be accompanied by resetting both request
+        # and response objects.
+        #
+        # if not self.request.method == httpMethods.CONNECT and \
+        #         self.response.state == httpParserStates.COMPLETE:
+        #     self.access_log()
         return chunk
 
     def on_client_data(self, raw: bytes) -> Optional[bytes]:
@@ -1094,8 +1258,7 @@ class HttpProxyPlugin(ProtocolHandlerPlugin):
                 if not os.path.isfile(cert_file_path):
                     logger.debug('Generating certificates %s', cert_file_path)
                     # TODO: Use ssl.get_server_certificate to populate generated certificate metadata
-                    # Currently we only set CN=example.org on the generated
-                    # certificates.
+                    # Currently we only set CN= field for generated certificates.
                     gen_cert = subprocess.Popen(
                         ['/usr/bin/openssl', 'req', '-new', '-key', self.config.ca_signing_key_file, '-subj',
                          '/CN=%s' % text_(self.request.host)],
@@ -1103,7 +1266,7 @@ class HttpProxyPlugin(ProtocolHandlerPlugin):
                         stderr=subprocess.PIPE)
                     sign_cert = subprocess.Popen(
                         ['/usr/bin/openssl', 'x509', '-req', '-days', '365', '-CA', self.config.ca_cert_file, '-CAkey',
-                         self.config.ca_key_file, '-set_serial', str(int(time.time() * 1000)), '-out', cert_file_path],
+                         self.config.ca_key_file, '-set_serial', str(int(time.time())), '-out', cert_file_path],
                         stdin=gen_cert.stdout,
                         stderr=subprocess.PIPE)
                     # TODO: Ensure sign_cert success.
@@ -1117,6 +1280,7 @@ class HttpProxyPlugin(ProtocolHandlerPlugin):
             return False
 
         # Note: can raise HttpRequestRejected exception
+        # Invoke plugin.before_upstream_connection
         for plugin in self.plugins.values():
             teardown = plugin.before_upstream_connection()
             if teardown:
@@ -1128,24 +1292,13 @@ class HttpProxyPlugin(ProtocolHandlerPlugin):
         for plugin in self.plugins.values():
             plugin.on_upstream_connection()
 
-        # for http connect methods (https requests)
-        # queue appropriate response for client
-        # notifying about established connection
-        if self.request.method == b'CONNECT':
+        if self.request.method == httpMethods.CONNECT:
             self.client.queue(
                 HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT)
             # If interception is enabled, generate server certificates
             if self.config.ca_key_file and self.config.ca_cert_file and self.config.ca_signing_key_file:
-                # Flush client buffer before wrapping, but is client ready for
-                # writes?
-                self.client.flush()
                 generated_cert = self.generate_upstream_certificate()
                 if generated_cert:
-                    # If client is communicating over https,
-                    # self.client.conn has already been wrapped before.
-                    # We could unwrap, but then we can't maintain our https
-                    # connection to the client. Below we handle the scenario
-                    # when client is communicating to proxy.py using http.
                     if not (self.config.keyfile and self.config.certfile) and \
                             self.server and isinstance(self.server.connection, socket.socket):
                         self.client._conn = ssl.wrap_socket(
@@ -1158,13 +1311,11 @@ class HttpProxyPlugin(ProtocolHandlerPlugin):
                             ssl.Purpose.SERVER_AUTH)
                         ctx.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1
                         self.server._conn = ctx.wrap_socket(
-                            self.server.connection, server_hostname=text_(
-                                self.request.host))
+                            self.server.connection,
+                            server_hostname=text_(self.request.host))
                         logger.info(
                             'TLS interception using %s', generated_cert)
                         return self.client.connection
-        # for general http requests, re-build request packet
-        # and queue for the server with appropriate headers
         elif self.server:
             # - proxy-connection header is a mistake, it doesn't seem to be
             #   officially documented in any specification, drop it.
@@ -1172,43 +1323,18 @@ class HttpProxyPlugin(ProtocolHandlerPlugin):
             self.request.del_headers(
                 [b'proxy-authorization', b'proxy-connection'])
             # - For HTTP/1.0, connection header defaults to close
-            # - For HTTP/1.1, connection header defaults to keep-alive
-            # Respect headers sent by client instead of manipulating
-            # Connection or Keep-Alive header.  However, note that per
-            # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Connection
-            # connection headers are meant for communication between client and
-            # first intercepting proxy.
-            self.request.add_headers([(b'Via', b'1.1 proxy.py v%s' % version)])
-            # Disable args.disable_headers before dispatching to upstream
-            self.server.queue(
-                self.request.build(
-                    disable_headers=self.config.disable_headers))
-        return False
-
-    def access_log(self) -> None:
-        if not self.request.has_upstream_server():
-            return
-
-        server_host, server_port = self.server.addr if self.server else (None, None)
-        if self.request.method == b'CONNECT':
-            logger.info(
-                '%s:%s - %s %s:%s - %s bytes' %
-                (self.client.addr[0],
-                 self.client.addr[1],
-                 text_(
-                     self.request.method),
-                 text_(server_host),
-                 text_(server_port),
-                 self.response.total_size))
-        elif self.request.method:
-            logger.info(
-                '%s:%s - %s %s:%s%s - %s %s - %s bytes' %
-                (self.client.addr[0], self.client.addr[1], text_(
-                    self.request.method), text_(server_host), server_port,
-                    text_(self.request.build_url()),
-                    text_(self.response.code),
-                    text_(self.response.reason),
-                    self.response.total_size))
+            # - For HTTP/1.1, connection header defaults to keep-alive
+            # Respect headers sent by client instead of manipulating
+            # Connection or Keep-Alive header.  However, note that per
+            # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Connection
+            # connection headers are meant for communication between client and
+            # first intercepting proxy.
+            self.request.add_headers([(b'Via', b'1.1 proxy.py v%s' % version)])
+            # Disable args.disable_headers before dispatching to upstream
+            self.server.queue(
+                self.request.build(
+                    disable_headers=self.config.disable_headers))
+        return False
 
     def authenticate(self) -> None:
         if self.config.auth_code:
@@ -1236,8 +1362,210 @@ class HttpProxyPlugin(ProtocolHandlerPlugin):
             raise ProtocolException()
 
 
-class HttpWebServerRoutePlugin(ABC):
-    """Web Server Route Plugin."""
+class WebsocketFrame:
+    """Websocket frames parser and constructor."""
+
+    GUID = b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
+
+    def __init__(self) -> None:
+        self.reset()
+
+    def reset(self) -> None:
+        self.fin: bool = False
+        self.rsv1: bool = False
+        self.rsv2: bool = False
+        self.rsv3: bool = False
+        self.opcode: int = 0
+        self.masked: bool = False
+        self.payload_length: Optional[int] = None
+        self.mask: Optional[bytes] = None
+        self.data: Optional[bytes] = None
+
+    def parse_fin_and_rsv(self, byte: int) -> None:
+        self.fin = bool(byte & 1 << 7)
+        self.rsv1 = bool(byte & 1 << 6)
+        self.rsv2 = bool(byte & 1 << 5)
+        self.rsv3 = bool(byte & 1 << 4)
+        self.opcode = byte & 0b00001111
+
+    def parse_mask_and_payload(self, byte: int) -> None:
+        self.masked = bool(byte & 0b10000000)
+        self.payload_length = byte & 0b01111111
+
+    def build(self) -> bytes:
+        if self.payload_length is None and self.data:
+            self.payload_length = len(self.data)
+        raw = io.BytesIO()
+        raw.write(
+            struct.pack(
+                '!B',
+                (1 << 7 if self.fin else 0) |
+                (1 << 6 if self.rsv1 else 0) |
+                (1 << 5 if self.rsv2 else 0) |
+                (1 << 4 if self.rsv3 else 0) |
+                self.opcode
+            ))
+        assert self.payload_length is not None
+        if self.payload_length < 126:
+            raw.write(
+                struct.pack(
+                    '!B',
+                    (1 << 7 if self.masked else 0) | self.payload_length
+                )
+            )
+        elif self.payload_length < 1 << 16:
+            raw.write(
+                struct.pack(
+                    '!BH',
+                    (1 << 7 if self.masked else 0) | 126,
+                    self.payload_length
+                )
+            )
+        elif self.payload_length < 1 << 64:
+            raw.write(
+                struct.pack(
+                    '!BHQ',
+                    (1 << 7 if self.masked else 0) | 127,
+                    self.payload_length
+                )
+            )
+        else:
+            raise ValueError(f'Invalid payload_length { self.payload_length },'
+                             f'maximum allowed { 1 << 64 }')
+        if self.masked and self.data:
+            mask = secrets.token_bytes(4) if self.mask is None else self.mask
+            raw.write(mask)
+            raw.write(self.apply_mask(self.data, mask))
+        elif self.data:
+            raw.write(self.data)
+        return raw.getvalue()
+
+    def parse(self, raw: bytes) -> bytes:
+        cur = 0
+        self.parse_fin_and_rsv(raw[cur])
+        cur += 1
+
+        self.parse_mask_and_payload(raw[cur])
+        cur += 1
+
+        if self.payload_length == 126:
+            data = raw[cur: cur + 2]
+            self.payload_length, = struct.unpack('!H', data)
+            cur += 2
+        elif self.payload_length == 127:
+            data = raw[cur: cur + 8]
+            self.payload_length, = struct.unpack('!Q', data)
+            cur += 8
+
+        if self.masked:
+            self.mask = raw[cur: cur + 4]
+            cur += 4
+
+        assert self.payload_length
+        self.data = raw[cur: cur + self.payload_length]
+        cur += self.payload_length
+        if self.masked:
+            assert self.mask is not None
+            self.data = self.apply_mask(self.data, self.mask)
+
+        return raw[cur:]
+
+    @staticmethod
+    def apply_mask(data: bytes, mask: bytes) -> bytes:
+        raw = bytearray(data)
+        for i in range(len(raw)):
+            raw[i] = raw[i] ^ mask[i % 4]
+        return bytes(raw)
+
+    @staticmethod
+    def key_to_accept(key: bytes) -> bytes:
+        sha1 = hashlib.sha1()
+        sha1.update(key + WebsocketFrame.GUID)
+        return base64.b64encode(sha1.digest())
+
+
+class WebsocketClient(TcpConnection):
+
+    def __init__(self,
+                 hostname: Union[ipaddress.IPv4Address, ipaddress.IPv6Address],
+                 port: int,
+                 path: bytes = b'/',
+                 on_message: Optional[Callable[[WebsocketFrame], None]] = None) -> None:
+        super().__init__(tcpConnectionTypes.CLIENT)
+        self.hostname: Union[ipaddress.IPv4Address, ipaddress.IPv6Address] = hostname
+        self.port: int = port
+        self.path: bytes = path
+        self.sock: socket.socket = new_socket_connection((str(self.hostname), self.port))
+        self.on_message: Optional[Callable[[WebsocketFrame], None]] = on_message
+        self.upgrade()
+        self.sock.setblocking(False)
+        self.selector: selectors.DefaultSelector = selectors.DefaultSelector()
+
+    @property
+    def connection(self) -> Union[ssl.SSLSocket, socket.socket]:
+        return self.sock
+
+    def upgrade(self) -> None:
+        key = base64.b64encode(secrets.token_bytes(16))
+        self.sock.send(build_websocket_handshake_request(key, url=self.path))
+        response = HttpParser(httpParserTypes.RESPONSE_PARSER)
+        response.parse(self.sock.recv(DEFAULT_BUFFER_SIZE))
+        accept = response.header(b'Sec-Websocket-Accept')
+        assert WebsocketFrame.key_to_accept(key) == accept
+
+    def ping(self, data: Optional[bytes] = None) -> None:
+        pass
+
+    def pong(self, data: Optional[bytes] = None) -> None:
+        pass
+
+    def shutdown(self, _data: Optional[bytes] = None) -> None:
+        """Closes connection with the server."""
+        super().close()
+
+    def run_once(self) -> bool:
+        ev = selectors.EVENT_READ
+        if self.has_buffer():
+            ev |= selectors.EVENT_WRITE
+        self.selector.register(self.sock.fileno(), ev)
+        events = self.selector.select(timeout=1)
+        self.selector.unregister(self.sock)
+        for key, mask in events:
+            if mask & selectors.EVENT_READ and self.on_message:
+                raw = self.recv()
+                if raw is None or raw == b'':
+                    self.closed = True
+                    logger.debug('Websocket connection closed by server')
+                    return True
+                frame = WebsocketFrame()
+                frame.parse(raw)
+                self.on_message(frame)
+            elif mask & selectors.EVENT_WRITE:
+                logger.debug(self.buffer)
+                self.flush()
+        return False
+
+    def run(self) -> None:
+        logger.debug('running')
+        try:
+            while not self.closed:
+                teardown = self.run_once()
+                if teardown:
+                    break
+        except KeyboardInterrupt:
+            pass
+        finally:
+            try:
+                self.selector.unregister(self.sock)
+                self.sock.shutdown(socket.SHUT_RDWR)
+            except Exception as e:
+                logging.exception('Exception while shutdown of websocket client', exc_info=e)
+            self.sock.close()
+        logger.info('done')
+
+
+class HttpWebServerBasePlugin(ABC):
+    """Web Server Plugin for routing of requests."""
 
     def __init__(
             self,
@@ -1246,82 +1574,216 @@ class HttpWebServerRoutePlugin(ABC):
         self.config = config
         self.client = client
 
+    @abstractmethod
     def routes(self) -> List[Tuple[int, bytes]]:
         """Return List(protocol, path) that this plugin handles."""
-        raise NotImplementedError()
+        raise NotImplementedError()     # pragma: no cover
 
+    @abstractmethod
     def handle_request(self, request: HttpParser) -> None:
         """Handle the request and serve response."""
-        raise NotImplementedError()
+        raise NotImplementedError()     # pragma: no cover
 
+    @abstractmethod
+    def on_websocket_open(self) -> None:
+        """Called when websocket handshake has finished."""
+        raise NotImplementedError()     # pragma: no cover
 
-def route(path: bytes, protocols: Optional[List[int]] = None) -> \
-        Callable[[Callable[[HttpParser], bytes]], Type[HttpWebServerRoutePlugin]]:
-    def decorator(func: Callable[[HttpParser], bytes]) -> Type[HttpWebServerRoutePlugin]:
-        class HttpWebServerRouteHandler(HttpWebServerRoutePlugin):
-            @staticmethod
-            def name() -> str:
-                return func.__name__
+    @abstractmethod
+    def on_websocket_message(self, frame: WebsocketFrame) -> None:
+        """Handle websocket frame."""
+        raise NotImplementedError()     # pragma: no cover
 
-            def routes(self) -> List[Tuple[int, bytes]]:
-                p = protocols
-                if p is None:
-                    p = [httpProtocolTypes.HTTP, httpProtocolTypes.HTTPS]
-                return [(protocol, path) for protocol in p]
+    @abstractmethod
+    def on_websocket_close(self) -> None:
+        """Called when websocket connection has been closed."""
+        raise NotImplementedError()     # pragma: no cover
 
-            def handle_request(self, request: HttpParser) -> None:
-                self.client.queue(func(request))
-                self.client.flush()
-        return HttpWebServerRouteHandler
-    return decorator
 
+class DevtoolsWebsocketPlugin(HttpWebServerBasePlugin):
+    """DevtoolsWebsocketPlugin handles Devtools Frontend websocket requests.
 
-class HttpWebServerPacFilePlugin(HttpWebServerRoutePlugin):
+    For every connected Devtools Frontend instance, a dispatcher thread is
+    started which drains the global Devtools protocol events queue.
+
+    Dispatcher thread is terminated when Devtools Frontend disconnects."""
 
     def __init__(
             self,
             config: ProtocolConfig,
             client: TcpClientConnection):
         super().__init__(config, client)
-        self.pac_file_response: Optional[bytes] = None
-        self.cache_pac_file_response()
+        self.event_dispatcher_thread: Optional[threading.Thread] = None
+        self.event_dispatcher_shutdown: Optional[threading.Event] = None
+
+    def start_dispatcher(self) -> None:
+        self.event_dispatcher_shutdown = threading.Event()
+        assert self.config.devtools_event_queue is not None
+        self.event_dispatcher_thread = threading.Thread(
+            target=DevtoolsWebsocketPlugin.event_dispatcher,
+            args=(self.event_dispatcher_shutdown,
+                  self.config.devtools_event_queue,
+                  self.client))
+        self.event_dispatcher_thread.setDaemon(True)
+        self.event_dispatcher_thread.start()
+
+    def stop_dispatcher(self) -> None:
+        assert self.event_dispatcher_shutdown is not None
+        assert self.event_dispatcher_thread is not None
+        self.event_dispatcher_shutdown.set()
+        self.event_dispatcher_thread.join()
+        logger.debug('Event dispatcher shutdown')
+
+    @staticmethod
+    def event_dispatcher(
+            shutdown: threading.Event,
+            devtools_event_queue: DevtoolsEventQueueType,
+            client: TcpClientConnection) -> None:
+        while not shutdown.is_set():
+            try:
+                ev = devtools_event_queue.get(timeout=1)
+                frame = WebsocketFrame()
+                frame.fin = True
+                frame.opcode = websocketOpcodes.TEXT_FRAME
+                frame.data = bytes_(json.dumps(ev))
+                logger.debug(ev)
+                client.queue(frame.build())
+            except queue.Empty:
+                pass
+            except Exception as e:
+                logger.exception('Event dispatcher exception', exc_info=e)
+                break
+            except KeyboardInterrupt:
+                break
 
     def routes(self) -> List[Tuple[int, bytes]]:
-        if self.config.pac_file_url_path:
-            return [(httpProtocolTypes.HTTP, bytes_(self.config.pac_file_url_path))]
-        return []
+        return [
+            (httpProtocolTypes.WEBSOCKET, self.config.devtools_ws_path)
+        ]
 
     def handle_request(self, request: HttpParser) -> None:
-        if self.config.pac_file and self.pac_file_response:
-            self.client.queue(self.pac_file_response)
-            self.client.flush()
+        pass
+
+    def on_websocket_open(self) -> None:
+        self.start_dispatcher()
+
+    def on_websocket_message(self, frame: WebsocketFrame) -> None:
+        if frame.data:
+            message = json.loads(frame.data)
+            self.handle_message(message)
+        else:
+            logger.debug('No data found in frame')
+
+    def on_websocket_close(self) -> None:
+        self.stop_dispatcher()
+
+    def handle_message(self, message: Dict[str, Any]) -> None:
+        frame = WebsocketFrame()
+        frame.fin = True
+        frame.opcode = websocketOpcodes.TEXT_FRAME
+
+        if message['method'] in (
+            'Page.canScreencast',
+            'Network.canEmulateNetworkConditions',
+            'Emulation.canEmulate'
+        ):
+            data = json.dumps({
+                'id': message['id'],
+                'result': False
+            })
+        elif message['method'] == 'Page.getResourceTree':
+            data = json.dumps({
+                'id': message['id'],
+                'result': {
+                    'frameTree': {
+                        'frame': {
+                            'id': 1,
+                            'url': 'http://proxypy',
+                            'mimeType': 'other',
+                        },
+                        'childFrames': [],
+                        'resources': []
+                    }
+                }
+            })
+        elif message['method'] == 'Network.getResponseBody':
+            logger.debug('received request method Network.getResponseBody')
+            data = json.dumps({
+                'id': message['id'],
+                'result': {
+                    'body': '',
+                    'base64Encoded': False,
+                }
+            })
+        else:
+            data = json.dumps({
+                'id': message['id'],
+                'result': {},
+            })
+
+        frame.data = bytes_(data)
+        self.client.queue(frame.build())
+
+
+class HttpWebServerPacFilePlugin(HttpWebServerBasePlugin):
+
+    def __init__(
+            self,
+            config: ProtocolConfig,
+            client: TcpClientConnection):
+        super().__init__(config, client)
+        self.pac_file_response: Optional[bytes] = None
+        self.cache_pac_file_response()
 
     def cache_pac_file_response(self) -> None:
         if self.config.pac_file:
             try:
                 with open(self.config.pac_file, 'rb') as f:
-                    logger.debug('Will serve pac file from disk')
                     content = f.read()
             except IOError:
-                logger.debug('Will serve pac file content from buffer')
                 content = bytes_(self.config.pac_file)
-            self.pac_file_response = HttpParser.build_response(
+            self.pac_file_response = build_http_response(
                 200, reason=b'OK', headers={
                     b'Content-Type': b'application/x-ns-proxy-autoconfig',
                     b'Connection': b'close'
                 }, body=content
             )
 
+    def routes(self) -> List[Tuple[int, bytes]]:
+        if self.config.pac_file_url_path:
+            return [(httpProtocolTypes.HTTP, bytes_(
+                self.config.pac_file_url_path))]
+        return []   # pragma: no cover
+
+    def handle_request(self, request: HttpParser) -> None:
+        if self.config.pac_file and self.pac_file_response:
+            self.client.queue(self.pac_file_response)
+
+    def on_websocket_open(self) -> None:
+        pass    # pragma: no cover
+
+    def on_websocket_message(self, frame: WebsocketFrame) -> None:
+        pass    # pragma: no cover
+
+    def on_websocket_close(self) -> None:
+        pass    # pragma: no cover
+
 
 class HttpWebServerPlugin(ProtocolHandlerPlugin):
-    """ProtocolHandler plugin which handles incoming requests to local webserver."""
+    """ProtocolHandler plugin which handles incoming requests to local web server."""
 
-    DEFAULT_404_RESPONSE = HttpParser.build_response(
+    DEFAULT_404_RESPONSE = build_http_response(
         404, reason=b'NOT FOUND',
         headers={b'Server': PROXY_AGENT_HEADER_VALUE,
                  b'Connection': b'close'}
     )
 
+    DEFAULT_501_RESPONSE = build_http_response(
+        501, reason=b'NOT IMPLEMENTED',
+        headers={b'Server': PROXY_AGENT_HEADER_VALUE,
+                 b'Connection': b'close'}
+    )
+
     def __init__(
             self,
             config: ProtocolConfig,
@@ -1329,68 +1791,131 @@ class HttpWebServerPlugin(ProtocolHandlerPlugin):
             request: HttpParser):
         super().__init__(config, client, request)
 
-        self.routes: Dict[int, Dict[bytes, HttpWebServerRoutePlugin]] = {
+        self.switched_protocol: Optional[int] = None
+
+        self.routes: Dict[int, Dict[bytes, HttpWebServerBasePlugin]] = {
             httpProtocolTypes.HTTP: {},
             httpProtocolTypes.HTTPS: {},
+            httpProtocolTypes.WEBSOCKET: {},
         }
 
-        if b'HttpWebServerRoutePlugin' in self.config.plugins:
-            for klass in self.config.plugins[b'HttpWebServerRoutePlugin']:
+        if b'HttpWebServerBasePlugin' in self.config.plugins:
+            for klass in self.config.plugins[b'HttpWebServerBasePlugin']:
                 instance = klass(self.config, self.client)
                 for (protocol, path) in instance.routes():
                     self.routes[protocol][path] = instance
 
+    def serve_file_or_404(self, path: str) -> None:
+        try:
+            with open(path, 'rb') as f:
+                content = f.read()
+            content_type = mimetypes.guess_type(path)[0]
+            if content_type is None:
+                content_type = 'text/plain'
+            self.client.queue(build_http_response(
+                200, reason=b'OK', headers={
+                    b'Content-Type': bytes_(content_type),
+                    b'Connection': b'close'
+                }, body=content
+            ))
+        except IOError:
+            self.client.queue(self.DEFAULT_404_RESPONSE)
+
+    def try_upgrade(self) -> bool:
+        if self.request.has_header(b'connection') and \
+                self.request.header(b'connection').lower() == b'upgrade':
+            if self.request.has_header(b'upgrade') and \
+                    self.request.header(b'upgrade').lower() == b'websocket':
+                self.client.queue(
+                    build_websocket_handshake_response(
+                        WebsocketFrame.key_to_accept(
+                            self.request.header(b'Sec-WebSocket-Key'))))
+                self.switched_protocol = httpProtocolTypes.WEBSOCKET
+            else:
+                self.client.queue(self.DEFAULT_501_RESPONSE)
+                return True
+        return False
+
     def on_request_complete(self) -> Union[socket.socket, bool]:
         if self.request.has_upstream_server():
             return False
 
         url = self.request.build_url()
 
-        # Routing
-        if self.request.method != b'CONNECT':
-            registered_routes = self.routes[httpProtocolTypes.HTTP]
-        else:
-            registered_routes = self.routes[httpProtocolTypes.HTTPS]
-        for route_ in registered_routes:
-            if url == route_:
-                registered_routes[route_].handle_request(self.request)
-                # But is client ready for flush?
-                self.client.flush()
+        # If a websocket route exists for the path, try upgrade
+        if url in self.routes[httpProtocolTypes.WEBSOCKET]:
+            # Connection upgrade
+            teardown = self.try_upgrade()
+            if teardown:
+                return True
+
+            # For upgraded connections, nothing more to do
+            if self.switched_protocol:
+                # Invoke plugin.on_websocket_open
+                for r in self.routes[httpProtocolTypes.WEBSOCKET]:
+                    if r == url:
+                        self.routes[httpProtocolTypes.WEBSOCKET][r].on_websocket_open()
+                return False
+
+        # Routing for Http(s) requests
+        protocol = httpProtocolTypes.HTTPS if self.config.certfile and self.config.keyfile else httpProtocolTypes.HTTP
+        for r in self.routes[protocol]:
+            if r == url:
+                self.routes[protocol][r].handle_request(self.request)
+                return True
+
+        # No-route found, try static serving if enabled
+        if self.config.enable_static_server:
+            path = text_(url).split('?')[0]
+            if os.path.isfile(self.config.static_server_dir + path):
+                self.serve_file_or_404(self.config.static_server_dir + path)
                 return True
 
         # Catch all unhandled web server requests, return 404
         self.client.queue(self.DEFAULT_404_RESPONSE)
-        # But is client ready for flush?
-        self.client.flush()
         return True
 
-    def access_log(self) -> None:
-        if self.request.has_upstream_server():
-            return
-        logger.info(
-            '%s:%s - %s %s' %
-            (self.client.addr[0], self.client.addr[1], text_(
-                self.request.method), text_(
-                self.request.build_url())))
-
-    def flush_to_descriptors(self, w: List[socket.socket]) -> bool:
+    def write_to_descriptors(self, w: List[Union[int, _HasFileno]]) -> bool:
         pass
 
-    def read_from_descriptors(self, r: List[socket.socket]) -> bool:
+    def read_from_descriptors(self, r: List[Union[int, _HasFileno]]) -> bool:
         pass
 
     def on_client_data(self, raw: bytes) -> Optional[bytes]:
+        if self.switched_protocol == httpProtocolTypes.WEBSOCKET:
+            url = self.request.build_url()
+            remaining = raw
+            frame = WebsocketFrame()
+            while remaining != b'':
+                # TODO: Teardown if invalid protocol exception
+                remaining = frame.parse(remaining)
+                for r in self.routes[httpProtocolTypes.WEBSOCKET]:
+                    if r == url:
+                        self.routes[httpProtocolTypes.WEBSOCKET][r].on_websocket_message(frame)
+                frame.reset()
+            return None
         return raw
 
-    def handle_response_chunk(self, chunk: bytes) -> bytes:
+    def on_response_chunk(self, chunk: bytes) -> bytes:
         return chunk
 
     def on_client_connection_close(self) -> None:
-        pass
+        if self.request.has_upstream_server():
+            return
+        if self.switched_protocol:
+            # Invoke plugin.on_websocket_close
+            for r in self.routes[httpProtocolTypes.WEBSOCKET]:
+                if r == self.request.build_url():
+                    self.routes[httpProtocolTypes.WEBSOCKET][r].on_websocket_close()
+        logger.info(
+            '%s:%s - %s %s' %
+            (self.client.addr[0], self.client.addr[1], text_(
+                self.request.method), text_(
+                self.request.build_url())))
 
     def get_descriptors(
-            self) -> Tuple[List[socket.socket], List[socket.socket], List[socket.socket]]:
-        return [], [], []
+            self) -> Tuple[List[socket.socket], List[socket.socket]]:
+        return [], []
 
 
 class ProtocolHandler(threading.Thread):
@@ -1402,20 +1927,30 @@ class ProtocolHandler(threading.Thread):
     def __init__(self, fileno: int, addr: Tuple[str, int],
                  config: Optional[ProtocolConfig] = None):
         super().__init__()
+        self.fileno: int = fileno
+        self.addr: Tuple[str, int] = addr
+
         self.start_time: datetime.datetime = self.now()
         self.last_activity: datetime.datetime = self.start_time
 
         self.config: ProtocolConfig = config if config else ProtocolConfig()
         self.request: HttpParser = HttpParser(httpParserTypes.REQUEST_PARSER)
+        self.response: HttpParser = HttpParser(httpParserTypes.RESPONSE_PARSER)
 
-        conn = self.optionally_wrap_socket(self.fromfd(fileno))
-        if conn is None:
-            raise TcpConnectionUninitializedException()
+        self.selector = selectors.DefaultSelector()
         self.client: TcpClientConnection = TcpClientConnection(
-            conn=conn,
-            addr=addr)
-
+            self.fromfd(self.fileno), self.addr
+        )
         self.plugins: Dict[str, ProtocolHandlerPlugin] = {}
+
+    @staticmethod
+    def now() -> datetime.datetime:
+        return datetime.datetime.utcnow()
+
+    def initialize(self) -> None:
+        conn = self.optionally_wrap_socket(self.client.connection)
+        conn.setblocking(False)
+        self.client = TcpClientConnection(conn=conn, addr=self.addr)
         if b'ProtocolHandlerPlugin' in self.config.plugins:
             for klass in self.config.plugins[b'ProtocolHandlerPlugin']:
                 instance = klass(self.config, self.client, self.request)
@@ -1426,32 +1961,28 @@ class ProtocolHandler(threading.Thread):
             fileno, family=socket.AF_INET if self.config.hostname.version == 4 else socket.AF_INET6,
             type=socket.SOCK_STREAM)
 
-    def optionally_wrap_socket(self, conn: socket.socket) -> Optional[Union[ssl.SSLSocket, socket.socket]]:
+    def optionally_wrap_socket(
+            self, conn: socket.socket) -> Union[ssl.SSLSocket, socket.socket]:
         """Attempts to wrap accepted client connection using provided certificates.
 
         Shutdown and closes client connection upon error.
         """
         if self.config.certfile and self.config.keyfile:
-            try:
-                ctx = ssl.create_default_context(
-                    ssl.Purpose.CLIENT_AUTH)
-                ctx.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1
-                ctx.verify_mode = ssl.CERT_NONE
-                ctx.load_cert_chain(
-                    certfile=self.config.certfile,
-                    keyfile=self.config.keyfile)
-                conn = ctx.wrap_socket(conn, server_side=True)
-                return conn
-            except Exception as e:
-                logger.exception('Error encountered', exc_info=e)
-                conn.shutdown(socket.SHUT_RDWR)
-                conn.close()
-                return None
+            ctx = ssl.create_default_context(
+                ssl.Purpose.CLIENT_AUTH)
+            ctx.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1
+            ctx.verify_mode = ssl.CERT_NONE
+            ctx.load_cert_chain(
+                certfile=self.config.certfile,
+                keyfile=self.config.keyfile)
+            conn = ctx.wrap_socket(conn, server_side=True)
         return conn
 
-    @staticmethod
-    def now() -> datetime.datetime:
-        return datetime.datetime.utcnow()
+    def send_server_error(self, e: Exception) -> None:
+        logger.exception('Server error', exc_info=e)
+        self.client.queue(build_http_response(
+            500, b'Server Error'
+        ))
 
     def connection_inactive_for(self) -> int:
         return (self.now() - self.last_activity).seconds
@@ -1460,9 +1991,17 @@ class ProtocolHandler(threading.Thread):
         # TODO: Add input argument option for timeout
         return self.connection_inactive_for() > 30
 
-    def handle_writables(self, writables: List[socket.socket]) -> bool:
-        if self.client.connection in writables:
-            logger.debug('Client is write, flushing buffer')
+    def handle_writables(self, writables: List[Union[int, _HasFileno]]) -> bool:
+        if self.client.buffer_size() > 0 and self.client.connection in writables:
+            logger.debug('Client is ready for writes, flushing buffer')
+
+            # Invoke plugin.on_response_chunk
+            chunk = self.client.buffer
+            for plugin in self.plugins.values():
+                chunk = plugin.on_response_chunk(chunk)
+                if chunk is None:
+                    break
+
             try:
                 self.client.flush()
             except BrokenPipeError:
@@ -1471,7 +2010,7 @@ class ProtocolHandler(threading.Thread):
                 return True
         return False
 
-    def handle_readables(self, readables: List[socket.socket]) -> bool:
+    def handle_readables(self, readables: List[Union[int, _HasFileno]]) -> bool:
         if self.client.connection in readables:
             logger.debug('Client is ready for reads, reading')
             client_data = self.client.recv(self.config.client_recvbuf_size)
@@ -1486,6 +2025,8 @@ class ProtocolHandler(threading.Thread):
             plugins = list(self.plugins.values())
             while plugin_index < len(plugins) and client_data:
                 client_data = plugins[plugin_index].on_client_data(client_data)
+                if client_data is None:
+                    break
                 plugin_index += 1
 
             if client_data:
@@ -1515,40 +2056,46 @@ class ProtocolHandler(threading.Thread):
                     response = e.response(self.request)
                     if response:
                         self.client.queue(response)
-                        # But is client also ready for writes?
-                        self.client.flush()
+                    else:
+                        # If ProtocolException doesn't return a response,
+                        # its a server error
+                        self.send_server_error(e)
                     return True
-                except Exception as e:
-                    raise e
         return False
 
-    def run_once(self) -> bool:
-        """Returns True if proxy must teardown."""
-        # Prepare list of descriptors
-        read_desc: List[socket.socket] = [self.client.connection]
-        write_desc: List[socket.socket] = []
-        err_desc: List[socket.socket] = []
+    def get_events(self) -> Dict[socket.socket, int]:
+        events: Dict[socket.socket, int] = {
+            self.client.connection: selectors.EVENT_READ
+        }
         if self.client.has_buffer():
-            write_desc.append(self.client.connection)
+            events[self.client.connection] |= selectors.EVENT_WRITE
 
         # ProtocolHandlerPlugin.get_descriptors
         for plugin in self.plugins.values():
-            plugin_read_desc, plugin_write_desc, plugin_err_desc = plugin.get_descriptors()
-            read_desc += plugin_read_desc
-            write_desc += plugin_write_desc
-            err_desc += plugin_err_desc
+            plugin_read_desc, plugin_write_desc = plugin.get_descriptors()
+            for r in plugin_read_desc:
+                if r not in events:
+                    events[r] = selectors.EVENT_READ
+                else:
+                    events[r] |= selectors.EVENT_READ
+            for w in plugin_write_desc:
+                if w not in events:
+                    events[w] = selectors.EVENT_WRITE
+                else:
+                    events[w] |= selectors.EVENT_WRITE
 
-        readables, writables, errored = select.select(
-            read_desc, write_desc, err_desc, 1)
+        return events
 
+    def handle_events(self, readables: List[Union[int, _HasFileno]], writables: List[Union[int, _HasFileno]]) -> bool:
+        """Returns True if proxy must teardown."""
         # Flush buffer for ready to write sockets
         teardown = self.handle_writables(writables)
         if teardown:
             return True
 
-        # Invoke plugin.flush_to_descriptors
+        # Invoke plugin.write_to_descriptors
         for plugin in self.plugins.values():
-            teardown = plugin.flush_to_descriptors(writables)
+            teardown = plugin.write_to_descriptors(writables)
             if teardown:
                 return True
 
@@ -1573,40 +2120,252 @@ class ProtocolHandler(threading.Thread):
 
         return False
 
+    def run_once(self) -> bool:
+        events = self.get_events()
+        for fd in events:
+            self.selector.register(fd, events[fd])
+
+        # Select
+        e: List[Tuple[selectors.SelectorKey, int]] = self.selector.select(timeout=1)
+        readables = []
+        writables = []
+        for key, mask in e:
+            if mask & selectors.EVENT_READ:
+                readables.append(key.fileobj)
+            if mask & selectors.EVENT_WRITE:
+                writables.append(key.fileobj)
+
+        teardown = self.handle_events(readables, writables)
+
+        # Unregister
+        for fd in events.keys():
+            self.selector.unregister(fd)
+
+        if teardown:
+            return True
+
+        return False
+
+    def flush(self) -> None:
+        if not self.client.has_buffer():
+            return
+        try:
+            self.selector.register(self.client.connection, selectors.EVENT_WRITE)
+            while self.client.has_buffer():
+                ev: List[Tuple[selectors.SelectorKey, int]] = self.selector.select(timeout=1)
+                if len(ev) == 0:
+                    continue
+                self.client.flush()
+        except BrokenPipeError:
+            pass
+        finally:
+            self.selector.unregister(self.client.connection)
+
     def run(self) -> None:
-        logger.debug('Proxying connection %r' % self.client.connection)
         try:
+            self.initialize()
+            logger.debug('Handling connection %r' % self.client.connection)
+
             while True:
                 teardown = self.run_once()
                 if teardown:
                     break
         except KeyboardInterrupt:  # pragma: no cover
             pass
+        except ssl.SSLError as e:
+            logger.exception('ssl.SSLError', exc_info=e)
         except Exception as e:
             logger.exception(
-                'Exception while handling connection %r with reason %r' %
-                (self.client.connection, e))
+                'Exception while handling connection %r' %
+                self.client.connection, exc_info=e)
         finally:
-            # Invoke plugin.access_log
-            for plugin in self.plugins.values():
-                plugin.access_log()
-
-            if not self.client.closed:
-                try:
-                    self.client.connection.shutdown(socket.SHUT_RDWR)
-                    self.client.close()
-                except OSError:
-                    pass
+            # Flush pending buffer if any
+            self.flush()
 
             # Invoke plugin.on_client_connection_close
             for plugin in self.plugins.values():
                 plugin.on_client_connection_close()
 
             logger.debug(
-                'Closed proxy for connection %r '
+                'Closing proxy for connection %r '
                 'at address %r with pending client buffer size %d bytes' %
                 (self.client.connection, self.client.addr, self.client.buffer_size()))
 
+            if not self.client.closed:
+                try:
+                    self.client.connection.shutdown(socket.SHUT_WR)
+                    logger.debug('Client connection shutdown successful')
+                except OSError:
+                    pass
+                finally:
+                    self.client.connection.close()
+                    logger.debug('Client connection closed')
+
+
+class DevtoolsProtocolPlugin(ProtocolHandlerPlugin):
+    """
+    DevtoolsProtocolPlugin taps into core `ProtocolHandler`
+    events and converts them into Devtools Protocol json messages.
+
+    A DevtoolsProtocolPlugin instance is created per request.
+    Per request devtool events are queued into a global multiprocessing queue.
+    """
+
+    frame_id = secrets.token_hex(8)
+    loader_id = secrets.token_hex(8)
+
+    def __init__(
+            self,
+            config: ProtocolConfig,
+            client: TcpClientConnection,
+            request: HttpParser):
+        self.id: str = f'{ os.getpid() }-{ threading.get_ident() }-{ time.time() }'
+        self.response = HttpParser(httpParserTypes.RESPONSE_PARSER)
+        super().__init__(config, client, request)
+
+    def get_descriptors(self) -> Tuple[List[socket.socket], List[socket.socket]]:
+        return [], []
+
+    def write_to_descriptors(self, w: List[Union[int, _HasFileno]]) -> bool:
+        return False
+
+    def read_from_descriptors(self, r: List[Union[int, _HasFileno]]) -> bool:
+        return False
+
+    def on_client_data(self, raw: bytes) -> Optional[bytes]:
+        return raw
+
+    def on_request_complete(self) -> Union[socket.socket, bool]:
+        if not self.request.has_upstream_server() and \
+                self.request.build_url() == self.config.devtools_ws_path:
+            return False
+
+        # Handle devtool frontend websocket upgrade
+        if self.config.devtools_event_queue:
+            self.config.devtools_event_queue.put({
+                'method': 'Network.requestWillBeSent',
+                'params': self.request_will_be_sent(),
+            })
+        return False
+
+    def on_response_chunk(self, chunk: bytes) -> bytes:
+        if not self.request.has_upstream_server() and \
+                self.request.build_url() == self.config.devtools_ws_path:
+            return chunk
+
+        if self.config.devtools_event_queue:
+            self.response.parse(chunk)
+            if self.response.state >= httpParserStates.HEADERS_COMPLETE:
+                self.config.devtools_event_queue.put({
+                    'method': 'Network.responseReceived',
+                    'params': self.response_received(),
+                })
+            if self.response.state >= httpParserStates.RCVING_BODY:
+                self.config.devtools_event_queue.put({
+                    'method': 'Network.dataReceived',
+                    'params': self.data_received(chunk)
+                })
+            if self.response.state == httpParserStates.COMPLETE:
+                self.config.devtools_event_queue.put({
+                    'method': 'Network.loadingFinished',
+                    'params': self.loading_finished()
+                })
+        return chunk
+
+    def on_client_connection_close(self) -> None:
+        pass
+
+    def request_will_be_sent(self) -> Dict[str, Any]:
+        now = time.time()
+        return {
+            'requestId': self.id,
+            'loaderId': self.loader_id,
+            'documentURL': 'http://proxy-py',
+            'request': {
+                'url': text_(
+                    self.request.build_url()
+                    if self.request.has_upstream_server() else
+                    b'http://' + bytes_(str(self.config.hostname)) +
+                    COLON + bytes_(self.config.port) + self.request.build_url()
+                ),
+                'urlFragment': '',
+                'method': text_(self.request.method),
+                'headers': {text_(v[0]): text_(v[1]) for v in self.request.headers.values()},
+                'initialPriority': 'High',
+                'mixedContentType': 'none',
+                'postData': None if self.request.method != 'POST'
+                else text_(self.request.body)
+            },
+            'timestamp': now - PROXY_PY_START_TIME,
+            'wallTime': now,
+            'initiator': {
+                'type': 'other'
+            },
+            'type': text_(self.request.header(b'content-type'))
+            if self.request.has_header(b'content-type')
+            else 'Other',
+            'frameId': self.frame_id,
+            'hasUserGesture': False
+        }
+
+    def response_received(self) -> Dict[str, Any]:
+        return {
+            'requestId': self.id,
+            'frameId': self.frame_id,
+            'loaderId': self.loader_id,
+            'timestamp': time.time(),
+            'type': text_(self.response.header(b'content-type'))
+            if self.response.has_header(b'content-type')
+            else 'Other',
+            'response': {
+                'url': '',
+                'status': '',
+                'statusText': '',
+                'headers': '',
+                'headersText': '',
+                'mimeType': '',
+                'connectionReused': True,
+                'connectionId': '',
+                'encodedDataLength': '',
+                'fromDiskCache': False,
+                'fromServiceWorker': False,
+                'timing': {
+                    'requestTime': '',
+                    'proxyStart': -1,
+                    'proxyEnd': -1,
+                    'dnsStart': -1,
+                    'dnsEnd': -1,
+                    'connectStart': -1,
+                    'connectEnd': -1,
+                    'sslStart': -1,
+                    'sslEnd': -1,
+                    'workerStart': -1,
+                    'workerReady': -1,
+                    'sendStart': 0,
+                    'sendEnd': 0,
+                    'receiveHeadersEnd': 0,
+                },
+                'requestHeaders': '',
+                'remoteIPAddress': '',
+                'remotePort': '',
+            }
+        }
+
+    def data_received(self, chunk: bytes) -> Dict[str, Any]:
+        return {
+            'requestId': self.id,
+            'timestamp': time.time(),
+            'dataLength': len(chunk),
+            'encodedDataLength': len(chunk),
+        }
+
+    def loading_finished(self) -> Dict[str, Any]:
+        return {
+            'requestId': self.id,
+            'timestamp': time.time(),
+            'encodedDataLength': self.response.total_size
+        }
+
 
 def is_py3() -> bool:
     """Exists only to avoid mocking sys.version_info in tests."""
@@ -1632,24 +2391,29 @@ def load_plugins(plugins: bytes) -> Dict[bytes, List[type]]:
     p: Dict[bytes, List[type]] = {
         b'ProtocolHandlerPlugin': [],
         b'HttpProxyBasePlugin': [],
-        b'HttpWebServerRoutePlugin': [],
+        b'HttpWebServerBasePlugin': [],
     }
-    for plugin in plugins.split(COMMA):
-        plugin = plugin.strip()
-        if plugin == b'':
+    for plugin_ in plugins.split(COMMA):
+        plugin = text_(plugin_.strip())
+        if plugin == '':
             continue
-        module_name, klass_name = plugin.rsplit(DOT, 1)
+        module_name, klass_name = plugin.rsplit(text_(DOT), 1)
         if module_name == 'proxy':
-            klass = getattr(sys.modules[__name__], text_(klass_name))
+            klass = getattr(
+                importlib.import_module(__name__),
+                klass_name)
         else:
-            klass = getattr(importlib.import_module(text_(module_name)), text_(klass_name))
+            klass = getattr(
+                importlib.import_module(module_name),
+                klass_name)
         base_klass = inspect.getmro(klass)[1]
         p[bytes_(base_klass.__name__)].append(klass)
         logger.info(
             'Loaded %s %s.%s',
             'plugin' if klass.__name__ != 'HttpWebServerRouteHandler' else 'route',
-            text_(module_name),
-            # HttpWebServerRouteHandler route decorator adds a special staticmethod to return decorated function name
+            module_name,
+            # HttpWebServerRouteHandler route decorator adds a special
+            # staticmethod to return decorated function name
             klass.__name__ if klass.__name__ != 'HttpWebServerRouteHandler' else klass.name())
     return p
 
@@ -1696,35 +2460,35 @@ def init_parser() -> argparse.ArgumentParser:
     parser.add_argument(
         '--ca-key-file',
         type=str,
-        default=None,
+        default=DEFAULT_CA_KEY_FILE,
         help='Default: None. CA key to use for signing dynamically generated '
              'HTTPS certificates.  If used, must also pass --ca-cert-file and --ca-signing-key-file'
     )
     parser.add_argument(
         '--ca-cert-dir',
         type=str,
-        default=None,
+        default=DEFAULT_CA_CERT_DIR,
         help='Default: ~/.proxy.py. Directory to store dynamically generated certificates. '
              'Also see --ca-key-file, --ca-cert-file and --ca-signing-key-file'
     )
     parser.add_argument(
         '--ca-cert-file',
         type=str,
-        default=None,
+        default=DEFAULT_CA_CERT_FILE,
         help='Default: None. Signing certificate to use for signing dynamically generated '
              'HTTPS certificates.  If used, must also pass --ca-key-file and --ca-signing-key-file'
     )
     parser.add_argument(
         '--ca-signing-key-file',
         type=str,
-        default=None,
+        default=DEFAULT_CA_SIGNING_KEY_FILE,
         help='Default: None. CA signing key to use for dynamic generation of '
              'HTTPS certificates.  If used, must also pass --ca-key-file and --ca-cert-file'
     )
     parser.add_argument(
         '--cert-file',
         type=str,
-        default=None,
+        default=DEFAULT_CERT_FILE,
         help='Default: None. Server certificate to enable end-to-end TLS encryption with clients. '
              'If used, must also pass --key-file.'
     )
@@ -1736,6 +2500,13 @@ def init_parser() -> argparse.ArgumentParser:
              'client in a single recv() operation. Bump this '
              'value for faster uploads at the expense of '
              'increased RAM.')
+    parser.add_argument(
+        '--devtools-ws-path',
+        type=str,
+        default=DEFAULT_DEVTOOLS_WS_PATH,
+        help='Default: /devtools.  Only applicable '
+             'if --enable-devtools is used.'
+    )
     parser.add_argument(
         '--disable-headers',
         type=str,
@@ -1747,6 +2518,21 @@ def init_parser() -> argparse.ArgumentParser:
         action='store_true',
         default=DEFAULT_DISABLE_HTTP_PROXY,
         help='Default: False.  Whether to disable proxy.HttpProxyPlugin.')
+    parser.add_argument(
+        '--enable-devtools',
+        action='store_true',
+        default=DEFAULT_ENABLE_DEVTOOLS,
+        help='Default: False.  Enables integration with Chrome Devtool Frontend.'
+    )
+    parser.add_argument(
+        '--enable-static-server',
+        action='store_true',
+        default=DEFAULT_ENABLE_STATIC_SERVER,
+        help='Default: False.  Enable inbuilt static file server. '
+             'Optionally, also use --static-server-dir to serve static content '
+             'from custom directory.  By default, static file server serves '
+             'from public folder.'
+    )
     parser.add_argument(
         '--enable-web-server',
         action='store_true',
@@ -1759,7 +2545,7 @@ def init_parser() -> argparse.ArgumentParser:
     parser.add_argument(
         '--key-file',
         type=str,
-        default=None,
+        default=DEFAULT_KEY_FILE,
         help='Default: None. Server key file to enable end-to-end TLS encryption with clients. '
              'If used, must also pass --cert-file.'
     )
@@ -1815,6 +2601,14 @@ def init_parser() -> argparse.ArgumentParser:
              'server in a single recv() operation. Bump this '
              'value for faster downloads at the expense of '
              'increased RAM.')
+    parser.add_argument(
+        '--static-server-dir',
+        type=str,
+        default=DEFAULT_STATIC_SERVER_DIR,
+        help='Default: ' + DEFAULT_STATIC_SERVER_DIR + '.  Static server root directory. '
+             'This option is only applicable when static server is also enabled. '
+             'See --enable-static-server.'
+    )
     parser.add_argument(
         '--version',
         '-v',
@@ -1855,6 +2649,24 @@ def main(input_args: List[str]) -> None:
         if args.basic_auth:
             auth_code = b'Basic %s' % base64.b64encode(bytes_(args.basic_auth))
 
+        default_plugins = ''
+        devtools_event_queue: Optional[DevtoolsEventQueueType] = None
+        if args.enable_devtools:
+            default_plugins += 'proxy.DevtoolsProtocolPlugin,'
+            default_plugins += 'proxy.HttpWebServerPlugin,'
+        if not args.disable_http_proxy:
+            default_plugins += 'proxy.HttpProxyPlugin,'
+        if args.enable_web_server or \
+                args.pac_file is not None or \
+                args.enable_static_server:
+            if 'proxy.HttpWebServerPlugin' not in default_plugins:
+                default_plugins += 'proxy.HttpWebServerPlugin,'
+        if args.enable_devtools:
+            default_plugins += 'proxy.DevtoolsWebsocketPlugin,'
+            devtools_event_queue = multiprocessing.Manager().Queue()
+        if args.pac_file is not None:
+            default_plugins += 'proxy.HttpWebServerPacFilePlugin,'
+
         config = ProtocolConfig(
             auth_code=auth_code,
             server_recvbuf_size=args.server_recvbuf_size,
@@ -1873,22 +2685,18 @@ def main(input_args: List[str]) -> None:
             hostname=ipaddress.ip_address(args.hostname),
             port=args.port,
             backlog=args.backlog,
-            num_workers=args.num_workers if args.num_workers > 0 else multiprocessing.cpu_count())
-
-        default_plugins = ''
-        if not args.disable_http_proxy:
-            default_plugins += 'proxy.HttpProxyPlugin,'
-        if args.enable_web_server or config.pac_file is not None:
-            default_plugins += 'proxy.HttpWebServerPlugin,'
-        if config.pac_file is not None:
-            default_plugins += 'proxy.HttpWebServerPacFilePlugin,'
+            num_workers=args.num_workers if args.num_workers > 0 else multiprocessing.cpu_count(),
+            static_server_dir=args.static_server_dir,
+            enable_static_server=args.enable_static_server,
+            devtools_event_queue=devtools_event_queue,
+            devtools_ws_path=args.devtools_ws_path)
 
         config.plugins = load_plugins(
             bytes_(
                 '%s%s' %
                 (default_plugins, args.plugins)))
 
-        server = WorkerPool(
+        acceptor_pool = AcceptorPool(
             hostname=config.hostname,
             port=config.port,
             backlog=config.backlog,
@@ -1897,8 +2705,17 @@ def main(input_args: List[str]) -> None:
             config=config)
         if args.pid_file:
             with open(args.pid_file, 'wb') as pid_file:
-                pid_file.write(bytes_(str(os.getpid())))
-        server.run()
+                pid_file.write(bytes_(os.getpid()))
+        acceptor_pool.setup()
+
+        try:
+            # TODO: Introduce cron feature instead of mindless sleep
+            while True:
+                time.sleep(1)
+        except Exception as e:
+            logger.exception('exception', exc_info=e)
+        finally:
+            acceptor_pool.shutdown()
     except KeyboardInterrupt:  # pragma: no cover
         pass
     finally:
diff --git a/public/devtools/.gitkeep b/public/devtools/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/requirements-release.txt b/requirements-release.txt
index f3c2cd3..2f5418c 100644
--- a/requirements-release.txt
+++ b/requirements-release.txt
@@ -1,1 +1,3 @@
 twine==2.0.0
+wheel==0.33.6
+setuptools==41.4.0
diff --git a/setup.py b/setup.py
index 0935635..a1ec7d7 100644
--- a/setup.py
+++ b/setup.py
@@ -2,10 +2,9 @@
 """
     proxy.py
     ~~~~~~~~
+    ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file.
     
-    Lightweight, Programmable, TLS interceptor Proxy for HTTP(S), HTTP2, WebSockets protocols in a single Python file.
-    
-    :copyright: (c) 2013-present by Abhinav Singh.
+    :copyright: (c) 2013-present by Abhinav Singh and contributors.
     :license: BSD, see LICENSE for more details.
 """
 from setuptools import setup
@@ -13,12 +12,21 @@ import proxy
 
 classifiers = [
     'Development Status :: 5 - Production/Stable',
+    'Environment :: Console',
     'Environment :: No Input/Output (Daemon)',
     'Environment :: Web Environment',
+    'Environment :: MacOS X',
+    'Environment :: Plugins',
+    'Environment :: Win32 (MS Windows)',
+    'Framework :: Robot Framework',
+    'Framework :: Robot Framework :: Library',
     'Intended Audience :: Developers',
     'Intended Audience :: Education',
+    'Intended Audience :: End Users/Desktop',
     'Intended Audience :: System Administrators',
+    'Intended Audience :: Science/Research',
     'License :: OSI Approved :: BSD License',
+    'Natural Language :: English',
     'Operating System :: MacOS',
     'Operating System :: MacOS :: MacOS 9',
     'Operating System :: MacOS :: MacOS X',
@@ -26,16 +34,33 @@ classifiers = [
     'Operating System :: POSIX :: Linux',
     'Operating System :: Unix',
     'Operating System :: Microsoft',
+    'Operating System :: Microsoft :: Windows',
+    'Operating System :: Microsoft :: Windows :: Windows 10',
+    'Operating System :: Android',
     'Operating System :: OS Independent',
+    'Programming Language :: Python :: Implementation',
+    'Programming Language :: Python :: 3 :: Only',
     'Programming Language :: Python :: 3',
     'Programming Language :: Python :: 3.6',
     'Programming Language :: Python :: 3.7',
+    'Programming Language :: Python :: 3.8',
+    'Programming Language :: Python :: 3.9',
+    'Topic :: Internet',
     'Topic :: Internet :: Proxy Servers',
     'Topic :: Internet :: WWW/HTTP',
+    'Topic :: Internet :: WWW/HTTP :: Browsers',
+    'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
+    'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries',
     'Topic :: Internet :: WWW/HTTP :: HTTP Servers',
+    'Topic :: Scientific/Engineering :: Information Analysis',
+    'Topic :: Software Development :: Debuggers',
     'Topic :: Software Development :: Libraries :: Python Modules',
+    'Topic :: System :: Monitoring',
+    'Topic :: System :: Networking',
+    'Topic :: System :: Networking :: Firewalls',
     'Topic :: System :: Networking :: Monitoring',
     'Topic :: Utilities',
+    'Typing :: Typed',
 ]
 
 setup(
@@ -52,5 +77,5 @@ setup(
     license=proxy.__license__,
     py_modules=['proxy'],
     scripts=['proxy.py'],
-    install_requires=[],
+    install_requires=['typing-extensions==3.7.4'],
 )


Test patch:
diff --git a/requirements-testing.txt b/requirements-testing.txt
index 6c53a73..21b5ab7 100644
--- a/requirements-testing.txt
+++ b/requirements-testing.txt
@@ -1,6 +1,7 @@
 python-coveralls==2.9.3
 coverage==4.5.4
 flake8==3.7.8
-pytest==5.1.3
+pytest==5.2.1
 autopep8==1.4.4
 mypy==0.730
+py-spy==0.2.2
diff --git a/tests.py b/tests.py
index d00e075..cb1eab6 100644
--- a/tests.py
+++ b/tests.py
@@ -2,20 +2,21 @@
 """
     proxy.py
     ~~~~~~~~
-    Lightweight, Programmable, TLS interceptor Proxy for HTTP(S), HTTP2, WebSockets protocols in a single Python file.
+    ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file.
 
-    :copyright: (c) 2013-present by Abhinav Singh.
+    :copyright: (c) 2013-present by Abhinav Singh and contributors.
     :license: BSD, see LICENSE for more details.
 """
-import ssl
 import base64
 import errno
+import ipaddress
 import logging
 import multiprocessing
 import os
+import selectors
 import socket
+import ssl
 import tempfile
-import time
 import unittest
 from contextlib import closing
 from http.server import HTTPServer, BaseHTTPRequestHandler
@@ -44,8 +45,28 @@ def get_available_port() -> int:
         return int(port)
 
 
-class TestTcpConnection(unittest.TestCase):
+class TestTextBytes(unittest.TestCase):
+
+    def test_text(self) -> None:
+        self.assertEqual(proxy.text_(b'hello'), 'hello')
+
+    def test_text_int(self) -> None:
+        self.assertEqual(proxy.text_(1), '1')
+
+    def test_text_nochange(self) -> None:
+        self.assertEqual(proxy.text_('hello'), 'hello')
+
+    def test_bytes(self) -> None:
+        self.assertEqual(proxy.bytes_('hello'), b'hello')
+
+    def test_bytes_int(self) -> None:
+        self.assertEqual(proxy.bytes_(1), b'1')
 
+    def test_bytes_nochange(self) -> None:
+        self.assertEqual(proxy.bytes_(b'hello'), b'hello')
+
+
+class TestTcpConnection(unittest.TestCase):
     class TcpConnectionToTest(proxy.TcpConnection):
 
         def __init__(self, conn: Optional[Union[ssl.SSLSocket, socket.socket]] = None,
@@ -59,6 +80,13 @@ class TestTcpConnection(unittest.TestCase):
                 raise proxy.TcpConnectionUninitializedException()
             return self._conn
 
+    def testFlushThrowsBrokenPipeIfClosed(self) -> None:
+        self.conn = TestTcpConnection.TcpConnectionToTest()
+        self.conn.queue(b'some data')
+        self.conn.closed = True
+        with self.assertRaises(BrokenPipeError):
+            self.conn.flush()
+
     def testThrowsKeyErrorIfNoConn(self) -> None:
         self.conn = TestTcpConnection.TcpConnectionToTest()
         with self.assertRaises(proxy.TcpConnectionUninitializedException):
@@ -105,8 +133,15 @@ class TestTcpConnection(unittest.TestCase):
         _conn.close.assert_not_called()
         self.assertTrue(self.conn.closed)
 
+    def testFlushReturnsIfNoBuffer(self) -> None:
+        _conn = mock.MagicMock()
+        self.conn = TestTcpConnection.TcpConnectionToTest(_conn)
+        self.conn.flush()
+        self.assertTrue(not _conn.send.called)
+
     @mock.patch('socket.socket')
-    def testTcpServerEstablishesIPv6Connection(self, mock_socket: mock.Mock) -> None:
+    def testTcpServerEstablishesIPv6Connection(
+            self, mock_socket: mock.Mock) -> None:
         conn = proxy.TcpServerConnection(
             str(proxy.DEFAULT_IPV6_HOSTNAME), proxy.DEFAULT_PORT)
         conn.connect()
@@ -114,8 +149,19 @@ class TestTcpConnection(unittest.TestCase):
         mock_socket.return_value.connect.assert_called_with(
             (str(proxy.DEFAULT_IPV6_HOSTNAME), proxy.DEFAULT_PORT, 0, 0))
 
+    @mock.patch('proxy.new_socket_connection')
+    def testTcpServerIgnoresDoubleConnectSilently(
+            self,
+            mock_new_socket_connection: mock.Mock) -> None:
+        conn = proxy.TcpServerConnection(
+            str(proxy.DEFAULT_IPV6_HOSTNAME), proxy.DEFAULT_PORT)
+        conn.connect()
+        conn.connect()
+        mock_new_socket_connection.assert_called_once()
+
     @mock.patch('socket.socket')
-    def testTcpServerEstablishesIPv4Connection(self, mock_socket: mock.Mock) -> None:
+    def testTcpServerEstablishesIPv4Connection(
+            self, mock_socket: mock.Mock) -> None:
         conn = proxy.TcpServerConnection(
             str(proxy.DEFAULT_IPV4_HOSTNAME), proxy.DEFAULT_PORT)
         conn.connect()
@@ -123,106 +169,225 @@ class TestTcpConnection(unittest.TestCase):
         mock_socket.return_value.connect.assert_called_with(
             (str(proxy.DEFAULT_IPV4_HOSTNAME), proxy.DEFAULT_PORT))
 
+    @mock.patch('proxy.new_socket_connection')
+    def testTcpServerConnectionProperty(
+            self,
+            mock_new_socket_connection: mock.Mock) -> None:
+        conn = proxy.TcpServerConnection(
+            str(proxy.DEFAULT_IPV6_HOSTNAME), proxy.DEFAULT_PORT)
+        conn.connect()
+        self.assertEqual(conn.connection, mock_new_socket_connection.return_value)
+
+    def testTcpServerRaisesTcpConnectionUninitializedException(self) -> None:
+        conn = proxy.TcpServerConnection(
+            str(proxy.DEFAULT_IPV6_HOSTNAME), proxy.DEFAULT_PORT)
+        with self.assertRaises(proxy.TcpConnectionUninitializedException):
+            _ = conn.connection
+
+    def testTcpClientRaisesTcpConnectionUninitializedException(self) -> None:
+        _conn = mock.MagicMock()
+        _addr = mock.MagicMock()
+        conn = proxy.TcpClientConnection(_conn, _addr)
+        conn._conn = None
+        with self.assertRaises(proxy.TcpConnectionUninitializedException):
+            _ = conn.connection
+
 
-class TcpServerUnderTest(proxy.TcpServer):
+class TestSocketConnectionUtils(unittest.TestCase):
 
-    def handle(self, client: proxy.TcpClientConnection) -> None:
-        data = client.recv(proxy.DEFAULT_BUFFER_SIZE)
-        if data != b'HELLO':
-            raise ValueError('Expected HELLO')
-        client.connection.sendall(b'WORLD')
-        client.close()
+    def setUp(self) -> None:
+        self.addr_ipv4 = (str(proxy.DEFAULT_IPV4_HOSTNAME), proxy.DEFAULT_PORT)
+        self.addr_ipv6 = (str(proxy.DEFAULT_IPV6_HOSTNAME), proxy.DEFAULT_PORT)
+        self.addr_dual = ('httpbin.org', 80)
 
-    def setup(self) -> None:
-        pass
+    @mock.patch('socket.socket')
+    def test_new_socket_connection_ipv4(self, mock_socket: mock.Mock) -> None:
+        conn = proxy.new_socket_connection(self.addr_ipv4)
+        mock_socket.assert_called_with(socket.AF_INET, socket.SOCK_STREAM, 0)
+        self.assertEqual(conn, mock_socket.return_value)
+        mock_socket.return_value.connect.assert_called_with(self.addr_ipv4)
 
-    def shutdown(self) -> None:
-        pass
+    @mock.patch('socket.socket')
+    def test_new_socket_connection_ipv6(self, mock_socket: mock.Mock) -> None:
+        conn = proxy.new_socket_connection(self.addr_ipv6)
+        mock_socket.assert_called_with(socket.AF_INET6, socket.SOCK_STREAM, 0)
+        self.assertEqual(conn, mock_socket.return_value)
+        mock_socket.return_value.connect.assert_called_with(
+            (self.addr_ipv6[0], self.addr_ipv6[1], 0, 0))
 
+    @mock.patch('socket.create_connection')
+    def test_new_socket_connection_dual(self, mock_socket: mock.Mock) -> None:
+        conn = proxy.new_socket_connection(self.addr_dual)
+        mock_socket.assert_called_with(self.addr_dual)
+        self.assertEqual(conn, mock_socket.return_value)
 
-@unittest.skipIf(os.getenv('TESTING_ON_TRAVIS', 0),
-                 'Opening sockets not allowed on Travis')
-class TestTcpServerIntegration(unittest.TestCase):
+    @mock.patch('proxy.new_socket_connection')
+    def test_decorator(self, mock_new_socket_connection: mock.Mock) -> None:
+        @proxy.socket_connection(self.addr_ipv4)
+        def dummy(conn: socket.socket) -> None:
+            self.assertEqual(conn, mock_new_socket_connection.return_value)
+        dummy()     # type: ignore
 
-    ipv4_port: Optional[int] = None
-    ipv6_port: Optional[int] = None
-    ipv4_server: Optional[TcpServerUnderTest] = None
-    ipv6_server: Optional[TcpServerUnderTest] = None
-    ipv4_thread: Optional[Thread] = None
-    ipv6_thread: Optional[Thread] = None
+    @mock.patch('proxy.new_socket_connection')
+    def test_context_manager(self, mock_new_socket_connection: mock.Mock) -> None:
+        with proxy.socket_connection(self.addr_ipv4) as conn:
+            self.assertEqual(conn, mock_new_socket_connection.return_value)
 
-    @classmethod
-    def setUpClass(cls) -> None:
-        cls.ipv4_port = get_available_port()
-        cls.ipv6_port = get_available_port()
-        cls.ipv4_server = TcpServerUnderTest(
-            hostname=proxy.DEFAULT_IPV4_HOSTNAME,
-            port=cls.ipv4_port)
-        cls.ipv6_server = TcpServerUnderTest(
-            hostname=proxy.DEFAULT_IPV6_HOSTNAME,
-            port=cls.ipv6_port)
-        cls.ipv4_thread = Thread(target=cls.ipv4_server.run)
-        cls.ipv6_thread = Thread(target=cls.ipv6_server.run)
-        cls.ipv4_thread.setDaemon(True)
-        cls.ipv6_thread.setDaemon(True)
-        cls.ipv4_thread.start()
-        cls.ipv6_thread.start()
 
-    @classmethod
-    def tearDownClass(cls) -> None:
-        if cls.ipv4_server:
-            cls.ipv4_server.stop()
-        if cls.ipv4_thread:
-            cls.ipv4_thread.join()
-        if cls.ipv6_server:
-            cls.ipv6_server.stop()
-        if cls.ipv6_thread:
-            cls.ipv6_thread.join()
-
-    def baseTestCase(self, ipv4: bool = True) -> None:
-        while True:
-            sock = None
-            try:
-                sock = socket.socket(
-                    socket.AF_INET if ipv4 else socket.AF_INET6,
-                    socket.SOCK_STREAM,
-                    0)
-                sock.connect(
-                    (str(
-                        proxy.DEFAULT_IPV4_HOSTNAME if ipv4 else proxy.DEFAULT_IPV6_HOSTNAME),
-                     self.ipv4_port if ipv4 else self.ipv6_port))
-                sock.sendall(b'HELLO')
-                data = sock.recv(proxy.DEFAULT_BUFFER_SIZE)
-                self.assertEqual(data, b'WORLD')
-                break
-            except ConnectionRefusedError:
-                time.sleep(0.1)
-            finally:
-                if sock:
-                    sock.close()
-
-    def testIpv4ClientConnection(self) -> None:
-        self.baseTestCase()
-
-    def testIpv6ClientConnection(self) -> None:
-        self.baseTestCase(ipv4=False)
-
-
-class TestTcpServer(unittest.TestCase):
-
-    # Can happen if client is sending invalid https request to our SSL server.
-    # Example, simply sending http traffic to HTTPS server.
-    @mock.patch('select.select')
+class TestAcceptorPool(unittest.TestCase):
+
+    @mock.patch('proxy.send_handle')
+    @mock.patch('multiprocessing.Pipe')
     @mock.patch('socket.socket')
-    def testAcceptSSLErrorsSilentlyIgnored(self, mock_socket: mock.Mock, mock_select: mock.Mock) -> None:
-        mock_socket.accept.side_effect = ssl.SSLError()
-        mock_select.return_value = ([mock_socket], [], [])
-        server = TcpServerUnderTest(hostname=proxy.DEFAULT_IPV6_HOSTNAME, port=1234)
-        server.socket = mock_socket
-        with mock.patch('proxy.logger') as mock_logger:
-            server.run_once()
-            mock_logger.exception.assert_called()
-            self.assertTrue(mock_logger.exception.call_args[0][0], 'SSLError encountered')
+    @mock.patch('proxy.Worker')
+    def test_setup_and_shutdown(
+            self,
+            mock_worker: mock.Mock,
+            mock_socket: mock.Mock,
+            mock_pipe: mock.Mock,
+            _mock_send_handle: mock.Mock) -> None:
+        mock_worker1 = mock.MagicMock()
+        mock_worker2 = mock.MagicMock()
+        mock_worker.side_effect = [mock_worker1, mock_worker2]
+
+        num_workers = 2
+        sock = mock_socket.return_value
+        work_klass = mock.MagicMock()
+        kwargs = {'config': proxy.ProtocolConfig()}
+        acceptor = proxy.AcceptorPool(
+            ipaddress.ip_address(proxy.DEFAULT_IPV6_HOSTNAME),
+            proxy.DEFAULT_PORT,
+            proxy.DEFAULT_BACKLOG,
+            num_workers,
+            work_klass=work_klass,
+            **kwargs
+        )
+
+        acceptor.setup()
+
+        mock_socket.assert_called_with(
+            socket.AF_INET6 if acceptor.hostname.version == 6 else socket.AF_INET,
+            socket.SOCK_STREAM
+        )
+        sock.setsockopt.assert_called_with(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+        sock.bind.assert_called_with((str(acceptor.hostname), acceptor.port))
+        sock.listen.assert_called_with(acceptor.backlog)
+        sock.setblocking.assert_called_with(False)
+        sock.settimeout.assert_called_with(0)
+
+        self.assertTrue(mock_pipe.call_count, num_workers)
+        self.assertTrue(mock_worker.call_count, num_workers)
+        mock_worker1.start.assert_called()
+        mock_worker1.join.assert_not_called()
+        mock_worker2.start.assert_called()
+        mock_worker2.join.assert_not_called()
+
+        sock.close.assert_called()
+
+        acceptor.shutdown()
+        mock_worker1.join.assert_called()
+        mock_worker2.join.assert_called()
+
+
+class TestWorker(unittest.TestCase):
+
+    @mock.patch('proxy.ProtocolHandler')
+    def setUp(self, mock_protocol_handler: mock.Mock) -> None:
+        self.pipe = multiprocessing.Pipe()
+        self.protocol_config = proxy.ProtocolConfig()
+        self.worker = proxy.Worker(
+            self.pipe[1],
+            mock_protocol_handler,
+            config=self.protocol_config)
+        self.mock_protocol_handler = mock_protocol_handler
+
+    @mock.patch('selectors.DefaultSelector')
+    @mock.patch('socket.fromfd')
+    @mock.patch('proxy.recv_handle')
+    def test_continues_when_no_events(
+            self,
+            mock_recv_handle: mock.Mock,
+            mock_fromfd: mock.Mock,
+            mock_selector: mock.Mock) -> None:
+        fileno = 10
+        conn = mock.MagicMock()
+        addr = mock.MagicMock()
+        sock = mock_fromfd.return_value
+        mock_fromfd.return_value.accept.return_value = (conn, addr)
+        mock_recv_handle.return_value = fileno
+
+        selector = mock_selector.return_value
+        selector.select.side_effect = [[], KeyboardInterrupt()]
+
+        self.pipe[0].send(socket.AF_INET6)
+        self.worker.run()
+
+        sock.accept.assert_not_called()
+        self.mock_protocol_handler.assert_not_called()
+
+    @mock.patch('selectors.DefaultSelector')
+    @mock.patch('socket.fromfd')
+    @mock.patch('proxy.recv_handle')
+    def test_worker_doesnt_teardown_on_blocking_io_error(
+            self,
+            mock_recv_handle: mock.Mock,
+            mock_fromfd: mock.Mock,
+            mock_selector: mock.Mock) -> None:
+        fileno = 10
+        conn = mock.MagicMock()
+        addr = mock.MagicMock()
+        sock = mock_fromfd.return_value
+        mock_fromfd.return_value.accept.return_value = (conn, addr)
+        mock_recv_handle.return_value = fileno
+
+        selector = mock_selector.return_value
+        selector.select.side_effect = [(None, None), KeyboardInterrupt()]
+        sock.accept.side_effect = BlockingIOError()
+
+        self.pipe[0].send(socket.AF_INET6)
+        self.worker.run()
+
+        self.mock_protocol_handler.assert_not_called()
+
+    @mock.patch('selectors.DefaultSelector')
+    @mock.patch('socket.fromfd')
+    @mock.patch('proxy.recv_handle')
+    def test_accepts_client_from_server_socket(
+            self,
+            mock_recv_handle: mock.Mock,
+            mock_fromfd: mock.Mock,
+            mock_selector: mock.Mock) -> None:
+        fileno = 10
+        conn = mock.MagicMock()
+        addr = mock.MagicMock()
+        sock = mock_fromfd.return_value
+        mock_fromfd.return_value.accept.return_value = (conn, addr)
+        mock_recv_handle.return_value = fileno
+
+        self.mock_protocol_handler.return_value.start.side_effect = KeyboardInterrupt()
+
+        selector = mock_selector.return_value
+        selector.select.return_value = [(None, None)]
+
+        self.pipe[0].send(socket.AF_INET6)
+        self.worker.run()
+
+        selector.register.assert_called_with(sock, selectors.EVENT_READ)
+        selector.unregister.assert_called_with(sock)
+        mock_recv_handle.assert_called_with(self.pipe[1])
+        mock_fromfd.assert_called_with(
+            fileno,
+            family=socket.AF_INET6,
+            type=socket.SOCK_STREAM
+        )
+        self.mock_protocol_handler.assert_called_with(
+            fileno=conn.fileno(),
+            addr=addr,
+            **{'config': self.protocol_config}
+        )
+        self.mock_protocol_handler.return_value.setDaemon.assert_called()
+        self.mock_protocol_handler.return_value.start.assert_called()
+        sock.close.assert_called()
 
 
 class TestChunkParser(unittest.TestCase):
@@ -296,6 +461,9 @@ class TestChunkParser(unittest.TestCase):
         self.assertEqual(self.parser.body, b'abcdefg')
         self.assertEqual(self.parser.state, proxy.chunkParserStates.COMPLETE)
 
+    def test_to_chunks(self) -> None:
+        self.assertEqual(b'f\r\n{"key":"value"}\r\n0\r\n\r\n', proxy.ChunkParser.to_chunks(b'{"key":"value"}'))
+
 
 class TestHttpParser(unittest.TestCase):
 
@@ -304,23 +472,24 @@ class TestHttpParser(unittest.TestCase):
 
     def test_build_request(self) -> None:
         self.assertEqual(
-            proxy.HttpParser.build_request(b'GET', b'http://localhost:12345', b'HTTP/1.1'),
+            proxy.build_http_request(
+                b'GET', b'http://localhost:12345', b'HTTP/1.1'),
             proxy.CRLF.join([
                 b'GET http://localhost:12345 HTTP/1.1',
                 proxy.CRLF
             ]))
         self.assertEqual(
-            proxy.HttpParser.build_request(b'GET', b'http://localhost:12345', b'HTTP/1.1',
-                                           headers={b'key': b'value'}),
+            proxy.build_http_request(b'GET', b'http://localhost:12345', b'HTTP/1.1',
+                                     headers={b'key': b'value'}),
             proxy.CRLF.join([
                 b'GET http://localhost:12345 HTTP/1.1',
                 b'key: value',
                 proxy.CRLF
             ]))
         self.assertEqual(
-            proxy.HttpParser.build_request(b'GET', b'http://localhost:12345', b'HTTP/1.1',
-                                           headers={b'key': b'value'},
-                                           body=b'Hello from proxy.py'),
+            proxy.build_http_request(b'GET', b'http://localhost:12345', b'HTTP/1.1',
+                                     headers={b'key': b'value'},
+                                     body=b'Hello from proxy.py'),
             proxy.CRLF.join([
                 b'GET http://localhost:12345 HTTP/1.1',
                 b'key: value',
@@ -329,14 +498,15 @@ class TestHttpParser(unittest.TestCase):
 
     def test_build_response(self) -> None:
         self.assertEqual(
-            proxy.HttpParser.build_response(200, reason=b'OK', protocol_version=b'HTTP/1.1'),
+            proxy.build_http_response(
+                200, reason=b'OK', protocol_version=b'HTTP/1.1'),
             proxy.CRLF.join([
                 b'HTTP/1.1 200 OK',
                 proxy.CRLF
             ]))
         self.assertEqual(
-            proxy.HttpParser.build_response(200, reason=b'OK', protocol_version=b'HTTP/1.1',
-                                            headers={b'key': b'value'}),
+            proxy.build_http_response(200, reason=b'OK', protocol_version=b'HTTP/1.1',
+                                      headers={b'key': b'value'}),
             proxy.CRLF.join([
                 b'HTTP/1.1 200 OK',
                 b'key: value',
@@ -346,31 +516,44 @@ class TestHttpParser(unittest.TestCase):
     def test_build_response_adds_content_length_header(self) -> None:
         body = b'Hello world!!!'
         self.assertEqual(
-            proxy.HttpParser.build_response(200, reason=b'OK', protocol_version=b'HTTP/1.1',
-                                            headers={b'key': b'value'},
-                                            body=body),
+            proxy.build_http_response(200, reason=b'OK', protocol_version=b'HTTP/1.1',
+                                      headers={b'key': b'value'},
+                                      body=body),
             proxy.CRLF.join([
                 b'HTTP/1.1 200 OK',
                 b'key: value',
-                b'Content-Length: ' + proxy.bytes_(str(len(body))),
+                b'Content-Length: ' + proxy.bytes_(len(body)),
                 proxy.CRLF
             ]) + body)
 
     def test_build_header(self) -> None:
         self.assertEqual(
-            proxy.HttpParser.build_header(
+            proxy.build_http_header(
                 b'key', b'value'), b'key: value')
 
+    def test_header_raises(self) -> None:
+        with self.assertRaises(KeyError):
+            self.parser.header(b'not-found')
+
+    def test_has_header(self) -> None:
+        self.parser.add_header(b'key', b'value')
+        self.assertFalse(self.parser.has_header(b'not-found'))
+        self.assertTrue(self.parser.has_header(b'key'))
+
+    def test_set_host_port_raises(self) -> None:
+        with self.assertRaises(KeyError):
+            self.parser.set_host_port()
+
     def test_find_line(self) -> None:
         self.assertEqual(
-            proxy.HttpParser.find_line(
+            proxy.find_http_line(
                 b'CONNECT python.org:443 HTTP/1.0\r\n\r\n'),
             (b'CONNECT python.org:443 HTTP/1.0',
              proxy.CRLF))
 
     def test_find_line_returns_None(self) -> None:
         self.assertEqual(
-            proxy.HttpParser.find_line(b'CONNECT python.org:443 HTTP/1.0'),
+            proxy.find_http_line(b'CONNECT python.org:443 HTTP/1.0'),
             (None,
              b'CONNECT python.org:443 HTTP/1.0'))
 
@@ -695,12 +878,67 @@ class TestHttpParser(unittest.TestCase):
         self.assertEqual(self.parser.body, b'Wikipedia in\r\n\r\nchunks.')
         self.assertEqual(self.parser.state, proxy.httpParserStates.COMPLETE)
 
+    def test_chunked_request_parse(self) -> None:
+        self.parser.type = proxy.httpParserTypes.REQUEST_PARSER
+        self.parser.parse(proxy.build_http_request(
+            proxy.httpMethods.POST, b'http://example.org/',
+            headers={
+                b'Transfer-Encoding': b'chunked',
+                b'Content-Type': b'application/json',
+            },
+            body=b'f\r\n{"key":"value"}\r\n0\r\n\r\n'))
+        self.assertEqual(self.parser.body, b'{"key":"value"}')
+        self.assertEqual(self.parser.state, proxy.httpParserStates.COMPLETE)
+
     def assertDictContainsSubset(self, subset: Dict[bytes, Tuple[bytes, bytes]],
                                  dictionary: Dict[bytes, Tuple[bytes, bytes]]) -> None:
         for k in subset.keys():
             self.assertTrue(k in dictionary)
 
 
+class TestWebsocketFrame(unittest.TestCase):
+
+    def test_build_with_mask(self) -> None:
+        raw = b'\x81\x85\xc6\ti\x8d\xael\x05\xe1\xa9'
+        frame = proxy.WebsocketFrame()
+        frame.fin = True
+        frame.opcode = proxy.websocketOpcodes.TEXT_FRAME
+        frame.masked = True
+        frame.mask = b'\xc6\ti\x8d'
+        frame.data = b'hello'
+        self.assertEqual(frame.build(), raw)
+
+    def test_parse_with_mask(self) -> None:
+        raw = b'\x81\x85\xc6\ti\x8d\xael\x05\xe1\xa9'
+        frame = proxy.WebsocketFrame()
+        frame.parse(raw)
+        self.assertEqual(frame.fin, True)
+        self.assertEqual(frame.rsv1, False)
+        self.assertEqual(frame.rsv2, False)
+        self.assertEqual(frame.rsv3, False)
+        self.assertEqual(frame.opcode, 0x1)
+        self.assertEqual(frame.masked, True)
+        assert frame.mask is not None
+        self.assertEqual(frame.mask, b'\xc6\ti\x8d')
+        self.assertEqual(frame.payload_length, 5)
+        self.assertEqual(frame.data, b'hello')
+
+
+class TestWebsocketClient(unittest.TestCase):
+
+    @mock.patch('base64.b64encode')
+    @mock.patch('proxy.new_socket_connection')
+    def test_handshake(self, mock_connect: mock.Mock, mock_b64encode: mock.Mock) -> None:
+        key = b'MySecretKey'
+        mock_b64encode.return_value = key
+        mock_connect.return_value.recv.return_value = \
+            proxy.build_websocket_handshake_response(proxy.WebsocketFrame.key_to_accept(key))
+        _ = proxy.WebsocketClient(proxy.DEFAULT_IPV4_HOSTNAME, 8899)
+        mock_connect.return_value.send.assert_called_with(
+            proxy.build_websocket_handshake_request(key)
+        )
+
+
 class TestHttpProtocolHandler(unittest.TestCase):
     http_server = None
     http_server_port = None
@@ -737,22 +975,23 @@ class TestHttpProtocolHandler(unittest.TestCase):
         if cls.http_server_thread:
             cls.http_server_thread.join()
 
+    @mock.patch('selectors.DefaultSelector')
     @mock.patch('socket.fromfd')
-    def setUp(self, mock_fromfd: mock.Mock) -> None:
+    def setUp(self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None:
         self.fileno = 10
         self._addr = ('127.0.0.1', 54382)
         self._conn = mock_fromfd.return_value
-        self.proxy = proxy.ProtocolHandler(self.fileno, self._addr, config=self.config)
+        self.mock_selector = mock_selector
+        self.proxy = proxy.ProtocolHandler(
+            self.fileno, self._addr, config=self.config)
+        self.proxy.initialize()
 
-    @mock.patch('select.select')
     @mock.patch('proxy.TcpServerConnection')
-    def test_http_get(self, mock_server_connection: mock.Mock, mock_select: mock.Mock) -> None:
+    def test_http_get(self, mock_server_connection: mock.Mock) -> None:
         server = mock_server_connection.return_value
         server.connect.return_value = True
-        mock_select.side_effect = [
-            ([self._conn], [], []),
-            ([self._conn], [], []),
-            ([], [server.connection], [])]
+        server.buffer_size.return_value = 0
+        self.mock_selector_for_client_read_read_server_write(self.mock_selector, server)
 
         # Send request line
         assert self.http_server_port is not None
@@ -779,9 +1018,11 @@ class TestHttpProtocolHandler(unittest.TestCase):
         self.proxy.run_once()
         server.flush.assert_called_once()
 
-    def assert_tunnel_response(self, mock_server_connection: mock.Mock, server: mock.Mock) -> None:
+    def assert_tunnel_response(
+            self, mock_server_connection: mock.Mock, server: mock.Mock) -> None:
         self.proxy.run_once()
-        self.assertTrue(self.proxy.plugins['HttpProxyPlugin'].server is not None)  # type: ignore
+        self.assertTrue(
+            self.proxy.plugins['HttpProxyPlugin'].server is not None)  # type: ignore
         self.assertEqual(
             self.proxy.client.buffer,
             proxy.HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT)
@@ -796,17 +1037,33 @@ class TestHttpProtocolHandler(unittest.TestCase):
         assert parser.code is not None
         self.assertEqual(int(parser.code), 200)
 
-    @mock.patch('select.select')
     @mock.patch('proxy.TcpServerConnection')
-    def test_http_tunnel(self, mock_server_connection: mock.Mock, mock_select: mock.Mock) -> None:
+    def test_http_tunnel(self, mock_server_connection: mock.Mock) -> None:
         server = mock_server_connection.return_value
         server.connect.return_value = True
+        server.buffer_size.return_value = 0
         server.has_buffer.side_effect = [False, False, False, True]
-        mock_select.side_effect = [
-            ([self._conn], [], []),         # client read ready
-            ([], [self._conn], []),         # client write ready
-            ([self._conn], [], []),         # client read ready
-            ([], [server.connection], [])   # server write ready
+        self.mock_selector.return_value.select.side_effect = [
+            [(selectors.SelectorKey(
+                fileobj=self._conn,
+                fd=self._conn.fileno,
+                events=selectors.EVENT_READ,
+                data=None), selectors.EVENT_READ), ],
+            [(selectors.SelectorKey(
+                fileobj=self._conn,
+                fd=self._conn.fileno,
+                events=0,
+                data=None), selectors.EVENT_WRITE), ],
+            [(selectors.SelectorKey(
+                fileobj=self._conn,
+                fd=self._conn.fileno,
+                events=selectors.EVENT_READ,
+                data=None), selectors.EVENT_READ), ],
+            [(selectors.SelectorKey(
+                fileobj=server.connection,
+                fd=server.connection.fileno,
+                events=0,
+                data=None), selectors.EVENT_WRITE), ],
         ]
 
         assert self.http_server_port is not None
@@ -827,29 +1084,30 @@ class TestHttpProtocolHandler(unittest.TestCase):
         self.assertEqual(server.queue.call_count, 1)
         server.flush.assert_called_once()
 
-    @mock.patch('select.select')
-    def test_proxy_connection_failed(self, mock_select: mock.Mock) -> None:
-        mock_select.return_value = ([self._conn], [], [])
+    def test_proxy_connection_failed(self) -> None:
+        self.mock_selector_for_client_read(self.mock_selector)
         self._conn.recv.return_value = proxy.CRLF.join([
             b'GET http://unknown.domain HTTP/1.1',
             b'Host: unknown.domain',
             proxy.CRLF
         ])
         self.proxy.run_once()
-        received = self._conn.send.call_args[0][0]
-        self.assertEqual(received, proxy.ProxyConnectionFailed.RESPONSE_PKT)
+        self.assertEqual(self.proxy.client.buffer, proxy.ProxyConnectionFailed.RESPONSE_PKT)
 
+    @mock.patch('selectors.DefaultSelector')
     @mock.patch('socket.fromfd')
-    @mock.patch('select.select')
-    def test_proxy_authentication_failed(self, mock_select: mock.Mock, mock_fromfd: mock.Mock) -> None:
+    def test_proxy_authentication_failed(
+            self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None:
         self._conn = mock_fromfd.return_value
-        mock_select.return_value = ([self._conn], [], [])
+        self.mock_selector_for_client_read(mock_selector)
         config = proxy.ProtocolConfig(
             auth_code=b'Basic %s' %
                       base64.b64encode(b'user:pass'))
         config.plugins = proxy.load_plugins(
             b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin')
-        self.proxy = proxy.ProtocolHandler(self.fileno, self._addr, config=config)
+        self.proxy = proxy.ProtocolHandler(
+            self.fileno, self._addr, config=config)
+        self.proxy.initialize()
         self._conn.recv.return_value = proxy.CRLF.join([
             b'GET http://abhinavsingh.com HTTP/1.1',
             b'Host: abhinavsingh.com',
@@ -857,20 +1115,22 @@ class TestHttpProtocolHandler(unittest.TestCase):
         ])
         self.proxy.run_once()
         self.assertEqual(
-            self._conn.send.call_args[0][0],
+            self.proxy.client.buffer,
             proxy.ProxyAuthenticationFailed.RESPONSE_PKT)
 
+    @mock.patch('selectors.DefaultSelector')
     @mock.patch('socket.fromfd')
-    @mock.patch('select.select')
     @mock.patch('proxy.TcpServerConnection')
     def test_authenticated_proxy_http_get(
             self, mock_server_connection: mock.Mock,
-            mock_select: mock.Mock,
-            mock_fromfd: mock.Mock) -> None:
+            mock_fromfd: mock.Mock,
+            mock_selector: mock.Mock) -> None:
         self._conn = mock_fromfd.return_value
-        mock_select.return_value = ([self._conn], [], [])
+        self.mock_selector_for_client_read(mock_selector)
+
         server = mock_server_connection.return_value
         server.connect.return_value = True
+        server.buffer_size.return_value = 0
 
         config = proxy.ProtocolConfig(
             auth_code=b'Basic %s' %
@@ -878,7 +1138,9 @@ class TestHttpProtocolHandler(unittest.TestCase):
         config.plugins = proxy.load_plugins(
             b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin')
 
-        self.proxy = proxy.ProtocolHandler(self.fileno, addr=self._addr, config=config)
+        self.proxy = proxy.ProtocolHandler(
+            self.fileno, addr=self._addr, config=config)
+        self.proxy.initialize()
         assert self.http_server_port is not None
 
         self._conn.recv.return_value = b'GET http://localhost:%d HTTP/1.1' % self.http_server_port
@@ -904,19 +1166,18 @@ class TestHttpProtocolHandler(unittest.TestCase):
         ])
         self.assert_data_queued(mock_server_connection, server)
 
+    @mock.patch('selectors.DefaultSelector')
     @mock.patch('socket.fromfd')
-    @mock.patch('select.select')
     @mock.patch('proxy.TcpServerConnection')
     def test_authenticated_proxy_http_tunnel(
             self, mock_server_connection: mock.Mock,
-            mock_select: mock.Mock,
-            mock_fromfd: mock.Mock) -> None:
+            mock_fromfd: mock.Mock,
+            mock_selector: mock.Mock) -> None:
         server = mock_server_connection.return_value
         server.connect.return_value = True
-
+        server.buffer_size.return_value = 0
         self._conn = mock_fromfd.return_value
-        mock_select.side_effect = [
-            ([self._conn], [], []), ([self._conn], [], []), ([], [server.connection], [])]
+        self.mock_selector_for_client_read_read_server_write(mock_selector, server)
 
         config = proxy.ProtocolConfig(
             auth_code=b'Basic %s' %
@@ -924,7 +1185,9 @@ class TestHttpProtocolHandler(unittest.TestCase):
         config.plugins = proxy.load_plugins(
             b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin')
 
-        self.proxy = proxy.ProtocolHandler(self.fileno, self._addr, config=config)
+        self.proxy = proxy.ProtocolHandler(
+            self.fileno, self._addr, config=config)
+        self.proxy.initialize()
 
         assert self.http_server_port is not None
         self._conn.recv.return_value = proxy.CRLF.join([
@@ -942,91 +1205,223 @@ class TestHttpProtocolHandler(unittest.TestCase):
         self.proxy.run_once()
         server.flush.assert_called_once()
 
+    def mock_selector_for_client_read_read_server_write(self, mock_selector: mock.Mock, server: mock.Mock) -> None:
+        mock_selector.return_value.select.side_effect = [
+            [(selectors.SelectorKey(
+                fileobj=self._conn,
+                fd=self._conn.fileno,
+                events=selectors.EVENT_READ,
+                data=None), selectors.EVENT_READ), ],
+            [(selectors.SelectorKey(
+                fileobj=self._conn,
+                fd=self._conn.fileno,
+                events=0,
+                data=None), selectors.EVENT_READ), ],
+            [(selectors.SelectorKey(
+                fileobj=server.connection,
+                fd=server.connection.fileno,
+                events=0,
+                data=None), selectors.EVENT_WRITE), ],
+        ]
+
+    @mock.patch('selectors.DefaultSelector')
     @mock.patch('socket.fromfd')
-    @mock.patch('select.select')
-    def test_pac_file_served_from_disk(self, mock_select: mock.Mock, mock_fromfd: mock.Mock) -> None:
+    def test_pac_file_served_from_disk(
+            self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None:
         pac_file = 'proxy.pac'
         self._conn = mock_fromfd.return_value
-        mock_select.return_value = [self._conn], [], []
+        self.mock_selector_for_client_read(mock_selector)
         self.init_and_make_pac_file_request(pac_file)
         self.proxy.run_once()
         self.assertEqual(
             self.proxy.request.state,
             proxy.httpParserStates.COMPLETE)
         with open('proxy.pac', 'rb') as f:
-            self._conn.send.called_once_with(proxy.HttpParser.build_response(
+            self._conn.send.called_once_with(proxy.build_http_response(
                 200, reason=b'OK', headers={
                     b'Content-Type': b'application/x-ns-proxy-autoconfig',
                     b'Connection': b'close'
                 }, body=f.read()
             ))
 
+    @mock.patch('selectors.DefaultSelector')
     @mock.patch('socket.fromfd')
-    @mock.patch('select.select')
-    def test_pac_file_served_from_buffer(self, mock_select: mock.Mock, mock_fromfd: mock.Mock) -> None:
+    def test_pac_file_served_from_buffer(
+            self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None:
         self._conn = mock_fromfd.return_value
+        self.mock_selector_for_client_read(mock_selector)
         pac_file_content = b'function FindProxyForURL(url, host) { return "PROXY localhost:8899; DIRECT"; }'
-        mock_select.return_value = [self._conn], [], []
         self.init_and_make_pac_file_request(proxy.text_(pac_file_content))
         self.proxy.run_once()
         self.assertEqual(
             self.proxy.request.state,
             proxy.httpParserStates.COMPLETE)
-        self._conn.send.called_once_with(proxy.HttpParser.build_response(
+        self._conn.send.called_once_with(proxy.build_http_response(
             200, reason=b'OK', headers={
                 b'Content-Type': b'application/x-ns-proxy-autoconfig',
                 b'Connection': b'close'
             }, body=pac_file_content
         ))
 
+    def mock_selector_for_client_read(self, mock_selector: mock.Mock) -> None:
+        mock_selector.return_value.select.return_value = [(
+            selectors.SelectorKey(
+                fileobj=self._conn,
+                fd=self._conn.fileno,
+                events=selectors.EVENT_READ,
+                data=None), selectors.EVENT_READ), ]
+
+    @mock.patch('selectors.DefaultSelector')
     @mock.patch('socket.fromfd')
-    @mock.patch('select.select')
-    def test_default_web_server_returns_404(self, mock_select: mock.Mock, mock_fromfd: mock.Mock) -> None:
+    def test_default_web_server_returns_404(
+            self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None:
         self._conn = mock_fromfd.return_value
-        mock_select.return_value = [self._conn], [], []
+        mock_selector.return_value.select.return_value = [(
+            selectors.SelectorKey(
+                fileobj=self._conn,
+                fd=self._conn.fileno,
+                events=selectors.EVENT_READ,
+                data=None), selectors.EVENT_READ), ]
         config = proxy.ProtocolConfig()
         config.plugins = proxy.load_plugins(
             b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin')
-        self.proxy = proxy.ProtocolHandler(self.fileno, self._addr, config=config)
+        self.proxy = proxy.ProtocolHandler(
+            self.fileno, self._addr, config=config)
+        self.proxy.initialize()
         self._conn.recv.return_value = proxy.CRLF.join([
             b'GET /hello HTTP/1.1',
             proxy.CRLF,
-            proxy.CRLF
         ])
         self.proxy.run_once()
         self.assertEqual(
             self.proxy.request.state,
             proxy.httpParserStates.COMPLETE)
         self.assertEqual(
-            self._conn.send.call_args[0][0],
+            self.proxy.client.buffer,
             proxy.HttpWebServerPlugin.DEFAULT_404_RESPONSE)
 
+    @mock.patch('selectors.DefaultSelector')
+    @mock.patch('socket.fromfd')
+    def test_static_web_server_serves(
+            self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None:
+        # Setup a static directory
+        static_server_dir = os.path.join(tempfile.gettempdir(), 'static')
+        index_file_path = os.path.join(static_server_dir, 'index.html')
+        html_file_content = b'''
+        <html>
+        <head></head>
+        <body></body>
+        </html>
+        '''
+        os.makedirs(static_server_dir, exist_ok=True)
+        with open(index_file_path, 'wb') as f:
+            f.write(html_file_content)
+
+        self._conn = mock_fromfd.return_value
+        self._conn.recv.return_value = proxy.build_http_request(b'GET', b'/index.html')
+
+        mock_selector.return_value.select.side_effect = [
+            [(selectors.SelectorKey(
+                fileobj=self._conn,
+                fd=self._conn.fileno,
+                events=selectors.EVENT_READ,
+                data=None), selectors.EVENT_READ)],
+            [(selectors.SelectorKey(
+                fileobj=self._conn,
+                fd=self._conn.fileno,
+                events=selectors.EVENT_WRITE,
+                data=None), selectors.EVENT_WRITE)], ]
+
+        config = proxy.ProtocolConfig(
+            enable_static_server=True,
+            static_server_dir=static_server_dir)
+        config.plugins = proxy.load_plugins(
+            b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin')
+
+        self.proxy = proxy.ProtocolHandler(
+            self.fileno, self._addr, config=config)
+        self.proxy.initialize()
+
+        self.proxy.run_once()
+        self.proxy.run_once()
+
+        self.assertEqual(mock_selector.return_value.select.call_count, 2)
+        self.assertEqual(self._conn.send.call_count, 1)
+        self.assertEqual(self._conn.send.call_args[0][0], proxy.build_http_response(
+            200, reason=b'OK', headers={
+                b'Content-Type': b'text/html',
+                b'Connection': b'close',
+                b'Content-Length': proxy.bytes_(len(html_file_content))
+            },
+            body=html_file_content
+        ))
+
+    @mock.patch('selectors.DefaultSelector')
+    @mock.patch('socket.fromfd')
+    def test_static_web_server_serves_404(
+            self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None:
+        self._conn = mock_fromfd.return_value
+        self._conn.recv.return_value = proxy.build_http_request(b'GET', b'/not-found.html')
+
+        mock_selector.return_value.select.side_effect = [
+            [(selectors.SelectorKey(
+                fileobj=self._conn,
+                fd=self._conn.fileno,
+                events=selectors.EVENT_READ,
+                data=None), selectors.EVENT_READ)],
+            [(selectors.SelectorKey(
+                fileobj=self._conn,
+                fd=self._conn.fileno,
+                events=selectors.EVENT_WRITE,
+                data=None), selectors.EVENT_WRITE)], ]
+
+        config = proxy.ProtocolConfig(enable_static_server=True)
+        config.plugins = proxy.load_plugins(
+            b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin')
+
+        self.proxy = proxy.ProtocolHandler(
+            self.fileno, self._addr, config=config)
+        self.proxy.initialize()
+
+        self.proxy.run_once()
+        self.proxy.run_once()
+
+        self.assertEqual(mock_selector.return_value.select.call_count, 2)
+        self.assertEqual(self._conn.send.call_count, 1)
+        self.assertEqual(self._conn.send.call_args[0][0],
+                         proxy.HttpWebServerPlugin.DEFAULT_404_RESPONSE)
+
     @mock.patch('socket.fromfd')
-    def test_on_client_connection_called_on_teardown(self, mock_fromfd: mock.Mock) -> None:
+    def test_on_client_connection_called_on_teardown(
+            self, mock_fromfd: mock.Mock) -> None:
         config = proxy.ProtocolConfig()
         plugin = mock.MagicMock()
         config.plugins = {b'ProtocolHandlerPlugin': [plugin]}
         self._conn = mock_fromfd.return_value
-        self.proxy = proxy.ProtocolHandler(self.fileno, self._addr, config=config)
+        self.proxy = proxy.ProtocolHandler(
+            self.fileno, self._addr, config=config)
+        self.proxy.initialize()
         plugin.assert_called()
         with mock.patch.object(self.proxy, 'run_once') as mock_run_once:
             mock_run_once.return_value = True
             self.proxy.run()
         self.assertTrue(self._conn.closed)
-        plugin.return_value.access_log.assert_called()
         plugin.return_value.on_client_connection_close.assert_called()
 
     def init_and_make_pac_file_request(self, pac_file: str) -> None:
         config = proxy.ProtocolConfig(pac_file=pac_file)
         config.plugins = proxy.load_plugins(
             b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin,proxy.HttpWebServerPacFilePlugin')
-        self.proxy = proxy.ProtocolHandler(self.fileno, self._addr, config=config)
+        self.proxy = proxy.ProtocolHandler(
+            self.fileno, self._addr, config=config)
+        self.proxy.initialize()
         self._conn.recv.return_value = proxy.CRLF.join([
             b'GET / HTTP/1.1',
             proxy.CRLF,
         ])
 
-    def assert_data_queued(self, mock_server_connection: mock.Mock, server: mock.Mock) -> None:
+    def assert_data_queued(
+            self, mock_server_connection: mock.Mock, server: mock.Mock) -> None:
         self.proxy.run_once()
         self.assertEqual(
             self.proxy.request.state,
@@ -1035,18 +1430,22 @@ class TestHttpProtocolHandler(unittest.TestCase):
         server.connect.assert_called_once()
         server.closed = False
         assert self.http_server_port is not None
-        server.queue.assert_called_once_with(proxy.CRLF.join([
+        pkt = proxy.CRLF.join([
             b'GET / HTTP/1.1',
             b'User-Agent: proxy.py/%s' % proxy.version,
             b'Host: localhost:%d' % self.http_server_port,
             b'Accept: */*',
             b'Via: %s' % b'1.1 proxy.py v%s' % proxy.version,
             proxy.CRLF
-        ]))
+        ])
+        server.queue.assert_called_once_with(pkt)
+        server.buffer_size.return_value = len(pkt)
 
     def assert_data_queued_to_server(self, server: mock.Mock) -> None:
         assert self.http_server_port is not None
-        self.assertEqual(self._conn.send.call_args[0][0], proxy.HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT)
+        self.assertEqual(
+            self._conn.send.call_args[0][0],
+            proxy.HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT)
 
         self._conn.recv.return_value = proxy.CRLF.join([
             b'GET / HTTP/1.1',
@@ -1055,42 +1454,89 @@ class TestHttpProtocolHandler(unittest.TestCase):
             proxy.CRLF
         ])
         self.proxy.run_once()
-        server.queue.assert_called_once_with(proxy.CRLF.join([
+
+        pkt = proxy.CRLF.join([
             b'GET / HTTP/1.1',
             b'Host: localhost:%d' % self.http_server_port,
             b'User-Agent: proxy.py/%s' % proxy.version,
             proxy.CRLF
-        ]))
+        ])
+        server.queue.assert_called_once_with(pkt)
+        server.buffer_size.return_value = len(pkt)
         server.flush.assert_not_called()
 
 
-class TestWorker(unittest.TestCase):
+class TestHttpProxyPlugin(unittest.TestCase):
 
-    @mock.patch('proxy.ProtocolHandler')
-    def setUp(self, mock_protocol_handler: mock.Mock) -> None:
-        self.pipe = multiprocessing.Pipe()
-        self.worker = proxy.Worker(self.pipe[1], mock_protocol_handler, config=proxy.ProtocolConfig())
-        self.mock_protocol_handler = mock_protocol_handler
+    @mock.patch('selectors.DefaultSelector')
+    @mock.patch('socket.fromfd')
+    def setUp(self,
+              mock_fromfd: mock.Mock,
+              mock_selector: mock.Mock) -> None:
+        self.mock_fromfd = mock_fromfd
+        self.mock_selector = mock_selector
 
-    @mock.patch('proxy.ProtocolHandler')
-    def test_shutdown_op(self, mock_http_proxy: mock.Mock) -> None:
-        self.pipe[0].send((proxy.workerOperations.SHUTDOWN, None))
-        self.worker.run()
-        self.assertFalse(mock_http_proxy.called)
+        self.fileno = 10
+        self._addr = ('127.0.0.1', 54382)
+        self.config = proxy.ProtocolConfig()
+        self.plugin = mock.MagicMock()
+        self.config.plugins = {
+            b'ProtocolHandlerPlugin': [proxy.HttpProxyPlugin],
+            b'HttpProxyBasePlugin': [self.plugin]
+        }
+        self._conn = mock_fromfd.return_value
+        self.proxy = proxy.ProtocolHandler(
+            self.fileno, self._addr, config=self.config)
+        self.proxy.initialize()
 
-    @mock.patch('proxy.recv_handle')
-    def test_spawns_http_proxy_threads(self, mock_recv_handle: mock.Mock) -> None:
-        fileno = 10
-        mock_recv_handle.return_value = fileno
-        self.pipe[0].send((proxy.workerOperations.HTTP_PROTOCOL, None))
-        self.pipe[0].send((proxy.workerOperations.SHUTDOWN, None))
-        self.worker.run()
-        self.assertTrue(self.mock_protocol_handler.called)
+    def test_proxy_plugin_initialized(self) -> None:
+        self.plugin.assert_called()
+
+    @mock.patch('proxy.TcpServerConnection')
+    def test_proxy_plugin_on_and_before_upstream_connection(
+            self,
+            mock_server_conn: mock.Mock) -> None:
+        self.plugin.return_value.before_upstream_connection.return_value = False
+        self.plugin.return_value.on_upstream_connection.return_value = None
+
+        self._conn.recv.return_value = proxy.build_http_request(
+            b'GET', b'http://upstream.host/not-found.html',
+            headers={
+                b'Host': b'upstream.host'
+            })
+        self.mock_selector.return_value.select.side_effect = [
+            [(selectors.SelectorKey(
+                fileobj=self._conn,
+                fd=self._conn.fileno,
+                events=selectors.EVENT_READ,
+                data=None), selectors.EVENT_READ)], ]
+
+        self.proxy.run_once()
+        mock_server_conn.assert_called_with('upstream.host', 80)
+        self.plugin.return_value.before_upstream_connection.assert_called()
+        self.plugin.return_value.on_upstream_connection.assert_called()
 
-    def test_handles_work_queue_recv_connection_refused(self) -> None:
-        with mock.patch.object(self.worker.work_queue, 'recv') as mock_recv:
-            mock_recv.side_effect = ConnectionRefusedError()
-            self.assertFalse(self.worker.run_once())  # doesn't teardown
+    @mock.patch('proxy.TcpServerConnection')
+    def test_proxy_plugin_before_upstream_connection_can_teardown(
+            self,
+            mock_server_conn: mock.Mock) -> None:
+        self.plugin.return_value.before_upstream_connection.return_value = True
+
+        self._conn.recv.return_value = proxy.build_http_request(
+            b'GET', b'http://upstream.host/not-found.html',
+            headers={
+                b'Host': b'upstream.host'
+            })
+        self.mock_selector.return_value.select.side_effect = [
+            [(selectors.SelectorKey(
+                fileobj=self._conn,
+                fd=self._conn.fileno,
+                events=selectors.EVENT_READ,
+                data=None), selectors.EVENT_READ)], ]
+
+        self.proxy.run_once()
+        self.plugin.return_value.before_upstream_connection.assert_called()
+        mock_server_conn.assert_not_called()
 
 
 class TestHttpRequestRejected(unittest.TestCase):
@@ -1125,190 +1571,223 @@ class TestHttpRequestRejected(unittest.TestCase):
 
 class TestMain(unittest.TestCase):
 
+    @staticmethod
+    def mock_default_args(mock_args: mock.Mock) -> None:
+        mock_args.version = False
+        mock_args.cert_file = proxy.DEFAULT_CERT_FILE
+        mock_args.key_file = proxy.DEFAULT_KEY_FILE
+        mock_args.ca_key_file = proxy.DEFAULT_CA_KEY_FILE
+        mock_args.ca_cert_file = proxy.DEFAULT_CA_CERT_FILE
+        mock_args.ca_signing_key_file = proxy.DEFAULT_CA_SIGNING_KEY_FILE
+        mock_args.pid_file = proxy.DEFAULT_PID_FILE
+        mock_args.log_file = proxy.DEFAULT_LOG_FILE
+        mock_args.log_level = proxy.DEFAULT_LOG_LEVEL
+        mock_args.log_format = proxy.DEFAULT_LOG_FORMAT
+        mock_args.basic_auth = proxy.DEFAULT_BASIC_AUTH
+        mock_args.hostname = proxy.DEFAULT_IPV6_HOSTNAME
+        mock_args.port = proxy.DEFAULT_PORT
+        mock_args.num_workers = proxy.DEFAULT_NUM_WORKERS
+        mock_args.disable_http_proxy = proxy.DEFAULT_DISABLE_HTTP_PROXY
+        mock_args.enable_web_server = proxy.DEFAULT_ENABLE_WEB_SERVER
+        mock_args.pac_file = proxy.DEFAULT_PAC_FILE
+        mock_args.plugins = proxy.DEFAULT_PLUGINS
+        mock_args.server_recvbuf_size = proxy.DEFAULT_SERVER_RECVBUF_SIZE
+        mock_args.client_recvbuf_size = proxy.DEFAULT_CLIENT_RECVBUF_SIZE
+        mock_args.open_file_limit = proxy.DEFAULT_OPEN_FILE_LIMIT
+        mock_args.enable_static_server = proxy.DEFAULT_ENABLE_STATIC_SERVER
+        mock_args.enable_devtools = proxy.DEFAULT_ENABLE_DEVTOOLS
+        mock_args.devtools_event_queue = None
+        mock_args.devtools_ws_path = proxy.DEFAULT_DEVTOOLS_WS_PATH
+
+    @mock.patch('time.sleep')
+    @mock.patch('proxy.load_plugins')
+    @mock.patch('proxy.init_parser')
     @mock.patch('proxy.set_open_file_limit')
-    @mock.patch('proxy.WorkerPool')
+    @mock.patch('proxy.ProtocolConfig')
+    @mock.patch('proxy.AcceptorPool')
     @mock.patch('proxy.logging.basicConfig')
-    def test_log_file_setup(
+    def test_init_with_no_arguments(
             self,
-            mock_config: mock.Mock,
-            mock_multicore_dispatcher: mock.Mock,
-            mock_set_open_file_limit: mock.Mock) -> None:
-        log_file = get_temp_file('proxy.log')
-        proxy.main(['--log-file', log_file])
-        mock_set_open_file_limit.assert_called()
-        mock_multicore_dispatcher.assert_called()
-        mock_multicore_dispatcher.return_value.run.assert_called()
-        mock_config.assert_called_with(
-            filename=log_file,
-            filemode='a',
+            mock_logging_config: mock.Mock,
+            mock_acceptor_pool: mock.Mock,
+            mock_protocol_config: mock.Mock,
+            mock_set_open_file_limit: mock.Mock,
+            mock_init_parser: mock.Mock,
+            mock_load_plugins: mock.Mock,
+            mock_sleep: mock.Mock) -> None:
+        mock_sleep.side_effect = KeyboardInterrupt()
+
+        mock_args = mock_init_parser.return_value.parse_args.return_value
+        self.mock_default_args(mock_args)
+        proxy.main([])
+
+        mock_init_parser.assert_called()
+        mock_init_parser.return_value.parse_args.called_with([])
+
+        mock_load_plugins.assert_called_with(b'proxy.HttpProxyPlugin,')
+        mock_logging_config.assert_called_with(
             level=logging.INFO,
             format=proxy.DEFAULT_LOG_FORMAT
         )
+        mock_set_open_file_limit.assert_called_with(mock_args.open_file_limit)
+
+        mock_protocol_config.assert_called_with(
+            auth_code=mock_args.basic_auth,
+            backlog=mock_args.backlog,
+            ca_cert_dir=mock_args.ca_cert_dir,
+            ca_cert_file=mock_args.ca_cert_file,
+            ca_key_file=mock_args.ca_key_file,
+            ca_signing_key_file=mock_args.ca_signing_key_file,
+            certfile=mock_args.cert_file,
+            client_recvbuf_size=mock_args.client_recvbuf_size,
+            hostname=mock_args.hostname,
+            keyfile=mock_args.key_file,
+            num_workers=multiprocessing.cpu_count(),
+            pac_file=mock_args.pac_file,
+            pac_file_url_path=mock_args.pac_file_url_path,
+            port=mock_args.port,
+            server_recvbuf_size=mock_args.server_recvbuf_size,
+            disable_headers=[
+                header.lower() for header in proxy.bytes_(
+                    mock_args.disable_headers).split(proxy.COMMA) if header.strip() != b''],
+            static_server_dir=mock_args.static_server_dir,
+            enable_static_server=mock_args.enable_static_server,
+            devtools_event_queue=None,
+            devtools_ws_path=proxy.DEFAULT_DEVTOOLS_WS_PATH,
+        )
+
+        mock_acceptor_pool.assert_called_with(
+            hostname=mock_protocol_config.return_value.hostname,
+            port=mock_protocol_config.return_value.port,
+            backlog=mock_protocol_config.return_value.backlog,
+            num_workers=mock_protocol_config.return_value.num_workers,
+            work_klass=proxy.ProtocolHandler,
+            config=mock_protocol_config.return_value,
+        )
+        mock_acceptor_pool.return_value.setup.assert_called()
+        mock_acceptor_pool.return_value.shutdown.assert_called()
+        mock_sleep.assert_called_with(1)
 
+    @mock.patch('time.sleep')
     @mock.patch('os.remove')
     @mock.patch('os.path.exists')
     @mock.patch('builtins.open')
-    @mock.patch('proxy.set_open_file_limit')
-    @mock.patch('proxy.WorkerPool')
-    @unittest.skipIf(
-        True,  # type: ignore
-        'This test passes while development on Intellij but fails via CLI :(')
+    @mock.patch('proxy.init_parser')
+    @mock.patch('proxy.AcceptorPool')
     def test_pid_file_is_written_and_removed(
             self,
-            mock_multicore_dispatcher,
-            mock_set_open_file_limit,
-            mock_open,
-            mock_exists,
-            mock_remove) -> None:
+            mock_acceptor_pool: mock.Mock,
+            mock_init_parser: mock.Mock,
+            mock_open: mock.Mock,
+            mock_exists: mock.Mock,
+            mock_remove: mock.Mock,
+            mock_sleep: mock.Mock) -> None:
         pid_file = get_temp_file('proxy.pid')
+        mock_sleep.side_effect = KeyboardInterrupt()
+        mock_args = mock_init_parser.return_value.parse_args.return_value
+        self.mock_default_args(mock_args)
+        mock_args.pid_file = pid_file
         proxy.main(['--pid-file', pid_file])
-        mock_set_open_file_limit.assert_called()
-        mock_multicore_dispatcher.assert_called()
-        mock_multicore_dispatcher.return_value.run.assert_called()
+        mock_init_parser.assert_called()
+        mock_acceptor_pool.assert_called()
+        mock_acceptor_pool.return_value.setup.assert_called()
         mock_open.assert_called_with(pid_file, 'wb')
         mock_open.return_value.__enter__.return_value.write.assert_called_with(
-            proxy.bytes_(str(os.getpid())))
+            proxy.bytes_(os.getpid()))
         mock_exists.assert_called_with(pid_file)
         mock_remove.assert_called_with(pid_file)
 
+    @mock.patch('time.sleep')
     @mock.patch('proxy.ProtocolConfig')
-    @mock.patch('proxy.set_open_file_limit')
-    @mock.patch('proxy.WorkerPool')
-    def test_main(
+    @mock.patch('proxy.AcceptorPool')
+    def test_basic_auth(
             self,
-            mock_multicore_dispatcher: mock.Mock,
-            mock_set_open_file_limit: mock.Mock,
-            mock_config: mock.Mock) -> None:
+            mock_acceptor_pool: mock.Mock,
+            mock_protocol_config: mock.Mock,
+            mock_sleep: mock.Mock) -> None:
+        mock_sleep.side_effect = KeyboardInterrupt()
         proxy.main(['--basic-auth', 'user:pass'])
-        self.assertTrue(mock_set_open_file_limit.called)
-        config = mock_config.return_value
-        mock_multicore_dispatcher.assert_called_with(
+        config = mock_protocol_config.return_value
+        mock_acceptor_pool.assert_called_with(
             hostname=config.hostname,
             port=config.port,
             backlog=config.backlog,
             num_workers=config.num_workers,
             work_klass=proxy.ProtocolHandler,
             config=config)
-        mock_config.assert_called_with(
-            auth_code=b'Basic dXNlcjpwYXNz',
-            client_recvbuf_size=proxy.DEFAULT_CLIENT_RECVBUF_SIZE,
-            server_recvbuf_size=proxy.DEFAULT_SERVER_RECVBUF_SIZE,
-            pac_file=proxy.DEFAULT_PAC_FILE,
-            pac_file_url_path=proxy.DEFAULT_PAC_FILE_URL_PATH,
-            disable_headers=proxy.DEFAULT_DISABLE_HEADERS,
-            hostname=proxy.DEFAULT_IPV6_HOSTNAME,
-            port=proxy.DEFAULT_PORT,
-            backlog=proxy.DEFAULT_BACKLOG,
-            num_workers=multiprocessing.cpu_count(),
-            keyfile=None,
-            certfile=None,
-            ca_cert_file=None,
-            ca_key_file=None,
-            ca_signing_key_file=None,
-            ca_cert_dir=None
-        )
+        self.assertEqual(mock_protocol_config.call_args[1]['auth_code'], b'Basic dXNlcjpwYXNz')
 
     @mock.patch('builtins.print')
-    @mock.patch('proxy.ProtocolConfig')
-    @mock.patch('proxy.set_open_file_limit')
-    @mock.patch('proxy.WorkerPool')
     def test_main_version(
             self,
-            mock_multicore_dispatcher: mock.Mock,
-            mock_set_open_file_limit: mock.Mock,
-            mock_config: mock.Mock,
             mock_print: mock.Mock) -> None:
         with self.assertRaises(SystemExit):
             proxy.main(['--version'])
             mock_print.assert_called_with(proxy.text_(proxy.version))
-        mock_multicore_dispatcher.assert_not_called()
-        mock_set_open_file_limit.assert_not_called()
-        mock_config.assert_not_called()
 
+    @mock.patch('time.sleep')
     @mock.patch('builtins.print')
-    @mock.patch('proxy.ProtocolConfig')
-    @mock.patch('proxy.set_open_file_limit')
-    @mock.patch('proxy.WorkerPool')
+    @mock.patch('proxy.AcceptorPool')
     @mock.patch('proxy.is_py3')
     def test_main_py3_runs(
             self,
             mock_is_py3: mock.Mock,
-            mock_multicore_dispatcher: mock.Mock,
-            mock_set_open_file_limit: mock.Mock,
-            mock_config: mock.Mock,
-            mock_print: mock.Mock) -> None:
+            mock_acceptor_pool: mock.Mock,
+            mock_print: mock.Mock,
+            mock_sleep: mock.Mock) -> None:
+        mock_sleep.side_effect = KeyboardInterrupt()
         mock_is_py3.return_value = True
         proxy.main([])
         mock_is_py3.assert_called()
         mock_print.assert_not_called()
-        mock_multicore_dispatcher.assert_called()
-        mock_set_open_file_limit.assert_called()
-        mock_config.assert_called()
+        mock_acceptor_pool.assert_called()
+        mock_acceptor_pool.return_value.setup.assert_called()
 
     @mock.patch('builtins.print')
-    @mock.patch('proxy.ProtocolConfig')
-    @mock.patch('proxy.set_open_file_limit')
-    @mock.patch('proxy.WorkerPool')
     @mock.patch('proxy.is_py3')
-    @unittest.skipIf(
-        True,  # type: ignore
-        'This test passes while development on Intellij but fails via CLI :(')
     def test_main_py2_exit(
             self,
-            mock_is_py3,
-            mock_multicore_dispatcher,
-            mock_set_open_file_limit,
-            mock_config,
-            mock_print) -> None:
+            mock_is_py3: mock.Mock,
+            mock_print: mock.Mock) -> None:
+        proxy.UNDER_TEST = False
         mock_is_py3.return_value = False
         with self.assertRaises(SystemExit):
             proxy.main([])
             mock_print.assert_called_with('DEPRECATION')
-            mock_is_py3.assert_called()
-        mock_multicore_dispatcher.assert_not_called()
-        mock_set_open_file_limit.assert_not_called()
-        mock_config.assert_not_called()
-
-    def test_text(self) -> None:
-        self.assertEqual(proxy.text_(b'hello'), 'hello')
+        mock_is_py3.assert_called()
 
-    def test_text_nochange(self) -> None:
-        self.assertEqual(proxy.text_('hello'), 'hello')
 
-    def test_bytes(self) -> None:
-        self.assertEqual(proxy.bytes_('hello'), b'hello')
+@unittest.skipIf(
+    os.name == 'nt',
+    'Open file limit tests disabled for Windows')
+class TestSetOpenFileLimit(unittest.TestCase):
 
-    def test_bytes_nochange(self) -> None:
-        self.assertEqual(proxy.bytes_(b'hello'), b'hello')
-
-    @unittest.skipIf(
-        os.name == 'nt',
-        'Open file limit tests disabled for Windows')
-    @mock.patch('resource.getrlimit', return_value=(128, 1024))  # type: ignore
+    @mock.patch('resource.getrlimit', return_value=(128, 1024))
     @mock.patch('resource.setrlimit', return_value=None)
-    def test_set_open_file_limit(self, mock_set_rlimit, mock_get_rlimit) -> None:
+    def test_set_open_file_limit(
+            self,
+            mock_set_rlimit: mock.Mock,
+            mock_get_rlimit: mock.Mock) -> None:
         proxy.set_open_file_limit(256)
         mock_get_rlimit.assert_called_with(resource.RLIMIT_NOFILE)
         mock_set_rlimit.assert_called_with(resource.RLIMIT_NOFILE, (256, 1024))
 
-    @unittest.skipIf(
-        os.name == 'nt',
-        'Open file limit tests disabled for Windows')
-    @mock.patch('resource.getrlimit', return_value=(256, 1024))  # type: ignore
+    @mock.patch('resource.getrlimit', return_value=(256, 1024))
     @mock.patch('resource.setrlimit', return_value=None)
     def test_set_open_file_limit_not_called(
-            self, mock_set_rlimit, mock_get_rlimit) -> None:
+            self,
+            mock_set_rlimit: mock.Mock,
+            mock_get_rlimit: mock.Mock) -> None:
         proxy.set_open_file_limit(256)
         mock_get_rlimit.assert_called_with(resource.RLIMIT_NOFILE)
         mock_set_rlimit.assert_not_called()
 
-    @unittest.skipIf(
-        os.name == 'nt',
-        'Open file limit tests disabled for Windows')
-    @mock.patch('resource.getrlimit', return_value=(256, 1024))  # type: ignore
+    @mock.patch('resource.getrlimit', return_value=(256, 1024))
     @mock.patch('resource.setrlimit', return_value=None)
-    def test_set_open_file_limit_not_called1(
-            self, mock_set_rlimit, mock_get_rlimit) -> None:
+    def test_set_open_file_limit_not_called_coz_upper_bound_check(
+            self,
+            mock_set_rlimit: mock.Mock,
+            mock_get_rlimit: mock.Mock) -> None:
         proxy.set_open_file_limit(1024)
         mock_get_rlimit.assert_called_with(resource.RLIMIT_NOFILE)
         mock_set_rlimit.assert_not_called()