Add an option to serve ES6 JS to clients (#10474)

* Add an option to serve ES6 JS to clients

* Rename es6 to latest

* Fixes

* Serve JS vrsions from separate dirs

* Revert websocket API change

* Update frontend to 20171110.0

* websocket: move request to constructor
This commit is contained in:
Andrey 2017-11-11 09:02:06 +02:00 committed by Paulus Schoutsen
parent 1c36e2f586
commit 5e92fa3404
10 changed files with 150 additions and 65 deletions

View file

@ -9,6 +9,7 @@ import hashlib
import json
import logging
import os
from urllib.parse import urlparse
from aiohttp import web
import voluptuous as vol
@ -21,21 +22,19 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED
from homeassistant.core import callback
from homeassistant.loader import bind_hass
REQUIREMENTS = ['home-assistant-frontend==20171106.0']
REQUIREMENTS = ['home-assistant-frontend==20171110.0']
DOMAIN = 'frontend'
DEPENDENCIES = ['api', 'websocket_api']
DEPENDENCIES = ['api', 'websocket_api', 'http']
URL_PANEL_COMPONENT = '/frontend/panels/{}.html'
URL_PANEL_COMPONENT_FP = '/frontend/panels/{}-{}.html'
POLYMER_PATH = os.path.join(os.path.dirname(__file__),
'home-assistant-polymer/')
FINAL_PATH = os.path.join(POLYMER_PATH, 'final')
CONF_THEMES = 'themes'
CONF_EXTRA_HTML_URL = 'extra_html_url'
CONF_FRONTEND_REPO = 'development_repo'
CONF_JS_VERSION = 'javascript_version'
JS_DEFAULT_OPTION = 'es5'
JS_OPTIONS = ['es5', 'latest', 'auto']
DEFAULT_THEME_COLOR = '#03A9F4'
@ -61,6 +60,7 @@ for size in (192, 384, 512, 1024):
DATA_FINALIZE_PANEL = 'frontend_finalize_panel'
DATA_PANELS = 'frontend_panels'
DATA_JS_VERSION = 'frontend_js_version'
DATA_EXTRA_HTML_URL = 'frontend_extra_html_url'
DATA_THEMES = 'frontend_themes'
DATA_DEFAULT_THEME = 'frontend_default_theme'
@ -68,8 +68,6 @@ DEFAULT_THEME = 'default'
PRIMARY_COLOR = 'primary-color'
# To keep track we don't register a component twice (gives a warning)
# _REGISTERED_COMPONENTS = set()
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema({
@ -80,6 +78,8 @@ CONFIG_SCHEMA = vol.Schema({
}),
vol.Optional(CONF_EXTRA_HTML_URL):
vol.All(cv.ensure_list, [cv.string]),
vol.Optional(CONF_JS_VERSION, default=JS_DEFAULT_OPTION):
vol.In(JS_OPTIONS)
}),
}, extra=vol.ALLOW_EXTRA)
@ -102,8 +102,9 @@ class AbstractPanel:
# Title to show in the sidebar (optional)
sidebar_title = None
# Url to the webcomponent
webcomponent_url = None
# Url to the webcomponent (depending on JS version)
webcomponent_url_es5 = None
webcomponent_url_latest = None
# Url to show the panel in the frontend
frontend_url_path = None
@ -135,16 +136,20 @@ class AbstractPanel:
'get', '/{}/{{extra:.+}}'.format(self.frontend_url_path),
index_view.get)
def as_dict(self):
def to_response(self, hass, request):
"""Panel as dictionary."""
return {
result = {
'component_name': self.component_name,
'icon': self.sidebar_icon,
'title': self.sidebar_title,
'url': self.webcomponent_url,
'url_path': self.frontend_url_path,
'config': self.config,
}
if _is_latest(hass.data[DATA_JS_VERSION], request):
result['url'] = self.webcomponent_url_latest
else:
result['url'] = self.webcomponent_url_es5
return result
class BuiltInPanel(AbstractPanel):
@ -170,15 +175,19 @@ class BuiltInPanel(AbstractPanel):
if frontend_repository_path is None:
import hass_frontend
import hass_frontend_es5
self.webcomponent_url = \
'/static/panels/ha-panel-{}-{}.html'.format(
self.webcomponent_url_latest = \
'/frontend_latest/panels/ha-panel-{}-{}.html'.format(
self.component_name,
hass_frontend.FINGERPRINTS[panel_path])
self.webcomponent_url_es5 = \
'/frontend_es5/panels/ha-panel-{}-{}.html'.format(
self.component_name,
hass_frontend_es5.FINGERPRINTS[panel_path])
else:
# Dev mode
self.webcomponent_url = \
self.webcomponent_url_es5 = self.webcomponent_url_latest = \
'/home-assistant-polymer/panels/{}/ha-panel-{}.html'.format(
self.component_name, self.component_name)
@ -208,18 +217,20 @@ class ExternalPanel(AbstractPanel):
"""
try:
if self.md5 is None:
yield from hass.async_add_job(_fingerprint, self.path)
self.md5 = yield from hass.async_add_job(
_fingerprint, self.path)
except OSError:
_LOGGER.error('Cannot find or access %s at %s',
self.component_name, self.path)
hass.data[DATA_PANELS].pop(self.frontend_url_path)
return
self.webcomponent_url = \
self.webcomponent_url_es5 = self.webcomponent_url_latest = \
URL_PANEL_COMPONENT_FP.format(self.component_name, self.md5)
if self.component_name not in self.REGISTERED_COMPONENTS:
hass.http.register_static_path(
self.webcomponent_url, self.path,
self.webcomponent_url_latest, self.path,
# if path is None, we're in prod mode, so cache static assets
frontend_repository_path is None)
self.REGISTERED_COMPONENTS.add(self.component_name)
@ -281,31 +292,50 @@ def async_setup(hass, config):
repo_path = conf.get(CONF_FRONTEND_REPO)
is_dev = repo_path is not None
hass.data[DATA_JS_VERSION] = js_version = conf.get(CONF_JS_VERSION)
if is_dev:
hass.http.register_static_path(
"/home-assistant-polymer", repo_path, False)
hass.http.register_static_path(
"/static/translations",
os.path.join(repo_path, "build/translations"), False)
sw_path = os.path.join(repo_path, "build/service_worker.js")
os.path.join(repo_path, "build-translations"), False)
sw_path_es5 = os.path.join(repo_path, "build-es5/service_worker.js")
sw_path_latest = os.path.join(repo_path, "build/service_worker.js")
static_path = os.path.join(repo_path, 'hass_frontend')
frontend_es5_path = os.path.join(repo_path, 'build-es5')
frontend_latest_path = os.path.join(repo_path, 'build')
else:
import hass_frontend
frontend_path = hass_frontend.where()
sw_path = os.path.join(frontend_path, "service_worker.js")
static_path = frontend_path
import hass_frontend_es5
sw_path_es5 = os.path.join(hass_frontend_es5.where(),
"service_worker.js")
sw_path_latest = os.path.join(hass_frontend.where(),
"service_worker.js")
# /static points to dir with files that are JS-type agnostic.
# ES5 files are served from /frontend_es5.
# ES6 files are served from /frontend_latest.
static_path = hass_frontend.where()
frontend_es5_path = hass_frontend_es5.where()
frontend_latest_path = static_path
hass.http.register_static_path("/service_worker.js", sw_path, False)
hass.http.register_static_path(
"/service_worker_es5.js", sw_path_es5, False)
hass.http.register_static_path(
"/service_worker.js", sw_path_latest, False)
hass.http.register_static_path(
"/robots.txt", os.path.join(static_path, "robots.txt"), not is_dev)
hass.http.register_static_path("/static", static_path, not is_dev)
hass.http.register_static_path(
"/frontend_latest", frontend_latest_path, not is_dev)
hass.http.register_static_path(
"/frontend_es5", frontend_es5_path, not is_dev)
local = hass.config.path('www')
if os.path.isdir(local):
hass.http.register_static_path("/local", local, not is_dev)
index_view = IndexView(is_dev)
index_view = IndexView(is_dev, js_version)
hass.http.register_view(index_view)
@asyncio.coroutine
@ -405,7 +435,7 @@ class IndexView(HomeAssistantView):
requires_auth = False
extra_urls = ['/states', '/states/{extra}']
def __init__(self, use_repo):
def __init__(self, use_repo, js_option):
"""Initialize the frontend view."""
from jinja2 import FileSystemLoader, Environment
@ -416,27 +446,37 @@ class IndexView(HomeAssistantView):
os.path.join(os.path.dirname(__file__), 'templates/')
)
)
self.js_option = js_option
@asyncio.coroutine
def get(self, request, extra=None):
"""Serve the index view."""
hass = request.app['hass']
latest = _is_latest(self.js_option, request)
compatibility_url = None
if self.use_repo:
core_url = '/home-assistant-polymer/build/core.js'
compatibility_url = \
'/home-assistant-polymer/build/compatibility.js'
core_url = '/home-assistant-polymer/{}/core.js'.format(
'build' if latest else 'build-es5')
ui_url = '/home-assistant-polymer/src/home-assistant.html'
icons_fp = ''
icons_url = '/static/mdi.html'
else:
if latest:
import hass_frontend
core_url = '/frontend_latest/core-{}.js'.format(
hass_frontend.FINGERPRINTS['core.js'])
ui_url = '/frontend_latest/frontend-{}.html'.format(
hass_frontend.FINGERPRINTS['frontend.html'])
else:
import hass_frontend_es5
core_url = '/frontend_es5/core-{}.js'.format(
hass_frontend_es5.FINGERPRINTS['core.js'])
compatibility_url = '/frontend_es5/compatibility-{}.js'.format(
hass_frontend_es5.FINGERPRINTS['compatibility.js'])
ui_url = '/frontend_es5/frontend-{}.html'.format(
hass_frontend_es5.FINGERPRINTS['frontend.html'])
import hass_frontend
core_url = '/static/core-{}.js'.format(
hass_frontend.FINGERPRINTS['core.js'])
compatibility_url = '/static/compatibility-{}.js'.format(
hass_frontend.FINGERPRINTS['compatibility.js'])
ui_url = '/static/frontend-{}.html'.format(
hass_frontend.FINGERPRINTS['frontend.html'])
icons_fp = '-{}'.format(hass_frontend.FINGERPRINTS['mdi.html'])
icons_url = '/static/mdi{}.html'.format(icons_fp)
@ -447,8 +487,10 @@ class IndexView(HomeAssistantView):
if panel == 'states':
panel_url = ''
elif latest:
panel_url = hass.data[DATA_PANELS][panel].webcomponent_url_latest
else:
panel_url = hass.data[DATA_PANELS][panel].webcomponent_url
panel_url = hass.data[DATA_PANELS][panel].webcomponent_url_es5
no_auth = 'true'
if hass.config.api.api_password and not is_trusted_ip(request):
@ -468,7 +510,10 @@ class IndexView(HomeAssistantView):
panel_url=panel_url, panels=hass.data[DATA_PANELS],
dev_mode=self.use_repo,
theme_color=MANIFEST_JSON['theme_color'],
extra_urls=hass.data[DATA_EXTRA_HTML_URL])
extra_urls=hass.data[DATA_EXTRA_HTML_URL],
latest=latest,
service_worker_name='/service_worker.js' if latest else
'/service_worker_es5.js')
return web.Response(text=resp, content_type='text/html')
@ -509,3 +554,20 @@ def _fingerprint(path):
"""Fingerprint a file."""
with open(path) as fil:
return hashlib.md5(fil.read().encode('utf-8')).hexdigest()
def _is_latest(js_option, request):
"""
Return whether we should serve latest untranspiled code.
Set according to user's preference and URL override.
"""
if request is None:
return js_option == 'latest'
latest_in_query = 'latest' in request.query or (
request.headers.get('Referer') and
'latest' in urlparse(request.headers['Referer']).query)
es5_in_query = 'es5' in request.query or (
request.headers.get('Referer') and
'es5' in urlparse(request.headers['Referer']).query)
return latest_in_query or (not es5_in_query and js_option == 'latest')

View file

@ -78,11 +78,11 @@
<a href='/'>TRY AGAIN</a>
</div>
</div>
<home-assistant icons='{{ icons }}'></home-assistant>
{# <script src='/static/home-assistant-polymer/build/_demo_data_compiled.js'></script> #}
<home-assistant></home-assistant>
{# <script src='/static/home-assistant-polymer/build/_demo_data_compiled.js'></script> -#}
{% if not latest -%}
<script>
var compatibilityRequired = (
typeof Object.assign != 'function');
var compatibilityRequired = (typeof Object.assign != 'function');
if (compatibilityRequired) {
var e = document.createElement('script');
e.onerror = initError;
@ -90,10 +90,11 @@
document.head.appendChild(e);
}
</script>
{% endif -%}
<script src='{{ core_url }}'></script>
{% if not dev_mode %}
<script src='/static/custom-elements-es5-adapter.js'></script>
{% endif %}
{% if not dev_mode and not latest -%}
<script src='/frontend_es5/custom-elements-es5-adapter.js'></script>
{% endif -%}
<script>
var webComponentsSupported = (
'customElements' in window &&
@ -105,6 +106,11 @@
e.src = '/static/webcomponents-lite.js';
document.head.appendChild(e);
}
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('{{ service_worker_name }}');
});
}
</script>
<link rel='import' href='{{ ui_url }}' onerror='initError()'>
{% if panel_url -%}

View file

@ -262,7 +262,6 @@ class HomeAssistantWSGI(object):
resource = CachingStaticResource
else:
resource = web.StaticResource
self.app.router.register_resource(resource(url_path, path))
return

View file

@ -65,7 +65,8 @@ class CachingFileResponse(FileResponse):
@asyncio.coroutine
def staticresource_middleware(request, handler):
"""Middleware to strip out fingerprint from fingerprinted assets."""
if not request.path.startswith('/static/'):
path = request.path
if not path.startswith('/static/') and not path.startswith('/frontend'):
return handler(request)
fingerprinted = _FINGERPRINT.match(request.match_info['filename'])

View file

@ -202,15 +202,16 @@ class WebsocketAPIView(HomeAssistantView):
def get(self, request):
"""Handle an incoming websocket connection."""
# pylint: disable=no-self-use
return ActiveConnection(request.app['hass']).handle(request)
return ActiveConnection(request.app['hass'], request).handle()
class ActiveConnection:
"""Handle an active websocket client connection."""
def __init__(self, hass):
def __init__(self, hass, request):
"""Initialize an active connection."""
self.hass = hass
self.request = request
self.wsock = None
self.event_listeners = {}
self.to_write = asyncio.Queue(maxsize=MAX_PENDING_MSG, loop=hass.loop)
@ -259,8 +260,9 @@ class ActiveConnection:
self._writer_task.cancel()
@asyncio.coroutine
def handle(self, request):
def handle(self):
"""Handle the websocket connection."""
request = self.request
wsock = self.wsock = web.WebSocketResponse()
yield from wsock.prepare(request)
self.debug("Connected")
@ -350,7 +352,7 @@ class ActiveConnection:
if wsock.closed:
self.debug("Connection closed by client")
else:
self.log_error("Unexpected TypeError", msg)
_LOGGER.exception("Unexpected TypeError: %s", msg)
except ValueError as err:
msg = "Received invalid JSON"
@ -483,9 +485,14 @@ class ActiveConnection:
Async friendly.
"""
msg = GET_PANELS_MESSAGE_SCHEMA(msg)
panels = {
panel:
self.hass.data[frontend.DATA_PANELS][panel].to_response(
self.hass, self.request)
for panel in self.hass.data[frontend.DATA_PANELS]}
self.to_write.put_nowait(result_message(
msg['id'], self.hass.data[frontend.DATA_PANELS]))
msg['id'], panels))
def handle_ping(self, msg):
"""Handle ping command.

View file

@ -330,7 +330,7 @@ hipnotify==1.0.8
holidays==0.8.1
# homeassistant.components.frontend
home-assistant-frontend==20171106.0
home-assistant-frontend==20171110.0
# homeassistant.components.camera.onvif
http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a

View file

@ -74,7 +74,7 @@ hbmqtt==0.8
holidays==0.8.1
# homeassistant.components.frontend
home-assistant-frontend==20171106.0
home-assistant-frontend==20171110.0
# homeassistant.components.influxdb
# homeassistant.components.sensor.influxdb

View file

@ -52,7 +52,7 @@ def test_frontend_and_static(mock_http_client):
# Test we can retrieve frontend.js
frontendjs = re.search(
r'(?P<app>\/static\/frontend-[A-Za-z0-9]{32}.html)', text)
r'(?P<app>\/frontend_es5\/frontend-[A-Za-z0-9]{32}.html)', text)
assert frontendjs is not None
resp = yield from mock_http_client.get(frontendjs.groups(0)[0])
@ -63,6 +63,10 @@ def test_frontend_and_static(mock_http_client):
@asyncio.coroutine
def test_dont_cache_service_worker(mock_http_client):
"""Test that we don't cache the service worker."""
resp = yield from mock_http_client.get('/service_worker_es5.js')
assert resp.status == 200
assert 'cache-control' not in resp.headers
resp = yield from mock_http_client.get('/service_worker.js')
assert resp.status == 200
assert 'cache-control' not in resp.headers

View file

@ -33,7 +33,7 @@ class TestPanelIframe(unittest.TestCase):
'panel_iframe': conf
})
@patch.dict('hass_frontend.FINGERPRINTS',
@patch.dict('hass_frontend_es5.FINGERPRINTS',
{'panels/ha-panel-iframe.html': 'md5md5'})
def test_correct_config(self):
"""Test correct config."""
@ -55,20 +55,20 @@ class TestPanelIframe(unittest.TestCase):
panels = self.hass.data[frontend.DATA_PANELS]
assert panels.get('router').as_dict() == {
assert panels.get('router').to_response(self.hass, None) == {
'component_name': 'iframe',
'config': {'url': 'http://192.168.1.1'},
'icon': 'mdi:network-wireless',
'title': 'Router',
'url': '/static/panels/ha-panel-iframe-md5md5.html',
'url': '/frontend_es5/panels/ha-panel-iframe-md5md5.html',
'url_path': 'router'
}
assert panels.get('weather').as_dict() == {
assert panels.get('weather').to_response(self.hass, None) == {
'component_name': 'iframe',
'config': {'url': 'https://www.wunderground.com/us/ca/san-diego'},
'icon': 'mdi:weather',
'title': 'Weather',
'url': '/static/panels/ha-panel-iframe-md5md5.html',
'url': '/frontend_es5/panels/ha-panel-iframe-md5md5.html',
'url_path': 'weather',
}

View file

@ -290,7 +290,7 @@ def test_get_panels(hass, websocket_client):
"""Test get_panels command."""
yield from hass.components.frontend.async_register_built_in_panel(
'map', 'Map', 'mdi:account-location')
hass.data[frontend.DATA_JS_VERSION] = 'es5'
websocket_client.send_json({
'id': 5,
'type': wapi.TYPE_GET_PANELS,
@ -300,8 +300,14 @@ def test_get_panels(hass, websocket_client):
assert msg['id'] == 5
assert msg['type'] == wapi.TYPE_RESULT
assert msg['success']
assert msg['result'] == {url: panel.as_dict() for url, panel
in hass.data[frontend.DATA_PANELS].items()}
assert msg['result'] == {'map': {
'component_name': 'map',
'url_path': 'map',
'config': None,
'url': None,
'icon': 'mdi:account-location',
'title': 'Map',
}}
@asyncio.coroutine