Skip to content

Commit

Permalink
yet a new runner "AppServer" ;-)
Browse files Browse the repository at this point in the history
  • Loading branch information
manatlan committed Aug 31, 2023
1 parent d7a6732 commit c5722a9
Show file tree
Hide file tree
Showing 5 changed files with 237 additions and 5 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ;-)

3 changes: 2 additions & 1 deletion htagweb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
212 changes: 212 additions & 0 deletions htagweb/appserver.py
Original file line number Diff line number Diff line change
@@ -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("<body ","<body onload='start()' ");
document.open();
document.write(html);
document.close();
};
""" % locals()

# here return a first rendering (only for SEO)
# the hrenderer is DESTROYED just after
hr=fqn2hr(fqn,jsbootstrap,commons.url2ak(str(request.url)),request.session)

return HTMLResponse( str(hr) )

# bootstrapHtmlPage="""<!DOCTYPE html>
# <html>
# <head>
# <script>
# %(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("<body ","<body onload='start()' ");

# document.open();
# document.write(html);
# document.close();
# };
# </script>
# </head>
# <body>loading</body>
# </html>
# """ % 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)
4 changes: 2 additions & 2 deletions htagweb/webbase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand All @@ -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):
Expand Down
14 changes: 12 additions & 2 deletions test_webbase.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -46,14 +46,17 @@ 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 )

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):
Expand All @@ -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('/')
Expand Down

0 comments on commit c5722a9

Please sign in to comment.