bingo-framework 0.2.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.
- bingo/__init__.py +44 -0
- bingo/application.py +136 -0
- bingo/channel_backends.py +163 -0
- bingo/channels.js +145 -0
- bingo/channels.py +566 -0
- bingo/cli/__init__.py +3 -0
- bingo/cli/app.py +32 -0
- bingo/cli/generate.py +64 -0
- bingo/cli/helpers.py +39 -0
- bingo/cli/inspect.py +17 -0
- bingo/cli/migrate.py +26 -0
- bingo/cli/new.py +20 -0
- bingo/cli/routes.py +12 -0
- bingo/cli/server.py +45 -0
- bingo/cli/worker.py +25 -0
- bingo/controller.py +47 -0
- bingo/conventions/__init__.py +4 -0
- bingo/conventions/errors.py +18 -0
- bingo/conventions/inspector.py +773 -0
- bingo/db/__init__.py +7 -0
- bingo/db/database.py +46 -0
- bingo/db/fields.py +67 -0
- bingo/db/migration.py +171 -0
- bingo/db/model.py +100 -0
- bingo/db/naming.py +24 -0
- bingo/db/query.py +81 -0
- bingo/exceptions.py +45 -0
- bingo/forms/__init__.py +3 -0
- bingo/forms/form.py +144 -0
- bingo/generators/__init__.py +11 -0
- bingo/generators/channel.py +75 -0
- bingo/generators/project.py +495 -0
- bingo/generators/resource.py +407 -0
- bingo/generators/task.py +46 -0
- bingo/management.py +147 -0
- bingo/request.py +71 -0
- bingo/response.py +3 -0
- bingo/routing.py +323 -0
- bingo/settings.py +105 -0
- bingo/tasks.py +345 -0
- bingo/templates/__init__.py +3 -0
- bingo/templates/engine.py +175 -0
- bingo/validation/__init__.py +4 -0
- bingo/validation/rules.py +131 -0
- bingo/validation/validator.py +52 -0
- bingo_framework-0.2.0.dist-info/METADATA +414 -0
- bingo_framework-0.2.0.dist-info/RECORD +50 -0
- bingo_framework-0.2.0.dist-info/WHEEL +4 -0
- bingo_framework-0.2.0.dist-info/entry_points.txt +2 -0
- bingo_framework-0.2.0.dist-info/licenses/LICENSE +21 -0
bingo/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from bingo.application import Application
|
|
2
|
+
from bingo.channels import Channel, Connection
|
|
3
|
+
from bingo.controller import Controller
|
|
4
|
+
from bingo.exceptions import (
|
|
5
|
+
BingoChannelError,
|
|
6
|
+
BingoChannelRejected,
|
|
7
|
+
BingoConventionError,
|
|
8
|
+
BingoDatabaseError,
|
|
9
|
+
BingoError,
|
|
10
|
+
BingoJSONViewError,
|
|
11
|
+
BingoNotFoundError,
|
|
12
|
+
BingoRouteError,
|
|
13
|
+
BingoTaskError,
|
|
14
|
+
BingoValidationError,
|
|
15
|
+
BingoViewError,
|
|
16
|
+
)
|
|
17
|
+
from bingo.management import BaseCommand, manage
|
|
18
|
+
from bingo.routing import Router
|
|
19
|
+
from bingo.settings import settings
|
|
20
|
+
from bingo.tasks import QueuedTask, Task
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Application",
|
|
24
|
+
"BaseCommand",
|
|
25
|
+
"BingoChannelError",
|
|
26
|
+
"BingoChannelRejected",
|
|
27
|
+
"BingoConventionError",
|
|
28
|
+
"BingoDatabaseError",
|
|
29
|
+
"BingoError",
|
|
30
|
+
"BingoJSONViewError",
|
|
31
|
+
"BingoNotFoundError",
|
|
32
|
+
"BingoRouteError",
|
|
33
|
+
"BingoTaskError",
|
|
34
|
+
"BingoValidationError",
|
|
35
|
+
"BingoViewError",
|
|
36
|
+
"Channel",
|
|
37
|
+
"Connection",
|
|
38
|
+
"Controller",
|
|
39
|
+
"QueuedTask",
|
|
40
|
+
"Router",
|
|
41
|
+
"Task",
|
|
42
|
+
"manage",
|
|
43
|
+
"settings",
|
|
44
|
+
]
|
bingo/application.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import parse_qs
|
|
6
|
+
|
|
7
|
+
from starlette.applications import Starlette
|
|
8
|
+
from starlette.middleware import Middleware
|
|
9
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
10
|
+
from starlette.responses import PlainTextResponse, Response
|
|
11
|
+
from starlette.routing import Route, WebSocketRoute
|
|
12
|
+
|
|
13
|
+
from bingo.channels import ChannelServer, close_channel_brokers
|
|
14
|
+
from bingo.db.database import database
|
|
15
|
+
from bingo.exceptions import BingoError, BingoNotFoundError, BingoValidationError
|
|
16
|
+
from bingo.routing import Router
|
|
17
|
+
from bingo.settings import settings
|
|
18
|
+
from bingo.templates import TemplateEngine
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@asynccontextmanager
|
|
22
|
+
async def application_lifespan(_application):
|
|
23
|
+
yield
|
|
24
|
+
|
|
25
|
+
from bingo.tasks import close_task_queues
|
|
26
|
+
|
|
27
|
+
await close_task_queues()
|
|
28
|
+
await close_channel_brokers()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MethodOverrideMiddleware:
|
|
32
|
+
"""Let ordinary HTML forms reach canonical PATCH and DELETE routes."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, app) -> None:
|
|
35
|
+
self.app = app
|
|
36
|
+
|
|
37
|
+
async def __call__(self, scope, receive, send) -> None:
|
|
38
|
+
headers = dict(scope.get("headers", []))
|
|
39
|
+
content_type = headers.get(b"content-type", b"").decode("latin-1")
|
|
40
|
+
is_form_post = (
|
|
41
|
+
scope["type"] == "http"
|
|
42
|
+
and scope["method"] == "POST"
|
|
43
|
+
and content_type.startswith("application/x-www-form-urlencoded")
|
|
44
|
+
)
|
|
45
|
+
if not is_form_post:
|
|
46
|
+
await self.app(scope, receive, send)
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
body = b""
|
|
50
|
+
more_body = True
|
|
51
|
+
while more_body:
|
|
52
|
+
message = await receive()
|
|
53
|
+
body += message.get("body", b"")
|
|
54
|
+
more_body = message.get("more_body", False)
|
|
55
|
+
|
|
56
|
+
form = parse_qs(body.decode("utf-8"), keep_blank_values=True)
|
|
57
|
+
override = form.get("_method", [""])[-1].upper()
|
|
58
|
+
if override in {"PATCH", "PUT", "DELETE"}:
|
|
59
|
+
scope = {**scope, "method": override}
|
|
60
|
+
|
|
61
|
+
delivered = False
|
|
62
|
+
|
|
63
|
+
async def replay_body():
|
|
64
|
+
nonlocal delivered
|
|
65
|
+
if delivered:
|
|
66
|
+
return {"type": "http.request", "body": b"", "more_body": False}
|
|
67
|
+
delivered = True
|
|
68
|
+
return {"type": "http.request", "body": body, "more_body": False}
|
|
69
|
+
|
|
70
|
+
await self.app(scope, replay_body, send)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Application:
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
routes: Router | None = None,
|
|
77
|
+
*,
|
|
78
|
+
root_path: str | Path | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
self.root_path = Path(root_path or Path.cwd()).resolve()
|
|
81
|
+
self.settings = settings.load(self.root_path)
|
|
82
|
+
self.router = routes or Router()
|
|
83
|
+
self.templates = TemplateEngine(self.root_path / "app" / "views")
|
|
84
|
+
|
|
85
|
+
if self.settings.DATABASE_URL:
|
|
86
|
+
database.configure(self.settings.DATABASE_URL)
|
|
87
|
+
|
|
88
|
+
middleware = [
|
|
89
|
+
Middleware(SessionMiddleware, secret_key=self.settings.SECRET_KEY)
|
|
90
|
+
]
|
|
91
|
+
channel_routes = self._channel_routes()
|
|
92
|
+
starlette = Starlette(
|
|
93
|
+
debug=self.settings.DEBUG,
|
|
94
|
+
routes=[*channel_routes, *self.router.starlette_routes(self)],
|
|
95
|
+
middleware=middleware,
|
|
96
|
+
lifespan=application_lifespan,
|
|
97
|
+
exception_handlers={
|
|
98
|
+
BingoNotFoundError: self._not_found,
|
|
99
|
+
BingoValidationError: self._validation_error,
|
|
100
|
+
BingoError: self._bingo_error,
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
self.asgi = MethodOverrideMiddleware(starlette)
|
|
104
|
+
|
|
105
|
+
def _channel_routes(self):
|
|
106
|
+
path = self.settings.CHANNEL_PATH
|
|
107
|
+
is_valid_path = isinstance(path, str) and path.startswith("/")
|
|
108
|
+
is_valid_path = is_valid_path and not path.startswith("//")
|
|
109
|
+
is_valid_path = is_valid_path and not path.endswith("/") and path != "/"
|
|
110
|
+
if not is_valid_path:
|
|
111
|
+
raise BingoError(
|
|
112
|
+
"CHANNEL_PATH must start with '/' and must not end with '/'."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
javascript = Path(__file__).with_name("channels.js").read_text(encoding="utf-8")
|
|
116
|
+
|
|
117
|
+
async def client(_request):
|
|
118
|
+
return Response(javascript, media_type="application/javascript")
|
|
119
|
+
|
|
120
|
+
channel_server = ChannelServer(self.root_path)
|
|
121
|
+
return [
|
|
122
|
+
Route(f"{path}.js", client, methods=["GET"]),
|
|
123
|
+
WebSocketRoute(path, channel_server.handle),
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
async def __call__(self, scope, receive, send) -> None:
|
|
127
|
+
await self.asgi(scope, receive, send)
|
|
128
|
+
|
|
129
|
+
async def _not_found(self, request, error: BingoNotFoundError):
|
|
130
|
+
return PlainTextResponse(str(error), status_code=404)
|
|
131
|
+
|
|
132
|
+
async def _validation_error(self, request, error: BingoValidationError):
|
|
133
|
+
return PlainTextResponse(str(error), status_code=422)
|
|
134
|
+
|
|
135
|
+
async def _bingo_error(self, request, error: BingoError):
|
|
136
|
+
return PlainTextResponse(str(error), status_code=500)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from bingo.exceptions import BingoChannelError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MemorySubscription:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
backend: MemoryChannelBackend,
|
|
16
|
+
stream: str,
|
|
17
|
+
queue: asyncio.Queue,
|
|
18
|
+
) -> None:
|
|
19
|
+
self.backend = backend
|
|
20
|
+
self.stream = stream
|
|
21
|
+
self.queue = queue
|
|
22
|
+
|
|
23
|
+
async def messages(self) -> AsyncIterator[dict[str, Any]]:
|
|
24
|
+
while True:
|
|
25
|
+
yield await self.queue.get()
|
|
26
|
+
|
|
27
|
+
async def close(self) -> None:
|
|
28
|
+
subscribers = self.backend.subscribers.get(self.stream, set())
|
|
29
|
+
subscribers.discard(self.queue)
|
|
30
|
+
if not subscribers:
|
|
31
|
+
self.backend.subscribers.pop(self.stream, None)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MemoryChannelBackend:
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self.subscribers: dict[str, set[asyncio.Queue]] = {}
|
|
37
|
+
|
|
38
|
+
async def subscribe(self, stream: str) -> MemorySubscription:
|
|
39
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
40
|
+
self.subscribers.setdefault(stream, set()).add(queue)
|
|
41
|
+
return MemorySubscription(self, stream, queue)
|
|
42
|
+
|
|
43
|
+
async def publish(self, stream: str, message: dict[str, Any]) -> None:
|
|
44
|
+
for queue in self.subscribers.get(stream, set()):
|
|
45
|
+
queue.put_nowait(message)
|
|
46
|
+
|
|
47
|
+
async def close(self) -> None:
|
|
48
|
+
self.subscribers.clear()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RedisSubscription:
|
|
52
|
+
def __init__(self, pubsub, stream: str) -> None:
|
|
53
|
+
self.pubsub = pubsub
|
|
54
|
+
self.stream = stream
|
|
55
|
+
|
|
56
|
+
async def messages(self) -> AsyncIterator[dict[str, Any]]:
|
|
57
|
+
async for message in self.pubsub.listen():
|
|
58
|
+
if message["type"] != "message":
|
|
59
|
+
continue
|
|
60
|
+
try:
|
|
61
|
+
yield json.loads(message["data"])
|
|
62
|
+
except (TypeError, ValueError) as error:
|
|
63
|
+
raise BingoChannelError(
|
|
64
|
+
f"Channel stream {self.stream!r} received invalid JSON."
|
|
65
|
+
) from error
|
|
66
|
+
|
|
67
|
+
async def close(self) -> None:
|
|
68
|
+
await self.pubsub.unsubscribe(self.stream)
|
|
69
|
+
await self.pubsub.aclose()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RedisChannelBackend:
|
|
73
|
+
def __init__(self, url: str) -> None:
|
|
74
|
+
from redis.asyncio import Redis
|
|
75
|
+
|
|
76
|
+
self.client = Redis.from_url(url, decode_responses=True)
|
|
77
|
+
|
|
78
|
+
async def subscribe(self, stream: str) -> RedisSubscription:
|
|
79
|
+
pubsub = self.client.pubsub()
|
|
80
|
+
await pubsub.subscribe(stream)
|
|
81
|
+
return RedisSubscription(pubsub, stream)
|
|
82
|
+
|
|
83
|
+
async def publish(self, stream: str, message: dict[str, Any]) -> None:
|
|
84
|
+
await self.client.publish(stream, json.dumps(message, allow_nan=False))
|
|
85
|
+
|
|
86
|
+
async def close(self) -> None:
|
|
87
|
+
await self.client.aclose()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class PostgresSubscription:
|
|
91
|
+
def __init__(self, connection, channel: str, stream: str) -> None:
|
|
92
|
+
self.connection = connection
|
|
93
|
+
self.channel = channel
|
|
94
|
+
self.stream = stream
|
|
95
|
+
|
|
96
|
+
async def messages(self) -> AsyncIterator[dict[str, Any]]:
|
|
97
|
+
async for notification in self.connection.notifies():
|
|
98
|
+
try:
|
|
99
|
+
yield json.loads(notification.payload)
|
|
100
|
+
except (TypeError, ValueError) as error:
|
|
101
|
+
raise BingoChannelError(
|
|
102
|
+
f"Channel stream {self.stream!r} received invalid JSON."
|
|
103
|
+
) from error
|
|
104
|
+
|
|
105
|
+
async def close(self) -> None:
|
|
106
|
+
await self.connection.close()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class PostgresChannelBackend:
|
|
110
|
+
def __init__(self, url: str) -> None:
|
|
111
|
+
self.url = url
|
|
112
|
+
self.publisher = None
|
|
113
|
+
self.publisher_lock = asyncio.Lock()
|
|
114
|
+
|
|
115
|
+
async def subscribe(self, stream: str) -> PostgresSubscription:
|
|
116
|
+
from psycopg import AsyncConnection, sql
|
|
117
|
+
|
|
118
|
+
connection = await AsyncConnection.connect(self.url, autocommit=True)
|
|
119
|
+
channel = _postgres_channel(stream)
|
|
120
|
+
statement = sql.SQL("LISTEN {}").format(sql.Identifier(channel))
|
|
121
|
+
await connection.execute(statement)
|
|
122
|
+
return PostgresSubscription(connection, channel, stream)
|
|
123
|
+
|
|
124
|
+
async def publish(self, stream: str, message: dict[str, Any]) -> None:
|
|
125
|
+
from psycopg import AsyncConnection
|
|
126
|
+
|
|
127
|
+
payload = json.dumps(message, allow_nan=False)
|
|
128
|
+
if len(payload.encode("utf-8")) >= 8_000:
|
|
129
|
+
raise BingoChannelError(
|
|
130
|
+
"PostgreSQL channel broadcasts must be smaller than 8,000 bytes."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
async with self.publisher_lock:
|
|
134
|
+
if self.publisher is None or self.publisher.closed:
|
|
135
|
+
self.publisher = await AsyncConnection.connect(
|
|
136
|
+
self.url,
|
|
137
|
+
autocommit=True,
|
|
138
|
+
)
|
|
139
|
+
channel = _postgres_channel(stream)
|
|
140
|
+
await self.publisher.execute(
|
|
141
|
+
"SELECT pg_notify(%s, %s)",
|
|
142
|
+
(channel, payload),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
async def close(self) -> None:
|
|
146
|
+
if self.publisher is not None and not self.publisher.closed:
|
|
147
|
+
await self.publisher.close()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def channel_backend(url: str):
|
|
151
|
+
scheme = url.split(":", 1)[0].lower()
|
|
152
|
+
if scheme == "memory":
|
|
153
|
+
return MemoryChannelBackend()
|
|
154
|
+
if scheme in {"redis", "rediss"}:
|
|
155
|
+
return RedisChannelBackend(url)
|
|
156
|
+
if scheme in {"postgres", "postgresql"}:
|
|
157
|
+
return PostgresChannelBackend(url)
|
|
158
|
+
raise BingoChannelError("CHANNEL_URL supports memory, Redis, and PostgreSQL URLs.")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _postgres_channel(stream: str) -> str:
|
|
162
|
+
digest = hashlib.sha256(stream.encode("utf-8")).hexdigest()[:48]
|
|
163
|
+
return f"bingo_{digest}"
|
bingo/channels.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
(function (global) {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
class Subscription {
|
|
5
|
+
constructor(consumer, identifier, channel, params, callbacks) {
|
|
6
|
+
this.consumer = consumer;
|
|
7
|
+
this.identifier = identifier;
|
|
8
|
+
this.channel = channel;
|
|
9
|
+
this.params = params;
|
|
10
|
+
Object.assign(this, callbacks || {});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
send(data) {
|
|
14
|
+
this.consumer.send({
|
|
15
|
+
command: "message",
|
|
16
|
+
identifier: this.identifier,
|
|
17
|
+
data: data,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
unsubscribe() {
|
|
22
|
+
this.consumer.unsubscribe(this.identifier);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class ChannelConsumer {
|
|
27
|
+
constructor(path) {
|
|
28
|
+
this.path = path || "/channels";
|
|
29
|
+
this.socket = null;
|
|
30
|
+
this.subscriptions = new Map();
|
|
31
|
+
this.nextIdentifier = 1;
|
|
32
|
+
this.reconnectDelay = 500;
|
|
33
|
+
this.reconnectTimer = null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
subscribe(channel, params, callbacks) {
|
|
37
|
+
const identifier = String(this.nextIdentifier++);
|
|
38
|
+
const subscription = new Subscription(
|
|
39
|
+
this,
|
|
40
|
+
identifier,
|
|
41
|
+
channel,
|
|
42
|
+
params || {},
|
|
43
|
+
callbacks,
|
|
44
|
+
);
|
|
45
|
+
this.subscriptions.set(identifier, subscription);
|
|
46
|
+
this.connect();
|
|
47
|
+
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
|
48
|
+
this.sendSubscription(subscription);
|
|
49
|
+
}
|
|
50
|
+
return subscription;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
connect() {
|
|
54
|
+
const isConnecting = this.socket && (
|
|
55
|
+
this.socket.readyState === WebSocket.OPEN ||
|
|
56
|
+
this.socket.readyState === WebSocket.CONNECTING
|
|
57
|
+
);
|
|
58
|
+
if (isConnecting) return;
|
|
59
|
+
|
|
60
|
+
const protocol = global.location.protocol === "https:" ? "wss:" : "ws:";
|
|
61
|
+
const url = `${protocol}//${global.location.host}${this.path}`;
|
|
62
|
+
this.socket = new WebSocket(url);
|
|
63
|
+
this.socket.addEventListener("open", () => this.opened());
|
|
64
|
+
this.socket.addEventListener("message", (event) => this.received(event));
|
|
65
|
+
this.socket.addEventListener("close", () => this.closed());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
opened() {
|
|
69
|
+
this.reconnectDelay = 500;
|
|
70
|
+
for (const subscription of this.subscriptions.values()) {
|
|
71
|
+
this.sendSubscription(subscription);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
received(event) {
|
|
76
|
+
let message;
|
|
77
|
+
try {
|
|
78
|
+
message = JSON.parse(event.data);
|
|
79
|
+
} catch (_error) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const subscription = this.subscriptions.get(message.identifier);
|
|
84
|
+
if (!subscription) return;
|
|
85
|
+
|
|
86
|
+
if (message.type === "subscribed" && subscription.connected) {
|
|
87
|
+
subscription.connected();
|
|
88
|
+
} else if (message.type === "message" && subscription.received) {
|
|
89
|
+
subscription.received(message.event, message.data);
|
|
90
|
+
} else if (message.type === "rejected") {
|
|
91
|
+
this.subscriptions.delete(message.identifier);
|
|
92
|
+
if (subscription.rejected) subscription.rejected(message.error);
|
|
93
|
+
if (this.subscriptions.size === 0 && this.socket) this.socket.close();
|
|
94
|
+
} else if (message.type === "error" && subscription.errored) {
|
|
95
|
+
subscription.errored(message.error);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
closed() {
|
|
100
|
+
this.socket = null;
|
|
101
|
+
for (const subscription of this.subscriptions.values()) {
|
|
102
|
+
if (subscription.disconnected) subscription.disconnected();
|
|
103
|
+
}
|
|
104
|
+
if (this.subscriptions.size === 0) return;
|
|
105
|
+
|
|
106
|
+
clearTimeout(this.reconnectTimer);
|
|
107
|
+
this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectDelay);
|
|
108
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 10000);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
unsubscribe(identifier) {
|
|
112
|
+
if (!this.subscriptions.has(identifier)) return;
|
|
113
|
+
this.send({ command: "unsubscribe", identifier: identifier });
|
|
114
|
+
this.subscriptions.delete(identifier);
|
|
115
|
+
if (this.subscriptions.size === 0 && this.socket) {
|
|
116
|
+
this.socket.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
sendSubscription(subscription) {
|
|
121
|
+
this.send({
|
|
122
|
+
command: "subscribe",
|
|
123
|
+
identifier: subscription.identifier,
|
|
124
|
+
channel: subscription.channel,
|
|
125
|
+
params: subscription.params,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
send(message) {
|
|
130
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
131
|
+
throw new Error("The Bingo channel connection is not open.");
|
|
132
|
+
}
|
|
133
|
+
this.socket.send(JSON.stringify(message));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const Bingo = global.Bingo || {};
|
|
138
|
+
const script = global.document.currentScript;
|
|
139
|
+
const defaultPath = script
|
|
140
|
+
? new URL(script.src, global.location.href).pathname.replace(/\.js$/, "")
|
|
141
|
+
: "/channels";
|
|
142
|
+
Bingo.channels = new ChannelConsumer(defaultPath);
|
|
143
|
+
Bingo.ChannelConsumer = ChannelConsumer;
|
|
144
|
+
global.Bingo = Bingo;
|
|
145
|
+
})(window);
|