The socket carries invalidation on both backends, and a transport to ride it
mizan-django's socket sent a bare result, so a mutation called over it dropped
its invalidation while the same mutation over HTTP applied it. Both backends
now put the {result, invalidate, merge} envelope in `data` — the shape the
Tauri and webview transports already document — so mizanCall applies
server-driven invalidation identically whichever transport carried the call.
Two channel tests pinned the bare-result shape and move with the contract.
FastAPI gains a `ctx` action: without it a socket transport cannot fetch a
context bundle, and every app needs an HTTP connection beside the socket.
@mizan/ws-transport implements MizanTransport over one connection — RPC and
context bundles correlated by id, channel subscriptions re-sent on reconnect,
in-flight calls rejected when the socket closes under them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@ Protocol:
|
||||
{"channel": "chat", "params": {"room": "general"}, "type": "DjangoMessage", "data": {...}}
|
||||
|
||||
# RPC responses
|
||||
{"id": "request-id", "ok": true, "data": {...}}
|
||||
{"id": "request-id", "ok": true, "data": {"result": {...}, "invalidate": [...]}}
|
||||
{"id": "request-id", "ok": false, "error": {...}}
|
||||
|
||||
{"error": "..."}
|
||||
@@ -390,7 +390,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
Protocol:
|
||||
Request: {"action": "rpc", "id": "request-id", "fn": "function_name", "args": {...}}
|
||||
Response: {"id": "request-id", "ok": true, "data": {...}}
|
||||
Response: {"id": "request-id", "ok": true, "data": {"result":..., "invalidate":[...]}}
|
||||
or: {"id": "request-id", "ok": false, "error": {...}}
|
||||
|
||||
Security:
|
||||
@@ -485,13 +485,23 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
}
|
||||
)
|
||||
else:
|
||||
await self.send_json(
|
||||
{
|
||||
"id": request_id,
|
||||
"ok": True,
|
||||
"data": result.data,
|
||||
}
|
||||
# the same {result, invalidate, merge} envelope the HTTP RPC path builds, so a
|
||||
# mutation sent over the socket invalidates exactly as one sent over HTTP
|
||||
from mizan.client.executor import _resolve_invalidation, _resolve_merges
|
||||
|
||||
data = {"result": result.data}
|
||||
invalidate = await sync_to_async(_resolve_invalidation, thread_sensitive=True)(
|
||||
fn_class, args
|
||||
)
|
||||
merges = await sync_to_async(_resolve_merges, thread_sensitive=True)(
|
||||
fn_class, args, result.data
|
||||
)
|
||||
if invalidate:
|
||||
data["invalidate"] = invalidate
|
||||
if merges:
|
||||
data["merge"] = merges
|
||||
|
||||
await self.send_json({"id": request_id, "ok": True, "data": data})
|
||||
|
||||
async def channel_message(self, event: dict):
|
||||
"""
|
||||
|
||||
@@ -1000,7 +1000,8 @@ class WebSocketRPCTests(TestCase):
|
||||
|
||||
self.assertEqual(response["id"], "test-123")
|
||||
self.assertTrue(response["ok"])
|
||||
self.assertEqual(response["data"]["echo"], "Echo: Hello")
|
||||
# data is the {result, invalidate, merge} envelope, as on the HTTP RPC path
|
||||
self.assertEqual(response["data"]["result"]["echo"], "Echo: Hello")
|
||||
|
||||
def test_handle_rpc_with_multiple_args(self):
|
||||
"""_handle_rpc should handle functions with multiple arguments."""
|
||||
@@ -1029,7 +1030,7 @@ class WebSocketRPCTests(TestCase):
|
||||
|
||||
response = consumer.sent_messages[0]
|
||||
self.assertTrue(response["ok"])
|
||||
self.assertEqual(response["data"]["result"], 8)
|
||||
self.assertEqual(response["data"]["result"]["result"], 8)
|
||||
|
||||
def test_handle_rpc_function_not_found(self):
|
||||
"""_handle_rpc should return error for unknown function."""
|
||||
|
||||
@@ -6,6 +6,7 @@ The WebSocket endpoint — channel subscriptions and RPC over one connection.
|
||||
{"action": "unsubscribe", "channel": "chat", "params": {...}}
|
||||
{"action": "message", "channel": "chat", "params": {...}, "data": {...}}
|
||||
{"action": "rpc", "id": "request-id", "fn": "function_name", "args": {...}}
|
||||
{"action": "ctx", "id": "request-id", "context": "name", "params": {...}}
|
||||
|
||||
Server sends:
|
||||
{"channel": "chat", "params": {...}, "type": "...", "data": {...}}
|
||||
@@ -29,7 +30,7 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from mizan_core.registry import get_function
|
||||
from mizan_core.registry import get_context_groups, get_function
|
||||
from mizan_fastapi import channels
|
||||
from mizan_fastapi.executor import (
|
||||
MizanError,
|
||||
@@ -162,11 +163,41 @@ async def _rpc(socket: WebSocket, body: dict[str, Any]) -> None:
|
||||
await socket.send_json({"id": request_id, "ok": True, "data": data})
|
||||
|
||||
|
||||
async def _ctx(socket: WebSocket, body: dict[str, Any]) -> None:
|
||||
"""A context bundle over the socket, so a client needs no second connection to read."""
|
||||
request_id = body.get("id")
|
||||
name = body.get("context") or ""
|
||||
fn_names = get_context_groups().get(name)
|
||||
if not fn_names:
|
||||
await socket.send_json(
|
||||
{
|
||||
"id": request_id,
|
||||
"ok": False,
|
||||
"error": {"code": "NOT_FOUND", "message": f"Context '{name}' not found"},
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
params = body.get("params") or {}
|
||||
request = _SocketRequest(socket)
|
||||
try:
|
||||
bundled = {fn: await execute_function(request, fn, params) for fn in fn_names}
|
||||
except MizanError as e:
|
||||
payload: dict[str, Any] = {"code": e.code.value, "message": e.message}
|
||||
if e.details:
|
||||
payload["details"] = e.details
|
||||
await socket.send_json({"id": request_id, "ok": False, "error": payload})
|
||||
return
|
||||
|
||||
await socket.send_json({"id": request_id, "ok": True, "data": bundled})
|
||||
|
||||
|
||||
_ACTIONS = {
|
||||
"subscribe": _subscribe,
|
||||
"unsubscribe": _unsubscribe,
|
||||
"message": _message,
|
||||
"rpc": _rpc,
|
||||
"ctx": _ctx,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user