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,462 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from contextlib import suppress
5
+ from dataclasses import dataclass, field
6
+ from time import monotonic
7
+
8
+ from tigrcorn_asgi.events.websocket import (
9
+ websocket_connect,
10
+ websocket_disconnect,
11
+ websocket_receive_bytes,
12
+ websocket_receive_text,
13
+ )
14
+ from tigrcorn_asgi.receive import QueueReceive
15
+ from tigrcorn_asgi.scopes.websocket import build_websocket_scope
16
+ from tigrcorn_config.model import ServerConfig
17
+ from tigrcorn_core.errors import ProtocolError
18
+ from tigrcorn_observability.logging import AccessLogger
19
+ from tigrcorn_observability.metrics import Metrics
20
+ from tigrcorn_protocols.http1.serializer import serialize_http11_response_head, serialize_http11_response_whole
21
+ from tigrcorn_protocols.websocket.codec import binary_frame, close_frame, pong_frame, text_frame
22
+ from tigrcorn_protocols.websocket.frames import serialize_frame
23
+ from tigrcorn_protocols.websocket.frames import (
24
+ OP_BINARY,
25
+ OP_CLOSE,
26
+ OP_CONT,
27
+ OP_PING,
28
+ OP_PONG,
29
+ OP_TEXT,
30
+ decode_close_payload,
31
+ read_frame,
32
+ )
33
+ from tigrcorn_protocols.websocket.extensions import PerMessageDeflateRuntime, default_permessage_deflate_agreement, negotiate_permessage_deflate, parse_permessage_deflate_offers
34
+ from tigrcorn_protocols.websocket.handshake import build_handshake_response, validate_client_handshake
35
+ from tigrcorn_protocols.flow.keepalive import KeepAlivePolicy, KeepAliveRuntime
36
+ from tigrcorn_core.types import ASGIApp
37
+ from tigrcorn_core.utils.headers import get_header
38
+
39
+
40
+ class _WebSocketCloseSignal(Exception):
41
+ def __init__(self, code: int, reason: str) -> None:
42
+ super().__init__(reason)
43
+ self.code = code
44
+ self.reason = reason
45
+
46
+
47
+ @dataclass(slots=True)
48
+ class _WSAppSend:
49
+ writer: asyncio.StreamWriter
50
+ server_header: bytes | None
51
+ state: dict
52
+ accepted: asyncio.Event
53
+ allowed_subprotocols: list[str] = field(default_factory=list)
54
+ include_date_header: bool = True
55
+ default_headers: list[tuple[bytes, bytes]] = field(default_factory=list)
56
+ config: ServerConfig | None = None
57
+ write_lock: asyncio.Lock | None = None
58
+ keepalive: KeepAliveRuntime | None = None
59
+
60
+ async def _write(self, data: bytes) -> None:
61
+ if self.write_lock is None:
62
+ self.writer.write(data)
63
+ self._record_activity()
64
+ return
65
+ async with self.write_lock:
66
+ self.writer.write(data)
67
+ await self.writer.drain()
68
+
69
+ def _record_activity(self) -> None:
70
+ if self.keepalive is not None:
71
+ self.keepalive.record_activity()
72
+
73
+ async def __call__(self, message: dict) -> None:
74
+ typ = message['type']
75
+ if typ == 'websocket.accept':
76
+ if self.state['accepted'] or self.state['http_denied']:
77
+ raise RuntimeError('websocket.accept sent more than once')
78
+ subprotocol = message.get('subprotocol')
79
+ if subprotocol is not None and subprotocol not in self.allowed_subprotocols:
80
+ raise RuntimeError('websocket.accept selected a subprotocol not offered by the client')
81
+ headers = [(bytes(k).lower(), bytes(v)) for k, v in message.get('headers', [])]
82
+ if get_header(headers, b'sec-websocket-extensions') is not None:
83
+ raise RuntimeError('websocket.accept must not override extension negotiation headers directly')
84
+ compression_mode = self.config.websocket.compression if self.config is not None else 'off'
85
+ if compression_mode == 'permessage-deflate' and self.state.get('permessage_deflate_offers'):
86
+ default_agreement = default_permessage_deflate_agreement(self.state.get('permessage_deflate_offers') or [])
87
+ if default_agreement is not None:
88
+ headers = headers + [(b'sec-websocket-extensions', default_agreement.as_header_value())]
89
+ negotiated_extensions: list[tuple[bytes, bytes]] = []
90
+ agreement = negotiate_permessage_deflate(
91
+ request_headers=self.state.get('request_headers', []),
92
+ response_headers=headers,
93
+ )
94
+ if agreement is not None:
95
+ negotiated_extensions.append((b'sec-websocket-extensions', agreement.as_header_value()))
96
+ self.state['permessage_deflate_runtime'] = PerMessageDeflateRuntime(agreement)
97
+ if get_header(headers, b'sec-websocket-protocol') is not None:
98
+ raise RuntimeError('use websocket.accept subprotocol instead of sec-websocket-protocol response headers')
99
+ payload = build_handshake_response(
100
+ self.state['sec_websocket_key'],
101
+ subprotocol=subprotocol,
102
+ headers=[(k, v) for k, v in headers if k != b'sec-websocket-extensions'] + negotiated_extensions,
103
+ server_header=self.server_header,
104
+ include_date_header=self.include_date_header,
105
+ default_headers=self.default_headers,
106
+ )
107
+ await self._write(payload)
108
+ self._record_activity()
109
+ self.state['accepted'] = True
110
+ self.accepted.set()
111
+ return
112
+ if typ == 'websocket.send':
113
+ if not self.state['accepted']:
114
+ raise RuntimeError('websocket.send before websocket.accept')
115
+ if self.state['closed']:
116
+ return
117
+ text = message.get('text')
118
+ data = message.get('bytes')
119
+ if text is not None and data is not None:
120
+ raise RuntimeError('websocket.send cannot contain both text and bytes')
121
+ if text is not None:
122
+ runtime = self.state.get('permessage_deflate_runtime')
123
+ if runtime is not None:
124
+ await self._write(serialize_frame(OP_TEXT, runtime.compress_message(text.encode('utf-8')), rsv1=True))
125
+ else:
126
+ await self._write(text_frame(text))
127
+ else:
128
+ raw = data or b''
129
+ runtime = self.state.get('permessage_deflate_runtime')
130
+ if runtime is not None:
131
+ await self._write(binary_frame(runtime.compress_message(raw), rsv1=True))
132
+ else:
133
+ await self._write(binary_frame(raw))
134
+ self._record_activity()
135
+ return
136
+ if typ == 'websocket.close':
137
+ code = int(message.get('code', 1000))
138
+ reason = message.get('reason', '')
139
+ if not self.state['accepted']:
140
+ await self._write(
141
+ serialize_http11_response_whole(
142
+ status=403,
143
+ headers=[],
144
+ body=b'',
145
+ keep_alive=False,
146
+ server_header=self.server_header,
147
+ include_date_header=self.include_date_header,
148
+ default_headers=self.default_headers,
149
+ )
150
+ )
151
+ self.state['http_denied'] = True
152
+ self.state['closed'] = True
153
+ return
154
+ if not self.state['closed']:
155
+ await self._write(close_frame(code, reason))
156
+ self.state['closed'] = True
157
+ return
158
+ if typ == 'websocket.http.response.start':
159
+ if self.state['accepted']:
160
+ raise RuntimeError('cannot send websocket.http.response.start after accept')
161
+ self.state['http_denial_status'] = int(message['status'])
162
+ self.state['http_denial_headers'] = list(message.get('headers', []))
163
+ self.state['http_denied'] = True
164
+ return
165
+ if typ == 'websocket.http.response.body':
166
+ if not self.state['http_denied']:
167
+ raise RuntimeError('websocket.http.response.body before denial start')
168
+ body = message.get('body', b'')
169
+ more = bool(message.get('more_body', False))
170
+ if not self.state['http_denial_started']:
171
+ if more:
172
+ head = serialize_http11_response_head(
173
+ status=self.state['http_denial_status'],
174
+ headers=self.state['http_denial_headers'],
175
+ keep_alive=False,
176
+ server_header=self.server_header,
177
+ chunked=True,
178
+ include_date_header=self.include_date_header,
179
+ default_headers=self.default_headers,
180
+ )
181
+ await self._write(head + (f'{len(body):X}'.encode('ascii') + b'\r\n' + body + b'\r\n' if body else b''))
182
+ else:
183
+ await self._write(
184
+ serialize_http11_response_whole(
185
+ status=self.state['http_denial_status'],
186
+ headers=self.state['http_denial_headers'],
187
+ body=body,
188
+ keep_alive=False,
189
+ server_header=self.server_header,
190
+ )
191
+ )
192
+ self.state['closed'] = True
193
+ self.state['http_denial_started'] = True
194
+ else:
195
+ if body:
196
+ await self._write(f'{len(body):X}'.encode('ascii') + b'\r\n' + body + b'\r\n')
197
+ if not more:
198
+ await self._write(b'0\r\n\r\n')
199
+ self.state['closed'] = True
200
+ self._record_activity()
201
+ return
202
+ raise RuntimeError(f'unexpected websocket send message: {typ!r}')
203
+
204
+
205
+ class WebSocketConnectionHandler:
206
+ def __init__(
207
+ self,
208
+ *,
209
+ app: ASGIApp,
210
+ config: ServerConfig,
211
+ access_logger: AccessLogger,
212
+ request,
213
+ reader,
214
+ writer,
215
+ client,
216
+ server,
217
+ scheme: str,
218
+ scope_extensions: dict | None = None,
219
+ metrics: Metrics | None = None,
220
+ ) -> None:
221
+ self.app = app
222
+ self.config = config
223
+ self.access_logger = access_logger
224
+ self.request = request
225
+ self.reader = reader
226
+ self.writer = writer
227
+ self.client = client
228
+ self.server = server
229
+ self.scheme = scheme
230
+ self.scope_extensions = dict(scope_extensions or {})
231
+ self.metrics = metrics
232
+ self.receive = QueueReceive(max_size=self.config.websocket.max_queue)
233
+ self.accepted = asyncio.Event()
234
+ self.write_lock = asyncio.Lock()
235
+ self.keepalive_policy = KeepAlivePolicy(
236
+ idle_timeout=self.config.http.idle_timeout,
237
+ ping_interval=self.config.websocket.ping_interval,
238
+ ping_timeout=self.config.websocket.ping_timeout,
239
+ )
240
+ self.keepalive = KeepAliveRuntime(self.keepalive_policy) if self.keepalive_policy.enabled else None
241
+ self.keepalive_task: asyncio.Task[None] | None = None
242
+ self.state = {
243
+ 'accepted': False,
244
+ 'closed': False,
245
+ 'http_denied': False,
246
+ 'http_denial_status': 403,
247
+ 'http_denial_headers': [],
248
+ 'http_denial_started': False,
249
+ 'sec_websocket_key': validate_client_handshake(request.headers),
250
+ 'request_headers': request.headers,
251
+ 'permessage_deflate_offers': parse_permessage_deflate_offers(request.headers),
252
+ 'permessage_deflate_runtime': None,
253
+ }
254
+ self.send = _WSAppSend(
255
+ writer=writer,
256
+ server_header=config.server_header_value,
257
+ state=self.state,
258
+ accepted=self.accepted,
259
+ allowed_subprotocols=build_websocket_scope(
260
+ self.request,
261
+ client=self.client,
262
+ server=self.server,
263
+ scheme=self.scheme,
264
+ extensions=self.scope_extensions,
265
+ root_path=self.config.proxy.root_path,
266
+ proxy=self.config.proxy,
267
+ )['subprotocols'],
268
+ include_date_header=config.include_date_header,
269
+ default_headers=list(config.default_response_headers),
270
+ config=config,
271
+ write_lock=self.write_lock,
272
+ keepalive=self.keepalive,
273
+ )
274
+
275
+ async def handle(self) -> None:
276
+ scope = build_websocket_scope(
277
+ self.request,
278
+ client=self.client,
279
+ server=self.server,
280
+ scheme=self.scheme,
281
+ extensions=self.scope_extensions,
282
+ root_path=self.config.proxy.root_path,
283
+ proxy=self.config.proxy,
284
+ )
285
+ self.send.allowed_subprotocols = scope['subprotocols']
286
+ await self.receive.put(websocket_connect())
287
+ reader_task = asyncio.create_task(self._frame_reader(), name='tigrcorn-ws-reader')
288
+ if self.keepalive is not None:
289
+ self.keepalive_task = asyncio.create_task(self._keepalive_loop(), name='tigrcorn-ws-keepalive')
290
+ try:
291
+ await self.app(scope, self.receive, self.send)
292
+ except Exception:
293
+ if self.state['accepted'] and not self.state['closed']:
294
+ with suppress(Exception):
295
+ await self._write(close_frame(1011, 'internal error'))
296
+ raise
297
+ finally:
298
+ if not self.state['accepted'] and not self.state['http_denied']:
299
+ await self._write(
300
+ serialize_http11_response_whole(
301
+ status=403,
302
+ headers=[],
303
+ body=b'',
304
+ keep_alive=False,
305
+ server_header=self.config.server_header_value,
306
+ include_date_header=self.config.include_date_header,
307
+ default_headers=self.config.default_response_headers,
308
+ )
309
+ )
310
+ self.state['closed'] = True
311
+ elif self.state['http_denied'] and not self.state['http_denial_started']:
312
+ await self._write(
313
+ serialize_http11_response_whole(
314
+ status=self.state['http_denial_status'],
315
+ headers=self.state['http_denial_headers'],
316
+ body=b'',
317
+ keep_alive=False,
318
+ server_header=self.config.server_header_value,
319
+ include_date_header=self.config.include_date_header,
320
+ default_headers=self.config.default_response_headers,
321
+ )
322
+ )
323
+ self.state['closed'] = True
324
+ elif self.state['accepted'] and not self.state['closed']:
325
+ await self._write(close_frame(1000, ''))
326
+ self.state['closed'] = True
327
+ if self.keepalive_task is not None:
328
+ self.keepalive_task.cancel()
329
+ with suppress(Exception):
330
+ await self.keepalive_task
331
+ reader_task.cancel()
332
+ with suppress(Exception):
333
+ await reader_task
334
+ self.access_logger.log_ws(self.client, self.request.path, 'accepted' if self.state['accepted'] else 'denied')
335
+
336
+ async def _write(self, data: bytes) -> None:
337
+ async with self.write_lock:
338
+ self.writer.write(data)
339
+ await self.writer.drain()
340
+
341
+ def _record_activity(self) -> None:
342
+ if self.keepalive is not None:
343
+ self.keepalive.record_activity()
344
+
345
+ async def _keepalive_loop(self) -> None:
346
+ await self.accepted.wait()
347
+ while not self.state['closed']:
348
+ await asyncio.sleep(0.05)
349
+ if self.keepalive is None or self.state['closed']:
350
+ return
351
+ if self.keepalive.ping_timed_out():
352
+ if self.metrics is not None:
353
+ self.metrics.websocket_ping_timeout()
354
+ await self._fail_connection(1011, 'ping timeout')
355
+ return
356
+ payload = self.keepalive.next_ping_payload()
357
+ if payload is None:
358
+ continue
359
+ if self.metrics is not None:
360
+ self.metrics.websocket_ping_sent()
361
+ await self._write(serialize_frame(OP_PING, payload))
362
+
363
+ def _ensure_message_size(self, size: int) -> None:
364
+ if size > self.config.websocket_max_message_size:
365
+ raise _WebSocketCloseSignal(1009, 'message too big')
366
+
367
+ async def _fail_connection(self, code: int, reason: str) -> None:
368
+ if not self.state['closed']:
369
+ await self._write(close_frame(code, reason))
370
+ await self.receive.put(websocket_disconnect(code, reason))
371
+ self.state['closed'] = True
372
+
373
+ async def _frame_reader(self) -> None:
374
+ await self.accepted.wait()
375
+ fragmented_opcode: int | None = None
376
+ fragments: list[bytes] = []
377
+ fragmented_compressed = False
378
+ current_message_size = 0
379
+ while not self.state['closed']:
380
+ try:
381
+ frame = await read_frame(
382
+ self.reader,
383
+ max_payload_size=self.config.websocket_max_message_size,
384
+ allow_rsv1=self.state.get('permessage_deflate_runtime') is not None,
385
+ )
386
+ self._record_activity()
387
+ if frame.opcode == OP_PING:
388
+ await self._write(pong_frame(frame.payload))
389
+ continue
390
+ if frame.opcode == OP_PONG:
391
+ if self.keepalive is not None:
392
+ self.keepalive.acknowledge_pong(frame.payload)
393
+ continue
394
+ if frame.opcode == OP_CLOSE:
395
+ code, reason = decode_close_payload(frame.payload)
396
+ if not self.state['closed']:
397
+ await self._write(close_frame(code, reason))
398
+ self.state['closed'] = True
399
+ await self.receive.put(websocket_disconnect(code, reason))
400
+ return
401
+
402
+ opcode = frame.opcode
403
+ if opcode in {OP_TEXT, OP_BINARY}:
404
+ if fragmented_opcode is not None:
405
+ raise ProtocolError('new data frame before fragmented message completion')
406
+ current_message_size = len(frame.payload)
407
+ self._ensure_message_size(current_message_size)
408
+ fragmented_opcode = opcode if not frame.fin else None
409
+ fragmented_compressed = frame.rsv1
410
+ if frame.fin:
411
+ runtime = self.state.get('permessage_deflate_runtime')
412
+ payload = runtime.decompress_message(frame.payload) if frame.rsv1 and runtime is not None else frame.payload
413
+ await self._deliver_message(opcode, payload)
414
+ current_message_size = 0
415
+ else:
416
+ fragments = [frame.payload]
417
+ continue
418
+ if opcode == OP_CONT:
419
+ if fragmented_opcode is None:
420
+ raise ProtocolError('unexpected continuation frame')
421
+ if frame.rsv1:
422
+ raise ProtocolError('RSV1 is only valid on the first frame of a compressed message')
423
+ current_message_size += len(frame.payload)
424
+ self._ensure_message_size(current_message_size)
425
+ fragments.append(frame.payload)
426
+ if frame.fin:
427
+ payload = b''.join(fragments)
428
+ if fragmented_compressed:
429
+ runtime = self.state.get('permessage_deflate_runtime')
430
+ if runtime is None:
431
+ raise ProtocolError('RSV1 is not negotiated')
432
+ payload = runtime.decompress_message(payload)
433
+ opcode = fragmented_opcode
434
+ fragmented_opcode = None
435
+ fragments = []
436
+ fragmented_compressed = False
437
+ current_message_size = 0
438
+ await self._deliver_message(opcode, payload)
439
+ continue
440
+ raise ProtocolError('unsupported websocket opcode')
441
+ except asyncio.CancelledError:
442
+ raise
443
+ except _WebSocketCloseSignal as exc:
444
+ await self._fail_connection(exc.code, exc.reason)
445
+ return
446
+ except ProtocolError:
447
+ await self._fail_connection(1002, 'protocol error')
448
+ return
449
+ except Exception:
450
+ await self.receive.put(websocket_disconnect(1006, ''))
451
+ self.state['closed'] = True
452
+ return
453
+
454
+ async def _deliver_message(self, opcode: int, payload: bytes) -> None:
455
+ if opcode == OP_TEXT:
456
+ try:
457
+ text = payload.decode('utf-8', 'strict')
458
+ except UnicodeDecodeError as exc:
459
+ raise _WebSocketCloseSignal(1007, 'invalid frame payload data') from exc
460
+ await self.receive.put(websocket_receive_text(text))
461
+ return
462
+ await self.receive.put(websocket_receive_bytes(payload))
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+
6
+ from tigrcorn_core.errors import ProtocolError
7
+ from tigrcorn_core.utils.headers import apply_response_header_policy, get_header, header_contains_token
8
+
9
+ _MAGIC = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
10
+
11
+
12
+ def is_websocket_upgrade(method: str, headers: list[tuple[bytes, bytes]]) -> bool:
13
+ return (
14
+ method.upper() == "GET"
15
+ and header_contains_token(headers, b"connection", b"upgrade")
16
+ and header_contains_token(headers, b"upgrade", b"websocket")
17
+ )
18
+
19
+
20
+ def websocket_accept_value(sec_websocket_key: bytes) -> bytes:
21
+ sha = hashlib.sha1(sec_websocket_key + _MAGIC).digest()
22
+ return base64.b64encode(sha)
23
+
24
+
25
+ def validate_client_handshake(headers: list[tuple[bytes, bytes]]) -> bytes:
26
+ version = get_header(headers, b"sec-websocket-version")
27
+ if version != b"13":
28
+ raise ProtocolError("unsupported websocket version")
29
+ key = get_header(headers, b"sec-websocket-key")
30
+ if not key:
31
+ raise ProtocolError("missing Sec-WebSocket-Key")
32
+ try:
33
+ decoded = base64.b64decode(key, validate=True)
34
+ except Exception as exc:
35
+ raise ProtocolError("invalid Sec-WebSocket-Key") from exc
36
+ if len(decoded) != 16:
37
+ raise ProtocolError("invalid Sec-WebSocket-Key length")
38
+ return key
39
+
40
+
41
+ def build_handshake_response(
42
+ sec_websocket_key: bytes,
43
+ *,
44
+ subprotocol: str | None = None,
45
+ headers: list[tuple[bytes, bytes]] | None = None,
46
+ server_header: bytes | None = None,
47
+ include_date_header: bool = True,
48
+ default_headers: list[tuple[bytes, bytes]] | tuple[tuple[bytes, bytes], ...] = (),
49
+ ) -> bytes:
50
+ response_headers = [
51
+ (b"upgrade", b"websocket"),
52
+ (b"connection", b"Upgrade"),
53
+ (b"sec-websocket-accept", websocket_accept_value(sec_websocket_key)),
54
+ ]
55
+ if subprotocol:
56
+ response_headers.append((b"sec-websocket-protocol", subprotocol.encode("ascii")))
57
+ if headers:
58
+ response_headers.extend([(k.lower(), v) for k, v in headers])
59
+ response_headers = apply_response_header_policy(
60
+ response_headers,
61
+ server_header=server_header,
62
+ include_date_header=include_date_header,
63
+ default_headers=default_headers,
64
+ )
65
+ lines = [b"HTTP/1.1 101 Switching Protocols"] + [k + b": " + v for k, v in response_headers]
66
+ return b"\r\n".join(lines) + b"\r\n\r\n"
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(slots=True)
7
+ class WebSocketState:
8
+ accepted: bool = False
9
+ close_sent: bool = False
10
+ close_received: bool = False