add datastar SSE rendering
This commit is contained in:
parent
2accb26546
commit
33dbb143fd
5 changed files with 329 additions and 19 deletions
85
repub/datastar.py
Normal file
85
repub/datastar.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
from typing import Protocol
|
||||
|
||||
from datastar_py import ServerSentEventGenerator as SSE
|
||||
from datastar_py.sse import DatastarEvent
|
||||
|
||||
|
||||
class HtmlRenderable(Protocol):
|
||||
def __html__(self) -> str: ...
|
||||
|
||||
|
||||
RenderResult = str | HtmlRenderable
|
||||
RenderFunction = Callable[[], Awaitable[RenderResult]]
|
||||
|
||||
|
||||
class RefreshBroker:
|
||||
def __init__(self) -> None:
|
||||
self._subscribers: set[asyncio.Queue[object]] = set()
|
||||
|
||||
def subscribe(self) -> asyncio.Queue[object]:
|
||||
queue: asyncio.Queue[object] = asyncio.Queue(maxsize=1)
|
||||
self._subscribers.add(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, queue: asyncio.Queue[object]) -> None:
|
||||
self._subscribers.discard(queue)
|
||||
|
||||
def publish(self, event: object = "refresh-event") -> None:
|
||||
for queue in tuple(self._subscribers):
|
||||
if queue.full():
|
||||
try:
|
||||
queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
try:
|
||||
queue.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
continue
|
||||
|
||||
|
||||
async def render_sse_event(
|
||||
render: RenderFunction, *, last_event_id: str | None = None
|
||||
) -> tuple[str | None, DatastarEvent | None]:
|
||||
html = _coerce_html(await render())
|
||||
event_id = _render_hash(html)
|
||||
if event_id == last_event_id:
|
||||
return last_event_id, None
|
||||
return event_id, SSE.patch_elements(html, event_id=event_id)
|
||||
|
||||
|
||||
async def render_stream(
|
||||
queue: asyncio.Queue[object],
|
||||
render: RenderFunction,
|
||||
*,
|
||||
last_event_id: str | None = None,
|
||||
render_on_connect: bool = True,
|
||||
) -> AsyncGenerator[DatastarEvent, None]:
|
||||
if render_on_connect:
|
||||
last_event_id, event = await render_sse_event(
|
||||
render, last_event_id=last_event_id
|
||||
)
|
||||
if event is not None:
|
||||
yield event
|
||||
|
||||
while True:
|
||||
await queue.get()
|
||||
last_event_id, event = await render_sse_event(
|
||||
render, last_event_id=last_event_id
|
||||
)
|
||||
if event is not None:
|
||||
yield event
|
||||
|
||||
|
||||
def _coerce_html(view: RenderResult) -> str:
|
||||
if isinstance(view, str):
|
||||
return view
|
||||
return view.__html__()
|
||||
|
||||
|
||||
def _render_hash(html: str) -> str:
|
||||
return hashlib.blake2s(html.encode("utf-8"), digest_size=16).hexdigest()
|
||||
|
|
@ -91,7 +91,7 @@ def page_header() -> Renderable:
|
|||
]
|
||||
|
||||
|
||||
def overview_section() -> Renderable:
|
||||
def overview_section(*, active_jobs: str) -> Renderable:
|
||||
return h.section[
|
||||
h.div(class_="mb-4 flex items-end justify-between")[
|
||||
h.div[
|
||||
|
|
@ -105,7 +105,11 @@ def overview_section() -> Renderable:
|
|||
h.p(class_="text-sm text-slate-500")["Updated from static fixture data"],
|
||||
],
|
||||
h.dl(class_="grid gap-4 md:grid-cols-2 xl:grid-cols-4")[
|
||||
stat_card(label="Active jobs", value="12", detail="9 scheduled, 3 paused"),
|
||||
stat_card(
|
||||
label="Active jobs",
|
||||
value=active_jobs,
|
||||
detail="Temporary live demo counter for Datastar refresh testing",
|
||||
),
|
||||
stat_card(label="Running now", value="2", detail="RSS and Pangea workers"),
|
||||
stat_card(
|
||||
label="Completed today", value="34", detail="31 succeeded, 3 failed"
|
||||
|
|
@ -450,7 +454,7 @@ def settings_panel() -> Renderable:
|
|||
]
|
||||
|
||||
|
||||
def admin_component() -> Renderable:
|
||||
def admin_component(*, active_jobs: str = "12") -> Renderable:
|
||||
running_rows = (
|
||||
(
|
||||
h.div(class_="font-semibold text-slate-950")["Pangea mobile articles"],
|
||||
|
|
@ -566,7 +570,7 @@ def admin_component() -> Renderable:
|
|||
h.div(class_="px-4 py-5 sm:px-6 lg:px-8 lg:py-8")[
|
||||
h.div(class_="mx-auto max-w-7xl space-y-6")[
|
||||
page_header(),
|
||||
overview_section(),
|
||||
overview_section(active_jobs=active_jobs),
|
||||
h.div(
|
||||
class_="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(22rem,0.95fr)]"
|
||||
)[
|
||||
|
|
|
|||
80
repub/web.py
80
repub/web.py
|
|
@ -1,12 +1,24 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import suppress
|
||||
from typing import cast
|
||||
|
||||
import htpy as h
|
||||
from datastar_py.quart import DatastarResponse
|
||||
from datastar_py.sse import DatastarEvent
|
||||
from htpy import Renderable
|
||||
from quart import Quart, Response, request, url_for
|
||||
|
||||
from repub.datastar import RefreshBroker, render_stream
|
||||
from repub.pages import admin_component, shim_page
|
||||
|
||||
REFRESH_BROKER_KEY = "repub.refresh_broker"
|
||||
ACTIVE_JOBS_KEY = "repub.demo_active_jobs"
|
||||
REFRESH_TASK_KEY = "repub.demo_refresh_task"
|
||||
|
||||
|
||||
def _render_shim_page(*, stylesheet_href: str, datastar_src: str) -> tuple[str, str]:
|
||||
head = (
|
||||
|
|
@ -18,8 +30,27 @@ def _render_shim_page(*, stylesheet_href: str, datastar_src: str) -> tuple[str,
|
|||
return body, etag
|
||||
|
||||
|
||||
def create_app() -> Quart:
|
||||
def create_app(*, enable_demo_refresh: bool = True) -> Quart:
|
||||
app = Quart(__name__)
|
||||
app.extensions[REFRESH_BROKER_KEY] = RefreshBroker()
|
||||
app.extensions[ACTIVE_JOBS_KEY] = 12
|
||||
|
||||
if enable_demo_refresh:
|
||||
|
||||
@app.before_serving
|
||||
async def start_demo_refresh() -> None:
|
||||
app.extensions[REFRESH_TASK_KEY] = asyncio.create_task(
|
||||
_demo_refresh_loop(app)
|
||||
)
|
||||
|
||||
@app.after_serving
|
||||
async def stop_demo_refresh() -> None:
|
||||
task = cast(asyncio.Task[None] | None, app.extensions.get(REFRESH_TASK_KEY))
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
@app.get("/")
|
||||
async def index() -> Response:
|
||||
|
|
@ -37,7 +68,50 @@ def create_app() -> Quart:
|
|||
return response
|
||||
|
||||
@app.post("/")
|
||||
async def index_patch() -> Response:
|
||||
return Response(str(admin_component()), mimetype="text/html")
|
||||
async def index_patch() -> DatastarResponse:
|
||||
queue = get_refresh_broker(app).subscribe()
|
||||
stream = render_stream(
|
||||
queue,
|
||||
render=lambda: render_dashboard(app),
|
||||
last_event_id=request.headers.get("last-event-id"),
|
||||
)
|
||||
return DatastarResponse(_unsubscribe_on_close(queue, stream, app))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def get_refresh_broker(app: Quart) -> RefreshBroker:
|
||||
return cast(RefreshBroker, app.extensions[REFRESH_BROKER_KEY])
|
||||
|
||||
|
||||
def trigger_refresh(app: Quart, event: object = "refresh-event") -> None:
|
||||
get_refresh_broker(app).publish(event)
|
||||
|
||||
|
||||
async def render_dashboard(app: Quart) -> Renderable:
|
||||
return admin_component(active_jobs=str(get_active_jobs(app)))
|
||||
|
||||
|
||||
async def _unsubscribe_on_close(
|
||||
queue: object, stream: AsyncGenerator[DatastarEvent, None], app: Quart
|
||||
) -> AsyncGenerator[DatastarEvent, None]:
|
||||
try:
|
||||
async for event in stream:
|
||||
yield event
|
||||
finally:
|
||||
get_refresh_broker(app).unsubscribe(cast(asyncio.Queue[object], queue))
|
||||
|
||||
|
||||
def get_active_jobs(app: Quart) -> int:
|
||||
return cast(int, app.extensions[ACTIVE_JOBS_KEY])
|
||||
|
||||
|
||||
def set_active_jobs(app: Quart, value: int) -> None:
|
||||
app.extensions[ACTIVE_JOBS_KEY] = value
|
||||
|
||||
|
||||
async def _demo_refresh_loop(app: Quart) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
set_active_jobs(app, get_active_jobs(app) + 1)
|
||||
trigger_refresh(app)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue