+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"
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()
+++ /dev/null
-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
--- /dev/null
+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
+++ /dev/null
-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)
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):
--- /dev/null
+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", ())
+ ]
+++ /dev/null
-(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;
- };
- },
- };
-}());
+++ /dev/null
-<!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>
+++ /dev/null
-(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)});
-})();
+++ /dev/null
-<!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>
+++ /dev/null
-(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)});
-})();
-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
#!/usr/bin/env python3
+import asyncio
+
from hub.app import main
if __name__ == "__main__":
- main()
+ asyncio.run(main())
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
--- /dev/null
+(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,
+ };
+}());
--- /dev/null
+<!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>
--- /dev/null
+(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)});
+})();
--- /dev/null
+<!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>
--- /dev/null
+(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); });
+})();
--- /dev/null
+<!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>
--- /dev/null
+li span {
+ display: inline-block;
+ width: 7em;
+}