Compare commits
12
Commits
c2e834a67a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
772f7d5a9c | ||
|
|
2761172a7c | ||
|
|
1c1ca14fe7 | ||
|
|
9986fb5faa | ||
|
|
a2a17e5ff9 | ||
|
|
9cd3803b61 | ||
|
|
09d3d8684c | ||
|
|
d9eb2d7b05 | ||
|
|
49cebb9f0a | ||
|
|
c10eca266a | ||
|
|
3fcb1fb8f5 | ||
|
|
fce7419c55 |
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env.development
|
||||||
+14
-2
@@ -1,11 +1,23 @@
|
|||||||
---
|
---
|
||||||
kind: pipeline
|
kind: pipeline
|
||||||
type: docker
|
type: docker
|
||||||
name: client
|
name: frontend
|
||||||
steps:
|
steps:
|
||||||
- name: build
|
- name: build
|
||||||
image: node:19.4-alpine
|
image: node:19.4-alpine
|
||||||
commands:
|
commands:
|
||||||
- cd glebby-client
|
- cd glebby-client
|
||||||
- npm install
|
- npm install
|
||||||
- npm run build
|
- VITE_GLEBBY_SERVER_URL=ws://localhost:5000/glebby npx vite build
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: backend
|
||||||
|
steps:
|
||||||
|
- name: typecheck
|
||||||
|
image: python:3.11-alpine
|
||||||
|
commands:
|
||||||
|
- cd glebby-server
|
||||||
|
- pip install -r requirements-dev.txt
|
||||||
|
- mypy --strict glebby
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
FROM node:19.4-alpine AS frontend
|
||||||
|
|
||||||
|
ARG server_url
|
||||||
|
RUN test -n "${server_url}"
|
||||||
|
|
||||||
|
COPY glebby-client /glebby-client
|
||||||
|
WORKDIR /glebby-client
|
||||||
|
RUN npm install
|
||||||
|
ENV VITE_GLEBBY_SERVER_URL=${server_url}
|
||||||
|
RUN npx vite build --outDir static
|
||||||
|
|
||||||
|
FROM python:3.11-alpine
|
||||||
|
|
||||||
|
COPY glebby-server /glebby-server
|
||||||
|
WORKDIR /glebby-server
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
RUN pip install hypercorn
|
||||||
|
COPY --from=frontend /glebby-client/static /glebby-server/glebby/static
|
||||||
|
|
||||||
|
CMD ["hypercorn", "--bind", "0.0.0.0:5000", "glebby:app"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
- [ ] composition api
|
||||||
|
- [x] quart
|
||||||
|
- [x] fix dockerfile
|
||||||
|
- [ ] lobby
|
||||||
|
- [ ] actual game logic
|
||||||
|
- [ ] websocket url config
|
||||||
|
- [ ] use poetry?
|
||||||
@@ -11,6 +11,7 @@ export default {
|
|||||||
Game
|
Game
|
||||||
},
|
},
|
||||||
data(): { ws: WebSocket, model: any } {
|
data(): { ws: WebSocket, model: any } {
|
||||||
|
console.log(import.meta.env.VITE_GLEBBY_SERVER_URL)
|
||||||
return {
|
return {
|
||||||
ws: new WebSocket(import.meta.env.VITE_GLEBBY_SERVER_URL),
|
ws: new WebSocket(import.meta.env.VITE_GLEBBY_SERVER_URL),
|
||||||
model: null
|
model: null
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
chatMessages: [] as { from: number, message: string }[],
|
chatMessages: [] as { from: Player, message: string }[],
|
||||||
|
|
||||||
playerName: '',
|
playerName: '',
|
||||||
chatMessage: ''
|
chatMessage: ''
|
||||||
@@ -55,7 +55,7 @@ export default defineComponent({
|
|||||||
break
|
break
|
||||||
case 'chat':
|
case 'chat':
|
||||||
this.chatMessages.push({
|
this.chatMessages.push({
|
||||||
from: message.from,
|
from: getPlayer(this.model, message.from)!,
|
||||||
message: payload.message
|
message: payload.message
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
@@ -127,8 +127,8 @@ export default defineComponent({
|
|||||||
<h2>chat</h2>
|
<h2>chat</h2>
|
||||||
<div class="chatbox-scroller">
|
<div class="chatbox-scroller">
|
||||||
<div class="chatbox-scroller-content">
|
<div class="chatbox-scroller-content">
|
||||||
<div v-for="message in chatMessages" class="chatbox-message">
|
<div v-for="chatMessage in chatMessages" class="chatbox-message">
|
||||||
<code>{{ model.players.get(message.from)!.name }}#{{ message.from }}:</code> {{ message.message }}
|
<code>{{ chatMessage.from.name }}#{{ chatMessage.from.id }}:</code> {{ chatMessage.message }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
venv
|
venv
|
||||||
__pycache__
|
__pycache__
|
||||||
|
static
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
import json
|
|
||||||
import simple_websocket
|
|
||||||
|
|
||||||
from collections import OrderedDict
|
|
||||||
from flask import Flask
|
|
||||||
from flask_sock import Sock
|
|
||||||
from queue import Queue
|
|
||||||
from random import Random
|
|
||||||
from threading import Thread, Lock
|
|
||||||
|
|
||||||
class BoardGenerator:
|
|
||||||
def __init__(self, seed):
|
|
||||||
self.dice = [
|
|
||||||
'aeaneg',
|
|
||||||
'wngeeh',
|
|
||||||
'ahspco',
|
|
||||||
'lnhnrz',
|
|
||||||
'aspffk',
|
|
||||||
'tstiyd',
|
|
||||||
'objoab',
|
|
||||||
'owtoat',
|
|
||||||
'iotmuc',
|
|
||||||
'erttyl',
|
|
||||||
'ryvdel',
|
|
||||||
'toessi',
|
|
||||||
'lreixd',
|
|
||||||
'terwhv',
|
|
||||||
'eiunes',
|
|
||||||
'nuihmq'
|
|
||||||
]
|
|
||||||
self.rng = Random(seed)
|
|
||||||
|
|
||||||
def generate_board(self):
|
|
||||||
shuffled_dice = self.rng.sample(self.dice, k=len(self.dice))
|
|
||||||
roll_results = [self.rng.choice(die) for die in shuffled_dice]
|
|
||||||
return [roll_results[4 * i : 4 * (i+1)] for i in range(0, 4)]
|
|
||||||
|
|
||||||
class Client:
|
|
||||||
def __init__(self, sock, client_id):
|
|
||||||
self.sock = sock
|
|
||||||
self.data = { 'id': client_id, 'name': '' }
|
|
||||||
|
|
||||||
class GlebbyState:
|
|
||||||
def __init__(self):
|
|
||||||
self.clients_lock = Lock()
|
|
||||||
# We want to preserve the order that clients arrived in,
|
|
||||||
# e.g. for whose turn it is
|
|
||||||
self.clients = OrderedDict()
|
|
||||||
|
|
||||||
self.next_client_id_lock = Lock()
|
|
||||||
self.next_client_id = 0
|
|
||||||
|
|
||||||
self.incoming_messages = Queue()
|
|
||||||
|
|
||||||
self.board_generator = BoardGenerator(42)
|
|
||||||
self.board = None
|
|
||||||
|
|
||||||
# domain stuff
|
|
||||||
|
|
||||||
def handle_message_from(self, client_id, payload):
|
|
||||||
print(f"{client_id}: {payload}")
|
|
||||||
|
|
||||||
if payload['type'] == 'set-name':
|
|
||||||
self.clients[client_id].data['name'] = payload['name']
|
|
||||||
self.broadcast(client_id, {
|
|
||||||
'type': 'set-name',
|
|
||||||
'name': payload['name']
|
|
||||||
})
|
|
||||||
elif payload['type'] == 'chat':
|
|
||||||
self.broadcast(client_id, {
|
|
||||||
'type': 'chat',
|
|
||||||
'message': payload['message']
|
|
||||||
})
|
|
||||||
elif payload['type'] == 'roll':
|
|
||||||
self.board = self.board_generator.generate_board()
|
|
||||||
self.broadcast(client_id, {
|
|
||||||
'type': 'roll',
|
|
||||||
'board': self.board
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
print("Unhandled!")
|
|
||||||
|
|
||||||
def get_state_dto(self, client_id):
|
|
||||||
return {
|
|
||||||
'yourId': client_id,
|
|
||||||
'players': [
|
|
||||||
self.clients[other_client_id].data
|
|
||||||
for other_client_id in self.clients
|
|
||||||
],
|
|
||||||
'board': self.board
|
|
||||||
}
|
|
||||||
|
|
||||||
# message receiving and sending
|
|
||||||
|
|
||||||
def put_incoming_message(self, client_id, payload):
|
|
||||||
self.incoming_messages.put({
|
|
||||||
'from': client_id,
|
|
||||||
'payload': json.loads(payload)
|
|
||||||
})
|
|
||||||
|
|
||||||
def send_to(self, client_id_from, client_id_to, payload):
|
|
||||||
self.clients[client_id_to].sock.send(json.dumps({
|
|
||||||
'from': client_id_from,
|
|
||||||
'payload': payload
|
|
||||||
}))
|
|
||||||
|
|
||||||
def broadcast(self, client_id_from, payload):
|
|
||||||
with self.clients_lock:
|
|
||||||
for client_id_to in self.clients:
|
|
||||||
self.send_to(client_id_from, client_id_to, payload)
|
|
||||||
|
|
||||||
# client management
|
|
||||||
|
|
||||||
def _get_next_client_id(self):
|
|
||||||
with self.next_client_id_lock:
|
|
||||||
client_id = self.next_client_id
|
|
||||||
self.next_client_id += 1
|
|
||||||
return client_id
|
|
||||||
|
|
||||||
def add_client(self, sock):
|
|
||||||
client_id = self._get_next_client_id()
|
|
||||||
self.broadcast(client_id, {'type': 'join'})
|
|
||||||
with self.clients_lock:
|
|
||||||
self.clients[client_id] = Client(sock, client_id)
|
|
||||||
self.send_to(None, client_id, {
|
|
||||||
'type': 'init',
|
|
||||||
'state': self.get_state_dto(client_id)
|
|
||||||
})
|
|
||||||
return client_id
|
|
||||||
|
|
||||||
def remove_client(self, client_id):
|
|
||||||
with self.clients_lock:
|
|
||||||
del self.clients[client_id]
|
|
||||||
self.broadcast(client_id, {'type': 'leave'})
|
|
||||||
|
|
||||||
# event thread stuff
|
|
||||||
|
|
||||||
def start_event_thread(self):
|
|
||||||
event_thread = Thread(target=lambda: self._event_thread())
|
|
||||||
event_thread.daemon = True
|
|
||||||
event_thread.start()
|
|
||||||
|
|
||||||
def _event_thread(self):
|
|
||||||
while True:
|
|
||||||
msg = self.incoming_messages.get()
|
|
||||||
self.handle_message_from(msg['from'], msg['payload'])
|
|
||||||
|
|
||||||
# Initialization
|
|
||||||
|
|
||||||
state = GlebbyState()
|
|
||||||
state.start_event_thread()
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
sock = Sock(app)
|
|
||||||
|
|
||||||
@sock.route('/glebby')
|
|
||||||
def echo(sock):
|
|
||||||
client_id = state.add_client(sock)
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
data = sock.receive()
|
|
||||||
state.put_incoming_message(client_id, data)
|
|
||||||
except simple_websocket.ConnectionClosed:
|
|
||||||
state.remove_client(client_id)
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from quart import Quart, Response, websocket
|
||||||
|
from typing import NoReturn
|
||||||
|
|
||||||
|
from glebby.connection_manager import ConnectionManager
|
||||||
|
from glebby.model import Model
|
||||||
|
|
||||||
|
connection_manager = ConnectionManager()
|
||||||
|
model = Model(connection_manager)
|
||||||
|
|
||||||
|
# Files in ./static are served directly under / instead of under /static
|
||||||
|
app = Quart(__name__, static_url_path='')
|
||||||
|
|
||||||
|
# Root route should point to ./static/index.html
|
||||||
|
@app.route('/')
|
||||||
|
async def root() -> Response:
|
||||||
|
return await app.send_static_file('index.html')
|
||||||
|
|
||||||
|
# Every time a websocket connection to /glebby is opened, a new connection
|
||||||
|
# is set up in the connection manager. It will handle sending and receiving
|
||||||
|
# and keeping the model up to date.
|
||||||
|
@app.websocket('/glebby')
|
||||||
|
async def ws() -> NoReturn:
|
||||||
|
await connection_manager.setup_connection()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from random import Random
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
class BoardGenerator:
|
||||||
|
dice: list[str]
|
||||||
|
rng: Random
|
||||||
|
|
||||||
|
def __init__(self, seed: Optional[int]):
|
||||||
|
self.dice = [
|
||||||
|
'aeaneg',
|
||||||
|
'wngeeh',
|
||||||
|
'ahspco',
|
||||||
|
'lnhnrz',
|
||||||
|
'aspffk',
|
||||||
|
'tstiyd',
|
||||||
|
'objoab',
|
||||||
|
'owtoat',
|
||||||
|
'iotmuc',
|
||||||
|
'erttyl',
|
||||||
|
'ryvdel',
|
||||||
|
'toessi',
|
||||||
|
'lreixd',
|
||||||
|
'terwhv',
|
||||||
|
'eiunes',
|
||||||
|
'nuihmq'
|
||||||
|
]
|
||||||
|
self.rng = Random(seed)
|
||||||
|
|
||||||
|
def generate_board(self) -> list[list[str]]:
|
||||||
|
shuffled_dice = self.rng.sample(self.dice, k=len(self.dice))
|
||||||
|
roll_results = [self.rng.choice(die) for die in shuffled_dice]
|
||||||
|
return [roll_results[4 * i : 4 * (i+1)] for i in range(0, 4)]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import asyncio
|
||||||
|
import itertools
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
from quart import websocket
|
||||||
|
from typing import Any, AsyncGenerator, Awaitable, Callable, Iterator, NoReturn, Optional
|
||||||
|
|
||||||
|
# Represents a single websocket connection
|
||||||
|
class Connection:
|
||||||
|
outgoing_queue: asyncio.Queue[str]
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.outgoing_queue = asyncio.Queue()
|
||||||
|
|
||||||
|
# Sending a message simply puts it in the outgoing queue...
|
||||||
|
async def send(self, data: str) -> None:
|
||||||
|
await self.outgoing_queue.put(data)
|
||||||
|
|
||||||
|
# ... and this infinite loop takes care of actually sending the outgoing
|
||||||
|
# messages through the websocket connection.
|
||||||
|
async def _send_loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
data = await self.outgoing_queue.get()
|
||||||
|
await websocket.send(data)
|
||||||
|
|
||||||
|
class ConnectionManager:
|
||||||
|
# Provides a *unique* identifier for each connection
|
||||||
|
id_generator: Iterator[int]
|
||||||
|
connections: OrderedDict[int, Connection]
|
||||||
|
|
||||||
|
on_open: Optional[Callable[[int], Awaitable[Any]]]
|
||||||
|
on_message: Optional[Callable[[int, str], Awaitable[Any]]]
|
||||||
|
on_close: Optional[Callable[[int], Awaitable[Any]]]
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.id_generator = itertools.count(start=0, step=1)
|
||||||
|
self.connections = OrderedDict()
|
||||||
|
|
||||||
|
self.on_open = None
|
||||||
|
self.on_message = None
|
||||||
|
self.on_close = None
|
||||||
|
|
||||||
|
# Wires up asyncio tasks etc. such that on_open, on_message and on_close
|
||||||
|
# are called at the right time with the right arguments.
|
||||||
|
async def setup_connection(self) -> NoReturn:
|
||||||
|
connection_id = next(self.id_generator)
|
||||||
|
connection = Connection()
|
||||||
|
self.connections[connection_id] = connection
|
||||||
|
|
||||||
|
if self.on_open:
|
||||||
|
await self.on_open(connection_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_task = asyncio.create_task(connection._send_loop())
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive()
|
||||||
|
if self.on_message:
|
||||||
|
await self.on_message(connection_id, data)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
del self.connections[connection_id]
|
||||||
|
send_task.cancel()
|
||||||
|
await send_task
|
||||||
|
|
||||||
|
if self.on_close:
|
||||||
|
await self.on_close(connection_id)
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Interface for the model to send messages. The model shouldn't
|
||||||
|
# use the Connection class at all.
|
||||||
|
async def send_to(self, connection_id: int, data: str) -> None:
|
||||||
|
await self.connections[connection_id].send(data)
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
from typing import Any, Iterator, Optional
|
||||||
|
|
||||||
|
from glebby.board import BoardGenerator
|
||||||
|
from glebby.connection_manager import ConnectionManager
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def __init__(self, client_id: int):
|
||||||
|
self.id = client_id
|
||||||
|
self.name = ''
|
||||||
|
|
||||||
|
def get_dto(self) -> dict[str, Any]:
|
||||||
|
return { 'id': self.id, 'name': self.name }
|
||||||
|
|
||||||
|
class Model:
|
||||||
|
connection_manager: ConnectionManager
|
||||||
|
clients: OrderedDict[int, Client]
|
||||||
|
board_generator: BoardGenerator
|
||||||
|
board: Optional[list[list[str]]]
|
||||||
|
|
||||||
|
def __init__(self, connection_manager: ConnectionManager):
|
||||||
|
self.connection_manager = connection_manager
|
||||||
|
self.clients = OrderedDict()
|
||||||
|
self.board_generator = BoardGenerator(None)
|
||||||
|
self.board = None
|
||||||
|
|
||||||
|
self.connection_manager.on_open = self.add_client
|
||||||
|
self.connection_manager.on_message = self.handle_incoming
|
||||||
|
self.connection_manager.on_close = self.remove_client
|
||||||
|
|
||||||
|
# Event handlers
|
||||||
|
|
||||||
|
async def add_client(self, client_id: int) -> None:
|
||||||
|
print(f'<{client_id}|OPEN>')
|
||||||
|
|
||||||
|
await self.broadcast(client_id, {
|
||||||
|
'type': 'join'
|
||||||
|
})
|
||||||
|
self.clients[client_id] = Client(client_id)
|
||||||
|
await self.send_to(None, client_id, {
|
||||||
|
'type': 'init',
|
||||||
|
'state': self.get_state_dto(client_id)
|
||||||
|
})
|
||||||
|
|
||||||
|
async def remove_client(self, client_id: int) -> None:
|
||||||
|
print(f'<{client_id}|CLOS>')
|
||||||
|
|
||||||
|
del self.clients[client_id]
|
||||||
|
await self.broadcast(client_id, {
|
||||||
|
'type': 'leave'
|
||||||
|
})
|
||||||
|
|
||||||
|
async def handle_incoming(self, client_id: int, data: str) -> None:
|
||||||
|
print(f'<{client_id}|DATA> {data}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(data)
|
||||||
|
except:
|
||||||
|
await self.send_to(None, client_id, {
|
||||||
|
'type': 'error',
|
||||||
|
'errorMessage': 'you sent invalid JSON!'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
if 'type' not in payload:
|
||||||
|
await self.send_to(None, client_id, {
|
||||||
|
'type': 'error',
|
||||||
|
'errorMessage': 'missing message type!'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
match payload:
|
||||||
|
case {'type': 'set-name', 'name': name}:
|
||||||
|
self.clients[client_id].name = name
|
||||||
|
await self.broadcast(client_id, {
|
||||||
|
'type': 'set-name',
|
||||||
|
'name': name
|
||||||
|
})
|
||||||
|
|
||||||
|
case {'type': 'chat', 'message': message}:
|
||||||
|
await self.broadcast(client_id, {
|
||||||
|
'type': 'chat',
|
||||||
|
'message': message
|
||||||
|
})
|
||||||
|
|
||||||
|
case {'type': 'roll'}:
|
||||||
|
self.board = self.board_generator.generate_board()
|
||||||
|
await self.broadcast(client_id, {
|
||||||
|
'type': 'roll',
|
||||||
|
'board': self.board
|
||||||
|
})
|
||||||
|
|
||||||
|
case _:
|
||||||
|
print(' Unhandled!')
|
||||||
|
|
||||||
|
def get_state_dto(self, client_id: int) -> Any:
|
||||||
|
return {
|
||||||
|
'yourId': client_id,
|
||||||
|
'players': [
|
||||||
|
client.get_dto()
|
||||||
|
for client in self.clients.values()
|
||||||
|
],
|
||||||
|
'board': self.board
|
||||||
|
}
|
||||||
|
|
||||||
|
# Message sending
|
||||||
|
|
||||||
|
async def send_to(self, client_id_from: Optional[int], client_id_to: int, payload: Any) -> None:
|
||||||
|
await self.connection_manager.send_to(client_id_to, json.dumps({
|
||||||
|
'from': client_id_from,
|
||||||
|
'payload': payload
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def broadcast(self, client_id_from: Optional[int], payload: Any) -> None:
|
||||||
|
for client_id_to in self.clients:
|
||||||
|
await self.send_to(client_id_from, client_id_to, payload)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
mypy==0.991
|
||||||
|
mypy-extensions==0.4.3
|
||||||
|
tomli==2.0.1
|
||||||
|
typing_extensions==4.4.0
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
|
aiofiles==22.1.0
|
||||||
|
blinker==1.5
|
||||||
click==8.1.3
|
click==8.1.3
|
||||||
Flask==2.2.2
|
|
||||||
flask-sock==0.6.0
|
|
||||||
h11==0.14.0
|
h11==0.14.0
|
||||||
|
h2==4.1.0
|
||||||
|
hpack==4.0.0
|
||||||
|
hypercorn==0.14.3
|
||||||
|
hyperframe==6.0.1
|
||||||
importlib-metadata==6.0.0
|
importlib-metadata==6.0.0
|
||||||
itsdangerous==2.1.2
|
itsdangerous==2.1.2
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
MarkupSafe==2.1.1
|
MarkupSafe==2.1.2
|
||||||
simple-websocket==0.9.0
|
priority==2.0.0
|
||||||
|
quart==0.18.3
|
||||||
|
toml==0.10.2
|
||||||
Werkzeug==2.2.2
|
Werkzeug==2.2.2
|
||||||
wsproto==1.2.0
|
wsproto==1.2.0
|
||||||
zipp==3.11.0
|
zipp==3.11.0
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import glebby
|
||||||
|
|
||||||
|
# For development use only
|
||||||
|
glebby.app.run()
|
||||||
Reference in New Issue
Block a user