瀏覽代碼

feat: support session reconnection using `reconnect_timeout` parameter

wangweimin 4 年之前
父節點
當前提交
2c68a45e6d

+ 26 - 17
docs/locales/zh_CN/LC_MESSAGES/platform.po

@@ -7,8 +7,8 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PyWebIO 1.1.0\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2021-03-14 21:20+0800\n"
-"PO-Revision-Date: 2021-03-14 21:31+0800\n"
+"POT-Creation-Date: 2021-03-16 22:45+0800\n"
+"PO-Revision-Date: 2021-03-16 23:55+0800\n"
 "Last-Translator: WangWeimin <wang0.618@qq.com>\n"
 "Language: zh_CN\n"
 "Language-Team: \n"
@@ -114,7 +114,16 @@ msgstr ""
 "例如 ``http_static_dir`` 路径下存在文件 ``A/B.jpg`` ,则其URL为 ``http://<host>:"
 "<port>/static/A/B.jpg``。"
 
-#: of pywebio.platform.path_deploy:17
+#: of pywebio.platform.path_deploy:16 pywebio.platform.tornado.start_server:31
+msgid ""
+"The client can reconnect to server within ``reconnect_timeout`` seconds after an "
+"unexpected disconnection. If set to 0 (default), once the client disconnects, the "
+"server session will be closed."
+msgstr ""
+"客户端重连超时时间(秒)。客户端若意外与服务端断开连接,在 ``reconnect_timeout`` 秒内"
+"可以重新连接并自动恢复会话。"
+
+#: of pywebio.platform.path_deploy:18
 msgid ""
 "The rest arguments of ``path_deploy()`` have the same meaning as for :func:"
 "`pywebio.platform.tornado.start_server`"
@@ -297,7 +306,7 @@ msgstr ""
 "例如 ``http_static_dir`` 路径下存在文件 ``A/B.jpg`` ,则其URL为 ``http://<host>:"
 "<port>/static/A/B.jpg``。"
 
-#: of pywebio.platform.tornado.start_server:31
+#: of pywebio.platform.tornado.start_server:33
 msgid ""
 "The allowed request source list. (The current server host is always allowed) The "
 "source contains the protocol, domain name, and port part. Can use Unix shell-style "
@@ -307,7 +316,7 @@ msgid ""
 "detail, see `Python Doc <https://docs.python.org/zh-tw/3/library/fnmatch.html>`_"
 msgstr ""
 
-#: of pywebio.platform.tornado.start_server:31
+#: of pywebio.platform.tornado.start_server:33
 msgid ""
 "The allowed request source list. (The current server host is always allowed) The "
 "source contains the protocol, domain name, and port part. Can use Unix shell-style "
@@ -316,34 +325,34 @@ msgstr ""
 "除当前域名外,服务器还允许的请求的来源列表。来源包含协议、域名和端口部分,允许使用 "
 "Unix shell 风格的匹配模式:"
 
-#: of pywebio.platform.tornado.start_server:35
+#: of pywebio.platform.tornado.start_server:37
 msgid "``*`` matches everything"
 msgstr "``*`` 为通配符"
 
-#: of pywebio.platform.tornado.start_server:36
+#: of pywebio.platform.tornado.start_server:38
 msgid "``?`` matches any single character"
 msgstr "``?`` 匹配单个字符"
 
-#: of pywebio.platform.tornado.start_server:37
+#: of pywebio.platform.tornado.start_server:39
 msgid "``[seq]`` matches any character in *seq*"
 msgstr "``[seq]`` 匹配seq中的任何字符"
 
-#: of pywebio.platform.tornado.start_server:38
+#: of pywebio.platform.tornado.start_server:40
 msgid "``[!seq]`` matches any character not in *seq*"
 msgstr "``[!seq]`` 匹配任何不在seq中的字符"
 
-#: of pywebio.platform.tornado.start_server:40
+#: of pywebio.platform.tornado.start_server:42
 msgid "Such as: ``https://*.example.com`` 、 ``*://*.example.com``"
 msgstr "比如 ``https://*.example.com`` 、 ``*://*.example.com``"
 
-#: of pywebio.platform.tornado.start_server:42
+#: of pywebio.platform.tornado.start_server:44
 msgid ""
 "For detail, see `Python Doc <https://docs.python.org/zh-tw/3/library/fnmatch."
 "html>`_"
 msgstr ""
 "全部规则参见 `Python文档 <https://docs.python.org/zh-tw/3/library/fnmatch.html>`_ "
 
-#: of pywebio.platform.tornado.start_server:43
+#: of pywebio.platform.tornado.start_server:45
 msgid ""
 "The validation function for request source. It receives the source string (which "
 "contains protocol, host, and port parts) as parameter and return ``True/False`` to "
@@ -354,13 +363,13 @@ msgstr ""
 "返回 ``True/False`` 指示服务器接受/拒绝该请求。若设置了 ``check_origin`` , "
 "``allowed_origins`` 参数将被忽略"
 
-#: of pywebio.platform.tornado.start_server:46
+#: of pywebio.platform.tornado.start_server:48
 msgid ""
 "Whether or not auto open web browser when server is started (if the operating "
 "system allows it) ."
 msgstr "当服务启动后,是否自动打开浏览器来访问服务。(该操作需要操作系统支持)"
 
-#: of pywebio.platform.tornado.start_server:47
+#: of pywebio.platform.tornado.start_server:49
 msgid ""
 "Max bytes of a message which Tornado can accept. Messages larger than the "
 "``websocket_max_message_size`` (default 10MB) will not be accepted. "
@@ -369,7 +378,7 @@ msgid ""
 "gigabytes, respectively). E.g: ``500``, ``'40K'``, ``'3M'``"
 msgstr ""
 
-#: of pywebio.platform.tornado.start_server:52
+#: of pywebio.platform.tornado.start_server:54
 msgid ""
 "If set to a number, all websockets will be pinged every n seconds. This can help "
 "keep the connection alive through certain proxy servers which close idle "
@@ -382,7 +391,7 @@ msgstr ""
 "WebSockets连接被代理服务器当作空闲连接而关闭。\n"
 "同时,若WebSockets连接在某些情况下被异常关闭,应用也可以及时感知。"
 
-#: of pywebio.platform.tornado.start_server:55
+#: of pywebio.platform.tornado.start_server:57
 msgid ""
 "If the ping interval is set, and the server doesn’t receive a ‘pong’ in this many "
 "seconds, it will close the websocket. The default is three times the ping "
@@ -394,7 +403,7 @@ msgstr ""
 "内收到'pong'消息,应用会将连接关闭。默认的超时时间为 ``websocket_ping_interval`` 的"
 "三倍。"
 
-#: of pywebio.platform.tornado.start_server:58
+#: of pywebio.platform.tornado.start_server:60
 msgid ""
 "Additional keyword arguments passed to the constructor of ``tornado.web."
 "Application``. For details, please refer: https://www.tornadoweb.org/en/stable/web."

+ 217 - 203
docs/locales/zh_CN/LC_MESSAGES/spec.po

@@ -7,16 +7,17 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PyWebIO 1.1.0\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2021-02-18 11:52+0800\n"
-"PO-Revision-Date: 2021-02-18 10:50+0800\n"
+"POT-Creation-Date: 2021-03-16 22:45+0800\n"
+"PO-Revision-Date: 2021-03-16 23:55+0800\n"
 "Last-Translator: WangWeimin <wang0.618@qq.com>\n"
 "Language: zh_CN\n"
 "Language-Team: \n"
-"Plural-Forms: nplurals=1; plural=0\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
 "MIME-Version: 1.0\n"
 "Content-Type: text/plain; charset=utf-8\n"
 "Content-Transfer-Encoding: 8bit\n"
 "Generated-By: Babel 2.8.0\n"
+"X-Generator: Poedit 2.4.2\n"
 
 #: ../../spec.rst:2
 msgid "Server-Client communication protocol"
@@ -24,26 +25,28 @@ msgstr "服务器-客户端通信协议"
 
 #: ../../spec.rst:4
 msgid ""
-"PyWebIO uses a server-client architecture, the server executes task code,"
-" and interacts with the client (that is, the user browser) through the "
-"network. This section introduce the protocol specification for the "
-"communication between PyWebIO server and client."
-msgstr "PyWebIO采用服务器-客户端架构,服务端运行任务代码,通过网络与客户端(也就是用户浏览器)交互。本章介绍PyWebIO服务端与客户端通信的协议。"
+"PyWebIO uses a server-client architecture, the server executes task code, and "
+"interacts with the client (that is, the user browser) through the network. This "
+"section introduce the protocol specification for the communication between "
+"PyWebIO server and client."
+msgstr ""
+"PyWebIO采用服务器-客户端架构,服务端运行任务代码,通过网络与客户端(也就是用户浏"
+"览器)交互。本章介绍PyWebIO服务端与客户端通信的协议。"
 
 #: ../../spec.rst:6
 msgid ""
-"There are two communication methods between server and client: WebSocket "
-"and Http."
+"There are two communication methods between server and client: WebSocket and "
+"Http."
 msgstr "服务器与客户端有两种通信方式:WebSocket 和 Http 通信。"
 
 #: ../../spec.rst:8
 msgid ""
-"When using Tornado or aiohttp backend, the server and client communicate "
-"through WebSocket, when using Flask or Django backend, the server and "
-"client communicate through Http."
+"When using Tornado or aiohttp backend, the server and client communicate through "
+"WebSocket, when using Flask or Django backend, the server and client communicate "
+"through Http."
 msgstr ""
-"使用 Tornado或aiohttp 后端时,服务器与客户端通过 WebSocket 通信,使用 Flask或Django "
-"后端时,服务器与客户端通过 Http 通信。"
+"使用 Tornado或aiohttp 后端时,服务器与客户端通过 WebSocket 通信,使用 Flask或"
+"Django 后端时,服务器与客户端通过 Http 通信。"
 
 #: ../../spec.rst:10
 msgid "**WebSocket communication**"
@@ -61,21 +64,24 @@ msgstr "**Http 通信:**"
 
 #: ../../spec.rst:16
 msgid ""
-"The client polls the backend through Http GET requests, and the backend "
-"returns a list of PyWebIO messages serialized in json"
-msgstr "* 客户端通过Http GET请求向后端轮询,后端返回json序列化之后的PyWebIO消息列表"
+"The client polls the backend through Http GET requests, and the backend returns "
+"a list of PyWebIO messages serialized in json"
+msgstr ""
+"* 客户端通过Http GET请求向后端轮询,后端返回json序列化之后的PyWebIO消息列表"
 
 #: ../../spec.rst:18
 msgid ""
-"When the user submits the form or clicks the button, the client submits "
-"data to the backend through Http POST request"
+"When the user submits the form or clicks the button, the client submits data to "
+"the backend through Http POST request"
 msgstr "* 当用户提交表单或者点击页面按钮后,客户端通过Http POST请求向后端提交数据"
 
 #: ../../spec.rst:20
 msgid ""
-"In the following, the data sent by the server to the client is called "
-"command, and the data sent by the client to the server is called event."
-msgstr "为方便区分,下文将由服务器向客户端发送的数据称作command,将客户端发向服务器的数据称作event"
+"In the following, the data sent by the server to the client is called command, "
+"and the data sent by the client to the server is called event."
+msgstr ""
+"为方便区分,下文将由服务器向客户端发送的数据称作command,将客户端发向服务器的数据"
+"称作event"
 
 #: ../../spec.rst:22
 msgid "The following describes the format of command and event"
@@ -87,8 +93,7 @@ msgstr ""
 
 #: ../../spec.rst:27
 msgid ""
-"Command is sent by the server to the client. The basic format of command "
-"is::"
+"Command is sent by the server to the client. The basic format of command is::"
 msgstr "command由服务器->客户端,基本格式为::"
 
 #: ../../spec.rst:29
@@ -110,20 +115,22 @@ msgstr "``command`` 字段表示指令名"
 
 #: ../../spec.rst:39
 msgid "``task_id`` : Id of the task that send the command"
-msgstr "``task_id`` 字段表示发送指令的Task id,客户端对于此命令的响应事件都会传递 task_id"
+msgstr ""
+"``task_id`` 字段表示发送指令的Task id,客户端对于此命令的响应事件都会传递 task_id"
 
 #: ../../spec.rst:41
 msgid ""
-"``spec`` : the data of the command, which is different depending on the "
-"command name"
+"``spec`` : the data of the command, which is different depending on the command "
+"name"
 msgstr "``spec`` 字段为指令的参数,不同指令参数不同"
 
 #: ../../spec.rst:43
 msgid ""
-"Note that: the arguments shown above are merely the same with the "
-"parameters of corresponding PyWebIO functions, but there are some "
-"differences."
-msgstr "需要注意,以下不同命令的参数和 PyWebIO 的对应函数的参数大部分含义一致,但是也有些许不同。"
+"Note that: the arguments shown above are merely the same with the parameters of "
+"corresponding PyWebIO functions, but there are some differences."
+msgstr ""
+"需要注意,以下不同命令的参数和 PyWebIO 的对应函数的参数大部分含义一致,但是也有些"
+"许不同。"
 
 #: ../../spec.rst:46
 msgid "The following describes the ``spec`` fields of different commands:"
@@ -203,20 +210,19 @@ msgstr "表单是否可以取消"
 
 #: ../../spec.rst
 msgid ""
-"If cancelable=True, a “Cancel” button will be displayed at the bottom of "
-"the form."
+"If cancelable=True, a “Cancel” button will be displayed at the bottom of the "
+"form."
 msgstr "若 ``cancelable=True`` 则会在表单底部显示一个”取消”按钮,"
 
 #: ../../spec.rst
 msgid ""
-"A ``from_cancel`` event is triggered after the user clicks the cancel "
-"button."
+"A ``from_cancel`` event is triggered after the user clicks the cancel button."
 msgstr "用户点击取消按钮后,触发 ``from_cancel`` 事件"
 
 #: ../../spec.rst:77
 msgid ""
-"The ``inputs`` field is a list of input items, each input item is a "
-"``dict``, the fields of the item are as follows:"
+"The ``inputs`` field is a list of input items, each input item is a ``dict``, "
+"the fields of the item are as follows:"
 msgstr "``inputs`` 字段为输入项组成的列表,每一输入项为一个 ``dict``,字段如下:"
 
 #: ../../spec.rst:79
@@ -233,8 +239,8 @@ msgstr "name: 输入项id。必选"
 
 #: ../../spec.rst:82
 msgid ""
-"auto_focus: Set focus automatically. At most one item of ``auto_focus`` "
-"can be true in the input item list"
+"auto_focus: Set focus automatically. At most one item of ``auto_focus`` can be "
+"true in the input item list"
 msgstr "auto_focus: 自动获取输入焦点. 输入项列表中最多只能由一项的auto_focus为真"
 
 #: ../../spec.rst:83
@@ -315,14 +321,13 @@ msgstr ""
 
 #: ../../spec.rst:106
 msgid ""
-"select: select  https://developer.mozilla.org/zh-"
-"CN/docs/Web/HTML/Element/select"
+"select: select  https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/select"
 msgstr ""
 
 #: ../../spec.rst:107
 msgid ""
-"textarea: textarea  https://developer.mozilla.org/zh-"
-"CN/docs/Web/HTML/Element/textarea"
+"textarea: textarea  https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/"
+"textarea"
 msgstr ""
 
 #: ../../spec.rst:108
@@ -331,8 +336,8 @@ msgstr ""
 
 #: ../../spec.rst:109
 msgid ""
-"actions: button[type=submit] https://developer.mozilla.org/zh-"
-"CN/docs/Web/HTML/Element/button"
+"actions: button[type=submit] https://developer.mozilla.org/zh-CN/docs/Web/HTML/"
+"Element/button"
 msgstr ""
 
 #: ../../spec.rst:111
@@ -341,14 +346,13 @@ msgstr ""
 
 #: ../../spec.rst:113
 msgid ""
-"text,number,password: * action: Display a button on the right of the "
-"input field."
+"text,number,password: * action: Display a button on the right of the input field."
 msgstr ""
 
 #: ../../spec.rst:115
 msgid ""
-"The format of ``action`` is ``{label: button label, callback_id: button "
-"click callback id}``"
+"The format of ``action`` is ``{label: button label, callback_id: button click "
+"callback id}``"
 msgstr ""
 
 #: ../../spec.rst:117
@@ -357,8 +361,8 @@ msgstr ""
 
 #: ../../spec.rst:119
 msgid ""
-"code: Codemirror options, same as ``code`` parameter of "
-":func:`pywebio.input.textarea`"
+"code: Codemirror options, same as ``code`` parameter of :func:`pywebio.input."
+"textarea`"
 msgstr "code: Codemirror 参数, 见 :func:`pywebio.input.textarea` 的 ``code`` 参数"
 
 #: ../../spec.rst:121
@@ -387,8 +391,7 @@ msgstr ""
 
 #: ../../spec.rst:137
 msgid ""
-"buttons: ``{label:, value:, [type: 'submit'/'reset'/'cancel'], "
-"[disabled:]}`` ."
+"buttons: ``{label:, value:, [type: 'submit'/'reset'/'cancel'], [disabled:]}`` ."
 msgstr ""
 
 #: ../../spec.rst:140
@@ -413,8 +416,8 @@ msgstr ""
 
 #: ../../spec.rst:149
 msgid ""
-"Update the input item, you can update the ``spec`` of the input item of "
-"the currently displayed form"
+"Update the input item, you can update the ``spec`` of the input item of the "
+"currently displayed form"
 msgstr "更新输入项,用于对当前显示表单中输入项的 ``spec`` 进行更新"
 
 #: ../../spec.rst:151
@@ -427,8 +430,8 @@ msgstr ""
 
 #: ../../spec.rst:154
 msgid ""
-"target_value: str, optional. Used to filter options in checkbox, radio, "
-"actions type"
+"target_value: str, optional. Used to filter options in checkbox, radio, actions "
+"type"
 msgstr ""
 
 #: ../../spec.rst:155
@@ -437,8 +440,8 @@ msgstr ""
 
 #: ../../spec.rst:157
 msgid ""
-"valid_status: When it is bool, it means setting the state of the input "
-"value, pass/fail; when it is 0, it means clear the valid_status flag"
+"valid_status: When it is bool, it means setting the state of the input value, "
+"pass/fail; when it is 0, it means clear the valid_status flag"
 msgstr ""
 
 #: ../../spec.rst:158
@@ -467,363 +470,379 @@ msgstr ""
 
 #: ../../spec.rst:167
 msgid ""
-"Indicates that the server has closed the connection. ``spec`` of the "
-"command is empty."
+"Indicates that the server has closed the connection. ``spec`` of the command is "
+"empty."
 msgstr "指示服务器端已经关闭连接。 ``spec`` 为空"
 
+#: ../../spec.rst:170
+msgid "set_session_id"
+msgstr ""
+
 #: ../../spec.rst:171
+msgid ""
+"Send current session id to client, used to reconnect to server (Only available "
+"in websocket connection). ``spec`` of the command is session id."
+msgstr ""
+"将当前会话id发送至客户端,客户端可以使用此id来重连会话(仅在websocket连接中可"
+"用)。 ``spec`` 字段为会话id"
+
+#: ../../spec.rst:175
 msgid "destroy_form"
 msgstr ""
 
-#: ../../spec.rst:172
+#: ../../spec.rst:176
 msgid "Destroy the current form. ``spec`` of the command is empty."
 msgstr ""
 
-#: ../../spec.rst:174
+#: ../../spec.rst:178
 msgid ""
-"Note: The form will not be automatically destroyed after it is submitted,"
-" it needs to be explicitly destroyed using this command"
+"Note: The form will not be automatically destroyed after it is submitted, it "
+"needs to be explicitly destroyed using this command"
 msgstr "表单在页面上提交之后不会自动销毁,需要使用此命令显式销毁"
 
-#: ../../spec.rst:177
+#: ../../spec.rst:181
 msgid "output"
 msgstr ""
 
-#: ../../spec.rst:178
+#: ../../spec.rst:182
 msgid "Output content"
 msgstr ""
 
-#: ../../spec.rst:180
+#: ../../spec.rst:184
 msgid "The ``spec`` fields of ``output`` commands:"
 msgstr ""
 
-#: ../../spec.rst:182
+#: ../../spec.rst:186
 msgid "type: content type"
 msgstr ""
 
-#: ../../spec.rst:183
+#: ../../spec.rst:187
 msgid "style: str, Additional css style"
 msgstr ""
 
-#: ../../spec.rst:184
+#: ../../spec.rst:188
 msgid ""
-"scope: str, CSS selector of the output container. If multiple containers "
-"are matched, the content will be output to every matched container"
-msgstr "scope: str, 内容输出的域的css选择器。若CSS选择器匹配到页面上的多个容器,则内容会输出到每个匹配到的容器"
+"scope: str, CSS selector of the output container. If multiple containers are "
+"matched, the content will be output to every matched container"
+msgstr ""
+"scope: str, 内容输出的域的css选择器。若CSS选择器匹配到页面上的多个容器,则内容会"
+"输出到每个匹配到的容器"
 
-#: ../../spec.rst:185
+#: ../../spec.rst:189
 msgid "position: int, see :ref:`scope - User manual <scope_param>`"
 msgstr "int, 在输出域中输出的位置, 见 :ref:`输出函数的scope相关参数 <scope_param>`"
 
-#: ../../spec.rst:186
+#: ../../spec.rst:190
 msgid "Other attributes of different types"
 msgstr "不同type时的特有字段"
 
-#: ../../spec.rst:188
+#: ../../spec.rst:192
 msgid "Unique attributes of different types:"
 msgstr "``type`` 的可选值及特有字段:"
 
-#: ../../spec.rst:190
+#: ../../spec.rst:194
 msgid "type: markdown"
 msgstr ""
 
-#: ../../spec.rst:192 ../../spec.rst:198 ../../spec.rst:203
+#: ../../spec.rst:196 ../../spec.rst:202 ../../spec.rst:207
 msgid "content: str"
 msgstr ""
 
-#: ../../spec.rst:193
+#: ../../spec.rst:197
 msgid "options: dict, `marked.js <https://github.com/markedjs/marked>`_ options"
 msgstr ""
 
-#: ../../spec.rst:194 ../../spec.rst:199
+#: ../../spec.rst:198 ../../spec.rst:203
 msgid "sanitize: bool, Whether to enable a XSS sanitizer for HTML"
 msgstr ""
-"sanitize: bool, 是否使用 `DOMPurify <https://github.com/cure53/DOMPurify>`_ "
-"对内容进行过滤来防止XSS攻击。"
+"sanitize: bool, 是否使用 `DOMPurify <https://github.com/cure53/DOMPurify>`_ 对内"
+"容进行过滤来防止XSS攻击。"
 
-#: ../../spec.rst:196
+#: ../../spec.rst:200
 msgid "type: html"
 msgstr ""
 
-#: ../../spec.rst:201
+#: ../../spec.rst:205
 msgid "type: text"
 msgstr ""
 
-#: ../../spec.rst:204
+#: ../../spec.rst:208
 msgid ""
-"inline: bool, Use text as an inline element (no line break at the end of "
-"the text)"
+"inline: bool, Use text as an inline element (no line break at the end of the "
+"text)"
 msgstr ""
 
-#: ../../spec.rst:206
+#: ../../spec.rst:210
 msgid "type: buttons"
 msgstr ""
 
-#: ../../spec.rst:208
+#: ../../spec.rst:212
 msgid "callback_id:"
 msgstr ""
 
-#: ../../spec.rst:209
+#: ../../spec.rst:213
 msgid "buttons:[ {value:, label:, [color:]},...]"
 msgstr ""
 
-#: ../../spec.rst:210
+#: ../../spec.rst:214
 msgid "small: bool, Whether to enable small button"
 msgstr "small: bool,是否显示为小按钮样式"
 
-#: ../../spec.rst:211
+#: ../../spec.rst:215
 msgid "link: bool, Whether to make button seem as link."
 msgstr "link: bool,是否显示为链接样式"
 
-#: ../../spec.rst:213
+#: ../../spec.rst:217
 msgid "type: file"
 msgstr ""
 
-#: ../../spec.rst:215
+#: ../../spec.rst:219
 msgid "name: File name when downloading"
 msgstr "name: 下载保存为的文件名"
 
-#: ../../spec.rst:216
+#: ../../spec.rst:220
 msgid "content: File content with base64 encoded"
 msgstr "content: 文件base64编码的内容"
 
-#: ../../spec.rst:218
+#: ../../spec.rst:222
 msgid "type: table"
 msgstr ""
 
-#: ../../spec.rst:220
+#: ../../spec.rst:224
 msgid ""
-"data: Table data, which is a two-dimensional list, the first row is table"
-" header."
+"data: Table data, which is a two-dimensional list, the first row is table header."
 msgstr ""
 
-#: ../../spec.rst:221
+#: ../../spec.rst:225
 msgid ""
-"span: cell span info. Format: {\"[row id],[col id]\": {\"row\":row span, "
-"\"col\":col span }}"
+"span: cell span info. Format: {\"[row id],[col id]\": {\"row\":row span, \"col\":"
+"col span }}"
 msgstr ""
 
-#: ../../spec.rst:224
+#: ../../spec.rst:228
 msgid "popup"
 msgstr ""
 
-#: ../../spec.rst:225
+#: ../../spec.rst:229
 msgid "Show popup"
 msgstr ""
 
-#: ../../spec.rst:227 ../../spec.rst:240
+#: ../../spec.rst:231 ../../spec.rst:244
 msgid "The ``spec`` fields of ``popup`` commands:"
 msgstr ""
 
-#: ../../spec.rst:229
+#: ../../spec.rst:233
 msgid "title"
 msgstr ""
 
-#: ../../spec.rst:230 ../../spec.rst:242
+#: ../../spec.rst:234 ../../spec.rst:246
 msgid "content"
 msgstr ""
 
-#: ../../spec.rst:231
+#: ../../spec.rst:235
 msgid "size: ``large``, ``normal``, ``small``"
 msgstr ""
 
-#: ../../spec.rst:232
+#: ../../spec.rst:236
 msgid "implicit_close"
 msgstr ""
 
-#: ../../spec.rst:233
+#: ../../spec.rst:237
 msgid "closable"
 msgstr ""
 
-#: ../../spec.rst:234
+#: ../../spec.rst:238
 msgid "dom_id: DOM id of popup container element"
 msgstr ""
 
-#: ../../spec.rst:237
+#: ../../spec.rst:241
 msgid "toast"
 msgstr ""
 
-#: ../../spec.rst:238
+#: ../../spec.rst:242
 msgid "Show a notification message"
 msgstr ""
 
-#: ../../spec.rst:243
+#: ../../spec.rst:247
 msgid "duration"
 msgstr ""
 
-#: ../../spec.rst:244
+#: ../../spec.rst:248
 msgid "position: `'left'` / `'center'` / `'right'`"
 msgstr ""
 
-#: ../../spec.rst:245
+#: ../../spec.rst:249
 msgid "color: hexadecimal color value starting with '#'"
 msgstr ""
 
-#: ../../spec.rst:246
+#: ../../spec.rst:250
 msgid "callback_id"
 msgstr ""
 
-#: ../../spec.rst:250
+#: ../../spec.rst:254
 msgid "close_popup"
 msgstr ""
 
-#: ../../spec.rst:251
+#: ../../spec.rst:255
 msgid "Close the current popup window."
 msgstr ""
 
-#: ../../spec.rst:253
+#: ../../spec.rst:257
 msgid "``spec`` of the command is empty."
 msgstr ""
 
-#: ../../spec.rst:256
+#: ../../spec.rst:260
 msgid "set_env"
 msgstr ""
 
-#: ../../spec.rst:257
+#: ../../spec.rst:261
 msgid "Config the environment of current session."
 msgstr ""
 
-#: ../../spec.rst:259
+#: ../../spec.rst:263
 msgid "The ``spec`` fields of ``set_env`` commands:"
 msgstr ""
 
-#: ../../spec.rst:261
+#: ../../spec.rst:265
 msgid "title (str)"
 msgstr ""
 
-#: ../../spec.rst:262
+#: ../../spec.rst:266
 msgid "output_animation (bool)"
 msgstr ""
 
-#: ../../spec.rst:263
+#: ../../spec.rst:267
 msgid "auto_scroll_bottom (bool)"
 msgstr ""
 
-#: ../../spec.rst:264
+#: ../../spec.rst:268
 msgid "http_pull_interval (int)"
 msgstr ""
 
-#: ../../spec.rst:267
+#: ../../spec.rst:271
 msgid "output_ctl"
 msgstr ""
 
-#: ../../spec.rst:268
+#: ../../spec.rst:272
 msgid "Output control"
 msgstr ""
 
-#: ../../spec.rst:270
+#: ../../spec.rst:274
 msgid "The ``spec`` fields of ``output_ctl`` commands:"
 msgstr ""
 
-#: ../../spec.rst:272
+#: ../../spec.rst:276
 msgid "set_scope: scope name"
 msgstr ""
 
-#: ../../spec.rst:274
+#: ../../spec.rst:278
 msgid "container: Specify css selector to the parent scope of target scope."
 msgstr "container: 新创建的scope的父scope的css选择器"
 
-#: ../../spec.rst:275
+#: ../../spec.rst:279
 msgid "position: int, The index where this scope is created in the parent scope."
 msgstr "position: int, 在父scope中创建此scope的位置."
 
-#: ../../spec.rst:276
+#: ../../spec.rst:280
 msgid "if_exist: What to do when the specified scope already exists:"
 msgstr "scope已经存在时如何操作:"
 
-#: ../../spec.rst:278
+#: ../../spec.rst:282
 msgid "null: Do nothing"
 msgstr "null/不指定: 表示立即返回不进行任何操作"
 
-#: ../../spec.rst:279
+#: ../../spec.rst:283
 msgid "`'remove'`: Remove the old scope first and then create a new one"
 msgstr "`’remove’` : 先移除旧scope再创建新scope"
 
-#: ../../spec.rst:280
+#: ../../spec.rst:284
 msgid ""
-"`'clear'`: Just clear the contents of the old scope, but don’t create a "
-"new scope"
+"`'clear'`: Just clear the contents of the old scope, but don’t create a new scope"
 msgstr "`’clear’` : 将旧scope的内容清除,不创建新scope"
 
-#: ../../spec.rst:282
+#: ../../spec.rst:286
 msgid "clear: css selector of the scope need to clear"
 msgstr "clear: 需要清空的scope的css选择器"
 
-#: ../../spec.rst:283
+#: ../../spec.rst:287
 msgid "clear_before"
 msgstr ""
 
-#: ../../spec.rst:284
+#: ../../spec.rst:288
 msgid "clear_after"
 msgstr ""
 
-#: ../../spec.rst:285
+#: ../../spec.rst:289
 msgid "clear_range:[,]"
 msgstr ""
 
-#: ../../spec.rst:286
+#: ../../spec.rst:290
 msgid "scroll_to"
 msgstr ""
 
-#: ../../spec.rst:287
+#: ../../spec.rst:291
 msgid ""
-"position: top/middle/bottom, Where to place the scope in the visible area"
-" of the page"
-msgstr "position: top/middle/bottom 与scroll_to一起出现, 表示滚动页面,让scope位于屏幕可视区域顶部/中部/底部"
+"position: top/middle/bottom, Where to place the scope in the visible area of the "
+"page"
+msgstr ""
+"position: top/middle/bottom 与scroll_to一起出现, 表示滚动页面,让scope位于屏幕可"
+"视区域顶部/中部/底部"
 
-#: ../../spec.rst:288
+#: ../../spec.rst:292
 msgid "remove: Remove the specified scope"
 msgstr "remove: 将给定的scope连同scope处的内容移除"
 
-#: ../../spec.rst:291
+#: ../../spec.rst:295
 msgid "run_script"
 msgstr ""
 
-#: ../../spec.rst:292
+#: ../../spec.rst:296
 msgid "run javascript code in user's browser"
 msgstr ""
 
-#: ../../spec.rst:294
+#: ../../spec.rst:298
 msgid "The ``spec`` fields of ``run_script`` commands:"
 msgstr ""
 
-#: ../../spec.rst:296
+#: ../../spec.rst:300
 msgid "code: str, code"
 msgstr "code: 字符串格式的要运行的js代码"
 
-#: ../../spec.rst:297
+#: ../../spec.rst:301
 msgid "args: dict, Local variables passed to js code"
-msgstr "args: 传递给代码的局部变量。字典类型,字典键表示变量名,字典值表示变量值(变量值需要可以被json序列化)"
+msgstr ""
+"args: 传递给代码的局部变量。字典类型,字典键表示变量名,字典值表示变量值(变量值需"
+"要可以被json序列化)"
 
-#: ../../spec.rst:300
+#: ../../spec.rst:304
 msgid "download"
 msgstr ""
 
-#: ../../spec.rst:301
+#: ../../spec.rst:305
 msgid "Send file to user"
 msgstr ""
 
-#: ../../spec.rst:303
+#: ../../spec.rst:307
 msgid "The ``spec`` fields of ``download`` commands:"
 msgstr ""
 
-#: ../../spec.rst:305
+#: ../../spec.rst:309
 msgid "name: str, File name when downloading"
 msgstr ""
 
-#: ../../spec.rst:306
+#: ../../spec.rst:310
 msgid "content: str, File content in base64 encoding."
 msgstr ""
 
-#: ../../spec.rst:309
+#: ../../spec.rst:313
 msgid "Event"
 msgstr ""
 
-#: ../../spec.rst:311
+#: ../../spec.rst:315
 msgid "Event is sent by the client to the server. The basic format of event is::"
 msgstr "Event消息由客户端发往服务端。基本格式::"
 
-#: ../../spec.rst:313
+#: ../../spec.rst:317
 msgid ""
 "{\n"
 "    event: event name\n"
@@ -832,97 +851,92 @@ msgid ""
 "}"
 msgstr ""
 
-#: ../../spec.rst:319
+#: ../../spec.rst:323
 msgid ""
-"The ``data`` field is the data carried by the event, and its content "
-"varies according to the event. The ``data`` field of different events is "
-"as follows:"
-msgstr "``event`` 表示事件名称。 ``data`` 为事件所携带的数据,其根据事件不同内容也会不同,不同事件对应的 ``data`` 字段如下:"
+"The ``data`` field is the data carried by the event, and its content varies "
+"according to the event. The ``data`` field of different events is as follows:"
+msgstr ""
+"``event`` 表示事件名称。 ``data`` 为事件所携带的数据,其根据事件不同内容也会不"
+"同,不同事件对应的 ``data`` 字段如下:"
 
-#: ../../spec.rst:323
+#: ../../spec.rst:327
 msgid "input_event"
 msgstr ""
 
-#: ../../spec.rst:324
+#: ../../spec.rst:328
 msgid "Triggered when the form changes"
 msgstr "表单发生更改时触发"
 
-#: ../../spec.rst:326
+#: ../../spec.rst:330
 msgid ""
-"event_name: Current available value is ``'blur'``, which indicates that "
-"the input item loses focus"
+"event_name: Current available value is ``'blur'``, which indicates that the "
+"input item loses focus"
 msgstr "event_name: 目前可用值 ``’blur’``,表示输入项失去焦点"
 
-#: ../../spec.rst:327
+#: ../../spec.rst:331
 msgid "name: name of input item"
 msgstr "name: 输入项name"
 
-#: ../../spec.rst:328
+#: ../../spec.rst:332
 msgid "value: value of input item"
 msgstr "value: 输入项值"
 
-#: ../../spec.rst:330
+#: ../../spec.rst:334
 msgid "note: checkbox and radio do not generate blur events"
 msgstr "注意: checkbox radio 不产生blur事件"
 
-#: ../../spec.rst:335
+#: ../../spec.rst:339
 msgid "callback"
 msgstr ""
 
-#: ../../spec.rst:336
+#: ../../spec.rst:340
 msgid "Triggered when the user clicks the button in the page"
 msgstr "用户点击显示区的按钮时触发"
 
-#: ../../spec.rst:338
+#: ../../spec.rst:342
 msgid ""
-"In the ``callback`` event, ``task_id`` is the ``callback_id`` field of "
-"the ``button``; The ``data`` of the event is the ``value`` of the button "
-"that was clicked"
+"In the ``callback`` event, ``task_id`` is the ``callback_id`` field of the "
+"``button``; The ``data`` of the event is the ``value`` of the button that was "
+"clicked"
 msgstr ""
-"在 ``callback`` 事件中,``task_id`` 为对应的 ``button`` 组件的 ``callback_id`` 字段;\n"
+"在 ``callback`` 事件中,``task_id`` 为对应的 ``button`` 组件的 ``callback_id`` 字"
+"段;\n"
 "事件的 ``data`` 为被点击button的 ``value``"
 
-#: ../../spec.rst:342
+#: ../../spec.rst:346
 msgid "from_submit"
 msgstr ""
 
-#: ../../spec.rst:343
+#: ../../spec.rst:347
 msgid "Triggered when the user submits the form"
 msgstr "用户提交表单时触发"
 
-#: ../../spec.rst:345
+#: ../../spec.rst:349
 msgid ""
-"The ``data`` of the event is a dict, whose key is the name of the input "
-"item, and whose value is the value of the input item."
+"The ``data`` of the event is a dict, whose key is the name of the input item, "
+"and whose value is the value of the input item."
 msgstr "事件 ``data`` 字段为表单 ``name`` -> 表单值 的字典"
 
-#: ../../spec.rst:348
+#: ../../spec.rst:352
 msgid "from_cancel"
 msgstr ""
 
-#: ../../spec.rst:349
+#: ../../spec.rst:353
 msgid "Cancel input form"
 msgstr "表单取消输入"
 
-#: ../../spec.rst:351
+#: ../../spec.rst:355
 msgid "The ``data`` of the event is ``None``"
 msgstr ""
 
-#: ../../spec.rst:354
+#: ../../spec.rst:358
 msgid "js_yield"
 msgstr ""
 
-#: ../../spec.rst:355
+#: ../../spec.rst:359
 msgid "submit data from js"
 msgstr "js代码提交数据"
 
-#: ../../spec.rst:357
+#: ../../spec.rst:361
 msgid "The ``data`` of the event is the data need to submit"
 msgstr "事件 ``data`` 字段为相应的数据"
-
-#~ msgid ""
-#~ "other fields of item's ``spec`` // "
-#~ "not support to inline adn label "
-#~ "fields"
-#~ msgstr ""
-

+ 4 - 0
docs/spec.rst

@@ -166,6 +166,10 @@ close_session
 ^^^^^^^^^^^^^^^
 Indicates that the server has closed the connection. ``spec`` of the command is empty.
 
+set_session_id
+^^^^^^^^^^^^^^^
+Send current session id to client, used to reconnect to server (Only available in websocket connection).
+``spec`` of the command is session id.
 
 destroy_form
 ^^^^^^^^^^^^^^^

+ 5 - 2
pywebio/platform/path_deploy.py

@@ -171,6 +171,7 @@ def _path_deploy(base, port=0, host='',
 
 def path_deploy(base, port=0, host='',
                 index=True, static_dir=None,
+                reconnect_timeout=0,
                 cdn=True, debug=True,
                 allowed_origins=None, check_origin=None,
                 websocket_max_message_size=None,
@@ -192,7 +193,8 @@ def path_deploy(base, port=0, host='',
        The files in this directory can be accessed via ``http://<host>:<port>/static/files``.
        For example, if there is a ``A/B.jpg`` file in ``http_static_dir`` path,
        it can be accessed via ``http://<host>:<port>/static/A/B.jpg``.
-
+    :param int reconnect_timeout: The client can reconnect to server within ``reconnect_timeout`` seconds after an unexpected disconnection.
+       If set to 0 (default), once the client disconnects, the server session will be closed.
     The rest arguments of ``path_deploy()`` have the same meaning as for :func:`pywebio.platform.tornado.start_server`
     """
     gen = _path_deploy(base, port=port, host=host,
@@ -207,7 +209,8 @@ def path_deploy(base, port=0, host='',
 
     index_func = {True: partial(default_index_page, base=abs_base), False: lambda p: '403 Forbidden'}.get(index, index)
 
-    Handler = webio_handler(lambda: None, cdn_url, allowed_origins=allowed_origins, check_origin=check_origin)
+    Handler = webio_handler(lambda: None, cdn_url, allowed_origins=allowed_origins,
+                            check_origin=check_origin, reconnect_timeout=reconnect_timeout)
 
     class WSHandler(Handler):
 

+ 122 - 38
pywebio/platform/tornado.py

@@ -4,8 +4,10 @@ import json
 import logging
 import os
 import threading
+import time
 import webbrowser
 from functools import partial
+from typing import Dict
 from urllib.parse import urlparse
 
 import tornado
@@ -19,7 +21,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, \
-    check_webio_js, parse_file_size
+    check_webio_js, parse_file_size, random_str, LRUDict
 
 logger = logging.getLogger(__name__)
 
@@ -61,7 +63,7 @@ def _is_same_site(origin, handler: WebSocketHandler):
     return origin == host
 
 
-def _webio_handler(applications=None, cdn=True, check_origin_func=_is_same_site):
+def _webio_handler(applications=None, cdn=True, reconnect_timeout=0, check_origin_func=_is_same_site):
     """
     :param dict applications: dict of `name -> task function`
     :param bool/str cdn: Whether to load front-end static resources from CDN
@@ -74,6 +76,15 @@ def _webio_handler(applications=None, cdn=True, check_origin_func=_is_same_site)
         applications = dict(index=lambda: None)  # mock PyWebIO app
 
     class WSHandler(WebSocketHandler):
+        def __init__(self, *args, **kwargs):
+            super().__init__(*args, **kwargs)
+            self._close_from_session = False
+            self.session_id = None
+            self.session = None  # type: Session
+            if reconnect_timeout and not type(self)._started_clean_task:
+                type(self)._started_clean_task = True
+                tornado.ioloop.IOLoop.current().call_later(reconnect_timeout // 2, type(self).clean_expired_sessions)
+                logger.debug("Started session clean task")
 
         def get_app(self):
             app_name = self.get_query_argument('app', 'index')
@@ -101,55 +112,115 @@ def _webio_handler(applications=None, cdn=True, check_origin_func=_is_same_site)
             # Non-None enables compression with default options.
             return {}
 
-        def send_msg_to_client(self, session: Session):
+        @classmethod
+        def clean_expired_sessions(cls):
+            tornado.ioloop.IOLoop.current().call_later(reconnect_timeout // 2, cls.clean_expired_sessions)
+
+            while cls._session_expire:
+                session_id, expire_ts = cls._session_expire.popitem(last=False)  # 弹出最早过期的session
+
+                if time.time() < expire_ts:
+                    # this session is not expired
+                    cls._session_expire[session_id] = expire_ts  # restore this item
+                    cls._session_expire.move_to_end(session_id, last=False)  # move to front
+                    break
+
+                # clean this session
+                logger.debug("session %s expired" % session_id)
+                cls._connections.pop(session_id, None)
+                session = cls._webio_sessions.pop(session_id, None)
+                if session:
+                    session.close(nonblock=True)
+
+        @classmethod
+        def send_msg_to_client(cls, _, session_id=None):
+            conn = cls._connections.get(session_id)
+            session = cls._webio_sessions[session_id]
+
+            if not conn or not conn.ws_connection:
+                return
+
             for msg in session.get_task_commands():
-                self.write_message(json.dumps(msg))
+                conn.write_message(json.dumps(msg))
+
+        @classmethod
+        def close_from_session(cls, session_id=None):
+            cls.send_msg_to_client(None, session_id=session_id)
+
+            conn = cls._connections.pop(session_id, None)
+            cls._webio_sessions.pop(session_id, None)
+            if conn and conn.ws_connection:
+                conn._close_from_session = True
+                conn.close()
+
+        _started_clean_task = False
+        _session_expire = LRUDict()  # session_id -> expire timestamp. In increasing order of expire time
+        _webio_sessions = {}  # type: Dict[str, Session]  # session_id -> session
+        _connections = {}  # type: Dict[str, WSHandler]  # session_id -> WSHandler
 
         def open(self):
             logger.debug("WebSocket opened")
-            # self.set_nodelay(True)
-
-            # 由session主动关闭连接
-            # connection is closed from session
-            self._close_from_session_tag = False
-
-            session_info = get_session_info_from_headers(self.request.headers)
-            session_info['user_ip'] = self.request.remote_ip
-            session_info['request'] = self.request
-            session_info['backend'] = 'tornado'
-            session_info['protocol'] = 'websocket'
-
-            application = self.get_app()
-            if iscoroutinefunction(application) or isgeneratorfunction(application):
-                self.session = CoroutineBasedSession(application, session_info=session_info,
-                                                     on_task_command=self.send_msg_to_client,
-                                                     on_session_close=self.close_from_session)
+            cls = type(self)
+
+            self.session_id = self.get_query_argument('session', None)
+            if self.session_id in ('NEW', None):  # 初始请求,创建新 Session
+                session_info = get_session_info_from_headers(self.request.headers)
+                session_info['user_ip'] = self.request.remote_ip
+                session_info['request'] = self.request
+                session_info['backend'] = 'tornado'
+                session_info['protocol'] = 'websocket'
+
+                application = self.get_app()
+                self.session_id = random_str(24)
+                cls._connections[self.session_id] = self
+
+                if iscoroutinefunction(application) or isgeneratorfunction(application):
+                    self.session = CoroutineBasedSession(
+                        application, session_info=session_info,
+                        on_task_command=partial(self.send_msg_to_client, session_id=self.session_id),
+                        on_session_close=partial(self.close_from_session, session_id=self.session_id))
+                else:
+                    self.session = ThreadBasedSession(
+                        application, session_info=session_info,
+                        on_task_command=partial(self.send_msg_to_client, session_id=self.session_id),
+                        on_session_close=partial(self.close_from_session, session_id=self.session_id),
+                        loop=asyncio.get_event_loop())
+                cls._webio_sessions[self.session_id] = self.session
+
+                if reconnect_timeout:
+                    self.write_message(json.dumps(dict(command='set_session_id', spec=self.session_id)))
+
+            elif self.session_id not in cls._webio_sessions:  # WebIOSession deleted
+                self.write_message(json.dumps(dict(command='close_session')))
             else:
-                self.session = ThreadBasedSession(application, session_info=session_info,
-                                                  on_task_command=self.send_msg_to_client,
-                                                  on_session_close=self.close_from_session,
-                                                  loop=asyncio.get_event_loop())
+                self.session = cls._webio_sessions[self.session_id]
+                cls._session_expire.pop(self.session_id, None)
+                cls._connections[self.session_id] = self
+                cls.send_msg_to_client(self.session, self.session_id)
+
+            logger.debug('session id: %s' % self.session_id)
 
         def on_message(self, message):
             data = json.loads(message)
             if data is not None:
                 self.session.send_client_event(data)
 
-        def close_from_session(self):
-            self._close_from_session_tag = True
-            self.close()
-
         def on_close(self):
-            # Session.close() is called only when connection is closed from the client.
-            # 只有在由客户端主动断开连接时,才调用 session.close()
-            if not self._close_from_session_tag:
+            cls = type(self)
+            cls._connections.pop(self.session_id, None)
+            if not reconnect_timeout and not self._close_from_session:
                 self.session.close(nonblock=True)
+            elif reconnect_timeout:
+                if self._close_from_session:
+                    cls._webio_sessions.pop(self.session_id, None)
+                elif self.session:
+                    cls._session_expire[self.session_id] = time.time() + reconnect_timeout
             logger.debug("WebSocket closed")
 
     return WSHandler
 
 
-def webio_handler(applications, cdn=True, allowed_origins=None, check_origin=None):
+def webio_handler(applications, cdn=True, reconnect_timeout=0, allowed_origins=None, check_origin=None):
     """Get the ``RequestHandler`` class for running PyWebIO applications in Tornado.
     The ``RequestHandler`` communicates with the browser by WebSocket protocol.
 
@@ -166,7 +237,8 @@ def webio_handler(applications, cdn=True, allowed_origins=None, check_origin=Non
     else:
         check_origin_func = lambda origin, handler: _is_same_site(origin, handler) or check_origin(origin)
 
-    return _webio_handler(applications=applications, cdn=cdn, check_origin_func=check_origin_func)
+    return _webio_handler(applications=applications, cdn=cdn, check_origin_func=check_origin_func,
+                          reconnect_timeout=reconnect_timeout)
 
 
 async def open_webbrowser_on_server_started(host, port):
@@ -197,6 +269,7 @@ def _setup_server(webio_handler, port=0, host='', static_dir=None, **tornado_app
 
 def start_server(applications, port=0, host='',
                  debug=False, cdn=True, static_dir=None,
+                 reconnect_timeout=0,
                  allowed_origins=None, check_origin=None,
                  auto_open_webbrowser=False,
                  websocket_max_message_size=None,
@@ -233,6 +306,8 @@ def start_server(applications, port=0, host='',
        The files in this directory can be accessed via ``http://<host>:<port>/static/files``.
        For example, if there is a ``A/B.jpg`` file in ``http_static_dir`` path,
        it can be accessed via ``http://<host>:<port>/static/A/B.jpg``.
+    :param int reconnect_timeout: The client can reconnect to server within ``reconnect_timeout`` seconds after an unexpected disconnection.
+       If set to 0 (default), once the client disconnects, the server session will be closed.
     :param list allowed_origins: The allowed request source list. (The current server host is always allowed)
        The source contains the protocol, domain name, and port part.
        Can use Unix shell-style wildcards:
@@ -276,7 +351,8 @@ def start_server(applications, port=0, host='',
 
     cdn = cdn_validation(cdn, 'warn')  # if CDN is not available, warn user and disable CDN
 
-    handler = webio_handler(applications, cdn, allowed_origins=allowed_origins, check_origin=check_origin)
+    handler = webio_handler(applications, cdn, allowed_origins=allowed_origins, check_origin=check_origin,
+                            reconnect_timeout=reconnect_timeout)
     _, port = _setup_server(webio_handler=handler, port=port, host=host, static_dir=static_dir, **tornado_app_settings)
 
     print('Listen on %s:%s' % (host or '0.0.0.0', port))
@@ -302,18 +378,26 @@ def start_server_in_current_thread_session():
 
         def open(self):
             self.main_session = False
+            cls = type(self)
             if SingleSessionWSHandler.session is None:
                 self.main_session = True
                 SingleSessionWSHandler.instance = self
+                self.session_id = 'main'
+                cls._connections[self.session_id] = self
+
                 session_info = get_session_info_from_headers(self.request.headers)
                 session_info['user_ip'] = self.request.remote_ip
                 session_info['request'] = self.request
                 session_info['backend'] = 'tornado'
                 session_info['protocol'] = 'websocket'
-                SingleSessionWSHandler.session = ScriptModeSession(thread, session_info=session_info,
-                                                                   on_task_command=self.send_msg_to_client,
-                                                                   loop=asyncio.get_event_loop())
+                self.session = SingleSessionWSHandler.session = ScriptModeSession(
+                    thread, session_info=session_info,
+                    on_task_command=partial(self.send_msg_to_client, session_id=self.session_id),
+                    loop=asyncio.get_event_loop())
                 websocket_conn_opened.set()
+
+                cls._webio_sessions[self.session_id] = self.session
+
             else:
                 self.close()
 

+ 6 - 3
webiojs/src/handlers/base.ts

@@ -7,14 +7,17 @@ export interface CommandHandler {
     handle_message(msg: Command): void
 }
 
-export class CloseHandler implements CommandHandler {
-    accept_command: string[] = ['close_session'];
+export class SessionCtrlHandler implements CommandHandler {
+    accept_command: string[] = ['close_session', 'set_session_id'];
 
     constructor(readonly session: Session) {
     }
 
     handle_message(msg: Command) {
-        this.session.close_session();
+        if (msg.command == 'close_session')
+            this.session.close_session();
+        else if (msg.command == 'set_session_id')
+            this.session.webio_session_id = msg.spec;
     }
 }
 

+ 10 - 5
webiojs/src/main.ts

@@ -2,7 +2,7 @@ import {config as appConfig, state} from "./state";
 import {ClientEvent, Command, HttpSession, is_http_backend, pushData, Session, WebSocketSession} from "./session";
 import {InputHandler} from "./handlers/input"
 import {OutputHandler} from "./handlers/output"
-import {CloseHandler, CommandDispatcher} from "./handlers/base"
+import {SessionCtrlHandler, CommandDispatcher} from "./handlers/base"
 import {PopupHandler} from "./handlers/popup";
 import {openApp} from "./utils";
 import {ScriptHandler} from "./handlers/script";
@@ -18,6 +18,10 @@ function backend_absaddr(addr: string) {
 // 初始化Handler和Session
 function set_up_session(webio_session: Session, output_container_elem: JQuery, input_container_elem: JQuery) {
     state.CurrentSession = webio_session;
+    webio_session.on_session_create(function () {
+        $('#favicon32').attr('href', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAwklEQVQ4T63TvU5CQRCG4WcwMfEuqOgNtQ2Nd4CxV2LHtVhJ0N7AHdjQUBtrrLwLA4ks2Rx+/Qucw3Y78807M7sz4ft5dq6mI7RQX7o/JCNzfdfetkNifRk6k9wLN9jYdxMkyZPQ1faZXYUwB/OCix8V/W4Y4zJDCsBAX7jdM7iQJY+udELu+cTrP2X/xU2+NMPAg3B3UPaVOOmFoQkapQC8Z8AUpyUBs6MAKrZQ+RErf2PlQTrKKK8gpZdpewgOXOcFTTxEjYwMoIkAAAAASUVORK5CYII=');
+        $('#favicon16').attr('href', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABmUlEQVRYR82XK0wDQRCGv21TUUUJGBwGDBggGCSGBIcAWnBAgsNAgkKhSMDgCA8HtEXgSDBIDC9DDRgcpoSiKo52yea49DiutMttsz27M/98N7s7OyNo9tujgxSTwDiCIaAXSH27l4AXJA/AFSUuWOajGWnR0ChLP3HWkWSAZEN716CM4JQKW6R5+sunPkCeJJJNBCtAosnAQTMHyS6CDWYoh2mEAxzTR4JzYOCfgYNuBRymmOc5uPAbIMswMS6BbkPBPZkiVSZIc+/X/Qng/vl1C4LXIBzG/JmoAag9hxuDaa+XwAIw6p2JGkCObQSrhtMeLifZYZY1tegCqKsW4zHCadfldqgyqK6oC3DGIZIFXZVI9oIjplkUqArXyatGkYkU1+dc5p0eQY4MghNTqlo6kjkFsI9gScvRlLHkQJDnFhgxpampc6cAikCXpqMp8zcF8AnETSlq6lTaAsD6Flg+hNavofVCZL0UW3+M2uI5VhBWGxIFYL0lUxBWm1KviFttyz0Iq4OJB2F1NPO/qdaG0+DD3qLx/AuMVJFhmC8dSgAAAABJRU5ErkJggg==');
+    })
     webio_session.on_session_close(function () {
         $('#favicon32').attr('href', 'data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAByElEQVRYR82XLUzDUBDH/9emYoouYHAYMGCAYJAYEhxiW2EOSOYwkKBQKBIwuIUPN2g7gSPBIDF8GWbA4DAjG2qitEfesi6lbGxlXd5q393/fr333t07QpdfPp8f0nV9CcACEU0DGAOgN9yrAN6Y+QnATbVavcrlcp/dSFMnI9M0J1RV3WHmFQCJTvaN9RoRXbiuu28YxstfPm0BbNtOMPMeEW0C0LoMHDZzmPmIiHbT6XStlUZLgEKhMK5p2iWAyX8GDruVHMdZzmazr+GFXwCmac4oinINYCSm4L5M2fO8RcMwHoO6PwAaf37bh+BNCMdx5oOZaAKIPQdwF2Pa2yWwBGDOPxNNAMuyDohoK+a0t5Rj5sNMJrMtFusA4qopivLcw2mPyu14njclrmgdoFgsnjLzWlSVXuyJ6CyVSq2TqHDJZPI9QpHpJW7Qt1apVEbJsqwVIjqPSzWKDjOvCoBjItqI4hiXLTOfkG3b9wBm4xKNqPMgAMoAhiM6xmX+IQC+AKhxKUbUcQcCQPoWyD2E0q+h9EIkvRRLb0YD0Y4FhNQHiQCQ/iQTEFIfpX4Nl/os9yGkDiY+hNTRLNhSpQ2n4b7er/H8G7N6BRSbHvW5AAAAAElFTkSuQmCC');
         $('#favicon16').attr('href', 'data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAA0ElEQVQ4T62TPQrCQBCF30tA8BZW9mJtY+MNEtKr2HkWK0Xtw+4NbGysxVorbyEKyZMNRiSgmJ/tZufNNzO7M0ThxHHc8zxvSnIIoPNyXyXt0zRdR1F0+gxhblhr25IWJMcA3vcFviRtSc6DILg5XyZ0wQB2AAbFir7YBwAjB8kAxpg1ycmfwZlM0iYMwyldz77vH3+U/Y2rJEn6NMYsSc7KZM+1kla01p4BdKsAAFwc4A6gVRHwaARQr4Xaj1j7G2sPUiOjnEMqL9PnDJRd5ycpJXsd2f2NIAAAAABJRU5ErkJggg==');
@@ -26,13 +30,13 @@ function set_up_session(webio_session: Session, output_container_elem: JQuery, i
     let output_ctrl = new OutputHandler(webio_session, output_container_elem);
     let input_ctrl = new InputHandler(webio_session, input_container_elem);
     let popup_ctrl = new PopupHandler(webio_session);
-    let close_ctrl = new CloseHandler(webio_session);
+    let session_ctrl = new SessionCtrlHandler(webio_session);
     let script_ctrl = new ScriptHandler(webio_session);
     let download_ctrl = new DownloadHandler();
     let toast_ctrl = new ToastHandler();
     let env_ctrl = new EnvSettingHandler();
 
-    let dispatcher = new CommandDispatcher(output_ctrl, input_ctrl, popup_ctrl, close_ctrl, script_ctrl, download_ctrl, toast_ctrl, env_ctrl);
+    let dispatcher = new CommandDispatcher(output_ctrl, input_ctrl, popup_ctrl, session_ctrl, script_ctrl, download_ctrl, toast_ctrl, env_ctrl);
 
     webio_session.on_server_message((msg: Command) => {
         try {
@@ -59,7 +63,7 @@ function startWebIOClient(options: {
     }
     const backend_addr = backend_absaddr(options.backend_address);
 
-    let start_session = (is_http:boolean) => {
+    let start_session = (is_http: boolean) => {
         let session;
         if (is_http)
             session = new HttpSession(backend_addr, options.app_name, appConfig.httpPullInterval);
@@ -68,7 +72,7 @@ function startWebIOClient(options: {
         set_up_session(session, options.output_container_elem, options.input_container_elem);
         session.start_session(appConfig.debug);
     };
-    if(options.protocol=='auto')
+    if (options.protocol == 'auto')
         is_http_backend(backend_addr).then(start_session);
     else
         start_session(options.protocol == 'http')
@@ -77,6 +81,7 @@ function startWebIOClient(options: {
 
 // @ts-ignore
 window.WebIO = {
+    '_state': state,
     'startWebIOClient': startWebIOClient,
     'sendMessage': (msg: ClientEvent) => {
         return state.CurrentSession.send_message(msg);

+ 34 - 10
webiojs/src/session.ts

@@ -21,6 +21,8 @@ export interface ClientEvent {
 * 提供的函数:start_session、send_message、close_session
 * */
 export interface Session {
+    webio_session_id: string;
+
     on_session_create(callback: () => void): void;
 
     on_session_close(callback: () => void): void;
@@ -39,7 +41,9 @@ export interface Session {
 export class WebSocketSession implements Session {
     ws: WebSocket;
     debug: boolean;
-    private _closed: boolean;
+    webio_session_id: string = 'NEW';
+    private _closed: boolean; // session logic closed (by `close_session` command)
+    private _session_create_ts = 0;
     private _on_session_create: (this: WebSocket, ev: Event) => any = () => {
     };
     private _on_session_close: (this: WebSocket, ev: CloseEvent) => any = () => {
@@ -47,20 +51,20 @@ export class WebSocketSession implements Session {
     private _on_server_message: (msg: Command) => any = () => {
     };
 
-    constructor(public ws_api: string, app_name: string = 'index') {
+    constructor(public ws_api: string, public app_name: string = 'index') {
         this.ws = null;
         this.debug = false;
         this._closed = false;
-
-        let url = new URL(ws_api);
+    }
+    set_ws_api(){
+        let url = new URL(this.ws_api);
         if (url.protocol !== 'wss:' && url.protocol !== 'ws:') {
             let protocol = url.protocol || window.location.protocol;
             url.protocol = protocol.replace('https', 'wss').replace('http', 'ws');
         }
-        url.search = "?app=" + app_name;
+        url.search = `?app=${this.app_name}&session=${this.webio_session_id}`;
         this.ws_api = url.href;
     }
-
     on_session_create(callback: () => any): void {
         this._on_session_create = callback;
     };
@@ -74,11 +78,27 @@ export class WebSocketSession implements Session {
     }
 
     start_session(debug: boolean = false): void {
+        let that = this;
+
+        this.set_ws_api();
+
+        this._session_create_ts = Date.now();
         this.debug = debug;
         this.ws = new WebSocket(this.ws_api);
         this.ws.onopen = this._on_session_create;
-        this.ws.onclose = this._on_session_close;
-        let that = this;
+
+        this.ws.onclose = function (evt) {
+            that._on_session_close.apply(that, evt);
+            if (!that._closed && that.webio_session_id!='NEW') {  // not receive `close_session` command && enabled reconnection
+                const session_create_interval = 5000;
+                if (Date.now() - that._session_create_ts > session_create_interval)
+                    that.start_session(that.debug);
+                else
+                    setTimeout(() => {
+                        that.start_session(that.debug);
+                    }, session_create_interval - Date.now() + that._session_create_ts);
+            }
+        };
         this.ws.onmessage = function (evt) {
             let msg: Command = JSON.parse(evt.data);
             if (debug) console.info('>>>', JSON.parse(evt.data));
@@ -161,7 +181,9 @@ export class HttpSession implements Session {
     start_session(debug: boolean = false): void {
         this.debug = debug;
         this.pull();
-        this.interval_pull_id = setInterval(()=>{this.pull()},this.pull_interval_ms);
+        this.interval_pull_id = setInterval(() => {
+            this.pull()
+        }, this.pull_interval_ms);
     }
 
     pull() {
@@ -236,7 +258,9 @@ export class HttpSession implements Session {
     change_pull_interval(new_interval: number): void {
         clearInterval(this.interval_pull_id);
         this.pull_interval_ms = new_interval;
-        this.interval_pull_id = setInterval(()=>{this.pull()}, this.pull_interval_ms);
+        this.interval_pull_id = setInterval(() => {
+            this.pull()
+        }, this.pull_interval_ms);
     }
 }