@lovrozagar/honey 0.1.0

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 (131) hide show
  1. package/README.md +51 -0
  2. package/docs/api-ref/go.md +399 -0
  3. package/docs/api-ref/python.md +399 -0
  4. package/docs/api-ref/rust.md +399 -0
  5. package/docs/api-ref/typescript.md +399 -0
  6. package/docs/invalidate-spec.md +303 -0
  7. package/docs/sdk.md +722 -0
  8. package/package.json +120 -0
  9. package/src/accepts.ts +78 -0
  10. package/src/body-limit.ts +100 -0
  11. package/src/build/index.ts +174 -0
  12. package/src/cli-go/auth.go +15 -0
  13. package/src/cli-go/config.go +165 -0
  14. package/src/cli-go/errors.go +57 -0
  15. package/src/cli-go/multipart.go +55 -0
  16. package/src/cli-go/output.go +109 -0
  17. package/src/cli-go/root.go +30 -0
  18. package/src/cli-go/stream.go +88 -0
  19. package/src/cli-go/version.go +5 -0
  20. package/src/cli.ts +204 -0
  21. package/src/client/error.ts +27 -0
  22. package/src/client/http.ts +502 -0
  23. package/src/client/index.ts +127 -0
  24. package/src/client/sdk.ts +265 -0
  25. package/src/client/sse.ts +102 -0
  26. package/src/client/types.ts +211 -0
  27. package/src/client/ws.ts +115 -0
  28. package/src/client-go/errors.go +207 -0
  29. package/src/client-go/invalidation.go +373 -0
  30. package/src/client-go/realtime.go +317 -0
  31. package/src/client-go/result.go +20 -0
  32. package/src/client-go/runtime.go +413 -0
  33. package/src/client-go/sse.go +149 -0
  34. package/src/client-go/transport.go +452 -0
  35. package/src/client-go/ws.go +104 -0
  36. package/src/client-mcp/README.md +10 -0
  37. package/src/client-mcp/server.ts +94 -0
  38. package/src/client-mcp/types.ts +19 -0
  39. package/src/client-python/_errors.py +115 -0
  40. package/src/client-python/_invalidation.py +578 -0
  41. package/src/client-python/_realtime.py +156 -0
  42. package/src/client-python/_runtime.py +469 -0
  43. package/src/client-python/_sse.py +99 -0
  44. package/src/client-python/_transport.py +335 -0
  45. package/src/client-python/_ws.py +131 -0
  46. package/src/client-python/py.typed +0 -0
  47. package/src/client-rust/errors.rs +200 -0
  48. package/src/client-rust/invalidation.rs +441 -0
  49. package/src/client-rust/invalidation_sync.rs +306 -0
  50. package/src/client-rust/realtime.rs +695 -0
  51. package/src/client-rust/result.rs +22 -0
  52. package/src/client-rust/runtime.rs +547 -0
  53. package/src/client-rust/runtime_sync.rs +452 -0
  54. package/src/client-rust/sse.rs +167 -0
  55. package/src/client-rust/ws.rs +143 -0
  56. package/src/cloudflare/realtime.ts +236 -0
  57. package/src/codegen-go-cli.ts +1180 -0
  58. package/src/codegen-go.ts +1010 -0
  59. package/src/codegen-hash.ts +8 -0
  60. package/src/codegen-ir.ts +517 -0
  61. package/src/codegen-loaders.ts +53 -0
  62. package/src/codegen-mcp.ts +364 -0
  63. package/src/codegen-python.ts +1028 -0
  64. package/src/codegen-rust.ts +1652 -0
  65. package/src/codegen-sanitize.ts +67 -0
  66. package/src/codegen-schema-naming.ts +146 -0
  67. package/src/codegen.ts +5015 -0
  68. package/src/context.ts +210 -0
  69. package/src/cookie-sign.ts +58 -0
  70. package/src/cookie.ts +89 -0
  71. package/src/cors.ts +96 -0
  72. package/src/crypto.ts +19 -0
  73. package/src/csrf.ts +54 -0
  74. package/src/curl-logger.ts +235 -0
  75. package/src/detect-runtime.ts +19 -0
  76. package/src/error.ts +52 -0
  77. package/src/errors.ts +87 -0
  78. package/src/etag.ts +74 -0
  79. package/src/feature-load.ts +23 -0
  80. package/src/go-type-emitter.ts +404 -0
  81. package/src/honey-response.ts +354 -0
  82. package/src/i18n-slot.ts +20 -0
  83. package/src/i18n.ts +234 -0
  84. package/src/index.ts +3843 -0
  85. package/src/init.ts +149 -0
  86. package/src/input.ts +8 -0
  87. package/src/ip-restrict.ts +192 -0
  88. package/src/logger.ts +149 -0
  89. package/src/middleware.ts +199 -0
  90. package/src/node-request.ts +240 -0
  91. package/src/node.ts +212 -0
  92. package/src/openapi/register.ts +19 -0
  93. package/src/openapi/scalar.ts +33 -0
  94. package/src/openapi/spec-factory.ts +46 -0
  95. package/src/openapi/spec.ts +31 -0
  96. package/src/openapi/swagger.ts +34 -0
  97. package/src/plugin.ts +771 -0
  98. package/src/powered-by.ts +17 -0
  99. package/src/pretty-json.ts +39 -0
  100. package/src/proxy.ts +155 -0
  101. package/src/python-type-emitter.ts +144 -0
  102. package/src/realtime/buffer.ts +101 -0
  103. package/src/realtime/bus.ts +93 -0
  104. package/src/realtime/longpoll.ts +148 -0
  105. package/src/realtime/protocol.ts +128 -0
  106. package/src/realtime/route.ts +104 -0
  107. package/src/realtime/runtime.ts +430 -0
  108. package/src/request-id.ts +23 -0
  109. package/src/request-to-curl.ts +34 -0
  110. package/src/response.ts +414 -0
  111. package/src/rust-type-emitter.ts +412 -0
  112. package/src/secure-headers.ts +69 -0
  113. package/src/serve-register.ts +8 -0
  114. package/src/serve-slot.ts +20 -0
  115. package/src/serve.ts +141 -0
  116. package/src/server-timing.ts +66 -0
  117. package/src/static.ts +60 -0
  118. package/src/telemetry/otel.ts +139 -0
  119. package/src/testing.ts +114 -0
  120. package/src/timeout.ts +36 -0
  121. package/src/tree.ts +464 -0
  122. package/src/ts-type-emitter.ts +106 -0
  123. package/src/type-emitter.ts +524 -0
  124. package/src/type-extractor.ts +749 -0
  125. package/src/types.ts +638 -0
  126. package/src/validation.ts +300 -0
  127. package/src/ws/bun.ts +89 -0
  128. package/src/ws/cloudflare.ts +133 -0
  129. package/src/ws/deno.ts +109 -0
  130. package/src/ws/node.ts +161 -0
  131. package/src/yaml.ts +80 -0
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import AsyncIterator, TypedDict
5
+
6
+ import httpx
7
+
8
+
9
+ class SSEEvent(TypedDict, total=False):
10
+ data: str
11
+ event: str
12
+ id: str
13
+ retry: int
14
+
15
+
16
+ def _parse_sse_block(block: str) -> SSEEvent | None:
17
+ lines = block.splitlines()
18
+ is_comment = True
19
+ data: str | None = None
20
+ event: str | None = None
21
+ id_val: str | None = None
22
+ retry: int | None = None
23
+
24
+ for line in lines:
25
+ if line.startswith(":"):
26
+ continue
27
+ is_comment = False
28
+ colon_idx = line.find(":")
29
+ if colon_idx == -1:
30
+ field_name = line
31
+ val = ""
32
+ else:
33
+ field_name = line[:colon_idx]
34
+ val = line[colon_idx + 1:]
35
+ if val.startswith(" "):
36
+ val = val[1:]
37
+
38
+ if field_name == "data":
39
+ data = val if data is None else f"{data}\n{val}"
40
+ elif field_name == "event":
41
+ event = val
42
+ elif field_name == "id":
43
+ # per SSE spec: id containing null byte must be ignored
44
+ if "\0" not in val:
45
+ id_val = val
46
+ elif field_name == "retry":
47
+ try:
48
+ n = int(val)
49
+ if n >= 0:
50
+ retry = n
51
+ except ValueError:
52
+ pass
53
+
54
+ if is_comment or data is None:
55
+ return None
56
+
57
+ result: SSEEvent = {"data": data}
58
+ if event is not None:
59
+ result["event"] = event
60
+ if id_val is not None:
61
+ result["id"] = id_val
62
+ if retry is not None:
63
+ result["retry"] = retry
64
+ return result
65
+
66
+
67
+ DEFAULT_MAX_BUFFER = 1024 * 1024
68
+
69
+ _SSE_BLOCK_RE = re.compile(r"\r\n\r\n|\r\n\r|\r\n\n|\r\r\n|\n\r\n|\n\r|\r\r|\n\n")
70
+
71
+
72
+ async def parse_sse_stream(
73
+ response: httpx.Response,
74
+ max_buffer_size: int = DEFAULT_MAX_BUFFER,
75
+ ) -> AsyncIterator[SSEEvent]:
76
+ buffer = ""
77
+ async for chunk in response.aiter_text():
78
+ buffer += chunk
79
+ if len(buffer) > max_buffer_size:
80
+ raise RuntimeError(f"SSE buffer exceeded {max_buffer_size} characters")
81
+
82
+ # split on double-newline (any combo of \r\n, \r, \n)
83
+ blocks = _SSE_BLOCK_RE.split(buffer)
84
+ buffer = blocks.pop()
85
+
86
+ for block in blocks:
87
+ if not block.strip():
88
+ continue
89
+ event = _parse_sse_block(block)
90
+ if event is not None:
91
+ yield event
92
+
93
+ if buffer.strip():
94
+ event = _parse_sse_block(buffer)
95
+ if event is not None:
96
+ yield event
97
+
98
+
99
+ __all__ = ["SSEEvent", "parse_sse_stream"]
@@ -0,0 +1,335 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ from typing import Any, Protocol, runtime_checkable
7
+ from urllib.parse import urlencode
8
+
9
+ try:
10
+ import websockets
11
+ except ImportError:
12
+ websockets = None # type: ignore[assignment]
13
+
14
+ try:
15
+ import httpx
16
+ except ImportError:
17
+ httpx = None # type: ignore[assignment]
18
+
19
+ from ._sse import parse_sse_stream
20
+
21
+
22
+ class TransportKind(str, Enum):
23
+ WS = "ws"
24
+ SSE = "sse"
25
+ LONGPOLL = "longpoll"
26
+
27
+
28
+ @dataclass
29
+ class TransportOpts:
30
+ reconnect_token: str | None = None
31
+ last_event_id: str | None = None
32
+ headers: dict[str, str] | None = None
33
+ max_reconnect_attempts: int = 5
34
+ reconnect_delay_ms: int = 1000
35
+ protocols: list[str] | None = None
36
+
37
+
38
+ @runtime_checkable
39
+ class TransportConn(Protocol):
40
+ async def recv(self) -> Any: ...
41
+ async def send(self, data: Any) -> None: ...
42
+ async def close(self) -> None: ...
43
+ def kind(self) -> TransportKind: ...
44
+
45
+
46
+ @runtime_checkable
47
+ class TransportAdapter(Protocol):
48
+ def name(self) -> str: ...
49
+ def kind(self) -> TransportKind: ...
50
+ async def connect(self, url: str, opts: TransportOpts) -> TransportConn: ...
51
+
52
+
53
+ class _StubConn:
54
+ """Shared stub retained from 8.b.2 for backward compat.
55
+
56
+ Real wire conns below (_WsConn/_SseConn/_LongpollConn) supersede it
57
+ inside the default adapters. Still importable for consumer-authored
58
+ tests that stub before the real impls landed.
59
+ """
60
+
61
+ def __init__(self, kind: TransportKind) -> None:
62
+ self._kind = kind
63
+
64
+ async def recv(self) -> Any:
65
+ raise NotImplementedError(
66
+ "_StubConn.recv is a placeholder; use WsAdapter/SseAdapter/LongpollAdapter",
67
+ )
68
+
69
+ async def send(self, data: Any) -> None:
70
+ raise NotImplementedError(
71
+ "_StubConn.send is a placeholder; use WsAdapter/SseAdapter/LongpollAdapter",
72
+ )
73
+
74
+ async def close(self) -> None:
75
+ return None
76
+
77
+ def kind(self) -> TransportKind:
78
+ return self._kind
79
+
80
+
81
+ def _to_ws_url(url: str) -> str:
82
+ if url.startswith("https://"):
83
+ return "wss://" + url[len("https://"):]
84
+ if url.startswith("http://"):
85
+ return "ws://" + url[len("http://"):]
86
+ return url
87
+
88
+
89
+ class _WsConn:
90
+ def __init__(self, conn: Any) -> None:
91
+ self._conn = conn
92
+
93
+ async def recv(self) -> Any:
94
+ msg = await self._conn.recv()
95
+ if isinstance(msg, (bytes, bytearray)):
96
+ msg = msg.decode("utf-8")
97
+ try:
98
+ return json.loads(msg)
99
+ except (ValueError, TypeError):
100
+ return msg
101
+
102
+ async def send(self, data: Any) -> None:
103
+ if isinstance(data, (str, bytes, bytearray)):
104
+ payload: Any = data
105
+ else:
106
+ payload = json.dumps(data)
107
+ await self._conn.send(payload)
108
+
109
+ async def close(self) -> None:
110
+ try:
111
+ await self._conn.close()
112
+ except Exception:
113
+ """ swallow — close must never raise """
114
+ pass
115
+
116
+ def kind(self) -> TransportKind:
117
+ return TransportKind.WS
118
+
119
+
120
+ @dataclass
121
+ class WsAdapter:
122
+ """Default WebSocket adapter using `websockets.connect`."""
123
+
124
+ subprotocols: list[str] = field(default_factory=list)
125
+
126
+ def name(self) -> str:
127
+ return "ws"
128
+
129
+ def kind(self) -> TransportKind:
130
+ return TransportKind.WS
131
+
132
+ async def connect(self, url: str, opts: TransportOpts) -> TransportConn:
133
+ if websockets is None:
134
+ raise RuntimeError(
135
+ "pip install websockets to use the default WsAdapter",
136
+ )
137
+ ws_url = _to_ws_url(url)
138
+ query: dict[str, str] = {}
139
+ if opts.reconnect_token is not None:
140
+ query["reconnect_token"] = opts.reconnect_token
141
+ if opts.last_event_id is not None:
142
+ query["last_event_id"] = opts.last_event_id
143
+ if query:
144
+ sep = "&" if "?" in ws_url else "?"
145
+ ws_url = f"{ws_url}{sep}{urlencode(query)}"
146
+ subprotocols = opts.protocols or (self.subprotocols or None)
147
+ headers = opts.headers or None
148
+ conn = await websockets.connect(
149
+ ws_url,
150
+ subprotocols=subprotocols,
151
+ additional_headers=headers,
152
+ )
153
+ return _WsConn(conn)
154
+
155
+
156
+ class _SseConn:
157
+ def __init__(
158
+ self,
159
+ client: Any,
160
+ owns_client: bool,
161
+ ctx: Any,
162
+ response: Any,
163
+ ) -> None:
164
+ self._client = client
165
+ self._owns_client = owns_client
166
+ self._ctx = ctx
167
+ self._response = response
168
+ self._iter = parse_sse_stream(response)
169
+
170
+ async def recv(self) -> Any:
171
+ event = await self._iter.__anext__()
172
+ data = event.get("data")
173
+ if data is None:
174
+ return event
175
+ try:
176
+ return json.loads(data)
177
+ except (ValueError, TypeError):
178
+ return data
179
+
180
+ async def send(self, data: Any) -> None:
181
+ raise RuntimeError("SSE transport is receive-only")
182
+
183
+ async def close(self) -> None:
184
+ try:
185
+ await self._ctx.__aexit__(None, None, None)
186
+ except Exception:
187
+ pass
188
+ if self._owns_client:
189
+ try:
190
+ await self._client.aclose()
191
+ except Exception:
192
+ pass
193
+
194
+ def kind(self) -> TransportKind:
195
+ return TransportKind.SSE
196
+
197
+
198
+ @dataclass
199
+ class SseAdapter:
200
+ """Default SSE adapter using `httpx.AsyncClient.stream`."""
201
+
202
+ http_client: Any | None = None
203
+
204
+ def name(self) -> str:
205
+ return "sse"
206
+
207
+ def kind(self) -> TransportKind:
208
+ return TransportKind.SSE
209
+
210
+ async def connect(self, url: str, opts: TransportOpts) -> TransportConn:
211
+ if httpx is None:
212
+ raise RuntimeError(
213
+ "pip install httpx to use the default SseAdapter",
214
+ )
215
+ params: dict[str, str] = {}
216
+ if opts.reconnect_token is not None:
217
+ params["reconnect_token"] = opts.reconnect_token
218
+ headers = dict(opts.headers or {})
219
+ headers.setdefault("Accept", "text/event-stream")
220
+ if opts.last_event_id is not None:
221
+ headers["Last-Event-ID"] = opts.last_event_id
222
+ client = self.http_client or httpx.AsyncClient()
223
+ owns_client = self.http_client is None
224
+ ctx = client.stream(
225
+ "GET", url, params=params or None, headers=headers,
226
+ )
227
+ response = await ctx.__aenter__()
228
+ response.raise_for_status()
229
+ return _SseConn(
230
+ client=client, owns_client=owns_client,
231
+ ctx=ctx, response=response,
232
+ )
233
+
234
+
235
+ class _LongpollConn:
236
+ def __init__(
237
+ self,
238
+ client: Any,
239
+ owns_client: bool,
240
+ url: str,
241
+ headers: dict[str, str],
242
+ reconnect_token: str | None,
243
+ last_event_id: str | None,
244
+ ) -> None:
245
+ self._client = client
246
+ self._owns_client = owns_client
247
+ self._url = url
248
+ self._headers = headers
249
+ self._reconnect_token = reconnect_token
250
+ self._last_event_id = last_event_id
251
+ self._closed = False
252
+
253
+ async def recv(self) -> Any:
254
+ if self._closed:
255
+ raise RuntimeError("longpoll connection closed")
256
+ params: dict[str, str] = {}
257
+ if self._reconnect_token is not None:
258
+ params["reconnect_token"] = self._reconnect_token
259
+ if self._last_event_id is not None:
260
+ params["last_event_id"] = self._last_event_id
261
+ resp = await self._client.get(
262
+ self._url,
263
+ headers=self._headers or None,
264
+ params=params or None,
265
+ )
266
+ resp.raise_for_status()
267
+ payload: Any
268
+ try:
269
+ payload = resp.json()
270
+ except Exception:
271
+ payload = resp.text
272
+ if isinstance(payload, dict):
273
+ new_token = payload.get("reconnect_token")
274
+ if isinstance(new_token, str):
275
+ self._reconnect_token = new_token
276
+ new_id = payload.get("id") or payload.get("last_event_id")
277
+ if isinstance(new_id, (str, int)):
278
+ self._last_event_id = str(new_id)
279
+ return payload
280
+
281
+ async def send(self, data: Any) -> None:
282
+ raise RuntimeError("longpoll transport is receive-only")
283
+
284
+ async def close(self) -> None:
285
+ self._closed = True
286
+ if self._owns_client:
287
+ try:
288
+ await self._client.aclose()
289
+ except Exception:
290
+ pass
291
+
292
+ def kind(self) -> TransportKind:
293
+ return TransportKind.LONGPOLL
294
+
295
+
296
+ @dataclass
297
+ class LongpollAdapter:
298
+ """Default long-poll adapter using repeated `httpx.AsyncClient.get`."""
299
+
300
+ http_client: Any | None = None
301
+ poll_timeout: float = 30.0
302
+
303
+ def name(self) -> str:
304
+ return "longpoll"
305
+
306
+ def kind(self) -> TransportKind:
307
+ return TransportKind.LONGPOLL
308
+
309
+ async def connect(self, url: str, opts: TransportOpts) -> TransportConn:
310
+ if httpx is None:
311
+ raise RuntimeError(
312
+ "pip install httpx to use the default LongpollAdapter",
313
+ )
314
+ client = self.http_client or httpx.AsyncClient(timeout=self.poll_timeout)
315
+ owns_client = self.http_client is None
316
+ return _LongpollConn(
317
+ client=client,
318
+ owns_client=owns_client,
319
+ url=url,
320
+ headers=dict(opts.headers or {}),
321
+ reconnect_token=opts.reconnect_token,
322
+ last_event_id=opts.last_event_id,
323
+ )
324
+
325
+
326
+ __all__ = [
327
+ "LongpollAdapter",
328
+ "SseAdapter",
329
+ "TransportAdapter",
330
+ "TransportConn",
331
+ "TransportKind",
332
+ "TransportOpts",
333
+ "WsAdapter",
334
+ "_StubConn",
335
+ ]
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, AsyncIterator, Callable
5
+
6
+ try:
7
+ import websockets
8
+ except ImportError:
9
+ websockets = None # type: ignore[assignment]
10
+
11
+ _VALID_EVENTS = frozenset({"open", "close", "error", "message"})
12
+
13
+
14
+ class _TypedWebSocket:
15
+ """Async context-manager wrapping a websockets connection.
16
+
17
+ Parity notes (Phase 10 WS audit):
18
+ - Remote close code + reason surfaced via close_code / close_reason after
19
+ __aexit__ or after the iterator terminates; also passed to "close"
20
+ handlers registered via .on().
21
+ - Auth handshake: caller passes query-string params (e.g. ?token=) to the
22
+ emitted method; server rejects via close frame which this class fires
23
+ as a "close" event plus sets close_code.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ url: str,
29
+ protocols: list[str] | None = None,
30
+ extra_headers: dict[str, str] | None = None,
31
+ ) -> None:
32
+ if websockets is None:
33
+ raise ImportError("pip install honey-sdk[ws] to use WebSocket methods")
34
+ self._url = url
35
+ self._protocols = protocols or []
36
+ self._extra_headers = extra_headers or {}
37
+ self._conn: Any = None
38
+ self._handlers: dict[str, list[Callable[..., Any]]] = {
39
+ "open": [],
40
+ "close": [],
41
+ "error": [],
42
+ "message": [],
43
+ }
44
+ self.close_code: int | None = None
45
+ self.close_reason: str | None = None
46
+
47
+ async def __aenter__(self) -> _TypedWebSocket:
48
+ if websockets is None:
49
+ raise ImportError("pip install honey-sdk[ws] to use WebSocket methods")
50
+ self._conn = await websockets.connect(
51
+ self._url,
52
+ subprotocols=self._protocols or None,
53
+ additional_headers=self._extra_headers or None,
54
+ )
55
+ for handler in self._handlers["open"]:
56
+ try:
57
+ handler()
58
+ except Exception:
59
+ pass
60
+ return self
61
+
62
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
63
+ if self._conn is not None:
64
+ try:
65
+ await self._conn.close()
66
+ except Exception:
67
+ pass
68
+ self._capture_close()
69
+ code = self.close_code if self.close_code is not None else 1000
70
+ reason = self.close_reason if self.close_reason is not None else ""
71
+ for handler in self._handlers["close"]:
72
+ try:
73
+ handler(code, reason)
74
+ except Exception:
75
+ pass
76
+
77
+ def __aiter__(self) -> AsyncIterator[str | bytes]:
78
+ return self._iter_messages()
79
+
80
+ async def _iter_messages(self) -> AsyncIterator[str | bytes]:
81
+ try:
82
+ async for msg in self._conn:
83
+ for handler in self._handlers["message"]:
84
+ try:
85
+ handler(msg)
86
+ except Exception:
87
+ pass
88
+ yield msg
89
+ finally:
90
+ self._capture_close()
91
+ code = self.close_code
92
+ reason = self.close_reason
93
+ if code is not None:
94
+ for handler in self._handlers["close"]:
95
+ try:
96
+ handler(code, reason or "")
97
+ except Exception:
98
+ pass
99
+
100
+ def _capture_close(self) -> None:
101
+ """Read close code/reason from the underlying websockets.Connection once
102
+ the peer sends a Close frame. websockets exposes these as attributes on
103
+ the connection object after the receive loop sees Close."""
104
+ if self._conn is None:
105
+ return
106
+ code = getattr(self._conn, "close_code", None)
107
+ reason = getattr(self._conn, "close_reason", None)
108
+ if code is not None and self.close_code is None:
109
+ self.close_code = int(code)
110
+ if reason is not None and self.close_reason is None:
111
+ self.close_reason = str(reason)
112
+
113
+ def on(self, event: str, callback: Callable[..., Any]) -> None:
114
+ """Register a callback for open, close, error, or message events."""
115
+ if event not in _VALID_EVENTS:
116
+ raise ValueError(
117
+ f"event must be one of {sorted(_VALID_EVENTS)}, got {event!r}",
118
+ )
119
+ self._handlers[event].append(callback)
120
+
121
+ async def send(self, data: str | bytes | dict[str, Any]) -> None:
122
+ if isinstance(data, dict):
123
+ data = json.dumps(data)
124
+ await self._conn.send(data)
125
+
126
+ async def close(self, code: int = 1000, reason: str = "") -> None:
127
+ if self._conn is not None:
128
+ await self._conn.close(code, reason)
129
+
130
+
131
+ __all__ = ["_TypedWebSocket"]
File without changes