Design Decisions#
This document captures the key architectural decisions made while building the at-run serverless execution system and AtmosphereConf VOD app. Decisions are attributed to who drove them.
Core Architecture#
Serverless on AT Protocol (Mainasara)#
The foundational idea: store JavaScript bundles as blobs on your PDS, execute them via runners. "Your code is your data." This enables decentralized compute where anyone can run a runner and execute bundles from any PDS.
Deno Sandbox (Claude)#
Use Deno subprocess for sandboxed execution instead of Bun's unsafe import(). Deno's permission model (--allow-net, --allow-read, etc.) maps cleanly to bundle-declared permissions, providing security without complex VM isolation.
Manifest + Endpoint Pattern (Claude)#
Bundle authors declare a manifest() with permissions and endpoint() functions with handlers. The runner extracts this metadata at load time and enforces permissions at execution time.
export const bundle = manifest({
name: "my-api",
permissions: { net: ["api.example.com"] },
})
export const getData = endpoint({
handler: async () => { ... },
})
Secrets Encryption (Claude)#
Secrets are encrypted with the runner's public key using X25519-XSalsa20-Poly1305. Bundle authors encrypt secrets for specific runners, stored on-chain. Runners decrypt at execution time and inject as environment variables. This keeps secrets safe even though bundles are public.
Env Permissions Derived from Secrets (Mainasara)#
Rather than bundle authors declaring which env vars they need, derive --allow-env permissions automatically from the secrets configured for that bundle on each runner. Simpler and more secure.
Task System#
Background Jobs with Concurrency Limiting (Mainasara)#
Heavy operations like thumbnail generation were killing the $5 VPS. The user requested a "job paradigm" - something that queues work and returns status.
Task Abstraction (Claude)#
Implemented task() as a variant of endpoint() with:
concurrency: max parallel executions (prevents resource exhaustion)cacheTtl: result caching (avoids redundant work)- Input-based deduplication (same args = same task)
Clients get { status: "pending" | "running" | "complete", result?: ... } and retry until complete.
export const videoMetadata = task({
concurrency: 2,
cacheTtl: 86400, // 24h
handler: async ({ uri }) => { ... },
})
Client Polling (Claude)#
Web client uses callTask() helper that retries every 2 seconds until status is "complete". Simple and works with any HTTP client.
Video Processing#
HLS Streaming (Mainasara)#
Use stream.place's existing HLS infrastructure. The VOD bundle fetches playlists, rewrites relative URLs to absolute, and serves to video players.
URL Rewriting for Local FFmpeg (Claude)#
HLS playlists use relative URLs. For ffmpeg to process them locally, rewrite all segment URLs to absolute (https://vod-beta.stream.place/xrpc/...).
Sprite Sheet Hover Previews (Mainasara)#
YouTube-style thumbnail previews on hover. User wanted this specific UX.
Sprite Implementation (Claude)#
Generate 8 frames at even intervals, combine into 4x2 grid using ffmpeg's xstack filter. Output WebVTT with #xywh= coordinates for each frame's position. Web component parses VTT and cycles through frames on hover.
2x Quality Sprites (Mainasara)#
Initial 160x90 frames looked blurry. User requested 320x180 for sharper previews.
Accelerator Architecture#
Offload to Serverless GPU (Mainasara)#
The $5 VPS couldn't handle ffmpeg thumbnail generation at scale. User suggested Modal/RunPod with GPU acceleration.
Runner as Gateway, Modal as Compute (Claude)#
Keep the runner as a lightweight API gateway handling:
- Task queue (deduplication, caching)
- Request routing
- Secrets management
Offload heavy compute to Modal. The task just calls Modal's endpoint and returns the result.
Frontend-Side Processing with MediaBunny (Mainasara)#
After debugging CUDA/ffmpeg image issues on Modal, decided to handle video processing from the frontend using mediabunny.dev instead of server-side acceleration.
Web App#
YouTube-Style Layout (Mainasara)#
User requested a layout matching YouTube's player page: video on left, metadata and related content on right.
Fixed Aspect Ratio Container (Claude)#
Use aspect-ratio: 16/9 on the video container to prevent layout shift when the video loads.
Creator Profiles from Bluesky (Mainasara)#
Show the video creator's Bluesky profile (avatar, display name) on the video page.
Profile Endpoint (Claude)#
Add getProfile endpoint that fetches from public.api.bsky.app and returns normalized profile data.
Deployment#
Docker with Bun + Deno + FFmpeg (Claude)#
Single Dockerfile that installs all three runtimes. Runner uses Bun for the HTTP server, spawns Deno for sandboxed execution.
Volume Mount for Keys (Mainasara)#
Runner keys stored at ~/.at-run/ on the host, mounted read-only into the container. User specified this path.
Dokploy Deployment (Mainasara)#
User's platform of choice for Docker deployments.
Social Sharing#
Share Endpoints Pattern (Mainasara)#
For rich link previews in a static SPA, create server endpoints that return HTML with OG meta tags then redirect to the frontend. The share button copies a link to the server endpoint, not the SPA route.
Simplified OG Images (Claude, with Mainasara's direction)#
Initially attempted satori-based OG image generation with custom fonts and layouts. Encountered WASM initialization issues in the Deno sandbox. Simplified to using original thumbnails/avatars directly as OG images, with titles formatted as "Streamhut | <title>".
Runner Improvements#
Dynamic Imports for Sandbox Compatibility (Claude)#
Libraries that access process.env at import time break bundle extraction. Solution: use dynamic import() inside handlers so the code only runs after permissions are applied.
Merge Env Permissions with Secrets (Claude)#
Runner was replacing bundle's declared env permissions with only secret keys. Fixed to merge both - bundles can declare env vars they need (e.g., JEST_WORKER_ID for satori) while also getting their secrets.
Don't Cache "Latest" Version Resolution (Claude)#
The runner was caching did/name/latest -> specific AT URI. After deploying a new version, old URI was served until restart. Fixed by skipping cache when version is "latest".
Robust Version Sorting (Claude)#
Use parseInt() instead of Number() for parsing version strings. Handles edge cases like "1.2.3-beta" correctly.
Lessons Learned#
-
Start simple, add complexity when needed: Began with synchronous endpoints, added task queue only when resource exhaustion became a problem.
-
Platform ffmpeg builds don't include CUDA: Getting GPU-accelerated ffmpeg requires custom builds or specialized images. Sometimes it's easier to use a managed service.
-
Env access at module load time breaks extraction: The runner extracts bundle metadata in a minimal sandbox. Any code that runs at import time (like
Deno.env.get()) must be deferred to handler execution. -
HLS + ffmpeg + seeking is tricky: Accurate seeking in HLS streams requires
-ssafter-i, not before. Seek times must be clamped to video duration. -
User-Agent matters: Some APIs return 403 without a proper User-Agent header. Always set one when making HTTP requests from serverless functions.
-
Keep OG images simple: Generated images with custom fonts require WASM, font fetching, and complex rendering. Using existing thumbnails/avatars is more reliable and the result is often better anyway.