diff --git a/.gitignore b/.gitignore index 2a14f5f..05d3e51 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,8 @@ junit.xml .wrangler/ # LLM context docs -.llms \ No newline at end of file +.llms + +# Agent working files +.swarm/ +.claude/ diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..0d9658f --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,10 @@ +# Stream Kit Architecture + +The canonical architecture document now lives at [`docs/architecture.md`](./docs/architecture.md). + +For the latest repo map and current status, start with: + +- [`docs/index.md`](./docs/index.md) +- [`docs/architecture.md`](./docs/architecture.md) +- [`docs/integration.md`](./docs/integration.md) +- [`docs/status.md`](./docs/status.md) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a4dd059 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,61 @@ +# AGENTS.md + +## Project + +- **Name:** Stream Kit +- **Description:** OGS cloud rendering and remote streaming workspace +- **Tech stack:** TypeScript, pnpm workspaces, Turbo, Vitest, Cloudflare Workers, Durable Objects, Containers, PeerJS, Puppeteer +- **Package manager:** pnpm +- **Source layout:** + - `packages/` — reusable SDK and testing packages + - `examples/bun-stream-server/` — working Cloudflare runtime example + - `docs/` — repo map, integration guidance, and current status + +## Feedback Commands + +Run in this order before committing: + +1. `pnpm test` +2. `pnpm typecheck` +3. `pnpm build` + +## Knowledge Base + +Start here. Load deeper docs only when needed. + +| Topic | Location | +|---|---| +| Docs catalog | [docs/index.md](docs/index.md) | +| Architecture | [docs/architecture.md](docs/architecture.md) | +| OGS integration and target SDK shape | [docs/integration.md](docs/integration.md) | +| Current verified state and next steps | [docs/status.md](docs/status.md) | +| Cloudflare example walkthrough | [examples/bun-stream-server/README.md](examples/bun-stream-server/README.md) | +| Workspace overview | [README.md](README.md) | + +> Progressive disclosure: do not load all docs up front. Start with this file, then open only the doc that matches the task. + +## Current Reality + +- The deployed Cloudflare example is working end to end. +- The package layer is still less mature than the example runtime. +- The intended product direction is OGS-owned infrastructure with a simpler client-facing `stream-kit` SDK. +- `app-bridge` should likely be hidden inside the eventual OGS SDK experience rather than exposed as the primary developer API. + +## Boundaries + +- `stream-kit` should own streaming runtime and SDK behavior. +- `opengame-api` should own the public control plane. +- `opengame-app` should own native cast and app-shell UX. + +## Key Conventions + +- Prefer `rg` for search. +- Use `apply_patch` for manual code edits. +- Never revert user changes unless explicitly asked. +- Treat `examples/bun-stream-server/` as the source of truth for end-to-end behavior. +- Keep docs aligned with verified behavior; if the architecture changes, update `docs/`. + +## Git + +- Current branch work should go through a `codex/*` branch for PR prep. +- Keep commits focused and descriptive. diff --git a/README.md b/README.md index 3f88940..3269480 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,63 @@ -# ☁️ @open-game-system/stream-kit +# @open-game-system/stream-kit -Monorepo for the Open Game System (OGS) Cloud Rendering service ("Stream Kit"). +Stream Kit is the Open Game System's cloud rendering and remote streaming workspace. -[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) +Today this repo contains two layers: -## Overview +- the reusable SDK packages under [`packages/`](./packages) +- the working Cloudflare streaming runtime under [`examples/bun-stream-server/`](./examples/bun-stream-server) -Stream Kit enables web games to offload intensive graphics rendering to powerful cloud servers and stream the output via WebRTC to the client. This allows for high-fidelity experiences even on devices with limited GPU capabilities. +The recent Cloudflare work is now verified end to end: the deployed example successfully boots a container, launches Chromium, captures a page, and delivers playable video to the receiver over WebRTC using Cloudflare TURN. -This monorepo contains the core libraries, React bindings, and server implementation for the Stream Kit service. +## Read This First -## Packages +- Repo map: [`docs/index.md`](./docs/index.md) +- Architecture: [`docs/architecture.md`](./docs/architecture.md) +- OGS integration guide: [`docs/integration.md`](./docs/integration.md) +- Current status and next steps: [`docs/status.md`](./docs/status.md) -- [`@open-game-system/stream-kit-types`](packages/stream-kit-types): Core TypeScript types. -- [`@open-game-system/stream-kit-web`](packages/stream-kit-web): Core client library for web browsers. -- [`@open-game-system/stream-kit-react`](packages/stream-kit-react): React hooks and components. -- [`@open-game-system/stream-kit-server`](packages/stream-kit-server): Server-side implementation (headless browser management, signaling). +## What This Repo Is For -## Development +The long-term product direction is: + +- third-party web games integrate a small `stream-kit` SDK +- `stream-kit` hides the `app-bridge` details when running inside `opengame-app` +- `opengame-api` exposes the product-facing control plane +- OGS-owned Cloudflare Workers/Containers run the streaming infrastructure + +That means third-party developers should not need to deploy their own Workers, TURN servers, or container stacks just to use cloud rendering in the OGS ecosystem. + +## What Works Today -This project uses `pnpm` workspaces and `turbo` for managing the monorepo. +- Local streaming works through the Bun/container example. +- Deployed streaming works through the Cloudflare example. +- Cloudflare TURN-backed WebRTC connectivity is working in the deployed path. +- Session routing is isolated per receiver session. +- Worker seam tests cover TURN normalization, debug auth, and session routing. + +## Workspace Layout + +- [`packages/stream-kit-types`](./packages/stream-kit-types): shared TypeScript types +- [`packages/stream-kit-web`](./packages/stream-kit-web): browser-side `RenderStream` client primitives +- [`packages/stream-kit-react`](./packages/stream-kit-react): React bindings around an existing stream +- [`packages/stream-kit-server`](./packages/stream-kit-server): experimental server-side abstractions +- [`packages/stream-kit-testing`](./packages/stream-kit-testing): testing helpers +- [`examples/bun-stream-server`](./examples/bun-stream-server): working Cloudflare Worker/DO/container example + +## Development -1. **Install Dependencies:** - ```bash - pnpm install - ``` -2. **Build all packages:** - ```bash - pnpm build - ``` -3. **Run tests:** - ```bash - pnpm test - ``` +```bash +pnpm install +pnpm build +pnpm test +pnpm typecheck +``` -## Contributing +## Notes -Contributions are welcome! Please see `CONTRIBUTING.md` (to be created). +- The reusable package API is still behind the working Cloudflare example in product maturity. +- The current OGS-facing SDK shape described in `docs/integration.md` is a target design, not a fully published package contract yet. ## License -MIT License. \ No newline at end of file +MIT diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..f0555d6 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,92 @@ +# Stream Kit Architecture + +## Repo Shape + +This repository currently contains two related but distinct layers: + +1. `packages/` — reusable SDK and testing primitives +2. `examples/bun-stream-server/` — the working Cloudflare runtime + +The example is the truth for deployed behavior today. The package layer is the productization path. + +## Current Working Runtime + +The deployed runtime lives in [`examples/bun-stream-server/`](../examples/bun-stream-server). + +Main pieces: + +- [`src/index.ts`](../examples/bun-stream-server/src/index.ts): Worker entrypoint, TURN minting, session routing, DO proxying +- [`container/src/server.ts`](../examples/bun-stream-server/container/src/server.ts): browser lifecycle and container HTTP server +- [`container/extension/`](../examples/bun-stream-server/container/extension): tab capture and PeerJS sender +- [`receiver.html`](../examples/bun-stream-server/receiver.html): browser receiver used for local and deployed testing + +## Deployed Flow + +```mermaid +flowchart LR + Game["Receiver / OGS client"] -->|"GET /ice-servers"| Worker["Cloudflare Worker"] + Game -->|"POST /start-stream"| Worker + Worker --> DO["Durable Object"] + DO --> Container["Cloudflare Container"] + Container --> Chromium["Chromium + extension"] + Chromium -->|"WebRTC media"| Game +``` + +Detailed flow: + +1. The receiver creates a session-scoped ID and PeerJS receiver peer. +2. The receiver asks the Worker for Cloudflare TURN credentials. +3. The receiver posts `/start-stream` with the target URL, receiver peer ID, and session header. +4. The Worker routes that request to a session-scoped Durable Object. +5. The Durable Object starts or reuses a container for that session. +6. The container launches Chromium, loads the target page, and captures the tab through the extension. +7. The extension creates a sender peer and calls the receiver using the same ICE configuration. +8. The receiver attaches the returned media to the page and plays the stream. + +## OGS Product Direction + +The intended product architecture is different from the standalone example: + +- `opengame-app` owns native app UX and cast picker integration. +- `app-bridge` is the transport between the WebView game and the native app. +- `stream-kit` should expose the game-developer-facing SDK and hide most `app-bridge` details. +- `opengame-api` should expose public stream and cast session APIs. +- OGS-owned Cloudflare infrastructure should run the actual streaming runtime. + +## Boundary Recommendation + +### `stream-kit` + +Should own: + +- client SDK primitives +- stream session client behavior +- React components/hooks +- app-bridge integration adapters +- streaming runtime implementation + +Should not be the public place where third-party developers manage infrastructure. + +### `opengame-api` + +Should own: + +- authenticated public API +- developer/game authorization +- entitlement and quota checks +- stream or cast session lifecycle endpoints +- usage accounting and audit trails + +### `opengame-app` + +Should own: + +- native cast UI +- session UX +- native capability bridging + +## Near-Term Reality + +- The deployed example works. +- The package API is not yet the polished OGS SDK shape. +- The internal architecture is now good enough to power a productized API, but that public API still needs to be designed and implemented. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..1bcdbd3 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,16 @@ +# Stream Kit Docs + +Start here, then load only the document you need. + +| Topic | Location | +|---|---| +| Architecture and repo boundaries | [architecture.md](./architecture.md) | +| OGS integration model and third-party developer story | [integration.md](./integration.md) | +| Current verified state and next steps | [status.md](./status.md) | +| Cloudflare example walkthrough | [../examples/bun-stream-server/README.md](../examples/bun-stream-server/README.md) | + +## Quick Summary + +- The Cloudflare example is the current source of truth for end-to-end streaming. +- The long-term product should be OGS-owned infrastructure, not self-hosting by third-party game developers. +- `stream-kit` should become the simple client SDK layer, likely hiding `app-bridge` internally when running inside `opengame-app`. diff --git a/docs/integration.md b/docs/integration.md new file mode 100644 index 0000000..749e4a8 --- /dev/null +++ b/docs/integration.md @@ -0,0 +1,107 @@ +# OGS Integration Guide + +## Intended Developer Experience + +For a third-party web game inside the OGS app, the developer should ideally: + +1. install `@open-game-system/stream-kit` or `@open-game-system/stream-kit-react` +2. wrap the app in an OGS provider +3. add a `CastButton`, `StreamCanvas`, or similar primitive +4. let OGS handle native bridge wiring, backend orchestration, and Cloudflare runtime management + +The developer should not need to run their own Workers, TURN servers, or Cloudflare containers. + +## Dependency Model + +Recommended model: + +- `stream-kit` depends on `app-bridge` internally +- `stream-kit` exposes the high-level developer API +- `app-bridge` remains mostly an implementation detail + +That keeps the public mental model simple while still using the existing `opengame-app` bridge architecture. + +## Theoretical Getting Started + +### React + +```tsx +import { + OGSProvider, + CastButton, + StreamCanvas, +} from "@open-game-system/stream-kit-react"; + +export function App() { + return ( + + + + + ); +} +``` + +### Non-React + +```ts +import { createOGSClient } from "@open-game-system/stream-kit"; + +const ogs = createOGSClient({ + gameId: "your-game-id", + environment: "production", +}); + +await ogs.cast.showPicker(); + +const stream = await ogs.streaming.createStream({ + route: "/render/world", +}); + +stream.mount(document.getElementById("stream-container")!); +``` + +## How This Should Work Under The Hood + +1. `stream-kit` detects whether it is running inside `opengame-app`. +2. If available, it uses `app-bridge` to talk to the native shell. +3. For cast actions, it dispatches native bridge events rather than exposing raw bridge APIs to the game developer. +4. For cloud rendering, it calls OGS-owned APIs that provision stream sessions on OGS-owned Cloudflare infrastructure. + +## Recommended Public API Shape + +React surface: + +- `OGSProvider` +- `CastButton` +- `StreamCanvas` +- `useCastSession()` +- `useStreamingSupport()` +- `useTVStream()` + +Non-React surface: + +- `createOGSClient()` +- `ogs.cast.showPicker()` +- `ogs.cast.subscribe()` +- `ogs.streaming.createStream()` +- `ogs.streaming.startTVStream()` + +## What OGS Should Own + +- `opengame-app`: native cast/session UX +- `opengame-api`: public control plane endpoints +- OGS Cloudflare Workers/Containers: streaming runtime + +## What Is Already True In The Codebase + +- `opengame-app` already exposes native cast state and `SHOW_CAST_PICKER` through `app-bridge`. +- `stream-kit` already has a working Cloudflare runtime example. +- `opengame-api` does not yet expose cast or stream control-plane endpoints. + +## What Still Needs To Be Built + +- a polished public `stream-kit` SDK API +- `opengame-api` stream/cast session endpoints +- a supported integration path from the OGS app to the stream runtime +- docs that distinguish current implementation from target public SDK shape diff --git a/docs/status.md b/docs/status.md new file mode 100644 index 0000000..6696e45 --- /dev/null +++ b/docs/status.md @@ -0,0 +1,59 @@ +# Stream Kit Status + +## What Has Been Completed + +Recent verified work in this repo: + +- Cloudflare deployed streaming now works end to end. +- Cloudflare TURN credentials are minted and normalized by the Worker. +- The receiver and sender both use the same ICE configuration. +- Worker tracing and debug tooling were added to diagnose deployed failures. +- Worker seam tests cover TURN normalization, debug endpoint auth, and session routing. +- Session routing now uses a per-receiver session ID instead of a single shared singleton. +- The `pnpm test` hang caused by watch mode in `packages/stream-kit-web` was fixed. + +Relevant commits: + +- `08235c9` Add Cloudflare TURN-backed deployed streaming +- `756091f` Harden Worker boundaries and add seam tests +- `2edf3f4` Isolate streamed sessions per receiver + +## What Is Working Today + +- Local Bun/container example +- Local receiver playback +- Deployed Worker + Durable Object + container boot +- Chromium page navigation inside the container +- WebRTC media delivery in the deployed environment + +## Main Risks / Gaps + +- `/ice-servers` is still public and should be hardened before wider rollout. +- The Cloudflare example is still the source of truth; the reusable package surface is behind it. +- Current docs and package READMEs still need further productization cleanup so they match the desired OGS SDK story. + +## What Needs To Be Done Next + +### Productization + +- define the public `stream-kit` SDK API +- decide exactly how `app-bridge` is hidden inside the SDK +- decide whether the stream runtime remains a sibling service or becomes a formal internal package + +### OGS Integration + +- add `opengame-api` endpoints for stream/cast session lifecycle +- define app-to-backend session flow for OGS-owned infrastructure +- define third-party developer configuration and game registration needs + +### Hardening + +- gate `/ice-servers` so TURN credentials cannot be minted anonymously +- decide whether `/debug-state` stays debug-only or is removed in production +- add more seam/integration coverage around stream startup and runtime failures + +### Docs + +- align package-level READMEs with the current OGS direction +- publish a first-class getting started guide for React and non-React games +- document the exact Worker secrets and deployment assumptions for the example diff --git a/examples/bun-stream-server/README.md b/examples/bun-stream-server/README.md index 1c37035..22ce27e 100644 --- a/examples/bun-stream-server/README.md +++ b/examples/bun-stream-server/README.md @@ -1,99 +1,59 @@ -# Stream Kit Bun Server Example +# Stream Kit Cloudflare Example -This example demonstrates using Stream Kit with a Bun server that can stream web pages using Puppeteer and Chrome extensions. +This example is the current source of truth for end-to-end Stream Kit behavior. -## Quick Start - -### Local Development (without Docker) -```bash -bun install -bun run src/index.ts -``` - -### Docker Container (with tab capture & WebRTC fixes) -```bash -cd container -docker build -t stream-container . -# Map both HTTP port and UDP port range for WebRTC -docker run -p 8080:8080 -p 40000-40100:40000-40100/udp stream-container -``` - -## Architecture - -The system consists of: - -1. **Container Server** (`container/src/server.ts`) - Manages Puppeteer browser instances with Chrome extension -2. **Chrome Extension** (`container/extension/`) - Handles tab capture and WebRTC streaming -3. **Receiver HTML** (`receiver.html`) - Web interface for receiving and displaying streams +It demonstrates: -## Docker Fixes +- a Cloudflare Worker control plane +- a Durable Object per stream session +- a Cloudflare Container running Chromium +- a Chrome extension that captures the rendered tab +- a browser receiver that plays the returned WebRTC media -### Tab Capture Fix +The deployed path now works with Cloudflare TURN-backed connectivity. -**Problem**: Chrome headless mode in Docker containers cannot properly capture tab content, resulting in empty video streams. - -**Solution**: -- Use virtual display (Xvfb) instead of headless mode -- Enable proper graphics acceleration flags -- Add window manager (fluxbox) for proper rendering context - -### WebRTC Networking Fix - -**Problem**: WebRTC peer-to-peer connections fail in Docker containers due to network isolation. Video streams connect but never receive data (readyState stays 0). +## Quick Start -**Solution**: -- Configure TURN servers in PeerJS for both sender and receiver -- Expose UDP port range (40000-40100) for WebRTC media traffic -- Use proper Chrome flags for media capture in containerized environment +### Local receiver + local container -### What was changed: +```bash +cd examples/bun-stream-server/container +bun install +bun run src/server.ts +``` -1. **Dockerfile**: Added Xvfb, X11VNC, and fluxbox packages -2. **Startup script**: Initializes virtual display before starting Chrome -3. **Chrome flags**: Use `headless: false` with virtual display, enable GPU acceleration -4. **Environment**: Set `DISPLAY=:0` for virtual X11 session +Then open [`receiver.html`](./receiver.html) and point it at `http://localhost:8080`. -### Testing the fix: +### Cloudflare deploy -1. Build and run the Docker container: ```bash -cd container -docker build -t stream-container . -# Include UDP port mapping for WebRTC media connections -docker run -p 8080:8080 -p 40000-40100:40000-40100/udp stream-container +cd examples/bun-stream-server +npx wrangler deploy ``` -2. Open `receiver.html` in your browser - -3. Enter a URL (e.g., `https://www.nytimes.com`) and click "Start Stream & Listen" +Required Worker secrets: -4. You should now see actual video content instead of a spinning/waiting state +- `CLOUDFLARE_TURN_API_TOKEN` +- `CLOUDFLARE_TURN_KEY_ID` +- optional `DEBUG_STATE_TOKEN` -### Debugging: +## Main Endpoints -If you still have issues, check the container logs for: -- `[TAB_CAPTURE]` messages showing stream details -- Video track settings (width, height, frameRate) -- Chrome launch success with virtual display +- `GET /health` +- `GET /ice-servers` +- `POST /start-stream` +- `GET /debug-state` when authorized -Expected log output: -``` -🔍 Starting basic browser launch mode: virtual display -✅ Test 1 PASSED: Basic browser launch works -[TAB_CAPTURE] Video track details: {width: 1920, height: 1080, frameRate: 30} -``` +## Current Notes -## Usage +- The Worker uses `x-stream-session-id` to isolate sessions. +- TURN credentials are short-lived and currently minted for the receiver on demand. +- The receiver is still a debugging/demo page, not yet the final OGS SDK surface. -1. Start the container server -2. Open `receiver.html` in a web browser -3. Enter the URL you want to stream -4. Click "Start Stream & Listen" -5. The receiver will automatically connect and display the live stream +## Product Direction -## Files +This example proves the runtime. The intended OGS product should eventually hide this infrastructure behind: -- `src/index.ts` - Main Bun server -- `container/` - Docker container setup -- `receiver.html` - Stream receiver interface -- `test-*.js` - Test scripts for development +- OGS-owned APIs +- a simpler `stream-kit` SDK +- `opengame-app` integration through `app-bridge` diff --git a/examples/bun-stream-server/container/Dockerfile b/examples/bun-stream-server/container/Dockerfile index 7b64a6c..f95e7c5 100644 --- a/examples/bun-stream-server/container/Dockerfile +++ b/examples/bun-stream-server/container/Dockerfile @@ -1,5 +1,5 @@ -# Use Debian ARM64 which has the actual Chromium binary (not a snap wrapper) -FROM --platform=linux/arm64 debian:bullseye-slim +# Cloudflare Containers require linux/amd64 +FROM --platform=linux/amd64 debian:bullseye-slim # Prevent interactive prompts during package installation ENV DEBIAN_FRONTEND=noninteractive @@ -59,4 +59,4 @@ COPY . . EXPOSE 8080 # Expose UDP port range for WebRTC media connections EXPOSE 40000-40100/udp -CMD ["tsx", "src/server.ts"] \ No newline at end of file +CMD ["npm", "start"] \ No newline at end of file diff --git a/examples/bun-stream-server/container/extension/streaming.js b/examples/bun-stream-server/container/extension/streaming.js index 2815268..f0f338b 100644 --- a/examples/bun-stream-server/container/extension/streaming.js +++ b/examples/bun-stream-server/container/extension/streaming.js @@ -68,9 +68,24 @@ window.streamingDebug = { callCount: 0, scriptLoadTime: new Date().toISOString(), peerJsAvailable: typeof Peer !== 'undefined', - chromeTabCaptureAvailable: typeof chrome !== 'undefined' && typeof chrome.tabCapture !== 'undefined' + chromeTabCaptureAvailable: typeof chrome !== 'undefined' && typeof chrome.tabCapture !== 'undefined', + captureDiagnostics: null, + testVideoDiagnostics: null, + eventLog: [] }; +function recordDebugEvent(event, details = {}) { + const entry = { + event, + details, + timestamp: new Date().toISOString() + }; + window.streamingDebug.eventLog.push(entry); + if (window.streamingDebug.eventLog.length > 40) { + window.streamingDebug.eventLog.shift(); + } +} + console.log('[INIT] streaming.js loaded successfully'); console.log('[INIT] PeerJS available:', window.streamingDebug.peerJsAvailable); console.log('[INIT] Chrome tabCapture available:', window.streamingDebug.chromeTabCaptureAvailable); @@ -94,6 +109,7 @@ function addConnection(peerId) { // Update debug info window.streamingDebug.addConnectionCalled = true; window.streamingDebug.lastPeerId = peerId; + recordDebugEvent('connection_added', { peerId, size: window.activeConnections.size }); } /** @@ -107,6 +123,7 @@ function removeConnection(peerId) { const wasPresent = window.activeConnections.has(peerId); window.activeConnections.delete(peerId); + recordDebugEvent('connection_removed', { peerId, wasPresent, size: window.activeConnections.size }); console.log(`[CONNECTION] Was peer present before removal: ${wasPresent}`); console.log(`[CONNECTION] activeConnections after remove:`, Array.from(window.activeConnections)); @@ -114,7 +131,7 @@ function removeConnection(peerId) { console.log(`[CONNECTION] Connection closed to ${peerId}. Active: ${window.activeConnections.size}`); } -async function INITIALIZE({ srcPeerId, destPeerId }) { +async function INITIALIZE({ srcPeerId, destPeerId, iceServers = [] }) { console.log(`[INITIALIZE] ========== STARTING INITIALIZATION ==========`); console.log(`[INITIALIZE] Function called with params:`, { srcPeerId, destPeerId }); console.log(`[INITIALIZE] srcPeerId type: ${typeof srcPeerId}, value: "${srcPeerId}"`); @@ -125,6 +142,9 @@ async function INITIALIZE({ srcPeerId, destPeerId }) { window.streamingDebug.callCount++; window.streamingDebug.lastPeerId = destPeerId; window.streamingDebug.lastError = null; // Reset error state + window.streamingDebug.captureDiagnostics = null; + window.streamingDebug.testVideoDiagnostics = null; + recordDebugEvent('initialize_called', { srcPeerId, destPeerId, iceServerCount: Array.isArray(iceServers) ? iceServers.length : 0 }); console.log(`[INITIALIZE] Updated debug info - call count: ${window.streamingDebug.callCount}`); console.log(`[INITIALIZE] Current activeConnections:`, Array.from(window.activeConnections)); @@ -355,6 +375,22 @@ async function INITIALIZE({ srcPeerId, destPeerId }) { settings: track.getSettings ? track.getSettings() : 'N/A' }); }); + window.streamingDebug.captureDiagnostics = { + streamId: stream.id, + active: stream.active, + trackCount: stream.getTracks().length, + videoTrackCount: stream.getVideoTracks().length, + audioTrackCount: stream.getAudioTracks().length, + tracks: stream.getTracks().map((track) => ({ + kind: track.kind, + label: track.label, + enabled: track.enabled, + readyState: track.readyState, + muted: track.muted, + settings: track.getSettings ? track.getSettings() : null + })) + }; + recordDebugEvent('capture_stream_created', window.streamingDebug.captureDiagnostics); // Test if the stream is actually producing data if (stream.getVideoTracks().length > 0) { @@ -382,25 +418,60 @@ async function INITIALIZE({ srcPeerId, destPeerId }) { console.error('[TAB_CAPTURE] 2. Extension permissions not properly granted'); console.error('[TAB_CAPTURE] 3. Chrome tab capture API not working in this environment'); } + window.streamingDebug.captureDiagnostics = { + ...window.streamingDebug.captureDiagnostics, + videoTrackDetails: { + width: videoSettings.width || 0, + height: videoSettings.height || 0, + frameRate: videoSettings.frameRate || null, + aspectRatio: videoSettings.aspectRatio || null, + zeroDimensions: videoSettings.width === 0 || videoSettings.height === 0 + } + }; + recordDebugEvent('capture_video_track_inspected', window.streamingDebug.captureDiagnostics.videoTrackDetails); // Create a test video element to verify the stream works try { const testVideo = document.createElement('video'); testVideo.srcObject = stream; testVideo.muted = true; // Required for autoplay + window.streamingDebug.testVideoDiagnostics = { + attached: true, + playStarted: false, + waiting: false, + stalled: false, + metadataLoaded: false, + videoWidth: 0, + videoHeight: 0, + hasNonZeroPixels: null, + samplePixels: null, + error: null + }; testVideo.onloadedmetadata = () => { - console.log('[TAB_CAPTURE] Test video metadata loaded:', JSON.stringify({ + const metadata = { videoWidth: testVideo.videoWidth, videoHeight: testVideo.videoHeight, duration: testVideo.duration, readyState: testVideo.readyState, networkState: testVideo.networkState - })); + }; + console.log('[TAB_CAPTURE] Test video metadata loaded:', JSON.stringify(metadata)); + window.streamingDebug.testVideoDiagnostics = { + ...window.streamingDebug.testVideoDiagnostics, + metadataLoaded: true, + ...metadata + }; + recordDebugEvent('test_video_metadata_loaded', metadata); // Try to play the video to see if it actually has content testVideo.play().then(() => { console.log('[TAB_CAPTURE] ✅ Test video can play'); + window.streamingDebug.testVideoDiagnostics = { + ...window.streamingDebug.testVideoDiagnostics, + playStarted: true + }; + recordDebugEvent('test_video_play_started'); // Check if video is actually updating by sampling pixels setTimeout(() => { @@ -412,38 +483,90 @@ async function INITIALIZE({ srcPeerId, destPeerId }) { ctx.drawImage(testVideo, 0, 0); const imageData = ctx.getImageData(0, 0, 10, 10); const hasNonZeroPixels = imageData.data.some(pixel => pixel > 0); + const samplePixels = Array.from(imageData.data.slice(0, 20)); console.log('[TAB_CAPTURE] Video width:', testVideo.videoWidth); console.log('[TAB_CAPTURE] Video height:', testVideo.videoHeight); console.log('[TAB_CAPTURE] Video has non-zero pixels:', hasNonZeroPixels); - console.log('[TAB_CAPTURE] Sample pixel data:', Array.from(imageData.data.slice(0, 20))); + console.log('[TAB_CAPTURE] Sample pixel data:', samplePixels); + window.streamingDebug.testVideoDiagnostics = { + ...window.streamingDebug.testVideoDiagnostics, + videoWidth: testVideo.videoWidth, + videoHeight: testVideo.videoHeight, + hasNonZeroPixels, + samplePixels + }; + recordDebugEvent('test_video_pixels_sampled', { + videoWidth: testVideo.videoWidth, + videoHeight: testVideo.videoHeight, + hasNonZeroPixels + }); } catch (canvasErr) { console.warn('[TAB_CAPTURE] Could not sample video pixels:', canvasErr); + window.streamingDebug.testVideoDiagnostics = { + ...window.streamingDebug.testVideoDiagnostics, + error: `Pixel sample failed: ${canvasErr.message}` + }; + recordDebugEvent('test_video_pixel_sample_failed', { message: canvasErr.message }); } }, 1000); }).catch((playErr) => { console.error('[TAB_CAPTURE] Test video play failed:', playErr); + window.streamingDebug.testVideoDiagnostics = { + ...window.streamingDebug.testVideoDiagnostics, + error: `Play failed: ${playErr.message || String(playErr)}` + }; + recordDebugEvent('test_video_play_failed', { + message: playErr.message || String(playErr) + }); }); }; testVideo.onerror = (e) => { console.error('[TAB_CAPTURE] Test video error:', e); console.error('[TAB_CAPTURE] Video error details:', testVideo.error); + window.streamingDebug.testVideoDiagnostics = { + ...window.streamingDebug.testVideoDiagnostics, + error: testVideo.error ? `Video error code ${testVideo.error.code}` : 'Unknown video error' + }; + recordDebugEvent('test_video_error', { + errorCode: testVideo.error ? testVideo.error.code : null + }); }; testVideo.onwaiting = () => { console.log('[TAB_CAPTURE] Test video waiting for data...'); + window.streamingDebug.testVideoDiagnostics = { + ...window.streamingDebug.testVideoDiagnostics, + waiting: true + }; + recordDebugEvent('test_video_waiting'); }; testVideo.onstalled = () => { console.log('[TAB_CAPTURE] Test video stalled'); + window.streamingDebug.testVideoDiagnostics = { + ...window.streamingDebug.testVideoDiagnostics, + stalled: true + }; + recordDebugEvent('test_video_stalled'); }; } catch (testErr) { console.warn('[TAB_CAPTURE] Could not create test video element:', testErr); + window.streamingDebug.testVideoDiagnostics = { + attached: false, + error: `Test video creation failed: ${testErr.message}` + }; + recordDebugEvent('test_video_create_failed', { message: testErr.message }); } } else { console.error('[TAB_CAPTURE] ❌ No video tracks in stream!'); + window.streamingDebug.captureDiagnostics = { + ...window.streamingDebug.captureDiagnostics, + error: 'No video tracks in stream' + }; + recordDebugEvent('capture_stream_missing_video_track'); } // Store globally to detect active capture on subsequent init calls @@ -469,8 +592,9 @@ async function INITIALIZE({ srcPeerId, destPeerId }) { console.log(`[PEERJS] Peer constructor available:`, typeof Peer !== 'undefined'); const peer = new Peer(srcPeerId, { + debug: 3, config: { - debug: 3, + iceServers: Array.isArray(iceServers) ? iceServers : [] } }); console.log(`[PEERJS] Peer object created:`, peer); @@ -599,11 +723,15 @@ async function INITIALIZE({ srcPeerId, destPeerId }) { console.log(`[INITIALIZE] Final activeConnections:`, Array.from(window.activeConnections)); console.log(`[INITIALIZE] Final activeConnections size:`, window.activeConnections.size); console.log(`[INITIALIZE] ========== INITIALIZATION COMPLETE ==========`); + recordDebugEvent('initialize_complete', { + activeConnectionsSize: window.activeConnections.size + }); } catch (error) { console.error(`[INITIALIZE] ❌ INITIALIZATION FAILED:`, error); console.error(`[INITIALIZE] Error stack:`, error.stack); window.streamingDebug.lastError = `Initialization failed: ${error.message}`; + recordDebugEvent('initialize_failed', { message: error.message }); throw error; // Re-throw so container server knows it failed } } diff --git a/examples/bun-stream-server/container/src/server.ts b/examples/bun-stream-server/container/src/server.ts index 98af3d4..fee05db 100644 --- a/examples/bun-stream-server/container/src/server.ts +++ b/examples/bun-stream-server/container/src/server.ts @@ -23,6 +23,10 @@ import type { Browser, Page } from 'puppeteer'; import crypto from 'crypto'; import http from 'http'; import url from 'url'; +import { + parseStartStreamRequest, + type IceServerConfig, +} from '../../src/protocol'; // TypeScript declaration for browser window extensions declare global { @@ -35,7 +39,7 @@ declare global { lastError: string | null; callCount: number; }; - INITIALIZE?: (params: { srcPeerId: string; destPeerId: string }) => Promise; + INITIALIZE?: (params: { srcPeerId: string; destPeerId: string; iceServers?: IceServerConfig[] }) => Promise; Peer?: any; // PeerJS constructor } } @@ -54,6 +58,21 @@ let streamingPage: Page | undefined; let connectionCheckInterval: NodeJS.Timeout | null = null; let shutdownTimer: NodeJS.Timeout | null = null; +function logTrace(traceId: string, event: string, details?: Record) { + if (details) { + console.log(`[trace:${traceId}] ${event}`, details); + return; + } + console.log(`[trace:${traceId}] ${event}`); +} + +function buildTraceHeaders(traceId?: string): HeadersInit | undefined { + if (!traceId) return undefined; + return { + 'x-stream-trace-id': traceId, + }; +} + /** Utility: Create JSON response */ function jsonResponse(data: unknown, init: ResponseInit = {}) { return new Response(JSON.stringify(data), { @@ -61,13 +80,60 @@ function jsonResponse(data: unknown, init: ResponseInit = {}) { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Headers': 'Content-Type, x-stream-trace-id', ...(init.headers || {}) }, ...init, }); } +async function collectBrowserState(traceId: string) { + const activePageState = activePage + ? await activePage + .evaluate(() => ({ + url: window.location.href, + title: document.title, + visibilityState: document.visibilityState, + readyState: document.readyState, + hasFocus: document.hasFocus(), + })) + .catch((error: Error) => ({ error: error.message })) + : null; + + const extensionState = streamingPage + ? await streamingPage + .evaluate(() => ({ + location: window.location.href, + hasInitialize: typeof window.INITIALIZE === 'function', + activeConnections: window.activeConnections ? Array.from(window.activeConnections) : [], + activeConnectionsSize: window.activeConnections ? window.activeConnections.size : 0, + streamingDebug: window.streamingDebug || null, + peerDefined: typeof window.Peer !== 'undefined', + })) + .catch((error: Error) => ({ error: error.message })) + : null; + + const targetSummary = browser + ? browser.targets().map((target) => ({ + type: target.type(), + url: target.url(), + })) + : []; + + const snapshot = { + browserActive: !!browser, + activePageState, + extensionState, + targetSummary, + monitoringActive: !!connectionCheckInterval, + shutdownTimerActive: !!shutdownTimer, + capturedAt: new Date().toISOString(), + }; + + logTrace(traceId, 'browser_state_snapshot', snapshot as Record); + return snapshot; +} + /** Build Puppeteer launch options */ function buildLaunchOptions() { const absoluteExtensionPath = require('path').resolve(EXTENSION_PATH); @@ -760,33 +826,68 @@ async function handleTest(): Promise { } } -async function handleStartStream(data: { url: string; peerId: string }): Promise { +async function handleDebugState(traceId: string): Promise { + const snapshot = await collectBrowserState(traceId); + return jsonResponse(snapshot, { + headers: buildTraceHeaders(traceId), + }); +} + +async function handleStartStream( + data: { url: string; peerId: string; iceServers?: IceServerConfig[] }, + traceId: string +): Promise { try { - const { url: targetUrl, peerId: destPeerId } = data; + const { url: targetUrl, peerId: destPeerId, iceServers } = data; if (!targetUrl || !destPeerId) { - return jsonResponse({ error: 'Missing targetUrl or peerId' }, { status: 400 }); + return jsonResponse( + { error: 'Missing targetUrl or peerId', traceId }, + { status: 400, headers: buildTraceHeaders(traceId) } + ); } + logTrace(traceId, 'start_stream_request_received', { + targetUrl, + destPeerId, + iceServerCount: Array.isArray(iceServers) ? iceServers.length : 0, + browserReused: !!browser, + }); + // Use existing browser or launch new one if (!browser) { - console.log('No existing browser, launching new instance...'); + logTrace(traceId, 'browser_launch_start'); browser = await launchBrowserWithExtension(); + logTrace(traceId, 'browser_launch_complete'); } else { - console.log('Using existing browser instance'); + logTrace(traceId, 'browser_reuse'); } // Create new page for this stream const page = await browser.newPage(); activePage = page; // Set as active page for monitoring + page.on('console', (msg) => { + logTrace(traceId, `page_console_${msg.type()}`, { text: msg.text() }); + }); + page.on('pageerror', (error) => { + logTrace(traceId, 'page_error', { message: error.message }); + }); // Navigate to target URL + logTrace(traceId, 'page_navigation_start', { targetUrl }); await page.goto(targetUrl); + logTrace(traceId, 'page_navigation_complete', { + finalUrl: page.url(), + title: await page.title().catch(() => '(unavailable)'), + }); // Set page to full screen await page.setViewport({ width: 1920, height: 1080 }); // Set a large viewport + logTrace(traceId, 'page_viewport_set', { width: 1920, height: 1080 }); // Get extension streaming page and initialize streaming + logTrace(traceId, 'extension_page_wait_start'); streamingPage = await getExtensionStreamingPage(browser, 30000); // This also sets EXTENSION_ID + logTrace(traceId, 'extension_page_ready', { extensionId: EXTENSION_ID }); // Trigger a user-gesture-like command to satisfy activeTab requirements try { @@ -800,21 +901,21 @@ async function handleStartStream(data: { url: string; peerId: string }): Promise await nyPage.keyboard.press('KeyS'); await nyPage.keyboard.up('Shift'); await nyPage.keyboard.up('Alt'); - console.log('🔑 Sent start-capture command shortcut'); + logTrace(traceId, 'capture_shortcut_sent'); } } catch (e) { - console.warn('Could not send command shortcut:', (e as Error).message); + logTrace(traceId, 'capture_shortcut_failed', { message: (e as Error).message }); } // Set up console log monitoring for the extension page streamingPage.on('console', (msg) => { const type = msg.type(); const text = msg.text(); - console.log(`[EXTENSION-${type.toUpperCase()}] ${text}`); + logTrace(traceId, `extension_console_${type}`, { text }); }); streamingPage.on('pageerror', (error) => { - console.error('[EXTENSION-ERROR] Page error:', error.message); + logTrace(traceId, 'extension_page_error', { message: error.message }); }); // Test if we can execute code in the extension context @@ -836,30 +937,34 @@ async function handleStartStream(data: { url: string; peerId: string }): Promise ) }; }); - console.log('🧪 Extension context test result:', testResult); + logTrace(traceId, 'extension_context_test', testResult as Record); } catch (error) { - console.error('🧪 Extension context test failed:', error); + logTrace(traceId, 'extension_context_test_failed', { message: (error as Error).message }); } // Ensure INITIALIZE function is loaded await assertExtensionLoaded(streamingPage); + logTrace(traceId, 'extension_initialize_detected'); // Force a simple log to test console monitoring - console.log('🔍 Forcing a test log in extension...'); + logTrace(traceId, 'extension_console_probe_start'); await streamingPage.evaluate(() => { console.log('[FORCED-TEST] This should appear in container logs if console monitoring works'); console.error('[FORCED-ERROR] This is a test error'); console.warn('[FORCED-WARN] This is a test warning'); }); - console.log('🔍 Test logs sent, checking if they appeared above...'); + logTrace(traceId, 'extension_console_probe_complete'); const srcPeerId = crypto.randomUUID(); - const peers = { srcPeerId, destPeerId }; + const peers = { srcPeerId, destPeerId, iceServers: Array.isArray(iceServers) ? iceServers : [] }; // Initialize streaming in extension page - console.log('🚀 About to call INITIALIZE function with params:', peers); - console.log('🚀 Extension page URL:', streamingPage.url()); + logTrace(traceId, 'extension_initialize_start', { + srcPeerId, + destPeerId, + extensionUrl: streamingPage.url(), + }); try { await Promise.race([ @@ -873,7 +978,7 @@ async function handleStartStream(data: { url: string; peerId: string }): Promise new Promise((_, reject) => setTimeout(() => reject(new Error('INITIALIZE timeout')), 30000)), ]); - console.log('✅ INITIALIZE function completed successfully'); + logTrace(traceId, 'extension_initialize_complete'); // Check the state immediately after INITIALIZE const postInitState = await streamingPage.evaluate(() => { @@ -885,11 +990,12 @@ async function handleStartStream(data: { url: string; peerId: string }): Promise }; }); - console.log('📊 Post-INITIALIZE state:', postInitState); + logTrace(traceId, 'post_initialize_state', postInitState as Record); + await new Promise((resolve) => setTimeout(resolve, 1500)); + await collectBrowserState(traceId); } catch (error) { - console.error('❌ INITIALIZE function failed:', error); - console.error('❌ Error details:', { + logTrace(traceId, 'extension_initialize_failed', { name: (error as Error).name, message: (error as Error).message, stack: (error as Error).stack @@ -902,16 +1008,29 @@ async function handleStartStream(data: { url: string; peerId: string }): Promise startConnectionMonitoring(); } + logTrace(traceId, 'start_stream_response_sent', { + srcPeerId, + monitoringActive: !!connectionCheckInterval, + }); return jsonResponse({ status: 'success', + traceId, srcPeerId, browserWSEndpoint: browser.wsEndpoint(), monitoringActive: !!connectionCheckInterval + }, { + headers: buildTraceHeaders(traceId), }); } catch (err: any) { - console.error('handleStartStream error:', err); + logTrace(traceId, 'start_stream_error', { + message: err.message, + stack: err.stack, + }); // Don't close browser on error - let monitoring handle lifecycle - return jsonResponse({ status: 'error', message: err.message }, { status: 500 }); + return jsonResponse( + { status: 'error', message: err.message, traceId }, + { status: 500, headers: buildTraceHeaders(traceId) } + ); } } @@ -919,12 +1038,18 @@ async function handleStartStream(data: { url: string; peerId: string }): Promise const server = http.createServer(async (req: any, res: any) => { const parsedUrl = url.parse(req.url || '', true); const { pathname } = parsedUrl; + const traceId = req.headers['x-stream-trace-id'] || crypto.randomUUID(); try { + logTrace(traceId, 'http_request_received', { + method: req.method, + pathname, + }); // Set CORS headers res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-stream-trace-id'); + res.setHeader('x-stream-trace-id', traceId); // Handle CORS preflight requests if (req.method === 'OPTIONS') { @@ -941,6 +1066,8 @@ const server = http.createServer(async (req: any, res: any) => { response = await handlePing(); } else if (pathname === '/test-puppeteer') { response = await handleTest(); + } else if (pathname === '/debug-state') { + response = await handleDebugState(traceId); } else if (pathname === '/start-stream' && req.method === 'POST') { // Parse request body for POST requests let body = ''; @@ -952,10 +1079,10 @@ const server = http.createServer(async (req: any, res: any) => { req.on('end', resolve); }); - const data = JSON.parse(body); - response = await handleStartStream(data); + const data = parseStartStreamRequest(JSON.parse(body)); + response = await handleStartStream(data, traceId); } else { - response = new Response('Not Found', { status: 404 }); + response = new Response(`Not Found: ${req.method} ${req.url} (parsed path: ${pathname})`, { status: 404 }); } // Convert Response object to Node.js response @@ -968,6 +1095,18 @@ const server = http.createServer(async (req: any, res: any) => { res.end(responseText); } catch (err: any) { + if (err instanceof SyntaxError || err?.message?.includes('must be')) { + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, x-stream-trace-id', + 'x-stream-trace-id': req.headers['x-stream-trace-id'] || '', + }); + res.end(JSON.stringify({ status: 'error', message: err.message })); + return; + } + console.error('Unhandled error:', err); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: err.message })); @@ -975,8 +1114,8 @@ const server = http.createServer(async (req: any, res: any) => { }); const PORT = 8080; -server.listen(PORT, () => { - console.log(`Container server running at http://localhost:${PORT}`); +server.listen(PORT, '0.0.0.0', () => { + console.log(`Container server running at http://0.0.0.0:${PORT}`); }); // Graceful shutdown on process termination @@ -990,4 +1129,4 @@ process.on('SIGINT', async () => { console.log('Received SIGINT, shutting down gracefully...'); await shutdownBrowser(); process.exit(0); -}); \ No newline at end of file +}); diff --git a/examples/bun-stream-server/package.json b/examples/bun-stream-server/package.json index c75076c..a7a4f8f 100644 --- a/examples/bun-stream-server/package.json +++ b/examples/bun-stream-server/package.json @@ -5,7 +5,8 @@ "scripts": { "deploy": "wrangler deploy", "dev": "wrangler dev", - "start": "wrangler dev" + "start": "wrangler dev", + "test": "npx --yes vitest@2.1.9 run test/index.test.ts" }, "devDependencies": { "@types/bun": "^1.2.21", diff --git a/examples/bun-stream-server/receiver.html b/examples/bun-stream-server/receiver.html index d7ec8dd..77c091e 100644 --- a/examples/bun-stream-server/receiver.html +++ b/examples/bun-stream-server/receiver.html @@ -172,6 +172,11 @@

How to use:

+
+ + +
+
@@ -209,6 +214,9 @@

How to use:

let currentConnection = null; let receivedCall = null; let isListening = false; + let callHandlersBound = false; + let currentTraceId = null; + const sessionId = 'session-' + Math.random().toString(36).substring(2, 15); const urlInput = document.getElementById('urlInput'); const receiverIdDisplay = document.getElementById('receiverIdDisplay'); @@ -229,6 +237,67 @@

How to use:

status.textContent = message; status.className = `status ${type}`; } + + async function logPeerConnectionStats(call, label = 'rtc') { + const pc = call && (call.peerConnection || call._negotiator?._pc || null); + if (!pc || typeof pc.getStats !== 'function') { + console.log(`📡 ${label}: peer connection stats unavailable`); + return; + } + + try { + const stats = await pc.getStats(); + const summary = { + connectionState: pc.connectionState, + iceConnectionState: pc.iceConnectionState, + iceGatheringState: pc.iceGatheringState, + signalingState: pc.signalingState, + inboundVideo: null, + inboundAudio: null, + candidatePair: null + }; + + stats.forEach((report) => { + if (report.type === 'inbound-rtp' && report.kind === 'video') { + summary.inboundVideo = { + bytesReceived: report.bytesReceived, + framesDecoded: report.framesDecoded, + frameWidth: report.frameWidth, + frameHeight: report.frameHeight, + framesPerSecond: report.framesPerSecond, + packetsLost: report.packetsLost + }; + } + + if (report.type === 'inbound-rtp' && report.kind === 'audio') { + summary.inboundAudio = { + bytesReceived: report.bytesReceived, + packetsLost: report.packetsLost + }; + } + + if (report.type === 'candidate-pair' && report.nominated && report.state === 'succeeded') { + summary.candidatePair = { + currentRoundTripTime: report.currentRoundTripTime, + availableIncomingBitrate: report.availableIncomingBitrate, + bytesReceived: report.bytesReceived, + bytesSent: report.bytesSent + }; + } + }); + + console.log(`📡 ${label} stats:`, summary); + } catch (error) { + console.error(`📡 ${label} stats failed:`, error); + } + } + + function createTraceId() { + if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + return 'trace-' + Math.random().toString(36).slice(2) + '-' + Date.now(); + } async function startStreamAndListen() { const url = urlInput.value.trim(); @@ -247,10 +316,36 @@

How to use:

startListeningBtn.disabled = true; try { - // Step 1: Start listening first + cleanup(); + + // Step 1: Load ICE configuration before creating either peer. + updateStatus('📡 Requesting stream from container server...', 'connecting'); + + const serverUrl = document.getElementById('serverUrlInput').value.trim(); + const endpoint = serverUrl.endsWith('/') ? `${serverUrl}start-stream` : `${serverUrl}/start-stream`; + const iceServersEndpoint = serverUrl.endsWith('/') ? `${serverUrl}ice-servers` : `${serverUrl}/ice-servers`; + currentTraceId = createTraceId(); + console.log('🧭 Stream trace ID:', currentTraceId); + + const iceResponse = await fetch(iceServersEndpoint, { + method: 'GET', + headers: { + 'x-stream-trace-id': currentTraceId, + 'x-stream-session-id': sessionId + } + }); + const icePayload = await iceResponse.json(); + if (!iceResponse.ok) { + throw new Error((icePayload.error || 'Failed to load ICE servers') + ` (trace ${icePayload.traceId || currentTraceId})`); + } + const iceServers = Array.isArray(icePayload.iceServers) ? icePayload.iceServers : []; + console.log('🧊 ICE servers loaded:', iceServers); + + // Step 2: Start listening with the fetched ICE servers. peer = new Peer(receiverId, { + debug: 3, config: { - debug: 3 + iceServers } }); @@ -268,29 +363,36 @@

How to use:

}); }); - // Step 2: Start the stream on the container server - updateStatus('📡 Requesting stream from container server...', 'connecting'); + // Step 3: Start listening for the incoming call before + // the container finishes INITIALIZE and dials our peer. + setupCallHandlers(); - const streamResponse = await fetch('http://localhost:8080/start-stream', { + // Step 4: Start the stream on the container server. + const streamResponse = await fetch(endpoint, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'x-stream-trace-id': currentTraceId, + 'x-stream-session-id': sessionId + }, body: JSON.stringify({ url: url, - peerId: receiverId + peerId: receiverId, + iceServers }) }); const result = await streamResponse.json(); + const responseTraceId = streamResponse.headers.get('x-stream-trace-id') || result.traceId || currentTraceId; + console.log('🧭 Stream response trace ID:', responseTraceId); if (result.status !== 'success') { - throw new Error(result.message || 'Failed to start stream'); + throw new Error((result.message || 'Failed to start stream') + ` (trace ${responseTraceId})`); } - console.log('✅ Stream started:', result.srcPeerId); - updateStatus('🎯 Stream started! Waiting for connection...', 'connecting'); - - // Step 3: Set up call handling (the container will call us) - setupCallHandlers(); + console.log('✅ Stream started:', result.srcPeerId, 'trace:', responseTraceId); + peerIdInput.value = result.srcPeerId || ''; + updateStatus(`🎯 Stream started! Waiting for connection... Trace: ${responseTraceId}`, 'connecting'); // Enable other controls connectBtn.disabled = false; @@ -303,9 +405,30 @@

How to use:

} function setupCallHandlers() { + if (!peer || callHandlersBound) { + return; + } + + callHandlersBound = true; + peer.on('call', (call) => { console.log('📞 Receiving call from:', call.peer); updateStatus('📡 Receiving stream from: ' + call.peer, 'connecting'); + const peerConnection = call.peerConnection || call._negotiator?._pc || null; + if (peerConnection) { + console.log('📡 Peer connection object detected'); + peerConnection.onconnectionstatechange = () => { + console.log('📡 connectionState:', peerConnection.connectionState); + }; + peerConnection.oniceconnectionstatechange = () => { + console.log('📡 iceConnectionState:', peerConnection.iceConnectionState); + }; + peerConnection.onicegatheringstatechange = () => { + console.log('📡 iceGatheringState:', peerConnection.iceGatheringState); + }; + } else { + console.log('📡 Peer connection object not available on call'); + } // Answer the call without providing our own stream call.answer(); @@ -447,7 +570,12 @@

How to use:

console.log(' - videoHeight:', remoteVideo.videoHeight); console.log(' - networkState:', remoteVideo.networkState); console.log(' - error:', remoteVideo.error); + logPeerConnectionStats(call, '2s'); }, 2000); + + setTimeout(() => { + logPeerConnectionStats(call, '5s'); + }, 5000); disconnectBtn.disabled = false; }); @@ -477,7 +605,9 @@

How to use:

isListening = true; // Create our peer with the receiver ID - peer = new Peer(receiverId); + peer = new Peer(receiverId, { + debug: 3 + }); peer.on('open', (id) => { console.log('Receiver peer opened with ID:', id); @@ -584,6 +714,9 @@

How to use:

peer = null; } + callHandlersBound = false; + currentTraceId = null; + if (remoteVideo.srcObject) { remoteVideo.srcObject.getTracks().forEach(track => track.stop()); remoteVideo.srcObject = null; @@ -625,7 +758,8 @@

How to use:

console.log('🎥 Stream Kit Receiver ready'); console.log('📱 Receiver ID:', receiverId); + console.log('🪪 Session ID:', sessionId); console.log('💡 Enter a URL and click "Start Stream & Listen" to begin'); - \ No newline at end of file + diff --git a/examples/bun-stream-server/src/index.ts b/examples/bun-stream-server/src/index.ts index 4b69ede..81563b3 100644 --- a/examples/bun-stream-server/src/index.ts +++ b/examples/bun-stream-server/src/index.ts @@ -1,10 +1,156 @@ +import crypto from "crypto"; +import { Buffer } from "buffer"; +import { + parseTurnCredentialsResponse, + type IceServerConfig, +} from "./protocol"; + const OPEN_CONTAINER_PORT = 8080; +const STREAM_INSTANCE_NAME = "default-singleton-debug-v3"; +const TURN_TTL_SECONDS = 300; +const DEBUG_TOKEN_HEADER = "x-debug-token"; +const SESSION_ID_HEADER = "x-stream-session-id"; + +function logTrace(traceId: string, event: string, details?: Record) { + if (details) { + console.log(`[trace:${traceId}] ${event}`, details); + return; + } + console.log(`[trace:${traceId}] ${event}`); +} + +function withTraceHeader(response: Response, traceId: string): Response { + const cloned = new Response(response.body, response); + cloned.headers.set("x-stream-trace-id", traceId); + return cloned; +} + +function withSessionHeader(response: Response, sessionId: string | null): Response { + if (!sessionId) { + return response; + } + + const cloned = new Response(response.body, response); + cloned.headers.set(SESSION_ID_HEADER, sessionId); + return cloned; +} + +export function normalizeIceServers(iceServers: IceServerConfig[]): IceServerConfig[] { + return iceServers.map((server) => { + const urls = Array.isArray(server.urls) ? server.urls : [server.urls]; + const filteredUrls = urls.filter((url) => { + const normalizedUrl = url.toLowerCase(); + return !( + normalizedUrl.includes(":53?") || + normalizedUrl.endsWith(":53") || + normalizedUrl.includes(":53#") || + normalizedUrl.includes(":53/") + ); + }); + return { + ...server, + urls: filteredUrls, + }; + }).filter((server) => { + const urls = Array.isArray(server.urls) ? server.urls : [server.urls]; + return urls.length > 0; + }); +} + +function timingSafeMatches(actual: string, expected: string): boolean { + const actualBytes = Buffer.from(actual); + const expectedBytes = Buffer.from(expected); + + if (actualBytes.length !== expectedBytes.length) { + return false; + } + + return crypto.timingSafeEqual(actualBytes, expectedBytes); +} + +export function isDebugRequestAuthorized(request: Request, env: Env): boolean { + if (!env.DEBUG_STATE_TOKEN) { + return true; + } + + const providedToken = request.headers.get(DEBUG_TOKEN_HEADER); + if (!providedToken) { + return false; + } + + return timingSafeMatches(providedToken, env.DEBUG_STATE_TOKEN); +} + +function buildCorsHeaders(methods: string): HeadersInit { + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": methods, + "Access-Control-Allow-Headers": `Content-Type, x-stream-trace-id, ${DEBUG_TOKEN_HEADER}, ${SESSION_ID_HEADER}`, + "Access-Control-Expose-Headers": `x-stream-trace-id, ${SESSION_ID_HEADER}`, + }; +} + +export function resolveSessionId(request: Request): string | null { + const providedSessionId = request.headers.get(SESSION_ID_HEADER); + if (!providedSessionId) { + return null; + } + + const normalizedSessionId = providedSessionId.trim(); + if (!/^[a-zA-Z0-9_-]{1,100}$/.test(normalizedSessionId)) { + return null; + } + + return normalizedSessionId; +} + +export async function generateTurnIceServers( + env: Env, + traceId: string +): Promise { + const apiToken = env.CLOUDFLARE_TURN_API_TOKEN; + const turnKeyId = env.CLOUDFLARE_TURN_KEY_ID; + + if (!apiToken || !turnKeyId) { + throw new Error("TURN credentials are not configured in Worker secrets"); + } + + logTrace(traceId, "turn_credentials_request_start", { ttlSeconds: TURN_TTL_SECONDS }); + const response = await fetch( + `https://rtc.live.cloudflare.com/v1/turn/keys/${turnKeyId}/credentials/generate-ice-servers`, + { + method: "POST", + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ ttl: TURN_TTL_SECONDS }), + } + ); + + const bodyText = await response.text(); + if (!response.ok) { + logTrace(traceId, "turn_credentials_request_failed", { + status: response.status, + body: bodyText, + }); + throw new Error(`TURN credentials request failed: ${response.status}`); + } + + const parsed = parseTurnCredentialsResponse(JSON.parse(bodyText)); + const iceServers = normalizeIceServers(parsed.iceServers); + logTrace(traceId, "turn_credentials_request_complete", { + serverCount: iceServers.length, + }); + return iceServers; +} // Helper functions async function startAndWaitForPort( container: Container, portToAwait: number, - maxTries = 10 + traceId: string, + maxTries = 120 ) { const port = container.getTcpPort(portToAwait); let monitor; @@ -12,20 +158,40 @@ async function startAndWaitForPort( for (let i = 0; i < maxTries; i++) { try { if (!container.running) { - container.start(); + // @ts-ignore - enableInternet is required for Puppeteer to reach external websites + container.start({ enableInternet: true }); monitor = container.monitor(); + logTrace(traceId, "container_start_requested", { try: i + 1, port: portToAwait }); } - await (await port.fetch("http://ping")).text(); + + const res = await port.fetch("http://localhost:8080/ping", { + // @ts-ignore + signal: AbortSignal.timeout(1500), + headers: { + "x-stream-trace-id": traceId, + }, + }); + const text = await res.text(); + if (!res.ok) { + throw new Error(`fetch failed: ${res.status} ${text}`); + } + logTrace(traceId, "container_ready", { try: i + 1, port: portToAwait }); return; } catch (err: any) { - console.error("Error connecting to the container on", i, "try", err); + logTrace(traceId, "container_wait_retry", { + try: i + 1, + message: err.message, + name: err.name, + }); + const errMsg = (err.message || "").toLowerCase(); if ( - err.message.includes("listening") || - err.message.includes( - "there is no container instance that can be provided" - ) + errMsg.includes("listening") || + errMsg.includes("there is no container instance that can be provided") || + errMsg.includes("timeout") || + err.name === "TimeoutError" || + errMsg.includes("fetch failed") ) { - await new Promise((res) => setTimeout(res, 300)); + await new Promise((res) => setTimeout(res, 500)); continue; } throw err; @@ -54,13 +220,16 @@ async function waitForLocalhost(port: number, maxTries = 10) { async function proxyFetch( container: Container, request: Request, - portNumber: number + portNumber: number, + traceId: string ): Promise { + const headers = new Headers(request.headers); + headers.set("x-stream-trace-id", traceId); const response = await container .getTcpPort(portNumber) .fetch(request.url.replace("https://", "http://"), { method: request.method, - headers: request.headers, + headers, body: request.body, }); return response; @@ -78,47 +247,116 @@ function createJsonResponse(data: unknown, init?: ResponseInit): Response { } // Durable Object implementation -export class MyContainer implements DurableObject { +export class StreamContainer implements DurableObject { + private waitPromise: Promise | null = null; + constructor( private readonly ctx: DurableObjectState, private readonly env: Env - ) { - ctx.blockConcurrencyWhile(async () => { - if (!ctx.container) { - // No container available, wait for localhost to be available - console.log("No container binding found, using localhost:8080"); + ) {} + + private async ensureRunning(traceId: string) { + if (this.ctx.container && !this.ctx.container.running && this.waitPromise) { + logTrace(traceId, "do_container_stopped_resetting_wait_promise"); + this.waitPromise = null; + } + + if (this.waitPromise) return this.waitPromise; + + this.waitPromise = (async () => { + if (!this.ctx.container) { + logTrace(traceId, "do_localhost_mode"); await waitForLocalhost(OPEN_CONTAINER_PORT); } else { - // Container available, start and wait for it - console.log("Container binding found, starting container"); - await startAndWaitForPort(ctx.container, OPEN_CONTAINER_PORT); + logTrace(traceId, "do_container_mode"); + await startAndWaitForPort(this.ctx.container, OPEN_CONTAINER_PORT, traceId); } - }); + })(); + + return this.waitPromise; } async fetch(request: Request): Promise { + const url = new URL(request.url); + const traceId = request.headers.get("x-stream-trace-id") || crypto.randomUUID(); + const startedAt = Date.now(); + logTrace(traceId, "do_request_received", { + method: request.method, + path: url.pathname, + }); + try { + await this.ensureRunning(traceId); + if (!this.ctx.container) { // No container, proxy to localhost:8080 + logTrace(traceId, "do_proxy_local", { method: request.method, path: url.pathname }); const localUrl = request.url.replace(new URL(request.url).origin, 'http://localhost:8080'); const response = await fetch(localUrl, { method: request.method, - headers: request.headers, + headers: new Headers(request.headers), body: request.body, }); - return response; + const tracedResponse = withTraceHeader(response, traceId); + logTrace(traceId, "do_response_sent", { + status: tracedResponse.status, + durationMs: Date.now() - startedAt, + }); + return tracedResponse; } else { // Use the container - return await proxyFetch( + logTrace(traceId, "do_proxy_container", { method: request.method, path: url.pathname }); + const res = await proxyFetch( this.ctx.container, request, - OPEN_CONTAINER_PORT + OPEN_CONTAINER_PORT, + traceId ); + const tracedResponse = withTraceHeader(res, traceId); + logTrace(traceId, "do_response_sent", { + status: tracedResponse.status, + durationMs: Date.now() - startedAt, + }); + return tracedResponse; } } catch (error) { + if ( + this.ctx.container && + (error as Error).message.includes("container is not running") + ) { + logTrace(traceId, "do_retry_after_container_stopped"); + this.waitPromise = null; + await this.ensureRunning(traceId); + + const retriedResponse = await proxyFetch( + this.ctx.container, + request, + OPEN_CONTAINER_PORT, + traceId + ); + const tracedResponse = withTraceHeader(retriedResponse, traceId); + logTrace(traceId, "do_response_sent_after_retry", { + status: tracedResponse.status, + durationMs: Date.now() - startedAt, + }); + return tracedResponse; + } + + logTrace(traceId, "do_error", { + message: (error as Error).message, + durationMs: Date.now() - startedAt, + }); return createJsonResponse( - { error: (!this.ctx.container ? "Local server" : "Container") + " error: " + (error as Error).message }, - { status: 500 } + { + error: (!this.ctx.container ? "Local server" : "Container") + " error: " + (error as Error).message, + traceId, + }, + { + status: 500, + headers: { + "x-stream-trace-id": traceId, + }, + } ); } } @@ -127,12 +365,104 @@ export class MyContainer implements DurableObject { // Worker entry point export default { async fetch(request: Request, env: Env): Promise { + const traceId = request.headers.get("x-stream-trace-id") || crypto.randomUUID(); const url = new URL(request.url); - const pathname = url.pathname; + const sessionId = resolveSessionId(request); + const streamInstanceName = sessionId ? `session-${sessionId}` : STREAM_INSTANCE_NAME; + logTrace(traceId, "worker_request_received", { + method: request.method, + path: url.pathname, + sessionId, + }); + + if (url.pathname === "/ice-servers" && request.method === "GET") { + try { + const response = withTraceHeader( + createJsonResponse( + { + iceServers: await generateTurnIceServers(env, traceId), + traceId, + sessionId, + }, + { + headers: { + ...buildCorsHeaders("GET, OPTIONS"), + }, + } + ), + traceId + ); + const tracedResponse = withSessionHeader(response, sessionId); + logTrace(traceId, "worker_response_sent", { status: tracedResponse.status, path: url.pathname, sessionId }); + return tracedResponse; + } catch (error) { + const response = withTraceHeader( + createJsonResponse( + { error: (error as Error).message, traceId, sessionId }, + { + status: 500, + headers: { + ...buildCorsHeaders("GET, OPTIONS"), + }, + } + ), + traceId + ); + const tracedResponse = withSessionHeader(response, sessionId); + logTrace(traceId, "worker_response_sent", { status: tracedResponse.status, path: url.pathname, sessionId }); + return tracedResponse; + } + } + + if (url.pathname === "/debug-state" && !isDebugRequestAuthorized(request, env)) { + const response = withTraceHeader( + createJsonResponse( + { error: "Forbidden", traceId }, + { + status: 403, + headers: buildCorsHeaders("GET, OPTIONS"), + } + ), + traceId + ); + const tracedResponse = withSessionHeader(response, sessionId); + logTrace(traceId, "worker_response_sent", { + status: tracedResponse.status, + path: url.pathname, + authorized: false, + sessionId, + }); + return tracedResponse; + } + + if (request.method === "OPTIONS") { + const response = withTraceHeader( + new Response(null, { + status: 204, + headers: buildCorsHeaders("GET, POST, OPTIONS"), + }), + traceId + ); + const tracedResponse = withSessionHeader(response, sessionId); + logTrace(traceId, "worker_response_sent", { status: tracedResponse.status, path: url.pathname, sessionId }); + return tracedResponse; + } + + const id = env.STREAM_CONTAINER.idFromName(streamInstanceName); + const stub = env.STREAM_CONTAINER.get(id); + const forwardedRequest = new Request(request, { + headers: new Headers(request.headers), + }); + forwardedRequest.headers.set("x-stream-trace-id", traceId); + if (sessionId) { + forwardedRequest.headers.set(SESSION_ID_HEADER, sessionId); + } - // Always route through Durable Objects - const id = env.MY_CONTAINER.idFromName(pathname); - const stub = env.MY_CONTAINER.get(id); - return await stub.fetch(request); + const response = withSessionHeader( + withTraceHeader(await stub.fetch(forwardedRequest), traceId), + sessionId + ); + logTrace(traceId, "worker_response_sent", { status: response.status, path: url.pathname, sessionId }); + return response; }, }; diff --git a/examples/bun-stream-server/src/protocol.ts b/examples/bun-stream-server/src/protocol.ts new file mode 100644 index 0000000..018e43d --- /dev/null +++ b/examples/bun-stream-server/src/protocol.ts @@ -0,0 +1,84 @@ +export type IceServerConfig = { + urls: string[] | string; + username?: string; + credential?: string; +}; + +export type StartStreamRequest = { + url: string; + peerId: string; + iceServers: IceServerConfig[]; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function parseNonEmptyString(value: unknown, fieldName: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${fieldName} must be a non-empty string`); + } + + return value.trim(); +} + +export function parseIceServerConfig(value: unknown): IceServerConfig { + if (!isRecord(value)) { + throw new Error("ice server must be an object"); + } + + const rawUrls = value.urls; + const urls = Array.isArray(rawUrls) + ? rawUrls.map((url, index) => parseNonEmptyString(url, `iceServers[].urls[${index}]`)) + : parseNonEmptyString(rawUrls, "iceServers[].urls"); + + if (typeof value.username !== "undefined" && typeof value.username !== "string") { + throw new Error("iceServers[].username must be a string when provided"); + } + + if (typeof value.credential !== "undefined" && typeof value.credential !== "string") { + throw new Error("iceServers[].credential must be a string when provided"); + } + + return { + urls, + username: value.username, + credential: value.credential, + }; +} + +export function parseIceServers(value: unknown): IceServerConfig[] { + if (typeof value === "undefined") { + return []; + } + + if (!Array.isArray(value)) { + throw new Error("iceServers must be an array when provided"); + } + + return value.map(parseIceServerConfig); +} + +export function parseTurnCredentialsResponse(value: unknown): { + iceServers: IceServerConfig[]; +} { + if (!isRecord(value) || !("iceServers" in value)) { + throw new Error("TURN credentials response did not include iceServers"); + } + + return { + iceServers: parseIceServers(value.iceServers), + }; +} + +export function parseStartStreamRequest(value: unknown): StartStreamRequest { + if (!isRecord(value)) { + throw new Error("request body must be an object"); + } + + return { + url: parseNonEmptyString(value.url, "url"), + peerId: parseNonEmptyString(value.peerId, "peerId"), + iceServers: parseIceServers(value.iceServers), + }; +} diff --git a/examples/bun-stream-server/test/index.test.ts b/examples/bun-stream-server/test/index.test.ts new file mode 100644 index 0000000..8ca1b65 --- /dev/null +++ b/examples/bun-stream-server/test/index.test.ts @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import worker from "../src/index"; + +type TestEnv = Parameters[1]; + +function createContainerNamespace(fetchImpl?: (request: Request) => Promise) { + const calls: Request[] = []; + const stubFetch = vi.fn(async (request: Request) => { + calls.push(request); + if (fetchImpl) { + return fetchImpl(request); + } + + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + return { + idFromName: vi.fn(() => "stream-id"), + get: vi.fn(() => ({ fetch: stubFetch })), + stubFetch, + calls, + }; +} + +function createEnv(overrides: Partial = {}) { + const container = createContainerNamespace(); + const env: TestEnv = { + CLOUDFLARE_TURN_API_TOKEN: "turn-token", + CLOUDFLARE_TURN_KEY_ID: "turn-key-id", + STREAM_CONTAINER: container as unknown as TestEnv["STREAM_CONTAINER"], + ...overrides, + }; + + return { env, container }; +} + +describe("bun-stream-server worker", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("returns normalized TURN ice servers", async () => { + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + iceServers: [ + { + urls: [ + "stun:stun.cloudflare.com:3478", + "turn:turn.cloudflare.com:53?transport=udp", + ], + }, + { + urls: "turn:turn.cloudflare.com:5349?transport=tcp", + username: "user", + credential: "pass", + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ); + vi.stubGlobal("fetch", fetchMock); + + const { env } = createEnv(); + const response = await worker.fetch( + new Request("https://example.com/ice-servers", { + headers: { + "x-stream-trace-id": "trace-ice-1", + }, + }), + env + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + iceServers: [ + { + urls: ["stun:stun.cloudflare.com:3478"], + }, + { + urls: ["turn:turn.cloudflare.com:5349?transport=tcp"], + username: "user", + credential: "pass", + }, + ], + sessionId: null, + traceId: "trace-ice-1", + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://rtc.live.cloudflare.com/v1/turn/keys/turn-key-id/credentials/generate-ice-servers", + { + method: "POST", + headers: { + Authorization: "Bearer turn-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ ttl: 300 }), + } + ); + }); + + it("blocks /debug-state when the debug token is missing", async () => { + const { env, container } = createEnv({ DEBUG_STATE_TOKEN: "top-secret" }); + + const response = await worker.fetch( + new Request("https://example.com/debug-state", { + headers: { + "x-stream-trace-id": "trace-debug-blocked", + }, + }), + env + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: "Forbidden", + traceId: "trace-debug-blocked", + }); + expect(container.get).not.toHaveBeenCalled(); + expect(container.stubFetch).not.toHaveBeenCalled(); + }); + + it("forwards /debug-state when the debug token matches", async () => { + const { env, container } = createEnv({ DEBUG_STATE_TOKEN: "top-secret" }); + + const response = await worker.fetch( + new Request("https://example.com/debug-state", { + headers: { + "x-stream-trace-id": "trace-debug-allowed", + "x-debug-token": "top-secret", + }, + }), + env + ); + + expect(response.status).toBe(200); + expect(container.get).toHaveBeenCalledOnce(); + expect(container.stubFetch).toHaveBeenCalledOnce(); + + const forwardedRequest = container.calls[0]; + expect(forwardedRequest.headers.get("x-stream-trace-id")).toBe( + "trace-debug-allowed" + ); + }); + + it("routes requests to a session-scoped durable object when a session header is present", async () => { + const { env, container } = createEnv(); + + const response = await worker.fetch( + new Request("https://example.com/start-stream", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-stream-trace-id": "trace-session-1", + "x-stream-session-id": "receiver-session-1", + }, + body: JSON.stringify({ + url: "https://example.com", + peerId: "receiver-123", + iceServers: [], + }), + }), + env + ); + + expect(response.status).toBe(200); + expect(container.idFromName).toHaveBeenCalledWith("session-receiver-session-1"); + + const forwardedRequest = container.calls[0]; + expect(forwardedRequest.headers.get("x-stream-session-id")).toBe( + "receiver-session-1" + ); + }); +}); diff --git a/examples/bun-stream-server/tsconfig.json b/examples/bun-stream-server/tsconfig.json index 6a06624..bc183bd 100644 --- a/examples/bun-stream-server/tsconfig.json +++ b/examples/bun-stream-server/tsconfig.json @@ -3,7 +3,7 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "node", - "types": ["./worker-configuration.d.ts"], + "types": ["node", "./worker-configuration.d.ts"], "esModuleInterop": true, "strict": true, "skipLibCheck": true, diff --git a/examples/bun-stream-server/worker-configuration.d.ts b/examples/bun-stream-server/worker-configuration.d.ts index 59b9119..529dbac 100644 --- a/examples/bun-stream-server/worker-configuration.d.ts +++ b/examples/bun-stream-server/worker-configuration.d.ts @@ -1,8 +1,11 @@ -// Generated by Wrangler by running `wrangler types` (hash: 95c09f256642a28e3040440af4a953e6) +// Generated by Wrangler by running `wrangler types` (hash: 4fcc58a36afdf2d2cf2f7a4e94908aa1) // Runtime types generated with workerd@1.20250319.0 2025-04-10 nodejs_compat,nodejs_compat_populate_process_env declare namespace Cloudflare { interface Env { - MY_CONTAINER: DurableObjectNamespace; + CLOUDFLARE_TURN_API_TOKEN: string; + CLOUDFLARE_TURN_KEY_ID: string; + DEBUG_STATE_TOKEN?: string; + STREAM_CONTAINER: DurableObjectNamespace; } } interface Env extends Cloudflare.Env {} diff --git a/examples/bun-stream-server/wrangler.jsonc b/examples/bun-stream-server/wrangler.jsonc index 413407e..809a3fc 100644 --- a/examples/bun-stream-server/wrangler.jsonc +++ b/examples/bun-stream-server/wrangler.jsonc @@ -14,14 +14,15 @@ "containers": [{ "name": "codeflare-containers", "image": "./container/Dockerfile", - "class_name": "MyContainer", - "instances": 2 + "class_name": "StreamContainer", + "instances": 2, + "enable_internet": true }], "durable_objects": { "bindings": [ { - "name": "MY_CONTAINER", - "class_name": "MyContainer" + "name": "STREAM_CONTAINER", + "class_name": "StreamContainer" } ] }, @@ -31,6 +32,15 @@ "new_sqlite_classes": [ "MyContainer" ] + }, + { + "tag": "v2", + "new_sqlite_classes": [ + "StreamContainer" + ], + "deleted_classes": [ + "MyContainer" + ] } ], "observability": { diff --git a/packages/stream-kit-react/README.md b/packages/stream-kit-react/README.md index 61132c0..b03ccfd 100644 --- a/packages/stream-kit-react/README.md +++ b/packages/stream-kit-react/README.md @@ -1,270 +1,98 @@ # @open-game-system/stream-kit-react -React components for displaying WebRTC streams from the Open Game System (OGS) Cloud Rendering service. +React bindings for an existing `RenderStream`. -## Overview +## What This Package Is -This package provides React components for integrating cloud-rendered streams into your React applications. +This package does not create stream sessions on its own. It wraps a `RenderStream` instance from `@open-game-system/stream-kit-web` and gives you: + +- `StreamProvider` +- `StreamCanvas` +- `createStreamStateContext()` +- `useStream()` + +It is a UI binding layer around an already-created stream object. ## Installation ```bash -npm install @open-game-system/stream-kit-react @open-game-system/stream-kit-web @open-game-system/stream-kit-types -# or pnpm add @open-game-system/stream-kit-react @open-game-system/stream-kit-web @open-game-system/stream-kit-types -# or -yarn add @open-game-system/stream-kit-react @open-game-system/stream-kit-web @open-game-system/stream-kit-types ``` -## Basic Usage +## Current API -```tsx -import { StreamProvider, StreamCanvas } from '@open-game-system/stream-kit-react'; -import { createStreamClient } from '@open-game-system/stream-kit-web'; +### `StreamProvider` -// Create the client -const client = createStreamClient({ - brokerUrl: 'https://opengame.tv/stream' -}); +`StreamProvider` expects a `stream` prop, not a client: -function App() { - return ( - -
- {/* Render the video stream */} - -
-
- ); -} +```tsx +import { StreamProvider } from "@open-game-system/stream-kit-react"; -> **Note:** The StreamCanvas component uses PeerJS for WebRTC signaling, which connects to the default PeerJS server automatically. No additional configuration is needed for development and testing purposes. For production environments, you may want to set up your own PeerJS server. + + +; ``` -## Components - -### StreamProvider +### `StreamCanvas` -Root provider component that manages the stream client: +`StreamCanvas` renders the underlying video element from the provided stream: ```tsx - - {/* Your app */} - -``` +import { StreamCanvas } from "@open-game-system/stream-kit-react"; -### StreamCanvas - -Component that renders the video stream: - -```tsx - { - console.log('Stream state:', state); - }} -/> +function Player() { + return ; +} ``` -## Testing - -The `@open-game-system/stream-kit-testing` package provides a mock client that allows you to simulate stream states and events: +### Full example ```tsx -import { render, screen } from '@testing-library/react'; -import { createMockStreamClient } from '@open-game-system/stream-kit-testing'; - -describe('Stream Components', () => { - it('handles stream state changes', async () => { - const mockClient = createMockStreamClient(); - const onStateChange = vi.fn(); - - render( - - - - ); - - // Initial state should be connecting - expect(onStateChange).toHaveBeenCalledWith(expect.objectContaining({ - status: 'connecting' - })); - - // Simulate successful connection - await mockClient.simulateStreamState({ - status: 'streaming', - fps: 60, - latency: 50 - }); - - // Should update with new state - expect(onStateChange).toHaveBeenCalledWith(expect.objectContaining({ - status: 'streaming', - fps: 60, - latency: 50 - })); - - // Simulate error - await mockClient.simulateStreamState({ - status: 'error', - error: new Error('Connection lost') - }); - - expect(onStateChange).toHaveBeenCalledWith(expect.objectContaining({ - status: 'error', - error: expect.any(Error) - })); - }); +import { createStreamClient } from "@open-game-system/stream-kit-web"; +import { StreamCanvas, StreamProvider } from "@open-game-system/stream-kit-react"; - it('handles stream events', async () => { - const mockClient = createMockStreamClient(); - - const { container } = render( - - { - if (event.type === 'click') { - container.querySelector('.overlay')?.classList.add('clicked'); - } - }} - /> - - ); - - // Simulate stream starting - await mockClient.simulateStreamState({ status: 'streaming' }); - - // Simulate receiving a click event from the stream - await mockClient.simulateStreamEvent({ - type: 'click', - position: { x: 100, y: 100 } - }); - - expect(container.querySelector('.overlay.clicked')).toBeInTheDocument(); - }); - - it('handles stream quality changes', async () => { - const mockClient = createMockStreamClient(); - const onQualityChange = vi.fn(); - - render( - - - - ); - - // Simulate quality degradation - await mockClient.simulateStreamQuality({ - resolution: '720p', - quality: 'medium', - reason: 'bandwidth' - }); - - expect(onQualityChange).toHaveBeenCalledWith({ - resolution: '720p', - quality: 'medium', - reason: 'bandwidth' - }); - }); +const client = createStreamClient({ + brokerUrl: "https://api.example.com", }); -``` - -The mock client provides several methods for testing: - -- `simulateStreamState(state)`: Change the stream's state (connecting, streaming, error, etc.) -- `simulateStreamEvent(event)`: Simulate receiving an event from the stream -- `simulateStreamQuality(quality)`: Simulate stream quality changes -- `simulateDisconnect()`: Simulate unexpected disconnection -- `simulateReconnect()`: Simulate successful reconnection - -This allows you to test all aspects of your stream integration, including: -- State transitions -- Event handling -- Quality adaptations -- Error scenarios -- Reconnection logic -## Examples - -### Multiple Views +const stream = client.createRenderStream({ + url: "https://your-game.com/render/world", +}); -```tsx -function GameWithMultipleViews() { +function App() { return ( - -
- - -
+ + ); } ``` -### With Loading States +### Stream state helpers -```tsx -function StreamWithStates() { - return ( - -
- { - if (state.status === 'connecting') { - // Show loading UI - } else if (state.status === 'streaming') { - // Show connected UI - } else if (state.status === 'error') { - // Show error UI - } - }} - /> -
-
- ); -} -``` +`createStreamStateContext()` returns selector helpers and small state components such as: + +- `Status` +- `When` +- `Stats` +- `Quality` +- `Match` +- `Overlay` + +These are useful when you want to subscribe to only part of the stream state. -## Best Practices +## Notes -1. Use a single `StreamProvider` at the root of your app -2. Each `StreamCanvas` manages its own stream instance -3. Handle stream states via the `onStateChange` prop -4. Use the testing utilities for reliable tests +- This package is intentionally narrow: it binds React to a `RenderStream`. +- It is not yet the future OGS product SDK shape described in `docs/integration.md`. +- The earlier README examples that passed `client` to `StreamProvider` were incorrect for the current code. -## TypeScript Support +## Related Packages -All components are fully typed. The stream state types are inherited from `@open-game-system/stream-kit-types`. +- [`@open-game-system/stream-kit-web`](../stream-kit-web/README.md) +- [`@open-game-system/stream-kit-types`](../stream-kit-types/README.md) +- [`@open-game-system/stream-kit-testing`](../stream-kit-testing/README.md) ## License -MIT License \ No newline at end of file +MIT diff --git a/packages/stream-kit-server/README.md b/packages/stream-kit-server/README.md index 4ee064e..a96d64e 100644 --- a/packages/stream-kit-server/README.md +++ b/packages/stream-kit-server/README.md @@ -1,144 +1,68 @@ # @open-game-system/stream-kit-server -Server-side implementation for the Open Game System (OGS). +Server-side primitives for the Stream Kit package layer. -## Overview +## What This Package Is -This package provides a hook-based router/middleware (`createStreamKitRouter`) for managing stream state persistence. By implementing `StreamKitHooks`, you can integrate with various storage backends (Redis, Cloudflare KV, etc.) to save, load, and delete stream state. +Today this package contains two things: -This package is designed to be used alongside other OGS components or custom logic that handles the actual browser automation (like Puppeteer) and WebRTC streaming. +- `createStreamKitRouter()` for storage-backed stream state endpoints +- newer `StreamKitServer` exports that are still incomplete relative to the deployed example + +The stable, tested piece here is the router. ## Installation ```bash -npm install @open-game-system/stream-kit-server @open-game-system/stream-kit-types -# or pnpm add @open-game-system/stream-kit-server @open-game-system/stream-kit-types -# or -yarn add @open-game-system/stream-kit-server @open-game-system/stream-kit-types -``` - -**Requirements:** - -- For Puppeteer-based rendering, your server environment needs Google Chrome/Chromium and ffmpeg installed. -- A Dockerfile setup will be required for production deployments. Documentation for this will be provided separately. -- For the hook-based router, install the necessary client library for your chosen storage (e.g., `ioredis`). - -## Usage (Hook-Based Architecture) - -The core idea is to implement storage interactions via hooks and pass them to the `createStreamKitRouter` factory. - -### Example: Using Redis for Storage (Node.js/Bun) - -1. **Define Environment/Context:** - -```typescript -// src/redis-client.ts -import Redis from "ioredis"; - -const redisClient = new Redis(process.env.REDIS_URL || "redis://localhost:6379"); - -export default redisClient; - -export interface AppEnv { - redis: Redis.Redis; -} ``` -2. **Implement Storage Hooks:** - -```typescript -// src/streamkit-hooks-redis.ts -import type { StreamKitHooks } from "@open-game-collective/stream-kit-server"; -import type { AppEnv } from "./redis-client"; -import redisClient from "./redis-client"; - -const getStreamKey = (streamId: string) => `stream:${streamId}:state`; - -export const redisStreamKitHooks: StreamKitHooks = { - async saveStreamState({ streamId, state, env }) { - const redis = redisClient; - await redis.set(getStreamKey(streamId), JSON.stringify(state)); - }, - - async loadStreamState({ streamId, env }) { - const redis = redisClient; - const stateJson = await redis.get(getStreamKey(streamId)); - return stateJson ? JSON.parse(stateJson) : null; - }, - - async deleteStreamState({ streamId, env }) { - const redis = redisClient; - await redis.del(getStreamKey(streamId)); - }, - - async *subscribeToStateChanges({ streamId, env, lastEventId }) { - // Implement your state change subscription logic here - // This could use Redis pub/sub, WebSocket connections, etc. - // Must yield StateChange objects: { type: 'patch' | 'snapshot', data: unknown, id?: string } - } -}; -``` - -3. **Integrate Router:** - -```typescript -// src/server.ts -import { createStreamKitRouter } from "@open-game-collective/stream-kit-server"; -import { redisStreamKitHooks } from "./streamkit-hooks-redis"; -import redisClient from "./redis-client"; - -// Create router with hooks -const streamRouterHandler = createStreamKitRouter({ - hooks: redisStreamKitHooks -}); - -// Create environment object that includes storage -const env: AppEnv = { - redis: redisClient -}; - -// Set up server (example using Bun) -Bun.serve({ - port: 3000, - async fetch(request: Request) { - const url = new URL(request.url); - - if (url.pathname.startsWith("/stream/")) { - return streamRouterHandler(request, env); - } - - return new Response("Not Found", { status: 404 }); +## `createStreamKitRouter()` + +This helper builds a request handler around storage hooks you provide. + +Supported routes: + +- `GET /stream/:streamId` +- `GET /stream/:streamId/sse` +- `POST /stream/:streamId` +- `DELETE /stream/:streamId` + +### Example + +```ts +import { createStreamKitRouter } from "@open-game-system/stream-kit-server"; + +const router = createStreamKitRouter({ + hooks: { + async saveStreamState({ streamId, state, env }) { + await env.kv.put(streamId, JSON.stringify(state)); + }, + async loadStreamState({ streamId, env }) { + const value = await env.kv.get(streamId); + return value ? JSON.parse(value) : null; + }, + async deleteStreamState({ streamId, env }) { + await env.kv.delete(streamId); + }, + async *subscribeToStateChanges() { + // Yield snapshot/patch events here + }, }, }); ``` -## API Reference - -### `createStreamKitRouter(config)` - -Creates a request handler function suitable for environments like Cloudflare Workers, Node.js, Deno, or Bun. - -- `config`: Configuration object - - `hooks: StreamKitHooks`: Required. An object containing your implementations of the storage interaction hooks. -- Returns: `(request: Request, env: TEnv) => Promise` - -**Endpoints:** - -- `GET /stream/:streamId`: Loads stream state -- `GET /stream/:streamId/sse`: Server-Sent Events for state changes -- `POST /stream/:streamId`: Saves stream state (expects JSON body) -- `DELETE /stream/:streamId`: Deletes stream state +## Notes -### `StreamKitHooks` Interface +- This package is not what powers the working Cloudflare example under `examples/bun-stream-server/`. +- The exported `StreamKitServer` and `createStreamClient` server-side API are not yet the source of truth for deployed behavior. +- If you want the working end-to-end runtime, look at the example and the repo-level docs instead. -This interface defines the required functions for handling storage operations: +## Related Packages -- `saveStreamState(params: { streamId: string; state: unknown; env: TEnv }): Promise` -- `loadStreamState(params: { streamId: string; env: TEnv }): Promise` -- `deleteStreamState(params: { streamId: string; env: TEnv }): Promise` -- `subscribeToStateChanges(params: { streamId: string; env: TEnv; lastEventId?: string }): AsyncIterable` +- [`@open-game-system/stream-kit-types`](../stream-kit-types/README.md) +- [`@open-game-system/stream-kit-web`](../stream-kit-web/README.md) ## License -MIT License \ No newline at end of file +MIT diff --git a/packages/stream-kit-testing/README.md b/packages/stream-kit-testing/README.md index 3f4478c..8c7118b 100644 --- a/packages/stream-kit-testing/README.md +++ b/packages/stream-kit-testing/README.md @@ -1,204 +1,80 @@ # @open-game-system/stream-kit-testing -Testing utilities for Stream Kit packages that make it easy to test stream-based applications and components. +Testing helpers for the current Stream Kit package layer. -## Installation +## What This Package Is -```bash -npm install --save-dev @open-game-system/stream-kit-testing -# or -pnpm add -D @open-game-system/stream-kit-testing -# or -yarn add -D @open-game-system/stream-kit-testing -``` +This package provides: -## Usage +- `createMockStreamClient()` +- broker event simulation helpers +- stream assertions -### Mock Stream Client +It is meant for testing code built against `@open-game-system/stream-kit-web` and `@open-game-system/stream-kit-react`. -The mock stream client allows you to simulate stream behavior in your tests without actual WebRTC connections. +## Installation -```typescript -import { createMockStreamClient } from '@open-game-system/stream-kit-testing'; -import { expect, test } from 'vitest'; +```bash +pnpm add -D @open-game-system/stream-kit-testing +``` -test('stream client handles connection lifecycle', async () => { - const mockClient = createMockStreamClient(); - - const stream = mockClient.createRenderStream({ - url: 'https://test.com/stream', - renderOptions: { resolution: '1080p' } - }); +## Mock client - // Test initial state - expect(stream.state.status).toBe('initialized'); +```ts +import { createMockStreamClient } from "@open-game-system/stream-kit-testing"; - // Start stream and verify connection - await stream.start(); - expect(stream.state.status).toBe('connecting'); +const mockClient = createMockStreamClient(); +const stream = mockClient.createRenderStream({ + url: "https://test.example/stream", }); ``` -### Broker Event Simulation +## Simulate broker events -Utilities to simulate broker Server-Sent Events (SSE) for stream management: +```ts +import { simulateBrokerEvent } from "@open-game-system/stream-kit-testing"; -```typescript -import { simulateBrokerEvent } from '@open-game-system/stream-kit-testing'; - -test('handles broker peer assignment', async () => { - const mockClient = createMockStreamClient(); - const stream = mockClient.createRenderStream({ - url: 'https://test.com/stream' - }); - - await stream.start(); - expect(stream.state.status).toBe('connecting'); - - // Simulate broker assigning a peer - simulateBrokerEvent(stream, { - type: 'peer_assigned', - peerId: 'render-node-1', - connectionDetails: { - iceServers: [{ urls: 'stun:stun.example.com' }] - } - }); - - // Assert stream moves to connected state - expect(stream.state.status).toBe('connected'); -}); - -test('handles broker error scenarios', async () => { - const mockClient = createMockStreamClient(); - const stream = mockClient.createRenderStream({ - url: 'https://test.com/stream' - }); - - await stream.start(); - - // Simulate broker indicating node failure - simulateBrokerEvent(stream, { - type: 'node_failure', - reason: 'render_node_crashed' - }); - - expect(stream.state.status).toBe('error'); - expect(stream.state.error?.code).toBe('NODE_FAILURE'); +simulateBrokerEvent(stream, { + type: "peer_assigned", + peerId: "render-node-1", + connectionDetails: { + iceServers: [{ urls: "stun:stun.example.com" }], + }, }); ``` -### Stream State Assertions +## Assertions -Helper functions to assert stream states and transitions: - -```typescript -import { +```ts +import { assertStreamConnected, assertStreamDisconnected, - waitForStreamState -} from '@open-game-system/stream-kit-testing'; - -test('stream transitions through expected states', async () => { - const mockClient = createMockStreamClient(); - const stream = mockClient.createRenderStream({ - url: 'https://test.com/stream' - }); - - await stream.start(); - - // Wait for connecting state - await waitForStreamState(stream, 'connecting'); - - // Simulate successful peer assignment - simulateBrokerEvent(stream, { - type: 'peer_assigned', - peerId: 'render-node-1', - connectionDetails: { - iceServers: [{ urls: 'stun:stun.example.com' }] - } - }); - - // Assert connected state - assertStreamConnected(stream); - - // Simulate stream end - simulateBrokerEvent(stream, { - type: 'stream_ended', - reason: 'session_timeout' - }); - - assertStreamDisconnected(stream); -}); -``` - -### Testing React Components - -When using with React components (requires `@open-game-system/stream-kit-react`): - -```typescript -import { render, screen } from '@testing-library/react'; -import { createMockStreamClient } from '@open-game-system/stream-kit-testing'; -import { StreamProvider } from '@open-game-system/stream-kit-react'; - -test('StreamView component renders stream correctly', () => { - const mockClient = createMockStreamClient(); - - render( - - - - ); - - // Assert video element is present - expect(screen.getByTestId('stream-video')).toBeInTheDocument(); -}); -``` - -## API Reference - -### MockStreamClient - -```typescript -interface MockStreamClient { - createRenderStream: (params: CreateRenderStreamParams) => RenderStream; -} -``` - -### Broker Event Types + waitForStreamState, +} from "@open-game-system/stream-kit-testing"; -```typescript -type BrokerEvent = - | { type: 'peer_assigned'; peerId: string; connectionDetails: RTCConfiguration } - | { type: 'node_failure'; reason: string } - | { type: 'stream_ended'; reason: string } - | { type: 'quality_change'; settings: StreamQualitySettings }; - -interface StreamQualitySettings { - resolution: '720p' | '1080p' | '1440p' | '2160p'; - bitrate: number; - fps: number; -} +await waitForStreamState(stream, "connecting"); +assertStreamConnected(stream); ``` -### Assertion Functions +## React usage -```typescript -function assertStreamConnected(stream: RenderStream): void; -function assertStreamDisconnected(stream: RenderStream): void; -function assertStreamError(stream: RenderStream, code?: string): void; -function waitForStreamState(stream: RenderStream, status: StreamStatus): Promise; -``` +```tsx +import { StreamProvider } from "@open-game-system/stream-kit-react"; +import { createMockStreamClient } from "@open-game-system/stream-kit-testing"; -### Simulation Functions +const mockClient = createMockStreamClient(); +const stream = mockClient.createRenderStream({ url: "https://test.example/stream" }); -```typescript -function simulateBrokerEvent(stream: RenderStream, event: BrokerEvent): void; + + +; ``` -## Contributing +## Notes -Please see our [Contributing Guide](../../CONTRIBUTING.md) for details on how to contribute to this package. +- These helpers mirror the current package layer, not the full Cloudflare example runtime. +- They are most useful when testing `RenderStream` consumers and React bindings. ## License -MIT License \ No newline at end of file +MIT diff --git a/packages/stream-kit-types/README.md b/packages/stream-kit-types/README.md index cedea02..267dc9d 100644 --- a/packages/stream-kit-types/README.md +++ b/packages/stream-kit-types/README.md @@ -1,34 +1,30 @@ # @open-game-system/stream-kit-types -Core type definitions for the Open Game System (OGS) Cloud Rendering service (`stream-kit`). +Shared TypeScript types for the Stream Kit workspace. -## Overview +## What This Package Is -This package provides TypeScript type definitions used across the `stream-kit` ecosystem. It defines the core interfaces and types for: +This package holds the shared interfaces used across the current Stream Kit packages, including: -- Stream configuration and options -- State management and events -- WebRTC signaling and connection management -- Server-side session state -- JSON patch operations for state synchronization +- render options +- stream session metadata +- client-side stream state +- input event payloads +- server-side stream session state + +It is the lowest-level package in the workspace and contains no runtime behavior. ## Installation ```bash -npm install @open-game-system/stream-kit-types -# or pnpm add @open-game-system/stream-kit-types -# or -yarn add @open-game-system/stream-kit-types ``` -## Key Types - -### RenderOptions +## Main Types -Configuration options for stream quality and behavior: +### `RenderOptions` -```typescript +```ts interface RenderOptions { resolution?: "720p" | "1080p" | "1440p" | "4k" | string; targetFps?: number; @@ -38,11 +34,9 @@ interface RenderOptions { } ``` -### StreamState - -Real-time state of a streaming session: +### `StreamState` -```typescript +```ts interface StreamState { status: "initializing" | "connecting" | "streaming" | "reconnecting" | "error" | "ended"; latency?: number; @@ -54,52 +48,53 @@ interface StreamState { } ``` -### StreamEvent & InputStreamEvent +### `StreamSession` -Event structures for client-server communication: - -```typescript -interface StreamEvent { - type: string; - payload?: any; +```ts +interface StreamSession { + sessionId: string; + status: StreamState["status"]; + signalingUrl?: string; + iceServers?: RTCIceServer[]; + estimatedStartTime?: number; + region?: string; + error?: string; } - -type InputStreamEvent = StreamEvent & { - type: "interaction" | "command"; - data: { - action?: string; - position?: { x: number; y: number }; - // ... other properties - }; -}; ``` -## Usage +### `InputStreamEvent` -Import types as needed in your TypeScript code: +```ts +type InputStreamEvent = + | { type: "interaction"; data: { action: string; position?: { x: number; y: number } } } + | { type: "command"; data: { command: string; args?: any[] } }; +``` -```typescript -import type { - RenderOptions, - StreamState, - StreamEvent -} from '@open-game-system/stream-kit-types'; +## Usage -function configureStream(options: RenderOptions) { - // Your implementation -} +```ts +import type { + RenderOptions, + StreamSession, + StreamState, +} from "@open-game-system/stream-kit-types"; -function handleStateChange(state: StreamState) { - // Your implementation +function handleState(state: StreamState) { + console.log(state.status); } ``` +## Notes + +- These types reflect the current package layer, not the final OGS SDK surface. +- Some types still model the older broker-style API used by `stream-kit-web`. + ## Related Packages -- `@open-game-system/stream-kit-web`: Core client implementation -- `@open-game-system/stream-kit-react`: React components and hooks -- `@open-game-system/stream-kit-server`: Server-side implementation +- [`@open-game-system/stream-kit-web`](../stream-kit-web/README.md) +- [`@open-game-system/stream-kit-react`](../stream-kit-react/README.md) +- [`@open-game-system/stream-kit-server`](../stream-kit-server/README.md) ## License -MIT License \ No newline at end of file +MIT diff --git a/packages/stream-kit-web/README.md b/packages/stream-kit-web/README.md index e0ed9c5..c792a60 100644 --- a/packages/stream-kit-web/README.md +++ b/packages/stream-kit-web/README.md @@ -1,160 +1,90 @@ # @open-game-system/stream-kit-web -Core client implementation for the Open Game System (OGS) Cloud Rendering service. +Low-level browser client primitives for the Stream Kit package layer. -## Overview +## What This Package Is -This package provides the foundational client-side implementation for integrating cloud-rendered streams into web applications. It handles: +This package exposes: -- Stream session management -- WebRTC connection setup and management -- State synchronization -- Input event forwarding -- Automatic reconnection and error handling +- `createStreamClient()` +- a broker-style `StreamClient` +- a `RenderStream` abstraction that owns a video element, stream state, and WebRTC setup + +It is a low-level client package. It does not start Cloudflare containers by itself, and it is not yet the polished OGS app SDK described in the repo docs. ## Installation ```bash -npm install @open-game-system/stream-kit-web @open-game-system/stream-kit-types -# or pnpm add @open-game-system/stream-kit-web @open-game-system/stream-kit-types -# or -yarn add @open-game-system/stream-kit-web @open-game-system/stream-kit-types ``` -## Usage +## Current API -### Basic Example +### `createStreamClient` -```typescript -import { createStreamClient } from '@open-game-system/stream-kit-web'; +```ts +import { createStreamClient } from "@open-game-system/stream-kit-web"; -// Create a client instance const client = createStreamClient({ - brokerUrl: 'https://api.opengame.org/stream', - getAuthToken: async () => 'your-auth-token' // Optional -}); - -// Create a render stream instance -const stream = client.createRenderStream({ - url: 'https://your-game.com/render/scene', - renderOptions: { - resolution: '1080p', - quality: 'high' - }, - autoConnect: true // Start streaming immediately -}); - -// Subscribe to state changes -stream.subscribe((state) => { - console.log('Stream state:', state); + brokerUrl: "https://api.example.com", }); - -// Get the video element to insert into your UI -const videoElement = stream.getVideoElement(); -if (videoElement) { - document.getElementById('stream-container')?.appendChild(videoElement); -} - -// Send input events when needed -stream.send({ - type: 'interaction', - data: { - action: 'click', - position: { x: 100, y: 200 } - } -}); - -// Clean up when done -stream.destroy(); ``` -> **Note:** Stream Kit uses PeerJS for WebRTC signaling, which automatically connects to the default PeerJS server. For development and testing, you don't need to configure a custom PeerJS server. For production environments, you may want to set up your own PeerJS server for better reliability and control. +The returned client currently assumes a broker-style HTTP API with endpoints like: -### Advanced Usage +- `POST /stream/session` +- `DELETE /stream/session/:id` +- `POST /stream/session/:id/input` +- `PATCH /stream/session/:id` -#### Custom WebRTC Configuration +### `createRenderStream` -```typescript +```ts const stream = client.createRenderStream({ - url: 'https://your-game.com/render/scene', + url: "https://your-game.com/render/world", renderOptions: { - resolution: '1080p', - quality: 'high', - priority: 'latency' + resolution: "1080p", + quality: "high", }, - initialData: { - scene: 'world-1', - playerPosition: { x: 0, y: 0, z: 0 } - } -}); - -// Update stream configuration dynamically -await stream.update({ - renderOptions: { - resolution: '720p', // Downgrade on poor connection - quality: 'medium' - }, - sceneData: { - playerPosition: { x: 10, y: 0, z: 5 } - } -}); -``` -#### Error Handling - -```typescript -stream.subscribe((state) => { - if (state.status === 'error') { - console.error('Stream error:', state.errorMessage); - if (state.errorCode === 'connection-lost') { - // Handle reconnection - stream.start(); - } - } }); ``` -## API Reference +### State subscription -### StreamClient +```ts +const unsubscribe = stream.subscribe((state) => { + console.log(state.status); +}); +``` -Core client for interacting with the OGS Stream API: +### Mounting the video element -```typescript -interface StreamClient { - requestStream: (params: RequestStreamParams) => Promise; - endStream: (sessionId: string) => Promise; - sendEvent: (sessionId: string, event: InputStreamEvent) => Promise; - updateStream: (sessionId: string, updates: StreamUpdates) => Promise; - createRenderStream: (params: CreateRenderStreamParams) => RenderStream; +```ts +const video = stream.getVideoElement(); +if (video) { + document.getElementById("stream-root")?.appendChild(video); } ``` -### RenderStream - -High-level stream management interface: - -```typescript -interface RenderStream { - readonly id: string; - readonly url: string; - readonly state: StreamState; - start: () => Promise; - end: () => Promise; - send: (event: InputStreamEvent) => void; - update: (updates: StreamUpdates) => Promise; - subscribe: (listener: (state: StreamState) => void) => () => void; - getVideoElement: () => HTMLVideoElement | null; - destroy: () => void; -} +### Cleanup + +```ts +await stream.end(); +stream.destroy(); ``` +## Notes + +- The current implementation still reflects an older broker/signaling model. +- The working end-to-end Cloudflare runtime in `examples/bun-stream-server/` does not directly consume this package yet. +- Treat this package as low-level and experimental relative to the deployed example. + ## Related Packages -- `@open-game-system/stream-kit-react`: React components and hooks -- `@open-game-system/stream-kit-types`: TypeScript type definitions -- `@open-game-system/stream-kit-server`: Server-side implementation +- [`@open-game-system/stream-kit-types`](../stream-kit-types/README.md) +- [`@open-game-system/stream-kit-react`](../stream-kit-react/README.md) +- [`@open-game-system/stream-kit-testing`](../stream-kit-testing/README.md) ## License -MIT License +MIT diff --git a/packages/stream-kit-web/package.json b/packages/stream-kit-web/package.json index c51e6e3..1132ec3 100644 --- a/packages/stream-kit-web/package.json +++ b/packages/stream-kit-web/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "test": "vitest", + "test": "vitest run", "test:watch": "vitest --watch", "test:coverage": "vitest --coverage", "typecheck": "tsc --noEmit",