diff --git a/15-chatroom/README.md b/15-chatroom/README.md
new file mode 100644
index 0000000..4d21ea9
--- /dev/null
+++ b/15-chatroom/README.md
@@ -0,0 +1,13 @@
+# WebSocket chatroom
+
+## How to Run
+
+First ensure that `uv` is installed:
+https://docs.astral.sh/uv/getting-started/installation/#standalone-installer
+
+Now, if you run `uv run pywrangler dev` within this directory, it should use the config
+in `wrangler.jsonc` to run the example.
+
+You can also run `uv run pywrangler deploy` to deploy the example.
+
+Navigate to `http://localhost:8787` to see the chatroom.
diff --git a/15-chatroom/package.json b/15-chatroom/package.json
new file mode 100644
index 0000000..538c0d7
--- /dev/null
+++ b/15-chatroom/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "python-websocket-stream-consumer",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "deploy": "uv run pywrangler deploy",
+ "dev": "uv run pywrangler dev",
+ "start": "uv run pywrangler dev"
+ },
+ "devDependencies": {
+ "wrangler": "^4.50.0"
+ }
+}
diff --git a/15-chatroom/pyproject.toml b/15-chatroom/pyproject.toml
new file mode 100644
index 0000000..339cc35
--- /dev/null
+++ b/15-chatroom/pyproject.toml
@@ -0,0 +1,15 @@
+[project]
+name = "python-chatroom"
+version = "0.1.0"
+description = "Python WebSocket chatroom example"
+readme = "README.md"
+requires-python = ">=3.12"
+dependencies = [
+ "webtypy>=0.1.7",
+]
+
+[dependency-groups]
+dev = [
+ "workers-py",
+ "workers-runtime-sdk"
+]
diff --git a/15-chatroom/src/chatroom.html b/15-chatroom/src/chatroom.html
new file mode 100644
index 0000000..cbc207d
--- /dev/null
+++ b/15-chatroom/src/chatroom.html
@@ -0,0 +1,457 @@
+
+
+
+
+
+ Chatroom
+
+
+
+
+
+
+
Connecting...
+
+
+
+
+
+
+
+
+
diff --git a/15-chatroom/src/entry.py b/15-chatroom/src/entry.py
new file mode 100644
index 0000000..a08ebd7
--- /dev/null
+++ b/15-chatroom/src/entry.py
@@ -0,0 +1,134 @@
+from workers import WorkerEntrypoint, Response, DurableObject
+from pathlib import Path
+from js import WebSocketPair
+import json
+from urllib.parse import urlparse
+from datetime import datetime, timezone
+
+
+class Chatroom(DurableObject):
+ """Durable Object that manages a chatroom with WebSocket connections."""
+
+ def __init__(self, state, env):
+ super().__init__(state, env)
+ self.state = state
+ self.env = env
+ self.message_history = [] # Limited message history
+ self.max_history = 50 # Maximum number of messages to keep
+
+ async def fetch(self, request):
+ """Handle incoming requests to the Durable Object."""
+
+ # Check if this is a WebSocket upgrade request
+ upgrade_header = request.headers.get("Upgrade")
+ if not upgrade_header or upgrade_header.lower() != "websocket":
+ # If not a WebSocket request, return an error
+ return Response("Expected WebSocket upgrade", status=400)
+
+ # Create a WebSocket pair
+ client, server = WebSocketPair.new().object_values()
+
+ # Accept the WebSocket connection - this tells the DO to handle it
+ self.state.acceptWebSocket(server)
+
+ # Send message history to the newly connected client
+ if self.message_history:
+ history_msg = {"type": "history", "messages": self.message_history}
+ server.send(json.dumps(history_msg))
+
+ # Send a welcome message
+ welcome_msg = {
+ "type": "system",
+ "text": "Connected to chatroom",
+ "timestamp": self.get_timestamp(),
+ }
+ server.send(json.dumps(welcome_msg))
+
+ # Return the client-side WebSocket in the response
+ return Response(None, status=101, web_socket=client)
+
+ async def webSocketMessage(self, ws, message):
+ """Handle incoming WebSocket messages."""
+ try:
+ data = json.loads(message)
+
+ # Create a message object
+ msg = {
+ "type": "message",
+ "username": data.get("username", "Anonymous"),
+ "text": data.get("text", ""),
+ "timestamp": self.get_timestamp(),
+ }
+
+ # Add to history
+ self.message_history.append(msg)
+ if len(self.message_history) > self.max_history:
+ self.message_history.pop(0)
+
+ # Broadcast to all connected clients
+ self.broadcast(json.dumps(msg))
+ except Exception as e:
+ print(f"Error handling message: {e}")
+
+ async def webSocketClose(self, ws, code, reason, wasClean):
+ """Handle WebSocket close events."""
+ ws.close(code, reason)
+ active_connections = len(self.state.getWebSockets())
+ print(f"Client disconnected. Active sessions: {active_connections}")
+
+ async def webSocketError(self, ws, error):
+ """Handle WebSocket error events."""
+ ws.close(1011, "WebSocket error")
+ print(f"WebSocket error: {error}")
+
+ def broadcast(self, message):
+ """Broadcast a message to all connected clients."""
+ # Get all active WebSocket connections from the state
+ websockets = self.state.getWebSockets()
+
+ # Send to all active sessions
+ for ws in websockets:
+ try:
+ ws.send(message)
+ except Exception as e:
+ print(f"Error broadcasting to session: {e}")
+
+ def get_timestamp(self):
+ """Get current timestamp in ISO format."""
+ return datetime.now(timezone.utc).isoformat()
+
+
+class Default(WorkerEntrypoint):
+ """Main worker entry point that routes requests to the Durable Object."""
+
+ async def fetch(self, request):
+ url = urlparse(request.url)
+ pathname = url.path
+
+ # Serve the HTML page for the root path
+ if pathname == "/":
+ html_file = Path(__file__).parent / "chatroom.html"
+ return Response(
+ html_file.read_text(), headers={"Content-Type": "text/html"}
+ )
+
+ # Handle room requests: /room/
+ if pathname.startswith("/room/"):
+ # Extract room name from path
+ room_name = pathname[6:] # Remove "/room/" prefix
+ if not room_name:
+ return Response("Room name required", status=400)
+
+ # Get the Durable Object namespace
+ namespace = self.env.CHATROOM
+
+ # Create a unique ID for this room
+ room_id = namespace.idFromName(room_name)
+ stub = namespace.get(room_id)
+
+ # Forward the request to the Durable Object
+ return await stub.fetch(request)
+
+ return Response(
+ "Not found. Use /room/ to connect to a chatroom.", status=404
+ )
diff --git a/15-chatroom/wrangler.jsonc b/15-chatroom/wrangler.jsonc
new file mode 100644
index 0000000..c0dac62
--- /dev/null
+++ b/15-chatroom/wrangler.jsonc
@@ -0,0 +1,29 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "python-chatroom",
+ "main": "src/entry.py",
+ "compatibility_date": "2025-11-02",
+ "compatibility_flags": [
+ "python_workers"
+ ],
+ "observability": {
+ "enabled": true
+ },
+ "durable_objects": {
+ "bindings": [
+ {
+ "name": "CHATROOM",
+ "class_name": "Chatroom",
+ "script_name": "python-chatroom"
+ }
+ ]
+ },
+ "migrations": [
+ {
+ "tag": "v1",
+ "new_sqlite_classes": [
+ "Chatroom"
+ ]
+ }
+ ]
+}