crosswind-workers 0.1.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.
- _crosswind_patches/__init__.py +13 -0
- _crosswind_patches/entropy.py +259 -0
- _crosswind_patches/runtime.py +45 -0
- asgi/__init__.py +7 -0
- crosswind_workers-0.1.0.data/purelib/crosswind_workers_patches.pth +1 -0
- crosswind_workers-0.1.0.dist-info/METADATA +9 -0
- crosswind_workers-0.1.0.dist-info/RECORD +35 -0
- crosswind_workers-0.1.0.dist-info/WHEEL +4 -0
- workers/__init__.py +88 -0
- workers/_adapter.py +24 -0
- workers/_body.py +34 -0
- workers/_js.py +121 -0
- workers/asgi.py +523 -0
- workers/bindings/__init__.py +36 -0
- workers/bindings/base.py +135 -0
- workers/bindings/d1.py +47 -0
- workers/bindings/env.py +50 -0
- workers/bindings/kv.py +121 -0
- workers/bindings/queues.py +36 -0
- workers/bindings/r2.py +130 -0
- workers/bindings/services.py +55 -0
- workers/bindings/workflow.py +64 -0
- workers/blob.py +158 -0
- workers/context.py +230 -0
- workers/entrypoints.py +183 -0
- workers/fetch.py +51 -0
- workers/formdata.py +92 -0
- workers/py.typed +0 -0
- workers/request.py +138 -0
- workers/response.py +181 -0
- workers/rpc.py +154 -0
- workers/types.py +57 -0
- workers/utils.py +55 -0
- workers/workflows.py +265 -0
- workers/wsgi.py +426 -0
workers/asgi.py
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""Serve an ASGI application, such as FastAPI, from a Worker.
|
|
2
|
+
|
|
3
|
+
Ported from the stock SDK at cb2db3c. Background work after a streamed
|
|
4
|
+
response or a WebSocket upgrade goes through `ctx.waitUntil` when the caller
|
|
5
|
+
passes `ctx`, because the module-level waitUntil doesn't keep a Python Worker
|
|
6
|
+
alive past the response.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from asyncio import Event, Future, Queue, create_task, ensure_future
|
|
11
|
+
from collections.abc import Awaitable
|
|
12
|
+
from contextlib import contextmanager, suppress
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import unquote
|
|
15
|
+
|
|
16
|
+
import js
|
|
17
|
+
|
|
18
|
+
from ._adapter import NULL_BODY_STATUSES, body_encoding, request_headers
|
|
19
|
+
from ._js import to_js_headers as _to_js_headers
|
|
20
|
+
from .context import ExecutionContext, js_promise
|
|
21
|
+
from .entrypoints import WorkerEntrypoint
|
|
22
|
+
from .request import Request
|
|
23
|
+
from .types import Context
|
|
24
|
+
|
|
25
|
+
ASGI = {"spec_version": "2.0", "version": "3.0"}
|
|
26
|
+
logger = logging.getLogger("asgi")
|
|
27
|
+
background_tasks = set()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_in_background(coro: Awaitable[Any]) -> Future:
|
|
31
|
+
fut = ensure_future(coro)
|
|
32
|
+
background_tasks.add(fut)
|
|
33
|
+
|
|
34
|
+
def _on_done(f):
|
|
35
|
+
background_tasks.discard(f)
|
|
36
|
+
exc = f.exception() if not f.cancelled() else None
|
|
37
|
+
if exc is not None:
|
|
38
|
+
logger.error("Unhandled exception in background task", exc_info=exc)
|
|
39
|
+
|
|
40
|
+
fut.add_done_callback(_on_done)
|
|
41
|
+
return fut
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def close_stream_quietly(writer):
|
|
45
|
+
try:
|
|
46
|
+
await writer.close()
|
|
47
|
+
except Exception:
|
|
48
|
+
logger.debug("Closing the response stream failed", exc_info=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@contextmanager
|
|
52
|
+
def acquire_js_buffer(pybuffer):
|
|
53
|
+
from pyodide.ffi import create_proxy
|
|
54
|
+
|
|
55
|
+
px = create_proxy(pybuffer)
|
|
56
|
+
buf = px.getBuffer()
|
|
57
|
+
px.destroy()
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
yield buf.data
|
|
61
|
+
finally:
|
|
62
|
+
buf.release()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def request_to_scope(req, env, ws=False, state=None):
|
|
66
|
+
from js import URL
|
|
67
|
+
|
|
68
|
+
# @app.get("/example")
|
|
69
|
+
# async def example(request: Request):
|
|
70
|
+
# request.headers.get("content-type")
|
|
71
|
+
# - this will error if header is not "bytes" as in ASGI spec.
|
|
72
|
+
headers = [(k.lower().encode(), v.encode()) for k, v in request_headers(req)]
|
|
73
|
+
|
|
74
|
+
url = URL.new(req.url)
|
|
75
|
+
assert url.protocol[-1] == ":"
|
|
76
|
+
assert "?".startswith(url.search[0:1])
|
|
77
|
+
|
|
78
|
+
scope = {
|
|
79
|
+
"asgi": ASGI,
|
|
80
|
+
"headers": headers,
|
|
81
|
+
"http_version": "1.1",
|
|
82
|
+
"method": req.method,
|
|
83
|
+
"scheme": url.protocol[:-1],
|
|
84
|
+
# ASGI requires "path" to be percent-decoded; the encoded original is
|
|
85
|
+
# available to apps via "raw_path".
|
|
86
|
+
# https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope
|
|
87
|
+
"path": unquote(url.pathname),
|
|
88
|
+
"raw_path": url.pathname.encode(),
|
|
89
|
+
"query_string": url.search[1:].encode(),
|
|
90
|
+
"type": "websocket" if ws else "http",
|
|
91
|
+
"env": env,
|
|
92
|
+
}
|
|
93
|
+
if state is not None:
|
|
94
|
+
# ASGI requires a shallow copy of lifespan state for each request.
|
|
95
|
+
scope["state"] = dict(state)
|
|
96
|
+
|
|
97
|
+
return scope
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _resolve(future: Future, value: Any) -> None:
|
|
101
|
+
if not future.done():
|
|
102
|
+
future.set_result(value)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _fail(future: Future, message: str) -> None:
|
|
106
|
+
if not future.done():
|
|
107
|
+
future.set_exception(RuntimeError(message))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _Lifespan:
|
|
111
|
+
"""One ASGI lifespan startup and shutdown cycle of an app.
|
|
112
|
+
|
|
113
|
+
`startup` resolves True on `lifespan.startup.complete`, False when the app
|
|
114
|
+
has no lifespan support, and raises on `lifespan.startup.failed`.
|
|
115
|
+
`shutdown_complete` mirrors the shutdown phase.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, app: Any) -> None:
|
|
119
|
+
self.app = app
|
|
120
|
+
self.state: dict[str, Any] = {}
|
|
121
|
+
self.startup: Future[bool] = Future()
|
|
122
|
+
self.shutdown_complete: Future[None] = Future()
|
|
123
|
+
self.receive_queue: Queue[dict[str, Any]] = Queue()
|
|
124
|
+
self.receive_queue.put_nowait({"type": "lifespan.startup"})
|
|
125
|
+
|
|
126
|
+
async def receive(self) -> dict[str, Any]:
|
|
127
|
+
return await self.receive_queue.get()
|
|
128
|
+
|
|
129
|
+
async def send(self, got: dict[str, Any]) -> None:
|
|
130
|
+
# Failures use `or`, not a .get default: an empty message field is
|
|
131
|
+
# legal and would otherwise produce RuntimeError('').
|
|
132
|
+
message = got.get("message")
|
|
133
|
+
if got["type"] == "lifespan.startup.complete":
|
|
134
|
+
_resolve(self.startup, True)
|
|
135
|
+
elif got["type"] == "lifespan.startup.failed":
|
|
136
|
+
_fail(self.startup, message or "ASGI lifespan startup failed")
|
|
137
|
+
elif got["type"] == "lifespan.shutdown.complete":
|
|
138
|
+
_resolve(self.shutdown_complete, None)
|
|
139
|
+
elif got["type"] == "lifespan.shutdown.failed":
|
|
140
|
+
_fail(self.shutdown_complete, message or "ASGI lifespan shutdown failed")
|
|
141
|
+
else:
|
|
142
|
+
raise RuntimeError(f"Unexpected lifespan event {got['type']}")
|
|
143
|
+
|
|
144
|
+
async def run(self) -> None:
|
|
145
|
+
scope = {"asgi": ASGI, "state": self.state, "type": "lifespan"}
|
|
146
|
+
try:
|
|
147
|
+
await self.app(scope, self.receive, self.send)
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
# Spec: an exception raised before startup is acked signals that
|
|
150
|
+
# the app doesn't support lifespan; serve requests anyway. Log it,
|
|
151
|
+
# or an app that crashed mid-startup looks like one with no
|
|
152
|
+
# lifespan handler at all.
|
|
153
|
+
if not self.startup.done():
|
|
154
|
+
logger.debug(
|
|
155
|
+
"ASGI lifespan task raised before reporting startup; "
|
|
156
|
+
"treating the app as not supporting lifespan",
|
|
157
|
+
exc_info=exc,
|
|
158
|
+
)
|
|
159
|
+
self.startup.set_result(False)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
# After a successful startup, a shutdown-phase error can't affect
|
|
163
|
+
# the already-served request, so log it and let shutdown complete.
|
|
164
|
+
logger.exception("Exception in ASGI lifespan application", exc_info=exc)
|
|
165
|
+
_resolve(self.shutdown_complete, None)
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
# App returned without acking: a missing startup ack means it has no
|
|
169
|
+
# lifespan handler, so mark startup unsupported rather than hanging.
|
|
170
|
+
if not self.startup.done():
|
|
171
|
+
self.startup.set_result(False)
|
|
172
|
+
else:
|
|
173
|
+
_resolve(self.shutdown_complete, None)
|
|
174
|
+
|
|
175
|
+
async def shutdown(self) -> None:
|
|
176
|
+
# Nothing to shut down if the app never completed a lifespan startup.
|
|
177
|
+
if self.startup.done() and not self.startup.result():
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
await self.receive_queue.put({"type": "lifespan.shutdown"})
|
|
181
|
+
await self.shutdown_complete
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def _no_lifespan_shutdown() -> None:
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def start_application(app):
|
|
189
|
+
# Drives one ASGI lifespan startup/shutdown cycle before/after serving the
|
|
190
|
+
# request. The lifespan protocol is optional, so we must tolerate apps that
|
|
191
|
+
# don't implement it and fall back to serving requests without lifespan.
|
|
192
|
+
# https://asgi.readthedocs.io/en/latest/specs/lifespan.html
|
|
193
|
+
lifespan = _Lifespan(app)
|
|
194
|
+
run_in_background(lifespan.run())
|
|
195
|
+
if not await lifespan.startup:
|
|
196
|
+
return _no_lifespan_shutdown, lifespan.state
|
|
197
|
+
|
|
198
|
+
return lifespan.shutdown, lifespan.state
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class _HttpCycle:
|
|
202
|
+
"""One HTTP request through an ASGI app, and the `js.Response` it sends."""
|
|
203
|
+
|
|
204
|
+
def __init__(self, app: Any) -> None:
|
|
205
|
+
self.app = app
|
|
206
|
+
self.status: int | None = None
|
|
207
|
+
self.headers: list[tuple[str, str]] | None = None
|
|
208
|
+
self.result: Future[js.Response] = Future()
|
|
209
|
+
self.finished_response = Event()
|
|
210
|
+
self.receive_queue: Queue[dict[str, Any]] = Queue()
|
|
211
|
+
# Streaming state, set on the first body chunk with more_body=True.
|
|
212
|
+
self.writer: Any = None
|
|
213
|
+
self.writer_closed = False
|
|
214
|
+
|
|
215
|
+
async def queue_body(self, req: "Request | js.Request") -> None:
|
|
216
|
+
if req.body:
|
|
217
|
+
async for data in req.body:
|
|
218
|
+
await self.receive_queue.put(
|
|
219
|
+
{"body": data.to_bytes(), "more_body": True, "type": "http.request"}
|
|
220
|
+
)
|
|
221
|
+
await self.receive_queue.put(
|
|
222
|
+
{"body": b"", "more_body": False, "type": "http.request"}
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
async def receive(self) -> dict[str, Any]:
|
|
226
|
+
if not self.receive_queue.empty():
|
|
227
|
+
return await self.receive_queue.get()
|
|
228
|
+
|
|
229
|
+
await self.finished_response.wait()
|
|
230
|
+
return {"type": "http.disconnect"}
|
|
231
|
+
|
|
232
|
+
async def send(self, got: dict[str, Any]) -> None:
|
|
233
|
+
if got["type"] == "http.response.start":
|
|
234
|
+
self.status = got["status"]
|
|
235
|
+
# Like above, we need to convert byte-pairs into string explicitly.
|
|
236
|
+
# A response header name may repeat (multiple Set-Cookie), so this
|
|
237
|
+
# must stay a sequence of pairs, not a name-keyed mapping.
|
|
238
|
+
self.headers = [(k.decode(), v.decode()) for k, v in got.get("headers", [])]
|
|
239
|
+
elif got["type"] == "http.response.body":
|
|
240
|
+
await self._send_body(got.get("body", b""), got.get("more_body", False))
|
|
241
|
+
|
|
242
|
+
async def _send_body(self, body: bytes, more_body: bool) -> None:
|
|
243
|
+
if self.writer is None and more_body:
|
|
244
|
+
self._start_stream()
|
|
245
|
+
if self.writer is None:
|
|
246
|
+
self._respond(body)
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
with acquire_js_buffer(body) as jsbytes:
|
|
250
|
+
await self.writer.write(jsbytes.slice())
|
|
251
|
+
if not more_body:
|
|
252
|
+
await self.writer.close()
|
|
253
|
+
self.writer_closed = True
|
|
254
|
+
self.finished_response.set()
|
|
255
|
+
|
|
256
|
+
def _start_stream(self) -> None:
|
|
257
|
+
# A TransformStream lets the runtime consume body chunks as they are
|
|
258
|
+
# written.
|
|
259
|
+
from js import Response, TransformStream
|
|
260
|
+
|
|
261
|
+
transform_stream = TransformStream.new()
|
|
262
|
+
self.writer = transform_stream.writable.getWriter()
|
|
263
|
+
self.result.set_result(
|
|
264
|
+
Response.new(transform_stream.readable, **self._response_init())
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def _respond(self, body: Any) -> None:
|
|
268
|
+
from js import Response
|
|
269
|
+
from pyodide.ffi import create_proxy
|
|
270
|
+
|
|
271
|
+
if self.status in NULL_BODY_STATUSES:
|
|
272
|
+
data = None
|
|
273
|
+
else:
|
|
274
|
+
px = create_proxy(body)
|
|
275
|
+
buf = px.getBuffer()
|
|
276
|
+
px.destroy()
|
|
277
|
+
data = buf.data
|
|
278
|
+
|
|
279
|
+
self.result.set_result(Response.new(data, **self._response_init()))
|
|
280
|
+
self.finished_response.set()
|
|
281
|
+
|
|
282
|
+
def _response_init(self) -> dict[str, Any]:
|
|
283
|
+
return {
|
|
284
|
+
"headers": _to_js_headers(self.headers),
|
|
285
|
+
"status": self.status,
|
|
286
|
+
**body_encoding(self.headers or []),
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async def run(
|
|
290
|
+
self, req: "Request | js.Request", env: Any, state: dict[str, Any] | None
|
|
291
|
+
) -> None:
|
|
292
|
+
try:
|
|
293
|
+
scope = request_to_scope(req, env, state=state)
|
|
294
|
+
await self.app(scope, self.receive, self.send)
|
|
295
|
+
# If no response has been set yet, the app didn't generate one.
|
|
296
|
+
if not self.result.done():
|
|
297
|
+
raise RuntimeError("The application did not generate a response")
|
|
298
|
+
except Exception as e:
|
|
299
|
+
if not self.result.done():
|
|
300
|
+
self.result.set_exception(e)
|
|
301
|
+
if self.writer is not None:
|
|
302
|
+
await self.writer.close()
|
|
303
|
+
self.finished_response.set()
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
# Response already sent — exception can't be propagated to the
|
|
307
|
+
# client, so log it to avoid silently swallowing errors.
|
|
308
|
+
logger.exception("Exception in ASGI application after response started")
|
|
309
|
+
self.finished_response.set()
|
|
310
|
+
if self.writer is not None and not self.writer_closed:
|
|
311
|
+
# End the body so a client reading it sees the stream stop
|
|
312
|
+
# instead of waiting forever for chunks that will never come.
|
|
313
|
+
# Deliberately not awaited: close() settles only once the
|
|
314
|
+
# queued chunks reach a consumer, so a response body that
|
|
315
|
+
# nobody reads would block this task, which the runtime is
|
|
316
|
+
# waiting on through wait_until.
|
|
317
|
+
run_in_background(close_stream_quietly(self.writer))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
async def process_request(
|
|
321
|
+
app: Any,
|
|
322
|
+
req: "Request | js.Request",
|
|
323
|
+
env: Any,
|
|
324
|
+
# Unused, kept for the stock signature.
|
|
325
|
+
ctx: "Context | ExecutionContext | None",
|
|
326
|
+
state: dict[str, Any] | None = None,
|
|
327
|
+
) -> tuple[js.Response, Future[None]]:
|
|
328
|
+
cycle = _HttpCycle(app)
|
|
329
|
+
await cycle.queue_body(req)
|
|
330
|
+
request_task = create_task(cycle.run(req, env, state))
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
response = await cycle.result
|
|
334
|
+
except Exception:
|
|
335
|
+
# The request task handles application exceptions and should be allowed
|
|
336
|
+
# to finish before its error is propagated to fetch().
|
|
337
|
+
await request_task
|
|
338
|
+
raise
|
|
339
|
+
|
|
340
|
+
# Buffered responses do not depend on a consumer, so request cleanup can
|
|
341
|
+
# finish before returning. Streaming responses must run alongside the
|
|
342
|
+
# consumer because writes can be subject to backpressure.
|
|
343
|
+
if cycle.writer is None:
|
|
344
|
+
await request_task
|
|
345
|
+
|
|
346
|
+
return response, request_task
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _wait_until(
|
|
350
|
+
ctx: "Context | ExecutionContext | None", awaitable: Awaitable[Any]
|
|
351
|
+
) -> None:
|
|
352
|
+
if ctx is None:
|
|
353
|
+
from workers import wait_until
|
|
354
|
+
|
|
355
|
+
wait_until(awaitable)
|
|
356
|
+
else:
|
|
357
|
+
ctx.waitUntil(js_promise(awaitable))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class _WebSocketSession:
|
|
361
|
+
"""Bridges a Workers server WebSocket to an ASGI app's receive and send."""
|
|
362
|
+
|
|
363
|
+
def __init__(self, server: Any) -> None:
|
|
364
|
+
self.server = server
|
|
365
|
+
self.queue: Queue[dict[str, Any]] = Queue()
|
|
366
|
+
self.app_closed = False
|
|
367
|
+
|
|
368
|
+
def onopen(self, evt: Any) -> None:
|
|
369
|
+
self.queue.put_nowait({"type": "websocket.connect"})
|
|
370
|
+
|
|
371
|
+
def onclose(self, evt: Any) -> None:
|
|
372
|
+
# Client-initiated closes surface to the app as "websocket.disconnect"
|
|
373
|
+
# per the ASGI spec ("websocket.close" is the app->server direction);
|
|
374
|
+
# frameworks raise their disconnect exceptions only on this type.
|
|
375
|
+
self.queue.put_nowait(
|
|
376
|
+
{"type": "websocket.disconnect", "code": evt.code, "reason": evt.reason}
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def onmessage(self, evt: Any) -> None:
|
|
380
|
+
data = evt.data
|
|
381
|
+
if isinstance(data, str):
|
|
382
|
+
self.queue.put_nowait({"type": "websocket.receive", "text": data})
|
|
383
|
+
else:
|
|
384
|
+
# Binary frames arrive as an ArrayBuffer; the ASGI spec requires
|
|
385
|
+
# them under "bytes" (frameworks like Starlette dispatch on which
|
|
386
|
+
# key is present, so labeling them "text" breaks receive_bytes()).
|
|
387
|
+
self.queue.put_nowait(
|
|
388
|
+
{"type": "websocket.receive", "bytes": data.to_bytes()}
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
async def receive(self) -> dict[str, Any]:
|
|
392
|
+
return await self.queue.get()
|
|
393
|
+
|
|
394
|
+
async def send(self, got: dict[str, Any]) -> None:
|
|
395
|
+
if got["type"] == "websocket.accept":
|
|
396
|
+
# The Workers WebSocketPair is accepted before the upgrade response
|
|
397
|
+
# is returned, so there is no deferred accept operation here.
|
|
398
|
+
return
|
|
399
|
+
if got["type"] == "websocket.send":
|
|
400
|
+
self._send_frame(got.get("bytes"), got.get("text"))
|
|
401
|
+
return
|
|
402
|
+
if got["type"] == "websocket.close":
|
|
403
|
+
self.app_closed = True
|
|
404
|
+
self.server.close(got.get("code", 1000), got.get("reason", ""))
|
|
405
|
+
return
|
|
406
|
+
logger.warning(" == Not implemented %s", got["type"])
|
|
407
|
+
|
|
408
|
+
def _send_frame(self, data: bytes | None, text: str | None) -> None:
|
|
409
|
+
if data is not None:
|
|
410
|
+
with acquire_js_buffer(data) as jsbytes:
|
|
411
|
+
# Unlike the `Response` constructor, server.send seems to
|
|
412
|
+
# eagerly copy the source buffer
|
|
413
|
+
self.server.send(jsbytes)
|
|
414
|
+
if text is not None:
|
|
415
|
+
self.server.send(text)
|
|
416
|
+
|
|
417
|
+
def close_transport(self, finished: Future) -> None:
|
|
418
|
+
if self.app_closed:
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
# The app ended without closing, so a half-open connection would leave
|
|
422
|
+
# the client hanging instead of reconnecting.
|
|
423
|
+
failed = not finished.cancelled() and finished.exception() is not None
|
|
424
|
+
# The peer may already have closed the socket.
|
|
425
|
+
with suppress(Exception):
|
|
426
|
+
self.server.close(1011 if failed else 1000, "")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
async def process_websocket(
|
|
430
|
+
app: Any,
|
|
431
|
+
req: "Request | js.Request",
|
|
432
|
+
env: Any = None,
|
|
433
|
+
ctx: "Context | ExecutionContext | None" = None,
|
|
434
|
+
) -> js.Response:
|
|
435
|
+
from js import Response, WebSocketPair
|
|
436
|
+
|
|
437
|
+
client, server = WebSocketPair.new().object_values()
|
|
438
|
+
server.accept()
|
|
439
|
+
# Binary frames otherwise arrive as Blob (observed under wrangler dev),
|
|
440
|
+
# which cannot be read synchronously in the message callback.
|
|
441
|
+
server.binaryType = "arraybuffer"
|
|
442
|
+
|
|
443
|
+
session = _WebSocketSession(server)
|
|
444
|
+
# onopen doesn't seem to get called. WS lifecycle events are a bit messed up
|
|
445
|
+
# here.
|
|
446
|
+
session.onopen(1)
|
|
447
|
+
server.onopen = session.onopen
|
|
448
|
+
server.onclose = session.onclose
|
|
449
|
+
server.onmessage = session.onmessage
|
|
450
|
+
|
|
451
|
+
scope = request_to_scope(req, env if env is not None else {}, ws=True)
|
|
452
|
+
task = run_in_background(app(scope, session.receive, session.send))
|
|
453
|
+
task.add_done_callback(session.close_transport)
|
|
454
|
+
_wait_until(ctx, task)
|
|
455
|
+
|
|
456
|
+
return Response.new(None, status=101, webSocket=client)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
async def fetch(
|
|
460
|
+
app: Any,
|
|
461
|
+
req: "Request | js.Request",
|
|
462
|
+
env: Any,
|
|
463
|
+
ctx: "Context | ExecutionContext | None" = None,
|
|
464
|
+
) -> js.Response:
|
|
465
|
+
logger.debug("ASGI request: %s %s", req.method, req.url)
|
|
466
|
+
shutdown, state = await start_application(app)
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
result, request_task = await process_request(app, req, env, ctx, state=state)
|
|
470
|
+
except Exception:
|
|
471
|
+
logger.exception("ASGI request failed")
|
|
472
|
+
await shutdown()
|
|
473
|
+
raise
|
|
474
|
+
|
|
475
|
+
if request_task.done():
|
|
476
|
+
await shutdown()
|
|
477
|
+
else:
|
|
478
|
+
|
|
479
|
+
async def finalize_request():
|
|
480
|
+
try:
|
|
481
|
+
await request_task
|
|
482
|
+
finally:
|
|
483
|
+
await shutdown()
|
|
484
|
+
|
|
485
|
+
_wait_until(ctx, run_in_background(finalize_request()))
|
|
486
|
+
|
|
487
|
+
return result
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
async def websocket(
|
|
491
|
+
app: Any,
|
|
492
|
+
req: "Request | js.Request",
|
|
493
|
+
env: Any = None,
|
|
494
|
+
ctx: "Context | ExecutionContext | None" = None,
|
|
495
|
+
) -> js.Response:
|
|
496
|
+
return await process_websocket(app, req, env, ctx)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def entrypoint(app: Any) -> type[WorkerEntrypoint]:
|
|
500
|
+
"""Create the default Worker entrypoint for an ASGI application."""
|
|
501
|
+
|
|
502
|
+
class Default(WorkerEntrypoint):
|
|
503
|
+
async def fetch(self, request):
|
|
504
|
+
# getattr: stock's tests build this class without calling __init__.
|
|
505
|
+
ctx = getattr(self, "ctx", None)
|
|
506
|
+
if (request.headers.get("upgrade") or "").lower() == "websocket":
|
|
507
|
+
return await websocket(app, request, self.env, ctx)
|
|
508
|
+
return await fetch(app, request, self.env, ctx)
|
|
509
|
+
|
|
510
|
+
return Default
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def __getattr__(name):
|
|
514
|
+
if name == "env":
|
|
515
|
+
from fastapi import Depends, Request # ty: ignore[unresolved-import]
|
|
516
|
+
|
|
517
|
+
@Depends
|
|
518
|
+
async def env(request: Request):
|
|
519
|
+
return request.scope["env"]
|
|
520
|
+
|
|
521
|
+
return env
|
|
522
|
+
|
|
523
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Typed classes for Worker bindings, and `Binding` for every other kind.
|
|
2
|
+
|
|
3
|
+
A name a class doesn't define passes through to the JS object under its exact
|
|
4
|
+
JS name, with arguments and results converted as for RPC. Typed methods take
|
|
5
|
+
snake_case keywords and forward any keyword they don't know into the JS
|
|
6
|
+
options object unchanged, so stock calls like `kv.put(k, v, expirationTtl=60)`
|
|
7
|
+
keep working.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .base import Binding
|
|
11
|
+
from .base import _convert as _convert
|
|
12
|
+
from .base import _js_options as _js_options
|
|
13
|
+
from .base import _RpcStub as _RpcStub
|
|
14
|
+
from .d1 import D1Database, D1PreparedStatement
|
|
15
|
+
from .env import _EnvWrapper as _EnvWrapper
|
|
16
|
+
from .kv import KVNamespace
|
|
17
|
+
from .queues import Queue
|
|
18
|
+
from .r2 import R2Bucket, R2Object, R2ObjectBody
|
|
19
|
+
from .services import DurableObjectNamespace, Fetcher
|
|
20
|
+
from .workflow import Workflow, WorkflowInstance
|
|
21
|
+
|
|
22
|
+
# The private names above are for the rest of the SDK and rpc's stock aliases.
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Binding",
|
|
25
|
+
"D1Database",
|
|
26
|
+
"D1PreparedStatement",
|
|
27
|
+
"DurableObjectNamespace",
|
|
28
|
+
"Fetcher",
|
|
29
|
+
"KVNamespace",
|
|
30
|
+
"Queue",
|
|
31
|
+
"R2Bucket",
|
|
32
|
+
"R2Object",
|
|
33
|
+
"R2ObjectBody",
|
|
34
|
+
"Workflow",
|
|
35
|
+
"WorkflowInstance",
|
|
36
|
+
]
|
workers/bindings/base.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""`Binding`, the passthrough wrapper every binding class builds on, and the
|
|
2
|
+
conversions its methods share."""
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import Any, Self
|
|
7
|
+
|
|
8
|
+
from .._js import js_name
|
|
9
|
+
from ..response import RESPONSE_ACCEPTED_TYPES
|
|
10
|
+
from ..rpc import python_from_rpc, python_to_rpc
|
|
11
|
+
|
|
12
|
+
__all__ = ["Binding"]
|
|
13
|
+
|
|
14
|
+
# JS values that stay raw when they come back from a binding, as in the stock
|
|
15
|
+
# SDK: they are meant for Response() and friends, have Python twins, or are
|
|
16
|
+
# promises Python awaits.
|
|
17
|
+
_RAW_JS_TYPES = RESPONSE_ACCEPTED_TYPES | {"Headers", "Promise"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Binding:
|
|
21
|
+
"""A binding with no typed class. Every name passes through to JS."""
|
|
22
|
+
|
|
23
|
+
def __new__(cls, js_object: Any) -> Self:
|
|
24
|
+
# Wrapping a wrapper of this class returns it unchanged.
|
|
25
|
+
return js_object if isinstance(js_object, cls) else super().__new__(cls)
|
|
26
|
+
|
|
27
|
+
def __init__(self, js_object: Any) -> None:
|
|
28
|
+
if js_object is not self:
|
|
29
|
+
self.js_object = js_object
|
|
30
|
+
|
|
31
|
+
def __repr__(self) -> str:
|
|
32
|
+
return f"{type(self).__name__}({js_name(self.js_object)})"
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def _binding(self) -> Any:
|
|
36
|
+
# The stock SDK's private name for the JS object, which its tests read.
|
|
37
|
+
return self.js_object
|
|
38
|
+
|
|
39
|
+
def __getattr__(self, name: str) -> Any:
|
|
40
|
+
# Python's protocol lookups stay off JS. A JS name such as
|
|
41
|
+
# `__unsafe_ensureInitialized` has no trailing `__` and passes through.
|
|
42
|
+
if (name.startswith("__") and name.endswith("__")) or name == "js_object":
|
|
43
|
+
raise AttributeError(name)
|
|
44
|
+
return self._pass_through(name, getattr(self.js_object, name))
|
|
45
|
+
|
|
46
|
+
def _pass_through(self, name: str, value: Any) -> Any:
|
|
47
|
+
"""A JS attribute's value as Python sees it, for a name the class
|
|
48
|
+
doesn't define. Subclasses override this to convert differently."""
|
|
49
|
+
if not callable(value):
|
|
50
|
+
# Read on every access. The stock SDK caches the first value.
|
|
51
|
+
return _convert(value)
|
|
52
|
+
return lambda *args, **kwargs: _invoke(value, args, kwargs)
|
|
53
|
+
|
|
54
|
+
def __getitem__(self, key: int | str) -> Any:
|
|
55
|
+
if isinstance(key, int):
|
|
56
|
+
return _convert(self.js_object[key])
|
|
57
|
+
return _convert(getattr(self.js_object, key))
|
|
58
|
+
|
|
59
|
+
def __iter__(self) -> Iterator[Any]:
|
|
60
|
+
if not hasattr(self.js_object, "__iter__"):
|
|
61
|
+
raise TypeError(f"'{js_name(self.js_object)}' object is not iterable")
|
|
62
|
+
|
|
63
|
+
for item in self.js_object:
|
|
64
|
+
yield _convert(item)
|
|
65
|
+
|
|
66
|
+
def __bool__(self) -> bool:
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
def __len__(self) -> int:
|
|
70
|
+
length = getattr(self.js_object, "length", None)
|
|
71
|
+
# An RPC stub answers every attribute, so check for a real number.
|
|
72
|
+
if not isinstance(length, int):
|
|
73
|
+
raise TypeError(f"'{js_name(self.js_object)}' object has no len()")
|
|
74
|
+
return length
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _RpcStub(Binding):
|
|
78
|
+
"""A function or RpcTarget received over RPC."""
|
|
79
|
+
|
|
80
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
81
|
+
return _invoke(self.js_object, args, kwargs)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _invoke(function: Any, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
85
|
+
result = function(
|
|
86
|
+
*map(python_to_rpc, args), **{k: python_to_rpc(v) for k, v in kwargs.items()}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if callable(getattr(result, "then", None)):
|
|
90
|
+
|
|
91
|
+
async def settle() -> Any:
|
|
92
|
+
return _convert(await result)
|
|
93
|
+
|
|
94
|
+
return settle()
|
|
95
|
+
|
|
96
|
+
return _convert(result)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _convert(value: Any) -> Any:
|
|
100
|
+
converted = python_from_rpc(value)
|
|
101
|
+
if isinstance(converted, list):
|
|
102
|
+
return [_wrap(item) for item in converted]
|
|
103
|
+
return _wrap(converted)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _wrap(value: Any) -> Any:
|
|
107
|
+
"""Wrap a JS value that conversion left as JS, so its names pass through."""
|
|
108
|
+
name = js_name(value)
|
|
109
|
+
if name is None or name in _RAW_JS_TYPES:
|
|
110
|
+
return value
|
|
111
|
+
return _RpcStub(value) if callable(value) else Binding(value)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _js_options(
|
|
115
|
+
given: Any = None, extra: dict[str, Any] | None = None, **named: Any
|
|
116
|
+
) -> Any:
|
|
117
|
+
"""One JS options object from a typed method's arguments.
|
|
118
|
+
|
|
119
|
+
`given` is the options argument in the stock SDK's position: None, a dict
|
|
120
|
+
of JS names, or a JS object. The snake_case `named` options become
|
|
121
|
+
camelCase and unset ones are left out. `extra` holds the caller's other
|
|
122
|
+
keywords, forwarded unchanged.
|
|
123
|
+
"""
|
|
124
|
+
named = {_camel(key): value for key, value in named.items() if value is not None}
|
|
125
|
+
|
|
126
|
+
if given is not None and not isinstance(given, dict):
|
|
127
|
+
if named or extra:
|
|
128
|
+
raise TypeError("pass options as a JS object or as keywords, not both")
|
|
129
|
+
return given
|
|
130
|
+
|
|
131
|
+
return python_to_rpc(named | (given or {}) | (extra or {}))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _camel(name: str) -> str:
|
|
135
|
+
return re.sub(r"_(\w)", lambda match: match.group(1).upper(), name)
|