]> git.mar77i.info Git - hublib/commitdiff
serve other hubapps too, consolidate and a lot more...
authormar77i <mar77i@protonmail.ch>
Mon, 1 Jan 2024 19:14:52 +0000 (20:14 +0100)
committermar77i <mar77i@protonmail.ch>
Mon, 1 Jan 2024 19:15:46 +0000 (20:15 +0100)
21 files changed:
hub/app.py
hub/hub.py [deleted file]
hub/hubapp.py [new file with mode: 0644]
hub/staticresource.py [deleted file]
hub/utils.py
hub/websocket.py [new file with mode: 0644]
hubapps/first/common.js [deleted file]
hubapps/first/index.html [deleted file]
hubapps/first/index.js [deleted file]
hubapps/first/master.html.tpl [deleted file]
hubapps/first/master.js [deleted file]
requirements.txt
run_server.py
setup_venv.sh
webroot/first/common.js [new file with mode: 0644]
webroot/first/index.html.j2 [new file with mode: 0644]
webroot/first/index.js [new file with mode: 0644]
webroot/first/master.html.j2 [new file with mode: 0644]
webroot/first/master.js [new file with mode: 0644]
webroot/index.html.j2 [new file with mode: 0644]
webroot/style.css [new file with mode: 0644]

index c5c92476178ff7409e6a003c37362adef04fd805..959024f2c2a41bd2bd3e223e6b58bc12a0c444db 100644 (file)
@@ -1,49 +1,55 @@
+import socket
+import sys
 from argparse import ArgumentParser
 from itertools import chain
+from pathlib import Path
 from secrets import token_urlsafe
-import socket
 from subprocess import run
-import sys
 from traceback import print_exception
-from typing import List, Optional
 from urllib.parse import urlunsplit
 
-from falcon.asgi import App
+from falcon.asgi import App as FalconApp
 from falcon.constants import MEDIA_HTML
+from jinja2 import Environment, FileSystemLoader, select_autoescape
 from uvicorn import Config, Server
 
-from .staticresource import StaticResource
-from .hub import Hub
+from .hubapp import HubApp, RootApp
 
 
-class HubApp(App):
-    def __init__(self, secret, browser, hubapp, *args, **kwargs):
+class App(FalconApp):
+    def __init__(self, secret, **kwargs):
         kwargs.setdefault("media_type", MEDIA_HTML)
-        super().__init__(*args, **kwargs)
+        super().__init__(**kwargs)
+        self.base_dir = Path(__file__).parents[1] / "webroot"
+        self.env = Environment(
+            loader=FileSystemLoader(self.base_dir),
+            autoescape=select_autoescape,
+            extensions=["hub.utils.StaticTag"],
+        )
         self.secret = secret or token_urlsafe(64)
-        self.hub = Hub(self.secret)
-        self.sr = StaticResource(self.secret, hubapp)
-        self.browser = browser
-        self.hub.add_routes(self)
-        self.sr.add_routes(self)
-        self.hub.update_context_vars(self.sr.context_vars)
+        self.hubapps = {}
+        RootApp(self, self.base_dir)
+        for base_dir in self.base_dir.iterdir():
+            if not base_dir.is_dir() or HubApp.is_ignored_filename(base_dir):
+                continue
+            HubApp(self, base_dir)
         self.add_error_handler(Exception, self.print_exception)
 
+    def get_hubapp_by_name(self, name):
+        if name == "root":
+            name = ""
+        return self.hubapps[name]
+
     async def print_exception(self, req, resp, ex, params):
         print_exception(*sys.exc_info())
 
 
-def get_app():
-    ap = ArgumentParser()
-    ap.add_argument("--secret")
-    ap.add_argument("--browser", default="firefox")
-    ap.add_argument("--hubapp", default="first")
-    args = ap.parse_args()
-    return HubApp(args.secret, args.browser, args.hubapp)
-
-
 class HubServer(Server):
-    async def startup(self, sockets: Optional[List[socket.socket]] = None) -> None:
+    def __init__(self, *args, browser, **kwargs):
+        self.browser = browser
+        super().__init__(*args, **kwargs)
+
+    async def startup(self, sockets: list[socket.socket] | None = None) -> None:
         await super().startup(sockets)
         config = self.config
         protocol_name = "https" if config.ssl else "http"
@@ -56,23 +62,35 @@ class HubServer(Server):
         if port == 0:
             try:
                 port = next(
-                    chain.from_iterable((server.sockets for server in getattr(self, "servers", ())))
+                    chain.from_iterable(
+                        (server.sockets for server in getattr(self, "servers", ()))
+                    )
                 ).getsockname()[1]
             except StopIteration:
                 pass
         if {"http": 80, "https": 443}[protocol_name] != port:
             host = f"{host}:{port}"
         app = config.loaded_app.app
-        print("master_uri", app.sr.master_uri)
-        master_url = urlunsplit((protocol_name, host, app.sr.master_uri, "", ""))
-        print("secret:", app.secret)
-        if not app.browser:
-            print("master url", master_url)
-        else:
-            run([app.browser, master_url])
+        print("Secret:", app.secret)
+        for key, value in app.root_app.files_per_uris.items():
+            if Path(value.path.name).stem == "index.html":
+                url = urlunsplit((protocol_name, host, key, "", ""))
+                print("URL:", url)
+                if self.browser:
+                    run([self.browser, url])
+
 
+app: App
 
-def main():
-    HubServer(
-        Config("hub.app:get_app", factory=True, port=5000, log_level="info")
-    ).run()
+
+async def main():
+    global app
+    ap = ArgumentParser()
+    ap.add_argument("--secret")
+    ap.add_argument("--browser", default="firefox")
+    args = ap.parse_args()
+    app = App(args.secret)
+    config = Config("hub.app:app", port=5000, log_level="info")
+    config.setup_event_loop()
+    hs = HubServer(config, browser=args.browser)
+    await hs.serve()
diff --git a/hub/hub.py b/hub/hub.py
deleted file mode 100644 (file)
index da78e1f..0000000
+++ /dev/null
@@ -1,105 +0,0 @@
-import asyncio
-from asyncio.exceptions import CancelledError
-import json
-import pickle
-import sys
-from traceback import print_exception
-
-from falcon import WebSocketDisconnected
-from redis.asyncio import StrictRedis
-
-from .utils import get_redis_pass, scramble
-
-
-class Hub:
-    def __init__(self, secret):
-        self.master_ws_uri = f"/{scramble(secret, 'ws')}"
-        self.redis = StrictRedis(password=get_redis_pass("/etc/redis/redis.conf"))
-        asyncio.ensure_future(self.redis.set("client_id", 0))
-
-    async def process_websocket(self, client_id, web_socket):
-        try:
-            while True:
-                data = await web_socket.receive_text()
-                try:
-                    parsed_data = json.loads(data)
-                except json.JSONDecodeError:
-                    parsed_data = None
-                if not isinstance(parsed_data, dict):
-                    parsed_data = {"data": data}
-                parsed_data["client_id"] = client_id
-                await self.redis.publish("master", pickle.dumps(parsed_data))
-        except (CancelledError, WebSocketDisconnected):
-            pass
-
-    async def process_pubsub(self, pubsub, web_socket):
-        try:
-            while True:
-                data = await pubsub.get_message(True, .3)
-                if not web_socket.ready or web_socket.closed:
-                    break
-                if data is not None:
-                    await web_socket.send_text(json.dumps(pickle.loads(data["data"])))
-        except (CancelledError, WebSocketDisconnected):
-            pass
-
-    async def on_websocket(self, req, web_socket):
-        client_id = await self.redis.incr("client_id")
-        await web_socket.accept()
-        pubsub = self.redis.pubsub()
-        await pubsub.subscribe(f"client-{client_id}")
-        await self.redis.publish(
-            "master", pickle.dumps({"action": "join", "client_id": client_id}),
-        )
-        try:
-            await asyncio.gather(
-                self.process_websocket(client_id, web_socket),
-                self.process_pubsub(pubsub, web_socket),
-                return_exceptions=True,
-            )
-        except (CancelledError, WebSocketDisconnected):
-            pass
-        except Exception:
-            print_exception(*sys.exc_info())
-        finally:
-            await web_socket.close()
-            await self.redis.publish(
-                "master",
-                pickle.dumps({"action": "leave", "client_id": client_id}),
-            )
-
-    async def process_websocket_master(self, web_socket):
-        try:
-            while True:
-                data = json.loads(await web_socket.receive_text())
-                for client_id in data.pop("client_ids", ()):
-                    await self.redis.publish(
-                        f"client-{client_id}",
-                        pickle.dumps(data),
-                    )
-        except (CancelledError, WebSocketDisconnected) as e:
-            pass
-
-    async def on_websocket_master(self, req, web_socket):
-        await web_socket.accept()
-        pubsub = self.redis.pubsub()
-        await pubsub.subscribe("master")
-        try:
-            await asyncio.gather(
-                self.process_websocket_master(web_socket),
-                self.process_pubsub(pubsub, web_socket),
-                return_exceptions=True,
-            )
-        except (CancelledError, WebSocketDisconnected):
-            pass
-        except Exception:
-            print_exception(*sys.exc_info())
-        finally:
-            await web_socket.close()
-
-    def add_routes(self, app):
-        app.add_route("/ws", self)
-        app.add_route(self.master_ws_uri, self, suffix="master")
-
-    def update_context_vars(self, context_vars):
-        context_vars["master_ws_uri"] = self.master_ws_uri
diff --git a/hub/hubapp.py b/hub/hubapp.py
new file mode 100644 (file)
index 0000000..bd2540b
--- /dev/null
@@ -0,0 +1,150 @@
+from pathlib import Path
+
+from falcon.constants import MEDIA_HTML, MEDIA_JS, MEDIA_TEXT
+from falcon.status_codes import HTTP_OK
+
+from .websocket import WebSocketHub
+from .utils import scramble
+
+MEDIA_CSS = "text/css"
+
+
+class StaticFile:
+    media_types_per_suffix = {
+        ".html": MEDIA_HTML,
+        ".js": MEDIA_JS,
+        ".css": MEDIA_CSS,
+        ".txt": MEDIA_TEXT,
+    }
+
+    def __init__(self, path):
+        self.path = path
+        self.media_type = self.get_media_type(path)
+        self.mtime = None
+        self.content = None
+
+    @classmethod
+    def get_media_type(cls, path):
+        return cls.media_types_per_suffix[path.suffix]
+
+    def get(self):
+        mtime = self.path.stat().st_mtime
+        if mtime != self.mtime:
+            with open(self.path) as fh:
+                self.content = fh.read()
+            self.mtime = mtime
+        return self.content
+
+    def __repr__(self):
+        return f"<{type(self).__name__} {self.path}>"
+
+
+class StaticTemplateFile(StaticFile):
+    TEMPLATE_SUFFIX = ".j2"
+
+    def __init__(self, path, hubapp):
+        super().__init__(path)
+        self.hubapp = hubapp
+        self.context = {"hubapp": self.hubapp}
+        self.template = hubapp.app.env.get_template(
+            str(path.relative_to(hubapp.app.env.loader.searchpath[0]))
+        )
+
+    def get(self):
+        return self.template.render(self.context)
+
+    @classmethod
+    def get_media_type(cls, path):
+        assert path.suffix == cls.TEMPLATE_SUFFIX
+        return cls.media_types_per_suffix[path.suffixes[-2]]
+
+
+class BaseHubApp:
+    SCAN_FILES_RECURSIVELY = True
+
+    def __init__(self, app, base_dir, name=None):
+        self.app = app
+        self.base_dir = base_dir
+        self.name = name if name is not None else base_dir.name
+        self.app.hubapps[self.name] = self
+        self.files_per_uris = {
+            self.uri_from(file.path): file for file in self.scan_files(base_dir)
+        }
+        for uri in self.files_per_uris:
+            app.add_route(uri, self)
+
+    @staticmethod
+    def is_master_uri(uri_tail):
+        slash = "/"
+        start = uri_tail.rfind(slash)
+        pos = uri_tail.find(".", start if start >= 0 else 0)
+        return (
+            uri_tail[start + len(slash):pos] == "master"
+            or "master" in uri_tail[:start].split(slash)
+        )
+
+    def uri_from(self, path):
+        if isinstance(path, Path) and path.is_absolute():
+            uri_tail = str(path.relative_to(self.base_dir))
+        else:
+            uri_tail = str(path)
+        suffix = StaticTemplateFile.TEMPLATE_SUFFIX
+        if uri_tail.endswith(suffix):
+            uri_tail = uri_tail[:-len(suffix)]
+
+        if self.is_master_uri(uri_tail):
+            uri_tail = scramble(self.app.secret, uri_tail)
+        elif uri_tail == "index.html":
+            uri_tail = ""
+        name = self.name
+        if name and uri_tail:
+            name = f"{name}/"
+        return f"/{name}{uri_tail}"
+
+    def scan_files(self, base_dir):
+        stack = [base_dir.iterdir()]
+        while len(stack):
+            try:
+                path = next(stack[-1])
+            except StopIteration:
+                stack.pop()
+                continue
+            if path.is_dir():
+                if self.SCAN_FILES_RECURSIVELY:
+                    stack.append(path.iterdir())
+            elif not self.is_ignored_filename(path):
+                if path.suffix == StaticTemplateFile.TEMPLATE_SUFFIX:
+                    static_file = StaticTemplateFile(path, self)
+                else:
+                    static_file = StaticFile(path)
+                yield static_file
+
+    @staticmethod
+    def is_ignored_filename(path):
+        return path.name.startswith(".")
+
+    async def on_get(self, req, resp):
+        resource = self.files_per_uris[req.path]
+        resp.content_type = resource.media_type
+        resp.data = resource.get().encode()
+        resp.status = HTTP_OK
+
+
+class HubApp(BaseHubApp):
+    def __init__(self, app, base_dir):
+        super().__init__(app, base_dir)
+        self.wsh = WebSocketHub(self)
+        self.ws_uris = {self.uri_from(f"ws_{value}"): value for value in ("client", "master")}
+        for uri, suffix in self.ws_uris.items():
+            app.add_route(uri, self.wsh, suffix=suffix)
+
+
+class RootApp(BaseHubApp):
+    SCAN_FILES_RECURSIVELY = False
+
+    def __init__(self, app, base_dir):
+        super().__init__(app, base_dir, "")
+
+    @staticmethod
+    def is_master_uri(uri_tail):
+        return True
diff --git a/hub/staticresource.py b/hub/staticresource.py
deleted file mode 100644 (file)
index e1e86dd..0000000
+++ /dev/null
@@ -1,96 +0,0 @@
-from io import BytesIO
-import json
-import mimetypes
-import os
-from pathlib import Path
-
-from .utils import scramble
-
-
-class StaticFile:
-    template_ext = ".tpl"
-    template_filters = {
-        b"tojson": lambda value: json.dumps(value)
-    }
-
-    def __init__(self, path):
-        self.path = path
-        self.mime_type = mimetypes.guess_type(self.remove_template_ext(str(path)))[0]
-        self.mtime = None
-        self.content = None
-
-    def load_content(self):
-        with open(self.path, "rb") as fh:
-            return fh.read()
-
-    def get(self, context_vars):
-        content = self.load_content() if self.path.stat().st_mtime != self.mtime else self.content
-        if self.path.suffix == self.template_ext:
-            content = self.render(content, context_vars)
-        self.content = content
-        return content
-
-    def render(self, content, context_vars):
-        bio = BytesIO()
-        pos = 0
-        while True:
-            new_pos = content.find(b"{{", pos)
-            if new_pos < 0:
-                break
-            bio.write(content[pos:new_pos])
-            end_pos = content.find(b"}}", new_pos)
-            assert end_pos > 0
-            full_tag = content[new_pos + 2:end_pos].split(b"|", 1)
-            value = context_vars[full_tag[0].strip().decode()]
-            if len(full_tag) == 2:
-                value = self.template_filters[full_tag[1].strip()](value)
-            bio.write(value.encode())
-            pos = end_pos + 2
-        bio.write(content[pos:])
-        return bio.getvalue()
-
-    def __str__(self):
-        return f"<StaticFile at \"{os.sep.join(self.path.parts[-2:])}\" [{self.mime_type}]>"
-
-    @classmethod
-    def remove_template_ext(cls, filename):
-        if filename.endswith(cls.template_ext):
-            filename = filename[:-4]
-        return filename
-
-
-class StaticResource:
-    def __init__(self, secret, current_hubapp):
-        mimetypes.init()
-        self.static_files = {}
-        self.context_vars = {}
-        self.master_uri = ""
-        self.setup(secret, current_hubapp)
-
-    def setup(self, secret, current_hubapp):
-        hubapp_dir = Path(__file__).parents[1] / "hubapps" / current_hubapp
-        for file in os.listdir(hubapp_dir):
-            uri_file = StaticFile.remove_template_ext(file)
-            if uri_file.startswith("master."):
-                uri = f"/{scramble(secret, uri_file)}"
-                if uri_file == "master.html":
-                    self.master_uri = uri
-                self.context_vars[uri_file.replace(".", "_")] = uri
-            else:
-                uri = f"/{uri_file}"
-            self.static_files[uri] = StaticFile(hubapp_dir / file)
-            if uri_file == "index.html":
-                self.static_files["/"] = self.static_files[uri]
-
-    async def on_get(self, req, resp):
-        path = req.path
-        index_uri = f"/index.html"
-        if path == "/":
-            path = index_uri
-        res = self.static_files[path]
-        resp.data = res.get(self.context_vars)
-        resp.content_type = res.mime_type
-
-    def add_routes(self, app):
-        for uri_template in self.static_files:
-            app.add_route(uri_template, self)
index c9d955cfa107e1e6cc01f5d7a7ea1b3d1e9a23d1..ae1fd1242684f692ccec08e4293fcaba6807214e 100644 (file)
@@ -1,11 +1,29 @@
 from base64 import urlsafe_b64encode
-from hashlib import sha3_512
+from hashlib import pbkdf2_hmac
+from pathlib import Path
+
+from jinja2_simple_tags import StandaloneTag
+
+
+class StaticTag(StandaloneTag):
+    tags = {"static"}
+
+    def render(self, filename="/", hubapp=None):
+        if not hubapp:
+            hubapp = self.context["hubapp"]
+        elif isinstance(hubapp, str):
+            hubapp = self.context["hubapp"].app.get_hubapp_by_name(hubapp)
+        return hubapp.uri_from(Path(filename))
 
 
 def scramble(secret, value):
-    h = sha3_512()
-    h.update(f"{secret}{value}".encode())
-    return urlsafe_b64encode(h.digest()).rstrip(b"=").decode("ascii")
+    if isinstance(value, str):
+        value = value.encode()
+    if isinstance(secret, str):
+        secret = secret.encode()
+    return urlsafe_b64encode(
+        pbkdf2_hmac("sha512", value, secret, 221100)
+    ).rstrip(b"=").decode("ascii")
 
 
 def get_redis_pass(redis_conf):
diff --git a/hub/websocket.py b/hub/websocket.py
new file mode 100644 (file)
index 0000000..3d51116
--- /dev/null
@@ -0,0 +1,139 @@
+import asyncio
+import json
+import pickle
+import sys
+from asyncio.exceptions import CancelledError
+from functools import partial
+from traceback import print_exception
+
+from falcon import WebSocketDisconnected
+from redis.asyncio import StrictRedis
+
+from .utils import get_redis_pass
+
+
+class BaseWebSocketHub:
+    first = True
+    client_ids_sem = asyncio.Semaphore(0)
+
+    @classmethod
+    def __class_init(cls, redis):
+        if not cls.first:
+            return
+        cls.first = False
+        asyncio.create_task(cls.initialize_client_ids(redis))
+
+    @classmethod
+    async def initialize_client_ids(cls, redis):
+        await redis.set("client_id", 0)
+        cls.client_ids_sem.release()
+
+    def __init__(self):
+        self.redis = StrictRedis(password=get_redis_pass("/etc/redis/redis.conf"))
+        self.__class_init(self.redis)
+
+    def task_done(self):
+        self.task = None
+
+    @staticmethod
+    async def process_websocket(redis, web_socket, extra_data={}, recipients=[]):
+        try:
+            while True:
+                data = json.loads(await web_socket.receive_text())
+                data.update(extra_data)
+                if callable(recipients):
+                    current_recipients = recipients(data)
+                else:
+                    current_recipients = recipients
+                for recipient in current_recipients:
+                    await redis.publish(recipient, pickle.dumps(data))
+        except (CancelledError, WebSocketDisconnected):
+            pass
+
+    @staticmethod
+    async def process_pubsub(pubsub, web_socket):
+        try:
+            while True:
+                data = await pubsub.get_message(True, 0.3)
+                if not web_socket.ready or web_socket.closed:
+                    break
+                if data is not None:
+                    await web_socket.send_text(json.dumps(pickle.loads(data["data"])))
+        except (CancelledError, WebSocketDisconnected):
+            pass
+
+    async def on_websocket(
+        self,
+        req,
+        web_socket,
+        pubsub_name=None,
+        process_websockets_kwargs=None,
+        join_cb=None,
+        leave_cb=None,
+    ):
+        await web_socket.accept()
+        pubsub = self.redis.pubsub()
+        if pubsub_name:
+            await pubsub.subscribe(pubsub_name)
+        if callable(join_cb):
+            await join_cb()
+        try:
+            await asyncio.gather(
+                self.process_websocket(
+                    self.redis, web_socket, **(process_websockets_kwargs or {})
+                ),
+                self.process_pubsub(pubsub, web_socket),
+                return_exceptions=True,
+            )
+        except (CancelledError, WebSocketDisconnected):
+            pass
+        except Exception:
+            print_exception(*sys.exc_info())
+        finally:
+            await web_socket.close()
+            if callable(leave_cb):
+                await leave_cb()
+
+
+class WebSocketHub(BaseWebSocketHub):
+    def __init__(self, hubapp):
+        super().__init__()
+        self.hubapp = hubapp
+
+    async def join_leave_client_notify(self, redis, action, client_id):
+        await redis.publish(
+            f"{self.hubapp.name}-master",
+            pickle.dumps({"action": action, "client_id": client_id}),
+        )
+
+    async def on_websocket_client(self, req, web_socket):
+        await self.client_ids_sem.acquire()
+        try:
+            client_id = await self.redis.incr("client_id")
+        finally:
+            self.client_ids_sem.release()
+        return await self.on_websocket(
+            req,
+            web_socket,
+            f"{self.hubapp.name}-client-{client_id}",
+            {
+                "extra_data": {"client_id": client_id},
+                "recipients": [f"{self.hubapp.name}-master"],
+            },
+            partial(self.join_leave_client_notify, self.redis, "join", client_id),
+            partial(self.join_leave_client_notify, self.redis, "leave", client_id),
+        )
+
+    async def on_websocket_master(self, req, web_socket):
+            return await self.on_websocket(
+                req,
+                web_socket,
+                f"{self.hubapp.name}-master",
+                {"recipients": self.get_master_recipients},
+            )
+
+    def get_master_recipients(self, data):
+        return [
+            f"{self.hubapp.name}-client-{int(client_id)}"
+            for client_id in data.pop("client_ids", ())
+        ]
diff --git a/hubapps/first/common.js b/hubapps/first/common.js
deleted file mode 100644 (file)
index 74449f9..0000000
+++ /dev/null
@@ -1,125 +0,0 @@
-(function () {
-    window.common = {
-        "HubClient": function (ws_uri, open, close, message) {
-            var ws_protocols = {"http:": "ws:", "https:": "wss:"};
-            this.close = close.bind(this);
-            this.message = message.bind(this);
-            this.open = open.bind(this);
-            this.ws = new WebSocket(
-                ws_protocols[window.location.protocol] +
-                "//" +
-                window.location.host +
-                "/" +
-                ws_uri
-            );
-            this.ws.addEventListener("close", this.close);
-            this.ws.addEventListener("message", this.message);
-            this.ws.addEventListener("open", this.open);
-            this.send = this.ws.send.bind(this.ws);
-        },
-        "get_input": function (on_enter) {
-            var input = document.createElement("input");
-            input.addEventListener("keydown", function (event) {
-                var keycode;
-                if (!event) {
-                    event = window.event;
-                }
-                keycode = event.code || event.key;
-                if (keycode === "Enter" || keycode === "NumpadEnter") {
-                    on_enter();
-                }
-            });
-            return input;
-        },
-        "write": function (output, msg) {
-            if (output.childNodes.length > 0) {
-                output.append(document.createElement("br"));
-            }
-            if (typeof msg !== "string") {
-                msg = String(msg);
-            }
-            output.append(document.createTextNode(msg));
-        },
-        "setup": function (callback) {
-            document.addEventListener("readystatechange", function (event) {
-                if (!event) {
-                    event = window.event;
-                }
-                if (event.target.readyState !== "complete") {
-                    return;
-                }
-                callback();
-            });
-        },
-        "ClientsList": function () {
-            this.ul = document.createElement("ul");
-            document.body.append(this.ul);
-            this.get_label = function (client_id) {
-                return "client-" + client_id.toString();
-            };
-            this.get_name = function (client_id) {
-                var input = this.get_checkbox(client_id);
-                if (input !== null) {
-                    return input.parentNode.childNodes[1].textContent;
-                }
-                return this.get_label(client_id);
-            };
-            this.append = function (client_id) {
-                var li = document.createElement("li");
-                var input = document.createElement("input");
-                var client_label = this.get_label(client_id);
-                input.type = "checkbox";
-                input.id = "checkbox-" + client_label;
-                input.checked = true;
-                li.append(input);
-                li.append(document.createTextNode(client_label));
-                this.ul.append(li);
-            };
-            this.get_checkbox = function (client_id) {
-                return document.getElementById("checkbox-" + this.get_label(client_id));
-            };
-            this.remove = function (client_id) {
-                var input = this.get_checkbox(client_id);
-                if (input === null) {
-                    return;
-                }
-                input.parentNode.remove();
-            };
-            this.set_name = function(client_id, new_name) {
-                var input = this.get_checkbox(client_id);
-                var old_name;
-                if (input === null) {
-                    return;
-                }
-                old_name = input.parentNode.childNodes[1].textContent;
-                while (input.parentNode.childNodes.length > 1)
-                    input.parentNode.childNodes[1].remove();
-                input.parentNode.append(document.createTextNode(new_name));
-                input.parentNode.append(
-                    document.createTextNode(" [" + client_id.toString() + "]")
-                );
-                return old_name;
-            };
-            this.all = function () {
-                var result = [];
-                Array.prototype.forEach.call(this.ul.children, function (child) {
-                    var id_parts = child.getElementsByTagName("input")[0].id.split("-");
-                    result.push(Number(id_parts[id_parts.length - 1]));
-                });
-                return result;
-            };
-            this.selected = function () {
-                var result = [];
-                Array.prototype.forEach.call(this.ul.children, function (child) {
-                    var input = child.getElementsByTagName("input")[0];
-                    var id_parts;
-                    if (input.checked) {
-                        id_parts = input.id.split("-");
-                        result.push(Number(id_parts[id_parts.length - 1]));
-                    }
-                });
-                return result;
-            };
-        },
-    };
-}());
diff --git a/hubapps/first/index.html b/hubapps/first/index.html
deleted file mode 100644 (file)
index 7560554..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-    <head>
-        <meta charset="UTF-8">
-        <meta name="viewport" content="width=device-width, initial-scale=1.0">
-        <meta http-equiv="X-UA-Compatible" content="ie=edge">
-        <title>Index</title>
-        <!-- <link rel="stylesheet" href="style.css"> -->
-    </head>
-    <body>
-    <script src="common.js"></script>
-    <script src="hub.js"></script>
-    <script type="text/javascript">
-        var ws_uri = "/ws";
-    </script>
-    <script src="index.js"></script>
-    </body>
-</html>
diff --git a/hubapps/first/index.js b/hubapps/first/index.js
deleted file mode 100644 (file)
index f6fa9c7..0000000
+++ /dev/null
@@ -1,80 +0,0 @@
-(function () {
-    "use strict";
-    var input_div = null;
-
-    function write(msg) {
-        common.write(input_div.previousSibling, msg);
-    }
-
-    function change_name_button(hub) {
-        var btn = document.createElement("button");
-        btn.append(document.createTextNode("change name"));
-        btn.addEventListener("click", function () {
-            setup_change_name(hub);
-        });
-        return btn;
-    }
-
-    function setup_chat(hub) {
-        input_div = document.createElement("div");
-        input_div.append(common.get_input(function () {
-            var input = input_div.getElementsByTagName("input")[0];
-            hub.send(input.value);
-            input.value = "";
-        }));
-        input_div.append(document.createTextNode(" "));
-        input_div.append(change_name_button(hub));
-
-        document.body.append(document.createElement("div"));
-        document.body.append(input_div);
-    }
-
-    function setup_change_name(hub) {
-        var div = document.createElement("div");
-        div.append(document.createTextNode("Enter name: "));
-        div.append(common.get_input(function () {
-            var client_name = div.children[0].value;
-            if (input_div.childNodes[0] !== input_div.children[0]) {
-                input_div.childNodes[0].remove();
-            }
-            input_div.insertBefore(
-                document.createTextNode(client_name + " "), input_div.children[0]
-            );
-            hub.send(JSON.stringify({"action": "set_name", "name": client_name}));
-            div.remove();
-            input_div.style.display = "";
-            input_div.children[0].focus();
-        }));
-        document.body.append(div);
-        input_div.style.display = "none";
-        div.children[0].focus();
-    }
-
-    function open() {
-        setup_chat(this);
-        write("connected to " + ws_uri);
-        setup_change_name(this);
-    }
-
-    function close() {
-        write("connection lost");
-    }
-
-    function message(msg) {
-        var obj = JSON.parse(msg.data);
-        if (obj.action === "join" || obj.action === "leave") {
-            write("[action]: " + obj.name + " " + obj.action + "s");
-        } else if (obj.action == "set_name") {
-            write(
-                "[action]: " +
-                obj.old_name +
-                " changes name to " +
-                obj.name
-            );
-        } else if (obj.hasOwnProperty("data")) {
-            write(obj.name + ": " + obj.data);
-        }
-    }
-
-    common.setup(function () {new common.HubClient(ws_uri, open, close, message)});
-})();
diff --git a/hubapps/first/master.html.tpl b/hubapps/first/master.html.tpl
deleted file mode 100644 (file)
index 11ed0b1..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <meta http-equiv="X-UA-Compatible" content="ie=edge">
-    <title>Master</title>
-    <!-- <link rel="stylesheet" href="style.css"> -->
-  </head>
-  <body>
-       <script src="common.js"></script>
-    <script src="hub.js"></script>
-       <script type="text/javascript">
-         var ws_uri = {{ master_ws_uri|tojson }};
-       </script>
-       <script src="{{ master_js }}"></script>
-  </body>
-</html>
diff --git a/hubapps/first/master.js b/hubapps/first/master.js
deleted file mode 100644 (file)
index d6074e8..0000000
+++ /dev/null
@@ -1,86 +0,0 @@
-(function () {
-    "use strict";
-    var input_div = null;
-    var clients_list = null;
-
-    function write(msg) {
-        common.write(input_div.previousSibling, msg);
-    }
-    function master_send(hub, client_ids, client_id, obj) {
-        obj.client_ids = client_ids;
-        obj.client_id = client_id;
-        hub.send(JSON.stringify(obj));
-    }
-
-    function setup_chat(hub) {
-        clients_list = new common.ClientsList();
-        input_div = document.createElement("div");
-        input_div.append(document.createTextNode("MASTER "));
-        input_div.append(common.get_input(function () {
-            var input = input_div.getElementsByTagName("input")[0];
-            master_send(hub, clients_list.selected(), null, {
-                "name": "MASTER",
-                "data": input.value,
-            });
-            write("MASTER [null]: " + input.value);
-            input.value = "";
-        }));
-
-        document.body.append(document.createElement("div")); // output
-        document.body.append(input_div);
-    }
-
-    function open() {
-        setup_chat(this);
-        write("connected to " + ws_uri);
-    }
-
-    function close() {
-        write("connection lost");
-    }
-
-    function join_leave_broadcast(hub, client_id, client_name, action) {
-        write("[action]: " + client_name + " " + obj.action + "s");
-        master_send(hub, clients_list.all(), obj.client_id, {
-            "action": obj.action,
-            "name": client_name,
-        });
-    }
-
-    function message(msg) {
-        var obj = JSON.parse(msg.data);
-        var client_name;
-        if (obj.action === "join") {
-            clients_list.append(obj.client_id);
-            join_leave_broadcast(
-                this, obj.client_id, clients_list.get_name(obj.client_id), obj.action
-            );
-        } else if (obj.action === "leave") {
-            client_name = clients_list.get_name(obj.client_id);
-            clients_list.remove(obj.client_id);
-            join_leave_broadcast(this, obj.client_id, client_name, obj.action);
-        } else if (obj.action == "set_name") {
-            client_name = clients_list.set_name(obj.client_id, obj.name);
-            write(
-                "[action]: " +
-                client_name +
-                " changes name to " +
-                obj.name
-            );
-            master_send(this, clients_list.all(), obj.client_id, {
-                "action": "set_name",
-                "old_name": client_name,
-                "name": obj.name,
-            });
-        } else if (obj.hasOwnProperty("data")) {
-            client_name = clients_list.get_name(obj.client_id);
-            write(client_name + " [" + obj.client_id + "]: " + obj.data);
-            master_send(this, clients_list.all(), obj.client_id, {
-                "name": client_name,
-                "data": obj.data,
-            });
-        }
-    }
-
-    common.setup(function () {new common.HubClient(ws_uri, open, close, message)});
-})();
index 01e36a79cdd5722336c10b6de8f51443e5d18702..a9f2dd912a66fd6277982fb9c4ca91677cd5f8ff 100644 (file)
@@ -1,23 +1,23 @@
-anyio==3.7.1
-black==23.7.0
-click==8.1.4
-falcon==3.1.1
+anyio==4.2.0
+black==23.12.1
+click==8.1.7
+falcon==3.1.3
 h11==0.14.0
-httptools==0.6.0
-idna==3.4
-isort==5.12.0
+httptools==0.6.1
+idna==3.6
+isort==5.13.2
 Jinja2==3.1.2
+jinja2-simple-tags==0.5.0
 MarkupSafe==2.1.3
 mypy-extensions==1.0.0
-packaging==23.1
-pathspec==0.11.1
-pipdeptree==2.11.0
-platformdirs==3.8.1
+packaging==23.2
+pathspec==0.12.1
+platformdirs==4.1.0
 python-dotenv==1.0.0
 PyYAML==6.0.1
-redis==4.6.0
+redis==5.0.1
 sniffio==1.3.0
-uvicorn==0.23.1
-uvloop==0.17.0
-watchfiles==0.19.0
-websockets==11.0.3
+uvicorn==0.25.0
+uvloop==0.19.0
+watchfiles==0.21.0
+websockets==12.0
index 6ce118875e57b0d98168a99a1eb71e602cdc3c96..5da43caecf10c9fd97c668d897a0b6d41bb8f047 100755 (executable)
@@ -1,6 +1,8 @@
 #!/usr/bin/env python3
 
+import asyncio
+
 from hub.app import main
 
 if __name__ == "__main__":
-    main()
+    asyncio.run(main())
index 91fc1e716e0d8fe9838068a9e520d986ec65316d..139fde9a7cc2830eb658a7050ad4f0213dea156a 100644 (file)
@@ -7,4 +7,5 @@ if [[ ! -d "${venv_dir}" ]]; then
 fi
 source "${venv_dir}/bin/activate"
 pip install -qU pip
-pip install -qU 'uvicorn[standard]' falcon black pip redis isort Jinja2
+pip install -qU 'uvicorn[standard]' falcon black pip redis isort \
+    Jinja2 jinja2-simple-tags
diff --git a/webroot/first/common.js b/webroot/first/common.js
new file mode 100644 (file)
index 0000000..57ae142
--- /dev/null
@@ -0,0 +1,85 @@
+(function () {
+    var domconf_event_prefix = "event_";
+    function domconf(domobj, obj) {
+        var key;
+        for (key in obj) {
+            if (key.substring(0, domconf_event_prefix.length) == domconf_event_prefix) {
+                domobj.addEventListener(key.substring(domconf_event_prefix.length), obj[key]);
+            } else {
+                domobj.setAttribute(key, obj[key])
+            }
+        }
+        return domobj;
+    }
+
+    function tag(tag_name, obj) {
+        return domconf(document.createElement(tag_name), obj);
+    }
+
+    function HubClient(ws_uri, open, close, message) {
+        this.close = close.bind(this);
+        this.message = message.bind(this);
+        this.open = open.bind(this);
+        this.ws = domconf(
+            new WebSocket(
+                {"http:": "ws:", "https:": "wss:"}[window.location.protocol] +
+                "//" +
+                window.location.host +
+                ws_uri
+            ),
+            {
+                "event_close": this.close,
+                "event_message": this.message,
+                "event_open": this.open,
+            },
+        );
+        this.send = this.ws.send.bind(this.ws);
+    }
+
+    function write(output, msg) {
+        if (output.childNodes.length > 0) {
+            output.append(tag("br"));
+        }
+        if (typeof msg !== "string") {
+            msg = String(msg);
+        }
+        output.append(document.createTextNode(msg));
+    }
+
+    function setup(callback) {
+        domconf(
+            document,
+            {
+                "event_readystatechange": function (event) {
+                    if (!event) {
+                        event = window.event;
+                    }
+                    if (event.target.readyState !== "complete") {
+                        return;
+                    }
+                    callback(event);
+                }
+            },
+        );
+    }
+
+    function make_key_event(callback) {
+        var wrapper = function (event) {
+            event = event || window.event;
+            callback(event, event.target || event.srcElement, event.code || event.key);
+        };
+        return wrapper;
+    }
+
+    function input_with_keydown_event(callback) {
+        return common.tag("input", {"event_keydown": make_key_event(callback)});
+    }
+
+    window.common = {
+        "tag": tag,
+        "HubClient": HubClient,
+        "write": write,
+        "setup": setup,
+        "input_with_keydown_event": input_with_keydown_event,
+    };
+}());
diff --git a/webroot/first/index.html.j2 b/webroot/first/index.html.j2
new file mode 100644 (file)
index 0000000..8ac692c
--- /dev/null
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="UTF-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <meta http-equiv="X-UA-Compatible" content="ie=edge">
+        <title>Chat client</title>
+        <!-- <link rel="stylesheet" href="{% static 'style.css' %}"> -->
+    </head>
+    <body>
+        <script src="{% static 'common.js' %}"></script>
+        <script type="text/javascript">
+            var ws_uri = "{% static 'ws_client' %}";
+        </script>
+        <script src="{% static 'index.js' %}"></script>
+        <h1>Chat</h1>
+    </body>
+</html>
diff --git a/webroot/first/index.js b/webroot/first/index.js
new file mode 100644 (file)
index 0000000..8cae34e
--- /dev/null
@@ -0,0 +1,97 @@
+(function () {
+    "use strict";
+    var input_div = null;
+
+    function write(msg) {
+        common.write(input_div.previousSibling, msg);
+    }
+
+    function change_name_button(hub) {
+        var btn = common.tag(
+            "button",
+            {
+                "event_click": function () {
+                    setup_change_name(hub);
+                }
+            }
+        );
+        btn.append(document.createTextNode("change name"));
+        return btn;
+    }
+
+    function setup_chat(hub) {
+        input_div = common.tag("div");
+        input_div.append(
+            common.input_with_keydown_event(
+                function (event, target, keycode) {
+                    if (keycode !== "Enter" && keycode !== "NumpadEnter") {
+                        return;
+                    }
+                    hub.send(JSON.stringify({"data": target.value}));
+                    target.value = "";
+                }
+            )
+        );
+        input_div.append(document.createTextNode(" "));
+        input_div.append(change_name_button(hub));
+
+        document.body.append(common.tag("div"));
+        document.body.append(input_div);
+    }
+
+    function setup_change_name(hub) {
+        var div = common.tag("div");
+        div.append(document.createTextNode("Enter name: "));
+        div.append(
+            common.input_with_keydown_event(
+                function (event, target, keycode) {
+                    if (keycode !== "Enter" && keycode !== "NumpadEnter") {
+                        return;
+                    }
+                    if (input_div.childNodes[0] !== input_div.children[0]) {
+                        input_div.childNodes[0].remove();
+                    }
+                    input_div.insertBefore(
+                        document.createTextNode(target.value + " "), input_div.children[0]
+                    );
+                    hub.send(JSON.stringify({"action": "set_name", "name": target.value}));
+                    div.remove();
+                    input_div.style.display = "";
+                    input_div.children[0].focus();
+                }
+            )
+        )
+        document.body.append(div);
+        input_div.style.display = "none";
+        div.children[0].focus();
+    }
+
+    function open() {
+        setup_chat(this);
+        write("connected to " + ws_uri);
+        setup_change_name(this);
+    }
+
+    function close() {
+        write("connection lost");
+        input_div.remove();
+    }
+
+    function message(msg) {
+        var obj = JSON.parse(msg.data);
+        if (obj.action === "join" || obj.action === "leave") {
+            write("[action]: " + obj.name + " " + obj.action + "s");
+        } else if (obj.action == "set_name") {
+            write(
+                "[action]: " +
+                obj.old_name +
+                " changes name to " +
+                obj.name
+            );
+        } else if (obj.hasOwnProperty("data")) {
+            write(obj.name + ": " + obj.data);
+        }
+    }
+
+    common.setup(function () {new common.HubClient(ws_uri, open, close, message)});
+})();
diff --git a/webroot/first/master.html.j2 b/webroot/first/master.html.j2
new file mode 100644 (file)
index 0000000..4de2a38
--- /dev/null
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="UTF-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <meta http-equiv="X-UA-Compatible" content="ie=edge">
+        <title>Chat Master</title>
+        <!-- <link rel="stylesheet" href="{% static 'style.css' %}"> -->
+    </head>
+    <body>
+        <script src="{% static 'common.js' %}"></script>
+        <script type="text/javascript">
+            var ws_uri = "{% static 'ws_master' %}";
+        </script>
+        <script src="{% static 'master.js' %}"></script>
+        <h1>Chat Master</h1>
+    </body>
+</html>
diff --git a/webroot/first/master.js b/webroot/first/master.js
new file mode 100644 (file)
index 0000000..ada5f96
--- /dev/null
@@ -0,0 +1,166 @@
+(function () {
+    "use strict";
+    var input_div = null;
+    var clients_list = null;
+
+    function write(msg) {
+        common.write(input_div.previousSibling, msg);
+    }
+
+    function master_send(hub, client_ids, client_id, obj) {
+        obj.client_ids = client_ids;
+        obj.client_id = client_id;
+        hub.send(JSON.stringify(obj));
+    }
+
+    function ClientsList() {
+        function Client(clients_list, client_id, name) {
+            var client_label;
+
+            this.clients_list = clients_list;
+            this.id = client_id;
+            this.get_label = function () {
+                return "client-" + this.id.toString();
+            };
+            client_label = this.get_label();
+            this.name = name || client_label;
+
+            this.li = common.tag("li")
+            this.checkbox = common.tag(
+                "input",
+                {
+                    "type": "checkbox",
+                    "id": "checkbox-" + client_label,
+                    "checked": "",
+                },
+            );
+            this.li.append(this.checkbox);
+            clients_list.ul.append(this.li);
+            this.set_name = function (new_name) {
+                var old_name = this.name;
+                this.name = new_name;
+                while (this.checkbox.nextSibling)
+                    this.checkbox.nextSibling.remove();
+                this.checkbox.parentNode.append(document.createTextNode(new_name));
+                return old_name;
+            };
+            this.set_name(client_label);
+        }
+        this.clients = {};
+        this.ul = common.tag("ul");
+        document.body.append(this.ul);
+
+        this.append = function (client_id) {
+            var client = new Client(this, client_id);
+            this.clients[client_id.toString()] = client;
+            return client;
+        };
+        this.remove = function (client_id) {
+            var client = this.clients[client_id];
+            if (client) {
+                client.li.remove();
+                delete this.clients[client_id];
+            }
+            return client;
+        };
+        this.all = function () {
+            var key;
+            var result = [];
+            for (key in this.clients) {
+                result.push(this.clients[key].id);
+            }
+            return result;
+        };
+        this.selected = function () {
+            var key;
+            var client;
+            var result = [];
+            for (key in this.clients) {
+                client = this.clients[key];
+                if (client.checkbox.checked) {
+                    result.push(client.id);
+                }
+            }
+            return result;
+        };
+    }
+
+    function open() {
+        var hub = this;
+        clients_list = new ClientsList();
+        input_div = common.tag("div");
+        input_div.append(document.createTextNode("MASTER "));
+        input_div.append(
+            common.input_with_keydown_event(
+                function (event, target, keycode) {
+                    if (keycode !== "Enter" && keycode !== "NumpadEnter") {
+                        return;
+                    }
+                    hub.send(JSON.stringify({
+                        "client_ids": clients_list.selected(),
+                        "name": "MASTER",
+                        "data": target.value,
+                    }));
+                    write("MASTER: " + target.value);
+                    target.value = "";
+                }
+            )
+        );
+
+        document.body.append(common.tag("div")); // output
+        document.body.append(input_div);
+        write("connected to " + ws_uri);
+    }
+
+    function close() {
+        write("connection lost");
+        input_div.remove();
+    }
+
+    function join_leave_broadcast(hub, client, action) {
+        if (!client) {
+            return;
+        }
+        write("[action]: " + client.name + " " + action + "s");
+        master_send(hub, clients_list.all(), client.id, {
+            "action": action,
+            "name": client.name,
+        });
+    }
+
+    function message(msg) {
+        var obj = JSON.parse(msg.data);
+        var client;
+        if (obj.action === "join") {
+            join_leave_broadcast(this, clients_list.append(obj.client_id), obj.action);
+            return;
+        } else if (obj.action === "leave") {
+            join_leave_broadcast(this, clients_list.remove(obj.client_id), obj.action);
+            return;
+        }
+        client = clients_list.clients[obj.client_id.toString()];
+
+        if (obj.action == "set_name") {
+            write(
+                "[action]: " +
+                client.name +
+                " changes name to " +
+                obj.name
+            );
+            master_send(this, clients_list.all(), client.id, {
+                "action": "set_name",
+                "old_name": client.name,
+                "name": obj.name,
+            });
+            client.set_name(obj.name);
+        } else if (obj.hasOwnProperty("data")) {
+            write(client.name + " [" + client.id + "]: " + obj.data);
+            master_send(this, clients_list.all(), client.id, {
+                "name": client.name,
+                "data": obj.data,
+            });
+        }
+    }
+
+    common.setup(function () { new common.HubClient(ws_uri, open, close, message); });
+})();
diff --git a/webroot/index.html.j2 b/webroot/index.html.j2
new file mode 100644 (file)
index 0000000..8203a3d
--- /dev/null
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="UTF-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <meta http-equiv="X-UA-Compatible" content="ie=edge">
+        <title>Hubapp Index</title>
+        <link rel="stylesheet" href="{% static 'style.css' %}">
+    </head>
+    <body>
+        <h1>App list</h1>
+        <ul>
+        {% for hubapp in hubapp.app.hubapps %}
+            <li>
+                <span>{{ hubapp.name }}</span>
+                <span><a href="{% static 'master.html', hubapp %}">master</a></span>
+                <span><a href="/{{ hubapp.name }}">/{{ hubapp.name }}</a></span>
+            </li>
+        {% endfor %}{# hubapps #}
+        </ul>
+    </body>
+</html>
diff --git a/webroot/style.css b/webroot/style.css
new file mode 100644 (file)
index 0000000..4f7171b
--- /dev/null
@@ -0,0 +1,4 @@
+li span {
+    display: inline-block;
+    width: 7em;
+}