1
0

nicegui.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. import asyncio
  2. import mimetypes
  3. import time
  4. import urllib.parse
  5. from pathlib import Path
  6. from typing import Dict
  7. from fastapi import HTTPException, Request
  8. from fastapi.middleware.gzip import GZipMiddleware
  9. from fastapi.responses import FileResponse, Response
  10. from fastapi.staticfiles import StaticFiles
  11. from fastapi_socketio import SocketManager
  12. from . import (background_tasks, binding, favicon, globals, json, outbox, # pylint: disable=redefined-builtin
  13. run_executor, welcome)
  14. from .app import App
  15. from .client import Client
  16. from .dependencies import js_components, libraries
  17. from .error import error_content
  18. from .helpers import is_file, safe_invoke
  19. from .json import NiceGUIJSONResponse
  20. from .middlewares import RedirectWithPrefixMiddleware
  21. from .page import page
  22. from .version import __version__
  23. globals.app = app = App(default_response_class=NiceGUIJSONResponse)
  24. # NOTE we use custom json module which wraps orjson
  25. socket_manager = SocketManager(app=app, mount_location='/_nicegui_ws/', json=json)
  26. globals.sio = sio = socket_manager._sio # pylint: disable=protected-access
  27. mimetypes.add_type('text/javascript', '.js')
  28. mimetypes.add_type('text/css', '.css')
  29. app.add_middleware(GZipMiddleware)
  30. app.add_middleware(RedirectWithPrefixMiddleware)
  31. static_files = StaticFiles(
  32. directory=(Path(__file__).parent / 'static').resolve(),
  33. follow_symlink=True,
  34. )
  35. app.mount(f'/_nicegui/{__version__}/static', static_files, name='static')
  36. globals.index_client = Client(page('/'), shared=True).__enter__() # pylint: disable=unnecessary-dunder-call
  37. @app.get('/')
  38. def _get_index(request: Request) -> Response:
  39. return globals.index_client.build_response(request)
  40. @app.get(f'/_nicegui/{__version__}' + '/libraries/{key:path}')
  41. def _get_library(key: str) -> FileResponse:
  42. is_map = key.endswith('.map')
  43. dict_key = key[:-4] if is_map else key
  44. if dict_key in libraries:
  45. path = libraries[dict_key].path
  46. if is_map:
  47. path = path.with_name(path.name + '.map')
  48. if path.exists():
  49. headers = {'Cache-Control': 'public, max-age=3600'}
  50. return FileResponse(path, media_type='text/javascript', headers=headers)
  51. raise HTTPException(status_code=404, detail=f'library "{key}" not found')
  52. @app.get(f'/_nicegui/{__version__}' + '/components/{key:path}')
  53. def _get_component(key: str) -> FileResponse:
  54. if key in js_components and js_components[key].path.exists():
  55. headers = {'Cache-Control': 'public, max-age=3600'}
  56. return FileResponse(js_components[key].path, media_type='text/javascript', headers=headers)
  57. raise HTTPException(status_code=404, detail=f'component "{key}" not found')
  58. @app.on_event('startup')
  59. def handle_startup(with_welcome_message: bool = True) -> None:
  60. """Handle the startup event."""
  61. # NOTE ping interval and timeout need to be lower than the reconnect timeout, but can't be too low
  62. globals.sio.eio.ping_interval = max(globals.reconnect_timeout * 0.8, 4)
  63. globals.sio.eio.ping_timeout = max(globals.reconnect_timeout * 0.4, 2)
  64. if not globals.ui_run_has_been_called:
  65. raise RuntimeError('\n\n'
  66. 'You must call ui.run() to start the server.\n'
  67. 'If ui.run() is behind a main guard\n'
  68. ' if __name__ == "__main__":\n'
  69. 'remove the guard or replace it with\n'
  70. ' if __name__ in {"__main__", "__mp_main__"}:\n'
  71. 'to allow for multiprocessing.')
  72. if globals.favicon:
  73. if is_file(globals.favicon):
  74. globals.app.add_route('/favicon.ico', lambda _: FileResponse(globals.favicon)) # type: ignore
  75. else:
  76. globals.app.add_route('/favicon.ico', lambda _: favicon.get_favicon_response())
  77. else:
  78. globals.app.add_route('/favicon.ico', lambda _: FileResponse(Path(__file__).parent / 'static' / 'favicon.ico'))
  79. globals.state = globals.State.STARTING
  80. globals.loop = asyncio.get_running_loop()
  81. with globals.index_client:
  82. for t in globals.startup_handlers:
  83. safe_invoke(t)
  84. background_tasks.create(binding.refresh_loop(), name='refresh bindings')
  85. background_tasks.create(outbox.loop(), name='send outbox')
  86. background_tasks.create(prune_clients(), name='prune clients')
  87. background_tasks.create(prune_slot_stacks(), name='prune slot stacks')
  88. globals.state = globals.State.STARTED
  89. if with_welcome_message:
  90. background_tasks.create(welcome.print_message())
  91. if globals.air:
  92. background_tasks.create(globals.air.connect())
  93. @app.on_event('shutdown')
  94. async def handle_shutdown() -> None:
  95. """Handle the shutdown event."""
  96. if app.native.main_window:
  97. app.native.main_window.signal_server_shutdown()
  98. globals.state = globals.State.STOPPING
  99. with globals.index_client:
  100. for t in globals.shutdown_handlers:
  101. safe_invoke(t)
  102. run_executor.tear_down()
  103. globals.state = globals.State.STOPPED
  104. if globals.air:
  105. await globals.air.disconnect()
  106. @app.exception_handler(404)
  107. async def _exception_handler_404(request: Request, exception: Exception) -> Response:
  108. globals.log.warning(f'{request.url} not found')
  109. with Client(page('')) as client:
  110. error_content(404, exception)
  111. return client.build_response(request, 404)
  112. @app.exception_handler(Exception)
  113. async def _exception_handler_500(request: Request, exception: Exception) -> Response:
  114. globals.log.exception(exception)
  115. with Client(page('')) as client:
  116. error_content(500, exception)
  117. return client.build_response(request, 500)
  118. @sio.on('handshake')
  119. async def _on_handshake(sid: str, client_id: str) -> bool:
  120. client = globals.clients.get(client_id)
  121. if not client:
  122. return False
  123. client.environ = sio.get_environ(sid)
  124. await sio.enter_room(sid, client.id)
  125. handle_handshake(client)
  126. return True
  127. def handle_handshake(client: Client) -> None:
  128. """Cancel pending disconnect task and invoke connect handlers."""
  129. if client.disconnect_task:
  130. client.disconnect_task.cancel()
  131. client.disconnect_task = None
  132. for t in client.connect_handlers:
  133. safe_invoke(t, client)
  134. for t in globals.connect_handlers:
  135. safe_invoke(t, client)
  136. @sio.on('disconnect')
  137. def _on_disconnect(sid: str) -> None:
  138. query_bytes: bytearray = sio.get_environ(sid)['asgi.scope']['query_string']
  139. query = urllib.parse.parse_qs(query_bytes.decode())
  140. client_id = query['client_id'][0]
  141. client = globals.clients.get(client_id)
  142. if client:
  143. client.disconnect_task = background_tasks.create(handle_disconnect(client))
  144. async def handle_disconnect(client: Client) -> None:
  145. """Wait for the browser to reconnect; invoke disconnect handlers if it doesn't."""
  146. delay = client.page.reconnect_timeout if client.page.reconnect_timeout is not None else globals.reconnect_timeout
  147. await asyncio.sleep(delay)
  148. if not client.shared:
  149. _delete_client(client.id)
  150. for t in client.disconnect_handlers:
  151. safe_invoke(t, client)
  152. for t in globals.disconnect_handlers:
  153. safe_invoke(t, client)
  154. @sio.on('event')
  155. def _on_event(_: str, msg: Dict) -> None:
  156. client = globals.clients.get(msg['client_id'])
  157. if not client or not client.has_socket_connection:
  158. return
  159. handle_event(client, msg)
  160. def handle_event(client: Client, msg: Dict) -> None:
  161. """Forward an event to the corresponding element."""
  162. with client:
  163. sender = client.elements.get(msg['id'])
  164. if sender:
  165. msg['args'] = [None if arg is None else json.loads(arg) for arg in msg.get('args', [])]
  166. if len(msg['args']) == 1:
  167. msg['args'] = msg['args'][0]
  168. sender._handle_event(msg) # pylint: disable=protected-access
  169. @sio.on('javascript_response')
  170. def _on_javascript_response(_: str, msg: Dict) -> None:
  171. client = globals.clients.get(msg['client_id'])
  172. if not client:
  173. return
  174. handle_javascript_response(client, msg)
  175. def handle_javascript_response(client: Client, msg: Dict) -> None:
  176. """Forward a JavaScript response to the corresponding element."""
  177. client.waiting_javascript_commands[msg['request_id']] = msg['result']
  178. async def prune_clients() -> None:
  179. """Prune stale clients in an endless loop."""
  180. while True:
  181. stale_clients = [
  182. id
  183. for id, client in globals.clients.items()
  184. if not client.shared and not client.has_socket_connection and client.created < time.time() - 60.0
  185. ]
  186. for client_id in stale_clients:
  187. _delete_client(client_id)
  188. await asyncio.sleep(10)
  189. async def prune_slot_stacks() -> None:
  190. """Prune stale slot stacks in an endless loop."""
  191. while True:
  192. running = [
  193. id(task)
  194. for task in asyncio.tasks.all_tasks()
  195. if not task.done() and not task.cancelled()
  196. ]
  197. stale = [
  198. id_
  199. for id_ in globals.slot_stacks
  200. if id_ not in running
  201. ]
  202. for id_ in stale:
  203. del globals.slot_stacks[id_]
  204. await asyncio.sleep(10)
  205. def _delete_client(client_id: str) -> None:
  206. globals.clients.pop(client_id).remove_all_elements()