diff --git a/.gitignore b/.gitignore index 50febabbe..1f937894d 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,3 @@ pip-wheel-metadata # --- JS --- node_modules - diff --git a/src/client/packages/idom-client-react/src/json-patch.js b/src/client/packages/idom-client-react/src/json-patch.js index 5323f11a9..4167e0f2f 100644 --- a/src/client/packages/idom-client-react/src/json-patch.js +++ b/src/client/packages/idom-client-react/src/json-patch.js @@ -1,42 +1,26 @@ import React from "react"; -import jsonpatch from "fast-json-patch"; export function useJsonPatchCallback(initial) { const doc = React.useRef(initial); const forceUpdate = useForceUpdate(); const applyPatch = React.useCallback( - (path, patch) => { + ({ path, new: newDoc }) => { if (!path) { - // We CANNOT mutate the part of the document because React checks some - // attributes of the model (e.g. model.attributes.style is checked for - // identity). - doc.current = applyNonMutativePatch( - doc.current, - patch, - false, - false, - true - ); + doc.current = newDoc; } else { - // We CAN mutate the document here though because we know that nothing above - // The patch `path` is changing. Thus, maintaining the identity for that section - // of the model is accurate. - applyMutativePatch(doc.current, [ - { - op: "replace", - path: path, - // We CANNOT mutate the part of the document where the actual patch is being - // applied. Instead we create a copy because React checks some attributes of - // the model (e.g. model.attributes.style is checked for identity). The part - // of the document above the `path` can be mutated though because we know it - // has not changed. - value: applyNonMutativePatch( - jsonpatch.getValueByPointer(doc.current, path), - patch - ), - }, - ]); + let value = doc.current; + const pathParts = path + .split("/") + .map((pathPart) => + startsWithNumber(pathPart) ? Number(pathPart) : pathPart + ); + const pathPrefix = pathParts.slice(0, -1); + const pathLast = pathParts[pathParts.length - 1]; + for (const pathPart in pathPrefix) { + value = value[pathPart]; + } + value[pathLast] = newDoc; } forceUpdate(); }, @@ -58,3 +42,7 @@ function useForceUpdate() { const [, updateState] = React.useState(); return React.useCallback(() => updateState({}), []); } + +function startsWithNumber(str) { + return /^\d/.test(str); +} diff --git a/src/client/packages/idom-client-react/src/mount.js b/src/client/packages/idom-client-react/src/mount.js index 926f2a8ae..6c9962722 100644 --- a/src/client/packages/idom-client-react/src/mount.js +++ b/src/client/packages/idom-client-react/src/mount.js @@ -38,6 +38,8 @@ function mountLayoutWithReconnectingWebSocket( socket.onopen = (event) => { console.info(`IDOM WebSocket connected.`); + socket.send(JSON.stringify({ type: "client-info", version: "0.0.1" })); + if (mountState.everMounted) { ReactDOM.unmountComponentAtNode(element); } @@ -46,13 +48,25 @@ function mountLayoutWithReconnectingWebSocket( mountLayout(element, { loadImportSource, saveUpdateHook: updateHookPromise.resolve, - sendEvent: (event) => socket.send(JSON.stringify(event)), + sendEvent: (event) => + socket.send(JSON.stringify({ type: "layout-event", data: event })), }); }; + const messageHandlers = { + "server-info": () => {}, + "layout-update": ({ data }) => + updateHookPromise.promise.then((update) => update(data)), + }; + socket.onmessage = (event) => { - const [pathPrefix, patch] = JSON.parse(event.data); - updateHookPromise.promise.then((update) => update(pathPrefix, patch)); + const msg = JSON.parse(event.data); + const handler = messageHandlers[msg["type"]]; + if (!handler) { + console.error(`Unknown message type '${msg["type"]}'`); + return; + } + handler(msg); }; socket.onclose = (event) => { diff --git a/src/client/vite.config.js b/src/client/vite.config.js index bbcb8ed43..22359a60f 100644 --- a/src/client/vite.config.js +++ b/src/client/vite.config.js @@ -8,5 +8,5 @@ export default defineConfig({ "react-dom": "preact/compat", }, }, - base: "/_idom", + base: "/_idom/client", }); diff --git a/src/idom/__init__.py b/src/idom/__init__.py index f092c326a..dd9e1781c 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -1,6 +1,6 @@ from . import backend, config, html, logging, sample, svg, types, web +from .backend.common.utils import run from .backend.hooks import use_connection, use_location, use_scope -from .backend.utils import run from .core import hooks from .core.component import component from .core.events import event @@ -16,7 +16,7 @@ use_state, ) from .core.layout import Layout -from .core.serve import Stop +from .core.server import Stop from .core.vdom import vdom from .utils import Ref, html_to_vdom, vdom_to_html from .widgets import hotswap diff --git a/src/idom/backend/common/__init__.py b/src/idom/backend/common/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/idom/backend/_common.py b/src/idom/backend/common/impl.py similarity index 97% rename from src/idom/backend/_common.py rename to src/idom/backend/common/impl.py index 90e2dea5b..5e871b46c 100644 --- a/src/idom/backend/_common.py +++ b/src/idom/backend/common/impl.py @@ -19,7 +19,7 @@ PATH_PREFIX = PurePosixPath("/_idom") MODULES_PATH = PATH_PREFIX / "modules" -ASSETS_PATH = PATH_PREFIX / "assets" +CLIENT_PATH = PATH_PREFIX / "client" STREAM_PATH = PATH_PREFIX / "stream" CLIENT_BUILD_DIR = Path(_idom_file_path).parent / "_client" @@ -115,7 +115,7 @@ class CommonOptions: html.link( { "rel": "icon", - "href": "_idom/assets/idom-logo-square-small.svg", + "href": "_idom/client/idom-logo-square-small.svg", "type": "image/svg+xml", } ), diff --git a/src/idom/backend/types.py b/src/idom/backend/common/types.py similarity index 100% rename from src/idom/backend/types.py rename to src/idom/backend/common/types.py diff --git a/src/idom/backend/utils.py b/src/idom/backend/common/utils.py similarity index 100% rename from src/idom/backend/utils.py rename to src/idom/backend/common/utils.py diff --git a/src/idom/backend/default.py b/src/idom/backend/default.py index c874f50ab..ebcb91ac4 100644 --- a/src/idom/backend/default.py +++ b/src/idom/backend/default.py @@ -5,8 +5,8 @@ from idom.types import RootComponentConstructor -from .types import BackendImplementation -from .utils import all_implementations +from .common.types import BackendImplementation +from .common.utils import all_implementations def configure( diff --git a/src/idom/backend/flask.py b/src/idom/backend/flask.py index 95c054b83..3c5993038 100644 --- a/src/idom/backend/flask.py +++ b/src/idom/backend/flask.py @@ -25,8 +25,8 @@ from werkzeug.serving import BaseWSGIServer, make_server import idom -from idom.backend._common import ( - ASSETS_PATH, +from idom.backend.common.impl import ( + CLIENT_PATH, MODULES_PATH, PATH_PREFIX, STREAM_PATH, @@ -35,11 +35,11 @@ safe_client_build_dir_path, safe_web_modules_dir_path, ) +from idom.backend.common.types import Connection, Location from idom.backend.hooks import ConnectionContext from idom.backend.hooks import use_connection as _use_connection -from idom.backend.types import Connection, Location from idom.core.layout import LayoutEvent, LayoutUpdate -from idom.core.serve import serve_json_patch +from idom.core.server import serve_json_patch from idom.core.types import ComponentType, RootComponentConstructor from idom.utils import Ref @@ -157,9 +157,9 @@ def _setup_common_routes( cors_params = cors_options if isinstance(cors_options, dict) else {} CORS(api_blueprint, **cors_params) - @api_blueprint.route(f"/{ASSETS_PATH.name}/") - def send_assets_dir(path: str = "") -> Any: - return send_file(safe_client_build_dir_path(f"assets/{path}")) + @api_blueprint.route(f"/{CLIENT_PATH.name}/") + def send_client_dir(path: str = "") -> Any: + return send_file(safe_client_build_dir_path(path)) @api_blueprint.route(f"/{MODULES_PATH.name}/") def send_modules_dir(path: str = "") -> Any: diff --git a/src/idom/backend/hooks.py b/src/idom/backend/hooks.py index c5b5d7c9a..772f83151 100644 --- a/src/idom/backend/hooks.py +++ b/src/idom/backend/hooks.py @@ -4,7 +4,7 @@ from idom.core.hooks import Context, create_context, use_context -from .types import Connection, Location +from .common.types import Connection, Location # backend implementations should establish this context at the root of an app diff --git a/src/idom/backend/sanic.py b/src/idom/backend/sanic.py index fda9d214f..741c57e24 100644 --- a/src/idom/backend/sanic.py +++ b/src/idom/backend/sanic.py @@ -13,9 +13,9 @@ from sanic.server.websockets.connection import WebSocketConnection from sanic_cors import CORS -from idom.backend.types import Connection, Location +from idom.backend.common.types import Connection, Location from idom.core.layout import Layout, LayoutEvent -from idom.core.serve import ( +from idom.core.server import ( RecvCoroutine, SendCoroutine, Stop, @@ -24,8 +24,8 @@ ) from idom.core.types import RootComponentConstructor -from ._common import ( - ASSETS_PATH, +from .common.impl import ( + CLIENT_PATH, MODULES_PATH, PATH_PREFIX, STREAM_PATH, @@ -130,9 +130,9 @@ async def asset_files( path: str = "", ) -> response.HTTPResponse: path = urllib_parse.unquote(path) - return await response.file(safe_client_build_dir_path(f"assets/{path}")) + return await response.file(safe_client_build_dir_path(path)) - api_blueprint.add_route(asset_files, f"/{ASSETS_PATH.name}/") + api_blueprint.add_route(asset_files, f"/{CLIENT_PATH.name}/") async def web_module_files( request: request.Request, diff --git a/src/idom/backend/starlette.py b/src/idom/backend/starlette.py index 21d5200af..cb5017aa4 100644 --- a/src/idom/backend/starlette.py +++ b/src/idom/backend/starlette.py @@ -13,21 +13,17 @@ from starlette.staticfiles import StaticFiles from starlette.websockets import WebSocket, WebSocketDisconnect +from idom.backend.common.types import Connection, Location from idom.backend.hooks import ConnectionContext -from idom.backend.types import Connection, Location from idom.config import IDOM_WEB_MODULES_DIR from idom.core.layout import Layout, LayoutEvent -from idom.core.serve import ( - RecvCoroutine, - SendCoroutine, - VdomJsonPatch, - serve_json_patch, -) +from idom.core.server import RecvCoroutine, SendCoroutine +from idom.core.server import serve as serve_layout from idom.core.types import RootComponentConstructor -from ._common import ( - ASSETS_PATH, +from .common.impl import ( CLIENT_BUILD_DIR, + CLIENT_PATH, MODULES_PATH, STREAM_PATH, CommonOptions, @@ -119,8 +115,8 @@ def _setup_common_routes(options: Options, app: Starlette) -> None: StaticFiles(directory=IDOM_WEB_MODULES_DIR.current, check_dir=False), ) app.mount( - str(ASSETS_PATH), - StaticFiles(directory=CLIENT_BUILD_DIR / "assets", check_dir=False), + str(CLIENT_PATH), + StaticFiles(directory=CLIENT_BUILD_DIR, check_dir=False), ) # register this last so it takes least priority index_route = _make_index_route(options) @@ -151,7 +147,9 @@ async def model_stream(socket: WebSocket) -> None: search = socket.scope["query_string"].decode() try: - await serve_json_patch( + await serve_layout( + send, + recv, Layout( ConnectionContext( constructor(), @@ -162,8 +160,6 @@ async def model_stream(socket: WebSocket) -> None: ), ) ), - send, - recv, ) except WebSocketDisconnect as error: logger.info(f"WebSocket disconnect: {error.code}") @@ -172,10 +168,10 @@ async def model_stream(socket: WebSocket) -> None: def _make_send_recv_callbacks( socket: WebSocket, ) -> Tuple[SendCoroutine, RecvCoroutine]: - async def sock_send(value: VdomJsonPatch) -> None: + async def sock_send(value: Any) -> None: await socket.send_text(json.dumps(value)) - async def sock_recv() -> LayoutEvent: - return LayoutEvent(**json.loads(await socket.receive_text())) + async def sock_recv() -> Any: + return json.loads(await socket.receive_text()) return sock_send, sock_recv diff --git a/src/idom/backend/tornado.py b/src/idom/backend/tornado.py index a9a112ffc..699d892b1 100644 --- a/src/idom/backend/tornado.py +++ b/src/idom/backend/tornado.py @@ -15,15 +15,15 @@ from tornado.websocket import WebSocketHandler from tornado.wsgi import WSGIContainer -from idom.backend.types import Connection, Location +from idom.backend.common.types import Connection, Location from idom.config import IDOM_WEB_MODULES_DIR from idom.core.layout import Layout, LayoutEvent -from idom.core.serve import VdomJsonPatch, serve_json_patch +from idom.core.server import VdomJsonPatch, serve_json_patch from idom.core.types import ComponentConstructor -from ._common import ( - ASSETS_PATH, +from .common.impl import ( CLIENT_BUILD_DIR, + CLIENT_PATH, MODULES_PATH, STREAM_PATH, CommonOptions, @@ -118,9 +118,9 @@ def _setup_common_routes(options: Options) -> _RouteHandlerSpecs: {"path": str(IDOM_WEB_MODULES_DIR.current)}, ), ( - rf"{ASSETS_PATH}/(.*)", + rf"{CLIENT_PATH}/(.*)", StaticFileHandler, - {"path": str(CLIENT_BUILD_DIR / "assets")}, + {"path": str(CLIENT_BUILD_DIR)}, ), ( r"/(.*)", diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index bbc1848a5..9dde02240 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -17,6 +17,7 @@ Optional, Set, Tuple, + TypedDict, TypeVar, cast, ) @@ -24,36 +25,32 @@ from weakref import ref as weakref from idom.config import IDOM_CHECK_VDOM_SPEC, IDOM_DEBUG_MODE +from idom.core._event_proxy import _wrap_in_warning_event_proxies +from idom.core.hooks import LifeCycleHook +from idom.core.types import ComponentType, EventHandlerDict, VdomDict, VdomJson +from idom.core.vdom import validate_vdom_json from idom.utils import Ref -from ._event_proxy import _wrap_in_warning_event_proxies -from .hooks import LifeCycleHook -from .types import ComponentType, EventHandlerDict, VdomDict, VdomJson -from .vdom import validate_vdom_json - logger = getLogger(__name__) -class LayoutUpdate(NamedTuple): +class LayoutUpdate(TypedDict): """A change to a view as a result of a :meth:`Layout.render`""" path: str """A "/" delimited path to the element from the root of the layout""" - old: Optional[VdomJson] - """The old state of the layout""" - new: VdomJson """The new state of the layout""" -class LayoutEvent(NamedTuple): +class LayoutEvent(TypedDict): """An event that should be relayed to its handler by :meth:`Layout.deliver`""" target: str """The ID of the event handler.""" - data: List[Any] + data: list[Any] """A list of event data passed to the event handler.""" @@ -110,16 +107,16 @@ async def deliver(self, event: LayoutEvent) -> None: # associated with a backend model that has been deleted. We only handle # events if the element and the handler exist in the backend. Otherwise # we just ignore the event. - handler = self._event_handlers.get(event.target) + handler = self._event_handlers.get(event["target"]) if handler is not None: try: - await handler.function(_wrap_in_warning_event_proxies(event.data)) + await handler.function(_wrap_in_warning_event_proxies(event["data"])) except Exception: logger.exception(f"Failed to execute event handler {handler}") else: logger.info( - f"Ignored event - handler {event.target!r} does not exist or its component unmounted" + f"Ignored event - handler {event['target']!r} does not exist or its component unmounted" ) async def render(self) -> LayoutUpdate: @@ -148,17 +145,10 @@ def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdate: with ExitStack() as exit_stack: self._render_component(exit_stack, old_state, new_state, component) - old_model: Optional[VdomJson] - try: - old_model = old_state.model.current - except AttributeError: - old_model = None - - return LayoutUpdate( - path=new_state.patch_path, - old=old_model, - new=new_state.model.current, - ) + return { + "path": new_state.patch_path, + "new": new_state.model.current, + } def _render_component( self, diff --git a/src/idom/core/serve.py b/src/idom/core/serve.py deleted file mode 100644 index 69071555f..000000000 --- a/src/idom/core/serve.py +++ /dev/null @@ -1,93 +0,0 @@ -from __future__ import annotations - -from asyncio import ensure_future -from asyncio.tasks import ensure_future -from logging import getLogger -from typing import Any, Awaitable, Callable, Dict, List, NamedTuple, cast - -from anyio import create_task_group -from jsonpatch import apply_patch - -from .layout import LayoutEvent, LayoutUpdate -from .types import LayoutType, VdomJson - - -logger = getLogger(__name__) - - -SendCoroutine = Callable[["VdomJsonPatch"], Awaitable[None]] -"""Send model patches given by a dispatcher""" - -RecvCoroutine = Callable[[], Awaitable[LayoutEvent]] -"""Called by a dispatcher to return a :class:`idom.core.layout.LayoutEvent` - -The event will then trigger an :class:`idom.core.proto.EventHandlerType` in a layout. -""" - - -class Stop(BaseException): - """Stop serving changes and events - - Raising this error will tell dispatchers to gracefully exit. Typically this is - called by code running inside a layout to tell it to stop rendering. - """ - - -async def serve_json_patch( - layout: LayoutType[LayoutUpdate, LayoutEvent], - send: SendCoroutine, - recv: RecvCoroutine, -) -> None: - """Run a dispatch loop for a single view instance""" - async with layout: - try: - async with create_task_group() as task_group: - task_group.start_soon(_single_outgoing_loop, layout, send) - task_group.start_soon(_single_incoming_loop, layout, recv) - except Stop: - logger.info("Stopped dispatch task") - - -async def render_json_patch(layout: LayoutType[LayoutUpdate, Any]) -> VdomJsonPatch: - """Render a class:`VdomJsonPatch` from a layout""" - return VdomJsonPatch.create_from(await layout.render()) - - -class VdomJsonPatch(NamedTuple): - """An object describing an update to a :class:`Layout` in the form of a JSON patch""" - - path: str - """The path where changes should be applied""" - - changes: List[Dict[str, Any]] - """A list of JSON patches to apply at the given path""" - - def apply_to(self, model: VdomJson) -> VdomJson: - """Return the model resulting from the changes in this update""" - return cast( - VdomJson, - apply_patch( - model, [{**c, "path": self.path + c["path"]} for c in self.changes] - ), - ) - - @classmethod - def create_from(cls, update: LayoutUpdate) -> VdomJsonPatch: - """Return a patch given an layout update""" - return cls(update.path, [{"op": "replace", "path": "", "value": update.new}]) - - -async def _single_outgoing_loop( - layout: LayoutType[LayoutUpdate, LayoutEvent], send: SendCoroutine -) -> None: - while True: - await send(await render_json_patch(layout)) - - -async def _single_incoming_loop( - layout: LayoutType[LayoutUpdate, LayoutEvent], recv: RecvCoroutine -) -> None: - while True: - # We need to fire and forget here so that we avoid waiting on the completion - # of this event handler before receiving and running the next one. - ensure_future(layout.deliver(await recv())) diff --git a/src/idom/core/server/__init__.py b/src/idom/core/server/__init__.py new file mode 100644 index 000000000..550ebd37f --- /dev/null +++ b/src/idom/core/server/__init__.py @@ -0,0 +1,4 @@ +from .serve import RecvCoroutine, SendCoroutine, Stop, serve + + +__all__ = ["serve", "Stop"] diff --git a/src/idom/core/server/files.py b/src/idom/core/server/files.py new file mode 100644 index 000000000..9baa24912 --- /dev/null +++ b/src/idom/core/server/files.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from asyncio import ( + FIRST_COMPLETED, + Event, + Queue, + Semaphore, + Task, + create_task, + sleep, + wait, +) +from contextlib import AsyncExitStack +from dataclasses import dataclass +from logging import getLogger +from typing import AsyncIterator +from weakref import ReferenceType, finalize, ref + +from idom.backend.common.types import ByteStream, FileUploadMessage + + +logger = getLogger(__name__) + + +class ByteStreamFiles: + def __init__( + self, + max_chunk_size: int | None = None, + max_queue_size: int | None = None, + max_stream_count: int | None = None, + message_timeout: float | None = None, + completion_timeout: float | None = None, + ) -> None: + self._max_chunk_size = max_chunk_size + self._max_queue_size = max_queue_size + self._message_timeout = message_timeout + self._completion_timeout = completion_timeout + self._stream_count_semaphore = ( + Semaphore(max_stream_count) if max_stream_count else None + ) + self._streams: dict[str, _ByteStreamState] = {} + + def get(self, file: str) -> ByteStream: + """Get a byte stream that will be written to.""" + if file not in self._streams: + stream = ByteStream( + self._max_chunk_size, + self._max_queue_size, + self._message_timeout, + ) + self._create_stream_state(stream) + else: + stream = self._streams[file] + return stream + + async def handle(self, message: FileUploadMessage) -> None: + file = message["file"] + state = self._streams.get(file) + if state is None: + logger.info(f"No stream exists for {file!r}") + + stream = state.stream() + if stream is None: + logger.info(f"No stream exists for {file!r}") + + if self._stream_count_semaphore is not None: + await state.exit_stack.enter_async_context(self._stream_count_semaphore) + + await stream.put(message["data"]) + + if not message["bytes-remaining"]: + await self._clean_stream_state(file) + + def _create_stream_state(self, file: str, stream: ByteStream) -> None: + async def clean_on_timeout(): + await sleep(self._completion_timeout) + logger.warning( + f"File upload for {file!r} timed out " + f"after {self._completion_timeout} seconds." + ) + await self._clean_stream_state(file) + + self._streams[file] = _ByteStreamState( + stream=ref(stream), + exit_stack=AsyncExitStack(), + timeout_task=create_task(clean_on_timeout()), + ) + + finalize(stream, lambda: create_task(self._create_stream_state(file))) + + async def _clean_stream_state(self, file: str) -> None: + state = self._streams.pop(file, None) + if state is None: + return None + state.timeout_task.cancel() + await self._streams.pop(file).exit_stack.aclose() + + +@dataclass +class _ByteStreamState: + stream: ReferenceType[ByteStream] + exit_stack: AsyncExitStack + timeout_task: Task[None] + + +class ByteStream: + def __init__( + self, + max_chunk_size: int | None = None, + max_queue_size: int | None = None, + default_timeout: float | None = None, + ) -> None: + self._queue: Queue[bytes] = Queue(max_queue_size or 0) + self._max_chunk_size = max_chunk_size + self._closed = Event() + self._default_timeout = default_timeout + + async def put(self, data: bytes, timeout: float) -> None: + """Put data into the stream and raise ``RuntimeError`` if closed.""" + if self._closed.is_set(): + raise RuntimeError("Stream already closed.") + elif self._max_chunk_size and len(data) > self._max_chunk_size: + raise RuntimeError(f"Max chunk size of {self._max_chunk_size} exceeded") + + timeout = timeout if timeout is not None else self._default_timeout + + put_task = create_task(self._queue.put(data)) + closed_task = create_task(self._closed.wait()) + await wait( + (put_task, closed_task), + timeout=timeout, + return_when=FIRST_COMPLETED, + ) + + if put_task.done(): + return None + elif closed_task.done(): + raise RuntimeError("Stream closed while putting.") + else: + raise TimeoutError(f"No data after {timeout} seconds") + + async def get(self, timeout: float | None = None) -> bytes | None: + """Return bytes or None when the stream is closed.""" + if self._closed.is_set(): + return None + + timeout = timeout if timeout is not None else self._default_timeout + + get_task = create_task(self._queue.get()) + closed_task = create_task(self._closed.wait()) + await wait( + (get_task, closed_task), + timeout=timeout, + return_when=FIRST_COMPLETED, + ) + + if get_task.done(): + return await get_task.result() + elif closed_task.done(): + return None + else: + raise TimeoutError(f"No data after {timeout} seconds") + + async def iter(self, timeout: float) -> AsyncIterator[bytes]: + while True: + value = await self.get(timeout) + if value is None: + return + yield value + + def close(self) -> None: + """Close the stream""" + self._closed.set() + + def is_closed(self): + """Whether this stream is already closed""" + return self._closed diff --git a/src/idom/core/server/serve.py b/src/idom/core/server/serve.py new file mode 100644 index 000000000..876afe4ef --- /dev/null +++ b/src/idom/core/server/serve.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from asyncio import create_task +from logging import getLogger +from typing import Any, Awaitable, Callable + +from anyio import create_task_group + +from idom.core.layout import LayoutEvent, LayoutUpdate +from idom.core.server.types import ( + ClientInfoMessage, + ClientMessage, + FileHandler, + FileUploadMessage, + LayoutEventMessage, + ServerMessage, +) +from idom.core.types import LayoutType + + +logger = getLogger(__name__) +VERSION = "0.0.1" + +SendCoroutine = Callable[[ServerMessage], Awaitable[None]] +RecvCoroutine = Callable[[], Awaitable[ClientMessage]] +BuiltinLayout = LayoutType[LayoutUpdate, LayoutEvent] + + +async def serve( + send: SendCoroutine, + recv: RecvCoroutine, + layout: BuiltinLayout, + file_handler: FileHandler | None = None, +) -> None: + + async with layout: + try: + async with create_task_group() as task_group: + task_group.start_soon(_outgoing_loop, send, layout) + task_group.start_soon(_incoming_loop, recv, layout, file_handler) + except Stop: + logger.info("Stopped dispatch task") + + +class Stop(BaseException): + """Stop serving changes and events + + Raising this error will tell dispatchers to gracefully exit. Typically this is + called by code running inside a layout to tell it to stop rendering. + """ + + +async def _outgoing_loop(send: SendCoroutine, layout: BuiltinLayout) -> None: + await send({"type": "server-info", "version": VERSION}) + while True: + await send({"type": "layout-update", "data": await layout.render()}) + + +async def _incoming_loop( + recv: RecvCoroutine, + layout: BuiltinLayout, + file_handler: FileHandler | None, +) -> None: + async def handle_layout_event(message: LayoutEventMessage) -> None: + await layout.deliver(message["data"]) + + async def handle_client_info(message: ClientInfoMessage) -> None: + ... + + async def handle_file_upload(message: FileUploadMessage) -> None: + if file_handler: + await file_handler.handle(message) + + message_handlers: dict[str, Callable[[Any], Awaitable[None]]] = { + "layout-event": handle_layout_event, + "client-info": handle_client_info, + "file-upload": handle_file_upload, + } + + while True: + message = await recv() + handler = message_handlers.get(message["type"]) + if handler is None: + logger.error(f"Unknown message type {message['type']!r}") + # We need to fire and forget here so that we avoid waiting on the completion + # of this event handler before receiving and running the next one. + create_task(handler(message)) diff --git a/src/idom/core/server/types.py b/src/idom/core/server/types.py new file mode 100644 index 000000000..325359417 --- /dev/null +++ b/src/idom/core/server/types.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from typing import Any, Callable, TypedDict, TypeVar, Union, get_type_hints + +from typing_extensions import Literal, Protocol, TypeGuard + +from idom.core.layout import LayoutEvent, LayoutUpdate + + +class FileHandler(Protocol): + async def handle(self, message: FileUploadMessage) -> None: + ... + + +_Type = TypeVar("_Type") + + +def _make_message_type_guard( + msg_type: type[_Type], +) -> Callable[[Any], TypeGuard[_Type]]: + annotations = get_type_hints(msg_type) + type_anno_args = getattr(annotations["type"], "__args__") + assert ( + isinstance(type_anno_args, tuple) + and len(type_anno_args) == 1 + and isinstance(type_anno_args[0], str) + ) + expected_type = type_anno_args[0] + + def type_guard(value: _Type) -> TypeGuard[_Type]: + assert isinstance(value, dict) + return value["type"] == expected_type + + type_guard.__doc__ = f"Check wheter the given value is a {expected_type!r} message" + + return type_guard + + +ServerMessage = "ServerInfoMessage" + +ClientMessage = Union[ + "ClientInfoMessage", + "LayoutEventMessage", + "FileUploadMessage", +] + +ServerInfoMessage = TypedDict( + "ServerInfoMessage", + { + "type": Literal["server-info"], + "version": str, + }, +) +is_server_info_message = _make_message_type_guard(ServerInfoMessage) + +ClientInfoMessage = TypedDict( + "ClientInfoMessage", + { + "type": Literal["client-info"], + "version": str, + }, +) +is_client_info_message = _make_message_type_guard(ClientInfoMessage) + + +LayoutUpdateMessage = TypedDict( + "LayoutUpdateMessage", + { + "type": Literal["layout-update"], + "data": LayoutUpdate, + }, +) +is_layout_udpate_message = _make_message_type_guard(LayoutUpdateMessage) + +LayoutEventMessage = TypedDict( + "LayoutEventMessage", + { + "type": Literal["layout-event"], + "data": LayoutEvent, + }, +) +is_layout_event_message = _make_message_type_guard(LayoutEventMessage) + +FileUploadMessage = TypedDict( + "FileUploadMessage", + { + "type": Literal["file-upload"], + "file": str, + "data": bytes, + "bytes-chunk-size": int, + "bytes-sent": int, + "bytes-remaining": int, + }, +) +is_file_upload_message = _make_message_type_guard(FileUploadMessage) diff --git a/src/idom/testing/backend.py b/src/idom/testing/backend.py index 3376f8439..0e475fded 100644 --- a/src/idom/testing/backend.py +++ b/src/idom/testing/backend.py @@ -8,8 +8,8 @@ from urllib.parse import urlencode, urlunparse from idom.backend import default as default_server -from idom.backend.types import BackendImplementation -from idom.backend.utils import find_available_port +from idom.backend.common.types import BackendImplementation +from idom.backend.common.utils import find_available_port from idom.widgets import hotswap from .logs import LogAssertionError, capture_idom_logs, list_logged_exceptions diff --git a/src/idom/types.py b/src/idom/types.py index 73ffef03b..d1b0cf8e3 100644 --- a/src/idom/types.py +++ b/src/idom/types.py @@ -4,7 +4,7 @@ - :mod:`idom.backend.types` """ -from .backend.types import BackendImplementation, Connection, Location +from .backend.common.types import BackendImplementation, Connection, Location from .core.component import Component from .core.hooks import Context from .core.types import ( diff --git a/temp.py b/temp.py new file mode 100644 index 000000000..e163ae961 --- /dev/null +++ b/temp.py @@ -0,0 +1,18 @@ +import asyncio + +from idom import component, html, run +from idom.backend.starlette import ( + configure, + create_development_app, + serve_development_app, +) + + +@component +def temp(): + return html.h1("asd") + + +app = create_development_app() +configure(app, temp) +asyncio.run(serve_development_app(app, "localhost", 8000)) diff --git a/tests/test_backend/test__common.py b/tests/test_backend/test__common.py index e575625a2..c08e230e8 100644 --- a/tests/test_backend/test__common.py +++ b/tests/test_backend/test__common.py @@ -1,7 +1,7 @@ import pytest from idom import html -from idom.backend._common import traversal_safe_path, vdom_head_elements_to_html +from idom.backend.common.impl import traversal_safe_path, vdom_head_elements_to_html @pytest.mark.parametrize( diff --git a/tests/test_backend/test_all.py b/tests/test_backend/test_all.py index 98036cb16..e6028b99c 100644 --- a/tests/test_backend/test_all.py +++ b/tests/test_backend/test_all.py @@ -5,9 +5,9 @@ import idom from idom import html from idom.backend import default as default_implementation -from idom.backend._common import PATH_PREFIX -from idom.backend.types import BackendImplementation, Connection, Location -from idom.backend.utils import all_implementations +from idom.backend.common.impl import PATH_PREFIX +from idom.backend.common.types import BackendImplementation, Connection, Location +from idom.backend.common.utils import all_implementations from idom.testing import BackendFixture, DisplayFixture, poll diff --git a/tests/test_backend/test_utils.py b/tests/test_backend/test_utils.py index c3cb13613..95c813391 100644 --- a/tests/test_backend/test_utils.py +++ b/tests/test_backend/test_utils.py @@ -6,8 +6,8 @@ from playwright.async_api import Page from idom.backend import flask as flask_implementation -from idom.backend.utils import find_available_port -from idom.backend.utils import run as sync_run +from idom.backend.common.utils import find_available_port +from idom.backend.common.utils import run as sync_run from idom.sample import SampleApp as SampleApp diff --git a/tests/test_client.py b/tests/test_client.py index 0e48e3390..dbe8c79c6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -5,7 +5,7 @@ from playwright.async_api import Browser import idom -from idom.backend.utils import find_available_port +from idom.backend.common.utils import find_available_port from idom.testing import BackendFixture, DisplayFixture from tests.tooling.common import DEFAULT_TYPE_DELAY diff --git a/tests/test_core/test_serve.py b/tests/test_core/test_serve.py index 8e3f05ded..b78bb7898 100644 --- a/tests/test_core/test_serve.py +++ b/tests/test_core/test_serve.py @@ -3,7 +3,7 @@ import idom from idom.core.layout import Layout, LayoutEvent, LayoutUpdate -from idom.core.serve import VdomJsonPatch, serve_json_patch +from idom.core.server import VdomJsonPatch, serve_json_patch from idom.testing import StaticEventHandler