supython 0.5.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.
- supython/__init__.py +8 -0
- supython/admin/__init__.py +3 -0
- supython/admin/api/__init__.py +24 -0
- supython/admin/api/auth.py +118 -0
- supython/admin/api/auth_templates.py +67 -0
- supython/admin/api/auth_users.py +225 -0
- supython/admin/api/db.py +174 -0
- supython/admin/api/functions.py +92 -0
- supython/admin/api/jobs.py +192 -0
- supython/admin/api/ops.py +224 -0
- supython/admin/api/realtime.py +281 -0
- supython/admin/api/service_auth.py +49 -0
- supython/admin/api/service_auth_templates.py +83 -0
- supython/admin/api/service_auth_users.py +346 -0
- supython/admin/api/service_db.py +214 -0
- supython/admin/api/service_functions.py +287 -0
- supython/admin/api/service_jobs.py +282 -0
- supython/admin/api/service_ops.py +213 -0
- supython/admin/api/service_realtime.py +30 -0
- supython/admin/api/service_storage.py +220 -0
- supython/admin/api/storage.py +117 -0
- supython/admin/api/system.py +37 -0
- supython/admin/audit.py +29 -0
- supython/admin/deps.py +22 -0
- supython/admin/errors.py +16 -0
- supython/admin/schemas.py +310 -0
- supython/admin/session.py +52 -0
- supython/admin/spa.py +38 -0
- supython/admin/static/assets/Alert-dluGVkos.js +49 -0
- supython/admin/static/assets/Audit-Njung3HI.js +2 -0
- supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
- supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
- supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
- supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
- supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
- supython/admin/static/assets/Crons-B67vc39F.js +2 -0
- supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
- supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
- supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
- supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
- supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
- supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
- supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
- supython/admin/static/assets/Input-DppYTq9C.js +259 -0
- supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
- supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
- supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
- supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
- supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
- supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
- supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
- supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
- supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
- supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
- supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
- supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
- supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
- supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
- supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
- supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
- supython/admin/static/assets/Space-n5-XcguU.js +400 -0
- supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
- supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
- supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
- supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
- supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
- supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
- supython/admin/static/assets/Users-wzwajhlh.js +2 -0
- supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
- supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
- supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
- supython/admin/static/assets/get-Ca6unauB.js +2 -0
- supython/admin/static/assets/index-CeE6v959.js +951 -0
- supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
- supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
- supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
- supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
- supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
- supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
- supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
- supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
- supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
- supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
- supython/admin/static/favicon.svg +1 -0
- supython/admin/static/icons.svg +24 -0
- supython/admin/static/index.html +24 -0
- supython/app.py +149 -0
- supython/auth/__init__.py +3 -0
- supython/auth/_email_job.py +11 -0
- supython/auth/providers/__init__.py +34 -0
- supython/auth/providers/github.py +22 -0
- supython/auth/providers/google.py +19 -0
- supython/auth/providers/oauth.py +56 -0
- supython/auth/providers/registry.py +16 -0
- supython/auth/ratelimit.py +39 -0
- supython/auth/router.py +282 -0
- supython/auth/schemas.py +79 -0
- supython/auth/service.py +587 -0
- supython/body_size.py +184 -0
- supython/cli.py +1653 -0
- supython/client/__init__.py +67 -0
- supython/client/_auth.py +249 -0
- supython/client/_client.py +145 -0
- supython/client/_config.py +92 -0
- supython/client/_functions.py +69 -0
- supython/client/_storage.py +255 -0
- supython/client/py.typed +0 -0
- supython/db.py +151 -0
- supython/db_admin.py +8 -0
- supython/functions/__init__.py +19 -0
- supython/functions/context.py +262 -0
- supython/functions/loader.py +307 -0
- supython/functions/router.py +228 -0
- supython/functions/schemas.py +50 -0
- supython/gen/__init__.py +5 -0
- supython/gen/_introspect.py +137 -0
- supython/gen/types_py.py +270 -0
- supython/gen/types_ts.py +365 -0
- supython/health.py +229 -0
- supython/hooks.py +117 -0
- supython/jobs/__init__.py +31 -0
- supython/jobs/backends.py +97 -0
- supython/jobs/context.py +58 -0
- supython/jobs/cron.py +152 -0
- supython/jobs/cron_inproc.py +118 -0
- supython/jobs/decorators.py +76 -0
- supython/jobs/registry.py +79 -0
- supython/jobs/router.py +136 -0
- supython/jobs/schemas.py +92 -0
- supython/jobs/service.py +311 -0
- supython/jobs/worker.py +219 -0
- supython/jwks.py +257 -0
- supython/keyset.py +279 -0
- supython/logging_config.py +291 -0
- supython/mail.py +33 -0
- supython/mailer.py +65 -0
- supython/migrate.py +81 -0
- supython/migrations/0001_extensions_and_roles.sql +46 -0
- supython/migrations/0002_auth_schema.sql +66 -0
- supython/migrations/0003_demo_todos.sql +42 -0
- supython/migrations/0004_auth_v0_2.sql +47 -0
- supython/migrations/0005_storage_schema.sql +117 -0
- supython/migrations/0006_realtime_schema.sql +206 -0
- supython/migrations/0007_jobs_schema.sql +254 -0
- supython/migrations/0008_jobs_last_error.sql +56 -0
- supython/migrations/0009_auth_rate_limits.sql +33 -0
- supython/migrations/0010_worker_heartbeat.sql +14 -0
- supython/migrations/0011_admin_schema.sql +45 -0
- supython/migrations/0012_auth_banned_until.sql +10 -0
- supython/migrations/0013_email_templates.sql +19 -0
- supython/migrations/0014_realtime_payload_warning.sql +96 -0
- supython/migrations/0015_backups_schema.sql +14 -0
- supython/passwords.py +15 -0
- supython/realtime/__init__.py +6 -0
- supython/realtime/broker.py +814 -0
- supython/realtime/protocol.py +234 -0
- supython/realtime/router.py +184 -0
- supython/realtime/schemas.py +207 -0
- supython/realtime/service.py +261 -0
- supython/realtime/topics.py +175 -0
- supython/realtime/websocket.py +586 -0
- supython/scaffold/__init__.py +5 -0
- supython/scaffold/init_project.py +133 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
- supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
- supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
- supython/scaffold/templates/env.example.tmpl +149 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +198 -0
- supython/storage/__init__.py +5 -0
- supython/storage/backends.py +392 -0
- supython/storage/router.py +341 -0
- supython/storage/schemas.py +50 -0
- supython/storage/service.py +445 -0
- supython/storage/signing.py +119 -0
- supython/tokens.py +85 -0
- supython-0.5.0.dist-info/METADATA +714 -0
- supython-0.5.0.dist-info/RECORD +188 -0
- supython-0.5.0.dist-info/WHEEL +4 -0
- supython-0.5.0.dist-info/entry_points.txt +2 -0
- supython-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
"""FastAPI WebSocket route for ``/realtime/v1/websocket``.
|
|
2
|
+
|
|
3
|
+
This module is the only place where Phoenix Channels framing meets
|
|
4
|
+
Starlette's WebSocket transport. The routing surface is a single
|
|
5
|
+
endpoint::
|
|
6
|
+
|
|
7
|
+
ws://<host>/realtime/v1/websocket?apikey=<jwt>&vsn=1.0.0
|
|
8
|
+
|
|
9
|
+
All framing, encode/decode, ref counters, and heartbeat timing live in
|
|
10
|
+
:mod:`.protocol`. Broker state (subscriptions, presence, fan-out) lives
|
|
11
|
+
in :mod:`.broker`. This module only:
|
|
12
|
+
|
|
13
|
+
* extracts the JWT (query string ``?apikey=`` or
|
|
14
|
+
``Sec-WebSocket-Protocol: bearer, <jwt>`` subprotocol — both paths
|
|
15
|
+
used by the official Supabase SDKs);
|
|
16
|
+
* runs three concurrent tasks per connection (reader, writer, heartbeat
|
|
17
|
+
watchdog) using :class:`asyncio.TaskGroup`;
|
|
18
|
+
* dispatches incoming frames to the right broker call;
|
|
19
|
+
* maps domain errors to ``phx_reply`` ``status="error"``.
|
|
20
|
+
|
|
21
|
+
Token decoding goes through :func:`tokens.decode_access_token` so RS256
|
|
22
|
+
support (deferred to v1.0) lights up automatically when the auth module
|
|
23
|
+
ships it.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import logging
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import jwt
|
|
31
|
+
from fastapi import APIRouter, WebSocket
|
|
32
|
+
from pydantic import ValidationError
|
|
33
|
+
from starlette.websockets import WebSocketDisconnect, WebSocketState
|
|
34
|
+
|
|
35
|
+
from .. import tokens
|
|
36
|
+
from ..settings import get_settings
|
|
37
|
+
from .broker import Broker, BrokerError, Connection, get_broker
|
|
38
|
+
from .protocol import (
|
|
39
|
+
EVENT_ACCESS_TOKEN,
|
|
40
|
+
EVENT_BROADCAST,
|
|
41
|
+
EVENT_HEARTBEAT,
|
|
42
|
+
EVENT_PHX_CLOSE,
|
|
43
|
+
EVENT_PHX_ERROR,
|
|
44
|
+
EVENT_PHX_JOIN,
|
|
45
|
+
EVENT_PHX_LEAVE,
|
|
46
|
+
EVENT_POSTGRES_CHANGES, # noqa: F401 (re-exported for completeness)
|
|
47
|
+
EVENT_PRESENCE,
|
|
48
|
+
STATUS_ERROR,
|
|
49
|
+
STATUS_OK,
|
|
50
|
+
TOPIC_PHOENIX,
|
|
51
|
+
HeartbeatTimeout,
|
|
52
|
+
ProtocolError,
|
|
53
|
+
decode,
|
|
54
|
+
encode,
|
|
55
|
+
make_reply,
|
|
56
|
+
make_server_push,
|
|
57
|
+
)
|
|
58
|
+
from .schemas import Frame, JoinReplyResponse, PhxJoinPayload
|
|
59
|
+
from .topics import (
|
|
60
|
+
FilterError,
|
|
61
|
+
TopicError,
|
|
62
|
+
assign_subscription_ids,
|
|
63
|
+
resolved_to_subscription_schema,
|
|
64
|
+
validate_topic,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
logger = logging.getLogger(__name__)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Close codes (RFC 6455 + Supabase conventions)
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
_WS_CLOSE_NORMAL = 1000
|
|
75
|
+
_WS_CLOSE_GOING_AWAY = 1001 # used for heartbeat timeout
|
|
76
|
+
_WS_CLOSE_POLICY_VIOLATION = 1008 # used for auth failure
|
|
77
|
+
_WS_CLOSE_TRY_AGAIN_LATER = 1013 # broker over capacity / writer back-pressure
|
|
78
|
+
_WS_CLOSE_INTERNAL_ERROR = 1011
|
|
79
|
+
|
|
80
|
+
_ANON_CLAIMS: dict[str, Any] = {"role": "anon"}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
router = APIRouter(prefix="/realtime/v1", tags=["realtime"])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Auth helpers
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _extract_token(ws: WebSocket) -> tuple[str | None, str | None]:
|
|
92
|
+
"""Pull the JWT off either the query string or the bearer subprotocol.
|
|
93
|
+
|
|
94
|
+
Returns ``(token, subprotocol_to_echo)``. ``subprotocol_to_echo`` is
|
|
95
|
+
``"bearer"`` if the client used the subprotocol path (so we have to
|
|
96
|
+
accept with that subprotocol to satisfy the RFC 6455 handshake) and
|
|
97
|
+
``None`` otherwise.
|
|
98
|
+
"""
|
|
99
|
+
token = ws.query_params.get("apikey") or ws.query_params.get("access_token")
|
|
100
|
+
if token is not None:
|
|
101
|
+
return token, None
|
|
102
|
+
subprotos = ws.scope.get("subprotocols") or []
|
|
103
|
+
if (
|
|
104
|
+
len(subprotos) >= 2
|
|
105
|
+
and subprotos[0].lower() == "bearer"
|
|
106
|
+
and subprotos[1]
|
|
107
|
+
):
|
|
108
|
+
return subprotos[1], "bearer"
|
|
109
|
+
return None, None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _decode_or_anon(token: str | None) -> tuple[str, dict[str, Any]]:
|
|
113
|
+
"""Decode *token* into ``(role, claims)``.
|
|
114
|
+
|
|
115
|
+
No token → ``("anon", {"role": "anon"})``. An invalid token raises
|
|
116
|
+
:class:`jwt.InvalidTokenError` which the caller turns into a
|
|
117
|
+
1008 (policy violation) close.
|
|
118
|
+
"""
|
|
119
|
+
if not token:
|
|
120
|
+
return "anon", dict(_ANON_CLAIMS)
|
|
121
|
+
claims = tokens.decode_access_token(token)
|
|
122
|
+
role = str(claims.get("role") or "authenticated")
|
|
123
|
+
return role, claims
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Frame send helpers
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def _send_frame(ws: WebSocket, frame: Frame) -> None:
|
|
132
|
+
if ws.client_state is not WebSocketState.CONNECTED:
|
|
133
|
+
return
|
|
134
|
+
try:
|
|
135
|
+
await ws.send_text(encode(frame))
|
|
136
|
+
except (WebSocketDisconnect, RuntimeError):
|
|
137
|
+
# Writer races with disconnect; the reader / cleanup path will
|
|
138
|
+
# close the broker registration shortly.
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def _reply_ok(
|
|
143
|
+
ws: WebSocket,
|
|
144
|
+
*,
|
|
145
|
+
join_ref: str | None,
|
|
146
|
+
ref: str | None,
|
|
147
|
+
topic: str,
|
|
148
|
+
response: dict[str, Any] | None = None,
|
|
149
|
+
) -> None:
|
|
150
|
+
await _send_frame(
|
|
151
|
+
ws,
|
|
152
|
+
make_reply(
|
|
153
|
+
join_ref=join_ref,
|
|
154
|
+
ref=ref,
|
|
155
|
+
topic=topic,
|
|
156
|
+
status=STATUS_OK,
|
|
157
|
+
response=response,
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def _reply_error(
|
|
163
|
+
ws: WebSocket,
|
|
164
|
+
*,
|
|
165
|
+
join_ref: str | None,
|
|
166
|
+
ref: str | None,
|
|
167
|
+
topic: str,
|
|
168
|
+
reason: str,
|
|
169
|
+
) -> None:
|
|
170
|
+
await _send_frame(
|
|
171
|
+
ws,
|
|
172
|
+
make_reply(
|
|
173
|
+
join_ref=join_ref,
|
|
174
|
+
ref=ref,
|
|
175
|
+
topic=topic,
|
|
176
|
+
status=STATUS_ERROR,
|
|
177
|
+
response={"reason": reason},
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# Endpoint
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@router.websocket("/websocket")
|
|
188
|
+
async def realtime_websocket(ws: WebSocket) -> None:
|
|
189
|
+
"""Phoenix-channels WebSocket endpoint speaking ``vsn=1.0.0`` JSON."""
|
|
190
|
+
settings = get_settings()
|
|
191
|
+
if not settings.realtime_enabled:
|
|
192
|
+
# Refuse upgrade — no accept = HTTP 403 to the WS handshake.
|
|
193
|
+
await ws.close(code=_WS_CLOSE_INTERNAL_ERROR, reason="realtime disabled")
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
token, subprotocol_echo = _extract_token(ws)
|
|
197
|
+
try:
|
|
198
|
+
role, claims = _decode_or_anon(token)
|
|
199
|
+
except jwt.InvalidTokenError as exc:
|
|
200
|
+
logger.warning("realtime: rejecting connection — invalid JWT: %s", exc)
|
|
201
|
+
await ws.close(code=_WS_CLOSE_POLICY_VIOLATION, reason="invalid token")
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
await ws.accept(subprotocol=subprotocol_echo)
|
|
205
|
+
|
|
206
|
+
broker = get_broker()
|
|
207
|
+
try:
|
|
208
|
+
conn = await broker.register(role=role, claims=claims)
|
|
209
|
+
except BrokerError as exc:
|
|
210
|
+
logger.warning("realtime: rejecting connection — %s", exc)
|
|
211
|
+
await ws.close(code=_WS_CLOSE_TRY_AGAIN_LATER, reason=str(exc))
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
heartbeat = HeartbeatTimeout(float(settings.realtime_heartbeat_timeout_seconds))
|
|
215
|
+
max_subs = settings.realtime_max_subs_per_conn
|
|
216
|
+
timed_out = False
|
|
217
|
+
|
|
218
|
+
async def _drain() -> None:
|
|
219
|
+
"""Writer task: pull frames off the broker outbound queue and send."""
|
|
220
|
+
while True:
|
|
221
|
+
frame = await conn.outbound.get()
|
|
222
|
+
await _send_frame(ws, frame)
|
|
223
|
+
|
|
224
|
+
async def _read() -> None:
|
|
225
|
+
"""Reader task: parse frames and dispatch to the broker."""
|
|
226
|
+
while True:
|
|
227
|
+
raw = await ws.receive_text()
|
|
228
|
+
heartbeat.touch()
|
|
229
|
+
try:
|
|
230
|
+
frame = decode(raw)
|
|
231
|
+
except ProtocolError as exc:
|
|
232
|
+
logger.debug("realtime: dropped malformed frame: %s", exc)
|
|
233
|
+
continue
|
|
234
|
+
await _dispatch(ws, broker, conn, frame, max_subs=max_subs)
|
|
235
|
+
|
|
236
|
+
async def _watchdog() -> None:
|
|
237
|
+
"""Heartbeat watchdog: close socket on idle deadline."""
|
|
238
|
+
nonlocal timed_out
|
|
239
|
+
await heartbeat.wait_for_timeout()
|
|
240
|
+
timed_out = True
|
|
241
|
+
# The reader's blocking ws.receive_text() will raise as soon as the
|
|
242
|
+
# socket is closed, unwinding the TaskGroup.
|
|
243
|
+
with _suppress_disconnect():
|
|
244
|
+
await ws.close(code=_WS_CLOSE_GOING_AWAY, reason="heartbeat timeout")
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
async with asyncio.TaskGroup() as tg:
|
|
248
|
+
tg.create_task(_read(), name=f"realtime-ws-{conn.id}-read")
|
|
249
|
+
tg.create_task(_drain(), name=f"realtime-ws-{conn.id}-write")
|
|
250
|
+
tg.create_task(_watchdog(), name=f"realtime-ws-{conn.id}-watchdog")
|
|
251
|
+
except* WebSocketDisconnect:
|
|
252
|
+
pass
|
|
253
|
+
except* Exception: # noqa: BLE001
|
|
254
|
+
logger.exception("realtime: unhandled error on conn=%s", conn.id)
|
|
255
|
+
finally:
|
|
256
|
+
await broker.unregister(conn)
|
|
257
|
+
if ws.client_state is not WebSocketState.DISCONNECTED:
|
|
258
|
+
with _suppress_disconnect():
|
|
259
|
+
await ws.close(code=_WS_CLOSE_NORMAL if not timed_out else _WS_CLOSE_GOING_AWAY)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# Dispatch
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def _dispatch(
|
|
268
|
+
ws: WebSocket,
|
|
269
|
+
broker: Broker,
|
|
270
|
+
conn: Connection,
|
|
271
|
+
frame: Frame,
|
|
272
|
+
*,
|
|
273
|
+
max_subs: int,
|
|
274
|
+
) -> None:
|
|
275
|
+
topic = frame.topic
|
|
276
|
+
event = frame.event
|
|
277
|
+
|
|
278
|
+
if topic == TOPIC_PHOENIX:
|
|
279
|
+
if event == EVENT_HEARTBEAT:
|
|
280
|
+
await _reply_ok(ws, join_ref=frame.join_ref, ref=frame.ref, topic=topic)
|
|
281
|
+
else:
|
|
282
|
+
logger.debug(
|
|
283
|
+
"realtime: ignoring unsupported phoenix event %r on conn=%s",
|
|
284
|
+
event,
|
|
285
|
+
conn.id,
|
|
286
|
+
)
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
# All other events live on a realtime:* channel.
|
|
290
|
+
try:
|
|
291
|
+
validate_topic(topic)
|
|
292
|
+
except TopicError as exc:
|
|
293
|
+
await _reply_error(
|
|
294
|
+
ws,
|
|
295
|
+
join_ref=frame.join_ref,
|
|
296
|
+
ref=frame.ref,
|
|
297
|
+
topic=topic,
|
|
298
|
+
reason=str(exc),
|
|
299
|
+
)
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
if event == EVENT_PHX_JOIN:
|
|
303
|
+
await _handle_join(ws, broker, conn, frame, max_subs=max_subs)
|
|
304
|
+
return
|
|
305
|
+
if event == EVENT_PHX_LEAVE:
|
|
306
|
+
await _handle_leave(ws, broker, conn, frame)
|
|
307
|
+
return
|
|
308
|
+
if event == EVENT_ACCESS_TOKEN:
|
|
309
|
+
await _handle_access_token(ws, broker, conn, frame)
|
|
310
|
+
return
|
|
311
|
+
if event == EVENT_BROADCAST:
|
|
312
|
+
await _handle_broadcast(ws, broker, conn, frame)
|
|
313
|
+
return
|
|
314
|
+
if event == EVENT_PRESENCE:
|
|
315
|
+
await _handle_presence(ws, broker, conn, frame)
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
logger.debug(
|
|
319
|
+
"realtime: ignoring unknown event %r on topic %r conn=%s",
|
|
320
|
+
event,
|
|
321
|
+
topic,
|
|
322
|
+
conn.id,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
# Handlers
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
async def _handle_join(
|
|
332
|
+
ws: WebSocket,
|
|
333
|
+
broker: Broker,
|
|
334
|
+
conn: Connection,
|
|
335
|
+
frame: Frame,
|
|
336
|
+
*,
|
|
337
|
+
max_subs: int,
|
|
338
|
+
) -> None:
|
|
339
|
+
try:
|
|
340
|
+
payload = PhxJoinPayload.model_validate(frame.payload)
|
|
341
|
+
except ValidationError as exc:
|
|
342
|
+
await _reply_error(
|
|
343
|
+
ws,
|
|
344
|
+
join_ref=frame.join_ref,
|
|
345
|
+
ref=frame.ref,
|
|
346
|
+
topic=frame.topic,
|
|
347
|
+
reason=f"invalid join config: {exc.errors()[0]['msg']}",
|
|
348
|
+
)
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
# Rotate JWT if the join carries one. Failure here is a hard reject —
|
|
352
|
+
# joining with a bad token would let the client believe it is
|
|
353
|
+
# authenticated when it is not.
|
|
354
|
+
if payload.access_token:
|
|
355
|
+
try:
|
|
356
|
+
role, claims = _decode_or_anon(payload.access_token)
|
|
357
|
+
except jwt.InvalidTokenError as exc:
|
|
358
|
+
await _reply_error(
|
|
359
|
+
ws,
|
|
360
|
+
join_ref=frame.join_ref,
|
|
361
|
+
ref=frame.ref,
|
|
362
|
+
topic=frame.topic,
|
|
363
|
+
reason=f"invalid access_token: {exc}",
|
|
364
|
+
)
|
|
365
|
+
return
|
|
366
|
+
broker.update_claims(conn, role=role, claims=claims)
|
|
367
|
+
|
|
368
|
+
# Per-connection subscription cap. Counts each filter as one
|
|
369
|
+
# subscription so a client that joins 100 channels with 100 filters
|
|
370
|
+
# each does not silently swamp the registry.
|
|
371
|
+
new_total = sum(
|
|
372
|
+
len(s.postgres_changes) for s in conn.subscriptions.values()
|
|
373
|
+
) + len(payload.config.postgres_changes)
|
|
374
|
+
if new_total > max_subs:
|
|
375
|
+
await _reply_error(
|
|
376
|
+
ws,
|
|
377
|
+
join_ref=frame.join_ref,
|
|
378
|
+
ref=frame.ref,
|
|
379
|
+
topic=frame.topic,
|
|
380
|
+
reason=f"per-connection subscription cap reached ({max_subs})",
|
|
381
|
+
)
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
resolved = assign_subscription_ids(payload.config)
|
|
386
|
+
except FilterError as exc:
|
|
387
|
+
await _reply_error(
|
|
388
|
+
ws,
|
|
389
|
+
join_ref=frame.join_ref,
|
|
390
|
+
ref=frame.ref,
|
|
391
|
+
topic=frame.topic,
|
|
392
|
+
reason=str(exc),
|
|
393
|
+
)
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
sub = await broker.subscribe(
|
|
397
|
+
conn,
|
|
398
|
+
topic=frame.topic,
|
|
399
|
+
join_ref=frame.join_ref,
|
|
400
|
+
postgres_changes=resolved,
|
|
401
|
+
broadcast_self=payload.config.broadcast.self_echo,
|
|
402
|
+
presence_key=payload.config.presence.key,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
response = JoinReplyResponse(
|
|
406
|
+
postgres_changes=[resolved_to_subscription_schema(r) for r in sub.postgres_changes],
|
|
407
|
+
)
|
|
408
|
+
await _reply_ok(
|
|
409
|
+
ws,
|
|
410
|
+
join_ref=frame.join_ref,
|
|
411
|
+
ref=frame.ref,
|
|
412
|
+
topic=frame.topic,
|
|
413
|
+
response=response.model_dump(by_alias=True),
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
await broker.push_presence_state(conn, frame.topic)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
async def _handle_leave(
|
|
420
|
+
ws: WebSocket,
|
|
421
|
+
broker: Broker,
|
|
422
|
+
conn: Connection,
|
|
423
|
+
frame: Frame,
|
|
424
|
+
) -> None:
|
|
425
|
+
await broker.unsubscribe(conn, frame.topic)
|
|
426
|
+
await _reply_ok(ws, join_ref=frame.join_ref, ref=frame.ref, topic=frame.topic)
|
|
427
|
+
# Phoenix follows the ack with a phx_close so the SDK can drop its
|
|
428
|
+
# local channel state without a rejoin loop.
|
|
429
|
+
await _send_frame(
|
|
430
|
+
ws,
|
|
431
|
+
make_server_push(topic=frame.topic, event=EVENT_PHX_CLOSE, payload={}),
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
async def _handle_access_token(
|
|
436
|
+
ws: WebSocket,
|
|
437
|
+
broker: Broker,
|
|
438
|
+
conn: Connection,
|
|
439
|
+
frame: Frame,
|
|
440
|
+
) -> None:
|
|
441
|
+
raw = frame.payload.get("access_token") if isinstance(frame.payload, dict) else None
|
|
442
|
+
if not isinstance(raw, str) or not raw:
|
|
443
|
+
await _reply_error(
|
|
444
|
+
ws,
|
|
445
|
+
join_ref=frame.join_ref,
|
|
446
|
+
ref=frame.ref,
|
|
447
|
+
topic=frame.topic,
|
|
448
|
+
reason="missing access_token",
|
|
449
|
+
)
|
|
450
|
+
return
|
|
451
|
+
try:
|
|
452
|
+
role, claims = _decode_or_anon(raw)
|
|
453
|
+
except jwt.InvalidTokenError as exc:
|
|
454
|
+
await _reply_error(
|
|
455
|
+
ws,
|
|
456
|
+
join_ref=frame.join_ref,
|
|
457
|
+
ref=frame.ref,
|
|
458
|
+
topic=frame.topic,
|
|
459
|
+
reason=f"invalid access_token: {exc}",
|
|
460
|
+
)
|
|
461
|
+
# An invalid rotation should not nuke the existing claims; the
|
|
462
|
+
# client may simply have shipped a malformed retry.
|
|
463
|
+
return
|
|
464
|
+
broker.update_claims(conn, role=role, claims=claims)
|
|
465
|
+
await _reply_ok(ws, join_ref=frame.join_ref, ref=frame.ref, topic=frame.topic)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
async def _handle_broadcast(
|
|
469
|
+
ws: WebSocket,
|
|
470
|
+
broker: Broker,
|
|
471
|
+
conn: Connection,
|
|
472
|
+
frame: Frame,
|
|
473
|
+
) -> None:
|
|
474
|
+
if frame.topic not in conn.subscriptions:
|
|
475
|
+
await _reply_error(
|
|
476
|
+
ws,
|
|
477
|
+
join_ref=frame.join_ref,
|
|
478
|
+
ref=frame.ref,
|
|
479
|
+
topic=frame.topic,
|
|
480
|
+
reason="not joined",
|
|
481
|
+
)
|
|
482
|
+
return
|
|
483
|
+
if conn.is_token_expired and conn.role != "anon":
|
|
484
|
+
await _reply_error(
|
|
485
|
+
ws,
|
|
486
|
+
join_ref=frame.join_ref,
|
|
487
|
+
ref=frame.ref,
|
|
488
|
+
topic=frame.topic,
|
|
489
|
+
reason="access_token expired",
|
|
490
|
+
)
|
|
491
|
+
return
|
|
492
|
+
payload = frame.payload if isinstance(frame.payload, dict) else {}
|
|
493
|
+
inner_event = str(payload.get("event") or "")
|
|
494
|
+
inner_payload = payload.get("payload") if isinstance(payload.get("payload"), dict) else {}
|
|
495
|
+
broker.broadcast(
|
|
496
|
+
topic=frame.topic,
|
|
497
|
+
event=inner_event,
|
|
498
|
+
payload=inner_payload, # type: ignore[arg-type]
|
|
499
|
+
sender_id=conn.id,
|
|
500
|
+
)
|
|
501
|
+
if frame.ref is not None:
|
|
502
|
+
await _reply_ok(ws, join_ref=frame.join_ref, ref=frame.ref, topic=frame.topic)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
async def _handle_presence(
|
|
506
|
+
ws: WebSocket,
|
|
507
|
+
broker: Broker,
|
|
508
|
+
conn: Connection,
|
|
509
|
+
frame: Frame,
|
|
510
|
+
) -> None:
|
|
511
|
+
if frame.topic not in conn.subscriptions:
|
|
512
|
+
await _reply_error(
|
|
513
|
+
ws,
|
|
514
|
+
join_ref=frame.join_ref,
|
|
515
|
+
ref=frame.ref,
|
|
516
|
+
topic=frame.topic,
|
|
517
|
+
reason="not joined",
|
|
518
|
+
)
|
|
519
|
+
return
|
|
520
|
+
if conn.is_token_expired and conn.role != "anon":
|
|
521
|
+
await _reply_error(
|
|
522
|
+
ws,
|
|
523
|
+
join_ref=frame.join_ref,
|
|
524
|
+
ref=frame.ref,
|
|
525
|
+
topic=frame.topic,
|
|
526
|
+
reason="access_token expired",
|
|
527
|
+
)
|
|
528
|
+
return
|
|
529
|
+
payload = frame.payload if isinstance(frame.payload, dict) else {}
|
|
530
|
+
presence_event = str(payload.get("event") or "")
|
|
531
|
+
inner_payload = payload.get("payload") if isinstance(payload.get("payload"), dict) else {}
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
if presence_event == "track":
|
|
535
|
+
await broker.track_presence(conn, topic=frame.topic, meta=inner_payload or {})
|
|
536
|
+
elif presence_event == "untrack":
|
|
537
|
+
await broker.untrack_presence(conn, topic=frame.topic)
|
|
538
|
+
else:
|
|
539
|
+
await _reply_error(
|
|
540
|
+
ws,
|
|
541
|
+
join_ref=frame.join_ref,
|
|
542
|
+
ref=frame.ref,
|
|
543
|
+
topic=frame.topic,
|
|
544
|
+
reason=f"unknown presence event {presence_event!r}",
|
|
545
|
+
)
|
|
546
|
+
return
|
|
547
|
+
except BrokerError as exc:
|
|
548
|
+
await _reply_error(
|
|
549
|
+
ws,
|
|
550
|
+
join_ref=frame.join_ref,
|
|
551
|
+
ref=frame.ref,
|
|
552
|
+
topic=frame.topic,
|
|
553
|
+
reason=str(exc),
|
|
554
|
+
)
|
|
555
|
+
return
|
|
556
|
+
|
|
557
|
+
if frame.ref is not None:
|
|
558
|
+
await _reply_ok(ws, join_ref=frame.join_ref, ref=frame.ref, topic=frame.topic)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# ---------------------------------------------------------------------------
|
|
562
|
+
# Misc
|
|
563
|
+
# ---------------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
class _suppress_disconnect:
|
|
567
|
+
"""Tiny context manager: swallow ``WebSocketDisconnect`` and ``RuntimeError``.
|
|
568
|
+
|
|
569
|
+
Closing or sending on an already-closed Starlette ``WebSocket`` raises
|
|
570
|
+
one of these two; in cleanup paths we never want to hide the original
|
|
571
|
+
failure behind a "WebSocket is not connected" wrapper.
|
|
572
|
+
"""
|
|
573
|
+
|
|
574
|
+
def __enter__(self) -> "_suppress_disconnect":
|
|
575
|
+
return self
|
|
576
|
+
|
|
577
|
+
def __exit__(self, exc_type, exc, tb) -> bool: # noqa: ANN001
|
|
578
|
+
return exc_type is not None and issubclass(
|
|
579
|
+
exc_type, (WebSocketDisconnect, RuntimeError)
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
__all__ = [
|
|
584
|
+
"router",
|
|
585
|
+
"EVENT_PHX_ERROR", # re-exported for downstream convenience
|
|
586
|
+
]
|