hypern 0.3.0__cp312-cp312-win32.whl → 0.3.1__cp312-cp312-win32.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.
- hypern/caching/strategies.py +115 -0
- hypern/hypern.cp312-win32.pyd +0 -0
- hypern/hypern.pyi +10 -2
- hypern/middleware/security.py +179 -0
- hypern/reload.py +26 -40
- hypern/ws/__init__.py +4 -0
- hypern/ws/channel.py +80 -0
- hypern/ws/heartbeat.py +74 -0
- hypern/ws/room.py +76 -0
- hypern/ws/route.py +26 -0
- {hypern-0.3.0.dist-info → hypern-0.3.1.dist-info}/METADATA +1 -1
- {hypern-0.3.0.dist-info → hypern-0.3.1.dist-info}/RECORD +14 -7
- {hypern-0.3.0.dist-info → hypern-0.3.1.dist-info}/WHEEL +1 -1
- {hypern-0.3.0.dist-info → hypern-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,115 @@
|
|
1
|
+
from typing import Any, Optional, Callable, TypeVar
|
2
|
+
from datetime import datetime
|
3
|
+
import asyncio
|
4
|
+
import orjson
|
5
|
+
|
6
|
+
from hypern.logging import logger
|
7
|
+
|
8
|
+
T = TypeVar("T")
|
9
|
+
|
10
|
+
|
11
|
+
class CacheEntry:
|
12
|
+
def __init__(self, value: Any, expires_at: int, stale_at: Optional[int] = None):
|
13
|
+
self.value = value
|
14
|
+
self.expires_at = expires_at
|
15
|
+
self.stale_at = stale_at or expires_at
|
16
|
+
self.is_revalidating = False
|
17
|
+
|
18
|
+
def to_json(self) -> str:
|
19
|
+
return orjson.dumps({"value": self.value, "expires_at": self.expires_at, "stale_at": self.stale_at, "is_revalidating": self.is_revalidating})
|
20
|
+
|
21
|
+
@classmethod
|
22
|
+
def from_json(cls, data: str) -> "CacheEntry":
|
23
|
+
data_dict = orjson.loads(data)
|
24
|
+
entry = cls(value=data_dict["value"], expires_at=data_dict["expires_at"], stale_at=data_dict["stale_at"])
|
25
|
+
entry.is_revalidating = data_dict["is_revalidating"]
|
26
|
+
return entry
|
27
|
+
|
28
|
+
|
29
|
+
class CacheStrategy:
|
30
|
+
def __init__(self, backend: Any):
|
31
|
+
self.backend = backend
|
32
|
+
|
33
|
+
async def get(self, key: str, loader: Callable[[], T]) -> T:
|
34
|
+
raise NotImplementedError
|
35
|
+
|
36
|
+
|
37
|
+
class StaleWhileRevalidateStrategy(CacheStrategy):
|
38
|
+
def __init__(self, backend: Any, stale_ttl: int, cache_ttl: int):
|
39
|
+
super().__init__(backend)
|
40
|
+
self.stale_ttl = stale_ttl
|
41
|
+
self.cache_ttl = cache_ttl
|
42
|
+
|
43
|
+
async def get(self, key: str, loader: Callable[[], T]) -> T:
|
44
|
+
now = int(datetime.now().timestamp())
|
45
|
+
|
46
|
+
# Try to get from cache
|
47
|
+
cached_data = await self.backend.get(key)
|
48
|
+
if cached_data:
|
49
|
+
entry = CacheEntry.from_json(cached_data)
|
50
|
+
|
51
|
+
if now < entry.stale_at:
|
52
|
+
# Cache is fresh
|
53
|
+
return entry.value
|
54
|
+
|
55
|
+
if now < entry.expires_at and not entry.is_revalidating:
|
56
|
+
# Cache is stale but usable - trigger background revalidation
|
57
|
+
entry.is_revalidating = True
|
58
|
+
await self.backend.set(key, entry.to_json(), self.cache_ttl)
|
59
|
+
asyncio.create_task(self._revalidate(key, loader))
|
60
|
+
return entry.value
|
61
|
+
|
62
|
+
# Cache miss or expired - load fresh data
|
63
|
+
value = await loader()
|
64
|
+
entry = CacheEntry(value=value, expires_at=now + self.cache_ttl, stale_at=now + (self.cache_ttl - self.stale_ttl))
|
65
|
+
await self.backend.set(key, entry.to_json(), self.cache_ttl)
|
66
|
+
return value
|
67
|
+
|
68
|
+
async def _revalidate(self, key: str, loader: Callable[[], T]):
|
69
|
+
try:
|
70
|
+
value = await loader()
|
71
|
+
now = int(datetime.now().timestamp())
|
72
|
+
entry = CacheEntry(value=value, expires_at=now + self.cache_ttl, stale_at=now + (self.cache_ttl - self.stale_ttl))
|
73
|
+
await self.backend.set(key, entry.to_json(), self.cache_ttl)
|
74
|
+
except Exception as e:
|
75
|
+
logger.error(f"Revalidation failed for key {key}: {e}")
|
76
|
+
|
77
|
+
|
78
|
+
class CacheAsideStrategy(CacheStrategy):
|
79
|
+
def __init__(self, backend: Any, ttl: int):
|
80
|
+
super().__init__(backend)
|
81
|
+
self.ttl = ttl
|
82
|
+
|
83
|
+
async def get(self, key: str, loader: Callable[[], T]) -> T:
|
84
|
+
# Try to get from cache
|
85
|
+
cached_data = await self.backend.get(key)
|
86
|
+
if cached_data:
|
87
|
+
entry = CacheEntry.from_json(cached_data)
|
88
|
+
if entry.expires_at > int(datetime.now().timestamp()):
|
89
|
+
return entry.value
|
90
|
+
|
91
|
+
# Cache miss or expired - load from source
|
92
|
+
value = await loader()
|
93
|
+
entry = CacheEntry(value=value, expires_at=int(datetime.now().timestamp()) + self.ttl)
|
94
|
+
await self.backend.set(key, entry.to_json(), self.ttl)
|
95
|
+
return value
|
96
|
+
|
97
|
+
|
98
|
+
def cache_with_strategy(strategy: CacheStrategy, key_prefix: str = None):
|
99
|
+
"""
|
100
|
+
Decorator for using cache strategies
|
101
|
+
"""
|
102
|
+
|
103
|
+
def decorator(func):
|
104
|
+
async def wrapper(*args, **kwargs):
|
105
|
+
# Generate cache key
|
106
|
+
cache_key = f"{key_prefix or func.__name__}:{hash(str(args) + str(kwargs))}"
|
107
|
+
|
108
|
+
async def loader():
|
109
|
+
return await func(*args, **kwargs)
|
110
|
+
|
111
|
+
return await strategy.get(cache_key, loader)
|
112
|
+
|
113
|
+
return wrapper
|
114
|
+
|
115
|
+
return decorator
|
hypern/hypern.cp312-win32.pyd
CHANGED
Binary file
|
hypern/hypern.pyi
CHANGED
@@ -16,6 +16,9 @@ class RedisBackend(BaseBackend):
|
|
16
16
|
get: Callable[[str], Any]
|
17
17
|
set: Callable[[Any, str, int], None]
|
18
18
|
delete_startswith: Callable[[str], None]
|
19
|
+
set_nx: Callable[[Any, str, int], None]
|
20
|
+
get_ttl: Callable[[str], int]
|
21
|
+
current_timestamp: Callable[[], int]
|
19
22
|
|
20
23
|
@dataclass
|
21
24
|
class BaseSchemaGenerator:
|
@@ -257,11 +260,16 @@ class WebsocketRouter:
|
|
257
260
|
class Header:
|
258
261
|
headers: Dict[str, str]
|
259
262
|
|
263
|
+
def get(self, key: str) -> str | None: ...
|
264
|
+
def set(self, key: str, value: str) -> None: ...
|
265
|
+
def append(self, key: str, value: str) -> None: ...
|
266
|
+
def update(self, headers: Dict[str, str]) -> None: ...
|
267
|
+
|
260
268
|
@dataclass
|
261
269
|
class Response:
|
262
270
|
status_code: int
|
263
271
|
response_type: str
|
264
|
-
headers:
|
272
|
+
headers: Header
|
265
273
|
description: str
|
266
274
|
file_path: str
|
267
275
|
|
@@ -286,7 +294,7 @@ class BodyData:
|
|
286
294
|
@dataclass
|
287
295
|
class Request:
|
288
296
|
query_params: QueryParams
|
289
|
-
headers:
|
297
|
+
headers: Header
|
290
298
|
path_params: Dict[str, str]
|
291
299
|
body: BodyData
|
292
300
|
method: str
|
@@ -0,0 +1,179 @@
|
|
1
|
+
import hashlib
|
2
|
+
import hmac
|
3
|
+
import secrets
|
4
|
+
import time
|
5
|
+
from base64 import b64decode, b64encode
|
6
|
+
from dataclasses import dataclass
|
7
|
+
from datetime import datetime, timedelta
|
8
|
+
from typing import Any, Dict, List, Optional
|
9
|
+
|
10
|
+
import jwt
|
11
|
+
|
12
|
+
from hypern.exceptions import Forbidden, Unauthorized
|
13
|
+
from hypern.hypern import Middleware, Request, Response
|
14
|
+
|
15
|
+
|
16
|
+
@dataclass
|
17
|
+
class CORSConfig:
|
18
|
+
allowed_origins: List[str]
|
19
|
+
allowed_methods: List[str]
|
20
|
+
max_age: int
|
21
|
+
|
22
|
+
|
23
|
+
@dataclass
|
24
|
+
class SecurityConfig:
|
25
|
+
rate_limiting: bool = False
|
26
|
+
jwt_auth: bool = False
|
27
|
+
cors_configuration: Optional[CORSConfig] = None
|
28
|
+
csrf_protection: bool = False
|
29
|
+
security_headers: Optional[Dict[str, str]] = None
|
30
|
+
jwt_secret: str = ""
|
31
|
+
jwt_algorithm: str = "HS256"
|
32
|
+
jwt_expires_in: int = 3600 # 1 hour in seconds
|
33
|
+
|
34
|
+
def __post_init__(self):
|
35
|
+
if self.cors_configuration:
|
36
|
+
self.cors_configuration = CORSConfig(**self.cors_configuration)
|
37
|
+
|
38
|
+
if self.security_headers is None:
|
39
|
+
self.security_headers = {
|
40
|
+
"X-Frame-Options": "DENY",
|
41
|
+
"X-Content-Type-Options": "nosniff",
|
42
|
+
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
43
|
+
}
|
44
|
+
|
45
|
+
|
46
|
+
class SecurityMiddleware(Middleware):
|
47
|
+
def __init__(self, config: SecurityConfig):
|
48
|
+
super().__init__()
|
49
|
+
self.config = config
|
50
|
+
self._secret_key = secrets.token_bytes(32)
|
51
|
+
self._token_lifetime = 3600
|
52
|
+
self._rate_limit_storage = {}
|
53
|
+
|
54
|
+
def _rate_limit_check(self, request: Request) -> Optional[Response]:
|
55
|
+
"""Check if the request exceeds rate limits"""
|
56
|
+
if not self.config.rate_limiting:
|
57
|
+
return None
|
58
|
+
|
59
|
+
client_ip = request.client.host
|
60
|
+
current_time = time.time()
|
61
|
+
window_start = int(current_time - 60) # 1-minute window
|
62
|
+
|
63
|
+
# Clean up old entries
|
64
|
+
self._rate_limit_storage = {ip: hits for ip, hits in self._rate_limit_storage.items() if hits["timestamp"] > window_start}
|
65
|
+
|
66
|
+
if client_ip not in self._rate_limit_storage:
|
67
|
+
self._rate_limit_storage[client_ip] = {"count": 1, "timestamp": current_time}
|
68
|
+
else:
|
69
|
+
self._rate_limit_storage[client_ip]["count"] += 1
|
70
|
+
|
71
|
+
if self._rate_limit_storage[client_ip]["count"] > 60: # 60 requests per minute
|
72
|
+
return Response(status_code=429, description=b"Too Many Requests", headers={"Retry-After": "60"})
|
73
|
+
return None
|
74
|
+
|
75
|
+
def _generate_jwt_token(self, user_data: Dict[str, Any]) -> str:
|
76
|
+
"""Generate a JWT token"""
|
77
|
+
if not self.config.jwt_secret:
|
78
|
+
raise ValueError("JWT secret key is not configured")
|
79
|
+
|
80
|
+
payload = {"user": user_data, "exp": datetime.utcnow() + timedelta(seconds=self.config.jwt_expires_in), "iat": datetime.utcnow()}
|
81
|
+
return jwt.encode(payload, self.config.jwt_secret, algorithm=self.config.jwt_algorithm)
|
82
|
+
|
83
|
+
def _verify_jwt_token(self, token: str) -> Dict[str, Any]:
|
84
|
+
"""Verify JWT token and return payload"""
|
85
|
+
try:
|
86
|
+
payload = jwt.decode(token, self.config.jwt_secret, algorithms=[self.config.jwt_algorithm])
|
87
|
+
return payload
|
88
|
+
except jwt.ExpiredSignatureError:
|
89
|
+
raise Unauthorized("Token has expired")
|
90
|
+
except jwt.InvalidTokenError:
|
91
|
+
raise Unauthorized("Invalid token")
|
92
|
+
|
93
|
+
def _generate_csrf_token(self, session_id: str) -> str:
|
94
|
+
"""Generate a new CSRF token"""
|
95
|
+
timestamp = str(int(time.time()))
|
96
|
+
token_data = f"{session_id}:{timestamp}"
|
97
|
+
signature = hmac.new(self._secret_key, token_data.encode(), hashlib.sha256).digest()
|
98
|
+
return b64encode(f"{token_data}:{b64encode(signature).decode()}".encode()).decode()
|
99
|
+
|
100
|
+
def _validate_csrf_token(self, token: str) -> bool:
|
101
|
+
"""Validate CSRF token"""
|
102
|
+
try:
|
103
|
+
decoded_token = b64decode(token.encode()).decode()
|
104
|
+
session_id, timestamp, signature = decoded_token.rsplit(":", 2)
|
105
|
+
|
106
|
+
# Verify timestamp
|
107
|
+
token_time = int(timestamp)
|
108
|
+
current_time = int(time.time())
|
109
|
+
if current_time - token_time > self._token_lifetime:
|
110
|
+
return False
|
111
|
+
|
112
|
+
# Verify signature
|
113
|
+
expected_data = f"{session_id}:{timestamp}"
|
114
|
+
expected_signature = hmac.new(self._secret_key, expected_data.encode(), hashlib.sha256).digest()
|
115
|
+
|
116
|
+
actual_signature = b64decode(signature)
|
117
|
+
return hmac.compare_digest(expected_signature, actual_signature)
|
118
|
+
|
119
|
+
except (ValueError, AttributeError, TypeError):
|
120
|
+
return False
|
121
|
+
|
122
|
+
def _apply_cors_headers(self, response: Response) -> None:
|
123
|
+
"""Apply CORS headers to response"""
|
124
|
+
if not self.config.cors_configuration:
|
125
|
+
return
|
126
|
+
|
127
|
+
cors = self.config.cors_configuration
|
128
|
+
response.headers.update(
|
129
|
+
{
|
130
|
+
"Access-Control-Allow-Origin": ", ".join(cors.allowed_origins),
|
131
|
+
"Access-Control-Allow-Methods": ", ".join(cors.allowed_methods),
|
132
|
+
"Access-Control-Max-Age": str(cors.max_age),
|
133
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-CSRF-Token",
|
134
|
+
"Access-Control-Allow-Credentials": "true",
|
135
|
+
}
|
136
|
+
)
|
137
|
+
|
138
|
+
def _apply_security_headers(self, response: Response) -> None:
|
139
|
+
"""Apply security headers to response"""
|
140
|
+
if self.config.security_headers:
|
141
|
+
response.headers.update(self.config.security_headers)
|
142
|
+
|
143
|
+
async def before_request(self, request: Request) -> Request | Response:
|
144
|
+
"""Process request before handling"""
|
145
|
+
# Rate limiting check
|
146
|
+
if rate_limit_response := self._rate_limit_check(request):
|
147
|
+
return rate_limit_response
|
148
|
+
|
149
|
+
# JWT authentication check
|
150
|
+
if self.config.jwt_auth:
|
151
|
+
auth_header = request.headers.get("Authorization")
|
152
|
+
if not auth_header or not auth_header.startswith("Bearer "):
|
153
|
+
raise Unauthorized("Missing or invalid authorization header")
|
154
|
+
token = auth_header.split(" ")[1]
|
155
|
+
try:
|
156
|
+
request.user = self._verify_jwt_token(token)
|
157
|
+
except Unauthorized as e:
|
158
|
+
return Response(status_code=401, description=str(e))
|
159
|
+
|
160
|
+
# CSRF protection check
|
161
|
+
if self.config.csrf_protection and request.method in ["POST", "PUT", "DELETE", "PATCH"]:
|
162
|
+
csrf_token = request.headers.get("X-CSRF-Token")
|
163
|
+
if not csrf_token or not self._validate_csrf_token(csrf_token):
|
164
|
+
raise Forbidden("CSRF token missing or invalid")
|
165
|
+
|
166
|
+
return request
|
167
|
+
|
168
|
+
async def after_request(self, response: Response) -> Response:
|
169
|
+
"""Process response after handling"""
|
170
|
+
self._apply_security_headers(response)
|
171
|
+
self._apply_cors_headers(response)
|
172
|
+
return response
|
173
|
+
|
174
|
+
def generate_csrf_token(self, request: Request) -> str:
|
175
|
+
"""Generate and set CSRF token for the request"""
|
176
|
+
if not hasattr(request, "session_id"):
|
177
|
+
request.session_id = secrets.token_urlsafe(32)
|
178
|
+
token = self._generate_csrf_token(request.session_id)
|
179
|
+
return token
|
hypern/reload.py
CHANGED
@@ -2,6 +2,8 @@ import sys
|
|
2
2
|
import time
|
3
3
|
import subprocess
|
4
4
|
from watchdog.events import FileSystemEventHandler
|
5
|
+
import signal
|
6
|
+
import os
|
5
7
|
|
6
8
|
from .logging import logger
|
7
9
|
|
@@ -10,51 +12,35 @@ class EventHandler(FileSystemEventHandler):
|
|
10
12
|
def __init__(self, file_path: str, directory_path: str) -> None:
|
11
13
|
self.file_path = file_path
|
12
14
|
self.directory_path = directory_path
|
13
|
-
self.process = None
|
14
|
-
self.last_reload = time.time()
|
15
|
-
|
16
|
-
def stop_server(self):
|
17
|
-
if self.process:
|
18
|
-
try:
|
19
|
-
# Check if the process is still alive
|
20
|
-
if self.process.poll() is None: # None means the process is still running
|
21
|
-
self.process.terminate() # Gracefully terminate the process
|
22
|
-
self.process.wait(timeout=5) # Wait for the process to exit
|
23
|
-
else:
|
24
|
-
logger.error("Process is not running.")
|
25
|
-
except subprocess.TimeoutExpired:
|
26
|
-
logger.error("Process did not terminate in time. Forcing termination.")
|
27
|
-
self.process.kill() # Forcefully kill the process if it doesn't stop
|
28
|
-
except ProcessLookupError:
|
29
|
-
logger.error("Process does not exist.")
|
30
|
-
except Exception as e:
|
31
|
-
logger.error(f"An error occurred while stopping the server: {e}")
|
32
|
-
else:
|
33
|
-
logger.debug("No process to stop.")
|
15
|
+
self.process = None
|
16
|
+
self.last_reload = time.time()
|
34
17
|
|
35
18
|
def reload(self):
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
19
|
+
# Kill all existing processes with the same command
|
20
|
+
current_cmd = [sys.executable, *sys.argv]
|
21
|
+
|
22
|
+
try:
|
23
|
+
# Find and kill existing processes
|
24
|
+
for proc in subprocess.Popen(["ps", "aux"], stdout=subprocess.PIPE).communicate()[0].decode().splitlines():
|
25
|
+
if all(str(arg) in proc for arg in current_cmd):
|
26
|
+
pid = int(proc.split()[1])
|
27
|
+
try:
|
28
|
+
os.kill(pid, signal.SIGKILL) # NOSONAR
|
29
|
+
logger.debug(f"Killed process with PID {pid}")
|
30
|
+
except ProcessLookupError:
|
31
|
+
pass
|
32
|
+
|
33
|
+
# Start new process
|
34
|
+
self.process = subprocess.Popen(current_cmd)
|
35
|
+
self.last_reload = time.time()
|
36
|
+
logger.debug("Server reloaded successfully")
|
37
|
+
|
38
|
+
except Exception as e:
|
39
|
+
logger.error(f"Reload failed: {e}")
|
47
40
|
|
48
41
|
def on_modified(self, event) -> None:
|
49
|
-
"""
|
50
|
-
This function is a callback that will start a new server on every even change
|
51
|
-
|
52
|
-
:param event FSEvent: a data structure with info about the events
|
53
|
-
"""
|
54
|
-
|
55
|
-
# Avoid reloading multiple times when watchdog detects multiple events
|
56
42
|
if time.time() - self.last_reload < 0.5:
|
57
43
|
return
|
58
44
|
|
59
|
-
time.sleep(0.2) #
|
45
|
+
time.sleep(0.2) # Ensure file is written
|
60
46
|
self.reload()
|
hypern/ws/__init__.py
ADDED
hypern/ws/channel.py
ADDED
@@ -0,0 +1,80 @@
|
|
1
|
+
from dataclasses import dataclass, field
|
2
|
+
from typing import Any, Awaitable, Callable, Dict, Set
|
3
|
+
|
4
|
+
from hypern.hypern import WebSocketSession
|
5
|
+
|
6
|
+
|
7
|
+
@dataclass
|
8
|
+
class Channel:
|
9
|
+
name: str
|
10
|
+
subscribers: Set[WebSocketSession] = field(default_factory=set)
|
11
|
+
handlers: Dict[str, Callable[[WebSocketSession, Any], Awaitable[None]]] = field(default_factory=dict)
|
12
|
+
|
13
|
+
def publish(self, event: str, data: Any, publisher: WebSocketSession = None):
|
14
|
+
"""Publish an event to all subscribers except the publisher"""
|
15
|
+
for subscriber in self.subscribers:
|
16
|
+
if subscriber != publisher:
|
17
|
+
subscriber.send({"channel": self.name, "event": event, "data": data})
|
18
|
+
|
19
|
+
def handle_event(self, event: str, session: WebSocketSession, data: Any):
|
20
|
+
"""Handle an event on this channel"""
|
21
|
+
if event in self.handlers:
|
22
|
+
self.handlers[event](session, data)
|
23
|
+
|
24
|
+
def add_subscriber(self, subscriber: WebSocketSession):
|
25
|
+
"""Add a subscriber to the channel"""
|
26
|
+
self.subscribers.add(subscriber)
|
27
|
+
|
28
|
+
def remove_subscriber(self, subscriber: WebSocketSession):
|
29
|
+
"""Remove a subscriber from the channel"""
|
30
|
+
self.subscribers.discard(subscriber)
|
31
|
+
|
32
|
+
def on(self, event: str):
|
33
|
+
"""Decorator for registering event handlers"""
|
34
|
+
|
35
|
+
def decorator(handler: Callable[[WebSocketSession, Any], Awaitable[None]]):
|
36
|
+
self.handlers[event] = handler
|
37
|
+
return handler
|
38
|
+
|
39
|
+
return decorator
|
40
|
+
|
41
|
+
|
42
|
+
class ChannelManager:
|
43
|
+
def __init__(self):
|
44
|
+
self.channels: Dict[str, Channel] = {}
|
45
|
+
self.client_channels: Dict[WebSocketSession, Set[str]] = {}
|
46
|
+
|
47
|
+
def create_channel(self, channel_name: str) -> Channel:
|
48
|
+
"""Create a new channel if it doesn't exist"""
|
49
|
+
if channel_name not in self.channels:
|
50
|
+
self.channels[channel_name] = Channel(channel_name)
|
51
|
+
return self.channels[channel_name]
|
52
|
+
|
53
|
+
def get_channel(self, channel_name: str) -> Channel:
|
54
|
+
"""Get a channel by name"""
|
55
|
+
return self.channels.get(channel_name)
|
56
|
+
|
57
|
+
def subscribe(self, client: WebSocketSession, channel_name: str):
|
58
|
+
"""Subscribe a client to a channel"""
|
59
|
+
channel = self.create_channel(channel_name)
|
60
|
+
channel.add_subscriber(client)
|
61
|
+
|
62
|
+
if client not in self.client_channels:
|
63
|
+
self.client_channels[client] = set()
|
64
|
+
self.client_channels[client].add(channel_name)
|
65
|
+
|
66
|
+
def unsubscribe(self, client: WebSocketSession, channel_name: str):
|
67
|
+
"""Unsubscribe a client from a channel"""
|
68
|
+
channel = self.get_channel(channel_name)
|
69
|
+
if channel:
|
70
|
+
channel.remove_subscriber(client)
|
71
|
+
if client in self.client_channels:
|
72
|
+
self.client_channels[client].discard(channel_name)
|
73
|
+
|
74
|
+
def unsubscribe_all(self, client: WebSocketSession):
|
75
|
+
"""Unsubscribe a client from all channels"""
|
76
|
+
if client in self.client_channels:
|
77
|
+
channels = self.client_channels[client].copy()
|
78
|
+
for channel_name in channels:
|
79
|
+
self.unsubscribe(client, channel_name)
|
80
|
+
del self.client_channels[client]
|
hypern/ws/heartbeat.py
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
import asyncio
|
2
|
+
from dataclasses import dataclass
|
3
|
+
from time import time
|
4
|
+
from typing import Dict
|
5
|
+
|
6
|
+
from hypern.hypern import WebSocketSession
|
7
|
+
|
8
|
+
|
9
|
+
@dataclass
|
10
|
+
class HeartbeatConfig:
|
11
|
+
ping_interval: float = 30.0 # Send ping every 30 seconds
|
12
|
+
ping_timeout: float = 10.0 # Wait 10 seconds for pong response
|
13
|
+
max_missed_pings: int = 2 # Disconnect after 2 missed pings
|
14
|
+
|
15
|
+
|
16
|
+
class HeartbeatManager:
|
17
|
+
def __init__(self, config: HeartbeatConfig = None):
|
18
|
+
self.config = config or HeartbeatConfig()
|
19
|
+
self.active_sessions: Dict[WebSocketSession, float] = {}
|
20
|
+
self.ping_tasks: Dict[WebSocketSession, asyncio.Task] = {}
|
21
|
+
self.missed_pings: Dict[WebSocketSession, int] = {}
|
22
|
+
|
23
|
+
async def start_heartbeat(self, session: WebSocketSession):
|
24
|
+
"""Start heartbeat monitoring for a session"""
|
25
|
+
self.active_sessions[session] = time()
|
26
|
+
self.missed_pings[session] = 0
|
27
|
+
self.ping_tasks[session] = asyncio.create_task(self._heartbeat_loop(session))
|
28
|
+
|
29
|
+
async def stop_heartbeat(self, session: WebSocketSession):
|
30
|
+
"""Stop heartbeat monitoring for a session"""
|
31
|
+
if session in self.ping_tasks:
|
32
|
+
self.ping_tasks[session].cancel()
|
33
|
+
del self.ping_tasks[session]
|
34
|
+
self.active_sessions.pop(session, None)
|
35
|
+
self.missed_pings.pop(session, None)
|
36
|
+
|
37
|
+
async def handle_pong(self, session: WebSocketSession):
|
38
|
+
"""Handle pong response from client"""
|
39
|
+
if session in self.active_sessions:
|
40
|
+
self.active_sessions[session] = time()
|
41
|
+
self.missed_pings[session] = 0
|
42
|
+
|
43
|
+
async def _heartbeat_loop(self, session: WebSocketSession):
|
44
|
+
"""Main heartbeat loop for a session"""
|
45
|
+
try:
|
46
|
+
while True:
|
47
|
+
await asyncio.sleep(self.config.ping_interval)
|
48
|
+
|
49
|
+
if session not in self.active_sessions:
|
50
|
+
break
|
51
|
+
|
52
|
+
# Send ping frame
|
53
|
+
try:
|
54
|
+
await session.ping()
|
55
|
+
last_pong = self.active_sessions[session]
|
56
|
+
|
57
|
+
# Wait for pong timeout
|
58
|
+
await asyncio.sleep(self.config.ping_timeout)
|
59
|
+
|
60
|
+
# Check if we received a pong
|
61
|
+
if self.active_sessions[session] == last_pong:
|
62
|
+
self.missed_pings[session] += 1
|
63
|
+
|
64
|
+
# Check if we exceeded max missed pings
|
65
|
+
if self.missed_pings[session] >= self.config.max_missed_pings:
|
66
|
+
await session.close(1001, "Connection timeout")
|
67
|
+
break
|
68
|
+
|
69
|
+
except Exception as e:
|
70
|
+
await session.close(1001, f"Heartbeat failed: {str(e)}")
|
71
|
+
break
|
72
|
+
|
73
|
+
finally:
|
74
|
+
await self.stop_heartbeat(session)
|
hypern/ws/room.py
ADDED
@@ -0,0 +1,76 @@
|
|
1
|
+
from dataclasses import dataclass, field
|
2
|
+
from typing import Dict, Set
|
3
|
+
|
4
|
+
from hypern.hypern import WebSocketSession
|
5
|
+
|
6
|
+
|
7
|
+
@dataclass
|
8
|
+
class Room:
|
9
|
+
name: str
|
10
|
+
clients: Set[WebSocketSession] = field(default_factory=set)
|
11
|
+
|
12
|
+
def broadcast(self, message: str, exclude: WebSocketSession = None):
|
13
|
+
"""Broadcast message to all clients in the room except excluded one"""
|
14
|
+
for client in self.clients:
|
15
|
+
if client != exclude:
|
16
|
+
client.send(message)
|
17
|
+
|
18
|
+
def add_client(self, client: WebSocketSession):
|
19
|
+
"""Add a client to the room"""
|
20
|
+
self.clients.add(client)
|
21
|
+
|
22
|
+
def remove_client(self, client: WebSocketSession):
|
23
|
+
"""Remove a client from the room"""
|
24
|
+
self.clients.discard(client)
|
25
|
+
|
26
|
+
@property
|
27
|
+
def client_count(self) -> int:
|
28
|
+
return len(self.clients)
|
29
|
+
|
30
|
+
|
31
|
+
class RoomManager:
|
32
|
+
def __init__(self):
|
33
|
+
self.rooms: Dict[str, Room] = {}
|
34
|
+
self.client_rooms: Dict[WebSocketSession, Set[str]] = {}
|
35
|
+
|
36
|
+
def create_room(self, room_name: str) -> Room:
|
37
|
+
"""Create a new room if it doesn't exist"""
|
38
|
+
if room_name not in self.rooms:
|
39
|
+
self.rooms[room_name] = Room(room_name)
|
40
|
+
return self.rooms[room_name]
|
41
|
+
|
42
|
+
def get_room(self, room_name: str) -> Room:
|
43
|
+
"""Get a room by name"""
|
44
|
+
return self.rooms.get(room_name)
|
45
|
+
|
46
|
+
def join_room(self, client: WebSocketSession, room_name: str):
|
47
|
+
"""Add a client to a room"""
|
48
|
+
room = self.create_room(room_name)
|
49
|
+
room.add_client(client)
|
50
|
+
|
51
|
+
if client not in self.client_rooms:
|
52
|
+
self.client_rooms[client] = set()
|
53
|
+
self.client_rooms[client].add(room_name)
|
54
|
+
|
55
|
+
room.broadcast(f"Client joined room: {room_name}", exclude=client)
|
56
|
+
|
57
|
+
def leave_room(self, client: WebSocketSession, room_name: str):
|
58
|
+
"""Remove a client from a room"""
|
59
|
+
room = self.get_room(room_name)
|
60
|
+
if room:
|
61
|
+
room.remove_client(client)
|
62
|
+
if client in self.client_rooms:
|
63
|
+
self.client_rooms[client].discard(room_name)
|
64
|
+
|
65
|
+
room.broadcast(f"Client left room: {room_name}", exclude=client)
|
66
|
+
|
67
|
+
if room.client_count == 0:
|
68
|
+
del self.rooms[room_name]
|
69
|
+
|
70
|
+
def leave_all_rooms(self, client: WebSocketSession):
|
71
|
+
"""Remove a client from all rooms"""
|
72
|
+
if client in self.client_rooms:
|
73
|
+
rooms = self.client_rooms[client].copy()
|
74
|
+
for room_name in rooms:
|
75
|
+
self.leave_room(client, room_name)
|
76
|
+
del self.client_rooms[client]
|
hypern/ws/route.py
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
from typing import Callable, Optional
|
2
|
+
|
3
|
+
from hypern.hypern import WebsocketRoute as WebsocketRouteInternal, WebSocketSession
|
4
|
+
|
5
|
+
|
6
|
+
class WebsocketRoute:
|
7
|
+
def __init__(self) -> None:
|
8
|
+
self.routes = []
|
9
|
+
self._disconnect_handler: Optional[Callable] = None
|
10
|
+
|
11
|
+
def on(self, path):
|
12
|
+
def wrapper(func):
|
13
|
+
self.routes.append(WebsocketRouteInternal(path, func))
|
14
|
+
return func
|
15
|
+
|
16
|
+
return wrapper
|
17
|
+
|
18
|
+
def on_disconnect(self, func):
|
19
|
+
"""Register a disconnect handler"""
|
20
|
+
self._disconnect_handler = func
|
21
|
+
return func
|
22
|
+
|
23
|
+
def handle_disconnect(self, session: WebSocketSession):
|
24
|
+
"""Internal method to handle disconnection"""
|
25
|
+
if self._disconnect_handler:
|
26
|
+
return self._disconnect_handler(session)
|
@@ -1,6 +1,6 @@
|
|
1
|
-
hypern-0.3.
|
2
|
-
hypern-0.3.
|
3
|
-
hypern-0.3.
|
1
|
+
hypern-0.3.1.dist-info/METADATA,sha256=dAKnEF83pJsKjgE50V8YZOD-_dTDlcrM3yc4Gtn5_IE,3754
|
2
|
+
hypern-0.3.1.dist-info/WHEEL,sha256=SK_cql1gpDHx6aBV-LOSvGbTt4TUC8AJJOzjOP2tdpI,92
|
3
|
+
hypern-0.3.1.dist-info/licenses/LICENSE,sha256=qbYKAIJLS6jYg5hYncKE7OtWmqOtpVTvKNkwOa0Iwwg,1328
|
4
4
|
hypern/application.py,sha256=D8tye5rpOJzHGQHUelaDpjUL3B9xO40B7xAL9wC3Uno,14328
|
5
5
|
hypern/args_parser.py,sha256=Crxzr8_uhiIk_AWJvuwJTEfRqEBqU_GfTbg6chg_YiY,1790
|
6
6
|
hypern/auth/authorization.py,sha256=-NprZsI0np889ZN1fp-MiVFrPoMNzUtatBJaCMtkllM,32
|
@@ -13,6 +13,7 @@ hypern/caching/cache_manager.py,sha256=TF6UosJ54950JgsrPVZUS3MH2R8zafAu5PTryNJ0s
|
|
13
13
|
hypern/caching/cache_tag.py,sha256=bZcjivMNETAzAHAIobuLN0S2wHgPgLLL8Gg4uso_qbk,267
|
14
14
|
hypern/caching/custom_key_maker.py,sha256=88RIIJjpQYFnv857wOlCKgWWBbK_S23zNHsIrJz_4PY,394
|
15
15
|
hypern/caching/redis_backend.py,sha256=IgQToCnHYGpKEErq2CNZkR5woo01z456Ef3C-XRPRV8,70
|
16
|
+
hypern/caching/strategies.py,sha256=S7qJiJOTJrk6KUFDrmjGkHP3XV8mYTRM3Sd4pA4UQKA,4199
|
16
17
|
hypern/caching/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
17
18
|
hypern/cli/commands.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
18
19
|
hypern/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
@@ -38,7 +39,7 @@ hypern/db/sql/__init__.py,sha256=1UoWQi2CIcUAbQj3FadR-8V0o_b286nI2wYvOsvtbFc,647
|
|
38
39
|
hypern/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
39
40
|
hypern/enum.py,sha256=KcVziJj7vWvyie0r2rtxhrLzdtkZAsf0DY58oJ4tQl4,360
|
40
41
|
hypern/exceptions.py,sha256=nHTkF0YdNBMKfSiNtjRMHMNKoY3RMUm68YYluuW15us,2428
|
41
|
-
hypern/hypern.pyi,sha256=
|
42
|
+
hypern/hypern.pyi,sha256=DhTRcW9c0qjVS976fx__insErvhk4k7W0NKcV6iZGxw,8038
|
42
43
|
hypern/i18n/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
43
44
|
hypern/logging/logger.py,sha256=WACam_IJiCMXX0hGVKMGSxUQpY4DgAXy7M1dD3q-Z9s,3256
|
44
45
|
hypern/logging/__init__.py,sha256=6eVriyncsJ4J73fGYhoejv9MX7aGTkRezTpPxO4DX1I,52
|
@@ -46,13 +47,14 @@ hypern/middleware/base.py,sha256=mJoz-i-7lqw1eDxZ8Bb0t8sz60lx5TE-OjZT4UR75e4,541
|
|
46
47
|
hypern/middleware/cors.py,sha256=x90DnCOJSfp4ojm1krttn_EdtlqeDazyUzVg66NES4A,1681
|
47
48
|
hypern/middleware/i18n.py,sha256=jHzVzjTx1nnjbraZtIVOprrnSaeKMxZB8RuSqRp2I4s,16
|
48
49
|
hypern/middleware/limit.py,sha256=8hzUxu_mxra2QiDjAghgZtvwN6Dx07irPUiL12dbVhY,8152
|
50
|
+
hypern/middleware/security.py,sha256=hIbmP1fkZ_KgxK1Wx7s9xdNH7M174mo6ySASiuzMz_A,7273
|
49
51
|
hypern/middleware/__init__.py,sha256=lXwR3fdmpVK4Z7QWaLsgf3Sazy5NPPFXIOxIEv1xDC8,273
|
50
52
|
hypern/openapi/schemas.py,sha256=YHfMlPUeP5DzDX5ao3YH8p_25Vvyaf616dh6XDCUZRc,1677
|
51
53
|
hypern/openapi/swagger.py,sha256=naqUY3rFAEYA1ZLIlmDsMYaol0yIm6TVebdkFa5cMTc,64
|
52
54
|
hypern/openapi/__init__.py,sha256=4rEVD8pa0kdSpsy7ZkJ5JY0Z2XF0NGSKDMwYAd7YZpE,141
|
53
55
|
hypern/processpool.py,sha256=YW7Dg33Hla9D33x3Mf8xsjaTprHaovkLPK-4XnQKiGU,4045
|
54
56
|
hypern/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
55
|
-
hypern/reload.py,sha256=
|
57
|
+
hypern/reload.py,sha256=nfaZCoChrQetHNtIqN4Xzi-a0v-irxSCMhwCK3bCEq0,1569
|
56
58
|
hypern/response/response.py,sha256=-dnboAraPic8asf503PxwmDuxhNllUO5h97_DGmbER4,4582
|
57
59
|
hypern/response/__init__.py,sha256=_w3u3TDNuYx5ejnnN1unqnTY8NlBgUATQi6wepEB_FQ,226
|
58
60
|
hypern/routing/dispatcher.py,sha256=i2wLAAW1ZXgpi5K2heGXhTODnP1WdQzaR5WlUjs1o9c,2368
|
@@ -63,7 +65,12 @@ hypern/routing/__init__.py,sha256=7rw7EAxougCXtmkgJjrmLP3N5RXctIpI_3JmG9FcKVU,10
|
|
63
65
|
hypern/scheduler.py,sha256=-k3tW2AGCnHYSthKXk-FOs_SCtWp3yIxQzwzUJMJsbo,67
|
64
66
|
hypern/security.py,sha256=3E86Yp_eOSVa1emUvBrDgoF0Sn6eNX0CfLnt87w5CPI,1773
|
65
67
|
hypern/worker.py,sha256=WQrhY_awR6zjMwY4Q7izXi4E4fFrDqt7jIblUW8Bzcg,924
|
68
|
+
hypern/ws/channel.py,sha256=0ns2qmeoFJOpGLXS_hqldhywDQm_DxHwj6KloQx4Q3I,3183
|
69
|
+
hypern/ws/heartbeat.py,sha256=sWMXzQm6cbDHHA2NHc-gFjv7G_E56XtxswHQ93_BueM,2861
|
70
|
+
hypern/ws/room.py,sha256=0_L6Nun0n007F0rfNY8yX5x_A8EuXuI67JqpMkJ4RNI,2598
|
71
|
+
hypern/ws/route.py,sha256=fGQ2RC708MPOiiIHPUo8aZ-oK379TTAyQYm4htNA5jM,803
|
72
|
+
hypern/ws/__init__.py,sha256=dhRoRY683_rfPfSPM5qUczfTuyYDeuLOCFxY4hIdKt8,131
|
66
73
|
hypern/ws.py,sha256=F6SA2Z1KVnqTEX8ssvOXqCtudUS4eo30JsiIsvfbHnE,394
|
67
74
|
hypern/__init__.py,sha256=9Ww_aUQ0vJls0tOq7Yw1_TVOCRsa5bHJ-RtnSeComwk,119
|
68
|
-
hypern/hypern.cp312-win32.pyd,sha256=
|
69
|
-
hypern-0.3.
|
75
|
+
hypern/hypern.cp312-win32.pyd,sha256=GClLmHIIv0CKb5uHz_ochUZKY_1wketwqHGB9TUdRPg,5482496
|
76
|
+
hypern-0.3.1.dist-info/RECORD,,
|
File without changes
|