From c5722a9bc9d5db89b95647c4af648bf3e771577b Mon Sep 17 00:00:00 2001 From: manatlan Date: Thu, 31 Aug 2023 17:45:11 +0200 Subject: [PATCH] yet a new runner "AppServer" ;-) --- README.md | 9 ++ htagweb/__init__.py | 3 +- htagweb/appserver.py | 212 +++++++++++++++++++++++++++++++++++++++++++ htagweb/webbase.py | 4 +- test_webbase.py | 14 ++- 5 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 htagweb/appserver.py diff --git a/README.md b/README.md index 2a40d17..798461b 100644 --- a/README.md +++ b/README.md @@ -112,3 +112,12 @@ It's not the official way to expose htag's apps on the web. But I'm currently ex Like ANY OTHERS htag runners : the live of a Htag's app is between the websocket open and the websocket close. So if you refresh the page : it will always rebuild all. Others runners avoid this, and make a lot of magics (on web side) to keep the same instance running for the same user. BTW, it's the only "web runner", with WebWS, which works with the new [Tag.update](https://manatlan.github.io/htag/tag_update/) feature ! + +# htagweb.AppServer + +A new runner ;-) ... fully compatible with WebServer/WebServerWS/WebHTTP/WebWS ... but using same concepts as "HtagServer". + +Except: tags should use "self.root.state" to maintain a state (because F5 will destroy/recreate instances) + +TODO: doc will come later ... for tests only, now ;-) + diff --git a/htagweb/__init__.py b/htagweb/__init__.py index 36159a4..750727a 100644 --- a/htagweb/__init__.py +++ b/htagweb/__init__.py @@ -10,8 +10,9 @@ from .webbase import WebServer from .webbase import WebServerWS +from .appserver import AppServer # a completly different beast, but compatible with ^^ from .htagserver import HtagServer # a completly different beast. -__all__= ["WebServer","WebServerWS","HtagServer"] +__all__= ["WebServer","WebServerWS","AppServer", "HtagServer"] __version__ = "0.0.0" # auto updated diff --git a/htagweb/appserver.py b/htagweb/appserver.py new file mode 100644 index 0000000..407f157 --- /dev/null +++ b/htagweb/appserver.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +# ############################################################################# +# Copyright (C) 2023 manatlan manatlan[at]gmail(dot)com +# +# MIT licence +# +# https://github.com/manatlan/htagweb +# ############################################################################# + +# gunicorn -w 4 -k uvicorn.workers.UvicornWorker -b localhost:8000 --preload basic:app + +""" +This thing is completly new different beast, and doesn't work as all classic runners. + +It's a runner between "WebHTTP/WebServer" & "HtagServer" : the best of two worlds + + - It's fully compatible with WebHTTP/WebServer (provide a serve method) + - it use same techs as HTagServer (WS only, parano mode, simple/#workers, etc ...) + - and the SEO trouble is faked by a pre-fake-rendering (it create a hr on http, for seo ... and recreate a real one at WS connect) + +Like HTagServer, as lifespan of htag instances is completly changed : +htag instances should base their state on "self.root.state" only! +Because a F5 will always destroy/recreate the instance. +""" + +import os +import sys +import json +import uuid +import inspect +import logging +import uvicorn +import importlib + +from starlette.applications import Starlette +from starlette.responses import HTMLResponse +from starlette.applications import Starlette +from starlette.routing import Route,WebSocketRoute +from starlette.endpoints import WebSocketEndpoint +from starlette.middleware import Middleware +from starlette.requests import HTTPConnection +from starlette.datastructures import MutableHeaders +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from shared_memory_dict import SharedMemoryDict + +from htag.render import HRenderer +from htag.runners import commons +from . import crypto + +logger = logging.getLogger(__name__) +#################################################### + +# reuse some things from htagserver ;-) +from .htagserver import WebServerSession,getClass +from .webbase import findfqn + + +def fqn2hr(fqn:str,js:str,init,session): + if ":" in fqn: + # no ambiguity + klass=getClass(fqn) + else: + if fqn.endswith(".App"): + # ensure compat (module.App -> module:App) + klass=getClass(fqn[:-4]+":App") + else: + klass=getClass(fqn+":App") + + return HRenderer( klass, js, init=init, session = session) + +class HRSocket(WebSocketEndpoint): + encoding = "text" + + async def _sendback(self,ws, txt:str) -> bool: + try: + if ws.app.parano: + txt = crypto.encrypt(txt.encode(),ws.app.parano) + + await ws.send_text( txt ) + return True + except Exception as e: + logger.error("Can't send to socket, error: %s",e) + return False + + async def on_connect(self, websocket): + fqn=websocket.path_params.get("fqn","") + + await websocket.accept() + js=""" +// rewrite the onmessage of the _WS_ to interpret json action now ! +_WS_.onmessage = async function(e) { + let actions = await _read_(e.data) + action(actions) +} + +// declare the interact js method to communicate thru the WS +async function interact( o ) { + _WS_.send( await _write_(JSON.stringify(o)) ); +} + +console.log("started") +""" + + try: + hr=fqn2hr(fqn,js,commons.url2ak(str(websocket.url)),websocket.session) + except Exception as e: + await self._sendback( websocket, str(e) ) + await websocket.close() + return + + self.hr=hr + + # send back the full rendering (1st onmessage after js connection) + await self._sendback( websocket, str(self.hr) ) + + # register the hr.sendactions, for tag.update feature + self.hr.sendactions=lambda actions: self._sendback(websocket,json.dumps(actions)) + + async def on_receive(self, websocket, data): + if websocket.app.parano: + data = crypto.decrypt(data.encode(),websocket.app.parano).decode() + + data=json.loads(data) + actions = await self.hr.interact(data["id"],data["method"],data["args"],data["kargs"],data.get("event")) + await self._sendback( websocket, json.dumps(actions) ) + + async def on_disconnect(self, websocket, close_code): + del self.hr + +class AppServer(Starlette): + def __init__(self,obj:"htag.Tag class|fqn|None"=None, debug:bool=True,ssl:bool=False,session_size:int=10240,parano:bool=False): + self.ssl=ssl + self.parano = str(uuid.uuid4()) if parano else None + Starlette.__init__( self, + debug=debug, + routes=[WebSocketRoute("/_/{fqn}", HRSocket)], + middleware=[Middleware(WebServerSession,https_only=ssl,session_size=session_size)], + ) + + if obj: + async def handleHome(request): + return await self.serve(request,obj) + self.add_route( '/', handleHome ) + + async def serve(self, request, obj, **NONUSED ) -> HTMLResponse: + fqn=findfqn(obj,":") + protocol = "wss" if self.ssl else "ws" + if self.parano: + jsparano = crypto.JSCRYPTO + jsparano += f"\nvar _PARANO_='{self.parano}'\n" + jsparano += "\nasync function _read_(x) {return await decrypt(x,_PARANO_)}\n" + jsparano += "\nasync function _write_(x) {return await encrypt(x,_PARANO_)}\n" + else: + jsparano = "" + jsparano += "\nasync function _read_(x) {return x}\n" + jsparano += "\nasync function _write_(x) {return x}\n" + #TODO: consider https://developer.chrome.com/blog/removing-document-write/ + + jsbootstrap=""" + %(jsparano)s + // instanciate the WEBSOCKET + var _WS_ = new WebSocket("%(protocol)s://"+location.host+"/_/%(fqn)s"+location.search); + _WS_.onmessage = async function(e) { + // when connected -> the full HTML page is returned, installed & start'ed !!! + + let html = await _read_(e.data); + html = html.replace(" + # + # + # + # + # loading + # + # """ % locals() + + # return HTMLResponse( bootstrapHtmlPage ) + + def run(self, host="0.0.0.0", port=8000, openBrowser=False): # localhost, by default !! + if openBrowser: + import webbrowser + webbrowser.open_new_tab(f"http://localhost:{port}") + + uvicorn.run(self, host=host, port=port) diff --git a/htagweb/webbase.py b/htagweb/webbase.py index 084fe88..2c3621f 100644 --- a/htagweb/webbase.py +++ b/htagweb/webbase.py @@ -113,7 +113,7 @@ async def send_wrapper(message: Message) -> None: -def findfqn(x) -> str: +def findfqn(x,separator=".") -> str: if isinstance(x,str): if ("." not in x) and (":" not in x): raise Exception(f"'{x}' is not a 'full qualified name' (expected 'module.name') of an App (htag.Tag class)") @@ -130,7 +130,7 @@ def findfqn(x) -> str: else: raise Exception(f"!!! wtf ({x}) ???") - return tagClass.__module__+"."+tagClass.__qualname__ + return tagClass.__module__+separator+tagClass.__qualname__ class WebBase(Starlette): diff --git a/test_webbase.py b/test_webbase.py index 84aabcb..d557279 100644 --- a/test_webbase.py +++ b/test_webbase.py @@ -1,6 +1,6 @@ import pytest from htag import Tag -from htagweb import WebServer,WebServerWS +from htagweb import WebServer,WebServerWS,AppServer from htagweb.webbase import findfqn, Users import sys,json from starlette.testclient import TestClient @@ -46,7 +46,7 @@ def test_fqn(): assert findfqn(MyTag) in ["__main__.MyTag","test_webbase.MyTag"] assert findfqn(sys.modules[__name__]) in ["__main__.App","test_webbase.App"] -@pytest.fixture( params=["wh_solo","wh_served","ws_solo","ws_served"] ) +@pytest.fixture( params=["wh_solo","wh_served","ws_solo","ws_served"] ) #TODO: add "as_solo","as_served" def app(request): if request.param=="wh_solo": return WebServer( App ) @@ -54,6 +54,9 @@ def app(request): elif request.param=="ws_solo": return WebServerWS( App ) + elif request.param=="as_solo": + return AppServer( App ) #TODO: should rewrite ws access + elif request.param=="wh_served": app=WebServer() async def handlePath(request): @@ -68,6 +71,13 @@ async def handlePath(request): app.add_route("/", handlePath ) return app + elif request.param=="as_served": + app=AppServer() #TODO: should rewrite ws access + async def handlePath(request): + return await request.app.serve(request, App) + app.add_route("/", handlePath ) + return app + def test_basics_webserver_and_webserverws(app): with TestClient(app) as client: response = client.get('/')