Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
658e3d6ec7
|
||
|
|
5e4e1310b4
|
||
|
|
db7ba30d13
|
||
|
|
f7fa8e78e5
|
@@ -32,7 +32,6 @@ jobs:
|
|||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
builder: multiplatform-builder
|
|
||||||
file: server/Dockerfile
|
file: server/Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -61,16 +61,6 @@ data:
|
|||||||
GAME_TTL_SECONDS: "86400"
|
GAME_TTL_SECONDS: "86400"
|
||||||
HAND_ACK_TIMEOUT_SECONDS: "30"
|
HAND_ACK_TIMEOUT_SECONDS: "30"
|
||||||
TURN_TIMEOUT_SECONDS: "30"
|
TURN_TIMEOUT_SECONDS: "30"
|
||||||
# CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or
|
|
||||||
# CORS_ALLOW_ORIGIN_REGEX is set — unneeded when the SPA and the API are
|
|
||||||
# served from the same origin. See server/.env.example for details.
|
|
||||||
# CORS_ALLOW_ORIGINS: "https://example.com,https://app.example.com" # or "*"
|
|
||||||
# CORS_ALLOW_ORIGIN_REGEX: 'https://tavolo-[a-z0-9-]+\.vercel\.app'
|
|
||||||
# CORS_ALLOW_METHODS: "GET,POST" # default: GET; "*" = all
|
|
||||||
# CORS_ALLOW_HEADERS: "Authorization,Content-Type" # "*" mirrors the request
|
|
||||||
# CORS_ALLOW_CREDENTIALS: "false"
|
|
||||||
# CORS_EXPOSE_HEADERS: ""
|
|
||||||
# CORS_MAX_AGE: "600"
|
|
||||||
# OIDC (provider lives in another namespace).
|
# OIDC (provider lives in another namespace).
|
||||||
OIDC_CLIENT_ID: tavolo
|
OIDC_CLIENT_ID: tavolo
|
||||||
OIDC_POST_LOGIN_REDIRECT: /
|
OIDC_POST_LOGIN_REDIRECT: /
|
||||||
|
|||||||
@@ -113,15 +113,6 @@ services:
|
|||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
|
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
|
||||||
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
|
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
|
||||||
# CORS is disabled unless CORS_ALLOW_ORIGINS or CORS_ALLOW_ORIGIN_REGEX
|
|
||||||
# is set (see server/.env.example for the full list of options).
|
|
||||||
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-}
|
|
||||||
CORS_ALLOW_ORIGIN_REGEX: ${CORS_ALLOW_ORIGIN_REGEX:-}
|
|
||||||
CORS_ALLOW_METHODS: ${CORS_ALLOW_METHODS:-}
|
|
||||||
CORS_ALLOW_HEADERS: ${CORS_ALLOW_HEADERS:-}
|
|
||||||
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-}
|
|
||||||
CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-}
|
|
||||||
CORS_MAX_AGE: ${CORS_MAX_AGE:-}
|
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
||||||
|
|
||||||
|
|||||||
@@ -39,28 +39,6 @@ TURN_TIMEOUT_SECONDS=30
|
|||||||
# schema). Unset logs DEBUG to the console.
|
# schema). Unset logs DEBUG to the console.
|
||||||
#LOGGING_CONFIG=/path/to/logging.yaml
|
#LOGGING_CONFIG=/path/to/logging.yaml
|
||||||
|
|
||||||
# CORS (via kaya-cors' CorsMixin; same semantics as Starlette's
|
|
||||||
# CORSMiddleware). Disabled unless CORS_ALLOW_ORIGINS or
|
|
||||||
# CORS_ALLOW_ORIGIN_REGEX is set — the app serves the SPA and the API from
|
|
||||||
# the same origin, so no CORS headers are needed by default.
|
|
||||||
# Comma-separated list of origins allowed to make cross-origin requests,
|
|
||||||
# or "*" for any origin:
|
|
||||||
#CORS_ALLOW_ORIGINS=https://example.com,https://app.example.com
|
|
||||||
# Optional regex (fullmatch) allowed origins are additionally checked
|
|
||||||
# against — handy for dynamic preview URLs:
|
|
||||||
#CORS_ALLOW_ORIGIN_REGEX=https://tavolo-[a-z0-9-]+\.vercel\.app
|
|
||||||
# Comma-separated allowed methods, or "*" for all (default GET):
|
|
||||||
#CORS_ALLOW_METHODS=GET,POST
|
|
||||||
# Comma-separated allowed request headers, or "*" to mirror back whatever
|
|
||||||
# the browser requests (default: only the CORS-safelisted headers):
|
|
||||||
#CORS_ALLOW_HEADERS=Authorization,Content-Type
|
|
||||||
# Allow cookies/credentials on cross-origin requests (1/true/yes/on):
|
|
||||||
#CORS_ALLOW_CREDENTIALS=false
|
|
||||||
# Comma-separated response headers exposed to the browser:
|
|
||||||
#CORS_EXPOSE_HEADERS=
|
|
||||||
# Seconds browsers may cache the preflight response (default 600):
|
|
||||||
#CORS_MAX_AGE=600
|
|
||||||
|
|
||||||
# App server
|
# App server
|
||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
APP_PORT=8080
|
APP_PORT=8080
|
||||||
|
|||||||
+6
-10
@@ -48,21 +48,17 @@ RUN --mount=type=cache,target=/var/cache/apk \
|
|||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY server/pyproject.toml server/README.md server/requirements.txt ./
|
||||||
COPY server/requirements.txt ./
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
|
||||||
python3 -m venv /opt/venv \
|
|
||||||
&& /opt/venv/bin/pip install --upgrade pip \
|
|
||||||
&& /opt/venv/bin/pip install -r requirements.txt
|
|
||||||
|
|
||||||
COPY server/pyproject.toml server/README.md ./
|
|
||||||
COPY server/src/ ./src/
|
COPY server/src/ ./src/
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
|
||||||
/opt/venv/bin/pip install .
|
|
||||||
# aerich migration files are a release artifact: the db-migrate compose
|
# aerich migration files are a release artifact: the db-migrate compose
|
||||||
# service runs `aerich upgrade` from this image before the app starts.
|
# service runs `aerich upgrade` from this image before the app starts.
|
||||||
COPY server/migrations/ ./migrations/
|
COPY server/migrations/ ./migrations/
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
|
python3 -m venv /opt/venv \
|
||||||
|
&& /opt/venv/bin/pip install --upgrade pip \
|
||||||
|
&& /opt/venv/bin/pip install -r requirements.txt .
|
||||||
|
|
||||||
# --- Runtime ---------------------------------------------------------------
|
# --- Runtime ---------------------------------------------------------------
|
||||||
FROM alpine:3.24
|
FROM alpine:3.24
|
||||||
|
|
||||||
|
|||||||
@@ -62,13 +62,6 @@ All configuration comes from environment variables (see `.env.example`):
|
|||||||
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them |
|
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them |
|
||||||
| `DEADLINE_HEARTBEAT_MS` | `1000` | Upper bound on the deadline consumer's poll interval (locally enqueued deadlines fire on time regardless) |
|
| `DEADLINE_HEARTBEAT_MS` | `1000` | Upper bound on the deadline consumer's poll interval (locally enqueued deadlines fire on time regardless) |
|
||||||
| `LOGGING_CONFIG` | unset | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console |
|
| `LOGGING_CONFIG` | unset | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console |
|
||||||
| `CORS_ALLOW_ORIGINS` | unset | Comma-separated origins allowed for cross-origin requests, or `*` for any. CORS is disabled unless this or `CORS_ALLOW_ORIGIN_REGEX` is set |
|
|
||||||
| `CORS_ALLOW_ORIGIN_REGEX` | unset | Regex (fullmatch) additionally matched against request origins, e.g. `https://tavolo-[a-z0-9-]+\.vercel\.app` |
|
|
||||||
| `CORS_ALLOW_METHODS` | `GET` | Comma-separated methods allowed for cross-origin requests, or `*` for all |
|
|
||||||
| `CORS_ALLOW_HEADERS` | unset | Comma-separated request headers allowed in cross-origin requests, or `*` to mirror back the requested ones. The CORS-safelisted headers are always allowed |
|
|
||||||
| `CORS_ALLOW_CREDENTIALS` | `false` | `1`/`true`/`yes`/`on` allow cookies/credentials on cross-origin requests |
|
|
||||||
| `CORS_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser |
|
|
||||||
| `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response |
|
|
||||||
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
|
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ readme = "README.md"
|
|||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kaya-core",
|
"kaya-core",
|
||||||
"kaya-cors",
|
|
||||||
"kaya-session",
|
"kaya-session",
|
||||||
"kaya-session-redis",
|
"kaya-session-redis",
|
||||||
"kaya-oidc",
|
"kaya-oidc",
|
||||||
|
|||||||
@@ -52,14 +52,11 @@ iso8601==2.1.0
|
|||||||
# via tortoise-orm
|
# via tortoise-orm
|
||||||
kaya-core==0.0.3
|
kaya-core==0.0.3
|
||||||
# via
|
# via
|
||||||
# kaya-cors
|
|
||||||
# kaya-oidc
|
# kaya-oidc
|
||||||
# kaya-openapi
|
# kaya-openapi
|
||||||
# kaya-rsgi
|
# kaya-rsgi
|
||||||
# kaya-session
|
# kaya-session
|
||||||
# tavolo (pyproject.toml)
|
# tavolo (pyproject.toml)
|
||||||
kaya-cors==0.0.3
|
|
||||||
# via tavolo (pyproject.toml)
|
|
||||||
kaya-oidc==0.0.3
|
kaya-oidc==0.0.3
|
||||||
# via tavolo (pyproject.toml)
|
# via tavolo (pyproject.toml)
|
||||||
kaya-openapi==0.0.3
|
kaya-openapi==0.0.3
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ Assembles the :class:`~kaya.core.KayaApp` with four mixins:
|
|||||||
- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at
|
- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at
|
||||||
``/api/openapi.json`` and a Swagger UI at ``/api/docs``)
|
``/api/openapi.json`` and a Swagger UI at ``/api/docs``)
|
||||||
|
|
||||||
A :class:`~kaya.cors.CorsMixin` is prepended when CORS is configured via the
|
|
||||||
``CORS_*`` environment variables (see :mod:`tavolo.config`).
|
|
||||||
|
|
||||||
Live games are kept in :data:`game_store` (Redis when configured, in-memory
|
Live games are kept in :data:`game_store` (Redis when configured, in-memory
|
||||||
otherwise). Routes and the websocket handlers are registered by importing
|
otherwise). Routes and the websocket handlers are registered by importing
|
||||||
their modules at the bottom; imports must happen after ``app`` is built.
|
their modules at the bottom; imports must happen after ``app`` is built.
|
||||||
@@ -22,17 +19,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from importlib.metadata import version as _pkg_version
|
from importlib.metadata import version as _pkg_version
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from kaya.core import KayaApp, KayaMixin
|
from kaya.core import KayaApp
|
||||||
from kaya.cors import CorsMixin
|
|
||||||
from kaya.oidc import OIDCConfig, OIDCMixin
|
from kaya.oidc import OIDCConfig, OIDCMixin
|
||||||
from kaya.openapi import OpenAPIMixin
|
from kaya.openapi import OpenAPIMixin
|
||||||
from kaya.session import InMemorySessionStore, SessionMixin, SessionStore
|
from kaya.session import InMemorySessionStore, SessionMixin, SessionStore
|
||||||
from kaya.session.redis import RedisSessionStore
|
from kaya.session.redis import RedisSessionStore
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
from .config import Settings, settings
|
from .config import settings
|
||||||
from .deadlines import DeadlineSchedulerMixin
|
from .deadlines import DeadlineSchedulerMixin
|
||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
from .store import GameStore, InMemoryGameStore, RedisGameStore
|
from .store import GameStore, InMemoryGameStore, RedisGameStore
|
||||||
@@ -41,27 +36,6 @@ from .tortoise_mixin import TortoiseMixin
|
|||||||
configure_logging(settings.logging_config)
|
configure_logging(settings.logging_config)
|
||||||
log = getLogger(__name__)
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def cors_mixin_from_settings(settings: Settings) -> Optional[CorsMixin]:
|
|
||||||
"""Build a :class:`~kaya.cors.CorsMixin` from the CORS settings.
|
|
||||||
|
|
||||||
Returns ``None`` — CORS disabled — unless at least one of
|
|
||||||
``CORS_ALLOW_ORIGINS`` / ``CORS_ALLOW_ORIGIN_REGEX`` is configured.
|
|
||||||
Settings left unset fall back to the mixin's own defaults.
|
|
||||||
"""
|
|
||||||
if settings.cors_allow_origins is None and settings.cors_allow_origin_regex is None:
|
|
||||||
return None
|
|
||||||
return CorsMixin(
|
|
||||||
allow_origins=settings.cors_allow_origins or (),
|
|
||||||
allow_origin_regex=settings.cors_allow_origin_regex,
|
|
||||||
allow_methods=settings.cors_allow_methods or ("GET",),
|
|
||||||
allow_headers=settings.cors_allow_headers or (),
|
|
||||||
allow_credentials=settings.cors_allow_credentials,
|
|
||||||
expose_headers=settings.cors_expose_headers or (),
|
|
||||||
max_age=settings.cors_max_age,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
session_store: SessionStore
|
session_store: SessionStore
|
||||||
if settings.redis_url is not None:
|
if settings.redis_url is not None:
|
||||||
# Lazy client: no connection is opened until a session is actually
|
# Lazy client: no connection is opened until a session is actually
|
||||||
@@ -103,21 +77,8 @@ tortoise_mixin = TortoiseMixin(
|
|||||||
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
|
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
mixins: list[KayaMixin] = [session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin,
|
app = KayaApp(mixins=[session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin,
|
||||||
DeadlineSchedulerMixin(game_store)]
|
DeadlineSchedulerMixin(game_store)])
|
||||||
cors_mixin = cors_mixin_from_settings(settings)
|
|
||||||
if cors_mixin is not None:
|
|
||||||
# First in the list: preflight requests are answered before the session
|
|
||||||
# and OIDC hooks run.
|
|
||||||
mixins.insert(0, cors_mixin)
|
|
||||||
log.info(
|
|
||||||
"CORS enabled: origins=%s origin_regex=%s credentials=%s",
|
|
||||||
settings.cors_allow_origins,
|
|
||||||
settings.cors_allow_origin_regex,
|
|
||||||
settings.cors_allow_credentials,
|
|
||||||
)
|
|
||||||
|
|
||||||
app = KayaApp(mixins=mixins)
|
|
||||||
log.debug(
|
log.debug(
|
||||||
"timeouts: hand_ack=%ds turn=%ds",
|
"timeouts: hand_ack=%ds turn=%ds",
|
||||||
settings.hand_ack_timeout_seconds,
|
settings.hand_ack_timeout_seconds,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional, Tuple
|
from typing import Optional
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
@@ -20,25 +20,6 @@ def _env(name: str, default: Optional[str] = None) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _env_list(name: str) -> Optional[Tuple[str, ...]]:
|
|
||||||
"""Parse a comma-separated environment variable into a tuple of values.
|
|
||||||
|
|
||||||
Items are stripped and empty items dropped. Unset or empty variables
|
|
||||||
yield ``None``.
|
|
||||||
"""
|
|
||||||
value = os.environ.get(name)
|
|
||||||
if value is None or value.strip() == "":
|
|
||||||
return None
|
|
||||||
return tuple(part.strip() for part in value.split(",") if part.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool = False) -> bool:
|
|
||||||
value = os.environ.get(name)
|
|
||||||
if value is None or value == "":
|
|
||||||
return default
|
|
||||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
|
||||||
|
|
||||||
|
|
||||||
def _database_url_from_parts(engine: str,
|
def _database_url_from_parts(engine: str,
|
||||||
user: str,
|
user: str,
|
||||||
password: Optional[str],
|
password: Optional[str],
|
||||||
@@ -103,16 +84,6 @@ class Settings:
|
|||||||
# Path to a YAML logging configuration file (logging.config.dictConfig
|
# Path to a YAML logging configuration file (logging.config.dictConfig
|
||||||
# schema). Unset uses the built-in default: DEBUG to the console.
|
# schema). Unset uses the built-in default: DEBUG to the console.
|
||||||
logging_config: Optional[str]
|
logging_config: Optional[str]
|
||||||
# CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or
|
|
||||||
# CORS_ALLOW_ORIGIN_REGEX is set; the app serves the SPA and the API
|
|
||||||
# from the same origin, so no CORS headers are needed by default.
|
|
||||||
cors_allow_origins: Optional[Tuple[str, ...]]
|
|
||||||
cors_allow_origin_regex: Optional[str]
|
|
||||||
cors_allow_methods: Optional[Tuple[str, ...]]
|
|
||||||
cors_allow_headers: Optional[Tuple[str, ...]]
|
|
||||||
cors_allow_credentials: bool
|
|
||||||
cors_expose_headers: Optional[Tuple[str, ...]]
|
|
||||||
cors_max_age: int
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_env() -> "Settings":
|
def from_env() -> "Settings":
|
||||||
@@ -149,16 +120,6 @@ class Settings:
|
|||||||
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
|
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
|
||||||
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")),
|
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")),
|
||||||
logging_config=os.environ.get("LOGGING_CONFIG"),
|
logging_config=os.environ.get("LOGGING_CONFIG"),
|
||||||
# CORS is disabled unless CORS_ALLOW_ORIGINS (a comma-separated
|
|
||||||
# list of origins, or "*" for any) or CORS_ALLOW_ORIGIN_REGEX
|
|
||||||
# is set.
|
|
||||||
cors_allow_origins=_env_list("CORS_ALLOW_ORIGINS"),
|
|
||||||
cors_allow_origin_regex=os.environ.get("CORS_ALLOW_ORIGIN_REGEX") or None,
|
|
||||||
cors_allow_methods=_env_list("CORS_ALLOW_METHODS"),
|
|
||||||
cors_allow_headers=_env_list("CORS_ALLOW_HEADERS"),
|
|
||||||
cors_allow_credentials=_env_bool("CORS_ALLOW_CREDENTIALS"),
|
|
||||||
cors_expose_headers=_env_list("CORS_EXPOSE_HEADERS"),
|
|
||||||
cors_max_age=int(_env("CORS_MAX_AGE", "600")),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -214,20 +214,14 @@ async def _fire_hand_end(store: GameStore, state: GameState, entry: Dict[str, An
|
|||||||
# --- consumer lifecycle -------------------------------------------------------
|
# --- consumer lifecycle -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def ensure_consumer(
|
def ensure_consumer(store: GameStore) -> None:
|
||||||
store: GameStore, loop: Optional[asyncio.AbstractEventLoop] = None
|
"""Start the deadline consumer on the running loop if not yet running.
|
||||||
) -> None:
|
|
||||||
"""Start the deadline consumer on the given (or running) loop if not
|
|
||||||
yet running.
|
|
||||||
|
|
||||||
Called lazily whenever a deadline is enqueued (the ASGI test transport
|
Called lazily whenever a deadline is enqueued (the ASGI test transport
|
||||||
never fires the lifespan hooks, so the mixin's ``setup`` alone is not
|
never fires the lifespan hooks, so the mixin's ``setup`` alone is not
|
||||||
enough) and on application startup. The explicit ``loop`` matters at
|
enough) and on application startup.
|
||||||
startup: under RSGI granian calls ``setup`` before the loop runs, so
|
|
||||||
``asyncio.get_running_loop()`` would fail there.
|
|
||||||
"""
|
"""
|
||||||
if loop is None:
|
loop = asyncio.get_running_loop()
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
for old in list(_consumers):
|
for old in list(_consumers):
|
||||||
if old.is_closed():
|
if old.is_closed():
|
||||||
_consumers.pop(old, None)
|
_consumers.pop(old, None)
|
||||||
@@ -290,7 +284,7 @@ class DeadlineSchedulerMixin(KayaMixin):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
|
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
ensure_consumer(self._store, loop)
|
ensure_consumer(self._store)
|
||||||
|
|
||||||
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
|
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
stop_consumer(loop)
|
stop_consumer(loop)
|
||||||
|
|||||||
@@ -81,54 +81,5 @@ class DatabaseUrlTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CorsSettingsTests(unittest.TestCase):
|
|
||||||
def test_cors_disabled_by_default(self):
|
|
||||||
settings = _settings({})
|
|
||||||
self.assertIsNone(settings.cors_allow_origins)
|
|
||||||
self.assertIsNone(settings.cors_allow_origin_regex)
|
|
||||||
self.assertIsNone(settings.cors_allow_methods)
|
|
||||||
self.assertIsNone(settings.cors_allow_headers)
|
|
||||||
self.assertFalse(settings.cors_allow_credentials)
|
|
||||||
self.assertIsNone(settings.cors_expose_headers)
|
|
||||||
self.assertEqual(600, settings.cors_max_age)
|
|
||||||
|
|
||||||
def test_allow_origins_parses_comma_separated_list(self):
|
|
||||||
settings = _settings({
|
|
||||||
"CORS_ALLOW_ORIGINS": "https://a.example, https://b.example ,,https://c.example",
|
|
||||||
})
|
|
||||||
self.assertEqual(
|
|
||||||
("https://a.example", "https://b.example", "https://c.example"),
|
|
||||||
settings.cors_allow_origins,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_allow_origins_star_is_passed_through(self):
|
|
||||||
settings = _settings({"CORS_ALLOW_ORIGINS": "*"})
|
|
||||||
self.assertEqual(("*",), settings.cors_allow_origins)
|
|
||||||
|
|
||||||
def test_allow_origin_regex_is_passed_through(self):
|
|
||||||
settings = _settings({"CORS_ALLOW_ORIGIN_REGEX": r"https://.*\.example\.com"})
|
|
||||||
self.assertEqual(r"https://.*\.example\.com", settings.cors_allow_origin_regex)
|
|
||||||
|
|
||||||
def test_allow_methods_and_headers_parse_as_lists(self):
|
|
||||||
settings = _settings({
|
|
||||||
"CORS_ALLOW_METHODS": "GET,POST",
|
|
||||||
"CORS_ALLOW_HEADERS": "Authorization, X-Custom-Header",
|
|
||||||
"CORS_EXPOSE_HEADERS": "X-Total-Count",
|
|
||||||
})
|
|
||||||
self.assertEqual(("GET", "POST"), settings.cors_allow_methods)
|
|
||||||
self.assertEqual(("Authorization", "X-Custom-Header"), settings.cors_allow_headers)
|
|
||||||
self.assertEqual(("X-Total-Count",), settings.cors_expose_headers)
|
|
||||||
|
|
||||||
def test_allow_credentials_parses_boolean(self):
|
|
||||||
for value in ("1", "true", "TRUE", "yes", "on"):
|
|
||||||
self.assertTrue(_settings({"CORS_ALLOW_CREDENTIALS": value}).cors_allow_credentials)
|
|
||||||
for value in ("0", "false", "no", "off", "anything-else"):
|
|
||||||
self.assertFalse(_settings({"CORS_ALLOW_CREDENTIALS": value}).cors_allow_credentials)
|
|
||||||
|
|
||||||
def test_max_age_parses_int(self):
|
|
||||||
settings = _settings({"CORS_MAX_AGE": "3600"})
|
|
||||||
self.assertEqual(3600, settings.cors_max_age)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
"""Integration tests for the CORS configuration in :mod:`tavolo.app`.
|
|
||||||
|
|
||||||
The mixin under test is kaya-cors' :class:`~kaya.cors.CorsMixin`; these
|
|
||||||
tests only verify that :func:`tavolo.app.cors_mixin_from_settings` maps the
|
|
||||||
environment-driven :class:`~tavolo.config.Settings` onto it correctly. A
|
|
||||||
minimal ``KayaApp`` is used instead of the global ``app`` so the tests do
|
|
||||||
not depend on the environment the suite was imported with.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
|
||||||
from kaya.core import HttpContext, KayaApp
|
|
||||||
from pwo import async_test
|
|
||||||
|
|
||||||
from tavolo.app import cors_mixin_from_settings
|
|
||||||
from tavolo.config import Settings
|
|
||||||
|
|
||||||
ORIGIN = "https://cards.example"
|
|
||||||
|
|
||||||
|
|
||||||
def _settings(env: dict) -> Settings:
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
|
||||||
return Settings.from_env()
|
|
||||||
|
|
||||||
|
|
||||||
def _app(settings: Settings) -> KayaApp:
|
|
||||||
mixin = cors_mixin_from_settings(settings)
|
|
||||||
assert mixin is not None
|
|
||||||
app = KayaApp(mixins=[mixin])
|
|
||||||
|
|
||||||
@app.GET("/api/health")
|
|
||||||
async def health(ctx: HttpContext) -> None:
|
|
||||||
await ctx.send_str(200, "ok")
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
class CorsMixinFromSettingsTests(unittest.TestCase):
|
|
||||||
def test_disabled_when_unconfigured(self):
|
|
||||||
self.assertIsNone(cors_mixin_from_settings(_settings({})))
|
|
||||||
|
|
||||||
def test_enabled_by_allow_origins(self):
|
|
||||||
self.assertIsNotNone(cors_mixin_from_settings(
|
|
||||||
_settings({"CORS_ALLOW_ORIGINS": ORIGIN})))
|
|
||||||
|
|
||||||
def test_enabled_by_allow_origin_regex_alone(self):
|
|
||||||
self.assertIsNotNone(cors_mixin_from_settings(
|
|
||||||
_settings({"CORS_ALLOW_ORIGIN_REGEX": r"https://.*\.example\.com"})))
|
|
||||||
|
|
||||||
|
|
||||||
class CorsBehaviorTests(unittest.TestCase):
|
|
||||||
@async_test
|
|
||||||
async def test_request_without_origin_is_untouched(self) -> None:
|
|
||||||
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app),
|
|
||||||
base_url="http://127.0.0.1") as client:
|
|
||||||
response = await client.get("/api/health")
|
|
||||||
self.assertEqual(200, response.status_code)
|
|
||||||
self.assertNotIn("access-control-allow-origin", response.headers)
|
|
||||||
|
|
||||||
@async_test
|
|
||||||
async def test_simple_request_with_allowed_origin(self) -> None:
|
|
||||||
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app),
|
|
||||||
base_url="http://127.0.0.1") as client:
|
|
||||||
response = await client.get("/api/health", headers={"Origin": ORIGIN})
|
|
||||||
self.assertEqual(200, response.status_code)
|
|
||||||
self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"])
|
|
||||||
|
|
||||||
@async_test
|
|
||||||
async def test_simple_request_with_disallowed_origin(self) -> None:
|
|
||||||
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app),
|
|
||||||
base_url="http://127.0.0.1") as client:
|
|
||||||
response = await client.get(
|
|
||||||
"/api/health", headers={"Origin": "https://mallory.example"})
|
|
||||||
self.assertEqual(200, response.status_code)
|
|
||||||
self.assertNotIn("access-control-allow-origin", response.headers)
|
|
||||||
|
|
||||||
@async_test
|
|
||||||
async def test_preflight_allowed(self) -> None:
|
|
||||||
app = _app(_settings({
|
|
||||||
"CORS_ALLOW_ORIGINS": ORIGIN,
|
|
||||||
"CORS_ALLOW_METHODS": "GET,POST",
|
|
||||||
"CORS_MAX_AGE": "3600",
|
|
||||||
}))
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app),
|
|
||||||
base_url="http://127.0.0.1") as client:
|
|
||||||
response = await client.options("/api/health", headers={
|
|
||||||
"Origin": ORIGIN,
|
|
||||||
"Access-Control-Request-Method": "POST",
|
|
||||||
})
|
|
||||||
self.assertEqual(200, response.status_code)
|
|
||||||
self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"])
|
|
||||||
self.assertEqual("GET, POST", response.headers["access-control-allow-methods"])
|
|
||||||
self.assertEqual("3600", response.headers["access-control-max-age"])
|
|
||||||
|
|
||||||
@async_test
|
|
||||||
async def test_preflight_disallowed_origin(self) -> None:
|
|
||||||
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app),
|
|
||||||
base_url="http://127.0.0.1") as client:
|
|
||||||
response = await client.options("/api/health", headers={
|
|
||||||
"Origin": "https://mallory.example",
|
|
||||||
"Access-Control-Request-Method": "GET",
|
|
||||||
})
|
|
||||||
self.assertEqual(400, response.status_code)
|
|
||||||
self.assertIn("Disallowed CORS", response.text)
|
|
||||||
|
|
||||||
@async_test
|
|
||||||
async def test_credentials_echo_origin_and_set_flag(self) -> None:
|
|
||||||
app = _app(_settings({
|
|
||||||
"CORS_ALLOW_ORIGINS": "*",
|
|
||||||
"CORS_ALLOW_CREDENTIALS": "true",
|
|
||||||
}))
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app),
|
|
||||||
base_url="http://127.0.0.1") as client:
|
|
||||||
response = await client.get("/api/health", headers={"Origin": ORIGIN})
|
|
||||||
self.assertEqual(200, response.status_code)
|
|
||||||
self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"])
|
|
||||||
self.assertEqual("true", response.headers["access-control-allow-credentials"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
+26
-215
@@ -1,7 +1,4 @@
|
|||||||
//! Live game page: table view over the websocket.
|
//! Live game page: table view over the websocket.
|
||||||
use std::cell::Cell;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
use crate::components::card::{card_back, card_img};
|
use crate::components::card::{card_back, card_img};
|
||||||
@@ -73,143 +70,6 @@ fn move_banner(mv: MoveView) -> View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Signals shared by the websocket connection and its reconnect attempts.
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct ConnCtx {
|
|
||||||
socket: Signal<Option<GameSocket>>,
|
|
||||||
game: Signal<Option<GameView>>,
|
|
||||||
over: Signal<Option<(Scores, Option<String>)>>,
|
|
||||||
error: Signal<Option<String>>,
|
|
||||||
closed: Signal<bool>,
|
|
||||||
/// Reconnect attempts exhausted; only a manual retry resumes.
|
|
||||||
gave_up: Signal<bool>,
|
|
||||||
/// The server closed the connection deliberately (auth or game gone);
|
|
||||||
/// retrying is pointless.
|
|
||||||
fatal: Signal<bool>,
|
|
||||||
attempts: Signal<u32>,
|
|
||||||
capture_choice: Signal<Option<(String, Vec<Vec<String>>)>>,
|
|
||||||
selected: Signal<Option<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reconnect attempts: 1s, 2s, 4s, … capped at 30s, at most this many.
|
|
||||||
const MAX_RECONNECT_ATTEMPTS: u32 = 10;
|
|
||||||
|
|
||||||
fn backoff_ms(attempt: u32) -> u32 {
|
|
||||||
(1000u32 << attempt.min(5)).min(30_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connect the game websocket, wiring state updates and reconnects.
|
|
||||||
///
|
|
||||||
/// The server pushes a full state snapshot on connect, so a reconnect is
|
|
||||||
/// also a resync: no client-side state merging is needed.
|
|
||||||
fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
|
||||||
let on_message = {
|
|
||||||
let alive = alive.clone();
|
|
||||||
move |msg: ServerMessage| {
|
|
||||||
if !alive.get() {
|
|
||||||
// The page is unmounted; its signals are disposed.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match msg {
|
|
||||||
ServerMessage::State { game: g } => {
|
|
||||||
ctx.capture_choice.set(None);
|
|
||||||
ctx.selected.set(None);
|
|
||||||
// A received state proves the (re)connection works.
|
|
||||||
ctx.attempts.set(0);
|
|
||||||
ctx.gave_up.set(false);
|
|
||||||
ctx.closed.set(false);
|
|
||||||
ctx.game.set(Some(g));
|
|
||||||
}
|
|
||||||
ServerMessage::GameOver { scores, winner } => {
|
|
||||||
ctx.over.set(Some((scores, winner)))
|
|
||||||
}
|
|
||||||
ServerMessage::Error { message, .. } => ctx.error.set(Some(message)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let on_close = {
|
|
||||||
let id = id.clone();
|
|
||||||
let alive = alive.clone();
|
|
||||||
move |code: Option<u16>| {
|
|
||||||
if !alive.get() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.closed.set(true);
|
|
||||||
match code {
|
|
||||||
Some(4401) => {
|
|
||||||
ctx.fatal.set(true);
|
|
||||||
ctx.error
|
|
||||||
.set(Some("Session expired — please log in again.".to_string()));
|
|
||||||
}
|
|
||||||
Some(4403) | Some(4404) => {
|
|
||||||
ctx.fatal.set(true);
|
|
||||||
ctx.error
|
|
||||||
.set(Some("This game is no longer available.".to_string()));
|
|
||||||
}
|
|
||||||
_ => schedule_retry(id.clone(), ctx, alive.clone()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match ws::connect(&id, on_message, on_close) {
|
|
||||||
Some(s) => ctx.socket.set(Some(s)),
|
|
||||||
// WebSocket::open failed synchronously: treat as a transient loss.
|
|
||||||
None if alive.get() => {
|
|
||||||
ctx.closed.set(true);
|
|
||||||
schedule_retry(id, ctx, alive);
|
|
||||||
}
|
|
||||||
None => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retry `start_connect` with exponential backoff, unless we gave up.
|
|
||||||
fn schedule_retry(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
|
||||||
let attempt = ctx.attempts.get();
|
|
||||||
if attempt >= MAX_RECONNECT_ATTEMPTS {
|
|
||||||
ctx.gave_up.set(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.attempts.set(attempt + 1);
|
|
||||||
gloo_timers::callback::Timeout::new(backoff_ms(attempt), move || {
|
|
||||||
if alive.get() {
|
|
||||||
start_connect(id, ctx, alive);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.forget();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Slim banner shown over the table while the socket is down.
|
|
||||||
fn conn_banner(
|
|
||||||
closed: bool,
|
|
||||||
gave_up: bool,
|
|
||||||
fatal: bool,
|
|
||||||
has_game: bool,
|
|
||||||
reconnect: Rc<dyn Fn()>,
|
|
||||||
) -> View {
|
|
||||||
if !closed || !has_game {
|
|
||||||
return view! {};
|
|
||||||
}
|
|
||||||
if fatal {
|
|
||||||
view! {
|
|
||||||
div(class="conn-banner") {
|
|
||||||
"Connection closed by the server. "
|
|
||||||
a(href="/") { "Back to lobby" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if gave_up {
|
|
||||||
view! {
|
|
||||||
div(class="conn-banner") {
|
|
||||||
"Connection lost."
|
|
||||||
button(class="button", on:click=move |_| reconnect()) { "Retry now" }
|
|
||||||
a(href="/") { "Back to lobby" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
view! {
|
|
||||||
div(class="conn-banner") { "Connection lost — reconnecting…" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component(inline_props)]
|
#[component(inline_props)]
|
||||||
pub fn GamePage(id: String) -> View {
|
pub fn GamePage(id: String) -> View {
|
||||||
let game = create_signal(Option::<GameView>::None);
|
let game = create_signal(Option::<GameView>::None);
|
||||||
@@ -218,51 +78,32 @@ pub fn GamePage(id: String) -> View {
|
|||||||
let selected = create_signal(Option::<String>::None);
|
let selected = create_signal(Option::<String>::None);
|
||||||
let over = create_signal(Option::<(Scores, Option<String>)>::None);
|
let over = create_signal(Option::<(Scores, Option<String>)>::None);
|
||||||
let closed = create_signal(false);
|
let closed = create_signal(false);
|
||||||
let gave_up = create_signal(false);
|
|
||||||
let fatal = create_signal(false);
|
|
||||||
let attempts = create_signal(0u32);
|
|
||||||
let socket = create_signal(Option::<GameSocket>::None);
|
let socket = create_signal(Option::<GameSocket>::None);
|
||||||
// Ticking clock driving the hand-end countdown display.
|
// Ticking clock driving the hand-end countdown display.
|
||||||
let now = create_signal(js_sys::Date::now());
|
let now = create_signal(js_sys::Date::now());
|
||||||
let ticker = gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now()));
|
gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now())).forget();
|
||||||
|
|
||||||
// Stops the ticker and any pending reconnect once the page unmounts.
|
{
|
||||||
let alive = Rc::new(Cell::new(true));
|
let on_message = move |msg: ServerMessage| match msg {
|
||||||
on_cleanup({
|
ServerMessage::State { game: g } => {
|
||||||
let alive = alive.clone();
|
capture_choice.set(None);
|
||||||
move || {
|
selected.set(None);
|
||||||
alive.set(false);
|
game.set(Some(g));
|
||||||
drop(ticker);
|
}
|
||||||
|
ServerMessage::GameOver { scores, winner } => {
|
||||||
|
over.set(Some((scores, winner)));
|
||||||
|
}
|
||||||
|
ServerMessage::Error { message, .. } => error.set(Some(message)),
|
||||||
|
};
|
||||||
|
let on_close = move || closed.set(true);
|
||||||
|
match ws::connect(&id, on_message, on_close) {
|
||||||
|
Some(s) => socket.set(Some(s)),
|
||||||
|
None => error.set(Some("Could not connect to the game".to_string())),
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
let id = Rc::new(id);
|
|
||||||
let ctx = ConnCtx {
|
|
||||||
socket,
|
|
||||||
game,
|
|
||||||
over,
|
|
||||||
error,
|
|
||||||
closed,
|
|
||||||
gave_up,
|
|
||||||
fatal,
|
|
||||||
attempts,
|
|
||||||
capture_choice,
|
|
||||||
selected,
|
|
||||||
};
|
|
||||||
start_connect(id.clone(), ctx, alive.clone());
|
|
||||||
let reconnect: Rc<dyn Fn()> = Rc::new(move || {
|
|
||||||
ctx.attempts.set(0);
|
|
||||||
ctx.gave_up.set(false);
|
|
||||||
ctx.closed.set(false);
|
|
||||||
start_connect(id.clone(), ctx, alive.clone());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clicking a card in the player's own hand.
|
// Clicking a card in the player's own hand.
|
||||||
let on_hand_card = move |code: String| {
|
let on_hand_card = move |code: String| {
|
||||||
if closed.get() {
|
|
||||||
// A dead socket would swallow the play silently.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Some(g) = game.get_clone() else { return };
|
let Some(g) = game.get_clone() else { return };
|
||||||
if g.your_turn != Some(true) {
|
if g.your_turn != Some(true) {
|
||||||
return;
|
return;
|
||||||
@@ -281,50 +122,20 @@ pub fn GamePage(id: String) -> View {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let reconnect_banner = reconnect.clone();
|
|
||||||
view! {
|
view! {
|
||||||
div(class="game-page") {
|
div(class="game-page") {
|
||||||
(toast(error))
|
(toast(error))
|
||||||
(move || conn_banner(
|
|
||||||
closed.get(),
|
|
||||||
gave_up.get(),
|
|
||||||
fatal.get(),
|
|
||||||
game.get_clone().is_some(),
|
|
||||||
reconnect_banner.clone(),
|
|
||||||
))
|
|
||||||
(move || match game.get_clone() {
|
(move || match game.get_clone() {
|
||||||
None => {
|
None => {
|
||||||
if fatal.get() {
|
let status = if closed.get() {
|
||||||
view! {
|
"Connection closed."
|
||||||
div(class="panel status-panel") {
|
|
||||||
p { "Connection closed." }
|
|
||||||
p { a(href="/") { "Back to lobby" } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if gave_up.get() {
|
|
||||||
let reconnect = reconnect.clone();
|
|
||||||
view! {
|
|
||||||
div(class="panel status-panel") {
|
|
||||||
p { "Connection lost." }
|
|
||||||
p {
|
|
||||||
button(class="button primary", on:click=move |_| reconnect()) {
|
|
||||||
"Retry now"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p { a(href="/") { "Back to lobby" } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
let status = if closed.get() {
|
"Connecting to the game…"
|
||||||
"Connection lost — reconnecting…"
|
};
|
||||||
} else {
|
view! {
|
||||||
"Connecting to the game…"
|
div(class="panel status-panel") {
|
||||||
};
|
p { (status) }
|
||||||
view! {
|
p { a(href="/") { "Back to lobby" } }
|
||||||
div(class="panel status-panel") {
|
|
||||||
p { (status) }
|
|
||||||
p { a(href="/") { "Back to lobby" } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-12
@@ -4,7 +4,7 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
use futures::channel::mpsc;
|
use futures::channel::mpsc;
|
||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use gloo_net::websocket::{futures::WebSocket, Message, WebSocketError};
|
use gloo_net::websocket::{futures::WebSocket, Message};
|
||||||
use wasm_bindgen_futures::spawn_local;
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
|
||||||
use crate::model::ServerMessage;
|
use crate::model::ServerMessage;
|
||||||
@@ -53,14 +53,10 @@ impl GameSocket {
|
|||||||
/// Open the websocket for `game_id` and forward parsed server messages to
|
/// Open the websocket for `game_id` and forward parsed server messages to
|
||||||
/// `on_message`. Returns the socket handle, or `None` if the connection
|
/// `on_message`. Returns the socket handle, or `None` if the connection
|
||||||
/// could not be created.
|
/// could not be created.
|
||||||
///
|
|
||||||
/// `on_close` fires exactly once when the connection ends; it receives the
|
|
||||||
/// server close code when one was sent (e.g. 4401 unauthenticated, 4403 not
|
|
||||||
/// seated, 4404 unknown game) or `None` for an abnormal network loss.
|
|
||||||
pub fn connect(
|
pub fn connect(
|
||||||
game_id: &str,
|
game_id: &str,
|
||||||
on_message: impl Fn(ServerMessage) + 'static,
|
on_message: impl Fn(ServerMessage) + 'static,
|
||||||
on_close: impl Fn(Option<u16>) + 'static,
|
on_close: impl Fn() + 'static,
|
||||||
) -> Option<GameSocket> {
|
) -> Option<GameSocket> {
|
||||||
let ws = WebSocket::open(&ws_url(game_id)).ok()?;
|
let ws = WebSocket::open(&ws_url(game_id)).ok()?;
|
||||||
let (mut write, mut read) = ws.split();
|
let (mut write, mut read) = ws.split();
|
||||||
@@ -76,7 +72,6 @@ pub fn connect(
|
|||||||
});
|
});
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
let mut close_code = None;
|
|
||||||
while let Some(msg) = read.next().await {
|
while let Some(msg) = read.next().await {
|
||||||
match msg {
|
match msg {
|
||||||
Ok(Message::Text(text)) => {
|
Ok(Message::Text(text)) => {
|
||||||
@@ -85,14 +80,10 @@ pub fn connect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Message::Bytes(_)) => {}
|
Ok(Message::Bytes(_)) => {}
|
||||||
Err(WebSocketError::ConnectionClose(e)) => {
|
|
||||||
close_code = Some(e.code);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
on_close(close_code);
|
on_close();
|
||||||
});
|
});
|
||||||
|
|
||||||
Some(GameSocket {
|
Some(GameSocket {
|
||||||
|
|||||||
@@ -418,27 +418,6 @@ table.matches td.lost {
|
|||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- connection banner ---------- */
|
|
||||||
|
|
||||||
.conn-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
background: rgba(232, 197, 71, 0.15);
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--accent);
|
|
||||||
padding: 0.4rem 1rem;
|
|
||||||
margin: 0.5rem auto 0;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.conn-banner a {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- overlays ---------- */
|
/* ---------- overlays ---------- */
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
|
|||||||
Reference in New Issue
Block a user