Skip to content

Latest commit

 

History

History
243 lines (190 loc) · 12.2 KB

File metadata and controls

243 lines (190 loc) · 12.2 KB

BugReplay — Agent Reference

Screen recording + web telemetry tool. Upload via browser extension, replay via web viewer.


Architecture

Extension (MV3) ──upload──► Worker (CF) ◄──fetch── Viewer (HTML/JS/CSS)
                                │
                           ┌────┴────┐
                           │ R2  D1  │
                           └─────────┘
Component Role
Browser Extension Captures screen + telemetry, uploads to backend
Cloudflare Worker API + serves viewer/dashboard HTML
Cloudflare R2 (BUCKET) Video .webm + telemetry .json blobs
Cloudflare D1 (DB) Session metadata (SQLite)
Web Viewer Plays video synced with telemetry (embedded in Worker)

Critical Rules

  • Vanilla JS only — no frameworks, no CSS libraries, no new npm packages
  • Only dependency: jose (JWT). Already in worker/package.json
  • Worker type: ES module ("type": "module" in package.json)
  • All viewer/dashboard code is embedded as template literals in worker/src/index.js (VIEWER_CSS, VIEWER_JS, VIEWER_HTML, DASHBOARD_CSS, DASHBOARD_JS, DASHBOARD_HTML, LANDING_HTML)
  • No comments in code unless requested
  • Parameterized SQL — always use ? placeholders for D1 queries
  • Error responses — always { "error": "message" } format
  • Single user system — one creator, infinite anonymous viewers

Project Structure

extension/
├── manifest.json              # MV3 — permissions: tabCapture, storage, activeTab, offscreen; host: https://*/*
├── popup/
│   ├── popup.html             # UI + inline <style> (NO separate popup.css)
│   └── popup.js               # Clipboard, auth status display
├── background/
│   └── service-worker.js      # Orchestrator: capture lifecycle, telemetry routing, offscreen mgmt
├── offscreen/
│   ├── offscreen.html         # Offscreen document host
│   └── offscreen.js           # MediaRecorder + upload + telemetry buffer (cannot run in SW)
├── content/
│   ├── injector.js            # Content script → relays page-script events to SW
│   ├── page-script.js         # MAIN world: wraps console/fetch/XHR, captures events (isWrapped guard)
│   └── widget.js              # Isolated world: draggable floating widget (pause/stop), closed Shadow DOM, document_idle
└── icons/                     # icon16.svg, icon48.svg, icon128.svg

worker/
├── wrangler.toml              # R2 binding: BUCKET, D1 binding: DB
├── package.json               # jose@^6.2.1, wrangler@^4.71.0, type: module
├── migrations/
│   ├── 0001_create_sessions.sql
│   └── 0002_add_duration.sql
└── src/
    ├── index.js               # Router + handlers + embedded viewer/dashboard HTML/CSS/JS
    ├── auth.js                # JWT sign/verify via jose (HS256, 30d expiry)
    └── utils.js               # generateId() → "bugreplay_" + 8 random chars

Key Variables & Constants

Location Variable Value/Purpose
offscreen.js:1 MAX_TELEMETRY_EVENTS 10000 — memory cap (environment events preserved on eviction)
service-worker.js:1 BACKEND_URL Loaded from gitignored config.local.js via importScripts; placeholder fallback https://YOUR-WORKER.workers.dev
service-worker.js:5 recordingTabId Tab ID of the recorded tab (filters telemetry, routes commands)
service-worker.js:6 recordingStartTime Recording epoch; passed to page-script for relative event timestamps
service-worker.js:8 isPaused Pause flag; mirrored to chrome.storage.local key isPaused; rehydrated on SW restart
index.js:3-9 CORS_HEADERS Allow-Origin: * (all routes)
index.js upload MAX_VIDEO_SIZE 50 MB
index.js upload MAX_TELEMETRY_SIZE 10 MB
JWT storage key 'jwtToken' in chrome.storage.local

API Endpoints

Method Route Auth Handler Notes
POST /api/auth/login Password body handleLogin Returns JWT
POST /api/upload JWT Bearer withAuth(handleUpload) FormData: video + telemetry Blob
GET /api/session/:id None handleRetrieve Returns metadata + R2 URLs
GET /api/dashboard/sessions JWT withAuth(handleListSessions) Paginated (?page=N)
DELETE /api/dashboard/sessions/:id JWT withAuth(handleDeleteSession) R2 + D1 cleanup
GET /bugreplay_{id}/video.webm None handleR2Proxy Streams from R2
GET /bugreplay_{id}/telemetry.json None handleR2Proxy Returns from R2
GET / None LANDING_HTML Landing page
GET /dashboard None DASHBOARD_HTML Dashboard SPA
GET /{sessionId} None VIEWER_HTML Viewer SPA (regex: /^[a-zA-Z0-9_-]+$/)

Router: Sequential if-chain in index.js fetch handler. No framework.

Auth middleware: withAuth(handler) — verifies Bearer token via authenticateRequest, passes auth.payload as 3rd arg.


D1 Schema

CREATE TABLE IF NOT EXISTS sessions (
  id         TEXT PRIMARY KEY,
  video_key  TEXT NOT NULL,
  telem_key  TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  browser    TEXT,
  os         TEXT,
  viewport   TEXT,
  url        TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at DESC);

R2 keys: {id}/video.webm, {id}/telemetry.json


Telemetry Event Schema

Every event: { type, timestamp, data }

type data fields
console level (log/warn/error/info), args
network method, url, requestHeaders, requestBody, status, responseHeaders, responseBody, duration
click target (CSS selector), x, y
navigation from, to
environment userAgent, viewportWidth, viewportHeight, url

Extension Data Flow

  1. Popup sends startRecording/stopRecording message to service worker
  2. SW captures recordingTabId from active tab; for tab mode calls chrome.tabCapture.getMediaStreamId(), for screen mode offscreen will call getDisplayMedia()
  3. SW creates/ensures offscreen document, forwards start message with streamId + JWT + backendUrl
  4. Offscreen: MediaRecorder (VP9, VP8 fallback), timeslice: 5000; buffers chunks in data[] and telemetry in telemetry[]
  5. Content scripts run independently: injector.js relays window.postMessage from page-script.js (MAIN world) to SW, which forwards to offscreen (filtered by recordingTabId); page-script wraps console/fetch/XHR + captures clicks/nav/environment
  6. On stop: SW sends stop-recording to offscreen; offscreen flushes MediaRecorder, builds FormData from data[] + telemetry[], uploads. SW concurrently sends stopTelemetry to the recorded tab.
  7. Upload: POST /api/upload with JWT Bearer → returns { id, viewerUrl }
  8. Offscreen notifies SW (recording-complete/recording-error); SW persists result to chrome.storage.local as pendingResult and closes the offscreen document; popup reads pendingResult on open if it was closed during upload → copies URL via navigator.clipboard.writeText()

Pause/Resume Flow (via floating widget)

  1. On startTelemetry reaching the recorded tab, widget.js (isolated world, document_idle) shows a draggable Shadow-DOM widget with Pause + Stop buttons. It queries getRecordingState on load to show only on the recorded tab (recordingThisTab) and to recover the correct paused state.
  2. Pause click → SW pauseRecording → SW sets/persists isPaused, sends pause-recording to offscreen and pauseTelemetry to the recorded tab.
  3. Offscreen calls MediaRecorder.pause() and gates its telemetry buffer with !paused (events arriving during pause are dropped).
  4. injector.js relays pauseTelemetrybugreplay-pause; page-script.js flips isRecording=false. This is provably sufficient to halt all telemetry — every emission funnels through the single sendTelemetry gate.
  5. Resume is the mirror. Stop while paused is valid (MediaRecorder.stop() works from the paused state).
  6. Timestamp rebase lives in the offscreen (rebaseTelemetry), NOT page-script: page-script is re-injected fresh on every navigation (its state resets), so a local accumulator would lose state. On stop, each event's timestamp is reduced by the paused duration occurring strictly before the event's wall time; durationMs subtracts total paused time. This keeps the telemetry timeline aligned with the paused video.
  7. startTelemetry carries a paused flag so a widget injected mid-pause (tab navigation) shows the correct state immediately.

Viewer Sync Engine (VIEWER_JS)

  1. Fetch /api/session/{id} → get videoUrl + telemetryUrl
  2. Sort telemetry by timestamp (required for binary search)
  3. On timeupdate (~4x/sec): binary search for events ≤ currentTime
  4. Incremental DOM render — full rebuild only on seek backward or tab switch
  5. Auto-scroll only when user is at bottom (<60px from end)
  6. Tabs: Console | Network | Events

Auth Flow

Creator ──(password)──► POST /api/auth/login ──► JWT (HS256, 30d)
Creator ──(JWT)────────► protected endpoints
Viewer  ──(no auth)────► public endpoints

JWT: jose library, issuer 'bugreplay-app', audience 'bugreplay-viewer'

Secrets set via wrangler secret put: AUTH_PASSWORD, JWT_SECRET


Known Gaps (Deliberately Skipped)

Issue Why
Path traversal in route matching Private repo, no public attack surface
Timing attack on password compare Requires network-level attacker
Math.random() ID generation Negligible collision risk for single user
CORS * on all endpoints Localhost/dev only
Hardcoded BACKEND_URL in extension Single-deployment use
XHR request headers not captured Incomplete but functional
Response body capture (10KB limit) Acceptable for single-user use

Deployment

wrangler r2 bucket create bugreplay-storage
wrangler d1 create bugreplay-db
# Update database_id in wrangler.toml

echo "your_password" | wrangler secret put AUTH_PASSWORD
openssl rand -base64 32 | wrangler secret put JWT_SECRET

wrangler d1 migrations apply bugreplay-db --remote
wrangler deploy

Code Patterns

  • No comments in code
  • Vanilla JS — no React, no Tailwind, no CSS libs
  • Cloudflare Workers — ES module export { fetch(request, env) }
  • Template literals — all HTML/CSS/JS embedded in index.js
  • MV3 offscreen patternMediaRecorder cannot run in the service worker; offscreen document owns capture + upload + telemetry buffer for the full recording
  • creatingOffscreen memo — guards against double createDocument races
  • Binary search — O(log n) telemetry sync, handles 10k+ events
  • Content script bridge — main world (page APIs) ↔ isolated world (messaging)
  • SVG-safe selectors — use getAttribute('class') not className
  • Upload reliability — 3 attempts, linear backoff (1s, 2s); R2 rollback on D1 failure
  • escapeHtml() — always sanitize before innerHTML in viewer
  • isWrapped guard — page-script wrappers installed once per page load (re-injected on tab navigation via chrome.tabs.onUpdated)
  • sendToRecordingTab — routes commands to the recorded tab by ID (not active tab)
  • Client-side duration — wall-clock Date.now() - recordingStartTime computed in offscreen, minus any paused time; sent via FormData duration field; viewer falls back to video.seekable.end() if absent
  • pendingResult storage — upload result survives popup closure via chrome.storage.local
  • isPaused persistence — pause flag mirrored to chrome.storage.local so SW restarts and widget re-injection (tab navigation) recover the correct state
  • Pause rebase is in offscreenrebaseTelemetry() subtracts pre-event paused intervals on stop; centralized offscreen because page-script resets on every navigation
  • Widget click exclusionpage-script.js click capture skips closest('[data-bugreplay-widget]') (Shadow-DOM clicks retarget to the host)