tigrcorn-protocols 0.3.16.dev5__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.
Files changed (80) hide show
  1. tigrcorn_protocols/__init__.py +1 -0
  2. tigrcorn_protocols/_compression.py +219 -0
  3. tigrcorn_protocols/connect.py +107 -0
  4. tigrcorn_protocols/content_coding.py +179 -0
  5. tigrcorn_protocols/custom/__init__.py +3 -0
  6. tigrcorn_protocols/custom/adapters.py +18 -0
  7. tigrcorn_protocols/custom/registry.py +15 -0
  8. tigrcorn_protocols/flow/__init__.py +1 -0
  9. tigrcorn_protocols/flow/backpressure.py +17 -0
  10. tigrcorn_protocols/flow/buffers.py +29 -0
  11. tigrcorn_protocols/flow/credits.py +21 -0
  12. tigrcorn_protocols/flow/keepalive.py +85 -0
  13. tigrcorn_protocols/flow/timeouts.py +17 -0
  14. tigrcorn_protocols/flow/watermarks.py +16 -0
  15. tigrcorn_protocols/http1/__init__.py +16 -0
  16. tigrcorn_protocols/http1/keepalive.py +21 -0
  17. tigrcorn_protocols/http1/parser.py +481 -0
  18. tigrcorn_protocols/http1/serializer.py +198 -0
  19. tigrcorn_protocols/http1/state.py +9 -0
  20. tigrcorn_protocols/http2/__init__.py +16 -0
  21. tigrcorn_protocols/http2/codec.py +266 -0
  22. tigrcorn_protocols/http2/flow.py +35 -0
  23. tigrcorn_protocols/http2/handler.py +1303 -0
  24. tigrcorn_protocols/http2/hpack.py +393 -0
  25. tigrcorn_protocols/http2/state.py +226 -0
  26. tigrcorn_protocols/http2/streams.py +76 -0
  27. tigrcorn_protocols/http2/websocket.py +360 -0
  28. tigrcorn_protocols/http3/__init__.py +82 -0
  29. tigrcorn_protocols/http3/codec.py +148 -0
  30. tigrcorn_protocols/http3/handler/__init__.py +3 -0
  31. tigrcorn_protocols/http3/handler/core.py +1823 -0
  32. tigrcorn_protocols/http3/handler/webtransport.py +184 -0
  33. tigrcorn_protocols/http3/handler.py +3 -0
  34. tigrcorn_protocols/http3/qpack.py +843 -0
  35. tigrcorn_protocols/http3/state.py +129 -0
  36. tigrcorn_protocols/http3/streams.py +657 -0
  37. tigrcorn_protocols/http3/websocket.py +360 -0
  38. tigrcorn_protocols/lifespan/__init__.py +3 -0
  39. tigrcorn_protocols/lifespan/driver.py +83 -0
  40. tigrcorn_protocols/py.typed +1 -0
  41. tigrcorn_protocols/rawframed/__init__.py +5 -0
  42. tigrcorn_protocols/rawframed/codec.py +18 -0
  43. tigrcorn_protocols/rawframed/frames.py +28 -0
  44. tigrcorn_protocols/rawframed/handler.py +72 -0
  45. tigrcorn_protocols/rawframed/state.py +9 -0
  46. tigrcorn_protocols/registry.py +22 -0
  47. tigrcorn_protocols/scheduler/__init__.py +17 -0
  48. tigrcorn_protocols/scheduler/cancellation.py +40 -0
  49. tigrcorn_protocols/scheduler/dispatch.py +27 -0
  50. tigrcorn_protocols/scheduler/fairness.py +21 -0
  51. tigrcorn_protocols/scheduler/policy.py +12 -0
  52. tigrcorn_protocols/scheduler/priorities.py +8 -0
  53. tigrcorn_protocols/scheduler/quotas.py +19 -0
  54. tigrcorn_protocols/scheduler/runtime.py +156 -0
  55. tigrcorn_protocols/scheduler/tasks.py +31 -0
  56. tigrcorn_protocols/sessions/__init__.py +1 -0
  57. tigrcorn_protocols/sessions/base.py +16 -0
  58. tigrcorn_protocols/sessions/connection.py +12 -0
  59. tigrcorn_protocols/sessions/limits.py +12 -0
  60. tigrcorn_protocols/sessions/manager.py +31 -0
  61. tigrcorn_protocols/sessions/metadata.py +10 -0
  62. tigrcorn_protocols/sessions/quic.py +14 -0
  63. tigrcorn_protocols/streams/__init__.py +1 -0
  64. tigrcorn_protocols/streams/base.py +13 -0
  65. tigrcorn_protocols/streams/ids.py +5 -0
  66. tigrcorn_protocols/streams/multiplex.py +6 -0
  67. tigrcorn_protocols/streams/registry.py +22 -0
  68. tigrcorn_protocols/streams/singleplex.py +6 -0
  69. tigrcorn_protocols/websocket/__init__.py +1 -0
  70. tigrcorn_protocols/websocket/codec.py +31 -0
  71. tigrcorn_protocols/websocket/extensions.py +324 -0
  72. tigrcorn_protocols/websocket/frames.py +174 -0
  73. tigrcorn_protocols/websocket/handler.py +462 -0
  74. tigrcorn_protocols/websocket/handshake.py +66 -0
  75. tigrcorn_protocols/websocket/state.py +10 -0
  76. tigrcorn_protocols-0.3.16.dev5.dist-info/METADATA +240 -0
  77. tigrcorn_protocols-0.3.16.dev5.dist-info/RECORD +80 -0
  78. tigrcorn_protocols-0.3.16.dev5.dist-info/WHEEL +5 -0
  79. tigrcorn_protocols-0.3.16.dev5.dist-info/licenses/LICENSE +163 -0
  80. tigrcorn_protocols-0.3.16.dev5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,324 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import zlib
5
+
6
+ from tigrcorn_core.errors import ProtocolError
7
+ from tigrcorn_core.utils.headers import get_headers
8
+
9
+ _PERMESSAGE_DEFLATE = b"permessage-deflate"
10
+ _SERVER_NO_CONTEXT_TAKEOVER = b"server_no_context_takeover"
11
+ _CLIENT_NO_CONTEXT_TAKEOVER = b"client_no_context_takeover"
12
+ _SERVER_MAX_WINDOW_BITS = b"server_max_window_bits"
13
+ _CLIENT_MAX_WINDOW_BITS = b"client_max_window_bits"
14
+
15
+ _VALID_PMD_PARAMETERS = {
16
+ _SERVER_NO_CONTEXT_TAKEOVER,
17
+ _CLIENT_NO_CONTEXT_TAKEOVER,
18
+ _SERVER_MAX_WINDOW_BITS,
19
+ _CLIENT_MAX_WINDOW_BITS,
20
+ }
21
+
22
+
23
+ def _split_quoted(value: bytes, delimiter: int) -> list[bytes]:
24
+ parts: list[bytes] = []
25
+ buf = bytearray()
26
+ in_quote = False
27
+ escape = False
28
+ for byte in value:
29
+ if escape:
30
+ buf.append(byte)
31
+ escape = False
32
+ continue
33
+ if in_quote:
34
+ if byte == 0x5C: # backslash
35
+ escape = True
36
+ continue
37
+ if byte == 0x22:
38
+ in_quote = False
39
+ buf.append(byte)
40
+ continue
41
+ if byte == 0x22:
42
+ in_quote = True
43
+ buf.append(byte)
44
+ continue
45
+ if byte == delimiter:
46
+ part = bytes(buf).strip()
47
+ if part:
48
+ parts.append(part)
49
+ buf.clear()
50
+ continue
51
+ buf.append(byte)
52
+ if in_quote:
53
+ raise ProtocolError('malformed websocket extension header')
54
+ part = bytes(buf).strip()
55
+ if part:
56
+ parts.append(part)
57
+ return parts
58
+
59
+
60
+ def _parse_token_value(value: bytes) -> bytes:
61
+ raw = value.strip()
62
+ if len(raw) >= 2 and raw[:1] == raw[-1:] == b'"':
63
+ inner = raw[1:-1]
64
+ if b'"' in inner:
65
+ raise ProtocolError('malformed websocket extension parameter value')
66
+ return inner
67
+ return raw
68
+
69
+
70
+ def _parse_window_bits(value: bytes) -> int:
71
+ if not value or (len(value) > 1 and value.startswith(b'0')):
72
+ raise ProtocolError('invalid permessage-deflate window bits parameter')
73
+ try:
74
+ bits = int(value.decode('ascii', 'strict'))
75
+ except UnicodeDecodeError as exc:
76
+ raise ProtocolError('invalid permessage-deflate window bits parameter') from exc
77
+ except ValueError as exc:
78
+ raise ProtocolError('invalid permessage-deflate window bits parameter') from exc
79
+ if not 8 <= bits <= 15:
80
+ raise ProtocolError('invalid permessage-deflate window bits parameter')
81
+ return bits
82
+
83
+
84
+ @dataclass(slots=True, frozen=True)
85
+ class PerMessageDeflateOffer:
86
+ server_no_context_takeover: bool = False
87
+ client_no_context_takeover: bool = False
88
+ server_max_window_bits: int | None = None
89
+ client_max_window_bits_requested: bool = False
90
+ client_max_window_bits: int | None = None
91
+
92
+
93
+ @dataclass(slots=True, frozen=True)
94
+ class PerMessageDeflateAgreement:
95
+ server_no_context_takeover: bool = False
96
+ client_no_context_takeover: bool = False
97
+ server_max_window_bits: int | None = None
98
+ client_max_window_bits: int | None = None
99
+
100
+ def as_header_value(self) -> bytes:
101
+ params = [b'permessage-deflate']
102
+ if self.server_no_context_takeover:
103
+ params.append(_SERVER_NO_CONTEXT_TAKEOVER)
104
+ if self.client_no_context_takeover:
105
+ params.append(_CLIENT_NO_CONTEXT_TAKEOVER)
106
+ if self.server_max_window_bits is not None:
107
+ params.append(_SERVER_MAX_WINDOW_BITS + b'=' + str(self.server_max_window_bits).encode('ascii'))
108
+ if self.client_max_window_bits is not None:
109
+ params.append(_CLIENT_MAX_WINDOW_BITS + b'=' + str(self.client_max_window_bits).encode('ascii'))
110
+ return b'; '.join(params)
111
+
112
+
113
+ class PerMessageDeflateRuntime:
114
+ def __init__(self, agreement: PerMessageDeflateAgreement) -> None:
115
+ self.agreement = agreement
116
+ self._compressor: zlib.compressobj | None = None
117
+ self._decompressor: zlib.decompressobj | None = None
118
+
119
+ @staticmethod
120
+ def _runtime_window_bits(bits: int | None) -> int:
121
+ if bits is None:
122
+ return 15
123
+ # Python's raw DEFLATE bindings reject 8 here; fall back to the smallest
124
+ # supported raw window while preserving interoperable decompression.
125
+ return max(bits, 9)
126
+
127
+ def _new_compressor(self) -> zlib.compressobj:
128
+ return zlib.compressobj(wbits=-self._runtime_window_bits(self.agreement.server_max_window_bits))
129
+
130
+ def _new_decompressor(self) -> zlib.decompressobj:
131
+ return zlib.decompressobj(wbits=-self._runtime_window_bits(self.agreement.client_max_window_bits))
132
+
133
+ def compress_message(self, payload: bytes) -> bytes:
134
+ if self._compressor is None or self.agreement.server_no_context_takeover:
135
+ self._compressor = self._new_compressor()
136
+ compressed = self._compressor.compress(payload) + self._compressor.flush(zlib.Z_SYNC_FLUSH)
137
+ if not compressed.endswith(b'\x00\x00\xff\xff'):
138
+ raise RuntimeError('unexpected permessage-deflate trailer')
139
+ if self.agreement.server_no_context_takeover:
140
+ self._compressor = None
141
+ return compressed[:-4]
142
+
143
+ def decompress_message(self, payload: bytes) -> bytes:
144
+ if self._decompressor is None or self.agreement.client_no_context_takeover:
145
+ self._decompressor = self._new_decompressor()
146
+ try:
147
+ data = self._decompressor.decompress(payload + b'\x00\x00\xff\xff')
148
+ except zlib.error as exc:
149
+ raise ProtocolError('invalid permessage-deflate payload') from exc
150
+ if self._decompressor.unconsumed_tail or self._decompressor.unused_data:
151
+ raise ProtocolError('invalid permessage-deflate payload')
152
+ if self.agreement.client_no_context_takeover:
153
+ self._decompressor = None
154
+ return data
155
+
156
+
157
+ def _iter_extension_elements(values: list[bytes]) -> list[tuple[bytes, list[tuple[bytes, bytes | None]]]]:
158
+ joined = b', '.join(value.strip() for value in values if value.strip())
159
+ if not joined:
160
+ return []
161
+ elements: list[tuple[bytes, list[tuple[bytes, bytes | None]]]] = []
162
+ for item in _split_quoted(joined, 0x2C): # comma
163
+ parts = _split_quoted(item, 0x3B) # semicolon
164
+ if not parts:
165
+ continue
166
+ name = parts[0].strip().lower()
167
+ params: list[tuple[bytes, bytes | None]] = []
168
+ for raw_param in parts[1:]:
169
+ if not raw_param:
170
+ continue
171
+ if b'=' in raw_param:
172
+ raw_name, raw_value = raw_param.split(b'=', 1)
173
+ param_name = raw_name.strip().lower()
174
+ param_value = _parse_token_value(raw_value)
175
+ else:
176
+ param_name = raw_param.strip().lower()
177
+ param_value = None
178
+ if not param_name:
179
+ raise ProtocolError('malformed websocket extension parameter')
180
+ params.append((param_name, param_value))
181
+ elements.append((name, params))
182
+ return elements
183
+
184
+
185
+ def parse_permessage_deflate_offers(headers: list[tuple[bytes, bytes]]) -> list[PerMessageDeflateOffer]:
186
+ offers: list[PerMessageDeflateOffer] = []
187
+ for name, params in _iter_extension_elements(get_headers(headers, b'sec-websocket-extensions')):
188
+ if name != _PERMESSAGE_DEFLATE:
189
+ continue
190
+ try:
191
+ offers.append(_parse_offer_parameters(params))
192
+ except ProtocolError:
193
+ continue
194
+ return offers
195
+
196
+
197
+
198
+
199
+ def default_permessage_deflate_agreement(offers: list[PerMessageDeflateOffer]) -> PerMessageDeflateAgreement | None:
200
+ """Choose a default server agreement for a valid permessage-deflate offer set.
201
+
202
+ The server accepts the first valid offer and mirrors explicit window constraints so
203
+ the generated response header corresponds to the client offer across websocket
204
+ carriers, including HTTP/2 and HTTP/3 third-party clients that require explicit
205
+ parameter echoing.
206
+ """
207
+ if not offers:
208
+ return None
209
+ offer = offers[0]
210
+ return PerMessageDeflateAgreement(
211
+ server_no_context_takeover=offer.server_no_context_takeover,
212
+ client_no_context_takeover=False,
213
+ server_max_window_bits=offer.server_max_window_bits,
214
+ client_max_window_bits=offer.client_max_window_bits if offer.client_max_window_bits_requested else None,
215
+ )
216
+ def negotiate_permessage_deflate(
217
+ *,
218
+ request_headers: list[tuple[bytes, bytes]],
219
+ response_headers: list[tuple[bytes, bytes]],
220
+ ) -> PerMessageDeflateAgreement | None:
221
+ response_values = get_headers(response_headers, b'sec-websocket-extensions')
222
+ if not response_values:
223
+ return None
224
+ response_elements = _iter_extension_elements(response_values)
225
+ if len(response_elements) != 1 or response_elements[0][0] != _PERMESSAGE_DEFLATE:
226
+ raise RuntimeError('unsupported websocket extension negotiation')
227
+ agreement = _parse_response_parameters(response_elements[0][1])
228
+ offers = parse_permessage_deflate_offers(request_headers)
229
+ if not offers:
230
+ raise RuntimeError('websocket extension not offered by the client')
231
+ for offer in offers:
232
+ if _agreement_matches_offer(agreement, offer):
233
+ return agreement
234
+ raise RuntimeError('websocket extension negotiation does not correspond to a client offer')
235
+
236
+
237
+ def _parse_offer_parameters(params: list[tuple[bytes, bytes | None]]) -> PerMessageDeflateOffer:
238
+ server_no_context_takeover = False
239
+ client_no_context_takeover = False
240
+ server_max_window_bits: int | None = None
241
+ client_max_window_bits_requested = False
242
+ client_max_window_bits: int | None = None
243
+ seen: set[bytes] = set()
244
+ for name, value in params:
245
+ if name not in _VALID_PMD_PARAMETERS or name in seen:
246
+ raise ProtocolError('invalid permessage-deflate offer')
247
+ seen.add(name)
248
+ if name == _SERVER_NO_CONTEXT_TAKEOVER:
249
+ if value is not None:
250
+ raise ProtocolError('invalid permessage-deflate offer')
251
+ server_no_context_takeover = True
252
+ continue
253
+ if name == _CLIENT_NO_CONTEXT_TAKEOVER:
254
+ if value is not None:
255
+ raise ProtocolError('invalid permessage-deflate offer')
256
+ client_no_context_takeover = True
257
+ continue
258
+ if name == _SERVER_MAX_WINDOW_BITS:
259
+ if value is None:
260
+ raise ProtocolError('invalid permessage-deflate offer')
261
+ server_max_window_bits = _parse_window_bits(value)
262
+ continue
263
+ if name == _CLIENT_MAX_WINDOW_BITS:
264
+ client_max_window_bits_requested = True
265
+ if value is not None:
266
+ client_max_window_bits = _parse_window_bits(value)
267
+ continue
268
+ return PerMessageDeflateOffer(
269
+ server_no_context_takeover=server_no_context_takeover,
270
+ client_no_context_takeover=client_no_context_takeover,
271
+ server_max_window_bits=server_max_window_bits,
272
+ client_max_window_bits_requested=client_max_window_bits_requested,
273
+ client_max_window_bits=client_max_window_bits,
274
+ )
275
+
276
+
277
+ def _parse_response_parameters(params: list[tuple[bytes, bytes | None]]) -> PerMessageDeflateAgreement:
278
+ server_no_context_takeover = False
279
+ client_no_context_takeover = False
280
+ server_max_window_bits: int | None = None
281
+ client_max_window_bits: int | None = None
282
+ seen: set[bytes] = set()
283
+ for name, value in params:
284
+ if name not in _VALID_PMD_PARAMETERS or name in seen:
285
+ raise RuntimeError('unsupported websocket extension negotiation')
286
+ seen.add(name)
287
+ if name == _SERVER_NO_CONTEXT_TAKEOVER:
288
+ if value is not None:
289
+ raise RuntimeError('unsupported websocket extension negotiation')
290
+ server_no_context_takeover = True
291
+ continue
292
+ if name == _CLIENT_NO_CONTEXT_TAKEOVER:
293
+ if value is not None:
294
+ raise RuntimeError('unsupported websocket extension negotiation')
295
+ client_no_context_takeover = True
296
+ continue
297
+ if name == _SERVER_MAX_WINDOW_BITS:
298
+ if value is None:
299
+ raise RuntimeError('unsupported websocket extension negotiation')
300
+ server_max_window_bits = _parse_window_bits(value)
301
+ continue
302
+ if value is None:
303
+ raise RuntimeError('unsupported websocket extension negotiation')
304
+ client_max_window_bits = _parse_window_bits(value)
305
+ return PerMessageDeflateAgreement(
306
+ server_no_context_takeover=server_no_context_takeover,
307
+ client_no_context_takeover=client_no_context_takeover,
308
+ server_max_window_bits=server_max_window_bits,
309
+ client_max_window_bits=client_max_window_bits,
310
+ )
311
+
312
+
313
+ def _agreement_matches_offer(agreement: PerMessageDeflateAgreement, offer: PerMessageDeflateOffer) -> bool:
314
+ if offer.server_no_context_takeover and not agreement.server_no_context_takeover:
315
+ return False
316
+ if offer.server_max_window_bits is not None:
317
+ if agreement.server_max_window_bits is None or agreement.server_max_window_bits > offer.server_max_window_bits:
318
+ return False
319
+ if agreement.client_max_window_bits is not None:
320
+ if not offer.client_max_window_bits_requested:
321
+ return False
322
+ if offer.client_max_window_bits is not None and agreement.client_max_window_bits > offer.client_max_window_bits:
323
+ return False
324
+ return True
@@ -0,0 +1,174 @@
1
+ from __future__ import annotations
2
+
3
+ import struct
4
+ from dataclasses import dataclass
5
+
6
+ from tigrcorn_core.errors import ProtocolError
7
+ from tigrcorn_core.types import StreamReaderLike
8
+
9
+ OP_CONT = 0x0
10
+ OP_TEXT = 0x1
11
+ OP_BINARY = 0x2
12
+ OP_CLOSE = 0x8
13
+ OP_PING = 0x9
14
+ OP_PONG = 0xA
15
+ _CONTROL_OPCODES = {OP_CLOSE, OP_PING, OP_PONG}
16
+ _DATA_OPCODES = {OP_CONT, OP_TEXT, OP_BINARY}
17
+ _VALID_OPCODES = _CONTROL_OPCODES | _DATA_OPCODES
18
+ _FORBIDDEN_CLOSE_CODES = {1004, 1005, 1006, 1015}
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class Frame:
23
+ fin: bool
24
+ opcode: int
25
+ payload: bytes
26
+ rsv1: bool = False
27
+
28
+
29
+ def _mask_payload(mask_key: bytes, payload: bytes) -> bytes:
30
+ return bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload))
31
+
32
+
33
+ def validate_close_code(code: int) -> None:
34
+ if code < 1000 or code >= 5000:
35
+ raise ProtocolError('invalid close code')
36
+ if code in _FORBIDDEN_CLOSE_CODES:
37
+ raise ProtocolError('invalid close code')
38
+ if 1016 <= code <= 2999:
39
+ raise ProtocolError('invalid close code')
40
+
41
+
42
+ def _validate_frame_semantics(fin: bool, opcode: int, payload_length: int) -> None:
43
+ if opcode not in _VALID_OPCODES:
44
+ raise ProtocolError('unsupported websocket opcode')
45
+ if opcode in _CONTROL_OPCODES:
46
+ if not fin:
47
+ raise ProtocolError('control frames must not be fragmented')
48
+ if payload_length > 125:
49
+ raise ProtocolError('control frame payload too large')
50
+
51
+
52
+ def parse_frame_bytes(data: bytes, *, expect_masked: bool = False, max_payload_size: int | None = None, allow_rsv1: bool = False) -> Frame:
53
+ if len(data) < 2:
54
+ raise ProtocolError('incomplete websocket frame')
55
+ pos = 0
56
+ b1, b2 = data[pos], data[pos + 1]
57
+ pos += 2
58
+ fin = bool(b1 & 0x80)
59
+ rsv1 = bool(b1 & 0x40)
60
+ rsv = b1 & 0x70
61
+ opcode = b1 & 0x0F
62
+ masked = bool(b2 & 0x80)
63
+ length = b2 & 0x7F
64
+ if rsv & 0x30:
65
+ raise ProtocolError('RSV2/RSV3 bits are not supported')
66
+ if rsv1 and not allow_rsv1:
67
+ raise ProtocolError('RSV1 is not negotiated')
68
+ if expect_masked and not masked:
69
+ raise ProtocolError('client websocket frames must be masked')
70
+ if length == 126:
71
+ if len(data) < pos + 2:
72
+ raise ProtocolError('incomplete websocket frame')
73
+ length = struct.unpack('!H', data[pos : pos + 2])[0]
74
+ pos += 2
75
+ elif length == 127:
76
+ if len(data) < pos + 8:
77
+ raise ProtocolError('incomplete websocket frame')
78
+ length = struct.unpack('!Q', data[pos : pos + 8])[0]
79
+ pos += 8
80
+ _validate_frame_semantics(fin, opcode, length)
81
+ if max_payload_size is not None and length > max_payload_size:
82
+ raise ProtocolError('websocket frame exceeds configured max payload size')
83
+ mask_key = b''
84
+ if masked:
85
+ if len(data) < pos + 4:
86
+ raise ProtocolError('incomplete websocket frame')
87
+ mask_key = data[pos : pos + 4]
88
+ pos += 4
89
+ if len(data) < pos + length:
90
+ raise ProtocolError('incomplete websocket frame')
91
+ payload = data[pos : pos + length]
92
+ if masked:
93
+ payload = _mask_payload(mask_key, payload)
94
+ return Frame(fin=fin, opcode=opcode, payload=payload, rsv1=rsv1)
95
+
96
+
97
+ async def read_frame(reader: StreamReaderLike, *, max_payload_size: int, expect_masked: bool = True, allow_rsv1: bool = False) -> Frame:
98
+ header = await reader.readexactly(2)
99
+ b1, b2 = header[0], header[1]
100
+ fin = bool(b1 & 0x80)
101
+ rsv1 = bool(b1 & 0x40)
102
+ rsv = b1 & 0x70
103
+ opcode = b1 & 0x0F
104
+ masked = bool(b2 & 0x80)
105
+ length = b2 & 0x7F
106
+ if rsv & 0x30:
107
+ raise ProtocolError('RSV2/RSV3 bits are not supported')
108
+ if rsv1 and not allow_rsv1:
109
+ raise ProtocolError('RSV1 is not negotiated')
110
+ if expect_masked and not masked:
111
+ raise ProtocolError('client websocket frames must be masked')
112
+ if length == 126:
113
+ length = struct.unpack('!H', await reader.readexactly(2))[0]
114
+ elif length == 127:
115
+ length = struct.unpack('!Q', await reader.readexactly(8))[0]
116
+ _validate_frame_semantics(fin, opcode, length)
117
+ if length > max_payload_size:
118
+ raise ProtocolError('websocket frame exceeds configured max payload size')
119
+ if masked:
120
+ mask_key = await reader.readexactly(4)
121
+ payload = await reader.readexactly(length)
122
+ payload = _mask_payload(mask_key, payload)
123
+ else:
124
+ payload = await reader.readexactly(length)
125
+ return Frame(fin=fin, opcode=opcode, payload=payload, rsv1=rsv1)
126
+
127
+
128
+ def serialize_frame(opcode: int, payload: bytes = b'', *, fin: bool = True, mask: bool = False, mask_key: bytes = b'\x00\x00\x00\x00', rsv1: bool = False) -> bytes:
129
+ _validate_frame_semantics(fin, opcode, len(payload))
130
+ first = opcode | (0x80 if fin else 0) | (0x40 if rsv1 else 0)
131
+ length = len(payload)
132
+ mask_bit = 0x80 if mask else 0
133
+ if length < 126:
134
+ head = bytes([first, mask_bit | length])
135
+ elif length <= 0xFFFF:
136
+ head = bytes([first, mask_bit | 126]) + struct.pack('!H', length)
137
+ else:
138
+ head = bytes([first, mask_bit | 127]) + struct.pack('!Q', length)
139
+ if not mask:
140
+ return head + payload
141
+ masked = _mask_payload(mask_key, payload)
142
+ return head + mask_key + masked
143
+
144
+
145
+ def encode_frame(opcode: int, payload: bytes = b'', *, fin: bool = True, masked: bool = False, mask_key: bytes = b'\x00\x00\x00\x00', rsv1: bool = False) -> bytes:
146
+ return serialize_frame(opcode, payload, fin=fin, mask=masked, mask_key=mask_key, rsv1=rsv1)
147
+
148
+
149
+ def decode_frame(data: bytes, *, expect_masked: bool = False, allow_rsv1: bool = False) -> Frame:
150
+ return parse_frame_bytes(data, expect_masked=expect_masked, allow_rsv1=allow_rsv1)
151
+
152
+
153
+ def encode_close_payload(code: int, reason: str = '') -> bytes:
154
+ validate_close_code(code)
155
+ encoded = reason.encode('utf-8')
156
+ if len(encoded) > 123:
157
+ raise ProtocolError('close reason too long')
158
+ return struct.pack('!H', code) + encoded if encoded or code != 1005 else b''
159
+
160
+
161
+ def decode_close_payload(payload: bytes) -> tuple[int, str]:
162
+ if not payload:
163
+ return 1005, ''
164
+ if len(payload) == 1:
165
+ raise ProtocolError('invalid close payload')
166
+ if len(payload) > 125:
167
+ raise ProtocolError('control frame payload too large')
168
+ code = struct.unpack('!H', payload[:2])[0]
169
+ validate_close_code(code)
170
+ try:
171
+ reason = payload[2:].decode('utf-8', 'strict')
172
+ except UnicodeDecodeError as exc:
173
+ raise ProtocolError('invalid close reason utf-8') from exc
174
+ return code, reason