An entry for the streamplace vod showcase
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(web): add documentation pages

- /docs - Overview of architecture and features
- /docs/frontend - Frontend setup, API, theming
- /docs/runner - Routes, security, jobs, secrets

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+480
+12
apps/web/src/main.tsx
··· 4 4 import { initTheme } from './lib/theme.ts' 5 5 import { UI4Editorial } from './ui/UI4Editorial.tsx' 6 6 import { ProfilePage } from './ui/ProfilePage.tsx' 7 + import { DocsPage } from './ui/DocsPage.tsx' 7 8 import { onRouteChange, matchPath } from './router.ts' 8 9 9 10 // Initialize theme before render to prevent flash ··· 26 27 const profileMatch = matchPath('/profile/:did', route) 27 28 if (profileMatch) { 28 29 return <ProfilePage did={profileMatch.did} /> 30 + } 31 + 32 + // Check for docs routes 33 + if (route === '/docs') { 34 + return <DocsPage section="overview" /> 35 + } 36 + if (route === '/docs/frontend') { 37 + return <DocsPage section="frontend" /> 38 + } 39 + if (route === '/docs/runner') { 40 + return <DocsPage section="runner" /> 29 41 } 30 42 31 43 return <UI4Editorial />
+468
apps/web/src/ui/DocsPage.tsx
··· 1 + import { useState } from "react" 2 + import { navigate } from "../router" 3 + 4 + type DocSection = "overview" | "frontend" | "runner" 5 + 6 + export function DocsPage({ section = "overview" }: { section?: DocSection }) { 7 + const [activeSection, setActiveSection] = useState<DocSection>(section) 8 + 9 + const handleNav = (s: DocSection) => { 10 + setActiveSection(s) 11 + navigate(s === "overview" ? "/docs" : `/docs/${s}`) 12 + } 13 + 14 + return ( 15 + <div style={styles.container}> 16 + <header style={styles.header}> 17 + <a href="/" onClick={(e) => { e.preventDefault(); navigate("/") }} style={styles.logo}> 18 + streamhut 19 + </a> 20 + <nav style={styles.nav}> 21 + <button 22 + style={{ ...styles.navButton, ...(activeSection === "overview" ? styles.navActive : {}) }} 23 + onClick={() => handleNav("overview")} 24 + > 25 + Overview 26 + </button> 27 + <button 28 + style={{ ...styles.navButton, ...(activeSection === "frontend" ? styles.navActive : {}) }} 29 + onClick={() => handleNav("frontend")} 30 + > 31 + Frontend 32 + </button> 33 + <button 34 + style={{ ...styles.navButton, ...(activeSection === "runner" ? styles.navActive : {}) }} 35 + onClick={() => handleNav("runner")} 36 + > 37 + Runner 38 + </button> 39 + </nav> 40 + </header> 41 + 42 + <main style={styles.main}> 43 + {activeSection === "overview" && <OverviewSection />} 44 + {activeSection === "frontend" && <FrontendSection />} 45 + {activeSection === "runner" && <RunnerSection />} 46 + </main> 47 + 48 + <footer style={styles.footer}> 49 + <p>Built on <a href="https://atproto.com" target="_blank" rel="noopener" style={styles.link}>AT Protocol</a></p> 50 + </footer> 51 + </div> 52 + ) 53 + } 54 + 55 + function OverviewSection() { 56 + return ( 57 + <article style={styles.article}> 58 + <h1 style={styles.h1}>Streamhut Documentation</h1> 59 + <p style={styles.lead}> 60 + Video streaming powered by AT Protocol serverless bundles. 61 + </p> 62 + 63 + <section style={styles.section}> 64 + <h2 style={styles.h2}>Architecture</h2> 65 + <p>Streamhut consists of three main components:</p> 66 + <div style={styles.cards}> 67 + <div style={styles.card}> 68 + <h3 style={styles.h3}>Frontend</h3> 69 + <p style={styles.cardText}> 70 + React-based video player with HLS streaming, thumbnail previews, and theme support. 71 + </p> 72 + </div> 73 + <div style={styles.card}> 74 + <h3 style={styles.h3}>VOD Bundle</h3> 75 + <p style={styles.cardText}> 76 + Serverless endpoints for video listing, streaming, and metadata - deployed to AT Protocol. 77 + </p> 78 + </div> 79 + <div style={styles.card}> 80 + <h3 style={styles.h3}>Runner</h3> 81 + <p style={styles.cardText}> 82 + HTTP server that fetches and executes bundles in sandboxed Deno environments. 83 + </p> 84 + </div> 85 + </div> 86 + </section> 87 + 88 + <section style={styles.section}> 89 + <h2 style={styles.h2}>How It Works</h2> 90 + <ol style={styles.list}> 91 + <li>Video metadata is stored on AT Protocol (Bluesky PDS)</li> 92 + <li>VOD bundle provides API endpoints for listing and streaming</li> 93 + <li>Runner executes bundle code in isolated Deno sandboxes</li> 94 + <li>Frontend fetches data and plays HLS streams via the runner</li> 95 + </ol> 96 + </section> 97 + 98 + <section style={styles.section}> 99 + <h2 style={styles.h2}>Key Features</h2> 100 + <ul style={styles.list}> 101 + <li><strong>Decentralized</strong> - Content lives on AT Protocol, not centralized servers</li> 102 + <li><strong>Sandboxed</strong> - All code runs in isolated Deno environments</li> 103 + <li><strong>Adaptive Streaming</strong> - HLS with quality selection</li> 104 + <li><strong>Thumbnail Previews</strong> - Sprite-based scrubbing</li> 105 + <li><strong>Scheduled Jobs</strong> - Cron-based video processing</li> 106 + </ul> 107 + </section> 108 + </article> 109 + ) 110 + } 111 + 112 + function FrontendSection() { 113 + return ( 114 + <article style={styles.article}> 115 + <h1 style={styles.h1}>Frontend</h1> 116 + <p style={styles.lead}> 117 + React-based video streaming interface with theme support. 118 + </p> 119 + 120 + <section style={styles.section}> 121 + <h2 style={styles.h2}>Features</h2> 122 + <ul style={styles.list}> 123 + <li><strong>HLS Video Streaming</strong> - Adaptive bitrate playback with quality selection</li> 124 + <li><strong>Thumbnail Previews</strong> - Sprite-based scrubbing with VTT timeline</li> 125 + <li><strong>Watch History</strong> - Local storage persistence with progress tracking</li> 126 + <li><strong>Themes</strong> - Light, Dark, Navy, and System modes</li> 127 + <li><strong>Profile Pages</strong> - View content by creator</li> 128 + <li><strong>Batch Loading</strong> - Efficient API calls for metadata</li> 129 + </ul> 130 + </section> 131 + 132 + <section style={styles.section}> 133 + <h2 style={styles.h2}>Environment Variables</h2> 134 + <table style={styles.table}> 135 + <thead> 136 + <tr> 137 + <th style={styles.th}>Variable</th> 138 + <th style={styles.th}>Description</th> 139 + </tr> 140 + </thead> 141 + <tbody> 142 + <tr> 143 + <td style={styles.td}><code style={styles.code}>VITE_RUNNER_URL</code></td> 144 + <td style={styles.td}>at-run runner URL</td> 145 + </tr> 146 + <tr> 147 + <td style={styles.td}><code style={styles.code}>VITE_BUNDLE_PATH</code></td> 148 + <td style={styles.td}>Bundle path (did/name/version)</td> 149 + </tr> 150 + </tbody> 151 + </table> 152 + </section> 153 + 154 + <section style={styles.section}> 155 + <h2 style={styles.h2}>API Endpoints</h2> 156 + <p>The frontend consumes these endpoints from the VOD bundle:</p> 157 + <table style={styles.table}> 158 + <thead> 159 + <tr> 160 + <th style={styles.th}>Endpoint</th> 161 + <th style={styles.th}>Method</th> 162 + <th style={styles.th}>Description</th> 163 + </tr> 164 + </thead> 165 + <tbody> 166 + <tr><td style={styles.td}><code style={styles.code}>listVideos</code></td><td style={styles.td}>POST</td><td style={styles.td}>Paginated video list</td></tr> 167 + <tr><td style={styles.td}><code style={styles.code}>getVideo</code></td><td style={styles.td}>POST</td><td style={styles.td}>Single video details</td></tr> 168 + <tr><td style={styles.td}><code style={styles.code}>getVideoStreams</code></td><td style={styles.td}>POST</td><td style={styles.td}>Available quality streams</td></tr> 169 + <tr><td style={styles.td}><code style={styles.code}>getPlaylist</code></td><td style={styles.td}>GET</td><td style={styles.td}>HLS playlist (cacheable)</td></tr> 170 + <tr><td style={styles.td}><code style={styles.code}>getThumbnail</code></td><td style={styles.td}>GET</td><td style={styles.td}>Video thumbnail (cacheable)</td></tr> 171 + <tr><td style={styles.td}><code style={styles.code}>getSprite</code></td><td style={styles.td}>GET</td><td style={styles.td}>Preview sprite sheet</td></tr> 172 + <tr><td style={styles.td}><code style={styles.code}>getVtt</code></td><td style={styles.td}>GET</td><td style={styles.td}>VTT timeline data</td></tr> 173 + <tr><td style={styles.td}><code style={styles.code}>batchMetadataReady</code></td><td style={styles.td}>POST</td><td style={styles.td}>Check multiple videos</td></tr> 174 + <tr><td style={styles.td}><code style={styles.code}>batchProfiles</code></td><td style={styles.td}>POST</td><td style={styles.td}>Fetch multiple profiles</td></tr> 175 + </tbody> 176 + </table> 177 + </section> 178 + 179 + <section style={styles.section}> 180 + <h2 style={styles.h2}>Theming</h2> 181 + <p>Themes use CSS custom properties on <code style={styles.code}>[data-theme]</code>:</p> 182 + <ul style={styles.list}> 183 + <li><strong>light</strong> - Light mode</li> 184 + <li><strong>dark</strong> - Dark mode</li> 185 + <li><strong>navy</strong> - Navy blue (default)</li> 186 + <li><strong>system</strong> - Follows OS preference</li> 187 + </ul> 188 + </section> 189 + 190 + <section style={styles.section}> 191 + <h2 style={styles.h2}>Development</h2> 192 + <pre style={styles.pre}>{`# Install dependencies 193 + bun install 194 + 195 + # Start dev server 196 + bun run dev 197 + 198 + # Build for production 199 + bun run build`}</pre> 200 + </section> 201 + </article> 202 + ) 203 + } 204 + 205 + function RunnerSection() { 206 + return ( 207 + <article style={styles.article}> 208 + <h1 style={styles.h1}>Runner</h1> 209 + <p style={styles.lead}> 210 + HTTP server that executes AT Protocol bundles in sandboxed Deno environments. 211 + </p> 212 + 213 + <section style={styles.section}> 214 + <h2 style={styles.h2}>How It Works</h2> 215 + <ol style={styles.list}> 216 + <li>Request arrives at <code style={styles.code}>/bundle/:did/:name/:version/:endpoint</code></li> 217 + <li>DID document lookup finds the PDS, fetches bundle record</li> 218 + <li>Bundle downloaded and cached to <code style={styles.code}>/tmp/atrun-*.js</code></li> 219 + <li>Deno executes bundle with enforced permissions and limits</li> 220 + <li>Response returned with CORS headers</li> 221 + </ol> 222 + </section> 223 + 224 + <section style={styles.section}> 225 + <h2 style={styles.h2}>Routes</h2> 226 + <table style={styles.table}> 227 + <thead> 228 + <tr> 229 + <th style={styles.th}>Route</th> 230 + <th style={styles.th}>Method</th> 231 + <th style={styles.th}>Description</th> 232 + </tr> 233 + </thead> 234 + <tbody> 235 + <tr><td style={styles.td}><code style={styles.code}>/bundle/:did/:name/:version/:endpoint</code></td><td style={styles.td}>GET/POST</td><td style={styles.td}>Execute bundle by name</td></tr> 236 + <tr><td style={styles.td}><code style={styles.code}>/at://did/collection/rkey/endpoint</code></td><td style={styles.td}>GET/POST</td><td style={styles.td}>Execute by AT URI</td></tr> 237 + <tr><td style={styles.td}><code style={styles.code}>/health</code></td><td style={styles.td}>GET</td><td style={styles.td}>Health check</td></tr> 238 + <tr><td style={styles.td}><code style={styles.code}>/jobs/status</code></td><td style={styles.td}>GET</td><td style={styles.td}>Scheduler status</td></tr> 239 + <tr><td style={styles.td}><code style={styles.code}>/jobs/run</code></td><td style={styles.td}>POST</td><td style={styles.td}>Manual job trigger</td></tr> 240 + </tbody> 241 + </table> 242 + </section> 243 + 244 + <section style={styles.section}> 245 + <h2 style={styles.h2}>Sandbox Security</h2> 246 + <p>Bundles execute in Deno with restricted permissions:</p> 247 + <ul style={styles.list}> 248 + <li><strong>Network</strong> - Only allowed hosts from bundle manifest</li> 249 + <li><strong>File System</strong> - Only allowed paths (typically <code style={styles.code}>/tmp</code>)</li> 250 + <li><strong>Environment</strong> - Only allowed variables</li> 251 + <li><strong>Time Limit</strong> - Enforced timeout (default 30s)</li> 252 + <li><strong>Memory</strong> - Capped allocation</li> 253 + </ul> 254 + <p>Effective permissions are the <strong>intersection</strong> of bundle manifest, endpoint permissions, and runner caps.</p> 255 + </section> 256 + 257 + <section style={styles.section}> 258 + <h2 style={styles.h2}>Configuration</h2> 259 + <p>Create <code style={styles.code}>at-run-config.json</code> in the working directory:</p> 260 + <pre style={styles.pre}>{`{ 261 + "port": 3000, 262 + "did": "did:plc:your-runner-did", 263 + "devMode": false, 264 + "access": { 265 + "allowedDids": ["did:plc:allowed-author"] 266 + }, 267 + "maxPermissions": { 268 + "net": true, 269 + "read": ["/tmp"], 270 + "write": ["/tmp"] 271 + }, 272 + "maxLimits": { 273 + "timeout": 60000, 274 + "memory": 512 275 + }, 276 + "jobs": { 277 + "enabled": true, 278 + "tickIntervalMs": 10000 279 + } 280 + }`}</pre> 281 + </section> 282 + 283 + <section style={styles.section}> 284 + <h2 style={styles.h2}>Secret Management</h2> 285 + <p>Runners can decrypt secrets for bundles:</p> 286 + <ol style={styles.list}> 287 + <li>Runner generates keypair (stored in <code style={styles.code}>at-run-keys.json</code>)</li> 288 + <li>Bundle author encrypts secrets to runner's public key</li> 289 + <li>Runner decrypts and injects as environment variables</li> 290 + </ol> 291 + <pre style={styles.pre}>{`# Author encrypts secret for runner 292 + at-run secrets set my-bundle API_KEY "secret" --runner did:plc:xxx 293 + 294 + # Available in bundle as Deno.env.get("API_KEY")`}</pre> 295 + </section> 296 + 297 + <section style={styles.section}> 298 + <h2 style={styles.h2}>Job Scheduler</h2> 299 + <p>The runner can execute scheduled jobs (cron-based):</p> 300 + <ul style={styles.list}> 301 + <li>Jobs registered via <code style={styles.code}>at-run jobs create</code></li> 302 + <li>Scheduler ticks and executes due jobs</li> 303 + <li>Jobs persist in SQLite and survive restarts</li> 304 + </ul> 305 + <pre style={styles.pre}>{`# Create a job that runs every 10 minutes 306 + at-run jobs create my-bundle syncVideos \\ 307 + --runner did:plc:xxx --cron "*/10 * * * *"`}</pre> 308 + </section> 309 + 310 + <section style={styles.section}> 311 + <h2 style={styles.h2}>Response Handling</h2> 312 + <p>Bundles can return JSON or Response objects:</p> 313 + <pre style={styles.pre}>{`// JSON response 314 + return { status: "ok", data: [...] } 315 + 316 + // Binary response 317 + return new Response(imageBytes, { 318 + headers: { "Content-Type": "image/jpeg" } 319 + })`}</pre> 320 + <p>Binary responses are base64-encoded through the sandbox boundary.</p> 321 + </section> 322 + </article> 323 + ) 324 + } 325 + 326 + const styles: Record<string, React.CSSProperties> = { 327 + container: { 328 + minHeight: "100vh", 329 + display: "flex", 330 + flexDirection: "column", 331 + background: "var(--bg)", 332 + color: "var(--text)", 333 + }, 334 + header: { 335 + display: "flex", 336 + alignItems: "center", 337 + justifyContent: "space-between", 338 + padding: "1rem 2rem", 339 + borderBottom: "1px solid var(--border)", 340 + background: "var(--bg-secondary)", 341 + }, 342 + logo: { 343 + fontSize: "1.25rem", 344 + fontWeight: 600, 345 + color: "var(--text-h)", 346 + textDecoration: "none", 347 + }, 348 + nav: { 349 + display: "flex", 350 + gap: "0.5rem", 351 + }, 352 + navButton: { 353 + padding: "0.5rem 1rem", 354 + background: "transparent", 355 + border: "1px solid transparent", 356 + borderRadius: "6px", 357 + color: "var(--text-muted)", 358 + fontSize: "0.875rem", 359 + cursor: "pointer", 360 + transition: "all 0.2s", 361 + }, 362 + navActive: { 363 + background: "var(--bg)", 364 + borderColor: "var(--border)", 365 + color: "var(--text-h)", 366 + }, 367 + main: { 368 + flex: 1, 369 + maxWidth: "800px", 370 + margin: "0 auto", 371 + padding: "2rem", 372 + width: "100%", 373 + }, 374 + footer: { 375 + padding: "2rem", 376 + textAlign: "center" as const, 377 + borderTop: "1px solid var(--border)", 378 + color: "var(--text-muted)", 379 + fontSize: "0.875rem", 380 + }, 381 + link: { 382 + color: "var(--accent)", 383 + textDecoration: "none", 384 + }, 385 + article: { 386 + lineHeight: 1.7, 387 + }, 388 + h1: { 389 + fontSize: "2rem", 390 + marginBottom: "0.5rem", 391 + color: "var(--text-h)", 392 + }, 393 + h2: { 394 + fontSize: "1.25rem", 395 + marginTop: "2rem", 396 + marginBottom: "1rem", 397 + paddingBottom: "0.5rem", 398 + borderBottom: "1px solid var(--border)", 399 + color: "var(--text-h)", 400 + }, 401 + h3: { 402 + fontSize: "1rem", 403 + marginBottom: "0.5rem", 404 + color: "var(--text-h)", 405 + }, 406 + lead: { 407 + fontSize: "1.125rem", 408 + color: "var(--text-muted)", 409 + marginBottom: "2rem", 410 + }, 411 + section: { 412 + marginBottom: "2rem", 413 + }, 414 + cards: { 415 + display: "grid", 416 + gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", 417 + gap: "1rem", 418 + marginTop: "1rem", 419 + }, 420 + card: { 421 + padding: "1rem", 422 + background: "var(--card-bg)", 423 + border: "1px solid var(--border)", 424 + borderRadius: "8px", 425 + }, 426 + cardText: { 427 + fontSize: "0.875rem", 428 + color: "var(--text-muted)", 429 + margin: 0, 430 + }, 431 + list: { 432 + paddingLeft: "1.5rem", 433 + margin: "1rem 0", 434 + }, 435 + table: { 436 + width: "100%", 437 + borderCollapse: "collapse" as const, 438 + marginTop: "1rem", 439 + fontSize: "0.875rem", 440 + }, 441 + th: { 442 + textAlign: "left" as const, 443 + padding: "0.75rem", 444 + borderBottom: "2px solid var(--border)", 445 + color: "var(--text-h)", 446 + }, 447 + td: { 448 + padding: "0.75rem", 449 + borderBottom: "1px solid var(--border)", 450 + }, 451 + code: { 452 + padding: "0.125rem 0.375rem", 453 + background: "var(--bg)", 454 + borderRadius: "4px", 455 + fontSize: "0.875em", 456 + fontFamily: "ui-monospace, monospace", 457 + }, 458 + pre: { 459 + padding: "1rem", 460 + background: "var(--bg)", 461 + border: "1px solid var(--border)", 462 + borderRadius: "8px", 463 + overflow: "auto", 464 + fontSize: "0.875rem", 465 + fontFamily: "ui-monospace, monospace", 466 + lineHeight: 1.5, 467 + }, 468 + }