added webrtc peer to peer cam example

Commit 20aaa46 · patx · 2025-01-30T00:52:33-05:00

Changeset
20aaa46543e811aaecc4716d53342da48e955918
Parents
3ce81161b1b9055c241af86800a4927f47316f98

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 8e379d5..dbc0bee 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
 
 ## **Introduction**
 
-**MicroPie** is a fast, lightweight, modern Python web framework that supports asynchronous web applications. Designed with flexibility and simplicity in mind, MicroPie enables you to handle high-concurrency HTTP applications with ease while allowing natural integration with external tools like Socket.IO for real-time communication.
+**MicroPie** is a fast, lightweight, modern Python web framework that supports asynchronous web applications. Designed with **flexibility** and **simplicity** in mind, MicroPie enables you to handle high-concurrency applications with ease while allowing natural integration with external tools like Socket.IO for real-time communication.
 
 ### **Key Features**
 - 🔄 **Routing:** Automatic mapping of URLs to functions with support for dynamic and query parameters.
@@ -32,7 +32,7 @@ pip install jinja2
 ```
 
 ### **Install an ASGI Web Server**
-In order to test and deploy your apps you will need a ASGI web server like uvicorn or Daphne. Install uvicorn with:
+In order to test and deploy your apps you will need a ASGI web server like Uvicorn, Hypercorn or Daphne. Install `uvicorn` with:
 ```bash
 pip install uvicorn
 ```
@@ -70,12 +70,12 @@ class MyApp(Server):
 
     async def hello(self):
         name = self.query_params.get("name", None)
-        return f"Hello {name}!" 
+        return f"Hello {name}!"
 ```
 **Access:**
 - [http://127.0.0.1:8000/greet?name=Alice](http://127.0.0.1:8000/greet?name=Alice) returns `Hello, Alice!`, same as [http://127.0.0.1:8000/greet/Alice](http://127.0.0.1:8000/greet/Alice) returns `Hello, Alice!`
 - [http://127.0.0.1:800/hello/Alice](http://127.0.0.1:800/hello/Alice) returns `Hello Alice!`, same as [http://127.0.0.1:800/hello?name=Alice](http://127.0.0.1:800/hello?name=Alice) returns `Hello Alice!`
-  
+
 ### **2. Flexible HTTP POST Request Handling**
 MicroPie also supports handling form data submitted via HTTP POST requests. Form data is automatically mapped to method arguments. It is able to handle default values and raw POST data:
 ```python
@@ -205,7 +205,7 @@ MicroPie allows you to take full advantage of these benefits while maintaining s
 
 
 Starlette performs best, maintaining the highest throughput and low latency due to its heavily optimized architecture. MicroPie also excels, especially at high concurrency,
-benefiting from lightweight processing. FastAPI offers stable performance but suffers increased latency under load, likely due to request validation overhead. Quart 
+benefiting from lightweight processing. FastAPI offers stable performance but suffers increased latency under load, likely due to request validation overhead. Quart
 performs the worst, with high latency and low throughput, likely due to its Flask compatibility, making it less suited for high-concurrency workloads.
 
 *Tests were performed on a Star Labs StarLite Mk IV with `uvicorn` using 4 workers. Benchmarked with `wrk` with 4 threads for 30s. This a minimal baseline benchmark, and should be taken with a grain of salt.*
diff --git a/examples/socketio/templates/stream.html b/examples/socketio/templates/stream.html
index b4f4e37..653f758 100644
--- a/examples/socketio/templates/stream.html
+++ b/examples/socketio/templates/stream.html
@@ -3,16 +3,18 @@
 <head>
   <meta charset="UTF-8" />
   <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+  <!-- Force only WebSocket transport -->
   <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
   <title>Streaming: {{ username }}</title>
 </head>
 <body>
   <h1>Streaming as {{ username }}</h1>
-  <video id="webcam" autoplay playsinline></video>
+  <video id="webcam" autoplay playsinline muted></video>
 
 <script>
   const username = "{{ username }}";
-  const socket = io();
+  // 1) Force only WebSocket
+  const socket = io({ transports: ["websocket"] });
 
   // Join the room as a streamer
   socket.emit("join_room", { username });
@@ -28,7 +30,16 @@
 
   async function startWebcam() {
     try {
-      const stream = await navigator.mediaDevices.getUserMedia({ video: true });
+      const constraints = {
+        video: {
+          width: { ideal: 640 },
+          height: { ideal: 480 },
+          frameRate: { ideal: 15 }
+        },
+        audio: false
+      };
+
+      const stream = await navigator.mediaDevices.getUserMedia(constraints);
       const videoElement = document.getElementById("webcam");
       videoElement.srcObject = stream;
 
@@ -39,13 +50,18 @@
       canvas.width = settings.width || 640;
       canvas.height = settings.height || 480;
 
+      // Send frames (binary) ~5 times per second
       setInterval(() => {
         context.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
-        const frameDataUrl = canvas.toDataURL("image/webp");
-
-        // Send frame to server
-        socket.emit("stream_frame", { username, frame: frameDataUrl });
-      }, 100);
+        // Instead of .toDataURL(), use .toBlob() for binary
+        canvas.toBlob((blob) => {
+          if (!blob) return;
+          socket.emit("stream_frame", {
+            username: username,
+            frame: blob
+          });
+        }, "image/jpeg", 0.5); // 'image/jpeg' @ 50% quality
+      }, 200);
     } catch (err) {
       console.error("Error accessing webcam:", err);
     }
diff --git a/examples/socketio/templates/watch.html b/examples/socketio/templates/watch.html
index e4f432d..8b87b3f 100644
--- a/examples/socketio/templates/watch.html
+++ b/examples/socketio/templates/watch.html
@@ -10,10 +10,10 @@
   <h1>Watching Stream of {{ username }}</h1>
   <img id="videoFeed" style="width: 100%; max-width: 800px;" />
 
-
 <script>
   const username = "{{ username }}";
-  const socket = io();
+  // 1) Force only WebSocket
+  const socket = io({ transports: ["websocket"] });
 
   // Join the room as a watcher
   socket.emit("join_room", { username });
@@ -22,9 +22,13 @@
     console.log("Connected as watcher for", username);
   });
 
+  // Listen for binary frames
   socket.on("video_frame", (data) => {
-    if (data.username === username) {
-      document.getElementById("videoFeed").src = data.frame;
+    // data = { username, frame: <binary> }
+    if (data.username === username && data.frame) {
+      // Reconstruct a Blob from the received 'frame' (binary)
+      const blob = new Blob([data.frame], { type: "image/jpeg" });
+      document.getElementById("videoFeed").src = URL.createObjectURL(blob);
     }
   });
 
@@ -35,3 +39,4 @@
 
 </body>
 </html>
+
diff --git a/examples/socketio/webcam.py b/examples/socketio/webcam.py
index dca2eba..65e7e86 100644
--- a/examples/socketio/webcam.py
+++ b/examples/socketio/webcam.py
@@ -36,12 +36,19 @@ async def disconnect(sid):
 
 @sio.on("stream_frame")
 async def handle_stream_frame(sid, data):
-    """Broadcast the streamed frame to all watchers."""
+    """
+    Broadcast the streamed frame (binary blob) to all watchers.
+    data = { "username": <str>, "frame": <binary blob> }
+    """
     username = data.get("username")
-    frame = data.get("frame")
+    frame = data.get("frame")  # This is binary
     if username in active_users:
-        # Emit the frame to watchers
-        await sio.emit("video_frame", {"username": username, "frame": frame}, room=username)
+        # Emit the frame to watchers in username's room
+        await sio.emit(
+            "video_frame",
+            {"username": username, "frame": frame},
+            room=username,
+        )
 
 @sio.on("join_room")
 async def join_room(sid, data):
@@ -61,4 +68,4 @@ async def leave_room(sid, data):
 
 # Attach the Socket.IO server to the ASGI app
 asgi_app = MyApp()
-app = socketio.ASGIApp(sio, app)
+app = socketio.ASGIApp(sio, asgi_app)
diff --git a/examples/socketio/webtrc/MicroPie.py b/examples/socketio/webtrc/MicroPie.py
new file mode 100644
index 0000000..867cf7c
--- /dev/null
+++ b/examples/socketio/webtrc/MicroPie.py
@@ -0,0 +1,372 @@
+"""
+MicroPie: A simple Python ultra-micro web framework with ASGI
+support. https://patx.github.io/micropie
+
+Copyright Harrison Erd
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice,
+   this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from this
+   software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
+IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
+OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
+OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
+EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+"""
+import inspect
+import mimetypes
+import os
+import time
+from typing import Optional, Dict, Any, Union, Tuple, List
+from urllib.parse import parse_qs
+import uuid
+
+try:
+    from jinja2 import Environment, FileSystemLoader
+    JINJA_INSTALLED = True
+    import asyncio
+except ImportError:
+    JINJA_INSTALLED = False
+
+
+class Server:
+    SESSION_TIMEOUT: int = 8 * 3600  # 8 hours
+
+    def __init__(self) -> None:
+        if JINJA_INSTALLED:
+            self.env = Environment(loader=FileSystemLoader("templates"))
+
+        self.sessions: Dict[str, Dict[str, Any]] = {}
+        self.query_params: Dict[str, List[str]] = {}
+        self.body_params: Dict[str, List[str]] = {}
+        self.path_params: List[str] = []
+        self.session: Dict[str, Any] = {}
+        self.files: Dict[str, Any] = {}
+
+    async def __call__(self, scope, receive, send):
+        await self.asgi_app(scope, receive, send)
+
+    async def asgi_app(self, scope: Dict[str, Any], receive: Any, send: Any) -> None:
+        """ASGI application entrypoint for both HTTP and WebSockets."""
+
+        if scope["type"] == "http":
+            self.scope = scope
+            method = scope["method"]
+            path = scope["path"].lstrip("/")
+            path_parts = path.split("/") if path else []
+            func_name = path_parts[0] if path_parts else "index"
+            self.path_params = path_parts[1:] if len(path_parts) > 1 else []
+
+            handler_function = getattr(self, func_name, None)
+            if not handler_function:
+                self.path_params = path_parts
+                handler_function = getattr(self, "index", None)
+
+            raw_query = scope.get("query_string", b"")
+            self.query_params = parse_qs(raw_query.decode("utf-8", "ignore"))
+
+            headers_dict = {
+                k.decode("latin-1").lower(): v.decode("latin-1")
+                for k, v in scope.get("headers", [])
+            }
+            cookies = self._parse_cookies(headers_dict.get("cookie", ""))
+
+            session_id = cookies.get("session_id")
+            if session_id and session_id in self.sessions:
+                self.session = self.sessions[session_id]
+                self.session["last_access"] = time.time()
+            else:
+                self.session = {}
+
+            self.body_params = {}
+            self.files = {}
+            if method in ("POST", "PUT", "PATCH"):
+                body_data = bytearray()
+                while True:
+                    msg = await receive()
+                    if msg["type"] == "http.request":
+                        body_data += msg.get("body", b"")
+                        if not msg.get("more_body"):
+                            break
+                content_type = headers_dict.get("content-type", "")
+                if "multipart/form-data" in content_type:
+                    self.parse_multipart(bytes(body_data), content_type)
+                else:
+                    body_str = body_data.decode("utf-8", "ignore")
+                    self.body_params = parse_qs(body_str)
+
+            sig = inspect.signature(handler_function)
+            func_args = []
+            for param in sig.parameters.values():
+                if self.path_params:
+                    func_args.append(self.path_params.pop(0))
+                elif param.name in self.query_params:
+                    func_args.append(self.query_params[param.name][0])
+                elif param.name in self.body_params:
+                    func_args.append(self.body_params[param.name][0])
+                elif param.name in self.files:
+                    func_args.append(self.files[param.name])
+                elif param.name in self.session:
+                    func_args.append(self.session[param.name])
+                elif param.default is not param.empty:
+                    func_args.append(param.default)
+                else:
+                    await self._send_response(
+                        send,
+                        status_code=400,
+                        body=f"400 Bad Request: Missing required parameter '{param.name}'",
+                    )
+                    return
+
+            if handler_function == getattr(self, "index", None) and not func_args and path:
+                await self._send_response(send, status_code=404, body="404 Not Found")
+                return
+
+            try:
+                if inspect.iscoroutinefunction(handler_function):
+                    result = await handler_function(*func_args)
+                else:
+                    result = handler_function(*func_args)
+            except Exception as e:
+                print(f"Error processing request: {e}")
+                await self._send_response(
+                    send, status_code=500, body="500 Internal Server Error"
+                )
+                return
+
+            status_code = 200
+            response_body = result
+            extra_headers: List[Tuple[str, str]] = []
+
+            if isinstance(result, tuple):
+                if len(result) == 2:
+                    status_code, response_body = result
+                elif len(result) == 3:
+                    status_code, response_body, extra_headers = result
+                else:
+                    await self._send_response(
+                        send, status_code=500,
+                        body="500 Internal Server Error: Invalid response tuple"
+                    )
+                    return
+
+            if self.session:
+                session_id = cookies.get("session_id", str(uuid.uuid4()))
+                self.sessions[session_id] = self.session  # Store session only if used
+                extra_headers.append(("Set-Cookie", f"session_id={session_id}; Path=/; HttpOnly; SameSite=Strict"))
+
+            await self._send_response(
+                send,
+                status_code=status_code,
+                body=response_body,
+                extra_headers=extra_headers
+            )
+        else:
+            pass
+
+    def _parse_cookies(self, cookie_header: str) -> Dict[str, str]:
+        cookies: Dict[str, str] = {}
+        if not cookie_header:
+            return cookies
+        for cookie in cookie_header.split(";"):
+            if "=" in cookie:
+                k, v = cookie.strip().split("=", 1)
+                cookies[k] = v
+        return cookies
+
+    def parse_multipart(self, body: bytes, content_type: str) -> None:
+        boundary = None
+        parts = content_type.split(";")
+        for part in parts:
+            part = part.strip()
+            if part.startswith("boundary="):
+                boundary = part.split("=", 1)[1]
+                break
+
+        if not boundary:
+            raise ValueError("Boundary not found in Content-Type header.")
+
+        boundary_bytes = boundary.encode("utf-8")
+        delimiter = b"--" + boundary_bytes
+        sections = body.split(delimiter)
+        for section in sections:
+            if not section or section in (b"--", b"--\r\n"):
+                continue
+            if section.startswith(b"\r\n"):
+                section = section[2:]
+            if section.endswith(b"\r\n"):
+                section = section[:-2]
+            if section == b"--":
+                continue
+
+            try:
+                headers, content = section.split(b"\r\n\r\n", 1)
+            except ValueError:
+                continue
+
+            headers_list = headers.decode("utf-8", "ignore").split("\r\n")
+            header_dict = {}
+            for header_line in headers_list:
+                if ":" in header_line:
+                    key, value = header_line.split(":", 1)
+                    header_dict[key.strip().lower()] = value.strip()
+
+            disposition = header_dict.get("content-disposition", "")
+            disposition_parts = disposition.split(";")
+            disposition_dict = {}
+            for disp_part in disposition_parts:
+                if "=" in disp_part:
+                    k, v = disp_part.strip().split("=", 1)
+                    disposition_dict[k] = v.strip('"')
+
+            name = disposition_dict.get("name")
+            filename = disposition_dict.get("filename")
+
+            if filename:
+                file_content_type = header_dict.get("content-type", "application/octet-stream")
+                self.files[name] = {
+                    "filename": filename,
+                    "content_type": file_content_type,
+                    "data": content
+                }
+            elif name:
+                value = content.decode("utf-8", "ignore")
+                if name in self.body_params:
+                    self.body_params[name].append(value)
+                else:
+                    self.body_params[name] = [value]
+
+    async def _send_response(
+        self,
+        send,
+        status_code: int,
+        body,
+        extra_headers=None
+    ):
+        if extra_headers is None:
+            extra_headers = []
+
+        # Common HTTP status text
+        status_map = {
+            200: "200 OK",
+            206: "206 Partial Content",
+            302: "302 Found",
+            403: "403 Forbidden",
+            404: "404 Not Found",
+            500: "500 Internal Server Error",
+        }
+        # Fallback if not in map
+        status_text = status_map.get(status_code, f"{status_code} OK")
+
+        # Ensure there's a Content-Type unless already provided
+        has_content_type = any(h[0].lower() == "content-type" for h in extra_headers)
+        if not has_content_type:
+            extra_headers.append(("Content-Type", "text/html; charset=utf-8"))
+
+        # Send the initial response start
+        await send({
+            "type": "http.response.start",
+            "status": status_code,
+            "headers": [
+                (k.encode("latin-1"), v.encode("latin-1")) for k, v in extra_headers
+            ],
+        })
+
+        # 1) Check if body is an async generator (has __aiter__)
+        if hasattr(body, "__aiter__"):
+            async for chunk in body:
+                if isinstance(chunk, str):
+                    chunk = chunk.encode("utf-8")
+                await send({
+                    "type": "http.response.body",
+                    "body": chunk,
+                    "more_body": True
+                })
+            # Send a final empty chunk to mark the end
+            await send({
+                "type": "http.response.body",
+                "body": b"",
+                "more_body": False
+            })
+            return
+
+        # 2) Check if body is a *sync* generator (has __iter__) and
+        #    is not a plain string/bytes
+        if hasattr(body, "__iter__") and not isinstance(body, (bytes, str)):
+            for chunk in body:
+                if isinstance(chunk, str):
+                    chunk = chunk.encode("utf-8")
+                await send({
+                    "type": "http.response.body",
+                    "body": chunk,
+                    "more_body": True
+                })
+            # Send a final empty chunk
+            await send({
+                "type": "http.response.body",
+                "body": b"",
+                "more_body": False
+            })
+            return
+
+        if isinstance(body, str):
+            response_body = body.encode("utf-8")
+        elif isinstance(body, bytes):
+            response_body = body
+        else:
+            # Convert anything else to string then to bytes
+            response_body = str(body).encode("utf-8")
+
+        await send({
+            "type": "http.response.body",
+            "body": response_body,
+            "more_body": False
+        })
+
+    def cleanup_sessions(self) -> None:
+        now = time.time()
+        self.sessions = {
+            sid: data
+            for sid, data in self.sessions.items()
+            if data.get("last_access", now) + self.SESSION_TIMEOUT > now
+        }
+
+    def redirect(self, location: str) -> Tuple[int, str]:
+        return (
+            302,
+            (
+                "<html><head>"
+                f"<meta http-equiv='refresh' content='0;url={location}'>"
+                "</head></html>"
+            ),
+        )
+
+    async def render_template(self, name: str, **kwargs: Any) -> str:
+        """
+        Async-compatible template rendering using Jinja2.
+        """
+        if not JINJA_INSTALLED:
+            raise ImportError("Jinja2 is not installed.")
+
+        def render_sync():
+            return self.env.get_template(name).render(kwargs)
+
+        return await asyncio.get_event_loop().run_in_executor(None, render_sync)
+
diff --git a/examples/socketio/webtrc/__pycache__/MicroPie.cpython-310.pyc b/examples/socketio/webtrc/__pycache__/MicroPie.cpython-310.pyc
new file mode 100644
index 0000000..8083fa3
Binary files /dev/null and b/examples/socketio/webtrc/__pycache__/MicroPie.cpython-310.pyc differ
diff --git a/examples/socketio/webtrc/__pycache__/app.cpython-310.pyc b/examples/socketio/webtrc/__pycache__/app.cpython-310.pyc
new file mode 100644
index 0000000..d0554d6
Binary files /dev/null and b/examples/socketio/webtrc/__pycache__/app.cpython-310.pyc differ
diff --git a/examples/socketio/webtrc/app.py b/examples/socketio/webtrc/app.py
new file mode 100644
index 0000000..0cac768
--- /dev/null
+++ b/examples/socketio/webtrc/app.py
@@ -0,0 +1,131 @@
+import socketio
+from MicroPie import Server
+
+# 1) Create the Async Socket.IO server and wrap with an ASGI app.
+sio = socketio.AsyncServer(async_mode="asgi")
+
+# Keep track of "active" usernames for demonstration
+active_users = set()
+
+# 2) Create a MicroPie server class with routes
+class MyApp(Server):
+    async def index(self):
+        # A simple response for the root path
+        return 'Use /stream/*username* or /watch/*username*'
+
+    async def stream(self, username: str):
+        # Mark the username active, render the streamer template
+        active_users.add(username)
+        return await self.render_template("stream.html", username=username)
+
+    async def watch(self, username: str):
+        # Mark the username active, render the watcher template
+        return await self.render_template("watch.html", username=username)
+
+#
+# ------------------- Socket.IO Events for Signaling --------------------
+#
+
[email protected]
+async def connect(sid, environ):
+    print(f"[connect] Client connected: {sid}")
+
[email protected]
+async def disconnect(sid):
+    print(f"[disconnect] Client disconnected: {sid}")
+
[email protected]("join_room")
+async def join_room(sid, data):
+    """Each client (streamer or watcher) joins a room named after <username>."""
+    username = data.get("username")
+    if username:
+        active_users.add(username)
+        await sio.enter_room(sid, username)
+        print(f"[join_room] {sid} joined room '{username}'")
+
[email protected]("new_watcher")
+async def new_watcher(sid, data):
+    """
+    A watcher informs the server it wants to watch <username>.
+    We broadcast 'new_watcher' to the entire room except the watcher,
+    so the streamer sees there's a new viewer to create an offer for.
+    """
+    username = data.get("username")
+    watcher_sid = data.get("watcherSid")
+    print(f"[new_watcher] {watcher_sid} => watch {username}")
+    if username in active_users:
+        # Notify others in the room (specifically the streamer)
+        await sio.emit("new_watcher",
+                       {"watcherSid": watcher_sid},
+                       room=username,
+                       skip_sid=watcher_sid)
+
[email protected]("offer")
+async def handle_offer(sid, data):
+    """
+    The streamer sends an offer for a specific watcherSid.
+    We forward it directly to that watcherSid.
+    """
+    username = data.get("username")
+    watcher_sid = data.get("watcherSid")
+    offer_sdp = data.get("offer")
+    offer_type = data.get("offerType")
+
+    print(f"[offer] From streamer {sid} to watcher {watcher_sid}, room={username}")
+
+    # Send the offer ONLY to watcherSid (not the whole room)
+    await sio.emit("offer",
+                   {
+                       "offer": offer_sdp,
+                       "offerType": offer_type,
+                       "streamerSid": sid
+                   },
+                   to=watcher_sid)
+
[email protected]("answer")
+async def handle_answer(sid, data):
+    """
+    The watcher sends back an answer to the streamerSid.
+    Forward that to the streamer.
+    """
+    streamer_sid = data.get("streamerSid")
+    answer_sdp = data.get("answer")
+    answer_type = data.get("answerType")
+
+    print(f"[answer] From watcher {sid} to streamer {streamer_sid}")
+
+    await sio.emit("answer",
+                   {
+                       "answer": answer_sdp,
+                       "answerType": answer_type,
+                       "watcherSid": sid
+                   },
+                   to=streamer_sid)
+
[email protected]("ice-candidate")
+async def handle_ice_candidate(sid, data):
+    """
+    Either streamer or watcher can send ICE candidates. We relay them
+    to 'targetSid' so the two peers can complete their direct connection.
+    """
+    target_sid = data.get("targetSid")
+    candidate = data.get("candidate")
+    sdp_mid = data.get("sdpMid")
+    sdp_mline_index = data.get("sdpMLineIndex")
+
+    print(f"[ice-candidate] {sid} => {target_sid}")
+
+    if target_sid:
+        await sio.emit("ice-candidate",
+                       {
+                           "candidate": candidate,
+                           "sdpMid": sdp_mid,
+                           "sdpMLineIndex": sdp_mline_index,
+                           "senderSid": sid
+                       },
+                       to=target_sid)
+
+
+asgi_app = MyApp()
+app = socketio.ASGIApp(sio, asgi_app)
+
diff --git a/examples/socketio/webtrc/templates/index.html b/examples/socketio/webtrc/templates/index.html
new file mode 100644
index 0000000..b710858
--- /dev/null
+++ b/examples/socketio/webtrc/templates/index.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Webcam Streaming</title>
+</head>
+<body>
+    <h1>Webcam Streaming App</h1>
+    <form method="post" action="/submit">
+        <input type="text" name="username" placeholder="Enter username" required>
+        <button type="submit" name="action" value="Start Streaming">Start Streaming</button>
+        <button type="submit" name="action" value="Watch Stream">Watch Stream</button>
+    </form>
+</body>
+</html>
+
diff --git a/examples/socketio/webtrc/templates/stream.html b/examples/socketio/webtrc/templates/stream.html
new file mode 100644
index 0000000..7224a90
--- /dev/null
+++ b/examples/socketio/webtrc/templates/stream.html
@@ -0,0 +1,118 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>WebRTC Streaming (Streamer)</title>
+  <meta charset="UTF-8"/>
+  <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
+  <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
+</head>
+<body>
+  <h1>Streaming as {{ username }}</h1>
+  <video id="localVideo" autoplay playsinline muted style="transform: scaleX(-1);"></video>
+
+  <script>
+    const username = "{{ username }}";
+    const socket = io({ transports: ["websocket"] });
+
+    // Keep a PeerConnection for each watcherSid
+    const peerConnections = {};
+    let localStream = null;
+
+    socket.on("connect", () => {
+      console.log("[Streamer] Connected:", socket.id);
+      // Join the 'username' room
+      socket.emit("join_room", { username });
+      // Start local camera
+      startLocalCamera();
+    });
+
+    async function startLocalCamera() {
+      try {
+        // Video only; use audio: true if you want to stream mic audio too
+        localStream = await navigator.mediaDevices.getUserMedia({
+          video: true,
+          audio: true
+        });
+        document.getElementById("localVideo").srcObject = localStream;
+      } catch (err) {
+        console.error("Error accessing camera:", err);
+      }
+    }
+
+    // When a new watcher arrives, we create an offer for them
+    socket.on("new_watcher", (data) => {
+      const watcherSid = data.watcherSid;
+      console.log("[Streamer] new_watcher event:", watcherSid);
+      createOfferForWatcher(watcherSid);
+    });
+
+    async function createOfferForWatcher(watcherSid) {
+      console.log("[Streamer] Creating offer for", watcherSid);
+      const pc = new RTCPeerConnection({
+        iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
+      });
+      peerConnections[watcherSid] = pc;
+
+      // Add local tracks to this new PeerConnection
+      localStream.getTracks().forEach((track) => pc.addTrack(track, localStream));
+
+      // Send our ICE candidates to the server => watchers
+      pc.onicecandidate = (event) => {
+        if (event.candidate) {
+          socket.emit("ice-candidate", {
+            candidate: event.candidate.candidate,
+            sdpMid: event.candidate.sdpMid,
+            sdpMLineIndex: event.candidate.sdpMLineIndex,
+            targetSid: watcherSid
+          });
+        }
+      };
+
+      // Create an offer, set as local description
+      const offer = await pc.createOffer();
+      await pc.setLocalDescription(offer);
+
+      // Send the offer to that watcher
+      socket.emit("offer", {
+        username,
+        offer: offer.sdp,
+        offerType: offer.type,
+        watcherSid
+      });
+    }
+
+    // Handle "answer" from watchers
+    socket.on("answer", async (data) => {
+      const { answer, answerType, watcherSid } = data;
+      console.log("[Streamer] Received answer from watcherSid:", watcherSid);
+
+      const pc = peerConnections[watcherSid];
+      if (!pc) {
+        console.warn("[Streamer] PeerConnection not found for", watcherSid);
+        return;
+      }
+
+      const remoteDesc = new RTCSessionDescription({
+        type: answerType,
+        sdp: answer
+      });
+      await pc.setRemoteDescription(remoteDesc);
+    });
+
+    // Handle ICE candidates from watchers
+    socket.on("ice-candidate", (data) => {
+      const { candidate, sdpMid, sdpMLineIndex, senderSid } = data;
+      console.log("[Streamer] ICE candidate from", senderSid);
+      const pc = peerConnections[senderSid];
+      if (pc && candidate) {
+        pc.addIceCandidate(new RTCIceCandidate({
+          candidate,
+          sdpMid,
+          sdpMLineIndex
+        })).catch(err => console.error("Error adding ICE candidate:", err));
+      }
+    });
+  </script>
+</body>
+</html>
+
diff --git a/examples/socketio/webtrc/templates/stream.html~ b/examples/socketio/webtrc/templates/stream.html~
new file mode 100644
index 0000000..7224a90
--- /dev/null
+++ b/examples/socketio/webtrc/templates/stream.html~
@@ -0,0 +1,118 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>WebRTC Streaming (Streamer)</title>
+  <meta charset="UTF-8"/>
+  <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
+  <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
+</head>
+<body>
+  <h1>Streaming as {{ username }}</h1>
+  <video id="localVideo" autoplay playsinline muted style="transform: scaleX(-1);"></video>
+
+  <script>
+    const username = "{{ username }}";
+    const socket = io({ transports: ["websocket"] });
+
+    // Keep a PeerConnection for each watcherSid
+    const peerConnections = {};
+    let localStream = null;
+
+    socket.on("connect", () => {
+      console.log("[Streamer] Connected:", socket.id);
+      // Join the 'username' room
+      socket.emit("join_room", { username });
+      // Start local camera
+      startLocalCamera();
+    });
+
+    async function startLocalCamera() {
+      try {
+        // Video only; use audio: true if you want to stream mic audio too
+        localStream = await navigator.mediaDevices.getUserMedia({
+          video: true,
+          audio: true
+        });
+        document.getElementById("localVideo").srcObject = localStream;
+      } catch (err) {
+        console.error("Error accessing camera:", err);
+      }
+    }
+
+    // When a new watcher arrives, we create an offer for them
+    socket.on("new_watcher", (data) => {
+      const watcherSid = data.watcherSid;
+      console.log("[Streamer] new_watcher event:", watcherSid);
+      createOfferForWatcher(watcherSid);
+    });
+
+    async function createOfferForWatcher(watcherSid) {
+      console.log("[Streamer] Creating offer for", watcherSid);
+      const pc = new RTCPeerConnection({
+        iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
+      });
+      peerConnections[watcherSid] = pc;
+
+      // Add local tracks to this new PeerConnection
+      localStream.getTracks().forEach((track) => pc.addTrack(track, localStream));
+
+      // Send our ICE candidates to the server => watchers
+      pc.onicecandidate = (event) => {
+        if (event.candidate) {
+          socket.emit("ice-candidate", {
+            candidate: event.candidate.candidate,
+            sdpMid: event.candidate.sdpMid,
+            sdpMLineIndex: event.candidate.sdpMLineIndex,
+            targetSid: watcherSid
+          });
+        }
+      };
+
+      // Create an offer, set as local description
+      const offer = await pc.createOffer();
+      await pc.setLocalDescription(offer);
+
+      // Send the offer to that watcher
+      socket.emit("offer", {
+        username,
+        offer: offer.sdp,
+        offerType: offer.type,
+        watcherSid
+      });
+    }
+
+    // Handle "answer" from watchers
+    socket.on("answer", async (data) => {
+      const { answer, answerType, watcherSid } = data;
+      console.log("[Streamer] Received answer from watcherSid:", watcherSid);
+
+      const pc = peerConnections[watcherSid];
+      if (!pc) {
+        console.warn("[Streamer] PeerConnection not found for", watcherSid);
+        return;
+      }
+
+      const remoteDesc = new RTCSessionDescription({
+        type: answerType,
+        sdp: answer
+      });
+      await pc.setRemoteDescription(remoteDesc);
+    });
+
+    // Handle ICE candidates from watchers
+    socket.on("ice-candidate", (data) => {
+      const { candidate, sdpMid, sdpMLineIndex, senderSid } = data;
+      console.log("[Streamer] ICE candidate from", senderSid);
+      const pc = peerConnections[senderSid];
+      if (pc && candidate) {
+        pc.addIceCandidate(new RTCIceCandidate({
+          candidate,
+          sdpMid,
+          sdpMLineIndex
+        })).catch(err => console.error("Error adding ICE candidate:", err));
+      }
+    });
+  </script>
+</body>
+</html>
+
diff --git a/examples/socketio/webtrc/templates/watch.html b/examples/socketio/webtrc/templates/watch.html
new file mode 100644
index 0000000..56a2378
--- /dev/null
+++ b/examples/socketio/webtrc/templates/watch.html
@@ -0,0 +1,116 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>WebRTC Watcher</title>
+  <meta charset="UTF-8"/>
+  <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
+  <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
+</head>
+<body>
+  <h1>Watching: {{ username }}</h1>
+
+  <!-- Make sure we have muted, controls, playsinline, and autoplay -->
+  <video
+    id="remoteVideo"
+    playsinline
+    autoplay
+    controls
+    muted
+    style="width: 640px; background: black;">
+  </video>
+
+  <script>
+    const username = "{{ username }}";
+    const socket = io({ transports: ["websocket"] });
+
+    let peerConnection = null;
+    const remoteVideo = document.getElementById("remoteVideo");
+
+    socket.on("connect", () => {
+      console.log("[Watcher] Connected:", socket.id);
+      // Join the same room
+      socket.emit("join_room", { username });
+      // Let the streamer know we want to watch
+      socket.emit("new_watcher", { watcherSid: socket.id, username });
+    });
+
+    // 1) Receive Offer from the streamer
+    socket.on("offer", async (data) => {
+      console.log("[Watcher] Received offer from:", data.streamerSid);
+
+      if (!peerConnection) {
+        peerConnection = new RTCPeerConnection({
+          iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
+        });
+
+        // 2) Handle local ICE candidates -> send to streamer
+        peerConnection.onicecandidate = (event) => {
+          if (event.candidate) {
+            socket.emit("ice-candidate", {
+              candidate: event.candidate.candidate,
+              sdpMid: event.candidate.sdpMid,
+              sdpMLineIndex: event.candidate.sdpMLineIndex,
+              targetSid: data.streamerSid
+            });
+          }
+        };
+
+        // 3) When remote tracks arrive
+        peerConnection.ontrack = (event) => {
+          console.log("[Watcher] ontrack =>", event.streams[0]);
+
+          // Attach the incoming stream to <video>
+          remoteVideo.srcObject = event.streams[0];
+          console.log("[Watcher] remoteVideo.srcObject set:", remoteVideo.srcObject);
+
+          // Explicitly call .play() to handle auto-play policy
+          remoteVideo.play().then(() => {
+            console.log("[Watcher] remoteVideo is playing");
+          }).catch(err => {
+            console.error("[Watcher] remoteVideo play() error:", err);
+          });
+        };
+      }
+
+      try {
+        // 4) Set remote description
+        const desc = new RTCSessionDescription({
+          type: data.offerType,
+          sdp: data.offer
+        });
+        await peerConnection.setRemoteDescription(desc);
+        console.log("[Watcher] setRemoteDescription done");
+
+        // 5) Create and send answer
+        const answer = await peerConnection.createAnswer();
+        await peerConnection.setLocalDescription(answer);
+        console.log("[Watcher] created answer, setLocalDescription done");
+
+        socket.emit("answer", {
+          answer: answer.sdp,
+          answerType: answer.type,
+          streamerSid: data.streamerSid
+        });
+        console.log("[Watcher] answer emitted");
+      } catch (err) {
+        console.error("[Watcher] Error handling offer/answer:", err);
+      }
+    });
+
+    // 6) ICE candidates from streamer -> add to our PeerConnection
+    socket.on("ice-candidate", (data) => {
+      const { candidate, sdpMid, sdpMLineIndex, senderSid } = data;
+      console.log("[Watcher] ICE candidate from", senderSid);
+
+      if (peerConnection && candidate) {
+        peerConnection.addIceCandidate(new RTCIceCandidate({
+          candidate,
+          sdpMid,
+          sdpMLineIndex
+        })).catch(err => console.error("[Watcher] addIceCandidate error:", err));
+      }
+    });
+  </script>
+</body>
+</html>
+
diff --git a/setup.py b/setup.py
index 5499588..23f1958 100644
--- a/setup.py
+++ b/setup.py
@@ -25,7 +25,7 @@ Links
 from distutils.core import setup
 
 setup(name="MicroPie",
-    version="0.9",
+    version="0.9.1",
     description="A ultra micro web framework w/ Jinja2.",
     long_description=__doc__,
     author="Harrison Erd",