Use Starlette, uvicorn and websockets; rename Docker files to container ones

Serve neo4j-browser with Starlette and uvicorn (the optional browser extra)
instead of a hand-written HTTP server, and handle Bolt over WebSocket with the
websockets library's Sans-I/O protocol. Rename Dockerfile, .dockerignore and
docker/ to Containerfile, .containerignore and container/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Bart BroereandClaude Opus 5.5 committed 2026-10-21 11:27:25 +02:00
1 parent 3e8e498b56
commit f0fb9ddc74
18 files changed
+189 -162

No files matched your search

+23 -6
View File
@@ -6,10 +6,10 @@ import asyncio
import functools
import logging
import os
import sys
from .backend import Config
from .bolt import handle_client
from .web import HttpServer
def env(name, default):
@@ -20,6 +20,17 @@ async def main():
logging.basicConfig(level=env("PEO_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("peo")
logging.getLogger("websockets").setLevel(logging.WARNING)
browser_dir = env("PEO_BROWSER_DIR", "")
if browser_dir:
try:
import uvicorn
from .web import create_app
except ImportError as e:
sys.exit(f"peo: PEO_BROWSER_DIR is set, but serving neo4j-browser needs the browser extra "
f"(pip install 'peo[browser]'): {e}")
bolt_port = int(env("PEO_BOLT_PORT", "7687"))
http_port = int(env("PEO_HTTP_PORT", "7474"))
@@ -33,17 +44,23 @@ async def main():
)
bolt = await asyncio.start_server(functools.partial(handle_client, config=config), "0.0.0.0", bolt_port)
servers = [bolt]
tasks = [bolt.serve_forever()]
log.info("Bolt listening on :%d (PostgreSQL %s:%d/%s, default graph %r)", bolt_port,
config.pg_host, config.pg_port, config.pg_database, config.default_graph)
browser_dir = env("PEO_BROWSER_DIR", "")
if browser_dir:
http = HttpServer(browser_dir, bolt_port)
servers.append(await asyncio.start_server(http.handle, "0.0.0.0", http_port))
http = uvicorn.Server(uvicorn.Config(create_app(browser_dir, bolt_port), host="0.0.0.0",
port=http_port, log_config=None, log_level="warning",
access_log=False, lifespan="off"))
tasks.append(http.serve())
log.info("neo4j-browser on http://localhost:%d/browser/", http_port)
await asyncio.gather(*(s.serve_forever() for s in servers))
# Stop when either server stops, e.g. uvicorn after a signal.
done, pending = await asyncio.wait(map(asyncio.ensure_future, tasks), return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
for task in done:
task.result()
def run():
+48 -50
View File
@@ -7,16 +7,18 @@ by neo4j-browser as well as current Python/Java drivers and cypher-shell.
"""
import asyncio
import base64
import hashlib
import itertools
import logging
import struct
from websockets.frames import CloseCode, Opcode
from websockets.protocol import State
from websockets.server import ServerProtocol
from . import packstream
from .backend import BoltFailure, Config, Session
from .discovery import discovery_document
from .packstream import Structure
from .web import discovery_document
log = logging.getLogger("peo.bolt")
@@ -61,11 +63,14 @@ class RawTransport:
class WebSocketTransport(RawTransport):
"""Minimal RFC 6455 server: Bolt bytes are carried in binary frames."""
"""Bolt over WebSocket: Bolt bytes are carried in binary frames.
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
Uses the Sans-I/O protocol of the websockets library, as this port also speaks raw Bolt.
"""
async def handshake(self):
# Read the request head here rather than in websockets, which only accepts GET:
# neo4j-browser also sends plain HTTP requests (discovery, CORS preflight) to this port.
request = bytearray(self.buffer)
self.buffer.clear()
while b"\r\n\r\n" not in request:
@@ -73,21 +78,26 @@ class WebSocketTransport(RawTransport):
if not chunk or len(request) > 65536:
raise ConnectionClosed()
request += chunk
head, _, rest = bytes(request).partition(b"\r\n\r\n")
head = bytes(request).partition(b"\r\n\r\n")[0]
headers = {}
for line in head.decode("latin-1").split("\r\n")[1:]:
k, _, v = line.partition(":")
headers[k.strip().lower()] = v.strip()
method = head.split(b" ", 1)[0]
key = headers.get("sec-websocket-key")
if method != b"GET" or not key:
if method != b"GET" or "websocket" not in headers.get("upgrade", "").lower():
await self._plain_http(method, headers)
raise ConnectionClosed()
accept = base64.b64encode(hashlib.sha1((key + self.GUID).encode()).digest()).decode()
self.writer.write(("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n").encode())
await self.writer.drain()
self.raw = bytearray(rest)
self.protocol = ServerProtocol(max_size=None)
self.protocol.receive_data(bytes(request))
events = self.protocol.events_received()
if not events:
raise ConnectionClosed()
response = self.protocol.accept(events[0])
self.protocol.send_response(response)
await self._flush()
if response.status_code != 101:
raise ConnectionClosed()
async def _plain_http(self, method, headers):
"""Answer non-WebSocket requests, e.g. neo4j-browser probing for discovery data."""
@@ -104,52 +114,39 @@ class WebSocketTransport(RawTransport):
self.writer.write(response)
await self.writer.drain()
async def _raw(self, n):
while len(self.raw) < n:
chunk = await self.reader.read(65536)
if not chunk:
raise ConnectionClosed()
self.raw += chunk
data = bytes(self.raw[:n])
del self.raw[:n]
return data
async def _flush(self):
for data in self.protocol.data_to_send():
if data:
self.writer.write(data)
await self.writer.drain()
async def read(self, n):
while len(self.buffer) < n:
b0, b1 = await self._raw(2)
opcode, masked, length = b0 & 0x0F, b1 & 0x80, b1 & 0x7F
if length == 126:
length = struct.unpack(">H", await self._raw(2))[0]
elif length == 127:
length = struct.unpack(">Q", await self._raw(8))[0]
mask = await self._raw(4) if masked else None
payload = await self._raw(length)
if mask:
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
if opcode == 0x8:
await self._frame(0x8, payload[:2])
for frame in self.protocol.events_received():
if frame.opcode in (Opcode.CONT, Opcode.TEXT, Opcode.BINARY):
self.buffer += frame.data
await self._flush() # pongs and the reply to a close frame
if len(self.buffer) >= n:
break
if self.protocol.state is not State.OPEN:
raise ConnectionClosed()
if opcode == 0x9:
await self._frame(0xA, payload)
elif opcode in (0x0, 0x1, 0x2):
self.buffer += payload
chunk = await self.reader.read(65536)
if not chunk:
raise ConnectionClosed()
self.protocol.receive_data(chunk)
data = bytes(self.buffer[:n])
del self.buffer[:n]
return data
async def _frame(self, opcode, payload):
n = len(payload)
if n < 126:
header = struct.pack(">BB", 0x80 | opcode, n)
elif n < 0x10000:
header = struct.pack(">BBH", 0x80 | opcode, 126, n)
else:
header = struct.pack(">BBQ", 0x80 | opcode, 127, n)
self.writer.write(header + payload)
await self.writer.drain()
async def write(self, data):
await self._frame(0x2, data)
self.protocol.send_binary(data)
await self._flush()
async def close(self):
if self.protocol.state is State.OPEN:
self.protocol.send_close(CloseCode.NORMAL_CLOSURE)
await self._flush()
self.writer.close()
# -- protocol -----------------------------------------------------------------------
@@ -364,6 +361,7 @@ async def handle_client(reader, writer, config: Config):
else:
return
await BoltConnection(transport, config).serve()
await transport.close()
except (ConnectionClosed, asyncio.IncompleteReadError, ConnectionResetError, BrokenPipeError):
pass
except Exception: # noqa: BLE001
+24
View File
@@ -0,0 +1,24 @@
# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
"""The Neo4j discovery document, served over HTTP on both the browser and the Bolt port."""
import json
import os
from .backend import NEO4J_VERSION
def discovery_document(headers, bolt_port) -> bytes:
"""The JSON Neo4j serves at ``/`` so clients can find the Bolt endpoint."""
host = headers.get("host") or "localhost"
if host.startswith("["): # IPv6 literal, e.g. [::1]:7474
host = host[:host.index("]") + 1]
else:
host = host.split(":", 1)[0]
port = os.environ.get("PEO_ADVERTISED_BOLT_PORT", str(bolt_port))
return json.dumps({
"bolt_routing": f"neo4j://{host}:{port}",
"bolt_direct": f"bolt://{host}:{port}",
"neo4j_version": NEO4J_VERSION,
"neo4j_edition": "community",
}).encode()
+44 -76
View File
@@ -1,86 +1,54 @@
# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tiny HTTP server for the neo4j-browser static files and Neo4j discovery."""
"""HTTP server for the neo4j-browser static files and Neo4j discovery.
import asyncio
import json
import logging
import mimetypes
import os
from pathlib import Path
from urllib.parse import unquote, urlsplit
Needs the ``browser`` extra: ``pip install 'peo[browser]'``.
"""
from .backend import NEO4J_VERSION
from starlette.applications import Starlette
from starlette.datastructures import MutableHeaders
from starlette.middleware import Middleware
from starlette.responses import RedirectResponse, Response
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
log = logging.getLogger("peo.http")
from .discovery import discovery_document
class HttpServer:
def __init__(self, browser_dir: str, bolt_port: int):
self.root = Path(browser_dir).resolve()
self.bolt_port = bolt_port
class BrowserFiles(StaticFiles):
"""neo4j-browser's build: index.html must be revalidated, the hashed assets can be cached."""
async def handle(self, reader, writer):
try:
request = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=30)
lines = request.decode("latin-1").split("\r\n")
method, target, _ = (lines[0].split(" ") + ["", "", ""])[:3]
headers = {}
for line in lines[1:]:
k, _, v = line.partition(":")
headers[k.strip().lower()] = v.strip()
status, extra, body = self.respond(method, target, headers)
head = [f"HTTP/1.1 {status}", f"Content-Length: {len(body)}", "Connection: close",
"X-Content-Type-Options: nosniff"]
head += [f"{k}: {v}" for k, v in extra.items()]
writer.write(("\r\n".join(head) + "\r\n\r\n").encode("latin-1"))
if method != "HEAD":
writer.write(body)
await writer.drain()
except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, asyncio.TimeoutError,
ConnectionResetError, BrokenPipeError):
pass
except Exception: # noqa: BLE001
log.exception("HTTP request failed")
finally:
writer.close()
def respond(self, method, target, headers):
if method not in ("GET", "HEAD"):
return "405 Method Not Allowed", {"Allow": "GET, HEAD"}, b""
path = unquote(urlsplit(target).path)
if path in ("/", ""):
if "text/html" in headers.get("accept", ""):
return "302 Found", {"Location": "/browser/"}, b""
return "200 OK", {"Content-Type": "application/json"}, discovery_document(headers, self.bolt_port)
if path == "/browser":
return "301 Moved Permanently", {"Location": "/browser/"}, b""
if not path.startswith("/browser/"):
return "404 Not Found", {"Content-Type": "text/plain"}, b"Not found\n"
file = (self.root / path[len("/browser/"):]).resolve()
if file.is_dir():
file = file / "index.html"
if not file.is_relative_to(self.root) or not file.is_file():
return "404 Not Found", {"Content-Type": "text/plain"}, b"Not found\n"
ctype = mimetypes.guess_type(file.name)[0] or "application/octet-stream"
cache = "no-cache" if file.name.endswith(".html") else "public, max-age=86400"
return "200 OK", {"Content-Type": ctype, "Cache-Control": cache}, file.read_bytes()
def file_response(self, full_path, *args, **kwargs):
response = super().file_response(full_path, *args, **kwargs)
response.headers["Cache-Control"] = ("no-cache" if str(full_path).endswith(".html")
else "public, max-age=86400")
return response
def discovery_document(headers, bolt_port) -> bytes:
"""The JSON Neo4j serves at ``/`` so clients can find the Bolt endpoint."""
host = headers.get("host") or "localhost"
if host.startswith("["): # IPv6 literal, e.g. [::1]:7474
host = host[:host.index("]") + 1]
else:
host = host.split(":", 1)[0]
port = os.environ.get("PEO_ADVERTISED_BOLT_PORT", str(bolt_port))
return json.dumps({
"bolt_routing": f"neo4j://{host}:{port}",
"bolt_direct": f"bolt://{host}:{port}",
"neo4j_version": NEO4J_VERSION,
"neo4j_edition": "community",
}).encode()
def nosniff(app):
async def wrapped(scope, receive, send):
async def send_with_header(message):
if message["type"] == "http.response.start":
MutableHeaders(scope=message)["X-Content-Type-Options"] = "nosniff"
await send(message)
await app(scope, receive, send_with_header)
return wrapped
def create_app(browser_dir: str, bolt_port: int) -> Starlette:
async def root(request):
if "text/html" in request.headers.get("accept", ""):
return RedirectResponse("/browser/", status_code=302)
return Response(discovery_document(request.headers, bolt_port), media_type="application/json")
async def browser(request):
return RedirectResponse("/browser/", status_code=301)
return Starlette(
routes=[
Route("/", root),
Route("/browser", browser),
Mount("/browser", BrowserFiles(directory=browser_dir, html=True)),
],
middleware=[Middleware(nosniff)],
)