hayate 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hayate/__init__.py +46 -0
- hayate/abort.py +62 -0
- hayate/adapters/__init__.py +5 -0
- hayate/adapters/asgi.py +220 -0
- hayate/adapters/aws.py +116 -0
- hayate/adapters/workers.py +94 -0
- hayate/app.py +325 -0
- hayate/body.py +78 -0
- hayate/context.py +159 -0
- hayate/cookies.py +90 -0
- hayate/exceptions.py +85 -0
- hayate/formdata.py +117 -0
- hayate/headers.py +179 -0
- hayate/jsonutil.py +28 -0
- hayate/middleware/__init__.py +27 -0
- hayate/middleware/_internal.py +27 -0
- hayate/middleware/basic_auth.py +37 -0
- hayate/middleware/body_limit.py +43 -0
- hayate/middleware/cache.py +53 -0
- hayate/middleware/compress.py +88 -0
- hayate/middleware/cors.py +78 -0
- hayate/middleware/etag.py +40 -0
- hayate/middleware/logger.py +26 -0
- hayate/middleware/secure_headers.py +43 -0
- hayate/middleware/static_files.py +136 -0
- hayate/middleware/timeout.py +21 -0
- hayate/py.typed +1 -0
- hayate/request.py +210 -0
- hayate/response.py +54 -0
- hayate/router.py +88 -0
- hayate/sse.py +43 -0
- hayate/url.py +451 -0
- hayate/urlpattern.py +211 -0
- hayate/validator.py +64 -0
- hayate/websocket.py +78 -0
- hayate-0.3.0.dist-info/METADATA +90 -0
- hayate-0.3.0.dist-info/RECORD +38 -0
- hayate-0.3.0.dist-info/WHEEL +4 -0
hayate/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""hayate — a web-standards-first Python web framework, inspired by Hono."""
|
|
2
|
+
|
|
3
|
+
from .abort import AbortError, AbortSignal
|
|
4
|
+
from .app import ErrorHandler, Handler, Hayate, Middleware
|
|
5
|
+
from .body import BodyInit
|
|
6
|
+
from .context import Context, ExecutionContext, Next
|
|
7
|
+
from .exceptions import HTTPException, problem
|
|
8
|
+
from .formdata import File, FormData
|
|
9
|
+
from .headers import Headers
|
|
10
|
+
from .request import HayateRequest, Request
|
|
11
|
+
from .response import Response
|
|
12
|
+
from .url import URL, URLSearchParams
|
|
13
|
+
from .urlpattern import URLPattern, URLPatternResult
|
|
14
|
+
from .validator import validator
|
|
15
|
+
from .websocket import WebSocket, WebSocketClosed
|
|
16
|
+
|
|
17
|
+
__version__ = "0.3.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"URL",
|
|
21
|
+
"AbortError",
|
|
22
|
+
"AbortSignal",
|
|
23
|
+
"BodyInit",
|
|
24
|
+
"Context",
|
|
25
|
+
"ErrorHandler",
|
|
26
|
+
"ExecutionContext",
|
|
27
|
+
"File",
|
|
28
|
+
"FormData",
|
|
29
|
+
"HTTPException",
|
|
30
|
+
"Handler",
|
|
31
|
+
"Hayate",
|
|
32
|
+
"HayateRequest",
|
|
33
|
+
"Headers",
|
|
34
|
+
"Middleware",
|
|
35
|
+
"Next",
|
|
36
|
+
"Request",
|
|
37
|
+
"Response",
|
|
38
|
+
"URLPattern",
|
|
39
|
+
"URLPatternResult",
|
|
40
|
+
"URLSearchParams",
|
|
41
|
+
"WebSocket",
|
|
42
|
+
"WebSocketClosed",
|
|
43
|
+
"__version__",
|
|
44
|
+
"problem",
|
|
45
|
+
"validator",
|
|
46
|
+
]
|
hayate/abort.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Minimal ``AbortSignal``, mirroring the DOM surface a server needs.
|
|
2
|
+
|
|
3
|
+
``request.signal`` lets handlers observe client disconnects. By default
|
|
4
|
+
hayate does not cancel handlers on abort (same safe default as Hono);
|
|
5
|
+
cancellation is opt-in via middleware.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
_logger = logging.getLogger("hayate")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AbortError(Exception):
|
|
18
|
+
"""Raised by ``AbortSignal.raise_if_aborted()`` (DOMException "AbortError")."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AbortSignal:
|
|
22
|
+
__slots__ = ("_aborted", "_callbacks", "_reason")
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self._aborted = False
|
|
26
|
+
self._reason: Any = None
|
|
27
|
+
self._callbacks: list[Callable[[], None]] = []
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def aborted(self) -> bool:
|
|
31
|
+
return self._aborted
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def reason(self) -> Any:
|
|
35
|
+
return self._reason
|
|
36
|
+
|
|
37
|
+
def add_listener(self, callback: Callable[[], None]) -> None:
|
|
38
|
+
"""Register an abort callback (fires immediately if already aborted)."""
|
|
39
|
+
if self._aborted:
|
|
40
|
+
callback()
|
|
41
|
+
return
|
|
42
|
+
self._callbacks.append(callback)
|
|
43
|
+
|
|
44
|
+
def raise_if_aborted(self) -> None:
|
|
45
|
+
"""Fetch ``throwIfAborted()``."""
|
|
46
|
+
if self._aborted:
|
|
47
|
+
raise AbortError(self._reason if self._reason is not None else "aborted")
|
|
48
|
+
|
|
49
|
+
def _abort(self, reason: Any = None) -> None:
|
|
50
|
+
if self._aborted:
|
|
51
|
+
return
|
|
52
|
+
self._aborted = True
|
|
53
|
+
self._reason = reason
|
|
54
|
+
callbacks, self._callbacks = self._callbacks, []
|
|
55
|
+
for callback in callbacks:
|
|
56
|
+
try:
|
|
57
|
+
callback()
|
|
58
|
+
except Exception:
|
|
59
|
+
_logger.exception("abort listener raised")
|
|
60
|
+
|
|
61
|
+
def __repr__(self) -> str:
|
|
62
|
+
return f"AbortSignal(aborted={self._aborted!r})"
|
hayate/adapters/asgi.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""ASGI adapter: translates ASGI ``http`` / ``lifespan`` events to ``app.fetch()``.
|
|
2
|
+
|
|
3
|
+
Users never see ``scope`` / ``receive`` / ``send`` — the ``Hayate`` app is
|
|
4
|
+
itself an ASGI callable that delegates here, so ``uvicorn main:app`` works
|
|
5
|
+
out of the box.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
from ..abort import AbortSignal
|
|
17
|
+
from ..context import Context
|
|
18
|
+
from ..headers import Headers
|
|
19
|
+
from ..request import HayateRequest, Request
|
|
20
|
+
from ..router import WEBSOCKET_METHOD
|
|
21
|
+
from ..url import URL
|
|
22
|
+
from ..websocket import WebSocket, WebSocketClosed
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from ..app import Hayate
|
|
26
|
+
from ..response import Response
|
|
27
|
+
|
|
28
|
+
_logger = logging.getLogger("hayate.asgi")
|
|
29
|
+
|
|
30
|
+
type Receive = Callable[[], Awaitable[dict[str, Any]]]
|
|
31
|
+
type Send = Callable[[dict[str, Any]], Awaitable[None]]
|
|
32
|
+
|
|
33
|
+
_DEFAULT_SCHEME_PORTS = {"http": "80", "https": "443", "ws": "80", "wss": "443"}
|
|
34
|
+
# pchar set — keep valid path characters intact when re-encoding a decoded path.
|
|
35
|
+
_PATH_SAFE = "/:@!$&'()*+,;=~-._"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _request_body(receive: Receive, signal: AbortSignal) -> AsyncIterator[bytes]:
|
|
39
|
+
while True:
|
|
40
|
+
message = await receive()
|
|
41
|
+
if message["type"] == "http.request":
|
|
42
|
+
chunk = message.get("body", b"")
|
|
43
|
+
if chunk:
|
|
44
|
+
yield chunk
|
|
45
|
+
if not message.get("more_body", False):
|
|
46
|
+
return
|
|
47
|
+
elif message["type"] == "http.disconnect":
|
|
48
|
+
signal._abort("client disconnected")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _call_hook(hook: Callable[[], Any]) -> None:
|
|
53
|
+
result = hook()
|
|
54
|
+
if inspect.isawaitable(result):
|
|
55
|
+
await result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ASGIAdapter:
|
|
59
|
+
__slots__ = ("_app",)
|
|
60
|
+
|
|
61
|
+
def __init__(self, app: Hayate) -> None:
|
|
62
|
+
self._app = app
|
|
63
|
+
|
|
64
|
+
async def __call__(self, scope: dict[str, Any], receive: Receive, send: Send) -> None:
|
|
65
|
+
kind = scope["type"]
|
|
66
|
+
if kind == "http":
|
|
67
|
+
await self._http(scope, receive, send)
|
|
68
|
+
elif kind == "websocket":
|
|
69
|
+
await self._websocket(scope, receive, send)
|
|
70
|
+
elif kind == "lifespan":
|
|
71
|
+
await self._lifespan(receive, send)
|
|
72
|
+
else:
|
|
73
|
+
raise RuntimeError(f"unsupported ASGI scope type: {kind!r}")
|
|
74
|
+
|
|
75
|
+
# -- http -----------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
async def _http(self, scope: dict[str, Any], receive: Receive, send: Send) -> None:
|
|
78
|
+
raw_headers: list[tuple[bytes, bytes]] = scope.get("headers", [])
|
|
79
|
+
host: bytes | None = None
|
|
80
|
+
expects_body = False
|
|
81
|
+
for name, value in raw_headers:
|
|
82
|
+
if name == b"host":
|
|
83
|
+
if host is None:
|
|
84
|
+
host = value
|
|
85
|
+
elif name == b"content-length":
|
|
86
|
+
expects_body = value not in (b"", b"0")
|
|
87
|
+
elif name == b"transfer-encoding":
|
|
88
|
+
expects_body = True
|
|
89
|
+
if expects_body:
|
|
90
|
+
signal: AbortSignal | None = AbortSignal()
|
|
91
|
+
body: Any = _request_body(receive, signal)
|
|
92
|
+
else:
|
|
93
|
+
# No content-length / transfer-encoding means no request body
|
|
94
|
+
# (RFC 9112) — a null body per Fetch; skip stream and signal.
|
|
95
|
+
signal = None
|
|
96
|
+
body = None
|
|
97
|
+
request = Request(
|
|
98
|
+
_build_url(scope, host),
|
|
99
|
+
method=scope["method"],
|
|
100
|
+
headers=Headers._from_wire(raw_headers, guard="immutable"),
|
|
101
|
+
body=body,
|
|
102
|
+
signal=signal,
|
|
103
|
+
)
|
|
104
|
+
response = await self._app.fetch(request)
|
|
105
|
+
try:
|
|
106
|
+
await _send_response(scope, send, response)
|
|
107
|
+
finally:
|
|
108
|
+
background = response._background
|
|
109
|
+
if background is not None:
|
|
110
|
+
await background._drain()
|
|
111
|
+
|
|
112
|
+
# -- websocket ---------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
async def _websocket(self, scope: dict[str, Any], receive: Receive, send: Send) -> None:
|
|
115
|
+
raw_path = scope.get("raw_path")
|
|
116
|
+
if raw_path:
|
|
117
|
+
path = raw_path.decode("latin-1")
|
|
118
|
+
else:
|
|
119
|
+
path = quote(scope.get("path", "/"), safe=_PATH_SAFE)
|
|
120
|
+
matched = self._app._router.match(WEBSOCKET_METHOD, path)
|
|
121
|
+
if matched is None:
|
|
122
|
+
await receive() # websocket.connect
|
|
123
|
+
await send({"type": "websocket.close", "code": 4404, "reason": "not found"})
|
|
124
|
+
return
|
|
125
|
+
route, params = matched
|
|
126
|
+
raw_headers: list[tuple[bytes, bytes]] = scope.get("headers", [])
|
|
127
|
+
host: bytes | None = None
|
|
128
|
+
for name, value in raw_headers:
|
|
129
|
+
if name == b"host":
|
|
130
|
+
host = value
|
|
131
|
+
break
|
|
132
|
+
request = Request(
|
|
133
|
+
_build_url(scope, host),
|
|
134
|
+
headers=Headers._from_wire(raw_headers, guard="immutable"),
|
|
135
|
+
)
|
|
136
|
+
hayate_request = HayateRequest(request)
|
|
137
|
+
hayate_request._params = params
|
|
138
|
+
c = Context(hayate_request, self._app._env, None)
|
|
139
|
+
ws = WebSocket(receive, send)
|
|
140
|
+
await ws.accept()
|
|
141
|
+
try:
|
|
142
|
+
await route.handler(c, ws)
|
|
143
|
+
except WebSocketClosed:
|
|
144
|
+
pass
|
|
145
|
+
except Exception:
|
|
146
|
+
_logger.exception("websocket handler failed on %s", path)
|
|
147
|
+
await ws.close(1011, "internal error")
|
|
148
|
+
return
|
|
149
|
+
await ws.close()
|
|
150
|
+
|
|
151
|
+
# -- lifespan ----------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
async def _lifespan(self, receive: Receive, send: Send) -> None:
|
|
154
|
+
while True:
|
|
155
|
+
message = await receive()
|
|
156
|
+
if message["type"] == "lifespan.startup":
|
|
157
|
+
try:
|
|
158
|
+
for hook in self._app._on_start:
|
|
159
|
+
await _call_hook(hook)
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
await send({"type": "lifespan.startup.failed", "message": str(exc)})
|
|
162
|
+
return
|
|
163
|
+
await send({"type": "lifespan.startup.complete"})
|
|
164
|
+
elif message["type"] == "lifespan.shutdown":
|
|
165
|
+
try:
|
|
166
|
+
for hook in self._app._on_stop:
|
|
167
|
+
await _call_hook(hook)
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
await send({"type": "lifespan.shutdown.failed", "message": str(exc)})
|
|
170
|
+
return
|
|
171
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _build_url(scope: dict[str, Any], host_header: bytes | None) -> URL:
|
|
176
|
+
scheme = scope.get("scheme", "http")
|
|
177
|
+
if host_header:
|
|
178
|
+
host = host_header.decode("latin-1")
|
|
179
|
+
else:
|
|
180
|
+
server = scope.get("server")
|
|
181
|
+
if server:
|
|
182
|
+
host = server[0]
|
|
183
|
+
port = server[1]
|
|
184
|
+
if port is not None and str(port) != _DEFAULT_SCHEME_PORTS.get(scheme):
|
|
185
|
+
host = f"{host}:{port}"
|
|
186
|
+
else:
|
|
187
|
+
host = "localhost"
|
|
188
|
+
raw_path = scope.get("raw_path")
|
|
189
|
+
if raw_path:
|
|
190
|
+
path = raw_path.decode("latin-1")
|
|
191
|
+
else:
|
|
192
|
+
path = quote(scope.get("path", "/"), safe=_PATH_SAFE)
|
|
193
|
+
query = scope.get("query_string", b"").decode("latin-1")
|
|
194
|
+
return URL._from_server(scheme, host, path, query)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def _send_response(scope: dict[str, Any], send: Send, response: Response) -> None:
|
|
198
|
+
status = response.status
|
|
199
|
+
body = response.body
|
|
200
|
+
headers = [
|
|
201
|
+
(name.encode("latin-1"), value.encode("latin-1")) for name, value in response.headers.raw()
|
|
202
|
+
]
|
|
203
|
+
body_allowed = status >= 200 and status not in (204, 304)
|
|
204
|
+
if body_allowed and not response.headers.has("content-length"):
|
|
205
|
+
if isinstance(body, bytes):
|
|
206
|
+
headers.append((b"content-length", str(len(body)).encode("latin-1")))
|
|
207
|
+
elif body is None:
|
|
208
|
+
headers.append((b"content-length", b"0"))
|
|
209
|
+
# Stream bodies get no content-length; the server frames them.
|
|
210
|
+
await send({"type": "http.response.start", "status": status, "headers": headers})
|
|
211
|
+
suppress_body = scope["method"] == "HEAD" or not body_allowed
|
|
212
|
+
if suppress_body or body is None:
|
|
213
|
+
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
214
|
+
return
|
|
215
|
+
if isinstance(body, bytes):
|
|
216
|
+
await send({"type": "http.response.body", "body": body, "more_body": False})
|
|
217
|
+
return
|
|
218
|
+
async for chunk in body:
|
|
219
|
+
await send({"type": "http.response.body", "body": bytes(chunk), "more_body": True})
|
|
220
|
+
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
hayate/adapters/aws.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""AWS Lambda adapter (Function URLs / API Gateway HTTP API, payload v2.0).
|
|
2
|
+
|
|
3
|
+
from hayate.adapters.aws import to_lambda
|
|
4
|
+
from app import app
|
|
5
|
+
|
|
6
|
+
handler = to_lambda(app)
|
|
7
|
+
|
|
8
|
+
Each invocation runs the app on a fresh event loop — the standard
|
|
9
|
+
pattern for sync Lambda handlers. Response bodies are emitted as text
|
|
10
|
+
when the content-type is textual and there is no content-encoding;
|
|
11
|
+
otherwise they are base64-encoded. ``Set-Cookie`` values map to the
|
|
12
|
+
payload's ``cookies`` list so they are never comma-joined.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import base64
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from ..headers import Headers
|
|
22
|
+
from ..request import Request
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
|
|
27
|
+
from ..app import Hayate
|
|
28
|
+
|
|
29
|
+
_TEXT_EXACT = {
|
|
30
|
+
"application/json",
|
|
31
|
+
"application/javascript",
|
|
32
|
+
"application/xml",
|
|
33
|
+
"application/manifest+json",
|
|
34
|
+
"image/svg+xml",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_textual(content_type: str) -> bool:
|
|
39
|
+
base = content_type.partition(";")[0].strip().lower()
|
|
40
|
+
return (
|
|
41
|
+
base.startswith("text/")
|
|
42
|
+
or base in _TEXT_EXACT
|
|
43
|
+
or base.endswith("+json")
|
|
44
|
+
or base.endswith("+xml")
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _handle(app: Hayate, event: dict[str, Any]) -> dict[str, Any]:
|
|
49
|
+
http = event["requestContext"]["http"]
|
|
50
|
+
method = http.get("method", "GET")
|
|
51
|
+
path = event.get("rawPath") or "/"
|
|
52
|
+
query = event.get("rawQueryString") or ""
|
|
53
|
+
header_map: dict[str, str] = event.get("headers") or {}
|
|
54
|
+
pairs = list(header_map.items())
|
|
55
|
+
request_cookies = event.get("cookies")
|
|
56
|
+
if request_cookies:
|
|
57
|
+
pairs.append(("cookie", "; ".join(request_cookies)))
|
|
58
|
+
|
|
59
|
+
host = header_map.get("host") or event["requestContext"].get("domainName", "lambda")
|
|
60
|
+
target = f"https://{host}{path}"
|
|
61
|
+
if query:
|
|
62
|
+
target += f"?{query}"
|
|
63
|
+
|
|
64
|
+
raw_body = event.get("body")
|
|
65
|
+
body: bytes | str | None
|
|
66
|
+
if raw_body is None:
|
|
67
|
+
body = None
|
|
68
|
+
elif event.get("isBase64Encoded"):
|
|
69
|
+
body = base64.b64decode(raw_body)
|
|
70
|
+
else:
|
|
71
|
+
body = raw_body
|
|
72
|
+
|
|
73
|
+
request = Request(target, method=method, headers=Headers(pairs, guard="immutable"), body=body)
|
|
74
|
+
response = await app.fetch(request)
|
|
75
|
+
background = response._background
|
|
76
|
+
if background is not None:
|
|
77
|
+
await background._drain()
|
|
78
|
+
|
|
79
|
+
payload = response.body
|
|
80
|
+
if payload is not None and not isinstance(payload, bytes):
|
|
81
|
+
payload = await response.bytes()
|
|
82
|
+
|
|
83
|
+
headers: dict[str, str] = {}
|
|
84
|
+
set_cookies: list[str] = []
|
|
85
|
+
for name, value in response.headers.raw():
|
|
86
|
+
if name == "set-cookie":
|
|
87
|
+
set_cookies.append(value)
|
|
88
|
+
elif name in headers:
|
|
89
|
+
headers[name] = f"{headers[name]}, {value}"
|
|
90
|
+
else:
|
|
91
|
+
headers[name] = value
|
|
92
|
+
|
|
93
|
+
result: dict[str, Any] = {"statusCode": response.status, "headers": headers}
|
|
94
|
+
if set_cookies:
|
|
95
|
+
result["cookies"] = set_cookies
|
|
96
|
+
if payload is None:
|
|
97
|
+
result["body"] = ""
|
|
98
|
+
result["isBase64Encoded"] = False
|
|
99
|
+
else:
|
|
100
|
+
content_type = response.headers.get("content-type") or ""
|
|
101
|
+
if response.headers.has("content-encoding") or not _is_textual(content_type):
|
|
102
|
+
result["body"] = base64.b64encode(payload).decode("ascii")
|
|
103
|
+
result["isBase64Encoded"] = True
|
|
104
|
+
else:
|
|
105
|
+
result["body"] = payload.decode("utf-8")
|
|
106
|
+
result["isBase64Encoded"] = False
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def to_lambda(app: Hayate) -> Callable[[dict[str, Any], Any], dict[str, Any]]:
|
|
111
|
+
"""Build a synchronous Lambda handler: ``handler = to_lambda(app)``."""
|
|
112
|
+
|
|
113
|
+
def handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
|
|
114
|
+
return asyncio.run(_handle(app, event))
|
|
115
|
+
|
|
116
|
+
return handler
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Cloudflare Python Workers adapter.
|
|
2
|
+
|
|
3
|
+
Usage — the entry module of a Python Worker:
|
|
4
|
+
|
|
5
|
+
from hayate.adapters.workers import to_workers
|
|
6
|
+
from app import app
|
|
7
|
+
|
|
8
|
+
Default = to_workers(app)
|
|
9
|
+
|
|
10
|
+
The Workers runtime modules (``workers``, ``js``) exist only inside
|
|
11
|
+
Pyodide, so they are imported inside ``to_workers()``; the translation
|
|
12
|
+
helpers are plain Python and unit-testable anywhere.
|
|
13
|
+
|
|
14
|
+
Unlike the official FastAPI integration (JS Request -> ASGI -> Starlette
|
|
15
|
+
Request), this is a single-step translation: JS Request -> hayate
|
|
16
|
+
Request, same conceptual model on both sides.
|
|
17
|
+
|
|
18
|
+
v0.2 scope: bodies are buffered across the FFI boundary (streaming
|
|
19
|
+
bridges need on-platform verification — docs/research/cloudflare.md §5)
|
|
20
|
+
and the abort signal is not yet bridged.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
from ..context import ExecutionContext
|
|
29
|
+
from ..headers import Headers
|
|
30
|
+
from ..request import Request
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from ..app import Hayate
|
|
34
|
+
from ..response import Response
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _js_headers_to_pairs(js_headers: Any) -> list[tuple[str, str]]:
|
|
38
|
+
return [(str(entry[0]), str(entry[1])) for entry in js_headers.entries()]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _to_hayate_request(js_request: Any) -> Request:
|
|
42
|
+
body: bytes | None = None
|
|
43
|
+
if js_request.body is not None:
|
|
44
|
+
buffer = await js_request.arrayBuffer()
|
|
45
|
+
data = buffer.to_py() if hasattr(buffer, "to_py") else buffer
|
|
46
|
+
body = bytes(data) or None
|
|
47
|
+
return Request(
|
|
48
|
+
str(js_request.url),
|
|
49
|
+
method=str(js_request.method),
|
|
50
|
+
headers=Headers(_js_headers_to_pairs(js_request.headers), guard="immutable"),
|
|
51
|
+
body=body,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def _to_workers_response(
|
|
56
|
+
response: Response, workers_response_cls: Any, js_headers_cls: Any
|
|
57
|
+
) -> Any:
|
|
58
|
+
body = response.body
|
|
59
|
+
if body is not None and not isinstance(body, bytes):
|
|
60
|
+
body = await response.bytes() # buffered crossing in v0.2
|
|
61
|
+
js_headers = js_headers_cls.new()
|
|
62
|
+
for name, value in response.headers.raw():
|
|
63
|
+
js_headers.append(name, value)
|
|
64
|
+
return workers_response_cls(body, status=response.status, headers=js_headers)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _WorkersExecutionContext(ExecutionContext):
|
|
68
|
+
"""Forwards ``wait_until`` to the Workers runtime (``ctx.waitUntil``)."""
|
|
69
|
+
|
|
70
|
+
__slots__ = ("_js_ctx",)
|
|
71
|
+
|
|
72
|
+
def __init__(self, js_ctx: Any) -> None:
|
|
73
|
+
super().__init__()
|
|
74
|
+
self._js_ctx = js_ctx
|
|
75
|
+
|
|
76
|
+
def wait_until(self, awaitable: Any) -> None:
|
|
77
|
+
# Pyodide converts asyncio futures to JS promises at the boundary.
|
|
78
|
+
self._js_ctx.waitUntil(asyncio.ensure_future(awaitable))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def to_workers(app: Hayate) -> type:
|
|
82
|
+
"""Build the Workers entrypoint class: ``Default = to_workers(app)``."""
|
|
83
|
+
from js import Headers as JsHeaders
|
|
84
|
+
from workers import Response as WorkersResponse
|
|
85
|
+
from workers import WorkerEntrypoint
|
|
86
|
+
|
|
87
|
+
class Default(WorkerEntrypoint):
|
|
88
|
+
async def fetch(self, request: Any) -> Any:
|
|
89
|
+
hayate_request = await _to_hayate_request(request)
|
|
90
|
+
exec_ctx = _WorkersExecutionContext(self.ctx)
|
|
91
|
+
response = await app.fetch(hayate_request, env=self.env, ctx=exec_ctx)
|
|
92
|
+
return await _to_workers_response(response, WorkersResponse, JsHeaders)
|
|
93
|
+
|
|
94
|
+
return Default
|