HTMLradar

HTMLRadar · Engineering

How I built HTMLRadar in three packages.

2026-05-14  ·  5 min read  ·  Engineering

Sender browserdashboardpackages/appNext.js · CF PagesRecipient browserreads the deck/r/{slug}packages/proxyCF Workerpackages/tracker14 KB IIFESupabaseauth · postgres · vaultR2document HTMLSupabasesessions · eventsfetch HTMLrpc
Two browsers, three packages, one database

I built HTMLRadar because I wanted to send investor updates as HTML, branded the way the rest of my company's site is branded, not flattened into a PDF. DocSend and the other tracking tools only accept PDFs. So I built a tracker for HTML decks for myself, then realised other founders had the same problem and open-sourced it.

The pattern is bigger than one founder's preference. Teams that use LLMs heavily ship more and more of their work as HTML — specs, design mocks, reports, dashboards, internal briefs. ChatGPT, Claude, v0, Lovable, and Anthropic Artifacts all produce HTML for the things that matter. PDFs are a pre-LLM artifact; the new deliverable is HTML. The analytics tooling stayed on PDFs. That's the gap HTMLRadar tries to fill.

HTMLRadar is open-source DocSend for HTML. You upload an HTML deck (or paste a URL), share a tracked link, and watch in real time who reads it, which sections they dwell on, when they bounce. The repo is AGPL-3.0 at github.com/htmlradar/htmlradar.

The code splits into three packages. Each runs in a different place. Each is small and does one thing.

The shape

packages/app is a Next.js 14 app on Cloudflare Pages. Dashboard, sign-in, upload, share creation — everything the sender does. The recipient never touches it.

packages/proxy is a Cloudflare Worker that owns the share URL. When someone opens htmlradar.com/r/swift-falcon-a3f2, the request hits this worker. It runs the email gate, fetches the HTML from R2 or an external URL, injects the tracker, streams the page back.

packages/tracker is a 14 KB browser IIFE. The proxy injects a single <script> tag into every served document. The tracker identifies the viewer, prompts for email if the share requires one, and streams session metrics to Supabase.

Splitting the proxy out keeps the recipient's bundle clean. The Worker has no React, no Supabase SDK, no Next runtime. It serves gated HTML with a tracker stitched in.

The tracker is 14 KB

14 KB is a tight budget. The tracker uses PostgREST directly rather than @supabase/supabase-js. The SDK is around 75 KB minified and brings a JWT lib, a fetch polyfill, and a realtime websocket client the tracker doesn't need.

packages/tracker/src/transport.ts
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/start_session`, {
  method: 'POST',
  headers: { apikey: anonKey, 'Content-Type': 'application/json' },
  body: JSON.stringify({ p_share_slug, p_email, p_fingerprint }),
});

The dwell tracker is a small state machine on the viewport. The tracker watches IntersectionObserver entries on h1[id], h2[id], h3[id] (the convention every static-site generator emits), treat the most recently-scrolled-past heading as the current section, and accumulate elapsed time on that section while the tab is visible.

Flush runs every 15 seconds and on pagehide with keepalive: true so the last second of analytics survives a close-tab. A single-flight mutex stops the 15-second timer racing the visibility-change flush during scroll-then-tab-away.

The proxy is where the security lives

Shares can require an email, a password, or both. The proxy enforces these before serving any HTML. The reader passes a gate, the proxy issues a cookie, subsequent requests skip the gate.

Gate sessions never touch the database. The cookie itself is the proof: an HMAC-SHA256 of slug + email + exp signed with one server-side secret.

packages/proxy/src/auth.ts
const payload = `${slug}.${b64email}.${exp}`;
const mac = await hmacSha256(env.SESSION_SECRET, payload);
const cookie = `${payload}.${mac}`;

No database round-trip per request. No session state to invalidate on revoke; the share's revoked_at column is the source of truth and the worker reads it on every request. Constant-time compare on verification. Unit tests cover round-trip and tamper-rejection.

This module is the one to read first if you're auditing security.

Recipient flow · /r/{slug}

  1. 01
    GET /r/{slug}
    recipient browser
  2. 02
    Gate check
    HMAC cookie or email prompt
  3. 03
    Fetch HTML
    R2 or external URL
  4. 04
    Inject <script>
    Cloudflare HTMLRewriter
  5. 05
    Stream response
    first byte to recipient
  6. 06
    start_session RPC
    tracker boots, gets token
  7. 07
    Heartbeat every 15s
    update_session, dwell + scroll

Database is the contract

Supabase Postgres is the only store. Two RPCs cover the entire writer surface for recipients: start_session and update_session. Both SECURITY DEFINER, both rate-limited, both return minimal data. The anon role has execute on these two functions and nothing else.

Rate limiting is a small rate_limits table keyed on slug + viewer identity rather than IP, because the tracker can't see the IP from the browser. Five attempts per 60s per identity. Forging viewers means fabricating distinct identities per request, which is a reasonable bar for v1.0.

Per-session bearer tokens live in sessions.token with a default of encode(gen_random_bytes(32), 'hex'). The tracker only sees the value through the RETURNING clause of start_session. Every subsequent update call must pass it back.

When a recipient session crosses the dwell threshold for the first time, a Postgres trigger fires pg_net directly at the Resend API. No queue, no Lambda, no Vercel function. The Resend API key lives in Supabase Vault and is decrypted at trigger time. Trade-off: pg_net is fire-and-forget so deliveries can fail silently; the trigger writes every dispatch to notifications_log for after-the-fact reconciliation.

The full schema is six SQL files at code/schema/001_* through 006_*. Each re-runs idempotently.

DataLives atNotes
Uploaded HTMLCloudflare R2encrypted at rest
Owner accounts + sharesSupabase PostgresRLS, owner-scoped
Recipient session + dwellSupabase Postgressessions, section_events
Email-gate cookieRecipient browserHMAC, stateless, 24h
Tracker fingerprintRecipient browserlocalStorage UUID, anon
Resend API keySupabase Vaultdecrypted at trigger time

What v1.0 doesn't do

No PDF. DocSend and Papermark already cover that surface. HTMLRadar is HTML-first by design.

No session replay. Section dwell, scroll depth, total active time. No mouse positions, no keystrokes, no DOM snapshots. Bundle stays small. Privacy bar stays higher.

No realtime websockets to the dashboard. Dashboard polls Supabase on page load; the trigger-driven email is the realtime channel. Supabase Realtime goes in when paying customers ask.

No third-party analytics. Events capture to a Supabase table that's schema-compatible with PostHog. Replay-able as a batch import if PostHog ever makes sense.

No Vercel. Cloudflare Pages runs Next.js on the edge. One fewer vendor in the dependency graph.

Takeaway

Three packages, six SQL files, two vendors (Cloudflare and Supabase). The whole thing is AGPL-3.0 at github.com/htmlradar/htmlradar. Issues and patches welcome.