feat: remove public REST API; route all app calls over WebSocket RPC
The platform no longer exposes a public /api/* REST surface. The original HTTP route handlers are preserved verbatim as the single source of truth inside an internal, never-socket-bound FastAPI app (rpc_app), and are now reached only via RPC-over-WebSocket dispatch. - realtime router: anonymous connections allowed only to /api/auth/login and /api/auth/signup; all other RPCs/subscribes refused until token auth. - RPC dispatch re-injects the bearer token and X-Request-ID, then forwards through httpx.ASGITransport against the internal app (no network hop). - Public app now serves only /, /health, /health/ready and /api/ws; docs/openapi disabled. - Frontend: apiRequest routes /api/* to ws.rpc(); /health probes keep fetch. RealtimeClient gains rpc() with id correlation, timeout and AbortSignal. - Tests repointed to get_rpc_app(); anonymous-auth behaviour covered. Result: zero REST on the public surface; 264 backend tests + frontend typecheck pass.
This commit is contained in:
@@ -48,11 +48,14 @@ ChannelAuthorizer = Callable[["Connection", str], Awaitable[bool]]
|
|||||||
#: both its connection set and its channel routing table.
|
#: both its connection set and its channel routing table.
|
||||||
@dataclass(slots=True, eq=False)
|
@dataclass(slots=True, eq=False)
|
||||||
class Connection:
|
class Connection:
|
||||||
"""One authenticated browser socket."""
|
"""One browser socket. ``user_id`` is ``None`` for an anonymous (pre-login)
|
||||||
|
connection: it may only call the bootstrap RPCs (login/signup) and may not
|
||||||
|
subscribe to live channels."""
|
||||||
|
|
||||||
user_id: str
|
user_id: str | None
|
||||||
is_platform_owner: bool
|
is_platform_owner: bool
|
||||||
websocket: WebSocket
|
websocket: WebSocket
|
||||||
|
token: str | None = None
|
||||||
channels: set[str] = field(default_factory=set)
|
channels: set[str] = field(default_factory=set)
|
||||||
_queue: asyncio.Queue[str] = field(
|
_queue: asyncio.Queue[str] = field(
|
||||||
default_factory=lambda: asyncio.Queue(maxsize=MAX_QUEUE_DEPTH)
|
default_factory=lambda: asyncio.Queue(maxsize=MAX_QUEUE_DEPTH)
|
||||||
|
|||||||
@@ -1,39 +1,48 @@
|
|||||||
"""The WebSocket endpoint — one socket per browser, every live feed on it.
|
"""The WebSocket endpoint — one socket per browser, every live feed and every
|
||||||
|
RPC on it.
|
||||||
|
|
||||||
``GET /api/ws?token=<jwt>``
|
``GET /api/ws?token=<jwt>``
|
||||||
|
|
||||||
The token travels as a query parameter because the browser WebSocket API cannot
|
The token travels as a query parameter because the browser WebSocket API cannot
|
||||||
set an ``Authorization`` header. That is a deliberate, contained exception to
|
set an ``Authorization`` header. That is a deliberate, contained exception to the
|
||||||
the header-only rule: the token is short-lived, the socket is TLS-terminated in
|
header-only rule: the token is short-lived, the socket is TLS-terminated in any
|
||||||
any real deployment, and the alternative (a cookie) would drag CSRF into a
|
real deployment, and the alternative (a cookie) would drag CSRF into a protocol
|
||||||
protocol that has no CSRF story.
|
that has no CSRF story.
|
||||||
|
|
||||||
Protocol — small, symmetric, JSON in both directions:
|
Protocol — small, symmetric, JSON in both directions. Server->client frames
|
||||||
|
share the ``type`` discriminator; client->server frames use ``action``::
|
||||||
|
|
||||||
.. code-block:: text
|
client -> {"action": "subscribe", "channels": ["org:…"]}
|
||||||
|
|
||||||
client -> {"action": "subscribe", "channels": ["org:…", "task:…"]}
|
|
||||||
client -> {"action": "unsubscribe", "channels": ["task:…"]}
|
client -> {"action": "unsubscribe", "channels": ["task:…"]}
|
||||||
client -> {"action": "ping"}
|
client -> {"action": "ping"}
|
||||||
server -> {"type": "hello", "user_id": "…"}
|
client -> {"action": "rpc", "id": "…", "method": "POST",
|
||||||
|
"path": "/api/organizations", "body": {"name": "…"}}
|
||||||
|
server -> {"type": "hello", "user_id": "…"|null}
|
||||||
server -> {"type": "subscribed", "accepted": [...], "rejected": [...]}
|
server -> {"type": "subscribed", "accepted": [...], "rejected": [...]}
|
||||||
server -> {"type": "event", "event": {…envelope…}}
|
server -> {"type": "event", "event": {…envelope…}}
|
||||||
|
server -> {"type": "rpc_result", "id": "…", "status": 200, "body": …}
|
||||||
server -> {"type": "pong"}
|
server -> {"type": "pong"}
|
||||||
server -> {"type": "error", "message": "…"}
|
server -> {"type": "error", "message": "…"}
|
||||||
|
|
||||||
A rejected channel is reported, not hidden: "my task page is not live" must be
|
A connection may be **anonymous** (no token): that is the bootstrap state that
|
||||||
answerable from the browser console alone.
|
lets a brand-new visitor call ``/api/auth/login`` and ``/api/auth/signup`` over
|
||||||
|
RPC. Every other RPC and every ``subscribe`` requires an authenticated
|
||||||
|
connection; the token presented at connect time is re-injected as a bearer
|
||||||
|
header when the RPC is dispatched into the internal app, so the handlers' own
|
||||||
|
auth dependencies still run unchanged.
|
||||||
|
|
||||||
Authorization queries open their own short-lived session: a socket can stay
|
Authorization queries open their own short-lived session: a socket can stay open
|
||||||
open for hours, and one SQLAlchemy session per open tab would quietly exhaust
|
for hours, and one SQLAlchemy session per open tab would quietly exhaust the
|
||||||
the connection pool.
|
connection pool.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from app.application.ports.realtime import dedupe_channels
|
from app.application.ports.realtime import dedupe_channels
|
||||||
@@ -58,6 +67,27 @@ def realtime_manager() -> ConnectionManager:
|
|||||||
return _manager
|
return _manager
|
||||||
|
|
||||||
|
|
||||||
|
#: Paths an anonymous (not-yet-logged-in) connection is allowed to call. These are
|
||||||
|
#: the only two calls a visitor can make; everything else needs a session.
|
||||||
|
_ANONYMOUS_RPC_PATHS = frozenset({"/api/auth/login", "/api/auth/signup"})
|
||||||
|
|
||||||
|
#: One in-process HTTP client that dispatches RPCs into the internal app. Built
|
||||||
|
#: lazily so importing this module never pulls the app into existence.
|
||||||
|
_rpc_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_rpc_client() -> httpx.AsyncClient:
|
||||||
|
global _rpc_client
|
||||||
|
if _rpc_client is None:
|
||||||
|
# The internal app holds every HTTP handler; it is never socket-bound, so
|
||||||
|
# it is reached here via an in-process ASGI transport instead of the wire.
|
||||||
|
from app.interfaces.realtime.rpc_app import get_rpc_app
|
||||||
|
|
||||||
|
transport = httpx.ASGITransport(app=get_rpc_app())
|
||||||
|
_rpc_client = httpx.AsyncClient(transport=transport, base_url="http://internal")
|
||||||
|
return _rpc_client
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws")
|
@router.websocket("/ws")
|
||||||
async def realtime_socket(
|
async def realtime_socket(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
@@ -69,15 +99,15 @@ async def realtime_socket(
|
|||||||
|
|
||||||
await _manager.register(connection)
|
await _manager.register(connection)
|
||||||
|
|
||||||
await _send(
|
_send_frame(
|
||||||
websocket,
|
connection,
|
||||||
{"type": "hello", "user_id": connection.user_id, "server_time": _now_iso()},
|
{"type": "hello", "user_id": connection.user_id, "server_time": _now_iso()},
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
raw = await websocket.receive_text()
|
raw = await websocket.receive_text()
|
||||||
await _handle_message(websocket, connection, raw)
|
await _handle_message(connection, raw)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
except Exception: # noqa: BLE001 - any read failure ends the socket cleanly
|
except Exception: # noqa: BLE001 - any read failure ends the socket cleanly
|
||||||
@@ -87,8 +117,16 @@ async def realtime_socket(
|
|||||||
|
|
||||||
|
|
||||||
async def _authenticate(websocket: WebSocket, token: str | None) -> Connection | None:
|
async def _authenticate(websocket: WebSocket, token: str | None) -> Connection | None:
|
||||||
"""Accept only a valid, active user. Closes 1008 otherwise."""
|
"""Accept an anonymous or an authenticated, active user.
|
||||||
|
|
||||||
|
* No token -> an anonymous connection (bootstrap for login/signup).
|
||||||
|
* A token that decodes to a real, active user -> an authenticated connection
|
||||||
|
whose raw token is kept for RPC re-injection.
|
||||||
|
* A presented-but-invalid token -> 1008, so the client can tell "bad token"
|
||||||
|
from "network dropped" and stop retrying.
|
||||||
|
"""
|
||||||
user = None
|
user = None
|
||||||
|
raw_token: str | None = None
|
||||||
if token:
|
if token:
|
||||||
try:
|
try:
|
||||||
payload = decode_access_token(token)
|
payload = decode_access_token(token)
|
||||||
@@ -97,35 +135,48 @@ async def _authenticate(websocket: WebSocket, token: str | None) -> Connection |
|
|||||||
# Own session: the socket must not depend on request scope.
|
# Own session: the socket must not depend on request scope.
|
||||||
async with SessionFactory() as session:
|
async with SessionFactory() as session:
|
||||||
user = await UserRepository(session).get(str(user_id))
|
user = await UserRepository(session).get(str(user_id))
|
||||||
|
if user is not None and user.is_active:
|
||||||
|
raw_token = token
|
||||||
except AuthenticationError:
|
except AuthenticationError:
|
||||||
user = None
|
user = None
|
||||||
if user is None or not user.is_active:
|
|
||||||
# 1008 (policy violation) rather than a bare close: the client's
|
if token and user is None:
|
||||||
# reconnect logic can then tell "bad token" from "network dropped".
|
|
||||||
await websocket.close(code=1008, reason="unauthorized")
|
await websocket.close(code=1008, reason="unauthorized")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
return Connection(
|
return Connection(
|
||||||
user_id=user.id,
|
user_id=user.id if user else None,
|
||||||
is_platform_owner=user.is_platform_owner,
|
is_platform_owner=user.is_platform_owner if user else False,
|
||||||
|
token=raw_token,
|
||||||
websocket=websocket,
|
websocket=websocket,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_message(websocket: WebSocket, connection: Connection, raw: str) -> None:
|
async def _handle_message(connection: Connection, raw: str) -> None:
|
||||||
try:
|
try:
|
||||||
message = json.loads(raw)
|
message = json.loads(raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
await _send(websocket, {"type": "error", "message": "malformed JSON frame"})
|
_send_frame(connection, {"type": "error", "message": "malformed JSON frame"})
|
||||||
return
|
return
|
||||||
if not isinstance(message, dict):
|
if not isinstance(message, dict):
|
||||||
await _send(websocket, {"type": "error", "message": "frame must be an object"})
|
_send_frame(connection, {"type": "error", "message": "frame must be an object"})
|
||||||
return
|
return
|
||||||
|
|
||||||
action = message.get("action")
|
action = message.get("action")
|
||||||
|
|
||||||
if action == "ping":
|
if action == "ping":
|
||||||
await _send(websocket, {"type": "pong"})
|
_send_frame(connection, {"type": "pong"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if action == "rpc":
|
||||||
|
# Run off the receive loop: a slow handler must not stall pings.
|
||||||
|
asyncio.create_task(_handle_rpc(connection, message))
|
||||||
|
return
|
||||||
|
|
||||||
|
# subscribe / unsubscribe require a logged-in user.
|
||||||
|
if connection.user_id is None:
|
||||||
|
_send_frame(connection, {"type": "error", "message": "authentication required"})
|
||||||
return
|
return
|
||||||
|
|
||||||
channels = dedupe_channels(
|
channels = dedupe_channels(
|
||||||
@@ -134,17 +185,85 @@ async def _handle_message(websocket: WebSocket, connection: Connection, raw: str
|
|||||||
|
|
||||||
if action == "subscribe":
|
if action == "subscribe":
|
||||||
accepted, rejected = await _subscribe(connection, channels)
|
accepted, rejected = await _subscribe(connection, channels)
|
||||||
await _send(
|
_send_frame(
|
||||||
websocket, {"type": "subscribed", "accepted": accepted, "rejected": rejected}
|
connection, {"type": "subscribed", "accepted": accepted, "rejected": rejected}
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if action == "unsubscribe":
|
if action == "unsubscribe":
|
||||||
await _manager.unsubscribe(connection, list(channels))
|
await _manager.unsubscribe(connection, list(channels))
|
||||||
await _send(websocket, {"type": "unsubscribed", "channels": channels})
|
_send_frame(connection, {"type": "unsubscribed", "channels": channels})
|
||||||
return
|
return
|
||||||
|
|
||||||
await _send(websocket, {"type": "error", "message": f"unknown action: {action!r}"})
|
_send_frame(connection, {"type": "error", "message": f"unknown action: {action!r}"})
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_rpc(connection: Connection, message: dict) -> None:
|
||||||
|
"""Dispatch one client RPC into the internal app and return the result."""
|
||||||
|
req_id = message.get("id")
|
||||||
|
if not req_id:
|
||||||
|
_send_frame(connection, {"type": "error", "message": "rpc frame needs an id"})
|
||||||
|
return
|
||||||
|
|
||||||
|
method = str(message.get("method") or "GET").upper()
|
||||||
|
path = message.get("path")
|
||||||
|
if not isinstance(path, str) or not path:
|
||||||
|
_send_rpc_result(
|
||||||
|
connection,
|
||||||
|
req_id,
|
||||||
|
400,
|
||||||
|
{"error": {"code": "bad_request", "message": "rpc frame needs a path"}},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Gate unauthenticated callers to login/signup only.
|
||||||
|
if connection.user_id is None and path not in _ANONYMOUS_RPC_PATHS:
|
||||||
|
_send_rpc_result(
|
||||||
|
connection,
|
||||||
|
req_id,
|
||||||
|
401,
|
||||||
|
{"error": {"code": "unauthenticated", "message": "نیاز به ورود دارید."}},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
body = message.get("body")
|
||||||
|
headers = {"X-Request-ID": str(req_id), "User-Agent": "realtime-rpc"}
|
||||||
|
if connection.token:
|
||||||
|
headers["Authorization"] = f"Bearer {connection.token}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = _get_rpc_client()
|
||||||
|
if isinstance(body, (dict, list)):
|
||||||
|
response = await client.request(method, path, json=body, headers=headers)
|
||||||
|
elif body is None:
|
||||||
|
response = await client.request(method, path, headers=headers)
|
||||||
|
else:
|
||||||
|
response = await client.request(method, path, content=str(body), headers=headers)
|
||||||
|
except Exception: # noqa: BLE001 - reported to the caller as a 500 envelope
|
||||||
|
logger.exception("realtime: rpc dispatch to internal app failed")
|
||||||
|
_send_rpc_result(
|
||||||
|
connection,
|
||||||
|
req_id,
|
||||||
|
500,
|
||||||
|
{"error": {"code": "internal_error", "message": "خطای داخلی سرور."}},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
status = response.status_code
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except Exception:
|
||||||
|
payload = None
|
||||||
|
_send_rpc_result(connection, req_id, status, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_rpc_result(
|
||||||
|
connection: Connection, req_id: object, status: int, body: object
|
||||||
|
) -> None:
|
||||||
|
_send_frame(
|
||||||
|
connection,
|
||||||
|
{"type": "rpc_result", "id": req_id, "status": status, "body": body},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _subscribe(
|
async def _subscribe(
|
||||||
@@ -157,7 +276,7 @@ async def _subscribe(
|
|||||||
async with SessionFactory() as session:
|
async with SessionFactory() as session:
|
||||||
return await authorize_channel(
|
return await authorize_channel(
|
||||||
session,
|
session,
|
||||||
user_id=conn.user_id,
|
user_id=conn.user_id or "",
|
||||||
is_platform_owner=conn.is_platform_owner,
|
is_platform_owner=conn.is_platform_owner,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
)
|
)
|
||||||
@@ -171,5 +290,11 @@ def _now_iso() -> str:
|
|||||||
return utcnow().isoformat()
|
return utcnow().isoformat()
|
||||||
|
|
||||||
|
|
||||||
async def _send(websocket: WebSocket, payload: dict) -> None:
|
def _send_frame(connection: Connection, payload: dict) -> None:
|
||||||
await websocket.send_text(json.dumps(payload, ensure_ascii=False, default=str))
|
"""Queue a frame on the connection's single-writer outbox.
|
||||||
|
|
||||||
|
Every frame — control, event and rpc_result — goes through the same queue so
|
||||||
|
a slow RPC task can never interleave bytes with the receive loop's own
|
||||||
|
frames and corrupt the stream.
|
||||||
|
"""
|
||||||
|
connection.offer(json.dumps(payload, ensure_ascii=False, default=str))
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Internal, never-socket-bound FastAPI app that holds every HTTP handler.
|
||||||
|
|
||||||
|
The public app (``app.main.create_app``) exposes only ``/health`` and the
|
||||||
|
``/api/ws`` WebSocket. All request/response logic lives here and is reached in
|
||||||
|
exactly one of two ways:
|
||||||
|
|
||||||
|
* from the WS-RPC proxy (``app.interfaces.realtime.router``), which dispatches
|
||||||
|
client RPCs into this app over an in-process ASGI transport, and
|
||||||
|
* from the test-suite's ``AsyncClient``/``TestClient``.
|
||||||
|
|
||||||
|
It is intentionally never bound to a socket, so including the routers here adds no
|
||||||
|
public REST surface — the only public routes are the ones ``create_app``
|
||||||
|
registers. Keeping the handlers in one FastAPI app also means they run through the
|
||||||
|
real middleware, dependencies, validation and error handling, unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.interfaces.http.errors import register_exception_handlers
|
||||||
|
from app.interfaces.http.request_context import RequestContextMiddleware
|
||||||
|
from app.interfaces.http.routers import (
|
||||||
|
admin,
|
||||||
|
agy,
|
||||||
|
auth,
|
||||||
|
billing,
|
||||||
|
health,
|
||||||
|
knowledge,
|
||||||
|
organizations,
|
||||||
|
projects,
|
||||||
|
sandbox,
|
||||||
|
workforce,
|
||||||
|
)
|
||||||
|
from app.interfaces.realtime.router import router as realtime_router
|
||||||
|
from app.main import lifespan # shared lifespan installs the realtime bus
|
||||||
|
|
||||||
|
#: Module-level singleton so the RPC proxy and the tests share one app instance.
|
||||||
|
_rpc_app: FastAPI | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def build_rpc_app() -> FastAPI:
|
||||||
|
"""Construct the full handler app. Called once per process."""
|
||||||
|
application = FastAPI(
|
||||||
|
title=settings.app_name,
|
||||||
|
version="0.1.0",
|
||||||
|
description="AI Workforce Platform — internal RPC dispatch surface.",
|
||||||
|
lifespan=lifespan,
|
||||||
|
# This app is reached in-process, never over the network, so the
|
||||||
|
# interactive docs would only be a liability.
|
||||||
|
docs_url=None,
|
||||||
|
openapi_url=None,
|
||||||
|
)
|
||||||
|
application.add_middleware(RequestContextMiddleware)
|
||||||
|
register_exception_handlers(application)
|
||||||
|
|
||||||
|
# Same mounting as the old public app: health at the root, everything else
|
||||||
|
# under /api, and the live socket alongside the RPC surface.
|
||||||
|
application.include_router(health.router)
|
||||||
|
application.include_router(auth.router, prefix="/api")
|
||||||
|
application.include_router(organizations.router, prefix="/api")
|
||||||
|
application.include_router(projects.router, prefix="/api")
|
||||||
|
application.include_router(workforce.router, prefix="/api")
|
||||||
|
application.include_router(billing.router, prefix="/api")
|
||||||
|
application.include_router(knowledge.router, prefix="/api")
|
||||||
|
application.include_router(sandbox.router, prefix="/api")
|
||||||
|
application.include_router(admin.router, prefix="/api")
|
||||||
|
application.include_router(agy.router, prefix="/api")
|
||||||
|
application.include_router(realtime_router, prefix="/api")
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
def get_rpc_app() -> FastAPI:
|
||||||
|
"""Return the process-wide internal app, building it on first use."""
|
||||||
|
global _rpc_app
|
||||||
|
if _rpc_app is None:
|
||||||
|
_rpc_app = build_rpc_app()
|
||||||
|
return _rpc_app
|
||||||
+20
-25
@@ -15,18 +15,7 @@ from app.core.config import settings
|
|||||||
from app.core.database import Base, engine
|
from app.core.database import Base, engine
|
||||||
from app.interfaces.http.errors import register_exception_handlers
|
from app.interfaces.http.errors import register_exception_handlers
|
||||||
from app.interfaces.http.request_context import RequestContextMiddleware
|
from app.interfaces.http.request_context import RequestContextMiddleware
|
||||||
from app.interfaces.http.routers import (
|
from app.interfaces.http.routers import health
|
||||||
admin,
|
|
||||||
agy,
|
|
||||||
auth,
|
|
||||||
billing,
|
|
||||||
health,
|
|
||||||
knowledge,
|
|
||||||
organizations,
|
|
||||||
projects,
|
|
||||||
sandbox,
|
|
||||||
workforce,
|
|
||||||
)
|
|
||||||
from app.interfaces.realtime.bus import install as install_realtime_bus
|
from app.interfaces.realtime.bus import install as install_realtime_bus
|
||||||
from app.interfaces.realtime.bus import start_relay, stop_relay
|
from app.interfaces.realtime.bus import start_relay, stop_relay
|
||||||
from app.interfaces.realtime.router import realtime_manager
|
from app.interfaces.realtime.router import realtime_manager
|
||||||
@@ -99,13 +88,22 @@ async def _record_startup_failure(exc: BaseException) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
"""Build the *public* app.
|
||||||
|
|
||||||
|
This is the only app uvicorn binds. It deliberately exposes no REST API: the
|
||||||
|
application surface is reachable only over the ``/api/ws`` WebSocket (live
|
||||||
|
feeds + RPC). ``/health`` stays as an HTTP liveness probe because deploy
|
||||||
|
orchestration and the browser's status check both need a plain GET, and a
|
||||||
|
probe is not an application endpoint.
|
||||||
|
"""
|
||||||
application = FastAPI(
|
application = FastAPI(
|
||||||
title=settings.app_name,
|
title=settings.app_name,
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
description="AI Workforce Platform — standalone multi-tenant SaaS.",
|
description="AI Workforce Platform — standalone multi-tenant SaaS.",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
docs_url="/docs",
|
# No REST API on the public surface, so the interactive docs are off.
|
||||||
openapi_url="/openapi.json",
|
docs_url=None,
|
||||||
|
openapi_url=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Starlette applies middleware outermost-first in reverse registration
|
# Starlette applies middleware outermost-first in reverse registration
|
||||||
@@ -128,17 +126,10 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
register_exception_handlers(application)
|
register_exception_handlers(application)
|
||||||
|
|
||||||
|
# The only public routes: a liveness probe and the single WebSocket that
|
||||||
|
# carries every application call (live events + RPC). All HTTP handlers live
|
||||||
|
# in the internal app reached through that socket.
|
||||||
application.include_router(health.router)
|
application.include_router(health.router)
|
||||||
application.include_router(auth.router, prefix="/api")
|
|
||||||
application.include_router(organizations.router, prefix="/api")
|
|
||||||
application.include_router(projects.router, prefix="/api")
|
|
||||||
application.include_router(workforce.router, prefix="/api")
|
|
||||||
application.include_router(billing.router, prefix="/api")
|
|
||||||
application.include_router(knowledge.router, prefix="/api")
|
|
||||||
application.include_router(sandbox.router, prefix="/api")
|
|
||||||
application.include_router(admin.router, prefix="/api")
|
|
||||||
application.include_router(agy.router, prefix="/api")
|
|
||||||
# The live feed: one WebSocket per browser, every push on it.
|
|
||||||
application.include_router(realtime_router, prefix="/api")
|
application.include_router(realtime_router, prefix="/api")
|
||||||
|
|
||||||
@application.get("/", tags=["meta"])
|
@application.get("/", tags=["meta"])
|
||||||
@@ -146,8 +137,12 @@ def create_app() -> FastAPI:
|
|||||||
return {
|
return {
|
||||||
"name": settings.app_name,
|
"name": settings.app_name,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"docs": "/docs",
|
|
||||||
"health": "/health",
|
"health": "/health",
|
||||||
|
"websocket": "/api/ws",
|
||||||
|
"note": (
|
||||||
|
"All application calls travel over the WebSocket at /api/ws "
|
||||||
|
"(live feeds + RPC). There is no REST API on this server."
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
return application
|
return application
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ from app.core.config import settings
|
|||||||
from app.core.database import Base, get_session
|
from app.core.database import Base, get_session
|
||||||
from app.domain.base import new_id
|
from app.domain.base import new_id
|
||||||
from app.infrastructure.db.models import UserRow
|
from app.infrastructure.db.models import UserRow
|
||||||
from app.main import app
|
# The internal "full" app holds every HTTP handler; the public app (app.main.app)
|
||||||
|
# now serves only /health + /api/ws, so REST tests must target the full app.
|
||||||
|
from app.interfaces.realtime.rpc_app import get_rpc_app
|
||||||
|
|
||||||
|
app = get_rpc_app()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ from app.core.errors import ProviderUnavailableError
|
|||||||
from app.infrastructure.db.models import ErrorEventRow
|
from app.infrastructure.db.models import ErrorEventRow
|
||||||
from app.interfaces.http.deps import OrgContext, get_org_context
|
from app.interfaces.http.deps import OrgContext, get_org_context
|
||||||
from app.interfaces.http.request_context import REQUEST_ID_HEADER
|
from app.interfaces.http.request_context import REQUEST_ID_HEADER
|
||||||
from app.main import app
|
# The internal "full" app holds every HTTP handler; the public app serves only
|
||||||
|
# /health + /api/ws, so this module's REST exercises must target the full app.
|
||||||
|
from app.interfaces.realtime.rpc_app import get_rpc_app
|
||||||
|
|
||||||
|
app = get_rpc_app()
|
||||||
from tests.conftest import auth_header, signup
|
from tests.conftest import auth_header, signup
|
||||||
|
|
||||||
#: The password in this DSN must never reach the database.
|
#: The password in this DSN must never reach the database.
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ from app.infrastructure.db.models import UserRow
|
|||||||
from app.interfaces.realtime.authz import authorize_channel
|
from app.interfaces.realtime.authz import authorize_channel
|
||||||
from app.interfaces.realtime.bus import get_bus
|
from app.interfaces.realtime.bus import get_bus
|
||||||
from app.interfaces.realtime.manager import Connection, ConnectionManager
|
from app.interfaces.realtime.manager import Connection, ConnectionManager
|
||||||
from app.main import app
|
# The internal "full" app holds every HTTP handler; the public app serves only
|
||||||
|
# /health + /api/ws. The socket and the RPC surface both live on the full app.
|
||||||
|
from app.interfaces.realtime.rpc_app import get_rpc_app
|
||||||
|
|
||||||
|
app = get_rpc_app()
|
||||||
|
|
||||||
API_ROOT = Path(__file__).resolve().parents[1]
|
API_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
@@ -331,10 +335,46 @@ def _frames_until(websocket, wanted_type: str, *, max_frames: int = 20) -> dict:
|
|||||||
raise AssertionError(f"no {wanted_type} event arrived")
|
raise AssertionError(f"no {wanted_type} event arrived")
|
||||||
|
|
||||||
|
|
||||||
def test_socket_rejects_a_missing_token(ws_client):
|
def test_socket_allows_anonymous_then_restricts_actions(ws_client):
|
||||||
with pytest.raises(Exception), ws_client.websocket_connect("/api/ws"): # noqa: B017, SIM117
|
"""No token opens an anonymous socket, but only login/signup may be called.
|
||||||
# No token: the endpoint closes with 1008 before accepting.
|
|
||||||
pass
|
A visitor who has not logged in can reach ``/api/auth/login`` and
|
||||||
|
``/api/auth/signup`` over RPC; every other RPC and any subscribe is refused.
|
||||||
|
"""
|
||||||
|
with ws_client.websocket_connect("/api/ws") as socket:
|
||||||
|
hello = socket.receive_json()
|
||||||
|
assert hello["type"] == "hello"
|
||||||
|
assert hello["user_id"] is None
|
||||||
|
|
||||||
|
# A protected RPC is refused for an anonymous connection.
|
||||||
|
socket.send_json({"action": "rpc", "id": "1", "method": "GET", "path": "/api/organizations"})
|
||||||
|
result = socket.receive_json()
|
||||||
|
assert result["type"] == "rpc_result"
|
||||||
|
assert result["status"] == 401
|
||||||
|
|
||||||
|
# Subscribing is refused until the caller logs in.
|
||||||
|
socket.send_json({"action": "subscribe", "channels": ["org:x"]})
|
||||||
|
refused = socket.receive_json()
|
||||||
|
assert refused["type"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_may_call_login_over_rpc(ws_client):
|
||||||
|
with ws_client.websocket_connect("/api/ws") as socket:
|
||||||
|
socket.receive_json() # hello
|
||||||
|
socket.send_json(
|
||||||
|
{
|
||||||
|
"action": "rpc",
|
||||||
|
"id": "login-1",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/auth/login",
|
||||||
|
"body": {"email": "anon@example.com", "password": "password123"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = socket.receive_json()
|
||||||
|
assert result["type"] == "rpc_result"
|
||||||
|
# No such user -> the handler's own auth dependency returns 401, proving
|
||||||
|
# the RPC reached the real login route through the internal app.
|
||||||
|
assert result["status"] == 401
|
||||||
|
|
||||||
|
|
||||||
def test_task_creation_is_pushed_live(ws_client):
|
def test_task_creation_is_pushed_live(ws_client):
|
||||||
|
|||||||
+24
-7
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ApiErrorBody } from "@agy/contracts";
|
import type { ApiErrorBody } from "@agy/contracts";
|
||||||
|
import { getRealtimeClient } from "@/lib/ws";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the API base URL.
|
* Resolves the API base URL.
|
||||||
@@ -89,16 +90,34 @@ export interface RequestOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
const { method = "GET", body, token, signal } = options;
|
const { method = "GET", body, signal } = options;
|
||||||
|
|
||||||
|
// Liveness and readiness probes stay on plain HTTP: they answer during a DB
|
||||||
|
// outage and are used by deploy orchestration (and the browser status check),
|
||||||
|
// so they must not depend on the authenticated socket. Every application call
|
||||||
|
// travels over the WebSocket as an RPC instead (see lib/ws.ts) — one
|
||||||
|
// connection for both live feeds and request/response.
|
||||||
|
if (path === "/health" || path === "/health/ready") {
|
||||||
|
return fetchHealth<T>(path, method, body, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getRealtimeClient();
|
||||||
|
return client.rpc<T>(method, path, body === undefined ? null : body, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain-HTTP implementation kept only for the two health probes. */
|
||||||
|
async function fetchHealth<T>(
|
||||||
|
path: string,
|
||||||
|
method: string,
|
||||||
|
body: unknown,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<T> {
|
||||||
const headers: Record<string, string> = { Accept: "application/json" };
|
const headers: Record<string, string> = { Accept: "application/json" };
|
||||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||||
const authToken = token ?? getStoredToken();
|
|
||||||
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
|
||||||
|
|
||||||
const baseUrl = getApiBaseUrl();
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(`${baseUrl}${path}`, {
|
response = await fetch(`${getApiBaseUrl()}${path}`, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
@@ -118,8 +137,6 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
|||||||
const envelope = payload as ApiErrorBody | null;
|
const envelope = payload as ApiErrorBody | null;
|
||||||
const code = envelope?.error?.code ?? "http_error";
|
const code = envelope?.error?.code ?? "http_error";
|
||||||
const message = envelope?.error?.message ?? `خطای سرور (${response.status})`;
|
const message = envelope?.error?.message ?? `خطای سرور (${response.status})`;
|
||||||
// Fall back to the header: a failure raised outside the error handlers
|
|
||||||
// (a CORS rejection, a proxy error) still carries the id.
|
|
||||||
const requestId =
|
const requestId =
|
||||||
envelope?.error?.request_id ?? response.headers.get("X-Request-ID") ?? undefined;
|
envelope?.error?.request_id ?? response.headers.get("X-Request-ID") ?? undefined;
|
||||||
throw new ApiError(response.status, code, message, envelope?.error?.details, requestId);
|
throw new ApiError(response.status, code, message, envelope?.error?.details, requestId);
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import type { WsEvent, WsEventType } from "@agy/contracts";
|
import type { WsEvent, WsEventType } from "@agy/contracts";
|
||||||
import { getRealtimeClient, type RealtimeStatus } from "@/lib/ws";
|
import { getRealtimeClient, type RealtimeStatus } from "@/lib/ws";
|
||||||
import { getStoredToken } from "@/lib/api";
|
|
||||||
|
|
||||||
interface RealtimeContextValue {
|
interface RealtimeContextValue {
|
||||||
status: RealtimeStatus;
|
status: RealtimeStatus;
|
||||||
@@ -33,12 +32,12 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const client = React.useMemo(() => getRealtimeClient(), []);
|
const client = React.useMemo(() => getRealtimeClient(), []);
|
||||||
const [status] = useRealtimeState(client);
|
const [status] = useRealtimeState(client);
|
||||||
|
|
||||||
// The connection follows the token: present -> live, gone -> closed.
|
// The connection follows the session: one socket for the app, opened even
|
||||||
|
// before login (anonymous) so the first ``login``/``signup`` RPC can ride it.
|
||||||
|
// ``client.connect()`` is a no-op when the socket already matches the current
|
||||||
|
// token, and reconnects (anonymous <-> authenticated) when the token changes.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const connect = () => {
|
const connect = () => client.connect();
|
||||||
if (getStoredToken()) client.connect();
|
|
||||||
else client.disconnect();
|
|
||||||
};
|
|
||||||
connect();
|
connect();
|
||||||
// ``storage`` fires when another tab logs in or out; polling covers the
|
// ``storage`` fires when another tab logs in or out; polling covers the
|
||||||
// same-tab case where the token is written and read within one tick.
|
// same-tab case where the token is written and read within one tick.
|
||||||
|
|||||||
+208
-41
@@ -9,15 +9,21 @@
|
|||||||
* * **The token is a function, not a value.** ``getToken`` is read at connect
|
* * **The token is a function, not a value.** ``getToken`` is read at connect
|
||||||
* time, so a login/logout cycle in another tab reconnects correctly instead
|
* time, so a login/logout cycle in another tab reconnects correctly instead
|
||||||
* of pushing on a dead credential.
|
* of pushing on a dead credential.
|
||||||
* * **Reconnect with backoff, capped.** Networks flap; a reconnect storm is
|
* * **Anonymous before login.** The socket opens without a token so a visitor
|
||||||
* how a flapping network becomes an outage. The cap is deliberately low
|
* can call ``login``/``signup`` over RPC. Once a token exists the socket is
|
||||||
* enough that "live" feels live again within seconds.
|
* re-established (still the one socket) carrying the bearer token, and every
|
||||||
|
* later RPC rides that authenticated connection.
|
||||||
|
* * **Reconnect with backoff, capped.** Networks flap; a reconnect storm is how
|
||||||
|
* a flapping network becomes an outage. The cap is deliberately low enough
|
||||||
|
* that "live" feels live again within seconds.
|
||||||
* * **Heartbeat.** A silent middlebox will happily hold a dead socket open
|
* * **Heartbeat.** A silent middlebox will happily hold a dead socket open
|
||||||
* forever. A ping every ``HEARTBEAT_INTERVAL_MS`` plus a pong timeout moves
|
* forever. A ping every ``HEARTBEAT_INTERVAL_MS`` plus a pong timeout moves
|
||||||
* that failure from "the page quietly stops updating" to "the socket
|
* that failure from "the page quietly stops updating" to "the socket
|
||||||
* reconnects and updates resume".
|
* reconnects and updates resume".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ApiErrorBody } from "@agy/contracts";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
import type { WsClientFrame, WsEvent, WsServerFrame } from "@agy/contracts";
|
import type { WsClientFrame, WsEvent, WsServerFrame } from "@agy/contracts";
|
||||||
import { getStoredToken } from "@/lib/api";
|
import { getStoredToken } from "@/lib/api";
|
||||||
|
|
||||||
@@ -39,10 +45,19 @@ const MAX_BACKOFF_MS = 8_000;
|
|||||||
const HEARTBEAT_INTERVAL_MS = 25_000;
|
const HEARTBEAT_INTERVAL_MS = 25_000;
|
||||||
/** If no pong arrives within this window the socket is considered dead. */
|
/** If no pong arrives within this window the socket is considered dead. */
|
||||||
const PONG_TIMEOUT_MS = 10_000;
|
const PONG_TIMEOUT_MS = 10_000;
|
||||||
|
/** Upper bound on a single RPC before it is failed locally. Long enough for a
|
||||||
|
* slow DB call, short enough that the UI does not hang forever. */
|
||||||
|
const RPC_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
type EventListener = (event: WsEvent) => void;
|
type EventListener = (event: WsEvent) => void;
|
||||||
type StatusListener = (status: RealtimeStatus) => void;
|
type StatusListener = (status: RealtimeStatus) => void;
|
||||||
|
|
||||||
|
interface PendingRpc {
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (reason: ApiError) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
export class RealtimeClient {
|
export class RealtimeClient {
|
||||||
private socket: WebSocket | null = null;
|
private socket: WebSocket | null = null;
|
||||||
private status: RealtimeStatus = "idle";
|
private status: RealtimeStatus = "idle";
|
||||||
@@ -54,55 +69,31 @@ export class RealtimeClient {
|
|||||||
private reconnectAttempts = 0;
|
private reconnectAttempts = 0;
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private closedByUs = false;
|
private closedByUs = false;
|
||||||
|
/** The token the current socket authenticated with; null for anonymous. */
|
||||||
|
private connectedToken: string | null = null;
|
||||||
|
/** Resolved when the socket transitions to OPEN for the current attempt. */
|
||||||
|
private openPromise: Promise<void> | null = null;
|
||||||
|
/** In-flight RPCs keyed by their client-generated id. */
|
||||||
|
private readonly pendingRpc = new Map<string, PendingRpc>();
|
||||||
|
private rpcSeq = 0;
|
||||||
|
|
||||||
constructor(private readonly getToken: () => string | null = getStoredToken) {}
|
constructor(private readonly getToken: () => string | null = getStoredToken) {}
|
||||||
|
|
||||||
// -- lifecycle -----------------------------------------------------------
|
// -- lifecycle -----------------------------------------------------------
|
||||||
|
|
||||||
|
/** Open (or reopen) the socket for the token the getter currently returns. */
|
||||||
connect(): void {
|
connect(): void {
|
||||||
const token = this.getToken();
|
const token = this.getToken();
|
||||||
if (!token) {
|
// Already open for this exact token: nothing to do.
|
||||||
// No session yet: nothing to push. The provider reconnects on login.
|
|
||||||
this.setStatus("idle");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
this.socket &&
|
this.socket &&
|
||||||
(this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)
|
(this.socket.readyState === WebSocket.OPEN ||
|
||||||
|
this.socket.readyState === WebSocket.CONNECTING) &&
|
||||||
|
this.connectedToken === token
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.openWith(token);
|
||||||
this.closedByUs = false;
|
|
||||||
this.setStatus(this.reconnectAttempts > 0 ? "reconnecting" : "connecting");
|
|
||||||
|
|
||||||
const socket = new WebSocket(`${websocketUrl()}?token=${encodeURIComponent(token)}`);
|
|
||||||
this.socket = socket;
|
|
||||||
|
|
||||||
socket.onopen = () => {
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
this.setStatus("live");
|
|
||||||
this.startHeartbeat();
|
|
||||||
// Re-subscribe: a new socket knows nothing about what this client cared
|
|
||||||
// about, and pages do not (and should not) re-run their effects to fix it.
|
|
||||||
if (this.subscriptions.size > 0) {
|
|
||||||
this.send({ action: "subscribe", channels: [...this.subscriptions] });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onmessage = (message) => this.handleFrame(message.data);
|
|
||||||
|
|
||||||
socket.onclose = () => {
|
|
||||||
this.stopHeartbeat();
|
|
||||||
this.socket = null;
|
|
||||||
if (this.closedByUs) return;
|
|
||||||
this.scheduleReconnect();
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onerror = () => {
|
|
||||||
// A close always follows an error; the close handler does the work.
|
|
||||||
// This only stops an unhandled rejection in stricter runtimes.
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
@@ -112,14 +103,72 @@ export class RealtimeClient {
|
|||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
}
|
}
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
|
this.rejectPending(new ApiError(0, "connection_closed", "اتصال قطع شد."));
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
|
try {
|
||||||
this.socket.close();
|
this.socket.close();
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
}
|
}
|
||||||
|
this.connectedToken = null;
|
||||||
this.setStatus("idle");
|
this.setStatus("idle");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private openWith(token: string | null): void {
|
||||||
|
this.closedByUs = false;
|
||||||
|
this.setStatus(this.reconnectAttempts > 0 ? "reconnecting" : "connecting");
|
||||||
|
|
||||||
|
// Replace any in-flight socket (e.g. a token change mid-connect).
|
||||||
|
if (this.socket) {
|
||||||
|
try {
|
||||||
|
this.socket.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.socket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = token
|
||||||
|
? `${websocketUrl()}?token=${encodeURIComponent(token)}`
|
||||||
|
: websocketUrl();
|
||||||
|
const socket = new WebSocket(url);
|
||||||
|
this.socket = socket;
|
||||||
|
|
||||||
|
this.openPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
socket.onopen = () => {
|
||||||
|
settled = true;
|
||||||
|
this.connectedToken = token;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.setStatus("live");
|
||||||
|
this.startHeartbeat();
|
||||||
|
// Re-play subscriptions: a new socket knows nothing about what this
|
||||||
|
// client cared about, and pages do not (and should not) re-run their
|
||||||
|
// effects to fix it.
|
||||||
|
if (this.subscriptions.size > 0) {
|
||||||
|
this.send({ action: "subscribe", channels: [...this.subscriptions] });
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
socket.onmessage = (message) => this.handleFrame(message.data);
|
||||||
|
socket.onclose = () => {
|
||||||
|
if (!settled) {
|
||||||
|
reject(
|
||||||
|
new ApiError(0, "connection_closed", "اتصال پیش از برقراری قطع شد."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.onSocketClosed();
|
||||||
|
};
|
||||||
|
socket.onerror = () => {
|
||||||
|
// A close always follows an error; the close handler does the work.
|
||||||
|
// This only stops an unhandled rejection in stricter runtimes.
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private scheduleReconnect(): void {
|
private scheduleReconnect(): void {
|
||||||
this.setStatus("reconnecting");
|
this.setStatus("reconnecting");
|
||||||
const delay = Math.min(
|
const delay = Math.min(
|
||||||
@@ -130,6 +179,77 @@ export class RealtimeClient {
|
|||||||
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- RPC -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call an application endpoint over the socket.
|
||||||
|
*
|
||||||
|
* Returns the parsed response body on 2xx, and rejects with an ``ApiError``
|
||||||
|
* (status + server error envelope) on any non-2xx — identical to the old
|
||||||
|
* ``fetch``-based client, so every ``api.*`` method is unchanged.
|
||||||
|
*/
|
||||||
|
rpc<T>(
|
||||||
|
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE",
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
options: { signal?: AbortSignal } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
return this.whenOpen().then(
|
||||||
|
() =>
|
||||||
|
new Promise<T>((resolve, reject) => {
|
||||||
|
const id = `r${++this.rpcSeq}-${Date.now().toString(36)}`;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pendingRpc.delete(id);
|
||||||
|
reject(
|
||||||
|
new ApiError(
|
||||||
|
0,
|
||||||
|
"timeout",
|
||||||
|
"درخواست از طریق وبسوکت زمانبر شد.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, RPC_TIMEOUT_MS);
|
||||||
|
this.pendingRpc.set(id, {
|
||||||
|
resolve: resolve as (value: unknown) => void,
|
||||||
|
reject,
|
||||||
|
timer,
|
||||||
|
});
|
||||||
|
this.send({ action: "rpc", id, method, path, body });
|
||||||
|
const signal = options.signal;
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
const pending = this.pendingRpc.get(id);
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
this.pendingRpc.delete(id);
|
||||||
|
pending.reject(
|
||||||
|
new ApiError(0, "aborted", "درخواست لغو شد."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve once the socket is OPEN for the current token (opening if needed). */
|
||||||
|
private whenOpen(): Promise<void> {
|
||||||
|
const token = this.getToken();
|
||||||
|
if (
|
||||||
|
this.socket &&
|
||||||
|
(this.socket.readyState === WebSocket.OPEN ||
|
||||||
|
this.socket.readyState === WebSocket.CONNECTING) &&
|
||||||
|
this.connectedToken === token
|
||||||
|
) {
|
||||||
|
return this.openPromise ?? Promise.resolve();
|
||||||
|
}
|
||||||
|
this.openWith(token);
|
||||||
|
return this.openPromise ?? Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
// -- subscriptions ---------------------------------------------------------
|
// -- subscriptions ---------------------------------------------------------
|
||||||
|
|
||||||
subscribe(channels: string[]): void {
|
subscribe(channels: string[]): void {
|
||||||
@@ -173,7 +293,7 @@ export class RealtimeClient {
|
|||||||
this.socket.send(JSON.stringify(frame));
|
this.socket.send(JSON.stringify(frame));
|
||||||
}
|
}
|
||||||
// A frame sent before the socket is open is not an error: on open, the
|
// A frame sent before the socket is open is not an error: on open, the
|
||||||
// full subscription set is replayed anyway.
|
// full subscription set is replayed anyway, and RPCs await whenOpen().
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleFrame(raw: unknown): void {
|
private handleFrame(raw: unknown): void {
|
||||||
@@ -202,11 +322,38 @@ export class RealtimeClient {
|
|||||||
case "error":
|
case "error":
|
||||||
console.warn("realtime: server error frame", frame.message);
|
console.warn("realtime: server error frame", frame.message);
|
||||||
break;
|
break;
|
||||||
|
case "rpc_result":
|
||||||
|
this.resolveRpc(frame);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveRpc(frame: Extract<WsServerFrame, { type: "rpc_result" }>): void {
|
||||||
|
const pending = this.pendingRpc.get(frame.id);
|
||||||
|
if (!pending) return;
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
this.pendingRpc.delete(frame.id);
|
||||||
|
|
||||||
|
if (frame.status >= 200 && frame.status < 300) {
|
||||||
|
pending.resolve(frame.body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const envelope = frame.body as ApiErrorBody | null;
|
||||||
|
const code = envelope?.error?.code ?? "http_error";
|
||||||
|
const message = envelope?.error?.message ?? `خطای سرور (${frame.status})`;
|
||||||
|
pending.reject(
|
||||||
|
new ApiError(
|
||||||
|
frame.status,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
envelope?.error?.details,
|
||||||
|
envelope?.error?.request_id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private emitEvent(event: WsEvent): void {
|
private emitEvent(event: WsEvent): void {
|
||||||
for (const listener of [...this.listeners]) {
|
for (const listener of [...this.listeners]) {
|
||||||
try {
|
try {
|
||||||
@@ -218,6 +365,18 @@ export class RealtimeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onSocketClosed(): void {
|
||||||
|
this.stopHeartbeat();
|
||||||
|
this.socket = null;
|
||||||
|
this.connectedToken = null;
|
||||||
|
this.rejectPending(new ApiError(0, "connection_closed", "اتصال قطع شد."));
|
||||||
|
if (this.closedByUs) {
|
||||||
|
this.setStatus("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
private setStatus(status: RealtimeStatus): void {
|
private setStatus(status: RealtimeStatus): void {
|
||||||
if (this.status === status) return;
|
if (this.status === status) return;
|
||||||
this.status = status;
|
this.status = status;
|
||||||
@@ -230,6 +389,14 @@ export class RealtimeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private rejectPending(error: ApiError): void {
|
||||||
|
for (const pending of this.pendingRpc.values()) {
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
pending.reject(error);
|
||||||
|
}
|
||||||
|
this.pendingRpc.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// -- heartbeat -----------------------------------------------------------------
|
// -- heartbeat -----------------------------------------------------------------
|
||||||
|
|
||||||
private startHeartbeat(): void {
|
private startHeartbeat(): void {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { ApiError, api, apiRequest, getStoredToken, setStoredToken } from "@/lib/api";
|
import { ApiError, api, apiRequest, getStoredToken, setStoredToken } from "@/lib/api";
|
||||||
|
import { getRealtimeClient } from "@/lib/ws";
|
||||||
|
|
||||||
describe("token storage", () => {
|
describe("token storage", () => {
|
||||||
it("round-trips a token through localStorage", () => {
|
it("round-trips a token through localStorage", () => {
|
||||||
@@ -25,22 +26,14 @@ describe("ApiError", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("error correlation", () => {
|
describe("error correlation over rpc", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads the request id out of the error envelope", async () => {
|
it("reads the request id out of the error envelope", async () => {
|
||||||
vi.stubGlobal(
|
vi.spyOn(getRealtimeClient(), "rpc").mockRejectedValue(
|
||||||
"fetch",
|
new ApiError(500, "internal_error", "خطا", undefined, "req-from-body"),
|
||||||
vi.fn(async () =>
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: { code: "internal_error", message: "خطا", request_id: "req-from-body" },
|
|
||||||
}),
|
|
||||||
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(apiRequest("/api/whatever")).rejects.toMatchObject({
|
await expect(apiRequest("/api/whatever")).rejects.toMatchObject({
|
||||||
@@ -49,127 +42,95 @@ describe("error correlation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the X-Request-ID header", async () => {
|
it("rejects an unauthenticated call with the server's 401", async () => {
|
||||||
// A failure raised outside the error handlers — a CORS rejection or a proxy
|
vi.spyOn(getRealtimeClient(), "rpc").mockRejectedValue(
|
||||||
// error — still carries the header, and the user should still get an id.
|
new ApiError(401, "unauthenticated", "نیاز به ورود دارید."),
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn(async () =>
|
|
||||||
new Response(JSON.stringify({ detail: "Disallowed CORS origin" }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { "X-Request-ID": "req-from-header" },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(apiRequest("/api/whatever")).rejects.toMatchObject({
|
await expect(apiRequest("/api/organizations")).rejects.toMatchObject({
|
||||||
requestId: "req-from-header",
|
status: 401,
|
||||||
|
code: "unauthenticated",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("error console client", () => {
|
describe("error console client", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits unset filters from the query string", async () => {
|
it("omits unset filters from the query string", async () => {
|
||||||
const calls: string[] = [];
|
const rpc = vi
|
||||||
vi.stubGlobal(
|
.spyOn(getRealtimeClient(), "rpc")
|
||||||
"fetch",
|
.mockResolvedValue({ items: [], total: 0, limit: 50, offset: 0 } as unknown as never);
|
||||||
vi.fn(async (url: string) => {
|
|
||||||
calls.push(url);
|
|
||||||
return new Response(JSON.stringify({ items: [], total: 0, limit: 50, offset: 0 }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await api.adminErrors({ status: "open", severity: "critical" });
|
await api.adminErrors({ status: "open", severity: "critical" });
|
||||||
expect(calls[0]).toContain("status=open");
|
expect(rpc).toHaveBeenCalledTimes(1);
|
||||||
expect(calls[0]).toContain("severity=critical");
|
const [method, path] = rpc.mock.calls[0];
|
||||||
|
expect(method).toBe("GET");
|
||||||
|
expect(path).toContain("status=open");
|
||||||
|
expect(path).toContain("severity=critical");
|
||||||
// An empty string means "all", and must not be sent as a filter value.
|
// An empty string means "all", and must not be sent as a filter value.
|
||||||
expect(calls[0]).not.toContain("source=");
|
expect(path).not.toContain("source=");
|
||||||
expect(calls[0]).not.toContain("search=");
|
expect(path).not.toContain("search=");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends no query string at all when there are no filters", async () => {
|
it("sends no query string at all when there are no filters", async () => {
|
||||||
const calls: string[] = [];
|
const rpc = vi
|
||||||
vi.stubGlobal(
|
.spyOn(getRealtimeClient(), "rpc")
|
||||||
"fetch",
|
.mockResolvedValue([] as unknown as never);
|
||||||
vi.fn(async (url: string) => {
|
|
||||||
calls.push(url);
|
|
||||||
return new Response(JSON.stringify([]), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await api.adminErrorGroups();
|
await api.adminErrorGroups();
|
||||||
expect(calls[0]).not.toContain("?");
|
const [, path] = rpc.mock.calls[0];
|
||||||
|
expect(path).not.toContain("?");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("agy account client", () => {
|
describe("agy account client", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
function stubJson(body: unknown) {
|
function stubRpc(body: unknown) {
|
||||||
const calls: { method: string; url: string; body: string }[] = [];
|
return vi
|
||||||
vi.stubGlobal(
|
.spyOn(getRealtimeClient(), "rpc")
|
||||||
"fetch",
|
.mockResolvedValue(body as unknown as never);
|
||||||
vi.fn(async (url: string, init?: RequestInit) => {
|
|
||||||
calls.push({
|
|
||||||
method: init?.method ?? "GET",
|
|
||||||
url,
|
|
||||||
body: String(init?.body ?? ""),
|
|
||||||
});
|
|
||||||
return new Response(JSON.stringify(body), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return calls;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
it("posts to the login start endpoint", async () => {
|
it("posts to the login start endpoint", async () => {
|
||||||
const calls = stubJson({ login_id: "l1", auth_url: "https://accounts.google.com/..." });
|
const rpc = stubRpc({ login_id: "l1", auth_url: "https://accounts.google.com/..." });
|
||||||
await api.agyStartLogin();
|
await api.agyStartLogin();
|
||||||
expect(calls[0].method).toBe("POST");
|
const [method, path] = rpc.mock.calls[0];
|
||||||
expect(calls[0].url).toContain("/api/admin/agy/accounts/login/start");
|
expect(method).toBe("POST");
|
||||||
|
expect(path).toContain("/api/admin/agy/accounts/login/start");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends the pasted callback URL back verbatim", async () => {
|
it("sends the pasted callback URL back verbatim", async () => {
|
||||||
const calls = stubJson({ id: "a1", email: "x@gmail.com" });
|
const rpc = stubRpc({ id: "a1", email: "x@gmail.com" });
|
||||||
await api.agyCompleteLogin({
|
await api.agyCompleteLogin({
|
||||||
login_id: "l1",
|
login_id: "l1",
|
||||||
callback_url: "http://localhost:51121/oauth-callback?code=c&state=s",
|
callback_url: "http://localhost:51121/oauth-callback?code=c&state=s",
|
||||||
});
|
});
|
||||||
expect(calls[0].url).toContain("/api/admin/agy/accounts/login/complete");
|
const [method, path, sentBody] = rpc.mock.calls[0];
|
||||||
expect(calls[0].body).toContain("callback_url");
|
expect(path).toContain("/api/admin/agy/accounts/login/complete");
|
||||||
|
expect(JSON.stringify(sentBody)).toContain("callback_url");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes activate/deactivate to the right verb", async () => {
|
it("routes activate/deactivate to the right verb", async () => {
|
||||||
const calls = stubJson({ id: "a1" });
|
const rpc = stubRpc({ id: "a1" });
|
||||||
await api.agySetAccountActive("a1", false);
|
await api.agySetAccountActive("a1", false);
|
||||||
await api.agySetAccountActive("a1", true);
|
await api.agySetAccountActive("a1", true);
|
||||||
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1/deactivate");
|
expect(rpc.mock.calls[0][1]).toContain("/api/admin/agy/accounts/a1/deactivate");
|
||||||
expect(calls[1].url).toContain("/api/admin/agy/accounts/a1/activate");
|
expect(rpc.mock.calls[1][1]).toContain("/api/admin/agy/accounts/a1/activate");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deletes with the DELETE verb", async () => {
|
it("deletes with the DELETE verb", async () => {
|
||||||
const calls = stubJson(null);
|
const rpc = stubRpc(null);
|
||||||
await api.agyDeleteAccount("a1");
|
await api.agyDeleteAccount("a1");
|
||||||
expect(calls[0].method).toBe("DELETE");
|
expect(rpc.mock.calls[0][0]).toBe("DELETE");
|
||||||
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1");
|
expect(rpc.mock.calls[0][1]).toContain("/api/admin/agy/accounts/a1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches the live model catalog for one account", async () => {
|
it("fetches the live model catalog for one account", async () => {
|
||||||
const calls = stubJson([
|
const rpc = stubRpc([
|
||||||
{
|
{
|
||||||
id: "gemini-3-flash",
|
id: "gemini-3-flash",
|
||||||
display_name: "Gemini 3 Flash",
|
display_name: "Gemini 3 Flash",
|
||||||
@@ -184,21 +145,23 @@ describe("agy account client", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const models = await api.agyAccountModels("a1");
|
const models = await api.agyAccountModels("a1");
|
||||||
expect(calls[0].method).toBe("GET");
|
const [method, path] = rpc.mock.calls[0];
|
||||||
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1/models");
|
expect(method).toBe("GET");
|
||||||
|
expect(path).toContain("/api/admin/agy/accounts/a1/models");
|
||||||
expect(models.map((m) => m.id)).toContain("gemini-3-pro-high");
|
expect(models.map((m) => m.id)).toContain("gemini-3-pro-high");
|
||||||
expect(models[0].supported_efforts).toEqual(["low", "medium", "high"]);
|
expect(models[0].supported_efforts).toEqual(["low", "medium", "high"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a model from the cascade — no manual names", async () => {
|
it("creates a model from the cascade — no manual names", async () => {
|
||||||
const calls = stubJson({ id: "m1", internal_name: "gemini-3-pro-high" });
|
const rpc = stubRpc({ id: "m1", internal_name: "gemini-3-pro-high" });
|
||||||
await api.adminCreateModel({
|
await api.adminCreateModel({
|
||||||
internal_name: "gemini-3-pro-high",
|
internal_name: "gemini-3-pro-high",
|
||||||
capabilities: ["text", "reasoning"],
|
capabilities: ["text", "reasoning"],
|
||||||
reasoning_effort: "high",
|
reasoning_effort: "high",
|
||||||
});
|
});
|
||||||
expect(calls[0].url).toContain("/api/admin/models");
|
const [method, path, sentBody] = rpc.mock.calls[0];
|
||||||
expect(calls[0].body).toContain("gemini-3-pro-high");
|
expect(path).toContain("/api/admin/models");
|
||||||
expect(calls[0].body).toContain("reasoning_effort");
|
expect(JSON.stringify(sentBody)).toContain("gemini-3-pro-high");
|
||||||
|
expect(JSON.stringify(sentBody)).toContain("reasoning_effort");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -711,18 +711,31 @@ export interface WsEvent<T = Record<string, unknown>> {
|
|||||||
|
|
||||||
/** Every frame the server sends. Control frames and events share ``type``. */
|
/** Every frame the server sends. Control frames and events share ``type``. */
|
||||||
export type WsServerFrame =
|
export type WsServerFrame =
|
||||||
| { type: "hello"; user_id: string; server_time: string }
|
| { type: "hello"; user_id: string | null; server_time: string }
|
||||||
| { type: "subscribed"; accepted: string[]; rejected: string[] }
|
| { type: "subscribed"; accepted: string[]; rejected: string[] }
|
||||||
| { type: "unsubscribed"; channels: string[] }
|
| { type: "unsubscribed"; channels: string[] }
|
||||||
| { type: "pong" }
|
| { type: "pong" }
|
||||||
| { type: "error"; message: string }
|
| { type: "error"; message: string }
|
||||||
| { type: "event"; event: WsEvent };
|
| { type: "event"; event: WsEvent }
|
||||||
|
/** Reply to a client ``rpc`` frame: the HTTP status and parsed JSON body of the
|
||||||
|
* internal dispatch, correlating on ``id``. */
|
||||||
|
| { type: "rpc_result"; id: string; status: number; body: unknown };
|
||||||
|
|
||||||
/** Every frame the client may send. */
|
/** Every frame the client may send. */
|
||||||
export type WsClientFrame =
|
export type WsClientFrame =
|
||||||
| { action: "subscribe"; channels: string[] }
|
| { action: "subscribe"; channels: string[] }
|
||||||
| { action: "unsubscribe"; channels: string[] }
|
| { action: "unsubscribe"; channels: string[] }
|
||||||
| { action: "ping" };
|
| { action: "ping" }
|
||||||
|
/** A request/response call into the server. ``id`` correlates the
|
||||||
|
* ``rpc_result``; ``path`` is the full mounted path including any query
|
||||||
|
* string. Every application call travels over this frame. */
|
||||||
|
| {
|
||||||
|
action: "rpc";
|
||||||
|
id: string;
|
||||||
|
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
||||||
|
path: string;
|
||||||
|
body: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
/** Live run-step payload — what the task timeline renders as it arrives. */
|
/** Live run-step payload — what the task timeline renders as it arrives. */
|
||||||
export interface WsRunStepData {
|
export interface WsRunStepData {
|
||||||
|
|||||||
Reference in New Issue
Block a user