muxws 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- muxws/__init__.py +94 -0
- muxws/api.py +267 -0
- muxws/codecs/__init__.py +99 -0
- muxws/codecs/json_.py +71 -0
- muxws/codecs/msgpack_.py +91 -0
- muxws/conf.py +28 -0
- muxws/errors.py +177 -0
- muxws/fragment.py +276 -0
- muxws/frames.py +112 -0
- muxws/lifecycle.py +113 -0
- muxws/observability.py +61 -0
- muxws/peer.py +1043 -0
- muxws/py.typed +0 -0
- muxws/reconnect.py +579 -0
- muxws/registry.py +128 -0
- muxws/stream.py +314 -0
- muxws/subprotocol.py +102 -0
- muxws/transports/__init__.py +44 -0
- muxws/transports/memory.py +81 -0
- muxws/transports/starlette.py +93 -0
- muxws/transports/websockets_.py +112 -0
- muxws/writer.py +237 -0
- muxws-0.2.0.dist-info/METADATA +92 -0
- muxws-0.2.0.dist-info/RECORD +26 -0
- muxws-0.2.0.dist-info/WHEEL +4 -0
- muxws-0.2.0.dist-info/licenses/LICENSE +21 -0
muxws/__init__.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""muxws - multiplexed, cancellable, bidirectional streams over one WebSocket.
|
|
2
|
+
|
|
3
|
+
The three backoff helpers are exported together or not at all: `backoff_delay`, `unjittered_delay`
|
|
4
|
+
and `should_retry` are one schedule seen from three sides, and an operator computing "when will it
|
|
5
|
+
try again" needs all three. Exporting one of them was arbitrary.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from muxws.api import accept, connect, resolve_codec, select_subprotocol, serve
|
|
9
|
+
from muxws.codecs import Codec, get_codec, register_codec, registered_codecs
|
|
10
|
+
from muxws.codecs.json_ import JsonCodec
|
|
11
|
+
from muxws.conf import Settings, settings
|
|
12
|
+
from muxws.errors import (
|
|
13
|
+
CodecError,
|
|
14
|
+
CodecMismatch,
|
|
15
|
+
CodecNotRegistered,
|
|
16
|
+
ConnectionClosed,
|
|
17
|
+
ConnectionGoingAway,
|
|
18
|
+
ConnectionLost,
|
|
19
|
+
MuxwsError,
|
|
20
|
+
ProtocolError,
|
|
21
|
+
RemoteError,
|
|
22
|
+
ResetCode,
|
|
23
|
+
StreamAlreadyConsumed,
|
|
24
|
+
StreamClosed,
|
|
25
|
+
StreamRefused,
|
|
26
|
+
StreamReset,
|
|
27
|
+
StreamTimeout,
|
|
28
|
+
)
|
|
29
|
+
from muxws.fragment import Assembler, encoded_length, MAX_FRAME_BYTES, split_frame
|
|
30
|
+
from muxws.frames import ABSENT, Frame, from_mapping, to_mapping
|
|
31
|
+
from muxws.observability import CloseReason
|
|
32
|
+
from muxws.peer import default_error_serializer, ErrorSerializer, Peer, StreamHandler
|
|
33
|
+
from muxws.reconnect import backoff_delay, Hello, Reconnect, should_retry, unjittered_delay
|
|
34
|
+
from muxws.registry import PeerRegistry
|
|
35
|
+
from muxws.stream import Stream, StreamState
|
|
36
|
+
from muxws.transports import SocketAdapter
|
|
37
|
+
|
|
38
|
+
__version__ = "0.2.0"
|
|
39
|
+
|
|
40
|
+
# The library registers JSON itself (WSM-CDC-004); the codec module must not (WSM-CDC-014).
|
|
41
|
+
register_codec("json", JsonCodec())
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"ABSENT",
|
|
45
|
+
"Assembler",
|
|
46
|
+
"CloseReason",
|
|
47
|
+
"Codec",
|
|
48
|
+
"CodecError",
|
|
49
|
+
"CodecMismatch",
|
|
50
|
+
"CodecNotRegistered",
|
|
51
|
+
"ConnectionClosed",
|
|
52
|
+
"ConnectionGoingAway",
|
|
53
|
+
"ConnectionLost",
|
|
54
|
+
"ErrorSerializer",
|
|
55
|
+
"Frame",
|
|
56
|
+
"Hello",
|
|
57
|
+
"JsonCodec",
|
|
58
|
+
"MAX_FRAME_BYTES",
|
|
59
|
+
"MuxwsError",
|
|
60
|
+
"Peer",
|
|
61
|
+
"PeerRegistry",
|
|
62
|
+
"ProtocolError",
|
|
63
|
+
"Reconnect",
|
|
64
|
+
"RemoteError",
|
|
65
|
+
"ResetCode",
|
|
66
|
+
"Settings",
|
|
67
|
+
"SocketAdapter",
|
|
68
|
+
"Stream",
|
|
69
|
+
"StreamAlreadyConsumed",
|
|
70
|
+
"StreamClosed",
|
|
71
|
+
"StreamHandler",
|
|
72
|
+
"StreamRefused",
|
|
73
|
+
"StreamReset",
|
|
74
|
+
"StreamState",
|
|
75
|
+
"StreamTimeout",
|
|
76
|
+
"__version__",
|
|
77
|
+
"accept",
|
|
78
|
+
"backoff_delay",
|
|
79
|
+
"connect",
|
|
80
|
+
"default_error_serializer",
|
|
81
|
+
"encoded_length",
|
|
82
|
+
"from_mapping",
|
|
83
|
+
"get_codec",
|
|
84
|
+
"register_codec",
|
|
85
|
+
"registered_codecs",
|
|
86
|
+
"resolve_codec",
|
|
87
|
+
"select_subprotocol",
|
|
88
|
+
"serve",
|
|
89
|
+
"settings",
|
|
90
|
+
"should_retry",
|
|
91
|
+
"split_frame",
|
|
92
|
+
"to_mapping",
|
|
93
|
+
"unjittered_delay",
|
|
94
|
+
]
|
muxws/api.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""`connect`, `accept` and `serve` - the module-level factories (§9.3).
|
|
2
|
+
|
|
3
|
+
The codec is resolved **before** any socket is touched. Putting the lookup after the upgrade is the
|
|
4
|
+
single most likely wrong implementation of WSM-CDC-016, and it is what makes a misconfigured
|
|
5
|
+
deployment fail as a puzzling decode error on the tenth frame rather than as a named startup error.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Awaitable, Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from muxws.codecs import Codec, get_codec
|
|
14
|
+
from muxws.conf import settings
|
|
15
|
+
from muxws.errors import CodecMismatch, ConnectionClosed, ProtocolError
|
|
16
|
+
from muxws.fragment import MAX_FRAME_BYTES
|
|
17
|
+
from muxws.peer import ErrorSerializer, Peer, StreamHandler
|
|
18
|
+
from muxws.reconnect import ConnectionLoop, Hello, Reconnect
|
|
19
|
+
from muxws.subprotocol import mismatch_error, offer, PREFIX
|
|
20
|
+
from muxws.transports import SocketAdapter
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_codec(override: Codec | None = None) -> Codec:
|
|
24
|
+
"""An explicit `codec=` wins (WSM-CDC-012); otherwise the configured name (WSM-CDC-010/016).
|
|
25
|
+
|
|
26
|
+
Never falls back to JSON. A deployment that believes it is running msgpack, is not, and finds
|
|
27
|
+
out from neither end is the failure WSM-INV-015 names.
|
|
28
|
+
"""
|
|
29
|
+
if override is not None:
|
|
30
|
+
return override
|
|
31
|
+
return get_codec(settings.codec)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def connect(
|
|
35
|
+
url: str,
|
|
36
|
+
*,
|
|
37
|
+
headers: dict[str, str] | None = None,
|
|
38
|
+
subprotocols: list[str] | None = None,
|
|
39
|
+
hello: Any = None,
|
|
40
|
+
hello_headers: dict[str, Any] | None = None,
|
|
41
|
+
reconnect: Reconnect | None = None,
|
|
42
|
+
ping_interval: float = 20.0,
|
|
43
|
+
ping_timeout: float = 10.0,
|
|
44
|
+
hello_timeout: float = 10.0,
|
|
45
|
+
max_payload_bytes: int = 67_108_864,
|
|
46
|
+
max_concurrent_streams: int = 100,
|
|
47
|
+
error_serializer: ErrorSerializer | None = None,
|
|
48
|
+
codec: Codec | None = None,
|
|
49
|
+
max_frame_bytes: int = MAX_FRAME_BYTES,
|
|
50
|
+
on_stream: StreamHandler | None = None,
|
|
51
|
+
on_close: Callable[[Any], None] | None = None,
|
|
52
|
+
on_reconnect: Callable[[int, Any], None] | None = None,
|
|
53
|
+
) -> Peer:
|
|
54
|
+
"""Dial `url` and return a serving peer.
|
|
55
|
+
|
|
56
|
+
Raises if the **first** attempt fails, with the underlying error, whatever `reconnect` says
|
|
57
|
+
(WSM-RCN-006). Reconnection applies to connections that were established and then lost; a peer
|
|
58
|
+
that retried its first dial forever would turn a typo in the URL into silence. There is
|
|
59
|
+
deliberately no option that changes this (WSM-INV-018): a caller who wants the first dial retried
|
|
60
|
+
writes that loop itself, where it can decide what a permanent failure looks like.
|
|
61
|
+
|
|
62
|
+
Everything after that first dial belongs to the reconnect helper: the backoff schedule, the
|
|
63
|
+
heartbeat, and the hello replayed verbatim on every connection this peer ever makes.
|
|
64
|
+
|
|
65
|
+
`on_stream`, `on_close` and `on_reconnect` are the same handlers `peer.on_stream(...)` and
|
|
66
|
+
friends register, and they are registered **before** the hello goes out. That is the whole reason
|
|
67
|
+
they are parameters rather than a line the caller writes afterwards: the acceptor may push a
|
|
68
|
+
stream the instant it sees the hello, and a peer whose handler is registered one await later
|
|
69
|
+
answers that push `reset(REFUSED, "no on_stream handler")` (WSM-STM-033).
|
|
70
|
+
"""
|
|
71
|
+
resolved = resolve_codec(codec)
|
|
72
|
+
dial = _websocket_dialer(url, resolved, headers=headers, subprotocols=subprotocols)
|
|
73
|
+
|
|
74
|
+
socket = await dial()
|
|
75
|
+
peer = Peer(
|
|
76
|
+
socket,
|
|
77
|
+
codec=resolved,
|
|
78
|
+
is_dialer=True,
|
|
79
|
+
error_serializer=error_serializer,
|
|
80
|
+
max_frame_bytes=max_frame_bytes,
|
|
81
|
+
max_payload_bytes=max_payload_bytes,
|
|
82
|
+
max_concurrent_streams=max_concurrent_streams,
|
|
83
|
+
)
|
|
84
|
+
if on_stream is not None:
|
|
85
|
+
peer.on_stream(on_stream)
|
|
86
|
+
if on_close is not None:
|
|
87
|
+
peer.on_close(on_close)
|
|
88
|
+
if on_reconnect is not None:
|
|
89
|
+
peer.on_reconnect(on_reconnect)
|
|
90
|
+
loop = ConnectionLoop(
|
|
91
|
+
peer,
|
|
92
|
+
dial,
|
|
93
|
+
options=reconnect or Reconnect(),
|
|
94
|
+
# Captured **here**, once, by value: replayed verbatim on every later connection and never
|
|
95
|
+
# re-read from the application's object (WSM-RCN-020).
|
|
96
|
+
hello=Hello(payload=hello, headers=hello_headers, timeout=hello_timeout),
|
|
97
|
+
ping_interval=ping_interval,
|
|
98
|
+
ping_timeout=ping_timeout,
|
|
99
|
+
)
|
|
100
|
+
await loop.establish()
|
|
101
|
+
loop.start()
|
|
102
|
+
# Held so the garbage collector cannot collect the supervisor out from under the peer.
|
|
103
|
+
peer._connection_loop = loop
|
|
104
|
+
return peer
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _websocket_dialer(
|
|
108
|
+
url: str,
|
|
109
|
+
resolved: Codec,
|
|
110
|
+
*,
|
|
111
|
+
headers: dict[str, str] | None,
|
|
112
|
+
subprotocols: list[str] | None,
|
|
113
|
+
) -> Callable[[], Awaitable[SocketAdapter]]:
|
|
114
|
+
"""One closure that dials, verifies the subprotocol, and hands back a `SocketAdapter`.
|
|
115
|
+
|
|
116
|
+
The helper is given this rather than a URL because it knows nothing about transports: every
|
|
117
|
+
reconnect is the same dial as the first one, down to the offered subprotocol list, so there is
|
|
118
|
+
exactly one place where a connection is made and no way for a reconnect to negotiate something
|
|
119
|
+
the first connection did not (WSM-CDC-016/020).
|
|
120
|
+
"""
|
|
121
|
+
offered = offer(resolved.name, subprotocols)
|
|
122
|
+
|
|
123
|
+
async def dial() -> SocketAdapter:
|
|
124
|
+
import websockets
|
|
125
|
+
|
|
126
|
+
from muxws.transports.websockets_ import verify_negotiated, WebsocketsSocket
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
connection = await websockets.connect(
|
|
130
|
+
url,
|
|
131
|
+
subprotocols=offered, # type: ignore[arg-type]
|
|
132
|
+
additional_headers=headers,
|
|
133
|
+
)
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
if _looks_like_a_refused_handshake(exc):
|
|
136
|
+
raise mismatch_error(resolved.name) from exc
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
verify_negotiated(getattr(connection, "subprotocol", None), resolved.name)
|
|
141
|
+
except Exception:
|
|
142
|
+
from muxws.transports.websockets_ import POLICY_VIOLATION
|
|
143
|
+
|
|
144
|
+
await connection.close(code=POLICY_VIOLATION, reason="codec mismatch")
|
|
145
|
+
raise
|
|
146
|
+
|
|
147
|
+
return WebsocketsSocket(connection)
|
|
148
|
+
|
|
149
|
+
return dial
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
#: What an acceptor answers a codec it does not speak (WSM-CDC-022), and the only status this reads
|
|
153
|
+
#: as a refusal. Anything else is a server that failed for its own reasons and must surface as itself.
|
|
154
|
+
_REFUSED = 400
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _looks_like_a_refused_handshake(exc: BaseException) -> bool:
|
|
158
|
+
"""A 400 on the upgrade is a refused muxws handshake, to be reported as `CodecMismatch`.
|
|
159
|
+
|
|
160
|
+
The status comes from `InvalidStatus.response`, never from the message. `"400" in str(exc)` also
|
|
161
|
+
matches the `OSError` for a connection refused on **port** 400, and matching a status code out of
|
|
162
|
+
prose is what let a cross-language dial - where the message wording differs - miss a real refusal
|
|
163
|
+
entirely (WSM-CDC-024). The string branch survives only as a fallback for a `websockets` release
|
|
164
|
+
that reports the status without attaching the response, and is narrowed to the phrase it uses.
|
|
165
|
+
"""
|
|
166
|
+
from websockets.exceptions import InvalidStatus
|
|
167
|
+
|
|
168
|
+
if isinstance(exc, InvalidStatus):
|
|
169
|
+
return exc.response.status_code == _REFUSED
|
|
170
|
+
return f"HTTP {_REFUSED}" in str(exc)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def accept(
|
|
174
|
+
socket: SocketAdapter | Any,
|
|
175
|
+
*,
|
|
176
|
+
max_payload_bytes: int = 67_108_864,
|
|
177
|
+
max_concurrent_streams: int = 100,
|
|
178
|
+
error_serializer: ErrorSerializer | None = None,
|
|
179
|
+
codec: Codec | None = None,
|
|
180
|
+
max_frame_bytes: int = MAX_FRAME_BYTES,
|
|
181
|
+
) -> Peer:
|
|
182
|
+
"""Accept an inbound connection and return a peer that is not yet serving.
|
|
183
|
+
|
|
184
|
+
A Starlette `WebSocket` is upgraded here, because `accept()` is the only party that knows which
|
|
185
|
+
subprotocol to select (WSM-CDC-026). Anything already implementing `SocketAdapter` is taken as
|
|
186
|
+
it is.
|
|
187
|
+
"""
|
|
188
|
+
resolved = resolve_codec(codec)
|
|
189
|
+
adapter = await _adapt(socket, resolved.name)
|
|
190
|
+
peer = Peer(
|
|
191
|
+
adapter,
|
|
192
|
+
codec=resolved,
|
|
193
|
+
is_dialer=False,
|
|
194
|
+
error_serializer=error_serializer,
|
|
195
|
+
max_frame_bytes=max_frame_bytes,
|
|
196
|
+
max_payload_bytes=max_payload_bytes,
|
|
197
|
+
max_concurrent_streams=max_concurrent_streams,
|
|
198
|
+
)
|
|
199
|
+
return peer
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def _adapt(socket: Any, codec_name: str) -> SocketAdapter:
|
|
203
|
+
"""Wrap whatever the application handed us, upgrading it first when that is still to be done.
|
|
204
|
+
|
|
205
|
+
The framework checks come **before** the `SocketAdapter` one. `SocketAdapter` is a
|
|
206
|
+
`runtime_checkable` Protocol, and `isinstance` against one of those tests only that the four
|
|
207
|
+
method *names* exist - which Starlette's `WebSocket` happens to satisfy. Checking it first meant
|
|
208
|
+
a Starlette socket was taken as an already-adapted one and the upgrade never happened, which
|
|
209
|
+
surfaces only against a real ASGI server.
|
|
210
|
+
"""
|
|
211
|
+
if _is_starlette_websocket(socket):
|
|
212
|
+
from muxws.transports.starlette import perform_upgrade
|
|
213
|
+
|
|
214
|
+
return await perform_upgrade(socket, codec_name)
|
|
215
|
+
|
|
216
|
+
if _is_websockets_connection(socket):
|
|
217
|
+
from muxws.transports.websockets_ import verify_negotiated, WebsocketsSocket
|
|
218
|
+
|
|
219
|
+
verify_negotiated(getattr(socket, "subprotocol", None), codec_name)
|
|
220
|
+
return WebsocketsSocket(socket)
|
|
221
|
+
|
|
222
|
+
if isinstance(socket, SocketAdapter):
|
|
223
|
+
return socket
|
|
224
|
+
|
|
225
|
+
raise ProtocolError(
|
|
226
|
+
f"{type(socket).__name__} is neither a SocketAdapter nor a socket muxws knows how to "
|
|
227
|
+
f"upgrade; wrap it in an adapter (WSM-API-021)"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _is_starlette_websocket(socket: Any) -> bool:
|
|
232
|
+
return hasattr(socket, "scope") and hasattr(socket, "client_state")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _is_websockets_connection(socket: Any) -> bool:
|
|
236
|
+
"""A `websockets` connection, which has already handshaken by the time we see it."""
|
|
237
|
+
return hasattr(socket, "recv") and hasattr(socket, "send") and hasattr(socket, "subprotocol")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def serve(socket: SocketAdapter | Any, *, handler: StreamHandler, **peer_options: Any) -> None:
|
|
241
|
+
"""Accept, register `handler`, and run the read loop until the socket closes."""
|
|
242
|
+
try:
|
|
243
|
+
peer = await accept(socket, **peer_options)
|
|
244
|
+
except CodecMismatch:
|
|
245
|
+
# The upgrade was refused and the 400 has already been sent (WSM-CDC-022). There is no peer
|
|
246
|
+
# and nothing further to do; raising here would turn an ordinary misconfiguration into a
|
|
247
|
+
# traceback out of the application's endpoint.
|
|
248
|
+
return
|
|
249
|
+
peer.on_stream(handler)
|
|
250
|
+
try:
|
|
251
|
+
await peer.serve()
|
|
252
|
+
except ConnectionClosed:
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def select_subprotocol(connection: Any, subprotocols: list[str]) -> str:
|
|
257
|
+
"""Re-exported handshake hook for the `websockets` library (WSM-CDC-027).
|
|
258
|
+
|
|
259
|
+
Raises `NegotiationError` rather than returning None on a mismatch; see the hook itself for why
|
|
260
|
+
that is the only shape `websockets` turns into the 400 WSM-CDC-022 requires.
|
|
261
|
+
"""
|
|
262
|
+
from muxws.transports.websockets_ import select_subprotocol as hook
|
|
263
|
+
|
|
264
|
+
return hook(connection, subprotocols)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
__all__ = ["PREFIX", "accept", "connect", "resolve_codec", "select_subprotocol", "serve"]
|
muxws/codecs/__init__.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""The codec seam: the port, and the explicit registry behind it (§2.1, WSM-CDC-001..016)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from muxws.errors import CodecNotRegistered
|
|
8
|
+
from muxws.frames import Frame
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class Codec(Protocol):
|
|
13
|
+
"""Turns a logical frame into a WebSocket message and back.
|
|
14
|
+
|
|
15
|
+
`binary` is **declared, not inferred** (WSM-CDC-002): the peer reads it to choose the socket's
|
|
16
|
+
text or binary send method and the inbound message type it expects. A peer never sniffs a message
|
|
17
|
+
to decide which branch to take.
|
|
18
|
+
|
|
19
|
+
`encode_payload` / `decode_payload` are the payload-level half of the port. WSM-CDC-001 names
|
|
20
|
+
only the frame-level pair, but fragmentation is defined in terms of the encoded form of a
|
|
21
|
+
*logical payload* (WSM-FRG-011) which the receiver hands back to the codec once reassembled
|
|
22
|
+
(WSM-FRG-030), and neither operation can be expressed through `encode`/`decode` alone.
|
|
23
|
+
See GAPS.md.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
binary: bool
|
|
28
|
+
|
|
29
|
+
def encode(self, frame: Frame) -> str | bytes:
|
|
30
|
+
"""One whole frame - envelope and payload - as it goes on the wire.
|
|
31
|
+
|
|
32
|
+
`str` under a text codec, `bytes` under a binary one; the peer picks the socket method from
|
|
33
|
+
`binary` and never from this return type. An absent field MUST NOT be emitted at all: an
|
|
34
|
+
omitted `payload` key and `"payload": null` are different frames.
|
|
35
|
+
"""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def decode(self, message: str | bytes) -> Frame:
|
|
39
|
+
"""The inverse. Unknown envelope fields MUST be dropped rather than rejected (WSM-FRM-001).
|
|
40
|
+
|
|
41
|
+
Raises `ProtocolError` when the message is not something this codec can read - that is a
|
|
42
|
+
connection-level failure, and the peer answers it with `goaway`.
|
|
43
|
+
"""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
def encode_payload(self, payload: Any) -> str | bytes:
|
|
47
|
+
"""A payload on its own, with no envelope around it.
|
|
48
|
+
|
|
49
|
+
This is what fragmentation slices (WSM-FRG-011), so its output has to be exactly the bytes
|
|
50
|
+
that would appear inside a frame's payload position - a codec whose two encoders disagree
|
|
51
|
+
cuts fragments at boundaries the receiver cannot rejoin.
|
|
52
|
+
"""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def decode_payload(self, data: str | bytes) -> Any:
|
|
56
|
+
"""What the receiver hands back once the fragments are concatenated (WSM-FRG-030).
|
|
57
|
+
|
|
58
|
+
The inverse of `encode_payload`, and the reason the port carries four members rather than
|
|
59
|
+
the two WSM-CDC-001 names.
|
|
60
|
+
"""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_REGISTRY: dict[str, Codec] = {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def register_codec(name: str, codec: Codec) -> None:
|
|
68
|
+
"""Register `codec` under `name`.
|
|
69
|
+
|
|
70
|
+
Registration is explicit and eager (WSM-CDC-013): there is no dynamic import, no lazy
|
|
71
|
+
auto-registration, no entry-point scan, and no probing of whether a module happens to be
|
|
72
|
+
installed. A codec module never registers itself at import time (WSM-CDC-014).
|
|
73
|
+
"""
|
|
74
|
+
_REGISTRY[name] = codec
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_codec(name: str) -> Codec:
|
|
78
|
+
"""Return the codec registered under `name`.
|
|
79
|
+
|
|
80
|
+
Raises `CodecNotRegistered` naming the environment variable, the value found and the registered
|
|
81
|
+
set (WSM-CDC-016). There is no fallback to JSON, ever - a deployment that believes it is running
|
|
82
|
+
msgpack and silently is not may never find out (WSM-INV-015).
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
return _REGISTRY[name]
|
|
86
|
+
except KeyError:
|
|
87
|
+
available = registered_codecs()
|
|
88
|
+
raise CodecNotRegistered(
|
|
89
|
+
f"codec {name!r} is not registered (MUXWS_CODEC={name!r}); "
|
|
90
|
+
f"registered codecs are {available!r}. Call register_codec({name!r}, ...) during "
|
|
91
|
+
f"bootstrap, before connecting.",
|
|
92
|
+
configured=name,
|
|
93
|
+
available=available,
|
|
94
|
+
) from None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def registered_codecs() -> list[str]:
|
|
98
|
+
"""Every registered codec name, sorted."""
|
|
99
|
+
return sorted(_REGISTRY)
|
muxws/codecs/json_.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""The JSON codec - the interoperability baseline (WSM-CDC-004..008).
|
|
2
|
+
|
|
3
|
+
The module name carries a trailing underscore: a top-level `json.py` inside the package would shadow
|
|
4
|
+
the stdlib module this very file imports.
|
|
5
|
+
|
|
6
|
+
This module does **not** register itself (WSM-CDC-014). `muxws/__init__.py` does, because the library
|
|
7
|
+
is required to ship JSON registered (WSM-CDC-004) while a side-effecting import can never be
|
|
8
|
+
tree-shaken out.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from muxws.errors import ProtocolError
|
|
18
|
+
from muxws.frames import Frame, from_mapping, to_mapping
|
|
19
|
+
|
|
20
|
+
#: Compact separators and no ASCII escaping, so that `json.dumps` here and `JSON.stringify` in the
|
|
21
|
+
#: TypeScript port emit the same bytes for the same value - for strings, containers, booleans, null
|
|
22
|
+
#: and integers up to 2**53. They do **not** agree on floats or on larger integers: Python writes
|
|
23
|
+
#: `1.0`, `-0.0`, `1e+16`, `1e-07` where JavaScript writes `1`, `0`, `10000000000000000`, `1e-7`,
|
|
24
|
+
#: and JavaScript rounds an integer beyond 2**53 to the nearest double. Fragment boundaries are cut
|
|
25
|
+
#: over this encoded form, so a payload carrying a float is cut differently by the two ports - see
|
|
26
|
+
#: GAPS.md. It costs nothing on the wire (the sender chooses the cuts and the receiver only
|
|
27
|
+
#: concatenates), but it does bound what the shared boundary corpus may contain.
|
|
28
|
+
_DUMP_KWARGS: dict[str, Any] = {"separators": (",", ":"), "ensure_ascii": False, "allow_nan": False}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _reject(value: Any) -> Any:
|
|
32
|
+
"""`json.dumps` default hook: refuse rather than invent an encoding.
|
|
33
|
+
|
|
34
|
+
Bytes are a first-class payload type under a binary codec and are *not* one under JSON, and muxws
|
|
35
|
+
MUST NOT base64-encode them on the application's behalf (WSM-CDC-008).
|
|
36
|
+
"""
|
|
37
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
38
|
+
raise TypeError(
|
|
39
|
+
f"the json codec cannot carry {type(value).__name__}: bytes are a payload type only under "
|
|
40
|
+
f"a binary codec, and muxws does not base64-encode them for you (WSM-CDC-008). Either "
|
|
41
|
+
f"encode them in the application or configure the msgpack codec."
|
|
42
|
+
)
|
|
43
|
+
raise TypeError(f"the json codec cannot encode {type(value).__name__}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class JsonCodec:
|
|
47
|
+
"""`Codec` implementation over the standard library's JSON."""
|
|
48
|
+
|
|
49
|
+
name = "json"
|
|
50
|
+
binary = False
|
|
51
|
+
|
|
52
|
+
def encode(self, frame: Frame) -> str:
|
|
53
|
+
return json.dumps(to_mapping(frame), default=_reject, **_DUMP_KWARGS)
|
|
54
|
+
|
|
55
|
+
def decode(self, message: str | bytes) -> Frame:
|
|
56
|
+
try:
|
|
57
|
+
mapping = json.loads(message)
|
|
58
|
+
except (ValueError, TypeError) as exc:
|
|
59
|
+
raise ProtocolError(f"the json codec could not decode the message: {exc} (WSM-FRM-005)") from exc
|
|
60
|
+
if not isinstance(mapping, dict):
|
|
61
|
+
raise ProtocolError(f"a frame must decode to an object, got {type(mapping).__name__} (WSM-FRM-005)")
|
|
62
|
+
return from_mapping(mapping)
|
|
63
|
+
|
|
64
|
+
def encode_payload(self, payload: Any) -> str:
|
|
65
|
+
return json.dumps(payload, default=_reject, **_DUMP_KWARGS)
|
|
66
|
+
|
|
67
|
+
def decode_payload(self, data: str | bytes) -> Any:
|
|
68
|
+
try:
|
|
69
|
+
return json.loads(data)
|
|
70
|
+
except (ValueError, TypeError) as exc:
|
|
71
|
+
raise ProtocolError(f"the json codec could not decode a reassembled payload: {exc}") from exc
|
muxws/codecs/msgpack_.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""The msgpack codec - the honest proof that the codec seam is a seam (WSM-CDC-006/007/008).
|
|
2
|
+
|
|
3
|
+
The module name carries a trailing underscore for the same reason `json_.py` does: a top-level
|
|
4
|
+
`msgpack.py` inside the package would shadow the third-party module this very file imports.
|
|
5
|
+
|
|
6
|
+
`msgpack` is an **optional extra** (WSM-PKG-002). Importing this module is the whole of the
|
|
7
|
+
selection: nothing inside muxws imports it, so a deployment that never asks for msgpack never pays
|
|
8
|
+
for it. And this module does **not** register itself (WSM-CDC-014) - the application calls
|
|
9
|
+
`register_codec("msgpack", MsgpackCodec())` during bootstrap. There is no entry-point scan and no
|
|
10
|
+
"is it installed" probe anywhere (WSM-CDC-013).
|
|
11
|
+
|
|
12
|
+
Nothing here may be pinned as bytes in a fixture (WSM-CDC-006): this library and
|
|
13
|
+
`@msgpack/msgpack` make different but equally valid choices about integer width and map format, and
|
|
14
|
+
a pinned-bytes fixture would make a legal encoder fail. Round-trip is the only assertion.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any, Final
|
|
20
|
+
|
|
21
|
+
import msgpack
|
|
22
|
+
|
|
23
|
+
from muxws.errors import ProtocolError
|
|
24
|
+
from muxws.frames import Frame, from_mapping, to_mapping
|
|
25
|
+
|
|
26
|
+
#: `use_bin_type=True` is what keeps `bytes` and `str` distinguishable on the wire, and bytes are a
|
|
27
|
+
#: first-class payload type under a binary codec (WSM-CDC-008). It has been the default since
|
|
28
|
+
#: msgpack 1.0; it is written out because a codec whose byte type collapsed into its string type
|
|
29
|
+
#: would still round-trip within one language and fail only against the other port.
|
|
30
|
+
_PACK_KWARGS: Final[dict[str, Any]] = {"use_bin_type": True}
|
|
31
|
+
|
|
32
|
+
#: - `raw=False` decodes msgpack `str` to `str` rather than to `bytes`. Without it every envelope key
|
|
33
|
+
#: and every `type` value would come back as `bytes`, `from_mapping` would see no `"type"` key, and
|
|
34
|
+
#: every inbound frame would be a protocol error. It is the 1.x default, and it is stated because
|
|
35
|
+
#: it was *not* the default in 0.x and a codec that silently changed shape on a downgrade is worse
|
|
36
|
+
#: than one that fails.
|
|
37
|
+
#: - `strict_map_key=False` lets a map key be something other than `str`/`bytes`. The default (True)
|
|
38
|
+
#: raises `ValueError` on an integer key, which would turn an application's choice of payload shape
|
|
39
|
+
#: into a decode failure that kills the connection - muxws does not police what a payload contains.
|
|
40
|
+
#: `@msgpack/msgpack` accepts integer keys by default, so this is also what makes the two ports
|
|
41
|
+
#: agree about which payloads are decodable at all. They do *not* agree on the result: JavaScript
|
|
42
|
+
#: object keys are strings, so `{1: "a"}` comes back as `{1: "a"}` here and as `{"1": "a"}` there.
|
|
43
|
+
#: That divergence is reported in GAPS.md and is why no such payload is in the shared corpus.
|
|
44
|
+
#: - `use_list=True` decodes a msgpack array to a `list`, not a `tuple`. It is the default, and it is
|
|
45
|
+
#: load-bearing: with `use_list=False`, `decode(encode(frame)) == frame` fails for every payload
|
|
46
|
+
#: containing an array, which is exactly the assertion WSM-CDC-006 rests on.
|
|
47
|
+
_UNPACK_KWARGS: Final[dict[str, Any]] = {"raw": False, "strict_map_key": False, "use_list": True}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _unpack(data: str | bytes, what: str) -> Any:
|
|
51
|
+
"""Decode one msgpack value, turning every library failure into a `ProtocolError`.
|
|
52
|
+
|
|
53
|
+
A `str` never reaches a binary codec from a correct peer: `binary` is declared, and a peer picks
|
|
54
|
+
`send_bytes` from it rather than sniffing the encoded value (WSM-CDC-002). A text message
|
|
55
|
+
arriving here therefore means the remote sent one on a binary connection, and there is no
|
|
56
|
+
recoding that could recover the bytes - the transport has already lost them. Saying so is the
|
|
57
|
+
only honest answer (WSM-FRM-005).
|
|
58
|
+
"""
|
|
59
|
+
if isinstance(data, str):
|
|
60
|
+
raise ProtocolError(
|
|
61
|
+
f"the msgpack codec was handed a text {what}: msgpack is a binary codec and a text "
|
|
62
|
+
f"WebSocket message cannot carry it (WSM-CDC-002, WSM-FRM-005)"
|
|
63
|
+
)
|
|
64
|
+
try:
|
|
65
|
+
return msgpack.unpackb(data, **_UNPACK_KWARGS)
|
|
66
|
+
except (msgpack.UnpackException, ValueError, TypeError) as exc:
|
|
67
|
+
raise ProtocolError(f"the msgpack codec could not decode the {what}: {exc} (WSM-FRM-005)") from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class MsgpackCodec:
|
|
71
|
+
"""`Codec` implementation over the `msgpack` package."""
|
|
72
|
+
|
|
73
|
+
name = "msgpack"
|
|
74
|
+
#: Declared, never inferred (WSM-CDC-002). The peer reads this to choose `send_bytes`; it does
|
|
75
|
+
#: not look at what `encode` returned.
|
|
76
|
+
binary = True
|
|
77
|
+
|
|
78
|
+
def encode(self, frame: Frame) -> bytes:
|
|
79
|
+
return msgpack.packb(to_mapping(frame), **_PACK_KWARGS)
|
|
80
|
+
|
|
81
|
+
def decode(self, message: str | bytes) -> Frame:
|
|
82
|
+
mapping = _unpack(message, "message")
|
|
83
|
+
if not isinstance(mapping, dict):
|
|
84
|
+
raise ProtocolError(f"a frame must decode to a map, got {type(mapping).__name__} (WSM-FRM-005)")
|
|
85
|
+
return from_mapping(mapping)
|
|
86
|
+
|
|
87
|
+
def encode_payload(self, payload: Any) -> bytes:
|
|
88
|
+
return msgpack.packb(payload, **_PACK_KWARGS)
|
|
89
|
+
|
|
90
|
+
def decode_payload(self, data: str | bytes) -> Any:
|
|
91
|
+
return _unpack(data, "reassembled payload")
|
muxws/conf.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Deployment configuration (§2.2, WSM-CDC-010/011).
|
|
2
|
+
|
|
3
|
+
The codec name is read from the environment, not from a call argument, because both ends of a
|
|
4
|
+
connection must agree on it and an argument is decided per call site rather than per deployment.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Settings:
|
|
13
|
+
"""The one settings singleton. Writable, so an application may set it during bootstrap."""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self.codec: str = os.environ.get("MUXWS_CODEC", "json")
|
|
17
|
+
|
|
18
|
+
def reload(self) -> None:
|
|
19
|
+
"""Re-read the environment. For tests; an application sets `codec` directly."""
|
|
20
|
+
self.codec = os.environ.get("MUXWS_CODEC", "json")
|
|
21
|
+
|
|
22
|
+
def __repr__(self) -> str:
|
|
23
|
+
return f"Settings(codec={self.codec!r})"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
#: Read at connection time, never at import time - or WSM-CDC-011's "an application may set it
|
|
27
|
+
#: during bootstrap" stops being true for anything that imports muxws early.
|
|
28
|
+
settings = Settings()
|