Jellyfin(8096), OrbStack(8097) 포트 충돌으로 변경. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
175 lines
6.0 KiB
Python
175 lines
6.0 KiB
Python
from __future__ import annotations
|
|
|
|
import typing
|
|
|
|
from js import Promise # type: ignore[import]
|
|
from pyodide.ffi import create_proxy, run_sync # type: ignore[import]
|
|
|
|
try:
|
|
from js import WebSocket as JSWebSocket # type: ignore[import]
|
|
except ImportError:
|
|
JSWebSocket = None
|
|
|
|
|
|
class PyodideWebSocketExtension:
|
|
"""WebSocket extension for Pyodide using the browser's native WebSocket API.
|
|
|
|
Synchronous via JSPI (run_sync). Uses JS Promises for signaling instead of
|
|
asyncio primitives, because run_sync bypasses the asyncio event loop.
|
|
Messages from JS callbacks are buffered in a list and delivered via
|
|
next_payload() which blocks via run_sync on a JS Promise.
|
|
"""
|
|
|
|
def __init__(self, url: str) -> None:
|
|
if JSWebSocket is None: # Defensive: depends on JS runtime
|
|
raise OSError(
|
|
"WebSocket is not available in this JavaScript runtime. "
|
|
"Browser environment required (not supported in Node.js)."
|
|
)
|
|
|
|
self._closed = False
|
|
self._pending: list[str | bytes | None] = []
|
|
self._waiting_resolve: typing.Any = None
|
|
self._last_msg: str | bytes | None = None
|
|
self._proxies: list[typing.Any] = []
|
|
|
|
# Create browser WebSocket
|
|
self._ws = JSWebSocket.new(url)
|
|
self._ws.binaryType = "arraybuffer"
|
|
|
|
# Open/error signaling via a JS Promise (not asyncio.Future,
|
|
# because run_sync/JSPI does not drive the asyncio event loop).
|
|
_open_state: dict[str, typing.Any] = {
|
|
"resolve": None,
|
|
"reject": None,
|
|
}
|
|
|
|
def _open_executor(resolve: typing.Any, reject: typing.Any) -> None:
|
|
_open_state["resolve"] = resolve
|
|
_open_state["reject"] = reject
|
|
|
|
exec_proxy = create_proxy(_open_executor)
|
|
open_promise = Promise.new(exec_proxy)
|
|
self._proxies.append(exec_proxy)
|
|
|
|
# JS→Python callbacks: invoked by the browser event loop, not Python call
|
|
# frames, so coverage cannot trace into them.
|
|
def _onopen(event: typing.Any) -> None: # Defensive: JS callback
|
|
r = _open_state["resolve"]
|
|
if r is not None:
|
|
_open_state["resolve"] = None
|
|
_open_state["reject"] = None
|
|
r()
|
|
|
|
def _onerror(event: typing.Any) -> None: # Defensive: JS callback
|
|
r = _open_state["reject"]
|
|
if r is not None:
|
|
_open_state["resolve"] = None
|
|
_open_state["reject"] = None
|
|
r("WebSocket connection failed")
|
|
|
|
def _onmessage(event: typing.Any) -> None: # Defensive: JS callback
|
|
data = event.data
|
|
if isinstance(data, str):
|
|
msg: str | bytes = data
|
|
else:
|
|
# ArrayBuffer → bytes via Pyodide
|
|
msg = bytes(data.to_py())
|
|
|
|
if self._waiting_resolve is not None:
|
|
self._last_msg = msg
|
|
r = self._waiting_resolve
|
|
self._waiting_resolve = None
|
|
r()
|
|
else:
|
|
self._pending.append(msg)
|
|
|
|
def _onclose(event: typing.Any) -> None: # Defensive: JS callback
|
|
if self._waiting_resolve is not None:
|
|
self._last_msg = None
|
|
r = self._waiting_resolve
|
|
self._waiting_resolve = None
|
|
r()
|
|
else:
|
|
self._pending.append(None)
|
|
|
|
# Create proxies so JS can call these Python functions
|
|
for name, fn in [
|
|
("onopen", _onopen),
|
|
("onerror", _onerror),
|
|
("onmessage", _onmessage),
|
|
("onclose", _onclose),
|
|
]:
|
|
proxy = create_proxy(fn)
|
|
self._proxies.append(proxy)
|
|
setattr(self._ws, name, proxy)
|
|
|
|
# Block until the connection is open (or error rejects the promise)
|
|
run_sync(open_promise)
|
|
|
|
@property
|
|
def closed(self) -> bool:
|
|
return self._closed
|
|
|
|
def next_payload(self) -> str | bytes | None:
|
|
"""Block (via JSPI) until the next message arrives.
|
|
Returns None when the remote end closes the connection."""
|
|
if self._closed: # Defensive: caller should not call after close
|
|
raise OSError("The WebSocket extension is closed")
|
|
|
|
# Drain from buffer first
|
|
if self._pending:
|
|
msg = self._pending.pop(0)
|
|
if msg is None: # Defensive: serverinitiated close sentinel
|
|
self._closed = True
|
|
return msg
|
|
|
|
# Wait for the next message via a JS Promise
|
|
def _executor(resolve: typing.Any, reject: typing.Any) -> None:
|
|
self._waiting_resolve = resolve
|
|
|
|
exec_proxy = create_proxy(_executor)
|
|
promise = Promise.new(exec_proxy)
|
|
run_sync(promise)
|
|
exec_proxy.destroy()
|
|
|
|
msg = self._last_msg
|
|
if msg is None: # Defensive: server-initiated close sentinel
|
|
self._closed = True
|
|
return msg
|
|
|
|
def send_payload(self, buf: str | bytes) -> None:
|
|
"""Send a message over the WebSocket."""
|
|
if self._closed: # Defensive: caller should not call after close
|
|
raise OSError("The WebSocket extension is closed")
|
|
|
|
if isinstance(buf, (bytes, bytearray)):
|
|
from js import Uint8Array # type: ignore[import]
|
|
|
|
self._ws.send(Uint8Array.new(buf))
|
|
else:
|
|
self._ws.send(buf)
|
|
|
|
def ping(self) -> None:
|
|
"""No-op — browser WebSocket handles ping/pong at protocol level."""
|
|
pass
|
|
|
|
def close(self) -> None:
|
|
"""Close the WebSocket and clean up proxies."""
|
|
if self._closed: # Defensive: idempotent close
|
|
return
|
|
|
|
self._closed = True
|
|
|
|
try:
|
|
self._ws.close()
|
|
except Exception: # Defensive: suppress JS errors on teardown
|
|
pass
|
|
|
|
for proxy in self._proxies:
|
|
try:
|
|
proxy.destroy()
|
|
except Exception: # Defensive: suppress JS errors on teardown
|
|
pass
|
|
self._proxies.clear()
|