@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.
- package/README.md +51 -0
- package/docs/api-ref/go.md +399 -0
- package/docs/api-ref/python.md +399 -0
- package/docs/api-ref/rust.md +399 -0
- package/docs/api-ref/typescript.md +399 -0
- package/docs/invalidate-spec.md +303 -0
- package/docs/sdk.md +722 -0
- package/package.json +120 -0
- package/src/accepts.ts +78 -0
- package/src/body-limit.ts +100 -0
- package/src/build/index.ts +174 -0
- package/src/cli-go/auth.go +15 -0
- package/src/cli-go/config.go +165 -0
- package/src/cli-go/errors.go +57 -0
- package/src/cli-go/multipart.go +55 -0
- package/src/cli-go/output.go +109 -0
- package/src/cli-go/root.go +30 -0
- package/src/cli-go/stream.go +88 -0
- package/src/cli-go/version.go +5 -0
- package/src/cli.ts +204 -0
- package/src/client/error.ts +27 -0
- package/src/client/http.ts +502 -0
- package/src/client/index.ts +127 -0
- package/src/client/sdk.ts +265 -0
- package/src/client/sse.ts +102 -0
- package/src/client/types.ts +211 -0
- package/src/client/ws.ts +115 -0
- package/src/client-go/errors.go +207 -0
- package/src/client-go/invalidation.go +373 -0
- package/src/client-go/realtime.go +317 -0
- package/src/client-go/result.go +20 -0
- package/src/client-go/runtime.go +413 -0
- package/src/client-go/sse.go +149 -0
- package/src/client-go/transport.go +452 -0
- package/src/client-go/ws.go +104 -0
- package/src/client-mcp/README.md +10 -0
- package/src/client-mcp/server.ts +94 -0
- package/src/client-mcp/types.ts +19 -0
- package/src/client-python/_errors.py +115 -0
- package/src/client-python/_invalidation.py +578 -0
- package/src/client-python/_realtime.py +156 -0
- package/src/client-python/_runtime.py +469 -0
- package/src/client-python/_sse.py +99 -0
- package/src/client-python/_transport.py +335 -0
- package/src/client-python/_ws.py +131 -0
- package/src/client-python/py.typed +0 -0
- package/src/client-rust/errors.rs +200 -0
- package/src/client-rust/invalidation.rs +441 -0
- package/src/client-rust/invalidation_sync.rs +306 -0
- package/src/client-rust/realtime.rs +695 -0
- package/src/client-rust/result.rs +22 -0
- package/src/client-rust/runtime.rs +547 -0
- package/src/client-rust/runtime_sync.rs +452 -0
- package/src/client-rust/sse.rs +167 -0
- package/src/client-rust/ws.rs +143 -0
- package/src/cloudflare/realtime.ts +236 -0
- package/src/codegen-go-cli.ts +1180 -0
- package/src/codegen-go.ts +1010 -0
- package/src/codegen-hash.ts +8 -0
- package/src/codegen-ir.ts +517 -0
- package/src/codegen-loaders.ts +53 -0
- package/src/codegen-mcp.ts +364 -0
- package/src/codegen-python.ts +1028 -0
- package/src/codegen-rust.ts +1652 -0
- package/src/codegen-sanitize.ts +67 -0
- package/src/codegen-schema-naming.ts +146 -0
- package/src/codegen.ts +5015 -0
- package/src/context.ts +210 -0
- package/src/cookie-sign.ts +58 -0
- package/src/cookie.ts +89 -0
- package/src/cors.ts +96 -0
- package/src/crypto.ts +19 -0
- package/src/csrf.ts +54 -0
- package/src/curl-logger.ts +235 -0
- package/src/detect-runtime.ts +19 -0
- package/src/error.ts +52 -0
- package/src/errors.ts +87 -0
- package/src/etag.ts +74 -0
- package/src/feature-load.ts +23 -0
- package/src/go-type-emitter.ts +404 -0
- package/src/honey-response.ts +354 -0
- package/src/i18n-slot.ts +20 -0
- package/src/i18n.ts +234 -0
- package/src/index.ts +3843 -0
- package/src/init.ts +149 -0
- package/src/input.ts +8 -0
- package/src/ip-restrict.ts +192 -0
- package/src/logger.ts +149 -0
- package/src/middleware.ts +199 -0
- package/src/node-request.ts +240 -0
- package/src/node.ts +212 -0
- package/src/openapi/register.ts +19 -0
- package/src/openapi/scalar.ts +33 -0
- package/src/openapi/spec-factory.ts +46 -0
- package/src/openapi/spec.ts +31 -0
- package/src/openapi/swagger.ts +34 -0
- package/src/plugin.ts +771 -0
- package/src/powered-by.ts +17 -0
- package/src/pretty-json.ts +39 -0
- package/src/proxy.ts +155 -0
- package/src/python-type-emitter.ts +144 -0
- package/src/realtime/buffer.ts +101 -0
- package/src/realtime/bus.ts +93 -0
- package/src/realtime/longpoll.ts +148 -0
- package/src/realtime/protocol.ts +128 -0
- package/src/realtime/route.ts +104 -0
- package/src/realtime/runtime.ts +430 -0
- package/src/request-id.ts +23 -0
- package/src/request-to-curl.ts +34 -0
- package/src/response.ts +414 -0
- package/src/rust-type-emitter.ts +412 -0
- package/src/secure-headers.ts +69 -0
- package/src/serve-register.ts +8 -0
- package/src/serve-slot.ts +20 -0
- package/src/serve.ts +141 -0
- package/src/server-timing.ts +66 -0
- package/src/static.ts +60 -0
- package/src/telemetry/otel.ts +139 -0
- package/src/testing.ts +114 -0
- package/src/timeout.ts +36 -0
- package/src/tree.ts +464 -0
- package/src/ts-type-emitter.ts +106 -0
- package/src/type-emitter.ts +524 -0
- package/src/type-extractor.ts +749 -0
- package/src/types.ts +638 -0
- package/src/validation.ts +300 -0
- package/src/ws/bun.ts +89 -0
- package/src/ws/cloudflare.ts +133 -0
- package/src/ws/deno.ts +109 -0
- package/src/ws/node.ts +161 -0
- 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
|