Skip to content
This repository has been archived by the owner on Mar 2, 2023. It is now read-only.

Study uvicorn-browser #15

Open
Kludex opened this issue Nov 12, 2022 · 6 comments
Open

Study uvicorn-browser #15

Kludex opened this issue Nov 12, 2022 · 6 comments

Comments

@Kludex
Copy link
Owner

Kludex commented Nov 12, 2022

I've implemented uvicorn-browser some months ago, but the implementation is too naive. Should I improve it, and release it here as well?

@florimondmanca
Copy link

florimondmanca commented Nov 13, 2022

This prompted me to think about arel vs uvicorn-browser.

I never thought we could implement browser reload at the ASGI server level. That's pretty interesting. As I understand, uvicorn-reload wraps the uvicorn CLI and plugs onto the reload_dirs etc options.

arel uses a different approach. It does reload at the application level, rather than server level. My motivation was to make it work for any ASGI app -- and server. The counterpart is: users have to do a bit of setup themselves. 1/ Register the WebSocket endpoint on their app (eg add a WebSocketRoute(hot_reload) on Starlette), 2/ register the reload JS script in their HTML pages (eg Jinja base template).

Arel has dedicated path reloading options, which allows reloading the page when something other than Python files changes (eg a Jinja template, a JS script, etc). But this also brings a problem. If I'm also running uvicorn <app> --reload during development, and I change a Python file, then Uvicorn reloads itself first, which breaks any open WebSocket reload connection. And so the web page doesn't get reloaded. One has to refresh the page so the WebSocket connection is established again.

uvicorn-browser doesn't use WebSocket, instead it relies on selenium to actually control the browser. Which is again pretty interesting. Is that something other reloaders do, e.g. in JavaScript frameworks? I always thought they mainly maintained a WebSocket connection.

So, arel has a problem (what to do when the ASGI server reloads, which closes any open connections? [right?*]), uvicorn-browser has another (it's an ASGI server-specific implementation, not agnostic)... Is there a way to solve both of these problems to have some sort of "ultimate ASGI browser reload" solution?

Edit: one solution might be to simply augment arel's JS script to retry upon WebSocket disconnects, after all...

@Kludex
Copy link
Owner Author

Kludex commented Nov 13, 2022

Hold on Florimond, let me type, stop editing! hahaha

@Kludex
Copy link
Owner Author

Kludex commented Nov 13, 2022

First, thanks for coming here. I'm always happy to read your messages. 🙏

Yesterday, I was playing with arel, and yes, indeed the --reload problem happened, and indeed, I solved it relying on a different JS script.

This is where I stopped:

FastAPI Docs Reload
from __future__ import annotations

from pathlib import Path

import arel
from fastapi import FastAPI
from fastapi.openapi.docs import (
    get_redoc_html,
    get_swagger_ui_html,
    get_swagger_ui_oauth2_redirect_html,
)
from fastapi.openapi.utils import get_openapi
from starlette.requests import Request
from starlette.responses import HTMLResponse
from starlette.routing import WebSocketRoute


def script(url: str) -> str:
    return f"""
    <script>
    function connect() {{
        var ws = new WebSocket('{url}');
        ws.onopen = function() {{

        }};

        ws.onmessage = function(e) {{
            window.location.reload();
        }};

        ws.onclose = function(e) {{
            console.log('Socket is closed. Reconnect will be attempted in 0.1 second.', e.reason);
            window.location.reload();
            setTimeout(function() {{
                connect();
            }}, 100);
        }};

        ws.onerror = function(err) {{
            console.error('Socket encountered error: ', err.message, 'Closing socket');
            ws.close();
        }};
    }}

    connect();
    </script>
    """


def reload_docs_ui(app: FastAPI, paths: list[Path]) -> None:
    # if app.docs_url or app.redoc_url:
    #     raise RuntimeError(
    #         "You cannot use `reload_docs_ui` when you have `docs_url` or `redoc_url`.\n"
    #         "On your `FastAPI` instance, set `FastAPI(docs_url=None, redoc_url=None)`."
    #     )

    hot_reload = arel.HotReload(paths=[arel.Path(str(path)) for path in paths])
    hot_reload_route = WebSocketRoute("/hot-reload", hot_reload, name="hot-reload")
    app.router.routes.append(hot_reload_route)
    app.add_event_handler("startup", hot_reload.startup)
    app.add_event_handler("shutdown", hot_reload.shutdown)

    def custom_openapi():
        return get_openapi(
            title=app.title,
            version=app.version,
            openapi_version=app.openapi_version,
            description=app.description,
            terms_of_service=app.terms_of_service,
            contact=app.contact,
            license_info=app.license_info,
            routes=app.routes,
            tags=app.openapi_tags,
            servers=app.servers,
        )

    app.openapi = custom_openapi

    if app.openapi_url and app.docs_url:

        async def swagger_ui_html(req: Request) -> HTMLResponse:
            root_path = req.scope.get("root_path", "").rstrip("/")
            openapi_url = root_path + app.openapi_url
            oauth2_redirect_url = app.swagger_ui_oauth2_redirect_url
            if oauth2_redirect_url:
                oauth2_redirect_url = root_path + oauth2_redirect_url
            html_response = get_swagger_ui_html(
                openapi_url=openapi_url,
                title=app.title + " - Swagger UI",
                oauth2_redirect_url=oauth2_redirect_url,
                init_oauth=app.swagger_ui_init_oauth,
                swagger_ui_parameters=app.swagger_ui_parameters,
            )
            return HTMLResponse(
                html_response.body.decode(html_response.charset)
                + script(req.url_for("hot-reload"))
            )

        app.add_route("/potato", swagger_ui_html, include_in_schema=False)

        if app.swagger_ui_oauth2_redirect_url:

            async def swagger_ui_redirect(req: Request) -> HTMLResponse:
                return get_swagger_ui_oauth2_redirect_html()

            app.add_route(
                app.swagger_ui_oauth2_redirect_url,
                swagger_ui_redirect,
                include_in_schema=False,
            )

    if app.openapi_url and app.redoc_url:

        async def redoc_html(req: Request) -> HTMLResponse:
            root_path = req.scope.get("root_path", "").rstrip("/")
            openapi_url = root_path + app.openapi_url
            return get_redoc_html(openapi_url=openapi_url, title=app.title + " - ReDoc")

        app.add_route(app.redoc_url, redoc_html, include_in_schema=False)

Application:

from pathlib import Path

from fastapi import FastAPI
from fastapi_docs_reload import reload_docs_ui

app = FastAPI()


# @app.get("/")
# def home():
#     ...


reload_docs_ui(app, [Path.cwd()])

Using uvicorn main:app --reload is possible with the above.

@Kludex
Copy link
Owner Author

Kludex commented Nov 13, 2022

It's kind of a draft, so if you try it, you see that I've hard coded the "/potato" endpoint.

@Kludex
Copy link
Owner Author

Kludex commented Nov 13, 2022

Can we create a middleware to inject the script when more_body is False or something like that? 🤔

Like, to use with another ASGI frameworks.

@florimondmanca
Copy link

@Kludex Ah, well yes there's another limitation with arel: what about the pages where we can't inject the {{ script }} ourselves? FastAPI docs are a good example.

A middleware that injects the script into the HTML might be a possibility, yes.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants