|
|
|
import os
|
|
|
|
import sys
|
|
|
|
import asyncio
|
|
|
|
|
|
|
|
from engineio.static_files import get_static_file
|
|
|
|
|
|
|
|
|
|
|
|
class ASGIApp:
|
|
|
|
"""ASGI application middleware for Engine.IO.
|
|
|
|
|
|
|
|
This middleware dispatches traffic to an Engine.IO application. It can
|
|
|
|
also serve a list of static files to the client, or forward unrelated
|
|
|
|
HTTP traffic to another ASGI application.
|
|
|
|
|
|
|
|
:param engineio_server: The Engine.IO server. Must be an instance of the
|
|
|
|
``engineio.AsyncServer`` class.
|
|
|
|
:param static_files: A dictionary with static file mapping rules. See the
|
|
|
|
documentation for details on this argument.
|
|
|
|
:param other_asgi_app: A separate ASGI app that receives all other traffic.
|
|
|
|
:param engineio_path: The endpoint where the Engine.IO application should
|
|
|
|
be installed. The default value is appropriate for
|
|
|
|
most cases.
|
|
|
|
:param on_startup: function to be called on application startup; can be
|
|
|
|
coroutine
|
|
|
|
:param on_shutdown: function to be called on application shutdown; can be
|
|
|
|
coroutine
|
|
|
|
|
|
|
|
Example usage::
|
|
|
|
|
|
|
|
import engineio
|
|
|
|
import uvicorn
|
|
|
|
|
|
|
|
eio = engineio.AsyncServer()
|
|
|
|
app = engineio.ASGIApp(eio, static_files={
|
|
|
|
'/': {'content_type': 'text/html', 'filename': 'index.html'},
|
|
|
|
'/index.html': {'content_type': 'text/html',
|
|
|
|
'filename': 'index.html'},
|
|
|
|
})
|
|
|
|
uvicorn.run(app, '127.0.0.1', 5000)
|
|
|
|
"""
|
|
|
|
def __init__(self, engineio_server, other_asgi_app=None,
|
|
|
|
static_files=None, engineio_path='engine.io',
|
|
|
|
on_startup=None, on_shutdown=None):
|
|
|
|
self.engineio_server = engineio_server
|
|
|
|
self.other_asgi_app = other_asgi_app
|
|
|
|
self.engineio_path = engineio_path
|
|
|
|
if not self.engineio_path.startswith('/'):
|
|
|
|
self.engineio_path = '/' + self.engineio_path
|
|
|
|
if not self.engineio_path.endswith('/'):
|
|
|
|
self.engineio_path += '/'
|
|
|
|
self.static_files = static_files or {}
|
|
|
|
self.on_startup = on_startup
|
|
|
|
self.on_shutdown = on_shutdown
|
|
|
|
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
|
|
if scope['type'] in ['http', 'websocket'] and \
|
|
|
|
scope['path'].startswith(self.engineio_path):
|
|
|
|
await self.engineio_server.handle_request(scope, receive, send)
|
|
|
|
else:
|
|
|
|
static_file = get_static_file(scope['path'], self.static_files) \
|
|
|
|
if scope['type'] == 'http' and self.static_files else None
|
|
|
|
if scope['type'] == 'lifespan':
|
|
|
|
await self.lifespan(scope, receive, send)
|
|
|
|
elif static_file and os.path.exists(static_file['filename']):
|
|
|
|
await self.serve_static_file(static_file, receive, send)
|
|
|
|
elif self.other_asgi_app is not None:
|
|
|
|
await self.other_asgi_app(scope, receive, send)
|
|
|
|
else:
|
|
|
|
await self.not_found(receive, send)
|
|
|
|
|
|
|
|
async def serve_static_file(self, static_file, receive,
|
|
|
|
send): # pragma: no cover
|
|
|
|
event = await receive()
|
|
|
|
if event['type'] == 'http.request':
|
|
|
|
with open(static_file['filename'], 'rb') as f:
|
|
|
|
payload = f.read()
|
|
|
|
await send({'type': 'http.response.start',
|
|
|
|
'status': 200,
|
|
|
|
'headers': [(b'Content-Type', static_file[
|
|
|
|
'content_type'].encode('utf-8'))]})
|
|
|
|
await send({'type': 'http.response.body',
|
|
|
|
'body': payload})
|
|
|
|
|
|
|
|
async def lifespan(self, scope, receive, send):
|
|
|
|
if self.other_asgi_app is not None and self.on_startup is None and \
|
|
|
|
self.on_shutdown is None:
|
|
|
|
# let the other ASGI app handle lifespan events
|
|
|
|
await self.other_asgi_app(scope, receive, send)
|
|
|
|
return
|
|
|
|
|
|
|
|
while True:
|
|
|
|
event = await receive()
|
|
|
|
if event['type'] == 'lifespan.startup':
|
|
|
|
if self.on_startup:
|
|
|
|
try:
|
|
|
|
await self.on_startup() \
|
|
|
|
if asyncio.iscoroutinefunction(self.on_startup) \
|
|
|
|
else self.on_startup()
|
|
|
|
except:
|
|
|
|
await send({'type': 'lifespan.startup.failed'})
|
|
|
|
return
|
|
|
|
await send({'type': 'lifespan.startup.complete'})
|
|
|
|
elif event['type'] == 'lifespan.shutdown':
|
|
|
|
if self.on_shutdown:
|
|
|
|
try:
|
|
|
|
await self.on_shutdown() \
|
|
|
|
if asyncio.iscoroutinefunction(self.on_shutdown) \
|
|
|
|
else self.on_shutdown()
|
|
|
|
except:
|
|
|
|
await send({'type': 'lifespan.shutdown.failed'})
|
|
|
|
return
|
|
|
|
await send({'type': 'lifespan.shutdown.complete'})
|
|
|
|
return
|
|
|
|
|
|
|
|
async def not_found(self, receive, send):
|
|
|
|
"""Return a 404 Not Found error to the client."""
|
|
|
|
await send({'type': 'http.response.start',
|
|
|
|
'status': 404,
|
|
|
|
'headers': [(b'Content-Type', b'text/plain')]})
|
|
|
|
await send({'type': 'http.response.body',
|
|
|
|
'body': b'Not Found'})
|
|
|
|
|
|
|
|
|
|
|
|
async def translate_request(scope, receive, send):
|
|
|
|
class AwaitablePayload(object): # pragma: no cover
|
|
|
|
def __init__(self, payload):
|
|
|
|
self.payload = payload or b''
|
|
|
|
|
|
|
|
async def read(self, length=None):
|
|
|
|
if length is None:
|
|
|
|
r = self.payload
|
|
|
|
self.payload = b''
|
|
|
|
else:
|
|
|
|
r = self.payload[:length]
|
|
|
|
self.payload = self.payload[length:]
|
|
|
|
return r
|
|
|
|
|
|
|
|
event = await receive()
|
|
|
|
payload = b''
|
|
|
|
if event['type'] == 'http.request':
|
|
|
|
payload += event.get('body') or b''
|
|
|
|
while event.get('more_body'):
|
|
|
|
event = await receive()
|
|
|
|
if event['type'] == 'http.request':
|
|
|
|
payload += event.get('body') or b''
|
|
|
|
elif event['type'] == 'websocket.connect':
|
|
|
|
pass
|
|
|
|
else:
|
|
|
|
return {}
|
|
|
|
|
|
|
|
raw_uri = scope['path'].encode('utf-8')
|
|
|
|
if 'query_string' in scope and scope['query_string']:
|
|
|
|
raw_uri += b'?' + scope['query_string']
|
|
|
|
environ = {
|
|
|
|
'wsgi.input': AwaitablePayload(payload),
|
|
|
|
'wsgi.errors': sys.stderr,
|
|
|
|
'wsgi.version': (1, 0),
|
|
|
|
'wsgi.async': True,
|
|
|
|
'wsgi.multithread': False,
|
|
|
|
'wsgi.multiprocess': False,
|
|
|
|
'wsgi.run_once': False,
|
|
|
|
'SERVER_SOFTWARE': 'asgi',
|
|
|
|
'REQUEST_METHOD': scope.get('method', 'GET'),
|
|
|
|
'PATH_INFO': scope['path'],
|
|
|
|
'QUERY_STRING': scope.get('query_string', b'').decode('utf-8'),
|
|
|
|
'RAW_URI': raw_uri.decode('utf-8'),
|
|
|
|
'SCRIPT_NAME': '',
|
|
|
|
'SERVER_PROTOCOL': 'HTTP/1.1',
|
|
|
|
'REMOTE_ADDR': '127.0.0.1',
|
|
|
|
'REMOTE_PORT': '0',
|
|
|
|
'SERVER_NAME': 'asgi',
|
|
|
|
'SERVER_PORT': '0',
|
|
|
|
'asgi.receive': receive,
|
|
|
|
'asgi.send': send,
|
|
|
|
'asgi.scope': scope,
|
|
|
|
}
|
|
|
|
|
|
|
|
for hdr_name, hdr_value in scope['headers']:
|
|
|
|
hdr_name = hdr_name.upper().decode('utf-8')
|
|
|
|
hdr_value = hdr_value.decode('utf-8')
|
|
|
|
if hdr_name == 'CONTENT-TYPE':
|
|
|
|
environ['CONTENT_TYPE'] = hdr_value
|
|
|
|
continue
|
|
|
|
elif hdr_name == 'CONTENT-LENGTH':
|
|
|
|
environ['CONTENT_LENGTH'] = hdr_value
|
|
|
|
continue
|
|
|
|
|
|
|
|
key = 'HTTP_%s' % hdr_name.replace('-', '_')
|
|
|
|
if key in environ:
|
|
|
|
hdr_value = '%s,%s' % (environ[key], hdr_value)
|
|
|
|
|
|
|
|
environ[key] = hdr_value
|
|
|
|
|
|
|
|
environ['wsgi.url_scheme'] = environ.get('HTTP_X_FORWARDED_PROTO', 'http')
|
|
|
|
return environ
|
|
|
|
|
|
|
|
|
|
|
|
async def make_response(status, headers, payload, environ):
|
|
|
|
headers = [(h[0].encode('utf-8'), h[1].encode('utf-8')) for h in headers]
|
|
|
|
if environ['asgi.scope']['type'] == 'websocket':
|
|
|
|
if status.startswith('200 '):
|
|
|
|
await environ['asgi.send']({'type': 'websocket.accept',
|
|
|
|
'headers': headers})
|
|
|
|
else:
|
|
|
|
if payload:
|
|
|
|
reason = payload.decode('utf-8') \
|
|
|
|
if isinstance(payload, bytes) else str(payload)
|
|
|
|
await environ['asgi.send']({'type': 'websocket.close',
|
|
|
|
'reason': reason})
|
|
|
|
else:
|
|
|
|
await environ['asgi.send']({'type': 'websocket.close'})
|
|
|
|
return
|
|
|
|
|
|
|
|
await environ['asgi.send']({'type': 'http.response.start',
|
|
|
|
'status': int(status.split(' ')[0]),
|
|
|
|
'headers': headers})
|
|
|
|
await environ['asgi.send']({'type': 'http.response.body',
|
|
|
|
'body': payload})
|
|
|
|
|
|
|
|
|
|
|
|
class WebSocket(object): # pragma: no cover
|
|
|
|
"""
|
|
|
|
This wrapper class provides an asgi WebSocket interface that is
|
|
|
|
somewhat compatible with eventlet's implementation.
|
|
|
|
"""
|
|
|
|
def __init__(self, handler, server):
|
|
|
|
self.handler = handler
|
|
|
|
self.asgi_receive = None
|
|
|
|
self.asgi_send = None
|
|
|
|
|
|
|
|
async def __call__(self, environ):
|
|
|
|
self.asgi_receive = environ['asgi.receive']
|
|
|
|
self.asgi_send = environ['asgi.send']
|
|
|
|
await self.asgi_send({'type': 'websocket.accept'})
|
|
|
|
await self.handler(self)
|
|
|
|
return '' # send nothing as response
|
|
|
|
|
|
|
|
async def close(self):
|
|
|
|
try:
|
|
|
|
await self.asgi_send({'type': 'websocket.close'})
|
|
|
|
except Exception:
|
|
|
|
# if the socket is already close we don't care
|
|
|
|
pass
|
|
|
|
|
|
|
|
async def send(self, message):
|
|
|
|
msg_bytes = None
|
|
|
|
msg_text = None
|
|
|
|
if isinstance(message, bytes):
|
|
|
|
msg_bytes = message
|
|
|
|
else:
|
|
|
|
msg_text = message
|
|
|
|
await self.asgi_send({'type': 'websocket.send',
|
|
|
|
'bytes': msg_bytes,
|
|
|
|
'text': msg_text})
|
|
|
|
|
|
|
|
async def wait(self):
|
|
|
|
event = await self.asgi_receive()
|
|
|
|
if event['type'] != 'websocket.receive':
|
|
|
|
raise IOError()
|
|
|
|
return event.get('bytes') or event.get('text')
|
|
|
|
|
|
|
|
|
|
|
|
_async = {
|
|
|
|
'asyncio': True,
|
|
|
|
'translate_request': translate_request,
|
|
|
|
'make_response': make_response,
|
|
|
|
'websocket': WebSocket,
|
|
|
|
}
|