소스 검색

feat: dynamically generate homepage html

* SEO friendly
* Easier integration with web frameworks
* No need to send a probe request first when start a new session
wangweimin 4 년 전
부모
커밋
4052a396fa

+ 2 - 1
MANIFEST.in

@@ -1,3 +1,4 @@
 recursive-include demos *.py
 prune docs
-graft pywebio/html
+graft pywebio/html
+graft pywebio/platform/tpl

+ 20 - 10
pywebio/html/index.html

@@ -1,11 +1,15 @@
 <!doctype html>
-<html lang="zh">
+<html lang="">
 <head>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
-    <title>PyWebIO</title>
-    <link rel="icon" type="image/png" sizes="32x32" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAwklEQVQ4T63TvU5CQRCG4WcwMfEuqOgNtQ2Nd4CxV2LHtVhJ0N7AHdjQUBtrrLwLA4ks2Rx+/Qucw3Y78807M7sz4ft5dq6mI7RQX7o/JCNzfdfetkNifRk6k9wLN9jYdxMkyZPQ1faZXYUwB/OCix8V/W4Y4zJDCsBAX7jdM7iQJY+udELu+cTrP2X/xU2+NMPAg3B3UPaVOOmFoQkapQC8Z8AUpyUBs6MAKrZQ+RErf2PlQTrKKK8gpZdpewgOXOcFTTxEjYwMoIkAAAAASUVORK5CYII=" id="favicon32">
-    <link rel="icon" type="image/png" sizes="16x16" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABmUlEQVRYR82XK0wDQRCGv21TUUUJGBwGDBggGCSGBIcAWnBAgsNAgkKhSMDgCA8HtEXgSDBIDC9DDRgcpoSiKo52yea49DiutMttsz27M/98N7s7OyNo9tujgxSTwDiCIaAXSH27l4AXJA/AFSUuWOajGWnR0ChLP3HWkWSAZEN716CM4JQKW6R5+sunPkCeJJJNBCtAosnAQTMHyS6CDWYoh2mEAxzTR4JzYOCfgYNuBRymmOc5uPAbIMswMS6BbkPBPZkiVSZIc+/X/Qng/vl1C4LXIBzG/JmoAag9hxuDaa+XwAIw6p2JGkCObQSrhtMeLifZYZY1tegCqKsW4zHCadfldqgyqK6oC3DGIZIFXZVI9oIjplkUqArXyatGkYkU1+dc5p0eQY4MghNTqlo6kjkFsI9gScvRlLHkQJDnFhgxpampc6cAikCXpqMp8zcF8AnETSlq6lTaAsD6Flg+hNavofVCZL0UW3+M2uI5VhBWGxIFYL0lUxBWm1KviFttyz0Iq4OJB2F1NPO/qdaG0+DD3qLx/AuMVJFhmC8dSgAAAABJRU5ErkJggg==" id="favicon16">
+    <title>PyWebIO Application</title>
+    <link rel="icon" type="image/png" sizes="32x32"
+          href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAwklEQVQ4T63TvU5CQRCG4WcwMfEuqOgNtQ2Nd4CxV2LHtVhJ0N7AHdjQUBtrrLwLA4ks2Rx+/Qucw3Y78807M7sz4ft5dq6mI7RQX7o/JCNzfdfetkNifRk6k9wLN9jYdxMkyZPQ1faZXYUwB/OCix8V/W4Y4zJDCsBAX7jdM7iQJY+udELu+cTrP2X/xU2+NMPAg3B3UPaVOOmFoQkapQC8Z8AUpyUBs6MAKrZQ+RErf2PlQTrKKK8gpZdpewgOXOcFTTxEjYwMoIkAAAAASUVORK5CYII="
+          id="favicon32">
+    <link rel="icon" type="image/png" sizes="16x16"
+          href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABmUlEQVRYR82XK0wDQRCGv21TUUUJGBwGDBggGCSGBIcAWnBAgsNAgkKhSMDgCA8HtEXgSDBIDC9DDRgcpoSiKo52yea49DiutMttsz27M/98N7s7OyNo9tujgxSTwDiCIaAXSH27l4AXJA/AFSUuWOajGWnR0ChLP3HWkWSAZEN716CM4JQKW6R5+sunPkCeJJJNBCtAosnAQTMHyS6CDWYoh2mEAxzTR4JzYOCfgYNuBRymmOc5uPAbIMswMS6BbkPBPZkiVSZIc+/X/Qng/vl1C4LXIBzG/JmoAag9hxuDaa+XwAIw6p2JGkCObQSrhtMeLifZYZY1tegCqKsW4zHCadfldqgyqK6oC3DGIZIFXZVI9oIjplkUqArXyatGkYkU1+dc5p0eQY4MghNTqlo6kjkFsI9gScvRlLHkQJDnFhgxpampc6cAikCXpqMp8zcF8AnETSlq6lTaAsD6Flg+hNavofVCZL0UW3+M2uI5VhBWGxIFYL0lUxBWm1KviFttyz0Iq4OJB2F1NPO/qdaG0+DD3qLx/AuMVJFhmC8dSgAAAABJRU5ErkJggg=="
+          id="favicon16">
     <link rel="stylesheet" href="css/markdown.min.css">
     <link rel="stylesheet" href="css/bootstrap.min.css">
     <link rel="stylesheet" href="css/codemirror.min.css">
@@ -87,12 +91,18 @@
     });
 
     const urlparams = new URLSearchParams(window.location.search);
-    let config = {
-        debug: urlparams.get('_pywebio_debug'),
-        outputAnimation: !urlparams.get('_pywebio_disable_animate'),
-        httpPullInterval: parseInt(urlparams.get('_pywebio_http_pull_interval') || 1000)
-    };
-    WebIO.startWebIOClient($('#markdown-body'), $('#input-cards'), urlparams.get('app') || 'index', config);
+    WebIO.startWebIOClient({
+        output_container_elem: $('#markdown-body'),
+        input_container_elem: $('#input-cards'),
+        backend_address: urlparams.get('pywebio_api') || './io',
+        app_name: urlparams.get('app') || 'index',
+        protocol: 'auto',
+        runtime_config: {
+            debug: urlparams.get('_pywebio_debug'),
+            outputAnimation: !urlparams.get('_pywebio_disable_animate'),
+            httpPullInterval: parseInt(urlparams.get('_pywebio_http_pull_interval') || 1000)
+        },
+    });
 
 </script>
 

+ 14 - 13
pywebio/platform/aiohttp.py

@@ -9,7 +9,7 @@ from urllib.parse import urlparse
 from aiohttp import web
 
 from .tornado import open_webbrowser_on_server_started
-from .utils import make_applications
+from .utils import make_applications, render_page
 from ..session import CoroutineBasedSession, ThreadBasedSession, register_session_implement_for_target, Session
 from ..session.base import get_session_info_from_headers
 from ..utils import get_free_port, STATIC_PATH, iscoroutinefunction, isgeneratorfunction
@@ -51,6 +51,16 @@ def _webio_handler(applications, websocket_settings, check_origin_func=_is_same_
         if origin and not check_origin_func(origin=origin, host=request.host):
             return web.Response(status=403, text="Cross origin websockets not allowed")
 
+        if request.headers.get("Upgrade", "").lower() != "websocket":
+            # Backward compatible
+            if request.query.getone('test', ''):
+                return web.Response(text="")
+
+            app_name = request.query.getone('app', 'index')
+            app = applications.get(app_name) or applications['index']
+            html = render_page(app, protocol='ws')
+            return web.Response(body=html, content_type='text/html')
+
         ws = web.WebSocketResponse(**websocket_settings)
         await ws.prepare(request)
 
@@ -159,17 +169,8 @@ def start_server(applications, port=0, host='', debug=False,
     :param str host: 服务绑定的地址。 ``host`` 可以是IP地址或者为hostname。如果为hostname,服务会监听所有与该hostname关联的IP地址。
         通过设置 ``host`` 为空字符串或 ``None`` 来将服务绑定到所有可用的地址上。
     :param bool debug: 是否开启asyncio的Debug模式
-    :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。
-        来源包含协议、域名和端口部分,允许使用 Unix shell 风格的匹配模式(全部规则参见 `Python文档 <https://docs.python.org/zh-tw/3/library/fnmatch.html>`_ ):
-
-        - ``*`` 为通配符
-        - ``?`` 匹配单个字符
-        - ``[seq]`` 匹配seq中的字符
-        - ``[!seq]`` 匹配不在seq中的字符
-
-        比如 ``https://*.example.com`` 、 ``*://*.example.com``
-    :param callable check_origin: 请求来源检查函数。接收请求来源(包含协议、域名和端口部分)字符串,
-        返回 ``True/False`` 。若设置了 ``check_origin`` , ``allowed_origins`` 参数将被忽略
+    :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。格式同 :func:`pywebio.platform.tornado.start_server` 的 ``allowed_origins`` 参数
+    :param callable check_origin: 请求来源检查函数。格式同 :func:`pywebio.platform.tornado.start_server` 的 ``check_origin`` 参数
     :param bool auto_open_webbrowser: 当服务启动后,是否自动打开浏览器来访问服务。(该操作需要操作系统支持)
     :param dict websocket_settings: 创建 aiohttp WebSocketResponse 时使用的参数。见 https://docs.aiohttp.org/en/stable/web_reference.html#websocketresponse
     :param aiohttp_settings: 需要传给 aiohttp Application 的参数。可用参数见 https://docs.aiohttp.org/en/stable/web_reference.html#application
@@ -186,7 +187,7 @@ def start_server(applications, port=0, host='', debug=False,
                             websocket_settings=websocket_settings)
 
     app = web.Application(**aiohttp_settings)
-    app.router.add_routes([web.get('/io', handler)])
+    app.router.add_routes([web.get('/', handler)])
     app.router.add_routes(static_routes())
 
     if auto_open_webbrowser:

+ 4 - 13
pywebio/platform/django.py

@@ -56,6 +56,7 @@ class DjangoHttpContext(HttpContext):
         :param content:
         :param bool json_type: content是否要序列化成json格式,并将 content-type 设置为application/json
         """
+        # self.response.content accept str and byte
         if json_type:
             self.set_header('content-type', 'application/json')
             self.response.content = json.dumps(content)
@@ -117,17 +118,8 @@ def start_server(applications, port=8080, host='localhost',
     :param int port: 服务监听的端口。设置为 ``0`` 时,表示自动选择可用端口。
     :param str host: 服务绑定的地址。 ``host`` 可以是IP地址或者为hostname。如果为hostname,服务会监听所有与该hostname关联的IP地址。
         通过设置 ``host`` 为空字符串或 ``None`` 来将服务绑定到所有可用的地址上。
-    :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。
-        来源包含协议、域名和端口部分,允许使用 Unix shell 风格的匹配模式(全部规则参见 `Python文档 <https://docs.python.org/zh-tw/3/library/fnmatch.html>`_ ):
-
-        - ``*`` 为通配符
-        - ``?`` 匹配单个字符
-        - ``[seq]`` 匹配seq中的字符
-        - ``[!seq]`` 匹配不在seq中的字符
-
-        比如 ``https://*.example.com`` 、 ``*://*.example.com``
-    :param callable check_origin: 请求来源检查函数。接收请求来源(包含协议、域名和端口部分)字符串,
-        返回 ``True/False`` 。若设置了 ``check_origin`` , ``allowed_origins`` 参数将被忽略
+    :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。格式同 :func:`pywebio.platform.tornado.start_server` 的 ``allowed_origins`` 参数
+    :param callable check_origin: 请求来源检查函数。格式同 :func:`pywebio.platform.tornado.start_server` 的 ``check_origin`` 参数
     :param int session_expire_seconds: 会话过期时间。若 session_expire_seconds 秒内没有收到客户端的请求,则认为会话过期。
     :param int session_cleanup_interval: 会话清理间隔(秒)。服务端会周期性清理过期的会话,释放会话占用的资源。
     :param bool debug: 开启 Django debug mode 和一般访问日志的记录
@@ -186,8 +178,7 @@ def start_server(applications, port=8080, host='localhost',
     )
 
     urlpatterns = [
-        path(r"io", webio_view_func),
-        path(r'', partial(serve, path='index.html'), {'document_root': STATIC_PATH}),
+        path(r"", webio_view_func),
         path(r'<path:path>', serve, {'document_root': STATIC_PATH}),
     ]
 

+ 5 - 14
pywebio/platform/flask.py

@@ -62,6 +62,7 @@ class FlaskHttpContext(HttpContext):
         :param content:
         :param bool json_type: content是否要序列化成json格式,并将 content-type 设置为application/json
         """
+        # self.response.data accept str and bytes
         if json_type:
             self.set_header('content-type', 'application/json')
             self.response.data = json.dumps(content)
@@ -118,17 +119,8 @@ def start_server(applications, port=8080, host='localhost',
     :param int port: 服务监听的端口。设置为 ``0`` 时,表示自动选择可用端口。
     :param str host: 服务绑定的地址。 ``host`` 可以是IP地址或者为hostname。如果为hostname,服务会监听所有与该hostname关联的IP地址。
         通过设置 ``host`` 为空字符串或 ``None`` 来将服务绑定到所有可用的地址上。
-    :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。
-        来源包含协议、域名和端口部分,允许使用 Unix shell 风格的匹配模式(全部规则参见 `Python文档 <https://docs.python.org/zh-tw/3/library/fnmatch.html>`_ ):
-
-        - ``*`` 为通配符
-        - ``?`` 匹配单个字符
-        - ``[seq]`` 匹配seq中的字符
-        - ``[!seq]`` 匹配不在seq中的字符
-
-        比如 ``https://*.example.com`` 、 ``*://*.example.com``
-    :param callable check_origin: 请求来源检查函数。接收请求来源(包含协议、域名和端口部分)字符串,
-        返回 ``True/False`` 。若设置了 ``check_origin`` , ``allowed_origins`` 参数将被忽略
+    :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。格式同 :func:`pywebio.platform.tornado.start_server` 的 ``allowed_origins`` 参数
+    :param callable check_origin: 请求来源检查函数。格式同 :func:`pywebio.platform.tornado.start_server` 的 ``check_origin`` 参数
     :param int session_expire_seconds: 会话过期时间。若 session_expire_seconds 秒内没有收到客户端的请求,则认为会话过期。
     :param int session_cleanup_interval: 会话清理间隔(秒)。服务端会周期性清理过期的会话,释放会话占用的资源。
     :param bool debug: 是否开启Flask Server的debug模式,开启后,代码发生修改后服务器会自动重启。
@@ -142,7 +134,7 @@ def start_server(applications, port=8080, host='localhost',
         port = get_free_port()
 
     app = Flask(__name__)
-    app.add_url_rule('/io', 'webio_view', webio_view(
+    app.add_url_rule('/', 'webio_view', webio_view(
         applications=applications,
         session_expire_seconds=session_expire_seconds,
         session_cleanup_interval=session_cleanup_interval,
@@ -150,9 +142,8 @@ def start_server(applications, port=8080, host='localhost',
         check_origin=check_origin
     ), methods=['GET', 'POST', 'OPTIONS'])
 
-    @app.route('/')
     @app.route('/<path:static_file>')
-    def serve_static_file(static_file='index.html'):
+    def serve_static_file(static_file):
         return send_from_directory(STATIC_PATH, static_file)
 
     has_coro_target = any(iscoroutinefunction(target) or isgeneratorfunction(target) for

+ 15 - 7
pywebio/platform/httpbased.py

@@ -21,7 +21,7 @@ from typing import Dict
 
 import time
 
-from .utils import make_applications
+from .utils import make_applications, render_page
 from ..session import CoroutineBasedSession, Session, ThreadBasedSession, register_session_implement_for_target
 from ..session.base import get_session_info_from_headers
 from ..utils import random_str, LRUDict, isgeneratorfunction, iscoroutinefunction
@@ -63,13 +63,13 @@ class HttpContext:
     def set_content(self, content, json_type=False):
         """设置响应的内容。方法应该仅被调用一次
 
-        :param content:
+        :param str/bytes/json-able content:
         :param bool json_type: content是否要序列化成json格式,并将 content-type 设置为application/json
         """
         pass
 
     def get_response(self):
-        """获取当前的响应对象,用于在图函数中返回"""
+        """获取当前的响应对象,用于在图函数中返回"""
         pass
 
     def get_client_ip(self):
@@ -166,16 +166,24 @@ class HttpHandler:
 
         if request_headers.get('Origin'):  # set headers for CORS request
             self._process_cors(context)
+        # CORS process end ############################
 
-        if context.request_url_parameter('test'):  # 测试接口,当会话使用给予http的backend时,返回 ok
+        if context.request_url_parameter('test'):  # 测试接口,当会话使用基于http的backend时,返回 ok
             context.set_content('ok')
             return context.get_response()
-        # CORS process end ############################
+
+        # 对首页HTML的请求
+        if 'webio-session-id' not in request_headers:
+            app_name = context.request_url_parameter('app', 'index')
+            app = self.applications.get(app_name) or self.applications['index']
+            html = render_page(app, protocol='http')
+            context.set_content(html)
+            return context.get_response()
 
         webio_session_id = None
 
-        # webio-session-id 的请求头为空时,创建新 Session
-        if 'webio-session-id' not in request_headers or not request_headers['webio-session-id']:
+        # 初始请求,创建新 Session
+        if not request_headers['webio-session-id'] or request_headers['webio-session-id'] == 'NEW':
             if context.request_method() == 'POST':  # 不能在POST请求中创建Session,防止CSRF攻击
                 context.set_status(403)
                 return context.get_response()

+ 19 - 3
pywebio/platform/tornado.py

@@ -18,7 +18,7 @@ from ..session import CoroutineBasedSession, ThreadBasedSession, ScriptModeSessi
     register_session_implement_for_target, Session
 from ..session.base import get_session_info_from_headers
 from ..utils import get_free_port, wait_host_port, STATIC_PATH, iscoroutinefunction, isgeneratorfunction
-from .utils import make_applications
+from .utils import make_applications, render_page
 
 logger = logging.getLogger(__name__)
 
@@ -62,6 +62,20 @@ def _webio_handler(applications, check_origin_func=_is_same_site):
 
     class WSHandler(WebSocketHandler):
 
+        async def get(self, *args, **kwargs) -> None:
+            # It's a simple http GET request
+            if self.request.headers.get("Upgrade", "").lower() != "websocket":
+                # Backward compatible
+                if self.get_query_argument('test', ''):
+                    return self.write('')
+
+                app_name = self.get_query_argument('app', 'index')
+                app = applications.get(app_name) or applications['index']
+                html = render_page(app, protocol='ws')
+                return self.write(html)
+            else:
+                await super().get()
+
         def check_origin(self, origin):
             return check_origin_func(origin=origin, handler=self)
 
@@ -150,7 +164,7 @@ def _setup_server(webio_handler, port=0, host='', **tornado_app_settings):
     if port == 0:
         port = get_free_port()
 
-    handlers = [(r"/io", webio_handler),
+    handlers = [(r"/", webio_handler),
                 (r"/(.*)", StaticFileHandler, {"path": STATIC_PATH, 'default_filename': 'index.html'})]
 
     app = tornado.web.Application(handlers=handlers, **tornado_app_settings)
@@ -231,7 +245,9 @@ def start_server_in_current_thread_session():
     websocket_conn_opened = threading.Event()
     thread = threading.current_thread()
 
-    class SingleSessionWSHandler(_webio_handler(applications={})):
+    mock_apps = dict(index=lambda: None)
+
+    class SingleSessionWSHandler(_webio_handler(applications=mock_apps)):
         session = None
         instance = None
 

+ 107 - 0
pywebio/platform/tpl/index.html

@@ -0,0 +1,107 @@
+<!doctype html>
+<html lang="">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <title>{{ title }}</title>
+    <meta name="description" content="{{ description }}">
+    <link rel="icon" type="image/png" sizes="32x32" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAwklEQVQ4T63TvU5CQRCG4WcwMfEuqOgNtQ2Nd4CxV2LHtVhJ0N7AHdjQUBtrrLwLA4ks2Rx+/Qucw3Y78807M7sz4ft5dq6mI7RQX7o/JCNzfdfetkNifRk6k9wLN9jYdxMkyZPQ1faZXYUwB/OCix8V/W4Y4zJDCsBAX7jdM7iQJY+udELu+cTrP2X/xU2+NMPAg3B3UPaVOOmFoQkapQC8Z8AUpyUBs6MAKrZQ+RErf2PlQTrKKK8gpZdpewgOXOcFTTxEjYwMoIkAAAAASUVORK5CYII=" id="favicon32">
+    <link rel="icon" type="image/png" sizes="16x16" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABmUlEQVRYR82XK0wDQRCGv21TUUUJGBwGDBggGCSGBIcAWnBAgsNAgkKhSMDgCA8HtEXgSDBIDC9DDRgcpoSiKo52yea49DiutMttsz27M/98N7s7OyNo9tujgxSTwDiCIaAXSH27l4AXJA/AFSUuWOajGWnR0ChLP3HWkWSAZEN716CM4JQKW6R5+sunPkCeJJJNBCtAosnAQTMHyS6CDWYoh2mEAxzTR4JzYOCfgYNuBRymmOc5uPAbIMswMS6BbkPBPZkiVSZIc+/X/Qng/vl1C4LXIBzG/JmoAag9hxuDaa+XwAIw6p2JGkCObQSrhtMeLifZYZY1tegCqKsW4zHCadfldqgyqK6oC3DGIZIFXZVI9oIjplkUqArXyatGkYkU1+dc5p0eQY4MghNTqlo6kjkFsI9gScvRlLHkQJDnFhgxpampc6cAikCXpqMp8zcF8AnETSlq6lTaAsD6Flg+hNavofVCZL0UW3+M2uI5VhBWGxIFYL0lUxBWm1KviFttyz0Iq4OJB2F1NPO/qdaG0+DD3qLx/AuMVJFhmC8dSgAAAABJRU5ErkJggg==" id="favicon16">
+    <link rel="stylesheet" href="css/markdown.min.css">
+    <link rel="stylesheet" href="css/bootstrap.min.css">
+    <link rel="stylesheet" href="css/codemirror.min.css">
+    <link rel="stylesheet" href="codemirror/base16-light.min.css">
+    <link rel="stylesheet" href="css/toastify.min.css">
+    <link rel="stylesheet" href="css/app.css">
+</head>
+<body>
+<div class="pywebio">
+    <div class="container no-fix-height" id="output-container">
+        <div class="markdown-body" id="markdown-body">
+            <div id="pywebio-scope-ROOT"></div>
+        </div>
+        <div id="end-space"></div>
+
+    </div>
+
+    <div id="input-container">
+        <div id="input-cards" class="container"></div>
+    </div>
+</div>
+
+
+<footer class="footer">
+    Powered by <a href="https://github.com/wang0618/PyWebIO" target="_blank">PyWebIO</a>
+</footer>
+
+<script src="js/mustache.min.js"></script>  <!--template system-->
+<script src="js/codemirror.min.js"></script>  <!--code textarea editor-->
+<script src="codemirror/matchbrackets.js"></script>  <!--codemirror plugin-->
+<script src="codemirror/python.js"></script> <!--codemirror python language support-->
+<script src="codemirror/loadmode.js"></script> <!--codemirror plugin: auto load mode-->
+<script src="codemirror/active-line.js"></script> <!--codemirror plugin: auto load mode-->
+<script src="js/prism.min.js"></script>  <!-- markdown code highlight -->
+<script src="js/FileSaver.min.js"></script>  <!-- saving files on the client-side -->
+<script src="js/jquery.min.js"></script>
+<script src="js/popper.min.js"></script>  <!-- tooltip engine -->
+<script src="js/bootstrap.min.js"></script>
+<script src="js/toastify.min.js"></script> <!-- toast -->
+<script src="js/bs-custom-file-input.min.js"></script> <!-- bootstrap custom file input-->
+<script src="js/purify.min.js"></script>  <!-- XSS sanitizer -->
+
+<script src="js/pywebio.min.js"></script>
+
+<script src="js/require.min.js"></script> <!-- JS module loader -->
+<script>
+
+    require.config({
+        paths: {
+            'plotly': "https://cdn.jsdelivr.net/npm/plotly.js@1.53.0/dist/plotly.min", // 'https://cdn.plot.ly/plotly-latest.min'
+            "bokeh": "https://cdn.jsdelivr.net/npm/@bokeh/bokehjs@2.0.2/build/js/bokeh.min",
+            "bokeh-widgets": "https://cdn.jsdelivr.net/npm/@bokeh/bokehjs@2.0.2/build/js/bokeh-widgets.min",
+            "bokeh-tables": "https://cdn.jsdelivr.net/npm/@bokeh/bokehjs@2.0.2/build/js/bokeh-tables.min",
+            "bokeh-gl": "https://cdn.jsdelivr.net/npm/@bokeh/bokehjs@2.0.2/build/js/bokeh-gl.min",
+        },
+        shim: {
+            'bokeh': {
+                exports: 'Bokeh'
+            },
+            'bokeh-widgets': {
+                exports: '_',
+                deps: ['bokeh'],
+            },
+            'bokeh-tables': {
+                exports: '_',
+                deps: ['bokeh'],
+            },
+            'bokeh-gl': {
+                exports: '_',
+                deps: ['bokeh'],
+            },
+        }
+    });
+
+
+    $(function () {
+        // https://www.npmjs.com/package/bs-custom-file-input
+        bsCustomFileInput.init()
+    });
+
+    const urlparams = new URLSearchParams(window.location.search);
+    WebIO.startWebIOClient({
+        output_container_elem: $('#markdown-body'),
+        input_container_elem: $('#input-cards'),
+        backend_address: urlparams.get('pywebio_api') || '',
+        app_name: urlparams.get('app') || 'index',
+        protocol: "{{ protocol }}",
+        runtime_config: {
+            debug: urlparams.get('_pywebio_debug'),
+            outputAnimation: !urlparams.get('_pywebio_disable_animate'),
+            httpPullInterval: parseInt(urlparams.get('_pywebio_http_pull_interval') || 1000)
+        },
+    });
+</script>
+
+
+</body>
+</html>

+ 36 - 3
pywebio/platform/utils.py

@@ -1,6 +1,39 @@
+from collections import namedtuple
 from collections.abc import Mapping, Sequence
-from ..utils import isgeneratorfunction, iscoroutinefunction, get_function_name
-import inspect
+from os import path
+
+from tornado import template
+
+from ..utils import isgeneratorfunction, iscoroutinefunction, get_function_name, get_function_doc
+
+AppMeta = namedtuple('App', 'title description')
+
+_here_dir = path.dirname(path.abspath(__file__))
+_index_page_tpl = template.Template(open(path.join(_here_dir, 'tpl', 'index.html')).read())
+
+
+def render_page(app, protocol):
+    """渲染首页
+
+    :param callable app: PyWebIO app
+    :param str protocol: 'ws'/'http'
+    :return: bytes
+    """
+    assert protocol in ('ws', 'http')
+    meta = parse_app_metadata(app)
+    return _index_page_tpl.generate(title=meta.title, description=meta.description, protocol=protocol)
+
+
+def parse_app_metadata(func):
+    """解析函数注释文档"""
+    doc = get_function_doc(func)
+    doc = doc.strip().split('\n\n', 1)
+    if len(doc) == 1:
+        title, description = doc[0] or 'PyWebIO Application', ''
+    else:
+        title, description = doc
+
+    return AppMeta(title, description)
 
 
 def _generate_index(applications):
@@ -9,7 +42,7 @@ def _generate_index(applications):
     md_text = "## Application index\n"
     for name, task in applications.items():
         # todo 保留当前页面的设置项
-        md_text += "- [{name}](?app={name}): {desc}\n".format(name=name, desc=(inspect.getdoc(task) or ''))
+        md_text += "- [{name}](?app={name}): {desc}\n".format(name=name, desc=get_function_doc(task))
 
     def index():
         from pywebio.output import put_markdown

+ 6 - 0
pywebio/utils.py

@@ -74,6 +74,12 @@ def get_function_name(func, default=None):
     return getattr(func, '__name__', default)
 
 
+def get_function_doc(func):
+    while isinstance(func, functools.partial):
+        func = func.func
+    return inspect.getdoc(func) or ''
+
+
 class LimitedSizeQueue(queue.Queue):
     """
     有限大小的队列

+ 1 - 0
setup.py

@@ -64,6 +64,7 @@ setup(
             "html/image/favicon_open_16.png",
             "html/image/favicon_closed_32.png",
             "html/index.html",
+            "platform/tpl/index.html"
         ],
     },
     classifiers=[

+ 3 - 3
test/12.cors.py

@@ -33,7 +33,7 @@ def test(server_proc: subprocess.Popen, browser: Chrome):
     server_proc.send_signal(signal.SIGINT)
 
     time.sleep(4)
-    browser.get('http://localhost:5000/?pywebio_api=http://localhost:8081/io')
+    browser.get('http://localhost:5000/?pywebio_api=http://localhost:8081/')
     raw_html = test_once(browser, '12.aiohttp_cors.html',
                          process_func=lambda i: i.replace('http://localhost:5000', 'http://localhost:8080').replace(
                              'localhost:8081', 'localhost:8080'))
@@ -41,7 +41,7 @@ def test(server_proc: subprocess.Popen, browser: Chrome):
     server_proc.send_signal(signal.SIGINT)
 
     time.sleep(4)
-    browser.get('http://localhost:5000/?pywebio_api=http://localhost:8082/io')
+    browser.get('http://localhost:5000/?pywebio_api=http://localhost:8082/')
     raw_html = test_once(browser, '12.flask_cors.html',
                          process_func=lambda i: i.replace('http://localhost:5000', 'http://localhost:8080').replace(
                              'localhost:8082', 'localhost:8080'))
@@ -77,4 +77,4 @@ def start_test_server():
 
 if __name__ == '__main__':
     util.run_test(start_test_server, test,
-                  address='http://localhost:5000/?pywebio_api=http://localhost:8080/io')
+                  address='http://localhost:5000/?pywebio_api=http://localhost:8080/')

+ 114 - 0
test/14.django_multiple_session_impliment.py

@@ -0,0 +1,114 @@
+import subprocess
+
+from selenium.webdriver import Chrome
+
+import pywebio
+import template
+import time
+import util
+from pywebio.input import *
+from pywebio.output import *
+from pywebio.utils import to_coroutine, run_as_function
+
+
+def target():
+    template.basic_output()
+    template.background_output()
+
+    run_as_function(template.basic_input())
+    actions(buttons=['Continue'])
+    template.background_input()
+
+
+async def async_target():
+    template.basic_output()
+    await template.coro_background_output()
+
+    await to_coroutine(template.basic_input())
+    await actions(buttons=['Continue'])
+    await template.coro_background_input()
+
+
+def test(server_proc: subprocess.Popen, browser: Chrome):
+    template.test_output(browser)
+    time.sleep(1)
+    template.test_input(browser)
+    time.sleep(1)
+    template.save_output(browser, '14.flask_multiple_session_impliment_p1.html')
+
+    browser.get('http://localhost:8080?_pywebio_debug=1&pywebio_api=io2&_pywebio_http_pull_interval=400')
+    template.test_output(browser)
+    time.sleep(1)
+    template.test_input(browser)
+
+    time.sleep(1)
+    template.save_output(browser, '14.flask_multiple_session_impliment_p2.html')
+
+
+urlpatterns = []
+
+
+def start_test_server():
+    global urlpatterns
+
+    pywebio.enable_debug()
+    import threading
+    from functools import partial
+    from pywebio.platform.django import webio_view, run_event_loop
+    from django.conf import settings
+    from django.core.wsgi import get_wsgi_application
+    from django.urls import path
+    from django.utils.crypto import get_random_string
+    from django.views.static import serve
+    from pywebio import STATIC_PATH
+
+    django_options = dict(
+        DEBUG=True,
+        ALLOWED_HOSTS=["*"],  # Disable host header validation
+        ROOT_URLCONF=__name__,  # Make this module the urlconf
+        SECRET_KEY=get_random_string(10),  # We aren't using any security features but Django requires this setting
+    )
+    django_options.setdefault('LOGGING', {
+        'version': 1,
+        'disable_existing_loggers': False,
+        'formatters': {
+            'simple': {
+                'format': '[%(asctime)s] %(message)s'
+            },
+        },
+        'handlers': {
+            'console': {
+                'class': 'logging.StreamHandler',
+                'formatter': 'simple'
+            },
+        },
+        'loggers': {
+            'django.server': {
+                'level': 'INFO',
+                'handlers': ['console'],
+            },
+        },
+    })
+    settings.configure(**django_options)
+
+    urlpatterns = [
+        path(r"io", webio_view(target)),
+        path(r"io2", webio_view(async_target)),
+        path(r'', partial(serve, path='index.html'), {'document_root': STATIC_PATH}),
+        path(r'<path:path>', serve, {'document_root': STATIC_PATH}),
+    ]
+
+    app = get_wsgi_application()  # load app
+
+    threading.Thread(target=run_event_loop, daemon=True).start()
+
+    import tornado.wsgi
+    container = tornado.wsgi.WSGIContainer(app)
+    http_server = tornado.httpserver.HTTPServer(container)
+    http_server.listen(8080, address='127.0.0.1')
+    tornado.ioloop.IOLoop.current().start()
+
+
+if __name__ == '__main__':
+    util.run_test(start_test_server, test,
+                  address='http://localhost:8080?_pywebio_debug=1&_pywebio_http_pull_interval=400')

+ 27 - 16
webiojs/src/main.ts

@@ -10,11 +10,9 @@ import {DownloadHandler} from "./handlers/download";
 import {ToastHandler} from "./handlers/toast";
 import {EnvSettingHandler} from "./handlers/env";
 
-// 获取后端API地址
-function get_backend_addr() {
-    const url = new URLSearchParams(window.location.search);
-    let uri = url.get('pywebio_api') || './io';
-    return new URL(uri, window.location.href).href;
+// 获取后端API的绝对地址
+function backend_absaddr(addr: string) {
+    return new URL(addr, window.location.href).href;
 }
 
 // 初始化Handler和Session
@@ -46,24 +44,37 @@ function set_up_session(webio_session: Session, output_container_elem: JQuery, i
     });
 }
 
-function startWebIOClient(output_container_elem: JQuery, input_container_elem: JQuery, app_name: string, config: { [name: string]: any }) {
-    for (let key in config) {
+
+function startWebIOClient(options: {
+    output_container_elem: JQuery,
+    input_container_elem: JQuery,
+    backend_address: string,
+    app_name: string,
+    protocol: string, // 'http', 'ws', 'auto'
+    runtime_config: { [name: string]: any }
+}) {
+    for (let key in options.runtime_config) {
         // @ts-ignore
-        appConfig[key] = config[key];
+        appConfig[key] = options.runtime_config[key];
     }
-    const backend_addr = get_backend_addr();
-    is_http_backend(backend_addr).then(function (http_backend) {
+    const backend_addr = backend_absaddr(options.backend_address);
+
+    let start_session = (is_http:boolean) => {
         let session;
-        if (http_backend)
-            session = new HttpSession(backend_addr, app_name, appConfig.httpPullInterval);
+        if (is_http)
+            session = new HttpSession(backend_addr, options.app_name, appConfig.httpPullInterval);
         else
-            session = new WebSocketSession(backend_addr, app_name);
-        set_up_session(session, output_container_elem, input_container_elem);
+            session = new WebSocketSession(backend_addr, options.app_name);
+        set_up_session(session, options.output_container_elem, options.input_container_elem);
         session.start_session(appConfig.debug);
-    });
-
+    };
+    if(options.protocol=='auto')
+        is_http_backend(backend_addr).then(start_session);
+    else
+        start_session(options.protocol == 'http')
 }
 
+
 // @ts-ignore
 window.WebIO = {
     'startWebIOClient': startWebIOClient,

+ 1 - 1
webiojs/src/session.ts

@@ -128,7 +128,7 @@ export class WebSocketSession implements Session {
 
 export class HttpSession implements Session {
     interval_pull_id: number = null;
-    webio_session_id: string;
+    webio_session_id: string = 'NEW';
     debug = false;
 
     private _closed = false;