client.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. from __future__ import annotations
  2. import asyncio
  3. import time
  4. import uuid
  5. from contextlib import contextmanager
  6. from pathlib import Path
  7. from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterable, Iterator, List, Optional, Union
  8. from fastapi import Request
  9. from fastapi.responses import Response
  10. from fastapi.templating import Jinja2Templates
  11. from nicegui import json
  12. from . import background_tasks, binding, core, outbox
  13. from .awaitable_response import AwaitableResponse
  14. from .dependencies import generate_resources
  15. from .element import Element
  16. from .favicon import get_favicon_url
  17. from .helpers import safe_invoke
  18. from .logging import log
  19. from .version import __version__
  20. if TYPE_CHECKING:
  21. from .page import page
  22. templates = Jinja2Templates(Path(__file__).parent / 'templates')
  23. class Client:
  24. page_routes: Dict[Callable[..., Any], str] = {}
  25. """Maps page builders to their routes."""
  26. instances: Dict[str, Client] = {}
  27. """Maps client IDs to clients."""
  28. index_client: Client
  29. """The client that is used to render the auto-index page."""
  30. def __init__(self, page: page, *, shared: bool = False) -> None:
  31. self.id = str(uuid.uuid4())
  32. self.created = time.time()
  33. self.instances[self.id] = self
  34. self.elements: Dict[int, Element] = {}
  35. self.next_element_id: int = 0
  36. self.is_waiting_for_connection: bool = False
  37. self.is_waiting_for_disconnect: bool = False
  38. self.environ: Optional[Dict[str, Any]] = None
  39. self.shared = shared
  40. self.on_air = False
  41. self.disconnect_task: Optional[asyncio.Task] = None
  42. with Element('q-layout', _client=self).props('view="hhh lpr fff"').classes('nicegui-layout') as self.layout:
  43. with Element('q-page-container') as self.page_container:
  44. with Element('q-page'):
  45. self.content = Element('div').classes('nicegui-content')
  46. self.waiting_javascript_commands: Dict[str, Any] = {}
  47. self.head_html = ''
  48. self.body_html = ''
  49. self.page = page
  50. self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
  51. self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
  52. self._temporary_socket_id: Optional[str] = None
  53. @property
  54. def is_index_client(self) -> bool:
  55. """Return True if this client is the auto-index client."""
  56. return self is self.index_client
  57. @property
  58. def ip(self) -> Optional[str]:
  59. """Return the IP address of the client, or None if the client is not connected."""
  60. return self.environ['asgi.scope']['client'][0] if self.environ else None # pylint: disable=unsubscriptable-object
  61. @property
  62. def has_socket_connection(self) -> bool:
  63. """Return True if the client is connected, False otherwise."""
  64. return self.environ is not None
  65. def __enter__(self):
  66. self.content.__enter__()
  67. return self
  68. def __exit__(self, *_):
  69. self.content.__exit__()
  70. def build_response(self, request: Request, status_code: int = 200) -> Response:
  71. """Build a FastAPI response for the client."""
  72. prefix = request.headers.get('X-Forwarded-Prefix', request.scope.get('root_path', ''))
  73. elements = json.dumps({
  74. id: element._to_dict() for id, element in self.elements.items() # pylint: disable=protected-access
  75. })
  76. socket_io_js_query_params = {**core.app.extra_config.socket_io_js_query_params, 'client_id': self.id}
  77. vue_html, vue_styles, vue_scripts, imports, js_imports = generate_resources(prefix, self.elements.values())
  78. return templates.TemplateResponse('index.html', {
  79. 'request': request,
  80. 'version': __version__,
  81. 'elements': elements.replace('&', '&')
  82. .replace('<', '&lt;')
  83. .replace('>', '&gt;')
  84. .replace('`', '&#96;'),
  85. 'head_html': self.head_html,
  86. 'body_html': '<style>' + '\n'.join(vue_styles) + '</style>\n' + self.body_html + '\n' + '\n'.join(vue_html),
  87. 'vue_scripts': '\n'.join(vue_scripts),
  88. 'imports': json.dumps(imports),
  89. 'js_imports': '\n'.join(js_imports),
  90. 'quasar_config': json.dumps(core.app.extra_config.quasar_config),
  91. 'title': self.page.resolve_title(),
  92. 'viewport': self.page.resolve_viewport(),
  93. 'favicon_url': get_favicon_url(self.page, prefix),
  94. 'dark': str(self.page.resolve_dark()),
  95. 'language': self.page.resolve_language(),
  96. 'prefix': prefix,
  97. 'tailwind': core.app.config.tailwind,
  98. 'prod_js': core.app.config.prod_js,
  99. 'socket_io_js_query_params': socket_io_js_query_params,
  100. 'socket_io_js_extra_headers': core.app.extra_config.socket_io_js_extra_headers,
  101. 'socket_io_js_transports': core.app.extra_config.socket_io_js_transports,
  102. }, status_code, {'Cache-Control': 'no-store', 'X-NiceGUI-Content': 'page'})
  103. async def connected(self, timeout: float = 3.0, check_interval: float = 0.1) -> None:
  104. """Block execution until the client is connected."""
  105. self.is_waiting_for_connection = True
  106. deadline = time.time() + timeout
  107. while not self.has_socket_connection:
  108. if time.time() > deadline:
  109. raise TimeoutError(f'No connection after {timeout} seconds')
  110. await asyncio.sleep(check_interval)
  111. self.is_waiting_for_connection = False
  112. async def disconnected(self, check_interval: float = 0.1) -> None:
  113. """Block execution until the client disconnects."""
  114. if not self.has_socket_connection:
  115. await self.connected()
  116. self.is_waiting_for_disconnect = True
  117. while self.id in self.instances:
  118. await asyncio.sleep(check_interval)
  119. self.is_waiting_for_disconnect = False
  120. def run_javascript(self, code: str, *,
  121. respond: Optional[bool] = None, # DEPRECATED
  122. timeout: float = 1.0, check_interval: float = 0.01) -> AwaitableResponse:
  123. """Execute JavaScript on the client.
  124. The client connection must be established before this method is called.
  125. You can do this by `await client.connected()` or register a callback with `client.on_connect(...)`.
  126. """
  127. if respond is True:
  128. log.warning('The "respond" argument of run_javascript() has been removed. '
  129. 'Now the method always returns an AwaitableResponse that can be awaited. '
  130. 'Please remove the "respond=True" argument.')
  131. if respond is False:
  132. raise ValueError('The "respond" argument of run_javascript() has been removed. '
  133. 'Now the method always returns an AwaitableResponse that can be awaited. '
  134. 'Please remove the "respond=False" argument and call the method without awaiting.')
  135. request_id = str(uuid.uuid4())
  136. target_id = self._temporary_socket_id or self.id
  137. def send_and_forget():
  138. outbox.enqueue_message('run_javascript', {'code': code}, target_id)
  139. async def send_and_wait():
  140. outbox.enqueue_message('run_javascript', {'code': code, 'request_id': request_id}, target_id)
  141. deadline = time.time() + timeout
  142. while request_id not in self.waiting_javascript_commands:
  143. if time.time() > deadline:
  144. raise TimeoutError('JavaScript did not respond in time')
  145. await asyncio.sleep(check_interval)
  146. return self.waiting_javascript_commands.pop(request_id)
  147. return AwaitableResponse(send_and_forget, send_and_wait)
  148. def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> None:
  149. """Open a new page in the client."""
  150. path = target if isinstance(target, str) else self.page_routes[target]
  151. outbox.enqueue_message('open', {'path': path, 'new_tab': new_tab}, self.id)
  152. def download(self, url: str, filename: Optional[str] = None) -> None:
  153. """Download a file from the given URL."""
  154. outbox.enqueue_message('download', {'url': url, 'filename': filename}, self.id)
  155. def on_connect(self, handler: Union[Callable[..., Any], Awaitable]) -> None:
  156. """Register a callback to be called when the client connects."""
  157. self.connect_handlers.append(handler)
  158. def on_disconnect(self, handler: Union[Callable[..., Any], Awaitable]) -> None:
  159. """Register a callback to be called when the client disconnects."""
  160. self.disconnect_handlers.append(handler)
  161. def handle_handshake(self) -> None:
  162. """Cancel pending disconnect task and invoke connect handlers."""
  163. if self.disconnect_task:
  164. self.disconnect_task.cancel()
  165. self.disconnect_task = None
  166. for t in self.connect_handlers:
  167. safe_invoke(t, self)
  168. for t in core.app._connect_handlers: # pylint: disable=protected-access
  169. safe_invoke(t, self)
  170. def handle_disconnect(self) -> None:
  171. """Wait for the browser to reconnect; invoke disconnect handlers if it doesn't."""
  172. async def handle_disconnect() -> None:
  173. delay = self.page.reconnect_timeout if self.page.reconnect_timeout is not None else core.app.config.reconnect_timeout
  174. await asyncio.sleep(delay)
  175. if not self.shared:
  176. self.delete()
  177. for t in self.disconnect_handlers:
  178. safe_invoke(t, self)
  179. for t in core.app._disconnect_handlers: # pylint: disable=protected-access
  180. safe_invoke(t, self)
  181. self.disconnect_task = background_tasks.create(handle_disconnect())
  182. def handle_event(self, msg: Dict) -> None:
  183. """Forward an event to the corresponding element."""
  184. with self:
  185. sender = self.elements.get(msg['id'])
  186. if sender:
  187. msg['args'] = [None if arg is None else json.loads(arg) for arg in msg.get('args', [])]
  188. if len(msg['args']) == 1:
  189. msg['args'] = msg['args'][0]
  190. sender._handle_event(msg) # pylint: disable=protected-access
  191. def handle_javascript_response(self, msg: Dict) -> None:
  192. """Store the result of a JavaScript command."""
  193. self.waiting_javascript_commands[msg['request_id']] = msg['result']
  194. def remove_elements(self, elements: Iterable[Element]) -> None:
  195. """Remove the given elements from the client."""
  196. binding.remove(elements, Element)
  197. element_ids = [element.id for element in elements]
  198. for element_id in element_ids:
  199. del self.elements[element_id]
  200. for element in elements:
  201. element._handle_delete() # pylint: disable=protected-access
  202. element._deleted = True # pylint: disable=protected-access
  203. outbox.enqueue_delete(element)
  204. def remove_all_elements(self) -> None:
  205. """Remove all elements from the client."""
  206. self.remove_elements(self.elements.values())
  207. def delete(self) -> None:
  208. """Delete a client and all its elements.
  209. If the global clients dictionary does not contain the client, its elements are still removed and a KeyError is raised.
  210. Normally this should never happen, but has been observed (see #1826).
  211. """
  212. self.remove_all_elements()
  213. del Client.instances[self.id]
  214. @contextmanager
  215. def individual_target(self, socket_id: str) -> Iterator[None]:
  216. """Use individual socket ID while in this context.
  217. This context is useful for limiting messages from the shared auto-index page to a single client.
  218. """
  219. self._temporary_socket_id = socket_id
  220. yield
  221. self._temporary_socket_id = None
  222. @classmethod
  223. async def prune_instances(cls) -> None:
  224. """Prune stale clients in an endless loop."""
  225. while True:
  226. try:
  227. stale_clients = [
  228. client
  229. for client in cls.instances.values()
  230. if not client.shared and not client.has_socket_connection and client.created < time.time() - 60.0
  231. ]
  232. for client in stale_clients:
  233. client.delete()
  234. except Exception:
  235. # NOTE: make sure the loop doesn't crash
  236. log.exception('Error while pruning clients')
  237. await asyncio.sleep(10)