GET /xrpc/app.bsky.actor.searchActorsTypeahead typeahead.waow.tech
16
fork

Configure Feed

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

split src/index.ts into modules, fix stale docs

break 1849-line single file into 16 modules with clean DAG
dependency structure. add backfill drift detection (discovered
counter). fix README and docs page to reflect that labels are
returned in search results.

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

+1848 -1762
+2 -2
README.md
··· 1 1 # typeahead 2 2 3 - community actor search for [atproto](https://atproto.com). aims to be a drop-in replacement for `app.bsky.actor.searchActorsTypeahead` — same endpoint path and query params, but returns only core fields (`did`, `handle`, `displayName`, `avatar`), not the full `profileViewBasic` surface (no moderation labels, viewer state, etc). 3 + community actor search for [atproto](https://atproto.com). aims to be a drop-in replacement for `app.bsky.actor.searchActorsTypeahead` — same endpoint path and query params, returns core fields (`did`, `handle`, `displayName`, `avatar`, `labels`) but not the full `profileViewBasic` surface (no `associated`, `viewer`, `createdAt`). 4 4 5 5 **live:** https://typeahead.waow.tech 6 6 ··· 16 16 Turso (libSQL/FTS5) 17 17 ``` 18 18 19 - - **ingester**: [zig](https://ziglang.org) on [fly.io](https://fly.io) — streams identity + profile events via [jetstream](https://docs.bsky.app/blog/jetstream), batches to worker 19 + - **ingester**: [zig](https://ziglang.org) on [fly.io](https://fly.io) — streams profiles, posts, likes, and follows via [jetstream](https://docs.bsky.app/blog/jetstream), batches to worker 20 20 - **worker**: [cloudflare worker](https://workers.cloudflare.com) + [Turso](https://turso.tech) + KV + cache API — FTS5 prefix search, edge-cached (60s), rate-limited 21 21 - **identity**: [slingshot](https://microcosm.blue) for on-demand handle resolution 22 22
+78
src/backfill.ts
··· 1 + import type { TursoDB } from "./db"; 2 + import type { Env } from "./types"; 3 + import { BSKY_TYPEAHEAD_URL } from "./types"; 4 + import { shouldHide } from "./moderation"; 5 + import { extractAvatarCid } from "./utils"; 6 + 7 + // --- backfill: remove this block once at parity with Bluesky --- 8 + 9 + export async function backfillFromBsky( 10 + term: string, 11 + limit: number, 12 + db: TursoDB, 13 + ): Promise<void> { 14 + try { 15 + const res = await fetch( 16 + `${BSKY_TYPEAHEAD_URL}?q=${encodeURIComponent(term)}&limit=${limit}` 17 + ); 18 + if (!res.ok) return; // 429 or other error — just bail 19 + 20 + const data: any = await res.json(); 21 + const actors: any[] = (data.actors || []).filter((a: any) => a.did); 22 + if (actors.length === 0) return; 23 + 24 + // drift detection: check which DIDs we already know about 25 + const placeholders = actors.map(() => '?').join(','); 26 + const existing = await db.prepare( 27 + `SELECT did FROM actors WHERE did IN (${placeholders})` 28 + ).bind(...actors.map(a => a.did)).all<{ did: string }>(); 29 + const known = new Set((existing.results || []).map(r => r.did)); 30 + 31 + // upsert all — fills in missing actors AND enriches existing ones 32 + // (e.g. actors ingested via Jetstream that lack avatar/displayName) 33 + const stmts = actors.map((a) => 34 + db.prepare( 35 + `INSERT INTO actors (did, handle, display_name, avatar_url, hidden, labels, updated_at) 36 + VALUES (?1, ?2, ?3, ?4, ?5, ?6, unixepoch()) 37 + ON CONFLICT(did) DO UPDATE SET 38 + handle = COALESCE(NULLIF(?2, ''), actors.handle), 39 + display_name = COALESCE(NULLIF(?3, ''), actors.display_name), 40 + avatar_url = COALESCE(NULLIF(?4, ''), actors.avatar_url), 41 + hidden = ?5, 42 + labels = ?6, 43 + updated_at = unixepoch()` 44 + ).bind( 45 + a.did, 46 + a.handle || '', 47 + a.displayName || '', 48 + extractAvatarCid(a.avatar || ''), 49 + shouldHide(a.labels) ? 1 : 0, 50 + JSON.stringify(a.labels || []) 51 + ) 52 + ); 53 + 54 + await db.batch(stmts); 55 + 56 + const discovered = actors.filter(a => !known.has(a.did)).length; 57 + console.log(JSON.stringify({ event: "backfill", term, upserted: actors.length, discovered })); 58 + } catch { 59 + // best-effort — don't let backfill errors affect anything 60 + } 61 + } 62 + 63 + export async function throttledBackfill(term: string, limit: number, db: TursoDB, env: Env): Promise<void> { 64 + // kill switch — set KV key "backfill" to "off" to disable without redeploying 65 + const flag = await env.KV.get("backfill"); 66 + if (flag === "off") return; 67 + 68 + // global budget — cap total backfill triggers across all users 69 + const { success } = await env.RATE_LIMITER_STRICT.limit({ key: "backfill" }); 70 + if (!success) { 71 + console.log(JSON.stringify({ event: "backfill_throttled", term })); 72 + return; 73 + } 74 + 75 + return backfillFromBsky(term, limit, db); 76 + } 77 + 78 + // --- end backfill ---
+76
src/cron.ts
··· 1 + import type { Stmt, TursoDB } from "./db"; 2 + import type { Env } from "./types"; 3 + import { BSKY_GET_PROFILES_URL } from "./types"; 4 + import { shouldHide } from "./moderation"; 5 + import { extractAvatarCid } from "./utils"; 6 + 7 + /** refresh moderation labels, walking the full index over multiple cron runs */ 8 + export async function refreshModeration(db: TursoDB, env: Env): Promise<void> { 9 + // resume where we left off (rowid cursor persisted in KV) 10 + const cursorStr = await env.KV.get("mod_cursor"); 11 + const cursor = cursorStr ? Number(cursorStr) : 0; 12 + 13 + const { results } = await db.prepare( 14 + "SELECT rowid, did FROM actors WHERE rowid > ?1 ORDER BY rowid ASC LIMIT 1000" 15 + ).bind(cursor).all<{ rowid: number; did: string }>(); 16 + 17 + if (!results || results.length === 0) { 18 + // wrapped around — reset cursor for next run 19 + await env.KV.put("mod_cursor", "0"); 20 + console.log(JSON.stringify({ event: "moderation_refresh", status: "wrapped", cursor })); 21 + return; 22 + } 23 + 24 + let checked = 0; 25 + let changed = 0; 26 + 27 + // batch into groups of 25 (getProfiles limit), ~200ms pause between calls 28 + for (let i = 0; i < results.length; i += 25) { 29 + const batch = results.slice(i, i + 25); 30 + const params = batch.map((r) => `actors=${encodeURIComponent(r.did)}`).join("&"); 31 + try { 32 + if (i > 0) await new Promise((r) => setTimeout(r, 200)); 33 + 34 + const res = await fetch(`${BSKY_GET_PROFILES_URL}?${params}`); 35 + if (res.status === 429) { 36 + // save progress and bail — pick up here next run 37 + const lastRowid = results[Math.max(0, i - 1)].rowid; 38 + await env.KV.put("mod_cursor", String(lastRowid)); 39 + console.log(JSON.stringify({ event: "moderation_refresh", status: "rate_limited", checked, changed, cursor: lastRowid })); 40 + return; 41 + } 42 + if (!res.ok) continue; 43 + 44 + const data: any = await res.json(); 45 + const profiles: any[] = data.profiles || []; 46 + checked += profiles.length; 47 + 48 + const stmts: Stmt[] = []; 49 + for (const p of profiles) { 50 + const hide = shouldHide(p.labels) ? 1 : 0; 51 + const avatarCid = extractAvatarCid(p.avatar || ''); 52 + stmts.push( 53 + db.prepare( 54 + `UPDATE actors SET hidden = ?1, 55 + handle = COALESCE(NULLIF(?3, ''), handle), 56 + display_name = COALESCE(NULLIF(?4, ''), display_name), 57 + avatar_url = COALESCE(NULLIF(?5, ''), avatar_url), 58 + labels = ?6 59 + WHERE did = ?2` 60 + ).bind(hide, p.did, p.handle || '', p.displayName || '', avatarCid, JSON.stringify(p.labels || [])) 61 + ); 62 + } 63 + if (stmts.length > 0) { 64 + const batchResults = await db.batch(stmts); 65 + changed += batchResults.filter((r) => r.meta.changes > 0).length; 66 + } 67 + } catch { 68 + // best-effort — skip failures 69 + } 70 + } 71 + 72 + // save cursor at end of this page 73 + const lastRowid = results[results.length - 1].rowid; 74 + await env.KV.put("mod_cursor", String(lastRowid)); 75 + console.log(JSON.stringify({ event: "moderation_refresh", checked, changed, cursor: lastRowid })); 76 + }
+54
src/db.ts
··· 1 + import { createClient, type Client } from "@libsql/client/web"; 2 + import type { Env } from "./types"; 3 + 4 + export interface Stmt { 5 + bind(...args: unknown[]): Stmt; 6 + all<T = Record<string, unknown>>(): Promise<{ results: T[] }>; 7 + first<T = Record<string, unknown>>(): Promise<T | null>; 8 + run(): Promise<{ meta: { changes: number } }>; 9 + } 10 + 11 + export interface TursoDB { 12 + prepare(sql: string): Stmt; 13 + batch(stmts: Stmt[]): Promise<{ results: unknown[]; meta: { changes: number } }[]>; 14 + } 15 + 16 + export function tursoDb(client: Client): TursoDB { 17 + return { 18 + prepare(sql) { 19 + let args: unknown[] = []; 20 + const s: Stmt & { _sql: string; _args: () => unknown[] } = { 21 + _sql: sql, 22 + _args: () => args, 23 + bind(...a) { args = a; return s; }, 24 + async all<T>() { 25 + const r = await client.execute({ sql, args: args as any }); 26 + return { results: r.rows as unknown as T[] }; 27 + }, 28 + async first<T>() { 29 + const r = await client.execute({ sql, args: args as any }); 30 + return (r.rows[0] as unknown as T) ?? null; 31 + }, 32 + async run() { 33 + const r = await client.execute({ sql, args: args as any }); 34 + return { meta: { changes: r.rowsAffected } }; 35 + }, 36 + }; 37 + return s; 38 + }, 39 + async batch(stmts) { 40 + const results = await client.batch( 41 + stmts.map((s) => ({ sql: (s as any)._sql as string, args: (s as any)._args() as any[] })), 42 + "write", 43 + ); 44 + return results.map((r) => ({ 45 + results: r.rows as unknown[], 46 + meta: { changes: r.rowsAffected }, 47 + })); 48 + }, 49 + }; 50 + } 51 + 52 + export function createDb(env: Env): TursoDB { 53 + return tursoDb(createClient({ url: env.TURSO_URL, authToken: env.TURSO_AUTH_TOKEN })); 54 + }
+158
src/enrichment.ts
··· 1 + import type { Stmt, TursoDB } from "./db"; 2 + import type { Env, SlingshotResponse } from "./types"; 3 + import { SLINGSHOT_URL, BSKY_GET_PROFILES_URL } from "./types"; 4 + import { shouldHide } from "./moderation"; 5 + import { extractAvatarCid } from "./utils"; 6 + 7 + /** record an actor-count snapshot for the current hour (idempotent) */ 8 + export async function recordSnapshot(db: TursoDB): Promise<void> { 9 + const hour = Math.floor(Date.now() / 3_600_000); 10 + const row = await db.prepare( 11 + `SELECT COUNT(*) AS total, 12 + SUM(CASE WHEN handle != '' THEN 1 ELSE 0 END) AS with_handles, 13 + SUM(CASE WHEN avatar_url != '' THEN 1 ELSE 0 END) AS with_avatars, 14 + SUM(CASE WHEN hidden != 0 THEN 1 ELSE 0 END) AS hidden 15 + FROM actors` 16 + ).first<{ total: number; with_handles: number; with_avatars: number; hidden: number }>(); 17 + if (row) { 18 + await db.prepare( 19 + `INSERT OR REPLACE INTO snapshots (hour, total, with_handles, with_avatars, hidden) 20 + VALUES (?1, ?2, ?3, ?4, ?5)` 21 + ) 22 + .bind(hour, row.total - (row.hidden ?? 0), row.with_handles, row.with_avatars, row.hidden ?? 0) 23 + .run(); 24 + } 25 + } 26 + 27 + /** lease-coordinated, two-phase enrichment: slingshot identity + getProfiles batch. 28 + * returns { resolved, enriched } counts for delta tracking. */ 29 + export async function enrichActors(db: TursoDB, env: Env): Promise<{ resolved: number; enriched: number }> { 30 + // lease via KV — 30s TTL, skip if another run is active 31 + const existing = await env.KV.get("enrich_lock"); 32 + if (existing) return { resolved: 0, enriched: 0 }; 33 + await env.KV.put("enrich_lock", "1", { expirationTtl: 30 }); 34 + 35 + let totalResolved = 0; 36 + let totalEnriched = 0; 37 + 38 + try { 39 + // phase 1: identity resolution via slingshot 40 + const { results: identityRows } = await db.prepare( 41 + `SELECT did FROM actors 42 + WHERE handle = '' AND identity_checked_at < unixepoch() - 3600 43 + ORDER BY identity_checked_at ASC LIMIT 100` 44 + ).all<{ did: string }>(); 45 + 46 + if (identityRows && identityRows.length > 0) { 47 + let resolved = 0; 48 + const BATCH = 20; 49 + for (let i = 0; i < identityRows.length; i += BATCH) { 50 + const batch = identityRows.slice(i, i + BATCH); 51 + await Promise.all(batch.map(async ({ did }) => { 52 + try { 53 + const res = await fetch( 54 + `${SLINGSHOT_URL}?identifier=${encodeURIComponent(did)}` 55 + ); 56 + if (!res.ok) { 57 + // mark attempt so we back off for 1hr 58 + await db.prepare( 59 + "UPDATE actors SET identity_checked_at = unixepoch() WHERE did = ?1" 60 + ).bind(did).run(); 61 + return; 62 + } 63 + const identity: SlingshotResponse = await res.json(); 64 + await db.prepare( 65 + `UPDATE actors SET handle = COALESCE(NULLIF(?1, ''), handle), 66 + pds = COALESCE(NULLIF(?2, ''), pds), 67 + identity_checked_at = unixepoch() 68 + WHERE did = ?3` 69 + ).bind(identity.handle || '', identity.pds || '', did).run(); 70 + if (identity.handle) resolved++; 71 + } catch { 72 + await db.prepare( 73 + "UPDATE actors SET identity_checked_at = unixepoch() WHERE did = ?1" 74 + ).bind(did).run().catch(() => {}); 75 + } 76 + })); 77 + } 78 + totalResolved = resolved; 79 + if (resolved > 0) { 80 + console.log(JSON.stringify({ event: "enrich_identity", resolved, checked: identityRows.length })); 81 + } 82 + } 83 + 84 + // phase 2: profile + labels enrichment via getProfiles batch 85 + // targets actors missing avatar OR labels — one API call per 25 actors 86 + const { results: profileRows } = await db.prepare( 87 + `SELECT did FROM actors 88 + WHERE handle != '' 89 + AND (avatar_url = '' OR labels = '[]') 90 + AND profile_checked_at < unixepoch() - 3600 91 + ORDER BY profile_checked_at ASC LIMIT 75` 92 + ).all<{ did: string }>(); 93 + 94 + if (profileRows && profileRows.length > 0) { 95 + let enriched = 0; 96 + for (let i = 0; i < profileRows.length; i += 25) { 97 + const batch = profileRows.slice(i, i + 25); 98 + const params = batch.map((r) => `actors=${encodeURIComponent(r.did)}`).join("&"); 99 + try { 100 + if (i > 0) await new Promise((r) => setTimeout(r, 200)); 101 + const res = await fetch(`${BSKY_GET_PROFILES_URL}?${params}`); 102 + if (res.status === 429) { 103 + console.log(JSON.stringify({ event: "enrich_profile", status: "rate_limited", enriched })); 104 + break; 105 + } 106 + if (!res.ok) continue; 107 + 108 + const data: any = await res.json(); 109 + const profiles: any[] = data.profiles || []; 110 + 111 + const stmts: Stmt[] = []; 112 + const returned = new Set<string>(); 113 + for (const p of profiles) { 114 + returned.add(p.did); 115 + const avatarCid = extractAvatarCid(p.avatar || ''); 116 + const hide = shouldHide(p.labels) ? 1 : 0; 117 + stmts.push( 118 + db.prepare( 119 + `UPDATE actors SET 120 + handle = COALESCE(NULLIF(?2, ''), handle), 121 + display_name = COALESCE(NULLIF(?3, ''), display_name), 122 + avatar_url = COALESCE(NULLIF(?4, ''), avatar_url), 123 + labels = ?5, hidden = ?6, 124 + profile_checked_at = unixepoch() 125 + WHERE did = ?1` 126 + ).bind( 127 + p.did, p.handle || '', p.displayName || '', 128 + avatarCid, JSON.stringify(p.labels || []), hide 129 + ) 130 + ); 131 + if (avatarCid) enriched++; 132 + } 133 + // mark actors not returned by getProfiles so we back off 134 + for (const r of batch) { 135 + if (!returned.has(r.did)) { 136 + stmts.push( 137 + db.prepare( 138 + "UPDATE actors SET profile_checked_at = unixepoch() WHERE did = ?1" 139 + ).bind(r.did) 140 + ); 141 + } 142 + } 143 + if (stmts.length > 0) await db.batch(stmts); 144 + } catch { 145 + // best-effort — skip failures 146 + } 147 + } 148 + totalEnriched = enriched; 149 + if (enriched > 0) { 150 + console.log(JSON.stringify({ event: "enrich_profile", enriched, checked: profileRows.length })); 151 + } 152 + } 153 + } finally { 154 + await env.KV.delete("enrich_lock").catch(() => {}); 155 + } 156 + 157 + return { resolved: totalResolved, enriched: totalEnriched }; 158 + }
+131
src/handlers/admin.ts
··· 1 + import type { TursoDB } from "../db"; 2 + import type { Env, SlingshotResponse } from "../types"; 3 + import { SLINGSHOT_URL } from "../types"; 4 + import { clientIP, json, extractAvatarCid } from "../utils"; 5 + import { shouldHide } from "../moderation"; 6 + import { recordActorDelta } from "../metrics"; 7 + 8 + export async function handleDelete( 9 + request: Request, 10 + db: TursoDB, 11 + env: Env, 12 + ctx: ExecutionContext, 13 + ): Promise<Response> { 14 + const auth = request.headers.get("Authorization"); 15 + if (auth !== `Bearer ${env.ADMIN_SECRET}`) { 16 + const ip = clientIP(request); 17 + await env.RATE_LIMITER_STRICT.limit({ key: `auth:${ip}` }); 18 + console.log(JSON.stringify({ event: "auth_failure", endpoint: "/admin/delete", ip })); 19 + return json({ error: "unauthorized" }, 401); 20 + } 21 + 22 + let body: { dids: string[] }; 23 + try { 24 + body = await request.json(); 25 + } catch { 26 + return json({ error: "invalid json" }, 400); 27 + } 28 + 29 + const { dids } = body; 30 + if (!Array.isArray(dids) || dids.length === 0) { 31 + return json({ error: "dids must be a non-empty array" }, 400); 32 + } 33 + if (dids.length > 10_000) { 34 + return json({ error: "batch too large (max 10000)" }, 400); 35 + } 36 + 37 + const stmts = dids.map((did) => 38 + db.prepare("DELETE FROM actors WHERE did = ?1").bind(did) 39 + ); 40 + const batchResults = await db.batch(stmts); 41 + const actualDeletes = batchResults.reduce((s, r) => s + r.meta.changes, 0); 42 + 43 + if (actualDeletes > 0) { 44 + ctx.waitUntil(recordActorDelta(db, { actors: -actualDeletes })); 45 + } 46 + 47 + return json({ ok: true, deleted: dids.length }); 48 + } 49 + 50 + export async function handleCursor( 51 + request: Request, 52 + env: Env 53 + ): Promise<Response> { 54 + const auth = request.headers.get("Authorization"); 55 + if (auth !== `Bearer ${env.ADMIN_SECRET}`) { 56 + const ip = clientIP(request); 57 + await env.RATE_LIMITER_STRICT.limit({ key: `auth:${ip}` }); 58 + console.log(JSON.stringify({ event: "auth_failure", endpoint: "/admin/cursor", ip })); 59 + return json({ error: "unauthorized" }, 401); 60 + } 61 + 62 + const cursor = await env.KV.get("jetstream_cursor"); 63 + return json({ cursor: cursor ? Number(cursor) : null }); 64 + } 65 + 66 + /** resolve a handle or DID via slingshot, then upsert into the active DB */ 67 + export async function handleRequestIndexing( 68 + request: Request, 69 + db: TursoDB, 70 + env: Env, 71 + ): Promise<Response> { 72 + const url = new URL(request.url); 73 + const identifier = 74 + url.searchParams.get("handle") || 75 + url.searchParams.get("did") || 76 + ""; 77 + 78 + if (!identifier) { 79 + return json({ error: "enter a handle or DID to request indexing." }, 400); 80 + } 81 + 82 + // resolve via slingshot 83 + const res = await fetch( 84 + `${SLINGSHOT_URL}?identifier=${encodeURIComponent(identifier)}` 85 + ); 86 + if (!res.ok) { 87 + return json({ error: `could not resolve "${identifier}". check that it's a valid handle or DID.` }, 404); 88 + } 89 + 90 + const identity: SlingshotResponse = await res.json(); 91 + 92 + // fetch profile from public API for display name + avatar + labels 93 + let displayName = ""; 94 + let avatarCid = ""; 95 + let hidden = false; 96 + let labelsJson = "[]"; 97 + try { 98 + const profileRes = await fetch( 99 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(identity.did)}` 100 + ); 101 + if (profileRes.ok) { 102 + const profile: any = await profileRes.json(); 103 + displayName = profile.displayName || ""; 104 + avatarCid = extractAvatarCid(profile.avatar || ""); 105 + hidden = shouldHide(profile.labels); 106 + labelsJson = JSON.stringify(profile.labels || []); 107 + } 108 + } catch { 109 + // profile enrichment is best-effort 110 + } 111 + 112 + await db.prepare( 113 + `INSERT INTO actors (did, handle, display_name, avatar_url, hidden, labels, updated_at) 114 + VALUES (?1, ?2, ?3, ?4, ?5, ?6, unixepoch()) 115 + ON CONFLICT(did) DO UPDATE SET 116 + handle = ?2, 117 + display_name = COALESCE(NULLIF(?3, ''), actors.display_name), 118 + avatar_url = COALESCE(NULLIF(?4, ''), actors.avatar_url), 119 + hidden = ?5, 120 + labels = ?6, 121 + updated_at = unixepoch()` 122 + ) 123 + .bind(identity.did, identity.handle, displayName, avatarCid, hidden ? 1 : 0, labelsJson) 124 + .run(); 125 + 126 + return json({ 127 + handle: identity.handle, 128 + did: identity.did, 129 + ...(hidden ? { hidden: true, reason: "hidden by moderation" } : { hidden: false }), 130 + }); 131 + }
+98
src/handlers/ingest.ts
··· 1 + import type { TursoDB } from "../db"; 2 + import type { Env, IngestEvent } from "../types"; 3 + import { clientIP, json } from "../utils"; 4 + import { recordActorDelta } from "../metrics"; 5 + import { enrichActors } from "../enrichment"; 6 + 7 + export async function handleIngest( 8 + request: Request, 9 + db: TursoDB, 10 + env: Env, 11 + ctx: ExecutionContext, 12 + ): Promise<Response> { 13 + const auth = request.headers.get("Authorization"); 14 + if (auth !== `Bearer ${env.ADMIN_SECRET}`) { 15 + const ip = clientIP(request); 16 + await env.RATE_LIMITER_STRICT.limit({ key: `auth:${ip}` }); 17 + console.log(JSON.stringify({ event: "auth_failure", endpoint: "/admin/ingest", ip })); 18 + return json({ error: "unauthorized" }, 401); 19 + } 20 + 21 + let body: { events: IngestEvent[]; cursor?: number }; 22 + try { 23 + body = await request.json(); 24 + } catch { 25 + return json({ error: "invalid json" }, 400); 26 + } 27 + 28 + const { events, cursor } = body; 29 + if (!Array.isArray(events) || events.length === 0) { 30 + return json({ error: "events must be a non-empty array" }, 400); 31 + } 32 + if (events.length > 10_000) { 33 + return json({ error: "batch too large (max 10000)" }, 400); 34 + } 35 + 36 + // batch upsert — bare-DID events use INSERT OR IGNORE (0 Turso writes for known actors), 37 + // profile/identity events use full UPSERT with COALESCE to preserve existing fields 38 + const stmts = events.map((e) => { 39 + const isBareDID = !e.handle && !e.display_name && !e.avatar_cid; 40 + if (isBareDID) { 41 + return db.prepare( 42 + "INSERT OR IGNORE INTO actors (did) VALUES (?1)" 43 + ).bind(e.did); 44 + } 45 + const avatarCid = e.avatar_cid || null; 46 + return db.prepare( 47 + `INSERT INTO actors (did, handle, display_name, avatar_url, updated_at) 48 + VALUES (?1, ?2, ?3, ?4, unixepoch()) 49 + ON CONFLICT(did) DO UPDATE SET 50 + handle = COALESCE(NULLIF(?2, ''), actors.handle), 51 + display_name = COALESCE(NULLIF(?3, ''), actors.display_name), 52 + avatar_url = COALESCE(NULLIF(?4, ''), actors.avatar_url), 53 + updated_at = unixepoch()` 54 + ).bind( 55 + e.did, 56 + e.handle || '', 57 + e.display_name || '', 58 + avatarCid || '' 59 + ); 60 + }); 61 + 62 + let batchResults: { results: unknown[]; meta: { changes: number } }[]; 63 + try { 64 + batchResults = await db.batch(stmts); 65 + } catch (e: any) { 66 + console.log(JSON.stringify({ event: "ingest_error", error: e?.message, count: events.length })); 67 + return json({ error: e?.message || "db batch failed" }, 500); 68 + } 69 + 70 + // count newly inserted bare DIDs (changes === 1 means INSERT succeeded, not IGNORE'd) 71 + let newActors = 0; 72 + for (let i = 0; i < events.length; i++) { 73 + const e = events[i]; 74 + const isBareDID = !e.handle && !e.display_name && !e.avatar_cid; 75 + if (isBareDID && batchResults[i]?.meta.changes === 1) newActors++; 76 + } 77 + if (newActors > 0) { 78 + ctx.waitUntil(recordActorDelta(db, { actors: newActors })); 79 + } 80 + 81 + if (cursor !== undefined) { 82 + try { 83 + await env.KV.put("jetstream_cursor", String(cursor)); 84 + } catch { 85 + // best-effort — don't fail the ingest if KV write fails (e.g. quota) 86 + } 87 + } 88 + 89 + ctx.waitUntil( 90 + enrichActors(db, env).then(({ resolved, enriched }) => { 91 + if (resolved > 0 || enriched > 0) { 92 + return recordActorDelta(db, { handles: resolved, avatars: enriched }); 93 + } 94 + }), 95 + ); 96 + 97 + return json({ ok: true, ingested: events.length }); 98 + }
+85
src/handlers/search.ts
··· 1 + import type { TursoDB } from "../db"; 2 + import type { ActorRow, Env } from "../types"; 3 + import { json, sanitize, avatarUrl } from "../utils"; 4 + import { recordMetric, recordTrafficSource } from "../metrics"; 5 + import { throttledBackfill } from "../backfill"; 6 + 7 + export async function handleSearch( 8 + request: Request, 9 + db: TursoDB, 10 + env: Env, 11 + ctx: ExecutionContext, 12 + ): Promise<Response> { 13 + const url = new URL(request.url); 14 + const q = url.searchParams.get("q") || url.searchParams.get("term") || ""; 15 + const limitRaw = url.searchParams.get("limit"); 16 + const limitParam = limitRaw !== null ? parseInt(limitRaw, 10) : 10; 17 + 18 + if (!q.trim()) { 19 + return json({ error: "InvalidRequest", message: "Error: Params must have the property \"q\"" }, 400); 20 + } 21 + if (isNaN(limitParam) || limitParam < 1 || limitParam > 100) { 22 + return json({ error: "InvalidRequest", message: "Error: limit must be between 1 and 100" }, 400); 23 + } 24 + 25 + const limit = limitParam; 26 + const term = sanitize(q); 27 + if (!term) { 28 + return json({ actors: [] }); 29 + } 30 + 31 + // cache API — edge cache for hot queries 32 + const cacheKey = new Request( 33 + `https://typeahead-cache/${encodeURIComponent(term)}:${limit}`, 34 + request 35 + ); 36 + const cache = caches.default; 37 + const cached = await cache.match(cacheKey); 38 + if (cached) { 39 + return cached; 40 + } 41 + 42 + const t0 = Date.now(); 43 + 44 + const ftsQuery = `"${term}"*`; 45 + const { results } = await db.prepare( 46 + `SELECT a.did, a.handle, a.display_name, a.avatar_url, a.labels 47 + FROM actors_fts 48 + JOIN actors a ON a.rowid = actors_fts.rowid 49 + WHERE actors_fts MATCH ?1 AND a.handle != '' AND a.hidden = 0 50 + ORDER BY rank 51 + LIMIT ?2` 52 + ) 53 + .bind(ftsQuery, limit) 54 + .all<ActorRow>(); 55 + 56 + const actors = (results || []) 57 + .map((r) => ({ 58 + did: r.did, 59 + handle: r.handle, 60 + ...(r.display_name ? { displayName: r.display_name } : {}), 61 + ...(r.avatar_url ? { avatar: avatarUrl(r.did, r.avatar_url) } : {}), 62 + labels: JSON.parse(r.labels || '[]'), 63 + })); 64 + 65 + // --- backfill: remove this block once at parity with Bluesky --- 66 + const hasGaps = actors.length < limit || actors.some((a) => !a.avatar); 67 + if (hasGaps) { 68 + ctx.waitUntil(throttledBackfill(term, limit, db, env)); 69 + } 70 + // --- end backfill --- 71 + 72 + ctx.waitUntil(Promise.all([ 73 + recordMetric(db, Date.now() - t0), 74 + recordTrafficSource(db, request), 75 + ])); 76 + 77 + const response = json({ actors }); 78 + 79 + // cache for 60 seconds 80 + const cacheable = new Response(response.body, response); 81 + cacheable.headers.set("Cache-Control", "public, max-age=60"); 82 + await cache.put(cacheKey, cacheable.clone()); 83 + 84 + return cacheable; 85 + }
+71
src/handlers/stats.ts
··· 1 + import type { TursoDB } from "../db"; 2 + import { html } from "../utils"; 3 + import { statsPage, type SnapshotPoint } from "../pages/stats"; 4 + 5 + export async function handleStats(db: TursoDB): Promise<Response> { 6 + const [metricsRes, snapshotRes, trafficRes, deltasRes] = 7 + await db.batch([ 8 + db.prepare( 9 + "SELECT hour, searches, total_ms FROM metrics ORDER BY hour DESC LIMIT 2016" 10 + ), 11 + db.prepare( 12 + "SELECT hour, total, with_handles, with_avatars, hidden FROM snapshots ORDER BY hour ASC LIMIT 2000" 13 + ), 14 + db.prepare( 15 + "SELECT domain, hits FROM traffic_sources ORDER BY hits DESC LIMIT 10" 16 + ), 17 + db.prepare( 18 + "SELECT bucket, actors_delta, handles_delta, avatars_delta FROM actor_deltas ORDER BY bucket ASC LIMIT 2016" 19 + ), 20 + ]); 21 + const rows = (metricsRes.results ?? []) as { 22 + hour: number; 23 + searches: number; 24 + total_ms: number; 25 + }[]; 26 + const dbSnapshots = (snapshotRes.results ?? []) as { hour: number; total: number; with_handles: number; with_avatars: number; hidden: number }[]; 27 + const trafficSources = (trafficRes.results ?? []) as { domain: string; hits: number }[]; 28 + const deltas = (deltasRes.results ?? []) as { bucket: number; actors_delta: number; handles_delta: number; avatars_delta: number }[]; 29 + 30 + // build snapshot points with timestamps 31 + const snapshots: SnapshotPoint[] = dbSnapshots.map((s) => ({ 32 + ts: s.hour * 3_600_000, 33 + total: s.total, 34 + with_handles: s.with_handles, 35 + with_avatars: s.with_avatars, 36 + })); 37 + 38 + // stitch deltas after last snapshot for interpolated points 39 + if (snapshots.length > 0 && deltas.length > 0) { 40 + const lastSnap = snapshots[snapshots.length - 1]; 41 + const lastSnapBucket = Math.floor(lastSnap.ts / 300_000); 42 + let cumActors = 0, cumHandles = 0, cumAvatars = 0; 43 + for (const d of deltas) { 44 + if (d.bucket <= lastSnapBucket) continue; 45 + cumActors += d.actors_delta; 46 + cumHandles += d.handles_delta; 47 + cumAvatars += d.avatars_delta; 48 + snapshots.push({ 49 + ts: d.bucket * 300_000, 50 + total: lastSnap.total + cumActors, 51 + with_handles: lastSnap.with_handles + cumHandles, 52 + with_avatars: lastSnap.with_avatars + cumAvatars, 53 + }); 54 + } 55 + } 56 + 57 + // derive current counts from the latest point (snapshot + deltas) 58 + const latest = snapshots[snapshots.length - 1] ?? { total: 0, with_handles: 0, with_avatars: 0 }; 59 + const total = latest.total; 60 + const withHandles = latest.with_handles; 61 + const withAvatars = latest.with_avatars; 62 + const hiddenCount = dbSnapshots.length > 0 ? (dbSnapshots[dbSnapshots.length - 1].hidden ?? 0) : 0; 63 + 64 + const totalSearches = rows.reduce((s, r) => s + r.searches, 0); 65 + const totalMs = rows.reduce((s, r) => s + r.total_ms, 0); 66 + const avgLatency = totalSearches > 0 ? totalMs / totalSearches : 0; 67 + const handlePct = total > 0 ? ((withHandles / total) * 100).toFixed(1) : "0"; 68 + const avatarPct = total > 0 ? ((withAvatars / total) * 100).toFixed(1) : "0"; 69 + 70 + return html(statsPage({ total, hiddenCount, rows, totalSearches, avgLatency, handlePct, avatarPct, snapshots, trafficSources })); 71 + }
+15 -1760
src/index.ts
··· 1 - import { createClient, type Client } from "@libsql/client/web"; 2 - 3 - interface Env { 4 - KV: KVNamespace; 5 - ADMIN_SECRET: string; 6 - RATE_LIMITER: RateLimit; 7 - RATE_LIMITER_STRICT: RateLimit; 8 - TURSO_URL: string; 9 - TURSO_AUTH_TOKEN: string; 10 - } 11 - 12 - interface Stmt { 13 - bind(...args: unknown[]): Stmt; 14 - all<T = Record<string, unknown>>(): Promise<{ results: T[] }>; 15 - first<T = Record<string, unknown>>(): Promise<T | null>; 16 - run(): Promise<{ meta: { changes: number } }>; 17 - } 18 - 19 - interface TursoDB { 20 - prepare(sql: string): Stmt; 21 - batch(stmts: Stmt[]): Promise<{ results: unknown[]; meta: { changes: number } }[]>; 22 - } 23 - 24 - function tursoDb(client: Client): TursoDB { 25 - return { 26 - prepare(sql) { 27 - let args: unknown[] = []; 28 - const s: Stmt & { _sql: string; _args: () => unknown[] } = { 29 - _sql: sql, 30 - _args: () => args, 31 - bind(...a) { args = a; return s; }, 32 - async all<T>() { 33 - const r = await client.execute({ sql, args: args as any }); 34 - return { results: r.rows as unknown as T[] }; 35 - }, 36 - async first<T>() { 37 - const r = await client.execute({ sql, args: args as any }); 38 - return (r.rows[0] as unknown as T) ?? null; 39 - }, 40 - async run() { 41 - const r = await client.execute({ sql, args: args as any }); 42 - return { meta: { changes: r.rowsAffected } }; 43 - }, 44 - }; 45 - return s; 46 - }, 47 - async batch(stmts) { 48 - const results = await client.batch( 49 - stmts.map((s) => ({ sql: (s as any)._sql as string, args: (s as any)._args() as any[] })), 50 - "write", 51 - ); 52 - return results.map((r) => ({ 53 - results: r.rows as unknown[], 54 - meta: { changes: r.rowsAffected }, 55 - })); 56 - }, 57 - }; 58 - } 59 - 60 - const CORS_HEADERS = { 61 - "Access-Control-Allow-Origin": "*", 62 - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", 63 - "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Client", 64 - }; 65 - 66 - const SLINGSHOT_URL = 67 - "https://slingshot.microcosm.blue/xrpc/blue.microcosm.identity.resolveMiniDoc"; 68 - 69 - const FAVICON = `<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='13' cy='13' r='9' fill='none' stroke='%2344aa99' stroke-width='2.5' opacity='0.8'/%3E%3Cline x1='20' y1='20' x2='28' y2='28' stroke='%2344aa99' stroke-width='2.5' stroke-linecap='round' opacity='0.8'/%3E%3C/svg%3E">`; 70 - 71 - function clientIP(request: Request): string { 72 - return request.headers.get("CF-Connecting-IP") || "unknown"; 73 - } 74 - 75 - function escHtml(s: string): string { 76 - return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); 77 - } 78 - 79 - function json(data: unknown, status = 200): Response { 80 - return Response.json(data, { status, headers: CORS_HEADERS }); 81 - } 82 - 83 - function avatarUrl(did: string, cidOrUrl: string): string { 84 - if (cidOrUrl.startsWith("https://")) return cidOrUrl; 85 - return `https://cdn.bsky.app/img/avatar/plain/${did}/${cidOrUrl}@jpeg`; 86 - } 87 - 88 - function extractAvatarCid(url: string): string { 89 - const match = url.match(/\/([^/]+)@jpeg$/); 90 - return match?.[1] ?? ''; 91 - } 92 - 93 - /** strip anything that could break FTS5 syntax, preserving unicode letters/digits */ 94 - function sanitize(q: string): string { 95 - return q.replace(/[^\p{L}\p{N}\s.-]/gu, "").trim(); 96 - } 97 - 98 - interface ActorRow { 99 - did: string; 100 - handle: string; 101 - display_name: string; 102 - avatar_url: string; 103 - } 104 - 105 - interface IngestEvent { 106 - did: string; 107 - handle?: string; 108 - display_name?: string; 109 - avatar_cid?: string; 110 - hidden?: boolean; 111 - } 112 - 113 - interface SlingshotResponse { 114 - did: string; 115 - handle: string; 116 - pds: string; 117 - } 118 - 119 - const BSKY_TYPEAHEAD_URL = 120 - "https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead"; 121 - 122 - const BSKY_MOD_DID = "did:plc:ar7c4by46qjdydhdevvrndac"; 123 - /** labels from bluesky's moderation service that hide an actor from search */ 124 - const MOD_HIDE_VALS = new Set(["!hide", "!takedown", "spam"]); 125 - 126 - /** 127 - * returns whether an actor should be hidden from search. 128 - * 129 - * only hides actors flagged by bluesky's moderation service (!hide, !takedown, spam). 130 - * !no-unauthenticated is intentionally NOT filtered — it applies to content, not identity. 131 - * bluesky's own public typeahead API returns !no-unauthenticated accounts, and so do we. 132 - */ 133 - function shouldHide(labels?: any[]): boolean { 134 - if (!labels) return false; 135 - const now = Date.now(); 136 - return labels.some((l: any) => { 137 - if (l.neg) return false; 138 - if (l.exp && new Date(l.exp).getTime() <= now) return false; 139 - return l.src === BSKY_MOD_DID && MOD_HIDE_VALS.has(l.val); 140 - }); 141 - } 142 - 143 - // --- backfill: remove this block once at parity with Bluesky --- 144 - 145 - async function backfillFromBsky( 146 - term: string, 147 - limit: number, 148 - db: TursoDB, 149 - ): Promise<void> { 150 - try { 151 - const res = await fetch( 152 - `${BSKY_TYPEAHEAD_URL}?q=${encodeURIComponent(term)}&limit=${limit}` 153 - ); 154 - if (!res.ok) return; // 429 or other error — just bail 155 - 156 - const data: any = await res.json(); 157 - const actors: any[] = (data.actors || []).filter((a: any) => a.did); 158 - if (actors.length === 0) return; 159 - 160 - // upsert all — fills in missing actors AND enriches existing ones 161 - // (e.g. actors ingested via Jetstream that lack avatar/displayName) 162 - const stmts = actors.map((a) => 163 - db.prepare( 164 - `INSERT INTO actors (did, handle, display_name, avatar_url, hidden, updated_at) 165 - VALUES (?1, ?2, ?3, ?4, ?5, unixepoch()) 166 - ON CONFLICT(did) DO UPDATE SET 167 - handle = COALESCE(NULLIF(?2, ''), actors.handle), 168 - display_name = COALESCE(NULLIF(?3, ''), actors.display_name), 169 - avatar_url = COALESCE(NULLIF(?4, ''), actors.avatar_url), 170 - hidden = ?5, 171 - updated_at = unixepoch()` 172 - ).bind( 173 - a.did, 174 - a.handle || '', 175 - a.displayName || '', 176 - extractAvatarCid(a.avatar || ''), 177 - shouldHide(a.labels) ? 1 : 0 178 - ) 179 - ); 180 - 181 - await db.batch(stmts); 182 - console.log(JSON.stringify({ event: "backfill", term, upserted: actors.length })); 183 - } catch { 184 - // best-effort — don't let backfill errors affect anything 185 - } 186 - } 187 - 188 - async function throttledBackfill(term: string, limit: number, db: TursoDB, env: Env): Promise<void> { 189 - // kill switch — set KV key "backfill" to "off" to disable without redeploying 190 - const flag = await env.KV.get("backfill"); 191 - if (flag === "off") return; 192 - 193 - // global budget — cap total backfill triggers across all users 194 - const { success } = await env.RATE_LIMITER_STRICT.limit({ key: "backfill" }); 195 - if (!success) { 196 - console.log(JSON.stringify({ event: "backfill_throttled", term })); 197 - return; 198 - } 199 - 200 - return backfillFromBsky(term, limit, db); 201 - } 202 - 203 - // --- end backfill --- 204 - 205 - /** record an actor-count snapshot for the current hour (idempotent) */ 206 - async function recordSnapshot(db: TursoDB): Promise<void> { 207 - const hour = Math.floor(Date.now() / 3_600_000); 208 - const row = await db.prepare( 209 - `SELECT COUNT(*) AS total, 210 - SUM(CASE WHEN handle != '' THEN 1 ELSE 0 END) AS with_handles, 211 - SUM(CASE WHEN avatar_url != '' THEN 1 ELSE 0 END) AS with_avatars 212 - FROM actors WHERE hidden = 0` 213 - ).first<{ total: number; with_handles: number; with_avatars: number }>(); 214 - if (row) { 215 - await db.prepare( 216 - `INSERT OR REPLACE INTO snapshots (hour, total, with_handles, with_avatars) 217 - VALUES (?1, ?2, ?3, ?4)` 218 - ) 219 - .bind(hour, row.total, row.with_handles, row.with_avatars) 220 - .run(); 221 - } 222 - } 223 - 224 - /** lease-coordinated, two-phase enrichment: slingshot identity + PDS-native profile. 225 - * returns { resolved, enriched } counts for delta tracking. */ 226 - async function enrichActors(db: TursoDB, env: Env): Promise<{ resolved: number; enriched: number }> { 227 - // lease via KV — 30s TTL, skip if another run is active 228 - const existing = await env.KV.get("enrich_lock"); 229 - if (existing) return { resolved: 0, enriched: 0 }; 230 - await env.KV.put("enrich_lock", "1", { expirationTtl: 30 }); 231 - 232 - let totalResolved = 0; 233 - let totalEnriched = 0; 234 - 235 - try { 236 - // phase 1: identity resolution via slingshot 237 - const { results: identityRows } = await db.prepare( 238 - `SELECT did FROM actors 239 - WHERE handle = '' AND identity_checked_at < unixepoch() - 3600 240 - ORDER BY identity_checked_at ASC LIMIT 100` 241 - ).all<{ did: string }>(); 242 - 243 - if (identityRows && identityRows.length > 0) { 244 - let resolved = 0; 245 - const BATCH = 20; 246 - for (let i = 0; i < identityRows.length; i += BATCH) { 247 - const batch = identityRows.slice(i, i + BATCH); 248 - await Promise.all(batch.map(async ({ did }) => { 249 - try { 250 - const res = await fetch( 251 - `${SLINGSHOT_URL}?identifier=${encodeURIComponent(did)}` 252 - ); 253 - if (!res.ok) { 254 - // mark attempt so we back off for 1hr 255 - await db.prepare( 256 - "UPDATE actors SET identity_checked_at = unixepoch() WHERE did = ?1" 257 - ).bind(did).run(); 258 - return; 259 - } 260 - const identity: SlingshotResponse = await res.json(); 261 - await db.prepare( 262 - `UPDATE actors SET handle = COALESCE(NULLIF(?1, ''), handle), 263 - pds = COALESCE(NULLIF(?2, ''), pds), 264 - identity_checked_at = unixepoch() 265 - WHERE did = ?3` 266 - ).bind(identity.handle || '', identity.pds || '', did).run(); 267 - if (identity.handle) resolved++; 268 - } catch { 269 - await db.prepare( 270 - "UPDATE actors SET identity_checked_at = unixepoch() WHERE did = ?1" 271 - ).bind(did).run().catch(() => {}); 272 - } 273 - })); 274 - } 275 - totalResolved = resolved; 276 - if (resolved > 0) { 277 - console.log(JSON.stringify({ event: "enrich_identity", resolved, checked: identityRows.length })); 278 - } 279 - } 280 - 281 - // phase 2: profile enrichment via PDS-native getRecord 282 - const { results: profileRows } = await db.prepare( 283 - `SELECT did, pds FROM actors 284 - WHERE handle != '' AND avatar_url = '' AND pds != '' 285 - AND profile_checked_at < unixepoch() - 3600 286 - ORDER BY profile_checked_at ASC LIMIT 20` 287 - ).all<{ did: string; pds: string }>(); 288 - 289 - if (profileRows && profileRows.length > 0) { 290 - let enriched = 0; 291 - await Promise.all(profileRows.map(async ({ did, pds }) => { 292 - try { 293 - const res = await fetch( 294 - `${pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self` 295 - ); 296 - if (!res.ok) { 297 - await db.prepare( 298 - "UPDATE actors SET profile_checked_at = unixepoch() WHERE did = ?1" 299 - ).bind(did).run(); 300 - return; 301 - } 302 - const data: any = await res.json(); 303 - const value = data?.value; 304 - const avatarCid = value?.avatar?.ref?.$link || ''; 305 - const displayName = value?.displayName || ''; 306 - await db.prepare( 307 - `UPDATE actors SET 308 - avatar_url = COALESCE(NULLIF(?1, ''), avatar_url), 309 - display_name = COALESCE(NULLIF(?2, ''), display_name), 310 - profile_checked_at = unixepoch() 311 - WHERE did = ?3` 312 - ).bind(avatarCid, displayName, did).run(); 313 - if (avatarCid) enriched++; 314 - } catch { 315 - await db.prepare( 316 - "UPDATE actors SET profile_checked_at = unixepoch() WHERE did = ?1" 317 - ).bind(did).run().catch(() => {}); 318 - } 319 - })); 320 - totalEnriched = enriched; 321 - if (enriched > 0) { 322 - console.log(JSON.stringify({ event: "enrich_profile", enriched, checked: profileRows.length })); 323 - } 324 - } 325 - } finally { 326 - await env.KV.delete("enrich_lock").catch(() => {}); 327 - } 328 - 329 - return { resolved: totalResolved, enriched: totalEnriched }; 330 - } 331 - 332 - const BSKY_GET_PROFILES_URL = 333 - "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles"; 334 - 335 - /** refresh moderation labels, walking the full index over multiple cron runs */ 336 - async function refreshModeration(db: TursoDB, env: Env): Promise<void> { 337 - // resume where we left off (rowid cursor persisted in KV) 338 - const cursorStr = await env.KV.get("mod_cursor"); 339 - const cursor = cursorStr ? Number(cursorStr) : 0; 340 - 341 - const { results } = await db.prepare( 342 - "SELECT rowid, did FROM actors WHERE rowid > ?1 ORDER BY rowid ASC LIMIT 1000" 343 - ).bind(cursor).all<{ rowid: number; did: string }>(); 344 - 345 - if (!results || results.length === 0) { 346 - // wrapped around — reset cursor for next run 347 - await env.KV.put("mod_cursor", "0"); 348 - console.log(JSON.stringify({ event: "moderation_refresh", status: "wrapped", cursor })); 349 - return; 350 - } 351 - 352 - let checked = 0; 353 - let changed = 0; 354 - 355 - // batch into groups of 25 (getProfiles limit), ~200ms pause between calls 356 - for (let i = 0; i < results.length; i += 25) { 357 - const batch = results.slice(i, i + 25); 358 - const params = batch.map((r) => `actors=${encodeURIComponent(r.did)}`).join("&"); 359 - try { 360 - if (i > 0) await new Promise((r) => setTimeout(r, 200)); 361 - 362 - const res = await fetch(`${BSKY_GET_PROFILES_URL}?${params}`); 363 - if (res.status === 429) { 364 - // save progress and bail — pick up here next run 365 - const lastRowid = results[Math.max(0, i - 1)].rowid; 366 - await env.KV.put("mod_cursor", String(lastRowid)); 367 - console.log(JSON.stringify({ event: "moderation_refresh", status: "rate_limited", checked, changed, cursor: lastRowid })); 368 - return; 369 - } 370 - if (!res.ok) continue; 371 - 372 - const data: any = await res.json(); 373 - const profiles: any[] = data.profiles || []; 374 - checked += profiles.length; 375 - 376 - const stmts: Stmt[] = []; 377 - for (const p of profiles) { 378 - const hide = shouldHide(p.labels) ? 1 : 0; 379 - const avatarCid = extractAvatarCid(p.avatar || ''); 380 - stmts.push( 381 - db.prepare( 382 - `UPDATE actors SET hidden = ?1, 383 - handle = COALESCE(NULLIF(?3, ''), handle), 384 - display_name = COALESCE(NULLIF(?4, ''), display_name), 385 - avatar_url = COALESCE(NULLIF(?5, ''), avatar_url) 386 - WHERE did = ?2` 387 - ).bind(hide, p.did, p.handle || '', p.displayName || '', avatarCid) 388 - ); 389 - } 390 - if (stmts.length > 0) { 391 - const batchResults = await db.batch(stmts); 392 - changed += batchResults.filter((r) => r.meta.changes > 0).length; 393 - } 394 - } catch { 395 - // best-effort — skip failures 396 - } 397 - } 398 - 399 - // save cursor at end of this page 400 - const lastRowid = results[results.length - 1].rowid; 401 - await env.KV.put("mod_cursor", String(lastRowid)); 402 - console.log(JSON.stringify({ event: "moderation_refresh", checked, changed, cursor: lastRowid })); 403 - } 404 - 405 - /** fire-and-forget: increment 5-min search count + accumulate response time */ 406 - async function recordMetric(db: TursoDB, ms: number): Promise<void> { 407 - const bucket = Math.floor(Date.now() / 300_000); 408 - await db.prepare( 409 - `INSERT INTO metrics (hour, searches, total_ms) 410 - VALUES (?1, 1, ?2) 411 - ON CONFLICT(hour) DO UPDATE SET 412 - searches = searches + 1, 413 - total_ms = total_ms + ?2` 414 - ) 415 - .bind(bucket, ms) 416 - .run(); 417 - } 418 - 419 - /** fire-and-forget: record actor count deltas at 5-min granularity */ 420 - async function recordActorDelta( 421 - db: TursoDB, 422 - deltas: { actors?: number; handles?: number; avatars?: number }, 423 - ): Promise<void> { 424 - const bucket = Math.floor(Date.now() / 300_000); 425 - await db.prepare( 426 - `INSERT INTO actor_deltas (bucket, actors_delta, handles_delta, avatars_delta) 427 - VALUES (?1, ?2, ?3, ?4) 428 - ON CONFLICT(bucket) DO UPDATE SET 429 - actors_delta = actors_delta + ?2, 430 - handles_delta = handles_delta + ?3, 431 - avatars_delta = avatars_delta + ?4` 432 - ) 433 - .bind(bucket, deltas.actors ?? 0, deltas.handles ?? 0, deltas.avatars ?? 0) 434 - .run(); 435 - } 436 - 437 - /** fire-and-forget: increment cumulative hit counter per client identity */ 438 - async function recordTrafficSource(db: TursoDB, request: Request): Promise<void> { 439 - const client = request.headers.get("X-Client"); 440 - const origin = request.headers.get("Origin"); 441 - const referer = request.headers.get("Referer"); 442 - const selfHost = new URL(request.url).hostname; 443 - 444 - let domain: string; 445 - if (client) { 446 - domain = client; 447 - } else if (origin) { 448 - domain = new URL(origin).hostname; 449 - } else if (referer) { 450 - try { 451 - const refHost = new URL(referer).hostname; 452 - domain = refHost === selfHost ? "homepage" : refHost; 453 - } catch { domain = "unknown"; } 454 - } else { 455 - domain = "unknown"; 456 - } 457 - 458 - // normalize local/dev traffic 459 - if (domain === "localhost" || domain.startsWith("127.") || domain === "[::1]") { 460 - domain = "unknown"; 461 - } 462 - await db.prepare( 463 - `INSERT INTO traffic_sources (domain, hits) 464 - VALUES (?1, 1) 465 - ON CONFLICT(domain) DO UPDATE SET hits = hits + 1` 466 - ) 467 - .bind(domain) 468 - .run(); 469 - } 470 - 471 - async function handleSearch( 472 - request: Request, 473 - db: TursoDB, 474 - env: Env, 475 - ctx: ExecutionContext, 476 - ): Promise<Response> { 477 - const url = new URL(request.url); 478 - const q = url.searchParams.get("q") || url.searchParams.get("term") || ""; 479 - const limitRaw = url.searchParams.get("limit"); 480 - const limitParam = limitRaw !== null ? parseInt(limitRaw, 10) : 10; 481 - 482 - if (!q.trim()) { 483 - return json({ error: "InvalidRequest", message: "Error: Params must have the property \"q\"" }, 400); 484 - } 485 - if (isNaN(limitParam) || limitParam < 1 || limitParam > 100) { 486 - return json({ error: "InvalidRequest", message: "Error: limit must be between 1 and 100" }, 400); 487 - } 488 - 489 - const limit = limitParam; 490 - const term = sanitize(q); 491 - if (!term) { 492 - return json({ actors: [] }); 493 - } 494 - 495 - // cache API — edge cache for hot queries 496 - const cacheKey = new Request( 497 - `https://typeahead-cache/${encodeURIComponent(term)}:${limit}`, 498 - request 499 - ); 500 - const cache = caches.default; 501 - const cached = await cache.match(cacheKey); 502 - if (cached) { 503 - return cached; 504 - } 505 - 506 - const t0 = Date.now(); 507 - 508 - const ftsQuery = `"${term}"*`; 509 - const { results } = await db.prepare( 510 - `SELECT a.did, a.handle, a.display_name, a.avatar_url 511 - FROM actors_fts 512 - JOIN actors a ON a.rowid = actors_fts.rowid 513 - WHERE actors_fts MATCH ?1 AND a.handle != '' AND a.hidden = 0 514 - ORDER BY rank 515 - LIMIT ?2` 516 - ) 517 - .bind(ftsQuery, limit) 518 - .all<ActorRow>(); 519 - 520 - const actors = (results || []) 521 - .map((r) => ({ 522 - did: r.did, 523 - handle: r.handle, 524 - ...(r.display_name ? { displayName: r.display_name } : {}), 525 - ...(r.avatar_url ? { avatar: avatarUrl(r.did, r.avatar_url) } : {}), 526 - })); 527 - 528 - // --- backfill: remove this block once at parity with Bluesky --- 529 - const hasGaps = actors.length < limit || actors.some((a) => !a.avatar); 530 - if (hasGaps) { 531 - ctx.waitUntil(throttledBackfill(term, limit, db, env)); 532 - } 533 - // --- end backfill --- 534 - 535 - ctx.waitUntil(Promise.all([ 536 - recordMetric(db, Date.now() - t0), 537 - recordTrafficSource(db, request), 538 - ])); 539 - 540 - const response = json({ actors }); 541 - 542 - // cache for 60 seconds 543 - const cacheable = new Response(response.body, response); 544 - cacheable.headers.set("Cache-Control", "public, max-age=60"); 545 - await cache.put(cacheKey, cacheable.clone()); 546 - 547 - return cacheable; 548 - } 549 - 550 - async function handleIngest( 551 - request: Request, 552 - db: TursoDB, 553 - env: Env, 554 - ctx: ExecutionContext, 555 - ): Promise<Response> { 556 - const auth = request.headers.get("Authorization"); 557 - if (auth !== `Bearer ${env.ADMIN_SECRET}`) { 558 - const ip = clientIP(request); 559 - await env.RATE_LIMITER_STRICT.limit({ key: `auth:${ip}` }); 560 - console.log(JSON.stringify({ event: "auth_failure", endpoint: "/admin/ingest", ip })); 561 - return json({ error: "unauthorized" }, 401); 562 - } 563 - 564 - let body: { events: IngestEvent[]; cursor?: number }; 565 - try { 566 - body = await request.json(); 567 - } catch { 568 - return json({ error: "invalid json" }, 400); 569 - } 570 - 571 - const { events, cursor } = body; 572 - if (!Array.isArray(events) || events.length === 0) { 573 - return json({ error: "events must be a non-empty array" }, 400); 574 - } 575 - if (events.length > 10_000) { 576 - return json({ error: "batch too large (max 10000)" }, 400); 577 - } 578 - 579 - // batch upsert — bare-DID events use INSERT OR IGNORE (0 Turso writes for known actors), 580 - // profile/identity events use full UPSERT with COALESCE to preserve existing fields 581 - const stmts = events.map((e) => { 582 - const isBareDID = !e.handle && !e.display_name && !e.avatar_cid && e.hidden === undefined; 583 - if (isBareDID) { 584 - return db.prepare( 585 - "INSERT OR IGNORE INTO actors (did) VALUES (?1)" 586 - ).bind(e.did); 587 - } 588 - const avatarCid = e.avatar_cid || null; 589 - const hidden = e.hidden !== undefined ? (e.hidden ? 1 : 0) : null; 590 - return db.prepare( 591 - `INSERT INTO actors (did, handle, display_name, avatar_url, hidden, updated_at) 592 - VALUES (?1, ?2, ?3, ?4, COALESCE(?5, 0), unixepoch()) 593 - ON CONFLICT(did) DO UPDATE SET 594 - handle = COALESCE(NULLIF(?2, ''), actors.handle), 595 - display_name = COALESCE(NULLIF(?3, ''), actors.display_name), 596 - avatar_url = COALESCE(NULLIF(?4, ''), actors.avatar_url), 597 - hidden = COALESCE(?5, actors.hidden), 598 - updated_at = unixepoch()` 599 - ).bind( 600 - e.did, 601 - e.handle || '', 602 - e.display_name || '', 603 - avatarCid || '', 604 - hidden 605 - ); 606 - }); 607 - 608 - let batchResults: { results: unknown[]; meta: { changes: number } }[]; 609 - try { 610 - batchResults = await db.batch(stmts); 611 - } catch (e: any) { 612 - console.log(JSON.stringify({ event: "ingest_error", error: e?.message, count: events.length })); 613 - return json({ error: e?.message || "db batch failed" }, 500); 614 - } 615 - 616 - // count newly inserted bare DIDs (changes === 1 means INSERT succeeded, not IGNORE'd) 617 - let newActors = 0; 618 - for (let i = 0; i < events.length; i++) { 619 - const e = events[i]; 620 - const isBareDID = !e.handle && !e.display_name && !e.avatar_cid && e.hidden === undefined; 621 - if (isBareDID && batchResults[i]?.meta.changes === 1) newActors++; 622 - } 623 - if (newActors > 0) { 624 - ctx.waitUntil(recordActorDelta(db, { actors: newActors })); 625 - } 626 - 627 - if (cursor !== undefined) { 628 - try { 629 - await env.KV.put("jetstream_cursor", String(cursor)); 630 - } catch { 631 - // best-effort — don't fail the ingest if KV write fails (e.g. quota) 632 - } 633 - } 634 - 635 - ctx.waitUntil( 636 - enrichActors(db, env).then(({ resolved, enriched }) => { 637 - if (resolved > 0 || enriched > 0) { 638 - return recordActorDelta(db, { handles: resolved, avatars: enriched }); 639 - } 640 - }), 641 - ); 642 - 643 - return json({ ok: true, ingested: events.length }); 644 - } 645 - 646 - async function handleDelete( 647 - request: Request, 648 - db: TursoDB, 649 - env: Env, 650 - ctx: ExecutionContext, 651 - ): Promise<Response> { 652 - const auth = request.headers.get("Authorization"); 653 - if (auth !== `Bearer ${env.ADMIN_SECRET}`) { 654 - const ip = clientIP(request); 655 - await env.RATE_LIMITER_STRICT.limit({ key: `auth:${ip}` }); 656 - console.log(JSON.stringify({ event: "auth_failure", endpoint: "/admin/delete", ip })); 657 - return json({ error: "unauthorized" }, 401); 658 - } 659 - 660 - let body: { dids: string[] }; 661 - try { 662 - body = await request.json(); 663 - } catch { 664 - return json({ error: "invalid json" }, 400); 665 - } 666 - 667 - const { dids } = body; 668 - if (!Array.isArray(dids) || dids.length === 0) { 669 - return json({ error: "dids must be a non-empty array" }, 400); 670 - } 671 - if (dids.length > 10_000) { 672 - return json({ error: "batch too large (max 10000)" }, 400); 673 - } 674 - 675 - const stmts = dids.map((did) => 676 - db.prepare("DELETE FROM actors WHERE did = ?1").bind(did) 677 - ); 678 - const batchResults = await db.batch(stmts); 679 - const actualDeletes = batchResults.reduce((s, r) => s + r.meta.changes, 0); 680 - 681 - if (actualDeletes > 0) { 682 - ctx.waitUntil(recordActorDelta(db, { actors: -actualDeletes })); 683 - } 684 - 685 - return json({ ok: true, deleted: dids.length }); 686 - } 687 - 688 - async function handleCursor( 689 - request: Request, 690 - env: Env 691 - ): Promise<Response> { 692 - const auth = request.headers.get("Authorization"); 693 - if (auth !== `Bearer ${env.ADMIN_SECRET}`) { 694 - const ip = clientIP(request); 695 - await env.RATE_LIMITER_STRICT.limit({ key: `auth:${ip}` }); 696 - console.log(JSON.stringify({ event: "auth_failure", endpoint: "/admin/cursor", ip })); 697 - return json({ error: "unauthorized" }, 401); 698 - } 699 - 700 - const cursor = await env.KV.get("jetstream_cursor"); 701 - return json({ cursor: cursor ? Number(cursor) : null }); 702 - } 703 - 704 - /** resolve a handle or DID via slingshot, then upsert into the active DB */ 705 - async function handleRequestIndexing( 706 - request: Request, 707 - db: TursoDB, 708 - env: Env, 709 - ): Promise<Response> { 710 - const url = new URL(request.url); 711 - const identifier = 712 - url.searchParams.get("handle") || 713 - url.searchParams.get("did") || 714 - ""; 715 - 716 - if (!identifier) { 717 - return json({ error: "enter a handle or DID to request indexing." }, 400); 718 - } 719 - 720 - // resolve via slingshot 721 - const res = await fetch( 722 - `${SLINGSHOT_URL}?identifier=${encodeURIComponent(identifier)}` 723 - ); 724 - if (!res.ok) { 725 - return json({ error: `could not resolve "${identifier}". check that it's a valid handle or DID.` }, 404); 726 - } 727 - 728 - const identity: SlingshotResponse = await res.json(); 729 - 730 - // fetch profile from public API for display name + avatar + labels 731 - let displayName = ""; 732 - let avatarCid = ""; 733 - let hidden = false; 734 - try { 735 - const profileRes = await fetch( 736 - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(identity.did)}` 737 - ); 738 - if (profileRes.ok) { 739 - const profile: any = await profileRes.json(); 740 - displayName = profile.displayName || ""; 741 - avatarCid = extractAvatarCid(profile.avatar || ""); 742 - hidden = shouldHide(profile.labels); 743 - } 744 - } catch { 745 - // profile enrichment is best-effort 746 - } 747 - 748 - await db.prepare( 749 - `INSERT INTO actors (did, handle, display_name, avatar_url, hidden, updated_at) 750 - VALUES (?1, ?2, ?3, ?4, ?5, unixepoch()) 751 - ON CONFLICT(did) DO UPDATE SET 752 - handle = ?2, 753 - display_name = COALESCE(NULLIF(?3, ''), actors.display_name), 754 - avatar_url = COALESCE(NULLIF(?4, ''), actors.avatar_url), 755 - hidden = ?5, 756 - updated_at = unixepoch()` 757 - ) 758 - .bind(identity.did, identity.handle, displayName, avatarCid, hidden ? 1 : 0) 759 - .run(); 760 - 761 - return json({ 762 - handle: identity.handle, 763 - did: identity.did, 764 - ...(hidden ? { hidden: true, reason: "hidden by moderation" } : { hidden: false }), 765 - }); 766 - } 767 - 768 - async function handleStats(db: TursoDB): Promise<Response> { 769 - const [totalRes, handlesRes, avatarsRes, hiddenRes, metricsRes, snapshotRes, trafficRes, deltasRes] = 770 - await db.batch([ 771 - db.prepare("SELECT COUNT(*) AS cnt FROM actors WHERE hidden = 0"), 772 - db.prepare("SELECT COUNT(*) AS cnt FROM actors WHERE handle != '' AND hidden = 0"), 773 - db.prepare("SELECT COUNT(*) AS cnt FROM actors WHERE avatar_url != '' AND hidden = 0"), 774 - db.prepare("SELECT COUNT(*) AS cnt FROM actors WHERE hidden != 0"), 775 - db.prepare( 776 - "SELECT hour, searches, total_ms FROM metrics ORDER BY hour DESC LIMIT 2016" 777 - ), 778 - db.prepare( 779 - "SELECT hour, total, with_handles, with_avatars FROM snapshots ORDER BY hour ASC LIMIT 2000" 780 - ), 781 - db.prepare( 782 - "SELECT domain, hits FROM traffic_sources ORDER BY hits DESC LIMIT 10" 783 - ), 784 - db.prepare( 785 - "SELECT bucket, actors_delta, handles_delta, avatars_delta FROM actor_deltas ORDER BY bucket ASC LIMIT 2016" 786 - ), 787 - ]); 788 - 789 - const total = (totalRes.results[0] as any)?.cnt ?? 0; 790 - const withHandles = (handlesRes.results[0] as any)?.cnt ?? 0; 791 - const withAvatars = (avatarsRes.results[0] as any)?.cnt ?? 0; 792 - const hiddenCount = (hiddenRes.results[0] as any)?.cnt ?? 0; 793 - const rows = (metricsRes.results ?? []) as { 794 - hour: number; 795 - searches: number; 796 - total_ms: number; 797 - }[]; 798 - const dbSnapshots = (snapshotRes.results ?? []) as { hour: number; total: number; with_handles: number; with_avatars: number }[]; 799 - const trafficSources = (trafficRes.results ?? []) as { domain: string; hits: number }[]; 800 - const deltas = (deltasRes.results ?? []) as { bucket: number; actors_delta: number; handles_delta: number; avatars_delta: number }[]; 801 - 802 - // build snapshot points with timestamps 803 - const snapshots: SnapshotPoint[] = dbSnapshots.map((s) => ({ 804 - ts: s.hour * 3_600_000, 805 - total: s.total, 806 - with_handles: s.with_handles, 807 - with_avatars: s.with_avatars, 808 - })); 809 - 810 - // stitch deltas after last snapshot for interpolated points 811 - if (snapshots.length > 0 && deltas.length > 0) { 812 - const lastSnap = snapshots[snapshots.length - 1]; 813 - const lastSnapBucket = Math.floor(lastSnap.ts / 300_000); 814 - let cumActors = 0, cumHandles = 0, cumAvatars = 0; 815 - for (const d of deltas) { 816 - if (d.bucket <= lastSnapBucket) continue; 817 - cumActors += d.actors_delta; 818 - cumHandles += d.handles_delta; 819 - cumAvatars += d.avatars_delta; 820 - snapshots.push({ 821 - ts: d.bucket * 300_000, 822 - total: lastSnap.total + cumActors, 823 - with_handles: lastSnap.with_handles + cumHandles, 824 - with_avatars: lastSnap.with_avatars + cumAvatars, 825 - }); 826 - } 827 - } 828 - 829 - // append live point 830 - const now = Date.now(); 831 - if (snapshots.length === 0 || snapshots[snapshots.length - 1].ts < now - 60_000) { 832 - snapshots.push({ ts: now, total, with_handles: withHandles, with_avatars: withAvatars }); 833 - } 834 - 835 - const totalSearches = rows.reduce((s, r) => s + r.searches, 0); 836 - const totalMs = rows.reduce((s, r) => s + r.total_ms, 0); 837 - const avgLatency = totalSearches > 0 ? totalMs / totalSearches : 0; 838 - const handlePct = total > 0 ? ((withHandles / total) * 100).toFixed(1) : "0"; 839 - const avatarPct = total > 0 ? ((withAvatars / total) * 100).toFixed(1) : "0"; 840 - 841 - return html(statsPage({ total, hiddenCount, rows, totalSearches, avgLatency, handlePct, avatarPct, snapshots, trafficSources })); 842 - } 843 - 844 - interface SnapshotPoint { 845 - ts: number; // millisecond timestamp 846 - total: number; 847 - with_handles: number; 848 - with_avatars: number; 849 - } 850 - 851 - interface StatsData { 852 - total: number; 853 - hiddenCount: number; 854 - rows: { hour: number; searches: number; total_ms: number }[]; 855 - totalSearches: number; 856 - avgLatency: number; 857 - handlePct: string; 858 - avatarPct: string; 859 - snapshots: SnapshotPoint[]; 860 - trafficSources: { domain: string; hits: number }[]; 861 - } 862 - 863 - function statsPage(d: StatsData): string { 864 - // search sparkline data (oldest first) 865 - const sorted = [...d.rows].reverse(); 866 - const counts = sorted.map((r) => r.searches); 867 - const sparkMax = Math.max(...counts, 1); 868 - const sw = 600, sh = 80; 869 - const step = counts.length > 1 ? sw / (counts.length - 1) : 0; 870 - const sparkPoints = counts 871 - .map((c, i) => `${(i * step).toFixed(1)},${(sh - (c / sparkMax) * sh).toFixed(1)}`) 872 - .join(" "); 873 - const sparkJson = JSON.stringify( 874 - sorted.map((r) => ({ 875 - time: new Date(r.hour * 300_000).toISOString().slice(0, 16) + "Z", 876 - searches: r.searches, 877 - })) 878 - ); 879 - 880 - const snapshotJson = JSON.stringify(d.snapshots); 881 - const trafficJson = JSON.stringify(d.trafficSources); 882 - 883 - const PIE_COLORS = ['#4a9', '#58a6ff', '#bc8cff', '#e5a04b', '#e06c75', '#56b6c2', '#c678dd', '#98c379', '#d19a66']; 884 - const trafficTotal = d.trafficSources.reduce((s, r) => s + r.hits, 0); 885 - const SPECIAL_DOMAINS: Record<string, string> = { unknown: '#555', homepage: '#666' }; 886 - const pieLegendHtml = d.trafficSources.map((r, i) => { 887 - const color = SPECIAL_DOMAINS[r.domain] ?? PIE_COLORS[i % PIE_COLORS.length]; 888 - const pct = trafficTotal > 0 ? ((r.hits / trafficTotal) * 100).toFixed(1) : '0'; 889 - const label = r.domain in SPECIAL_DOMAINS 890 - ? r.domain 891 - : `<a href="https://${r.domain}" target="_blank" rel="noopener" class="legend-link">${r.domain}</a>`; 892 - return `<span><span class="ldot" style="background:${color}"></span>${label} (${pct}%)</span>`; 893 - }).join(''); 894 - 895 - return `<!doctype html> 896 - <html> 897 - <head> 898 - <meta charset="utf-8"> 899 - <meta name="viewport" content="width=device-width, initial-scale=1"> 900 - <title>typeahead — stats</title> 901 - ${FAVICON} 902 - <style> 903 - * { margin: 0; padding: 0; box-sizing: border-box; } 904 - body { font-family: system-ui, sans-serif; background: #0a0a0a; color: #e0e0e0; 905 - display: flex; justify-content: center; padding: 2rem 1rem; min-height: 100vh; } 906 - .container { max-width: 620px; width: 100%; } 907 - .header { display: flex; align-items: baseline; gap: 0.4rem; margin-bottom: 0.4rem; } 908 - h1 { font-size: 1.1rem; font-weight: 400; color: #888; } 909 - h1 strong { color: #e0e0e0; } 910 - .subtitle { font-size: 0.8rem; color: #555; margin-bottom: 1.5rem; } 911 - 912 - .chart-wrap { background: #111; border: 1px solid #222; border-radius: 6px; 913 - padding: 0.9rem; margin-bottom: 1.5rem; position: relative; } 914 - .chart-wrap h2 { font-size: 0.75rem; font-weight: 400; color: #555; margin-bottom: 0.6rem; } 915 - #trend { width: 100%; height: 200px; display: block; touch-action: none; } 916 - .legend { display: flex; gap: 1.2rem; margin-top: 0.6rem; font-size: 0.7rem; color: #888; } 917 - .legend span { display: flex; align-items: center; gap: 0.35rem; } 918 - .ldot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } 919 - .chart-tip { display: none; position: fixed; background: rgba(17, 17, 17, 0.95); 920 - border: 1px solid #333; border-radius: 6px; padding: 0.5rem 0.7rem; 921 - font-size: 0.72rem; color: #ccc; pointer-events: none; z-index: 50; 922 - line-height: 1.6; white-space: nowrap; 923 - box-shadow: 0 4px 12px rgba(0,0,0,0.5); } 924 - .ct-time { color: #666; font-size: 0.65rem; } 925 - .ct-row { display: flex; align-items: center; gap: 0.35rem; } 926 - .ct-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; flex-shrink: 0; } 927 - 928 - .sparkline-wrap { background: #111; border: 1px solid #222; border-radius: 6px; 929 - padding: 0.9rem; margin-bottom: 1.5rem; } 930 - .sparkline-wrap h2 { font-size: 0.75rem; font-weight: 400; color: #555; margin-bottom: 0.6rem; } 931 - svg { width: 100%; height: auto; } 932 - polyline { fill: none; stroke: #4a9; stroke-width: 1.5; } 933 - 934 - .summary { display: grid; gap: 0.8rem; margin-bottom: 1.5rem; } 935 - .summary-2 { grid-template-columns: repeat(2, 1fr); } 936 - .summary-3 { grid-template-columns: repeat(3, 1fr); } 937 - .metric { background: #111; border: 1px solid #222; border-radius: 6px; padding: 0.7rem 0.9rem; } 938 - .metric .label { font-size: 0.7rem; color: #555; margin-bottom: 0.2rem; } 939 - .metric .label[data-tip] { cursor: default; position: relative; border-bottom: 1px dotted #444; display: inline-block; } 940 - .metric .label[data-tip]:hover::after { 941 - content: attr(data-tip); position: absolute; left: 0; top: 1.6em; width: 200px; 942 - padding: 0.4rem 0.5rem; background: #1a1a1a; border: 1px solid #333; border-radius: 4px; 943 - font-size: 0.95em; color: #999; line-height: 1.4; z-index: 20; white-space: normal; 944 - } 945 - .metric .value { font-size: 1.1rem; color: #ccc; } 946 - .spark-tip { display: none; position: fixed; background: #1a1a1a; border: 1px solid #333; 947 - border-radius: 4px; padding: 0.4rem 0.6rem; font-size: 0.75rem; color: #ccc; 948 - pointer-events: none; z-index: 50; } 949 - 950 - .pie-wrap { background: #111; border: 1px solid #222; border-radius: 6px; 951 - padding: 0.9rem; margin-bottom: 1.5rem; } 952 - .pie-wrap h2 { font-size: 0.75rem; font-weight: 400; color: #555; margin-bottom: 0.6rem; } 953 - #pie { display: block; margin: 0 auto; width: 280px; height: 280px; touch-action: none; } 954 - .pie-legend { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; margin-top: 0.7rem; 955 - font-size: 0.7rem; color: #888; justify-content: center; } 956 - .pie-legend span { display: flex; align-items: center; gap: 0.3rem; line-height: 1.4; } 957 - .legend-link { color: #888; text-decoration: none; border-bottom: 1px dotted #444; } 958 - .legend-link:hover { color: #ccc; border-bottom-color: #888; } 959 - .pie-tip { display: none; position: fixed; background: rgba(17, 17, 17, 0.95); 960 - border: 1px solid #333; border-radius: 6px; padding: 0.5rem 0.7rem; 961 - font-size: 0.72rem; color: #ccc; pointer-events: none; z-index: 50; 962 - white-space: nowrap; box-shadow: 0 4px 12px rgba(0,0,0,0.5); } 963 - 964 - footer { padding-top: 1.5rem; border-top: 1px solid #1a1a1a; font-size: 0.7rem; 965 - color: #444; display: flex; justify-content: center; gap: 0.4rem; } 966 - footer a { color: #555; text-decoration: none; } 967 - footer a:hover { color: #888; } 968 - 969 - @media (max-width: 640px) { 970 - body { padding: 1.5rem 0.75rem; } 971 - #trend { height: 160px; } 972 - .legend { gap: 0.8rem; font-size: 0.65rem; flex-wrap: wrap; } 973 - .summary-3 { grid-template-columns: 1fr 1fr; } 974 - #pie { width: 220px; height: 220px; } 975 - } 976 - </style> 977 - </head> 978 - <body> 979 - <div class="container"> 980 - <div class="header"> 981 - <h1><a href="/" style="color:inherit;text-decoration:none"><strong>typeahead</strong></a> stats</h1> 982 - </div> 983 - <p class="subtitle">index health and search activity</p> 984 - 985 - <div class="chart-wrap"> 986 - <h2>actors indexed</h2> 987 - ${d.snapshots.length > 1 988 - ? `<canvas id="trend"></canvas>` 989 - : `<div style="color:#444;font-size:0.8rem;padding:2rem 0;text-align:center">collecting data — check back soon</div>`} 990 - <div class="legend"> 991 - ${[ 992 - { color: '#4a9', label: `total (${d.total.toLocaleString()})`, value: d.total }, 993 - { color: '#bc8cff', label: 'with avatars', value: d.snapshots[d.snapshots.length - 1]?.with_avatars ?? 0 }, 994 - { color: '#58a6ff', label: 'with handles', value: d.snapshots[d.snapshots.length - 1]?.with_handles ?? 0 }, 995 - ].sort((a, b) => b.value - a.value) 996 - .map(r => `<span><span class="ldot" style="background:${r.color}"></span> ${r.label}</span>`) 997 - .join('\n ')} 998 - </div> 999 - </div> 1000 - <div class="chart-tip" id="chart-tip"></div> 1001 - 1002 - <div class="summary summary-3"> 1003 - <div class="metric"> 1004 - <div class="label" data-tip="% of indexed actors with a resolved handle (vs bare DID only)">handle coverage</div> 1005 - <div class="value">${d.handlePct}%</div> 1006 - </div> 1007 - <div class="metric"> 1008 - <div class="label" data-tip="% of indexed actors with a profile image">avatar coverage</div> 1009 - <div class="value">${d.avatarPct}%</div> 1010 - </div> 1011 - <div class="metric"> 1012 - <div class="label" data-tip="actors hidden by bluesky moderation (!hide, !takedown, spam)">hidden by moderation</div> 1013 - <div class="value">${d.hiddenCount.toLocaleString()}</div> 1014 - </div> 1015 - </div> 1016 - 1017 - <div class="sparkline-wrap"> 1018 - <h2>searches / 5 min (7 days)</h2> 1019 - ${counts.length > 1 1020 - ? `<svg viewBox="0 0 ${sw} ${sh}" preserveAspectRatio="none" id="spark"> 1021 - <polyline points="${sparkPoints}" /> 1022 - </svg>` 1023 - : `<div style="color:#444;font-size:0.8rem;padding:1rem 0;text-align:center">no data yet</div>`} 1024 - </div> 1025 - <div class="summary summary-2"> 1026 - <div class="metric"> 1027 - <div class="label">total searches (7d)</div> 1028 - <div class="value">${d.totalSearches.toLocaleString()}</div> 1029 - </div> 1030 - <div class="metric"> 1031 - <div class="label" data-tip="average response time for uncached searches">avg latency</div> 1032 - <div class="value">${d.avgLatency.toFixed(1)} ms</div> 1033 - </div> 1034 - </div> 1035 - 1036 - <div class="pie-wrap"> 1037 - <h2>traffic sources</h2> 1038 - ${d.trafficSources.length > 0 1039 - ? `<canvas id="pie"></canvas> 1040 - <div class="pie-legend">${pieLegendHtml}</div>` 1041 - : `<div style="color:#444;font-size:0.8rem;padding:2rem 0;text-align:center">collecting data — check back soon</div>`} 1042 - <div style="margin-top:0.6rem;font-size:0.7rem;color:#555;text-align:center"> 1043 - want to show up here? <a href="/docs" style="color:#58a6ff;text-decoration:none">switch from bluesky&rsquo;s typeahead &rarr;</a> 1044 - </div> 1045 - </div> 1046 - <div class="pie-tip" id="pie-tip"></div> 1047 - 1048 - <footer> 1049 - <a href="/">&larr; home</a> 1050 - </footer> 1051 - </div> 1052 - <div class="spark-tip" id="spark-tip"></div> 1053 - <script> 1054 - const COLORS = { total: '#4a9', handles: '#58a6ff', avatars: '#bc8cff' }; 1055 - 1056 - function fmtNum(n) { 1057 - if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; 1058 - if (n >= 1e3) return (n / 1e3).toFixed(0) + 'k'; 1059 - return n.toLocaleString(); 1060 - } 1061 - 1062 - /* --- actors indexed trend chart --- */ 1063 - const snaps = ${snapshotJson}; 1064 - const canvas = document.getElementById('trend'); 1065 - const chartTip = document.getElementById('chart-tip'); 1066 - 1067 - function drawChart(hoverIdx) { 1068 - if (!canvas || snaps.length < 2) return; 1069 - const ctx = canvas.getContext('2d'); 1070 - const dpr = window.devicePixelRatio || 1; 1071 - const W = canvas.clientWidth, H = canvas.clientHeight; 1072 - canvas.width = W * dpr; canvas.height = H * dpr; 1073 - ctx.scale(dpr, dpr); 1074 - ctx.clearRect(0, 0, W, H); 1075 - 1076 - const pad = { t: 16, r: 12, b: 24, l: 12 }; 1077 - const cw = W - pad.l - pad.r, ch = H - pad.t - pad.b; 1078 - const n = snaps.length; 1079 - 1080 - let yMax = 0; 1081 - for (const s of snaps) { if (s.total > yMax) yMax = s.total; } 1082 - yMax = yMax * 1.08 || 1; 1083 - 1084 - const toX = i => pad.l + (i / (n - 1)) * cw; 1085 - const toY = v => pad.t + ch - (v / yMax) * ch; 1086 - 1087 - const series = [ 1088 - { key: 'with_avatars', color: COLORS.avatars }, 1089 - { key: 'with_handles', color: COLORS.handles }, 1090 - { key: 'total', color: COLORS.total }, 1091 - ]; 1092 - 1093 - for (const s of series) { 1094 - const pts = snaps.map((d, i) => [toX(i), toY(d[s.key])]); 1095 - const trace = () => { 1096 - ctx.beginPath(); 1097 - ctx.moveTo(pts[0][0], pts[0][1]); 1098 - for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); 1099 - }; 1100 - 1101 - // glow 1102 - ctx.save(); trace(); 1103 - ctx.shadowColor = s.color; ctx.shadowBlur = 18; 1104 - ctx.strokeStyle = s.color; ctx.lineWidth = 2; ctx.globalAlpha = 0.07; 1105 - ctx.stroke(); ctx.restore(); 1106 - 1107 - // medium glow 1108 - ctx.save(); trace(); 1109 - ctx.shadowColor = s.color; ctx.shadowBlur = 6; 1110 - ctx.strokeStyle = s.color; ctx.lineWidth = 1; ctx.globalAlpha = 0.15; 1111 - ctx.stroke(); ctx.restore(); 1112 - 1113 - // core line 1114 - trace(); 1115 - ctx.strokeStyle = s.color; 1116 - ctx.lineWidth = 1.2; ctx.globalAlpha = 0.55; 1117 - ctx.stroke(); ctx.globalAlpha = 1; 1118 - } 1119 - 1120 - // x-axis 1121 - { 1122 - const axisY = H - pad.b; 1123 - ctx.beginPath(); ctx.moveTo(pad.l, axisY); ctx.lineTo(W - pad.r, axisY); 1124 - ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; 1125 - ctx.stroke(); 1126 - 1127 - ctx.font = '10px system-ui, sans-serif'; 1128 - ctx.fillStyle = '#555'; ctx.textAlign = 'center'; 1129 - const t0ms = snaps[0].ts; 1130 - const tNms = snaps[n - 1].ts; 1131 - const spanDays = (tNms - t0ms) / 86400000; 1132 - 1133 - // walk midnights 1134 - const d = new Date(t0ms); 1135 - d.setUTCMinutes(0, 0, 0); 1136 - d.setUTCHours(0); 1137 - d.setUTCDate(d.getUTCDate() + 1); 1138 - const minGap = W < 500 ? 65 : 50; 1139 - let prevX = -Infinity; 1140 - 1141 - while (d.getTime() <= tNms) { 1142 - const frac = (d.getTime() - t0ms) / (tNms - t0ms); 1143 - const x = pad.l + frac * cw; 1144 - if (x >= pad.l && x <= W - pad.r && x - prevX >= minGap) { 1145 - ctx.beginPath(); ctx.moveTo(x, axisY); ctx.lineTo(x, axisY + 4); 1146 - ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 1; ctx.stroke(); 1147 - const label = spanDays > 14 1148 - ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' }) 1149 - : d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }); 1150 - ctx.globalAlpha = 0.4; 1151 - ctx.fillText(label, x, axisY + 15); 1152 - ctx.globalAlpha = 1; 1153 - prevX = x; 1154 - } 1155 - d.setUTCDate(d.getUTCDate() + 1); 1156 - } 1157 - } 1158 - 1159 - // hover crosshair + dots 1160 - if (hoverIdx != null && hoverIdx >= 0 && hoverIdx < n) { 1161 - const x = toX(hoverIdx); 1162 - ctx.beginPath(); ctx.moveTo(x, pad.t); ctx.lineTo(x, H - pad.b); 1163 - ctx.strokeStyle = 'rgba(255,255,255,0.1)'; 1164 - ctx.lineWidth = 1; ctx.setLineDash([3, 3]); ctx.stroke(); ctx.setLineDash([]); 1165 - 1166 - const snap = snaps[hoverIdx]; 1167 - for (const s of series) { 1168 - const y = toY(snap[s.key]); 1169 - ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); 1170 - ctx.fillStyle = '#fff'; ctx.globalAlpha = 0.9; ctx.fill(); 1171 - ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); 1172 - ctx.strokeStyle = s.color; ctx.lineWidth = 1.5; ctx.globalAlpha = 0.8; 1173 - ctx.stroke(); ctx.globalAlpha = 1; 1174 - } 1175 - } 1176 - } 1177 - 1178 - function chartHover(clientX, clientY) { 1179 - if (!canvas || snaps.length < 2) return; 1180 - const rect = canvas.getBoundingClientRect(); 1181 - const mx = clientX - rect.left; 1182 - const pad = { l: 12, r: 12 }; 1183 - const cw = rect.width - pad.l - pad.r; 1184 - const frac = (mx - pad.l) / cw; 1185 - const idx = Math.max(0, Math.min(snaps.length - 1, Math.round(frac * (snaps.length - 1)))); 1186 - drawChart(idx); 1187 - 1188 - const snap = snaps[idx]; 1189 - const t = new Date(snap.ts); 1190 - const time = t.toISOString().replace('T', ' ').slice(0, 16) + ' UTC'; 1191 - chartTip.innerHTML = 1192 - '<div class="ct-time">' + time + '</div>' 1193 - const tipRows = [ 1194 - { color: COLORS.total, value: snap.total, label: 'actors' }, 1195 - { color: COLORS.avatars, value: snap.with_avatars, label: 'with avatars' }, 1196 - { color: COLORS.handles, value: snap.with_handles, label: 'with handles' }, 1197 - ].sort((a, b) => b.value - a.value); 1198 - chartTip.innerHTML = '<div class="ct-time">' + time + '</div>' 1199 - + tipRows.map(r => '<div class="ct-row"><span class="ct-dot" style="background:' + r.color + '"></span> ' + fmtNum(r.value) + ' ' + r.label + '</div>').join(''); 1200 - chartTip.style.display = 'block'; 1201 - const tx = clientX + 14, ty = clientY - 10; 1202 - chartTip.style.left = (tx + chartTip.offsetWidth > window.innerWidth ? clientX - chartTip.offsetWidth - 10 : tx) + 'px'; 1203 - chartTip.style.top = ty + 'px'; 1204 - } 1205 - 1206 - if (canvas) { 1207 - drawChart(null); 1208 - window.addEventListener('resize', () => drawChart(null)); 1209 - 1210 - canvas.addEventListener('mousemove', e => chartHover(e.clientX, e.clientY)); 1211 - canvas.addEventListener('mouseleave', () => { 1212 - chartTip.style.display = 'none'; 1213 - drawChart(null); 1214 - }); 1215 - canvas.addEventListener('touchmove', e => { 1216 - e.preventDefault(); 1217 - const t = e.touches[0]; 1218 - chartHover(t.clientX, t.clientY); 1219 - }); 1220 - canvas.addEventListener('touchend', () => { 1221 - chartTip.style.display = 'none'; 1222 - drawChart(null); 1223 - }); 1224 - } 1225 - 1226 - /* --- traffic sources pie chart --- */ 1227 - const PIE_COLORS = ['#4a9', '#58a6ff', '#bc8cff', '#e5a04b', '#e06c75', '#56b6c2', '#c678dd', '#98c379', '#d19a66']; 1228 - const traffic = ${trafficJson}; 1229 - const pieCanvas = document.getElementById('pie'); 1230 - const pieTip = document.getElementById('pie-tip'); 1231 - 1232 - if (pieCanvas && traffic.length > 0) { 1233 - const pieTotal = traffic.reduce((s, r) => s + r.hits, 0); 1234 - 1235 - const SPECIAL = { unknown: '#555', homepage: '#666' }; 1236 - function getColor(i) { 1237 - return SPECIAL[traffic[i].domain] || PIE_COLORS[i % PIE_COLORS.length]; 1238 - } 1239 - 1240 - // build segments: { start, end, color, domain, hits } 1241 - const segments = []; 1242 - let angle = -Math.PI / 2; 1243 - for (let i = 0; i < traffic.length; i++) { 1244 - const sweep = (traffic[i].hits / pieTotal) * Math.PI * 2; 1245 - segments.push({ start: angle, end: angle + sweep, color: getColor(i), domain: traffic[i].domain, hits: traffic[i].hits }); 1246 - angle += sweep; 1247 - } 1248 - 1249 - let animProgress = 0; 1250 - let hoverIdx = -1; 1251 - const animStart = performance.now(); 1252 - const animDur = 800; 1253 - 1254 - function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } 1255 - 1256 - function drawPie() { 1257 - const pctx = pieCanvas.getContext('2d'); 1258 - const dpr = window.devicePixelRatio || 1; 1259 - const W = pieCanvas.clientWidth, H = pieCanvas.clientHeight; 1260 - pieCanvas.width = W * dpr; pieCanvas.height = H * dpr; 1261 - pctx.scale(dpr, dpr); 1262 - pctx.clearRect(0, 0, W, H); 1263 - 1264 - const cx = W / 2, cy = H / 2; 1265 - const outerR = Math.min(W, H) / 2 - 8; 1266 - const innerR = outerR * 0.6; 1267 - const maxAngle = -Math.PI / 2 + animProgress * Math.PI * 2; 1268 - 1269 - for (let i = 0; i < segments.length; i++) { 1270 - const seg = segments[i]; 1271 - const drawStart = seg.start; 1272 - const drawEnd = Math.min(seg.end, maxAngle); 1273 - if (drawEnd <= drawStart) continue; 1274 - 1275 - const grow = hoverIdx === i ? 4 : 0; 1276 - const alpha = hoverIdx >= 0 && hoverIdx !== i ? 0.4 : 1; 1277 - 1278 - pctx.globalAlpha = alpha; 1279 - pctx.beginPath(); 1280 - pctx.arc(cx, cy, outerR + grow, drawStart, drawEnd); 1281 - pctx.arc(cx, cy, innerR - (grow ? 2 : 0), drawEnd, drawStart, true); 1282 - pctx.closePath(); 1283 - pctx.fillStyle = seg.color; 1284 - pctx.fill(); 1285 - } 1286 - 1287 - // center text 1288 - pctx.globalAlpha = 1; 1289 - pctx.fillStyle = '#888'; 1290 - pctx.font = '600 ' + (outerR * 0.22) + 'px system-ui, sans-serif'; 1291 - pctx.textAlign = 'center'; 1292 - pctx.textBaseline = 'middle'; 1293 - pctx.fillText(fmtNum(pieTotal), cx, cy - outerR * 0.06); 1294 - pctx.font = (outerR * 0.11) + 'px system-ui, sans-serif'; 1295 - pctx.fillStyle = '#555'; 1296 - pctx.fillText('hits', cx, cy + outerR * 0.14); 1297 - } 1298 - 1299 - function animLoop(now) { 1300 - const t = Math.min((now - animStart) / animDur, 1); 1301 - animProgress = easeOutCubic(t); 1302 - drawPie(); 1303 - if (t < 1) requestAnimationFrame(animLoop); 1304 - } 1305 - requestAnimationFrame(animLoop); 1306 - 1307 - function hitTest(clientX, clientY) { 1308 - const rect = pieCanvas.getBoundingClientRect(); 1309 - const mx = clientX - rect.left - rect.width / 2; 1310 - const my = clientY - rect.top - rect.height / 2; 1311 - const dist = Math.sqrt(mx * mx + my * my); 1312 - const outerR = Math.min(rect.width, rect.height) / 2 - 8; 1313 - const innerR = outerR * 0.6; 1314 - if (dist < innerR || dist > outerR + 8) return -1; 1315 - let a = Math.atan2(my, mx); 1316 - if (a < -Math.PI / 2) a += Math.PI * 2; 1317 - for (let i = 0; i < segments.length; i++) { 1318 - if (a >= segments[i].start && a < segments[i].end) return i; 1319 - } 1320 - return -1; 1321 - } 1322 - 1323 - function pieHover(clientX, clientY) { 1324 - const idx = hitTest(clientX, clientY); 1325 - if (idx !== hoverIdx) { 1326 - hoverIdx = idx; 1327 - drawPie(); 1328 - } 1329 - if (idx >= 0) { 1330 - const seg = segments[idx]; 1331 - const pct = ((seg.hits / pieTotal) * 100).toFixed(1); 1332 - pieTip.innerHTML = '<strong>' + seg.domain + '</strong><br>' + seg.hits.toLocaleString() + (seg.hits === 1 ? ' hit' : ' hits') + ' (' + pct + '%)'; 1333 - pieTip.style.display = 'block'; 1334 - const tx = clientX + 14, ty = clientY - 10; 1335 - pieTip.style.left = (tx + pieTip.offsetWidth > window.innerWidth ? clientX - pieTip.offsetWidth - 10 : tx) + 'px'; 1336 - pieTip.style.top = ty + 'px'; 1337 - } else { 1338 - pieTip.style.display = 'none'; 1339 - } 1340 - } 1341 - 1342 - pieCanvas.addEventListener('mousemove', e => pieHover(e.clientX, e.clientY)); 1343 - pieCanvas.addEventListener('mouseleave', () => { hoverIdx = -1; drawPie(); pieTip.style.display = 'none'; }); 1344 - pieCanvas.addEventListener('touchmove', e => { e.preventDefault(); const t = e.touches[0]; pieHover(t.clientX, t.clientY); }); 1345 - pieCanvas.addEventListener('touchend', () => { hoverIdx = -1; drawPie(); pieTip.style.display = 'none'; }); 1346 - window.addEventListener('resize', () => drawPie()); 1347 - } 1348 - 1349 - /* --- search sparkline --- */ 1350 - const sparkData = ${sparkJson}; 1351 - const spark = document.getElementById('spark'); 1352 - const sparkTip = document.getElementById('spark-tip'); 1353 - if (spark) { 1354 - spark.addEventListener('mousemove', e => { 1355 - const rect = spark.getBoundingClientRect(); 1356 - const x = e.clientX - rect.left; 1357 - const idx = Math.min(Math.round(x / rect.width * (sparkData.length - 1)), sparkData.length - 1); 1358 - if (idx >= 0 && sparkData[idx]) { 1359 - const sc = sparkData[idx].searches; 1360 - sparkTip.textContent = sparkData[idx].time + ' — ' + sc + (sc === 1 ? ' search' : ' searches'); 1361 - sparkTip.style.display = 'block'; 1362 - sparkTip.style.left = (e.clientX + 12) + 'px'; 1363 - sparkTip.style.top = (e.clientY - 28) + 'px'; 1364 - } 1365 - }); 1366 - spark.addEventListener('mouseleave', () => { sparkTip.style.display = 'none'; }); 1367 - } 1368 - </script> 1369 - </body> 1370 - </html>`; 1371 - } 1372 - 1373 - function indexPage(): string { 1374 - return `<!doctype html> 1375 - <html> 1376 - <head> 1377 - <meta charset="utf-8"> 1378 - <meta name="viewport" content="width=device-width, initial-scale=1"> 1379 - <title>typeahead</title> 1380 - ${FAVICON} 1381 - <style> 1382 - * { margin: 0; padding: 0; box-sizing: border-box; } 1383 - body { font-family: system-ui, sans-serif; background: #0a0a0a; color: #e0e0e0; 1384 - display: flex; justify-content: center; align-items: center; min-height: 100vh; } 1385 - .container { max-width: 460px; width: 100%; padding: 2rem; } 1386 - .header { display: flex; align-items: baseline; gap: 0.4rem; margin-bottom: 0.4rem; } 1387 - h1 { font-size: 1.1rem; font-weight: 400; color: #888; } 1388 - h1 strong { color: #e0e0e0; } 1389 - .src { font-size: 0.75em; color: #555; text-decoration: none; margin-left: 0.3em; } 1390 - .src:hover { color: #888; } 1391 - .experimental { font-size: 0.55em; color: #664; cursor: default; position: relative; 1392 - vertical-align: super; line-height: 1; margin-left: 0.3em; } 1393 - .experimental:hover::after { 1394 - content: "this is experimental and may break or disappear — don't depend on it for anything critical"; 1395 - position: absolute; left: 0; top: 1.4em; width: 220px; padding: 0.5rem; 1396 - background: #1a1a1a; border: 1px solid #333; border-radius: 4px; 1397 - font-size: 1.4em; color: #999; line-height: 1.4; z-index: 20; white-space: normal; 1398 - } 1399 - .subtitle { font-size: 0.8rem; color: #555; margin-bottom: 1.5rem; } 1400 - .subtitle a { color: #555; text-decoration: none; } 1401 - .subtitle a:hover { color: #888; } 1402 - section { margin-bottom: 2rem; } 1403 - label { display: block; font-size: 0.8rem; color: #666; margin-bottom: 0.5rem; } 1404 - .search-wrap { position: relative; } 1405 - input { width: 100%; padding: 0.6rem 0.8rem; background: #1a1a1a; border: 1px solid #333; 1406 - border-radius: 6px; color: #e0e0e0; font-size: 16px; outline: none; } 1407 - input:focus { border-color: #555; } 1408 - input::placeholder { color: #555; } 1409 - .results { position: absolute; top: 100%; left: 0; right: 0; margin-top: 4px; 1410 - background: #141414; border: 1px solid #2a2a2a; border-radius: 6px; 1411 - max-height: 260px; overflow-y: auto; z-index: 10; display: none; } 1412 - .results.show { display: block; } 1413 - .result { display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0.7rem; 1414 - cursor: default; font-size: 0.85rem; } 1415 - .result:hover { background: #1a1a1a; } 1416 - .result img { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; } 1417 - .result .placeholder { width: 28px; height: 28px; border-radius: 50%; background: #222; flex-shrink: 0; } 1418 - .result .info { min-width: 0; } 1419 - .result .name { color: #ccc; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 1420 - .result .handle { color: #555; font-size: 0.75rem; } 1421 - .empty { padding: 0.6rem 0.8rem; color: #444; font-size: 0.8rem; } 1422 - .index-form { display: flex; gap: 0.5rem; } 1423 - .index-form input { flex: 1; } 1424 - button { padding: 0.6rem 1rem; background: #2a2a2a; border: 1px solid #333; 1425 - border-radius: 6px; color: #e0e0e0; font-size: 0.9rem; cursor: pointer; } 1426 - button:hover { background: #333; } 1427 - button:disabled { opacity: 0.5; cursor: default; } 1428 - .msg { margin-top: 0.75rem; padding: 0.6rem 0.8rem; background: #1a1a1a; border-radius: 6px; 1429 - font-size: 0.85rem; line-height: 1.4; display: flex; justify-content: space-between; 1430 - align-items: start; gap: 0.5rem; } 1431 - .msg.error { border-left: 2px solid #a55; } 1432 - .msg .dismiss { color: #555; cursor: pointer; font-size: 0.75rem; flex-shrink: 0; 1433 - background: none; border: none; padding: 0; } 1434 - .msg .dismiss:hover { color: #888; } 1435 - .api { margin-bottom: 1.5rem; background: #111; border: 1px solid #222; border-radius: 6px; 1436 - padding: 0.5rem 0.7rem; display: flex; align-items: center; gap: 0.5rem; 1437 - overflow-x: auto; white-space: nowrap; } 1438 - .api .method { font-size: 0.65rem; font-weight: 600; color: #4a9; background: #4a92; 1439 - padding: 0.15rem 0.4rem; border-radius: 3px; flex-shrink: 0; 1440 - font-family: ui-monospace, monospace; letter-spacing: 0.03em; } 1441 - .api .path { font-size: 0.7rem; color: #666; font-family: ui-monospace, monospace; } 1442 - footer { padding-top: 1.5rem; border-top: 1px solid #1a1a1a; font-size: 0.7rem; 1443 - color: #444; display: flex; justify-content: center; gap: 0.4rem; } 1444 - footer a { color: #555; text-decoration: none; } 1445 - footer a:hover { color: #888; } 1446 - </style> 1447 - </head> 1448 - <body> 1449 - <div class="container"> 1450 - <div class="header"> 1451 - <h1><strong>typeahead</strong><sup class="experimental">*experimental</sup> <a href="https://tangled.sh/zzstoatzz.io/typeahead" class="src" target="_blank" rel="noopener">[src]</a></h1> 1452 - </div> 1453 - <p class="subtitle">community actor search for <a href="https://atproto.com" target="_blank" rel="noopener">atproto</a></p> 1454 - 1455 - <section> 1456 - <label>try it</label> 1457 - <div class="search-wrap"> 1458 - <input id="q" placeholder="search handles..." autocomplete="off" autofocus> 1459 - <div class="results" id="results"></div> 1460 - </div> 1461 - </section> 1462 - 1463 - <section> 1464 - <label>request indexing</label> 1465 - <form class="index-form" id="index-form"> 1466 - <input id="handle-input" placeholder="handle or DID" autocomplete="off"> 1467 - <button type="submit" id="index-btn">index</button> 1468 - </form> 1469 - <div id="index-msg"></div> 1470 - </section> 1471 - 1472 - <div class="api"> 1473 - <span class="method">GET</span> 1474 - <span class="path">/xrpc/app.bsky.actor.searchActorsTypeahead?q=...&amp;limit=10</span> 1475 - </div> 1476 - 1477 - <footer> 1478 - by <a href="https://bsky.app/profile/zzstoatzz.io" target="_blank" rel="noopener">@zzstoatzz.io</a> 1479 - · <a href="/stats">stats</a> 1480 - · <a href="/docs">docs</a> 1481 - </footer> 1482 - </div> 1483 - <script> 1484 - const q = document.getElementById('q'); 1485 - const results = document.getElementById('results'); 1486 - let timer = null; 1487 - q.addEventListener('input', () => { 1488 - clearTimeout(timer); 1489 - const v = q.value.trim(); 1490 - if (v.length < 2) { results.classList.remove('show'); return; } 1491 - timer = setTimeout(async () => { 1492 - try { 1493 - const r = await fetch('/xrpc/app.bsky.actor.searchActorsTypeahead?q=' + encodeURIComponent(v) + '&limit=8'); 1494 - const data = await r.json(); 1495 - const actors = data.actors || []; 1496 - if (actors.length === 0) { 1497 - results.innerHTML = '<div class="empty">no results</div>'; 1498 - } else { 1499 - results.innerHTML = actors.map(a => 1500 - '<div class="result">' + 1501 - (a.avatar ? '<img src="' + a.avatar + '" alt="">' : '<div class="placeholder"></div>') + 1502 - '<div class="info"><div class="name">' + esc(a.displayName || a.handle) + '</div>' + 1503 - '<div class="handle">@' + esc(a.handle) + '</div></div></div>' 1504 - ).join(''); 1505 - } 1506 - results.classList.add('show'); 1507 - } catch(e) {} 1508 - }, 200); 1509 - }); 1510 - document.addEventListener('click', e => { if (!e.target.closest('.search-wrap')) results.classList.remove('show'); }); 1511 - q.addEventListener('focus', () => { if (results.innerHTML) results.classList.add('show'); }); 1512 - function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } 1513 - 1514 - // request indexing form 1515 - const indexForm = document.getElementById('index-form'); 1516 - const handleInput = document.getElementById('handle-input'); 1517 - const indexBtn = document.getElementById('index-btn'); 1518 - const indexMsg = document.getElementById('index-msg'); 1519 - 1520 - function showMsg(text, isError) { 1521 - indexMsg.innerHTML = '<div class="msg' + (isError ? ' error' : '') + '">' + 1522 - '<span>' + text + '</span>' + 1523 - '<button class="dismiss" onclick="dismissMsg()">\\u00d7</button></div>'; 1524 - } 1525 - 1526 - function dismissMsg() { 1527 - indexMsg.innerHTML = ''; 1528 - } 1529 - 1530 - indexForm.addEventListener('submit', async (e) => { 1531 - e.preventDefault(); 1532 - const val = handleInput.value.trim(); 1533 - if (!val) { showMsg('enter a handle or DID.', true); return; } 1534 - 1535 - indexBtn.disabled = true; 1536 - indexBtn.textContent = '...'; 1537 - dismissMsg(); 1538 - 1539 - try { 1540 - const r = await fetch('/request-indexing?handle=' + encodeURIComponent(val), { method: 'POST' }); 1541 - const data = await r.json(); 1542 - if (data.error) { 1543 - showMsg(esc(data.error), true); 1544 - } else { 1545 - const hidden = data.hidden ? ' <em style="color:#886">(' + esc(data.reason || 'hidden') + ')</em>' : ''; 1546 - showMsg('indexed <strong>@' + esc(data.handle) + '</strong>' + hidden, false); 1547 - handleInput.value = ''; 1548 - } 1549 - } catch { 1550 - showMsg('something went wrong — try again.', true); 1551 - } finally { 1552 - indexBtn.disabled = false; 1553 - indexBtn.textContent = 'index'; 1554 - } 1555 - }); 1556 - </script> 1557 - </body> 1558 - </html>`; 1559 - } 1560 - 1561 - function docsPage(): string { 1562 - return `<!doctype html> 1563 - <html> 1564 - <head> 1565 - <meta charset="utf-8"> 1566 - <meta name="viewport" content="width=device-width, initial-scale=1"> 1567 - <title>typeahead — switching from bluesky</title> 1568 - ${FAVICON} 1569 - <style> 1570 - * { margin: 0; padding: 0; box-sizing: border-box; } 1571 - body { font-family: system-ui, sans-serif; background: #0a0a0a; color: #e0e0e0; 1572 - display: flex; justify-content: center; padding: 2rem 1rem; min-height: 100vh; } 1573 - .container { max-width: 620px; width: 100%; } 1574 - .header { display: flex; align-items: baseline; gap: 0.4rem; margin-bottom: 0.4rem; } 1575 - h1 { font-size: 1.1rem; font-weight: 400; color: #888; } 1576 - h1 strong { color: #e0e0e0; } 1577 - .subtitle { font-size: 0.8rem; color: #555; margin-bottom: 1.5rem; } 1578 - h2 { font-size: 0.85rem; font-weight: 600; color: #ccc; margin: 1.5rem 0 0.6rem; } 1579 - h3 { font-size: 0.8rem; font-weight: 600; color: #aaa; margin: 1.2rem 0 0.5rem; } 1580 - p, li { font-size: 0.8rem; color: #999; line-height: 1.6; } 1581 - p { margin-bottom: 0.7rem; } 1582 - ul { margin: 0 0 0.7rem 1.2rem; } 1583 - li { margin-bottom: 0.3rem; } 1584 - code { font-family: ui-monospace, monospace; font-size: 0.75rem; background: #1a1a1a; 1585 - border: 1px solid #222; border-radius: 3px; padding: 0.1rem 0.35rem; color: #ccc; } 1586 - pre { background: #111; border: 1px solid #222; border-radius: 6px; padding: 0.8rem; 1587 - overflow-x: auto; margin-bottom: 0.7rem; } 1588 - pre code { background: none; border: none; padding: 0; font-size: 0.72rem; color: #bbb; } 1589 - .diff-add { color: #4a9; } 1590 - .diff-del { color: #a55; } 1591 - .kw { color: #c678dd; } 1592 - .str { color: #98c379; } 1593 - .fn { color: #61afef; } 1594 - .cm { color: #5c6370; font-style: italic; } 1595 - .op { color: #abb2bf; } 1596 - .callout { background: #111; border: 1px solid #222; border-left: 3px solid #4a9; 1597 - border-radius: 6px; padding: 0.7rem 0.9rem; margin-bottom: 1rem; 1598 - font-size: 0.78rem; color: #999; line-height: 1.6; } 1599 - .callout strong { color: #ccc; } 1600 - .callout.warn { border-left-color: #b98a3e; } 1601 - table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; font-size: 0.75rem; } 1602 - th { text-align: left; color: #666; font-weight: 400; padding: 0.4rem 0.6rem; 1603 - border-bottom: 1px solid #222; } 1604 - td { padding: 0.4rem 0.6rem; border-bottom: 1px solid #1a1a1a; color: #999; } 1605 - td code { font-size: 0.7rem; } 1606 - .yes { color: #4a9; } 1607 - .no { color: #666; } 1608 - footer { padding-top: 1.5rem; border-top: 1px solid #1a1a1a; font-size: 0.7rem; 1609 - color: #444; display: flex; justify-content: center; gap: 0.4rem; } 1610 - footer a { color: #555; text-decoration: none; } 1611 - footer a:hover { color: #888; } 1612 - a { color: #58a6ff; text-decoration: none; } 1613 - a:hover { text-decoration: underline; } 1614 - @media (max-width: 640px) { 1615 - body { padding: 1.5rem 0.75rem; } 1616 - } 1617 - </style> 1618 - </head> 1619 - <body> 1620 - <div class="container"> 1621 - <div class="header"> 1622 - <h1><a href="/" style="color:inherit;text-decoration:none"><strong>typeahead</strong></a> docs</h1> 1623 - </div> 1624 - <p class="subtitle">switching from the bluesky typeahead API</p> 1625 - 1626 - <div class="callout"> 1627 - <strong>tl;dr</strong> — replace <code>public.api.bsky.app</code> with <code>typeahead.waow.tech</code>. 1628 - </div> 1629 - 1630 - <h2>what this is</h2> 1631 - <p> 1632 - typeahead is a community-run actor search for <a href="https://atproto.com">atproto</a>, 1633 - aiming to be a drop-in replacement for bluesky's 1634 - <code>app.bsky.actor.searchActorsTypeahead</code> endpoint. the endpoint path and query 1635 - params are identical; the response shape is compatible but slimmer (see 1636 - <a href="#response-comparison">response comparison</a> below). 1637 - </p> 1638 - <p> 1639 - the index is built from a few <a href="https://docs.bsky.app/blog/jetstream">jetstream</a> 1640 - collections — profiles, posts, likes, and follows — so any account that creates or 1641 - interacts with content gets discovered automatically. for accounts that predate the index 1642 - or haven't been seen yet, search queries trigger a throttled backfill from the bluesky API 1643 - to fill gaps on demand. searches use FTS5 prefix matching against handles and display names, 1644 - with results edge-cached for 60s. 1645 - </p> 1646 - 1647 - <h2>the change</h2> 1648 - <p>replace the base URL. everything else stays the same:</p> 1649 - <pre><code><span class="diff-del">- https://public.api.bsky.app</span>/xrpc/app.bsky.actor.searchActorsTypeahead?q=...&amp;limit=10 1650 - <span class="diff-add">+ https://typeahead.waow.tech</span>/xrpc/app.bsky.actor.searchActorsTypeahead?q=...&amp;limit=10</code></pre> 1651 - 1652 - <h3>before</h3> 1653 - <pre><code><span class="kw">const</span> response <span class="op">=</span> <span class="kw">await</span> <span class="fn">fetch</span>( 1654 - <span class="str">\`https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=\${<span class="fn">encodeURIComponent</span>(query)}&amp;limit=10\`</span> 1655 - );</code></pre> 1656 - 1657 - <h3>after</h3> 1658 - <pre><code><span class="kw">const</span> TYPEAHEAD_URL <span class="op">=</span> <span class="str">'https://typeahead.waow.tech'</span>; 1659 - 1660 - <span class="kw">const</span> response <span class="op">=</span> <span class="kw">await</span> <span class="fn">fetch</span>( 1661 - <span class="str">\`\${TYPEAHEAD_URL}/xrpc/app.bsky.actor.searchActorsTypeahead?q=\${<span class="fn">encodeURIComponent</span>(query)}&amp;limit=10\`</span> 1662 - );</code></pre> 1663 - 1664 - <p> 1665 - extracting the base URL into a constant (or env var) makes it easy to switch back 1666 - if you ever need to. 1667 - </p> 1668 - 1669 - <h2>identify your app</h2> 1670 - <p> 1671 - set the <code>X-Client</code> header so your app shows up by name in our 1672 - <a href="/stats">traffic stats</a> instead of as "unknown": 1673 - </p> 1674 - <pre><code><span class="kw">const</span> response <span class="op">=</span> <span class="kw">await</span> <span class="fn">fetch</span>( 1675 - <span class="str">\`\${TYPEAHEAD_URL}/xrpc/app.bsky.actor.searchActorsTypeahead?q=\${<span class="fn">encodeURIComponent</span>(query)}&amp;limit=10\`</span>, 1676 - { headers: { <span class="str">'X-Client'</span>: <span class="str">'my-app.example.com'</span> } } 1677 - );</code></pre> 1678 - <p> 1679 - use your domain or app name — whatever you want to be identified as. 1680 - browser-based apps will also be identified automatically via the <code>Origin</code> header, 1681 - but <code>X-Client</code> is preferred since it works everywhere (server-side, CLI, native apps). 1682 - </p> 1683 - 1684 - <h2 id="response-comparison">response comparison</h2> 1685 - <p> 1686 - both return <code>{ "actors": [...] }</code>. the actor objects differ: 1687 - </p> 1688 - <table> 1689 - <tr><th>field</th><th>bluesky</th><th>typeahead</th></tr> 1690 - <tr><td><code>did</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> 1691 - <tr><td><code>handle</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> 1692 - <tr><td><code>displayName</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> 1693 - <tr><td><code>avatar</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> 1694 - <tr><td><code>associated</code></td><td class="yes">✓</td><td class="no">—</td></tr> 1695 - <tr><td><code>labels</code></td><td class="yes">✓</td><td class="no">—</td></tr> 1696 - <tr><td><code>createdAt</code></td><td class="yes">✓</td><td class="no">—</td></tr> 1697 - <tr><td><code>viewer</code></td><td class="yes">✓</td><td class="no">—</td></tr> 1698 - </table> 1699 - 1700 - <div class="callout warn"> 1701 - <strong>if you depend on <code>labels</code>, <code>associated</code>, <code>viewer</code>, 1702 - or <code>createdAt</code></strong> — this API doesn't return them. most typeahead UIs only 1703 - need did + handle + displayName + avatar, which is exactly what we return. if you need the 1704 - full <code>profileViewBasic</code> surface, you'll need to stick with the bluesky API or 1705 - fetch those fields separately. 1706 - </div> 1707 - 1708 - <h2>operational notes</h2> 1709 - <ul> 1710 - <li><strong>rate limited</strong> — 60 req/min per IP</li> 1711 - <li><strong>cached</strong> — results are edge-cached for 60s, so very recent profile changes may lag</li> 1712 - <li><strong>limit range</strong> — <code>1–100</code> (bluesky defaults to 10)</li> 1713 - <li><strong>moderation</strong> — actors with <code>!hide</code> or <code>!takedown</code> labels are excluded</li> 1714 - <li><strong>CORS</strong> — enabled, so browser-based apps can call it directly</li> 1715 - </ul> 1716 - 1717 - <h2>example: plyr.fm</h2> 1718 - <p> 1719 - <a href="https://tangled.sh/zzstoatzz.io/plyr.fm">plyr.fm</a> uses typeahead for actor 1720 - search. the integration looks roughly like: 1721 - </p> 1722 - <pre><code><span class="cm">// config.ts</span> 1723 - <span class="kw">export const</span> TYPEAHEAD_URL <span class="op">=</span> <span class="str">'https://typeahead.waow.tech'</span>; 1724 - 1725 - <span class="cm">// HandleSearch.svelte</span> 1726 - <span class="kw">const</span> response <span class="op">=</span> <span class="kw">await</span> <span class="fn">fetch</span>( 1727 - <span class="str">\`\${TYPEAHEAD_URL}/xrpc/app.bsky.actor.searchActorsTypeahead?q=\${<span class="fn">encodeURIComponent</span>(query)}&amp;limit=10\`</span> 1728 - ); 1729 - <span class="kw">const</span> data <span class="op">=</span> <span class="kw">await</span> response.<span class="fn">json</span>(); 1730 - <span class="kw">const</span> actors <span class="op">=</span> (data.actors ?? []).<span class="fn">map</span>(actor <span class="op">=></span> ({ 1731 - did: actor.did, 1732 - handle: actor.handle, 1733 - display_name: actor.displayName ?? actor.handle, 1734 - avatar_url: actor.avatar ?? <span class="kw">null</span>, 1735 - }));</code></pre> 1736 - 1737 - <h2>request indexing</h2> 1738 - <p> 1739 - if someone isn't showing up in results, you (or your users) can request indexing from the 1740 - <a href="/">homepage</a>. newly created accounts are picked up automatically via jetstream, 1741 - but accounts created before the index existed may need a manual nudge. 1742 - </p> 1743 - 1744 - <footer> 1745 - <a href="/">&larr; home</a> 1746 - · <a href="/stats">stats</a> 1747 - </footer> 1748 - </div> 1749 - </body> 1750 - </html>`; 1751 - } 1752 - 1753 - function html(body: string, status = 200): Response { 1754 - return new Response(body, { 1755 - status, 1756 - headers: { "Content-Type": "text/html; charset=utf-8", ...CORS_HEADERS }, 1757 - }); 1758 - } 1 + import type { Env } from "./types"; 2 + import { CORS_HEADERS } from "./types"; 3 + import { createDb } from "./db"; 4 + import { clientIP, json, html } from "./utils"; 5 + import { recordSnapshot } from "./enrichment"; 6 + import { enrichActors } from "./enrichment"; 7 + import { refreshModeration } from "./cron"; 8 + import { handleSearch } from "./handlers/search"; 9 + import { handleIngest } from "./handlers/ingest"; 10 + import { handleDelete, handleCursor, handleRequestIndexing } from "./handlers/admin"; 11 + import { handleStats } from "./handlers/stats"; 12 + import { indexPage } from "./pages/home"; 13 + import { docsPage } from "./pages/docs"; 1759 14 1760 15 export default { 1761 16 async scheduled(_event: ScheduledEvent, env: Env, _ctx: ExecutionContext): Promise<void> { 1762 - const db = tursoDb(createClient({ url: env.TURSO_URL, authToken: env.TURSO_AUTH_TOKEN })); 17 + const db = createDb(env); 1763 18 await recordSnapshot(db); 1764 19 await refreshModeration(db, env); 1765 20 await enrichActors(db, env); ··· 1785 40 return new Response(null, { status: 302, headers: { Location: "/" } }); 1786 41 } 1787 42 1788 - const db = tursoDb(createClient({ url: env.TURSO_URL, authToken: env.TURSO_AUTH_TOKEN })); 43 + const db = createDb(env); 1789 44 1790 45 if (pathname === "/stats" && request.method === "GET") { 1791 46 return handleStats(db);
+67
src/metrics.ts
··· 1 + import type { TursoDB } from "./db"; 2 + 3 + /** fire-and-forget: increment 5-min search count + accumulate response time */ 4 + export async function recordMetric(db: TursoDB, ms: number): Promise<void> { 5 + const bucket = Math.floor(Date.now() / 300_000); 6 + await db.prepare( 7 + `INSERT INTO metrics (hour, searches, total_ms) 8 + VALUES (?1, 1, ?2) 9 + ON CONFLICT(hour) DO UPDATE SET 10 + searches = searches + 1, 11 + total_ms = total_ms + ?2` 12 + ) 13 + .bind(bucket, ms) 14 + .run(); 15 + } 16 + 17 + /** fire-and-forget: record actor count deltas at 5-min granularity */ 18 + export async function recordActorDelta( 19 + db: TursoDB, 20 + deltas: { actors?: number; handles?: number; avatars?: number }, 21 + ): Promise<void> { 22 + const bucket = Math.floor(Date.now() / 300_000); 23 + await db.prepare( 24 + `INSERT INTO actor_deltas (bucket, actors_delta, handles_delta, avatars_delta) 25 + VALUES (?1, ?2, ?3, ?4) 26 + ON CONFLICT(bucket) DO UPDATE SET 27 + actors_delta = actors_delta + ?2, 28 + handles_delta = handles_delta + ?3, 29 + avatars_delta = avatars_delta + ?4` 30 + ) 31 + .bind(bucket, deltas.actors ?? 0, deltas.handles ?? 0, deltas.avatars ?? 0) 32 + .run(); 33 + } 34 + 35 + /** fire-and-forget: increment cumulative hit counter per client identity */ 36 + export async function recordTrafficSource(db: TursoDB, request: Request): Promise<void> { 37 + const client = request.headers.get("X-Client"); 38 + const origin = request.headers.get("Origin"); 39 + const referer = request.headers.get("Referer"); 40 + const selfHost = new URL(request.url).hostname; 41 + 42 + let domain: string; 43 + if (client) { 44 + domain = client; 45 + } else if (origin) { 46 + domain = new URL(origin).hostname; 47 + } else if (referer) { 48 + try { 49 + const refHost = new URL(referer).hostname; 50 + domain = refHost === selfHost ? "homepage" : refHost; 51 + } catch { domain = "unknown"; } 52 + } else { 53 + domain = "unknown"; 54 + } 55 + 56 + // normalize local/dev traffic 57 + if (domain === "localhost" || domain.startsWith("127.") || domain === "[::1]") { 58 + domain = "unknown"; 59 + } 60 + await db.prepare( 61 + `INSERT INTO traffic_sources (domain, hits) 62 + VALUES (?1, 1) 63 + ON CONFLICT(domain) DO UPDATE SET hits = hits + 1` 64 + ) 65 + .bind(domain) 66 + .run(); 67 + }
+20
src/moderation.ts
··· 1 + export const BSKY_MOD_DID = "did:plc:ar7c4by46qjdydhdevvrndac"; 2 + /** labels from bluesky's moderation service that hide an actor from search */ 3 + export const MOD_HIDE_VALS = new Set(["!hide", "!takedown", "spam"]); 4 + 5 + /** 6 + * returns whether an actor should be hidden from search. 7 + * 8 + * only hides actors flagged by bluesky's moderation service (!hide, !takedown, spam). 9 + * !no-unauthenticated is intentionally NOT filtered — it applies to content, not identity. 10 + * bluesky's own public typeahead API returns !no-unauthenticated accounts, and so do we. 11 + */ 12 + export function shouldHide(labels?: any[]): boolean { 13 + if (!labels) return false; 14 + const now = Date.now(); 15 + return labels.some((l: any) => { 16 + if (l.neg) return false; 17 + if (l.exp && new Date(l.exp).getTime() <= now) return false; 18 + return l.src === BSKY_MOD_DID && MOD_HIDE_VALS.has(l.val); 19 + }); 20 + }
+193
src/pages/docs.ts
··· 1 + import { FAVICON } from "../types"; 2 + 3 + export function docsPage(): string { 4 + return `<!doctype html> 5 + <html> 6 + <head> 7 + <meta charset="utf-8"> 8 + <meta name="viewport" content="width=device-width, initial-scale=1"> 9 + <title>typeahead — switching from bluesky</title> 10 + ${FAVICON} 11 + <style> 12 + * { margin: 0; padding: 0; box-sizing: border-box; } 13 + body { font-family: system-ui, sans-serif; background: #0a0a0a; color: #e0e0e0; 14 + display: flex; justify-content: center; padding: 2rem 1rem; min-height: 100vh; } 15 + .container { max-width: 620px; width: 100%; } 16 + .header { display: flex; align-items: baseline; gap: 0.4rem; margin-bottom: 0.4rem; } 17 + h1 { font-size: 1.1rem; font-weight: 400; color: #888; } 18 + h1 strong { color: #e0e0e0; } 19 + .subtitle { font-size: 0.8rem; color: #555; margin-bottom: 1.5rem; } 20 + h2 { font-size: 0.85rem; font-weight: 600; color: #ccc; margin: 1.5rem 0 0.6rem; } 21 + h3 { font-size: 0.8rem; font-weight: 600; color: #aaa; margin: 1.2rem 0 0.5rem; } 22 + p, li { font-size: 0.8rem; color: #999; line-height: 1.6; } 23 + p { margin-bottom: 0.7rem; } 24 + ul { margin: 0 0 0.7rem 1.2rem; } 25 + li { margin-bottom: 0.3rem; } 26 + code { font-family: ui-monospace, monospace; font-size: 0.75rem; background: #1a1a1a; 27 + border: 1px solid #222; border-radius: 3px; padding: 0.1rem 0.35rem; color: #ccc; } 28 + pre { background: #111; border: 1px solid #222; border-radius: 6px; padding: 0.8rem; 29 + overflow-x: auto; margin-bottom: 0.7rem; } 30 + pre code { background: none; border: none; padding: 0; font-size: 0.72rem; color: #bbb; } 31 + .diff-add { color: #4a9; } 32 + .diff-del { color: #a55; } 33 + .kw { color: #c678dd; } 34 + .str { color: #98c379; } 35 + .fn { color: #61afef; } 36 + .cm { color: #5c6370; font-style: italic; } 37 + .op { color: #abb2bf; } 38 + .callout { background: #111; border: 1px solid #222; border-left: 3px solid #4a9; 39 + border-radius: 6px; padding: 0.7rem 0.9rem; margin-bottom: 1rem; 40 + font-size: 0.78rem; color: #999; line-height: 1.6; } 41 + .callout strong { color: #ccc; } 42 + .callout.warn { border-left-color: #b98a3e; } 43 + table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; font-size: 0.75rem; } 44 + th { text-align: left; color: #666; font-weight: 400; padding: 0.4rem 0.6rem; 45 + border-bottom: 1px solid #222; } 46 + td { padding: 0.4rem 0.6rem; border-bottom: 1px solid #1a1a1a; color: #999; } 47 + td code { font-size: 0.7rem; } 48 + .yes { color: #4a9; } 49 + .no { color: #666; } 50 + footer { padding-top: 1.5rem; border-top: 1px solid #1a1a1a; font-size: 0.7rem; 51 + color: #444; display: flex; justify-content: center; gap: 0.4rem; } 52 + footer a { color: #555; text-decoration: none; } 53 + footer a:hover { color: #888; } 54 + a { color: #58a6ff; text-decoration: none; } 55 + a:hover { text-decoration: underline; } 56 + @media (max-width: 640px) { 57 + body { padding: 1.5rem 0.75rem; } 58 + } 59 + </style> 60 + </head> 61 + <body> 62 + <div class="container"> 63 + <div class="header"> 64 + <h1><a href="/" style="color:inherit;text-decoration:none"><strong>typeahead</strong></a> docs</h1> 65 + </div> 66 + <p class="subtitle">switching from the bluesky typeahead API</p> 67 + 68 + <div class="callout"> 69 + <strong>tl;dr</strong> — replace <code>public.api.bsky.app</code> with <code>typeahead.waow.tech</code>. 70 + </div> 71 + 72 + <h2>what this is</h2> 73 + <p> 74 + typeahead is a community-run actor search for <a href="https://atproto.com">atproto</a>, 75 + aiming to be a drop-in replacement for bluesky's 76 + <code>app.bsky.actor.searchActorsTypeahead</code> endpoint. the endpoint path and query 77 + params are identical; the response shape is compatible but slimmer (see 78 + <a href="#response-comparison">response comparison</a> below). 79 + </p> 80 + <p> 81 + the index is built from a few <a href="https://docs.bsky.app/blog/jetstream">jetstream</a> 82 + collections — profiles, posts, likes, and follows — so any account that creates or 83 + interacts with content gets discovered automatically. for accounts that predate the index 84 + or haven't been seen yet, search queries trigger a throttled backfill from the bluesky API 85 + to fill gaps on demand. searches use FTS5 prefix matching against handles and display names, 86 + with results edge-cached for 60s. 87 + </p> 88 + 89 + <h2>the change</h2> 90 + <p>replace the base URL. everything else stays the same:</p> 91 + <pre><code><span class="diff-del">- https://public.api.bsky.app</span>/xrpc/app.bsky.actor.searchActorsTypeahead?q=...&amp;limit=10 92 + <span class="diff-add">+ https://typeahead.waow.tech</span>/xrpc/app.bsky.actor.searchActorsTypeahead?q=...&amp;limit=10</code></pre> 93 + 94 + <h3>before</h3> 95 + <pre><code><span class="kw">const</span> response <span class="op">=</span> <span class="kw">await</span> <span class="fn">fetch</span>( 96 + <span class="str">\`https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=\${<span class="fn">encodeURIComponent</span>(query)}&amp;limit=10\`</span> 97 + );</code></pre> 98 + 99 + <h3>after</h3> 100 + <pre><code><span class="kw">const</span> TYPEAHEAD_URL <span class="op">=</span> <span class="str">'https://typeahead.waow.tech'</span>; 101 + 102 + <span class="kw">const</span> response <span class="op">=</span> <span class="kw">await</span> <span class="fn">fetch</span>( 103 + <span class="str">\`\${TYPEAHEAD_URL}/xrpc/app.bsky.actor.searchActorsTypeahead?q=\${<span class="fn">encodeURIComponent</span>(query)}&amp;limit=10\`</span> 104 + );</code></pre> 105 + 106 + <p> 107 + extracting the base URL into a constant (or env var) makes it easy to switch back 108 + if you ever need to. 109 + </p> 110 + 111 + <h2>identify your app</h2> 112 + <p> 113 + set the <code>X-Client</code> header so your app shows up by name in our 114 + <a href="/stats">traffic stats</a> instead of as "unknown": 115 + </p> 116 + <pre><code><span class="kw">const</span> response <span class="op">=</span> <span class="kw">await</span> <span class="fn">fetch</span>( 117 + <span class="str">\`\${TYPEAHEAD_URL}/xrpc/app.bsky.actor.searchActorsTypeahead?q=\${<span class="fn">encodeURIComponent</span>(query)}&amp;limit=10\`</span>, 118 + { headers: { <span class="str">'X-Client'</span>: <span class="str">'my-app.example.com'</span> } } 119 + );</code></pre> 120 + <p> 121 + use your domain or app name — whatever you want to be identified as. 122 + browser-based apps will also be identified automatically via the <code>Origin</code> header, 123 + but <code>X-Client</code> is preferred since it works everywhere (server-side, CLI, native apps). 124 + </p> 125 + 126 + <h2 id="response-comparison">response comparison</h2> 127 + <p> 128 + both return <code>{ "actors": [...] }</code>. the actor objects differ: 129 + </p> 130 + <table> 131 + <tr><th>field</th><th>bluesky</th><th>typeahead</th></tr> 132 + <tr><td><code>did</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> 133 + <tr><td><code>handle</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> 134 + <tr><td><code>displayName</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> 135 + <tr><td><code>avatar</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> 136 + <tr><td><code>associated</code></td><td class="yes">✓</td><td class="no">—</td></tr> 137 + <tr><td><code>labels</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> 138 + <tr><td><code>createdAt</code></td><td class="yes">✓</td><td class="no">—</td></tr> 139 + <tr><td><code>viewer</code></td><td class="yes">✓</td><td class="no">—</td></tr> 140 + </table> 141 + 142 + <div class="callout warn"> 143 + <strong>if you depend on <code>associated</code>, <code>viewer</code>, 144 + or <code>createdAt</code></strong> — this API doesn't return them. we return 145 + did + handle + displayName + avatar + labels, which covers most typeahead UIs. 146 + if you need the full <code>profileViewBasic</code> surface, you'll need to stick with 147 + the bluesky API or fetch those fields separately. 148 + </div> 149 + 150 + <h2>operational notes</h2> 151 + <ul> 152 + <li><strong>rate limited</strong> — 60 req/min per IP</li> 153 + <li><strong>cached</strong> — results are edge-cached for 60s, so very recent profile changes may lag</li> 154 + <li><strong>limit range</strong> — <code>1–100</code> (bluesky defaults to 10)</li> 155 + <li><strong>moderation</strong> — actors with <code>!hide</code> or <code>!takedown</code> labels are excluded</li> 156 + <li><strong>CORS</strong> — enabled, so browser-based apps can call it directly</li> 157 + </ul> 158 + 159 + <h2>example: plyr.fm</h2> 160 + <p> 161 + <a href="https://tangled.sh/zzstoatzz.io/plyr.fm">plyr.fm</a> uses typeahead for actor 162 + search. the integration looks roughly like: 163 + </p> 164 + <pre><code><span class="cm">// config.ts</span> 165 + <span class="kw">export const</span> TYPEAHEAD_URL <span class="op">=</span> <span class="str">'https://typeahead.waow.tech'</span>; 166 + 167 + <span class="cm">// HandleSearch.svelte</span> 168 + <span class="kw">const</span> response <span class="op">=</span> <span class="kw">await</span> <span class="fn">fetch</span>( 169 + <span class="str">\`\${TYPEAHEAD_URL}/xrpc/app.bsky.actor.searchActorsTypeahead?q=\${<span class="fn">encodeURIComponent</span>(query)}&amp;limit=10\`</span> 170 + ); 171 + <span class="kw">const</span> data <span class="op">=</span> <span class="kw">await</span> response.<span class="fn">json</span>(); 172 + <span class="kw">const</span> actors <span class="op">=</span> (data.actors ?? []).<span class="fn">map</span>(actor <span class="op">=></span> ({ 173 + did: actor.did, 174 + handle: actor.handle, 175 + display_name: actor.displayName ?? actor.handle, 176 + avatar_url: actor.avatar ?? <span class="kw">null</span>, 177 + }));</code></pre> 178 + 179 + <h2>request indexing</h2> 180 + <p> 181 + if someone isn't showing up in results, you (or your users) can request indexing from the 182 + <a href="/">homepage</a>. newly created accounts are picked up automatically via jetstream, 183 + but accounts created before the index existed may need a manual nudge. 184 + </p> 185 + 186 + <footer> 187 + <a href="/">&larr; home</a> 188 + · <a href="/stats">stats</a> 189 + </footer> 190 + </div> 191 + </body> 192 + </html>`; 193 + }
+189
src/pages/home.ts
··· 1 + import { FAVICON } from "../types"; 2 + 3 + export function indexPage(): string { 4 + return `<!doctype html> 5 + <html> 6 + <head> 7 + <meta charset="utf-8"> 8 + <meta name="viewport" content="width=device-width, initial-scale=1"> 9 + <title>typeahead</title> 10 + ${FAVICON} 11 + <style> 12 + * { margin: 0; padding: 0; box-sizing: border-box; } 13 + body { font-family: system-ui, sans-serif; background: #0a0a0a; color: #e0e0e0; 14 + display: flex; justify-content: center; align-items: center; min-height: 100vh; } 15 + .container { max-width: 460px; width: 100%; padding: 2rem; } 16 + .header { display: flex; align-items: baseline; gap: 0.4rem; margin-bottom: 0.4rem; } 17 + h1 { font-size: 1.1rem; font-weight: 400; color: #888; } 18 + h1 strong { color: #e0e0e0; } 19 + .src { font-size: 0.75em; color: #555; text-decoration: none; margin-left: 0.3em; } 20 + .src:hover { color: #888; } 21 + .experimental { font-size: 0.55em; color: #664; cursor: default; position: relative; 22 + vertical-align: super; line-height: 1; margin-left: 0.3em; } 23 + .experimental:hover::after { 24 + content: "this is experimental and may break or disappear — don't depend on it for anything critical"; 25 + position: absolute; left: 0; top: 1.4em; width: 220px; padding: 0.5rem; 26 + background: #1a1a1a; border: 1px solid #333; border-radius: 4px; 27 + font-size: 1.4em; color: #999; line-height: 1.4; z-index: 20; white-space: normal; 28 + } 29 + .subtitle { font-size: 0.8rem; color: #555; margin-bottom: 1.5rem; } 30 + .subtitle a { color: #555; text-decoration: none; } 31 + .subtitle a:hover { color: #888; } 32 + section { margin-bottom: 2rem; } 33 + label { display: block; font-size: 0.8rem; color: #666; margin-bottom: 0.5rem; } 34 + .search-wrap { position: relative; } 35 + input { width: 100%; padding: 0.6rem 0.8rem; background: #1a1a1a; border: 1px solid #333; 36 + border-radius: 6px; color: #e0e0e0; font-size: 16px; outline: none; } 37 + input:focus { border-color: #555; } 38 + input::placeholder { color: #555; } 39 + .results { position: absolute; top: 100%; left: 0; right: 0; margin-top: 4px; 40 + background: #141414; border: 1px solid #2a2a2a; border-radius: 6px; 41 + max-height: 260px; overflow-y: auto; z-index: 10; display: none; } 42 + .results.show { display: block; } 43 + .result { display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0.7rem; 44 + cursor: default; font-size: 0.85rem; } 45 + .result:hover { background: #1a1a1a; } 46 + .result img { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; } 47 + .result .placeholder { width: 28px; height: 28px; border-radius: 50%; background: #222; flex-shrink: 0; } 48 + .result .info { min-width: 0; } 49 + .result .name { color: #ccc; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 50 + .result .handle { color: #555; font-size: 0.75rem; } 51 + .empty { padding: 0.6rem 0.8rem; color: #444; font-size: 0.8rem; } 52 + .index-form { display: flex; gap: 0.5rem; } 53 + .index-form input { flex: 1; } 54 + button { padding: 0.6rem 1rem; background: #2a2a2a; border: 1px solid #333; 55 + border-radius: 6px; color: #e0e0e0; font-size: 0.9rem; cursor: pointer; } 56 + button:hover { background: #333; } 57 + button:disabled { opacity: 0.5; cursor: default; } 58 + .msg { margin-top: 0.75rem; padding: 0.6rem 0.8rem; background: #1a1a1a; border-radius: 6px; 59 + font-size: 0.85rem; line-height: 1.4; display: flex; justify-content: space-between; 60 + align-items: start; gap: 0.5rem; } 61 + .msg.error { border-left: 2px solid #a55; } 62 + .msg .dismiss { color: #555; cursor: pointer; font-size: 0.75rem; flex-shrink: 0; 63 + background: none; border: none; padding: 0; } 64 + .msg .dismiss:hover { color: #888; } 65 + .api { margin-bottom: 1.5rem; background: #111; border: 1px solid #222; border-radius: 6px; 66 + padding: 0.5rem 0.7rem; display: flex; align-items: center; gap: 0.5rem; 67 + overflow-x: auto; white-space: nowrap; } 68 + .api .method { font-size: 0.65rem; font-weight: 600; color: #4a9; background: #4a92; 69 + padding: 0.15rem 0.4rem; border-radius: 3px; flex-shrink: 0; 70 + font-family: ui-monospace, monospace; letter-spacing: 0.03em; } 71 + .api .path { font-size: 0.7rem; color: #666; font-family: ui-monospace, monospace; } 72 + footer { padding-top: 1.5rem; border-top: 1px solid #1a1a1a; font-size: 0.7rem; 73 + color: #444; display: flex; justify-content: center; gap: 0.4rem; } 74 + footer a { color: #555; text-decoration: none; } 75 + footer a:hover { color: #888; } 76 + </style> 77 + </head> 78 + <body> 79 + <div class="container"> 80 + <div class="header"> 81 + <h1><strong>typeahead</strong><sup class="experimental">*experimental</sup> <a href="https://tangled.sh/zzstoatzz.io/typeahead" class="src" target="_blank" rel="noopener">[src]</a></h1> 82 + </div> 83 + <p class="subtitle">community actor search for <a href="https://atproto.com" target="_blank" rel="noopener">atproto</a></p> 84 + 85 + <section> 86 + <label>try it</label> 87 + <div class="search-wrap"> 88 + <input id="q" placeholder="search handles..." autocomplete="off" autofocus> 89 + <div class="results" id="results"></div> 90 + </div> 91 + </section> 92 + 93 + <section> 94 + <label>request indexing</label> 95 + <form class="index-form" id="index-form"> 96 + <input id="handle-input" placeholder="handle or DID" autocomplete="off"> 97 + <button type="submit" id="index-btn">index</button> 98 + </form> 99 + <div id="index-msg"></div> 100 + </section> 101 + 102 + <div class="api"> 103 + <span class="method">GET</span> 104 + <span class="path">/xrpc/app.bsky.actor.searchActorsTypeahead?q=...&amp;limit=10</span> 105 + </div> 106 + 107 + <footer> 108 + by <a href="https://bsky.app/profile/zzstoatzz.io" target="_blank" rel="noopener">@zzstoatzz.io</a> 109 + · <a href="/stats">stats</a> 110 + · <a href="/docs">docs</a> 111 + </footer> 112 + </div> 113 + <script> 114 + const q = document.getElementById('q'); 115 + const results = document.getElementById('results'); 116 + let timer = null; 117 + q.addEventListener('input', () => { 118 + clearTimeout(timer); 119 + const v = q.value.trim(); 120 + if (v.length < 2) { results.classList.remove('show'); return; } 121 + timer = setTimeout(async () => { 122 + try { 123 + const r = await fetch('/xrpc/app.bsky.actor.searchActorsTypeahead?q=' + encodeURIComponent(v) + '&limit=8'); 124 + const data = await r.json(); 125 + const actors = data.actors || []; 126 + if (actors.length === 0) { 127 + results.innerHTML = '<div class="empty">no results</div>'; 128 + } else { 129 + results.innerHTML = actors.map(a => 130 + '<div class="result">' + 131 + (a.avatar ? '<img src="' + a.avatar + '" alt="">' : '<div class="placeholder"></div>') + 132 + '<div class="info"><div class="name">' + esc(a.displayName || a.handle) + '</div>' + 133 + '<div class="handle">@' + esc(a.handle) + '</div></div></div>' 134 + ).join(''); 135 + } 136 + results.classList.add('show'); 137 + } catch(e) {} 138 + }, 200); 139 + }); 140 + document.addEventListener('click', e => { if (!e.target.closest('.search-wrap')) results.classList.remove('show'); }); 141 + q.addEventListener('focus', () => { if (results.innerHTML) results.classList.add('show'); }); 142 + function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } 143 + 144 + // request indexing form 145 + const indexForm = document.getElementById('index-form'); 146 + const handleInput = document.getElementById('handle-input'); 147 + const indexBtn = document.getElementById('index-btn'); 148 + const indexMsg = document.getElementById('index-msg'); 149 + 150 + function showMsg(text, isError) { 151 + indexMsg.innerHTML = '<div class="msg' + (isError ? ' error' : '') + '">' + 152 + '<span>' + text + '</span>' + 153 + '<button class="dismiss" onclick="dismissMsg()">\\u00d7</button></div>'; 154 + } 155 + 156 + function dismissMsg() { 157 + indexMsg.innerHTML = ''; 158 + } 159 + 160 + indexForm.addEventListener('submit', async (e) => { 161 + e.preventDefault(); 162 + const val = handleInput.value.trim(); 163 + if (!val) { showMsg('enter a handle or DID.', true); return; } 164 + 165 + indexBtn.disabled = true; 166 + indexBtn.textContent = '...'; 167 + dismissMsg(); 168 + 169 + try { 170 + const r = await fetch('/request-indexing?handle=' + encodeURIComponent(val), { method: 'POST' }); 171 + const data = await r.json(); 172 + if (data.error) { 173 + showMsg(esc(data.error), true); 174 + } else { 175 + const hidden = data.hidden ? ' <em style="color:#886">(' + esc(data.reason || 'hidden') + ')</em>' : ''; 176 + showMsg('indexed <strong>@' + esc(data.handle) + '</strong>' + hidden, false); 177 + handleInput.value = ''; 178 + } 179 + } catch { 180 + showMsg('something went wrong — try again.', true); 181 + } finally { 182 + indexBtn.disabled = false; 183 + indexBtn.textContent = 'index'; 184 + } 185 + }); 186 + </script> 187 + </body> 188 + </html>`; 189 + }
+530
src/pages/stats.ts
··· 1 + import { FAVICON } from "../types"; 2 + 3 + export interface SnapshotPoint { 4 + ts: number; // millisecond timestamp 5 + total: number; 6 + with_handles: number; 7 + with_avatars: number; 8 + } 9 + 10 + export interface StatsData { 11 + total: number; 12 + hiddenCount: number; 13 + rows: { hour: number; searches: number; total_ms: number }[]; 14 + totalSearches: number; 15 + avgLatency: number; 16 + handlePct: string; 17 + avatarPct: string; 18 + snapshots: SnapshotPoint[]; 19 + trafficSources: { domain: string; hits: number }[]; 20 + } 21 + 22 + export function statsPage(d: StatsData): string { 23 + // search sparkline data (oldest first) 24 + const sorted = [...d.rows].reverse(); 25 + const counts = sorted.map((r) => r.searches); 26 + const sparkMax = Math.max(...counts, 1); 27 + const sw = 600, sh = 80; 28 + const step = counts.length > 1 ? sw / (counts.length - 1) : 0; 29 + const sparkPoints = counts 30 + .map((c, i) => `${(i * step).toFixed(1)},${(sh - (c / sparkMax) * sh).toFixed(1)}`) 31 + .join(" "); 32 + const sparkJson = JSON.stringify( 33 + sorted.map((r) => ({ 34 + time: new Date(r.hour * 300_000).toISOString().slice(0, 16) + "Z", 35 + searches: r.searches, 36 + })) 37 + ); 38 + 39 + const snapshotJson = JSON.stringify(d.snapshots); 40 + const trafficJson = JSON.stringify(d.trafficSources); 41 + 42 + const PIE_COLORS = ['#4a9', '#58a6ff', '#bc8cff', '#e5a04b', '#e06c75', '#56b6c2', '#c678dd', '#98c379', '#d19a66']; 43 + const trafficTotal = d.trafficSources.reduce((s, r) => s + r.hits, 0); 44 + const SPECIAL_DOMAINS: Record<string, string> = { unknown: '#555', homepage: '#666' }; 45 + const pieLegendHtml = d.trafficSources.map((r, i) => { 46 + const color = SPECIAL_DOMAINS[r.domain] ?? PIE_COLORS[i % PIE_COLORS.length]; 47 + const pct = trafficTotal > 0 ? ((r.hits / trafficTotal) * 100).toFixed(1) : '0'; 48 + const label = r.domain in SPECIAL_DOMAINS 49 + ? r.domain 50 + : `<a href="https://${r.domain}" target="_blank" rel="noopener" class="legend-link">${r.domain}</a>`; 51 + return `<span><span class="ldot" style="background:${color}"></span>${label} (${pct}%)</span>`; 52 + }).join(''); 53 + 54 + return `<!doctype html> 55 + <html> 56 + <head> 57 + <meta charset="utf-8"> 58 + <meta name="viewport" content="width=device-width, initial-scale=1"> 59 + <title>typeahead — stats</title> 60 + ${FAVICON} 61 + <style> 62 + * { margin: 0; padding: 0; box-sizing: border-box; } 63 + body { font-family: system-ui, sans-serif; background: #0a0a0a; color: #e0e0e0; 64 + display: flex; justify-content: center; padding: 2rem 1rem; min-height: 100vh; } 65 + .container { max-width: 620px; width: 100%; } 66 + .header { display: flex; align-items: baseline; gap: 0.4rem; margin-bottom: 0.4rem; } 67 + h1 { font-size: 1.1rem; font-weight: 400; color: #888; } 68 + h1 strong { color: #e0e0e0; } 69 + .subtitle { font-size: 0.8rem; color: #555; margin-bottom: 1.5rem; } 70 + 71 + .chart-wrap { background: #111; border: 1px solid #222; border-radius: 6px; 72 + padding: 0.9rem; margin-bottom: 1.5rem; position: relative; } 73 + .chart-wrap h2 { font-size: 0.75rem; font-weight: 400; color: #555; margin-bottom: 0.6rem; } 74 + #trend { width: 100%; height: 200px; display: block; touch-action: none; } 75 + .legend { display: flex; gap: 1.2rem; margin-top: 0.6rem; font-size: 0.7rem; color: #888; } 76 + .legend span { display: flex; align-items: center; gap: 0.35rem; } 77 + .ldot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } 78 + .chart-tip { display: none; position: fixed; background: rgba(17, 17, 17, 0.95); 79 + border: 1px solid #333; border-radius: 6px; padding: 0.5rem 0.7rem; 80 + font-size: 0.72rem; color: #ccc; pointer-events: none; z-index: 50; 81 + line-height: 1.6; white-space: nowrap; 82 + box-shadow: 0 4px 12px rgba(0,0,0,0.5); } 83 + .ct-time { color: #666; font-size: 0.65rem; } 84 + .ct-row { display: flex; align-items: center; gap: 0.35rem; } 85 + .ct-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; flex-shrink: 0; } 86 + 87 + .sparkline-wrap { background: #111; border: 1px solid #222; border-radius: 6px; 88 + padding: 0.9rem; margin-bottom: 1.5rem; } 89 + .sparkline-wrap h2 { font-size: 0.75rem; font-weight: 400; color: #555; margin-bottom: 0.6rem; } 90 + svg { width: 100%; height: auto; } 91 + polyline { fill: none; stroke: #4a9; stroke-width: 1.5; } 92 + 93 + .summary { display: grid; gap: 0.8rem; margin-bottom: 1.5rem; } 94 + .summary-2 { grid-template-columns: repeat(2, 1fr); } 95 + .summary-3 { grid-template-columns: repeat(3, 1fr); } 96 + .metric { background: #111; border: 1px solid #222; border-radius: 6px; padding: 0.7rem 0.9rem; } 97 + .metric .label { font-size: 0.7rem; color: #555; margin-bottom: 0.2rem; } 98 + .metric .label[data-tip] { cursor: default; position: relative; border-bottom: 1px dotted #444; display: inline-block; } 99 + .metric .label[data-tip]:hover::after { 100 + content: attr(data-tip); position: absolute; left: 0; top: 1.6em; width: 200px; 101 + padding: 0.4rem 0.5rem; background: #1a1a1a; border: 1px solid #333; border-radius: 4px; 102 + font-size: 0.95em; color: #999; line-height: 1.4; z-index: 20; white-space: normal; 103 + } 104 + .metric .value { font-size: 1.1rem; color: #ccc; } 105 + .spark-tip { display: none; position: fixed; background: #1a1a1a; border: 1px solid #333; 106 + border-radius: 4px; padding: 0.4rem 0.6rem; font-size: 0.75rem; color: #ccc; 107 + pointer-events: none; z-index: 50; } 108 + 109 + .pie-wrap { background: #111; border: 1px solid #222; border-radius: 6px; 110 + padding: 0.9rem; margin-bottom: 1.5rem; } 111 + .pie-wrap h2 { font-size: 0.75rem; font-weight: 400; color: #555; margin-bottom: 0.6rem; } 112 + #pie { display: block; margin: 0 auto; width: 280px; height: 280px; touch-action: none; } 113 + .pie-legend { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; margin-top: 0.7rem; 114 + font-size: 0.7rem; color: #888; justify-content: center; } 115 + .pie-legend span { display: flex; align-items: center; gap: 0.3rem; line-height: 1.4; } 116 + .legend-link { color: #888; text-decoration: none; border-bottom: 1px dotted #444; } 117 + .legend-link:hover { color: #ccc; border-bottom-color: #888; } 118 + .pie-tip { display: none; position: fixed; background: rgba(17, 17, 17, 0.95); 119 + border: 1px solid #333; border-radius: 6px; padding: 0.5rem 0.7rem; 120 + font-size: 0.72rem; color: #ccc; pointer-events: none; z-index: 50; 121 + white-space: nowrap; box-shadow: 0 4px 12px rgba(0,0,0,0.5); } 122 + 123 + footer { padding-top: 1.5rem; border-top: 1px solid #1a1a1a; font-size: 0.7rem; 124 + color: #444; display: flex; justify-content: center; gap: 0.4rem; } 125 + footer a { color: #555; text-decoration: none; } 126 + footer a:hover { color: #888; } 127 + 128 + @media (max-width: 640px) { 129 + body { padding: 1.5rem 0.75rem; } 130 + #trend { height: 160px; } 131 + .legend { gap: 0.8rem; font-size: 0.65rem; flex-wrap: wrap; } 132 + .summary-3 { grid-template-columns: 1fr 1fr; } 133 + #pie { width: 220px; height: 220px; } 134 + } 135 + </style> 136 + </head> 137 + <body> 138 + <div class="container"> 139 + <div class="header"> 140 + <h1><a href="/" style="color:inherit;text-decoration:none"><strong>typeahead</strong></a> stats</h1> 141 + </div> 142 + <p class="subtitle">index health and search activity</p> 143 + 144 + <div class="chart-wrap"> 145 + <h2>actors indexed</h2> 146 + ${d.snapshots.length > 1 147 + ? `<canvas id="trend"></canvas>` 148 + : `<div style="color:#444;font-size:0.8rem;padding:2rem 0;text-align:center">collecting data — check back soon</div>`} 149 + <div class="legend"> 150 + ${[ 151 + { color: '#4a9', label: `total (${d.total.toLocaleString()})`, value: d.total }, 152 + { color: '#bc8cff', label: 'with avatars', value: d.snapshots[d.snapshots.length - 1]?.with_avatars ?? 0 }, 153 + { color: '#58a6ff', label: 'with handles', value: d.snapshots[d.snapshots.length - 1]?.with_handles ?? 0 }, 154 + ].sort((a, b) => b.value - a.value) 155 + .map(r => `<span><span class="ldot" style="background:${r.color}"></span> ${r.label}</span>`) 156 + .join('\n ')} 157 + </div> 158 + </div> 159 + <div class="chart-tip" id="chart-tip"></div> 160 + 161 + <div class="summary summary-3"> 162 + <div class="metric"> 163 + <div class="label" data-tip="% of indexed actors with a resolved handle (vs bare DID only)">handle coverage</div> 164 + <div class="value">${d.handlePct}%</div> 165 + </div> 166 + <div class="metric"> 167 + <div class="label" data-tip="% of indexed actors with a profile image">avatar coverage</div> 168 + <div class="value">${d.avatarPct}%</div> 169 + </div> 170 + <div class="metric"> 171 + <div class="label" data-tip="actors hidden by bluesky moderation (!hide, !takedown, spam)">hidden by moderation</div> 172 + <div class="value">${d.hiddenCount.toLocaleString()}</div> 173 + </div> 174 + </div> 175 + 176 + <div class="sparkline-wrap"> 177 + <h2>searches / 5 min (7 days)</h2> 178 + ${counts.length > 1 179 + ? `<svg viewBox="0 0 ${sw} ${sh}" preserveAspectRatio="none" id="spark"> 180 + <polyline points="${sparkPoints}" /> 181 + </svg>` 182 + : `<div style="color:#444;font-size:0.8rem;padding:1rem 0;text-align:center">no data yet</div>`} 183 + </div> 184 + <div class="summary summary-2"> 185 + <div class="metric"> 186 + <div class="label">total searches (7d)</div> 187 + <div class="value">${d.totalSearches.toLocaleString()}</div> 188 + </div> 189 + <div class="metric"> 190 + <div class="label" data-tip="average response time for uncached searches">avg latency</div> 191 + <div class="value">${d.avgLatency.toFixed(1)} ms</div> 192 + </div> 193 + </div> 194 + 195 + <div class="pie-wrap"> 196 + <h2>traffic sources</h2> 197 + ${d.trafficSources.length > 0 198 + ? `<canvas id="pie"></canvas> 199 + <div class="pie-legend">${pieLegendHtml}</div>` 200 + : `<div style="color:#444;font-size:0.8rem;padding:2rem 0;text-align:center">collecting data — check back soon</div>`} 201 + <div style="margin-top:0.6rem;font-size:0.7rem;color:#555;text-align:center"> 202 + want to show up here? <a href="/docs" style="color:#58a6ff;text-decoration:none">switch from bluesky&rsquo;s typeahead &rarr;</a> 203 + </div> 204 + </div> 205 + <div class="pie-tip" id="pie-tip"></div> 206 + 207 + <footer> 208 + <a href="/">&larr; home</a> 209 + </footer> 210 + </div> 211 + <div class="spark-tip" id="spark-tip"></div> 212 + <script> 213 + const COLORS = { total: '#4a9', handles: '#58a6ff', avatars: '#bc8cff' }; 214 + 215 + function fmtNum(n) { 216 + if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; 217 + if (n >= 1e3) return (n / 1e3).toFixed(0) + 'k'; 218 + return n.toLocaleString(); 219 + } 220 + 221 + /* --- actors indexed trend chart --- */ 222 + const snaps = ${snapshotJson}; 223 + const canvas = document.getElementById('trend'); 224 + const chartTip = document.getElementById('chart-tip'); 225 + 226 + function drawChart(hoverIdx) { 227 + if (!canvas || snaps.length < 2) return; 228 + const ctx = canvas.getContext('2d'); 229 + const dpr = window.devicePixelRatio || 1; 230 + const W = canvas.clientWidth, H = canvas.clientHeight; 231 + canvas.width = W * dpr; canvas.height = H * dpr; 232 + ctx.scale(dpr, dpr); 233 + ctx.clearRect(0, 0, W, H); 234 + 235 + const pad = { t: 16, r: 12, b: 24, l: 12 }; 236 + const cw = W - pad.l - pad.r, ch = H - pad.t - pad.b; 237 + const n = snaps.length; 238 + 239 + let yMax = 0; 240 + for (const s of snaps) { if (s.total > yMax) yMax = s.total; } 241 + yMax = yMax * 1.08 || 1; 242 + 243 + const toX = i => pad.l + (i / (n - 1)) * cw; 244 + const toY = v => pad.t + ch - (v / yMax) * ch; 245 + 246 + const series = [ 247 + { key: 'with_avatars', color: COLORS.avatars }, 248 + { key: 'with_handles', color: COLORS.handles }, 249 + { key: 'total', color: COLORS.total }, 250 + ]; 251 + 252 + for (const s of series) { 253 + const pts = snaps.map((d, i) => [toX(i), toY(d[s.key])]); 254 + const trace = () => { 255 + ctx.beginPath(); 256 + ctx.moveTo(pts[0][0], pts[0][1]); 257 + for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); 258 + }; 259 + 260 + // glow 261 + ctx.save(); trace(); 262 + ctx.shadowColor = s.color; ctx.shadowBlur = 18; 263 + ctx.strokeStyle = s.color; ctx.lineWidth = 2; ctx.globalAlpha = 0.07; 264 + ctx.stroke(); ctx.restore(); 265 + 266 + // medium glow 267 + ctx.save(); trace(); 268 + ctx.shadowColor = s.color; ctx.shadowBlur = 6; 269 + ctx.strokeStyle = s.color; ctx.lineWidth = 1; ctx.globalAlpha = 0.15; 270 + ctx.stroke(); ctx.restore(); 271 + 272 + // core line 273 + trace(); 274 + ctx.strokeStyle = s.color; 275 + ctx.lineWidth = 1.2; ctx.globalAlpha = 0.55; 276 + ctx.stroke(); ctx.globalAlpha = 1; 277 + } 278 + 279 + // x-axis 280 + { 281 + const axisY = H - pad.b; 282 + ctx.beginPath(); ctx.moveTo(pad.l, axisY); ctx.lineTo(W - pad.r, axisY); 283 + ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; 284 + ctx.stroke(); 285 + 286 + ctx.font = '10px system-ui, sans-serif'; 287 + ctx.fillStyle = '#555'; ctx.textAlign = 'center'; 288 + const t0ms = snaps[0].ts; 289 + const tNms = snaps[n - 1].ts; 290 + const spanDays = (tNms - t0ms) / 86400000; 291 + 292 + // walk midnights 293 + const d = new Date(t0ms); 294 + d.setUTCMinutes(0, 0, 0); 295 + d.setUTCHours(0); 296 + d.setUTCDate(d.getUTCDate() + 1); 297 + const minGap = W < 500 ? 65 : 50; 298 + let prevX = -Infinity; 299 + 300 + while (d.getTime() <= tNms) { 301 + const frac = (d.getTime() - t0ms) / (tNms - t0ms); 302 + const x = pad.l + frac * cw; 303 + if (x >= pad.l && x <= W - pad.r && x - prevX >= minGap) { 304 + ctx.beginPath(); ctx.moveTo(x, axisY); ctx.lineTo(x, axisY + 4); 305 + ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 1; ctx.stroke(); 306 + const label = spanDays > 14 307 + ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' }) 308 + : d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }); 309 + ctx.globalAlpha = 0.4; 310 + ctx.fillText(label, x, axisY + 15); 311 + ctx.globalAlpha = 1; 312 + prevX = x; 313 + } 314 + d.setUTCDate(d.getUTCDate() + 1); 315 + } 316 + } 317 + 318 + // hover crosshair + dots 319 + if (hoverIdx != null && hoverIdx >= 0 && hoverIdx < n) { 320 + const x = toX(hoverIdx); 321 + ctx.beginPath(); ctx.moveTo(x, pad.t); ctx.lineTo(x, H - pad.b); 322 + ctx.strokeStyle = 'rgba(255,255,255,0.1)'; 323 + ctx.lineWidth = 1; ctx.setLineDash([3, 3]); ctx.stroke(); ctx.setLineDash([]); 324 + 325 + const snap = snaps[hoverIdx]; 326 + for (const s of series) { 327 + const y = toY(snap[s.key]); 328 + ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); 329 + ctx.fillStyle = '#fff'; ctx.globalAlpha = 0.9; ctx.fill(); 330 + ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); 331 + ctx.strokeStyle = s.color; ctx.lineWidth = 1.5; ctx.globalAlpha = 0.8; 332 + ctx.stroke(); ctx.globalAlpha = 1; 333 + } 334 + } 335 + } 336 + 337 + function chartHover(clientX, clientY) { 338 + if (!canvas || snaps.length < 2) return; 339 + const rect = canvas.getBoundingClientRect(); 340 + const mx = clientX - rect.left; 341 + const pad = { l: 12, r: 12 }; 342 + const cw = rect.width - pad.l - pad.r; 343 + const frac = (mx - pad.l) / cw; 344 + const idx = Math.max(0, Math.min(snaps.length - 1, Math.round(frac * (snaps.length - 1)))); 345 + drawChart(idx); 346 + 347 + const snap = snaps[idx]; 348 + const t = new Date(snap.ts); 349 + const time = t.toISOString().replace('T', ' ').slice(0, 16) + ' UTC'; 350 + chartTip.innerHTML = 351 + '<div class="ct-time">' + time + '</div>' 352 + const tipRows = [ 353 + { color: COLORS.total, value: snap.total, label: 'actors' }, 354 + { color: COLORS.avatars, value: snap.with_avatars, label: 'with avatars' }, 355 + { color: COLORS.handles, value: snap.with_handles, label: 'with handles' }, 356 + ].sort((a, b) => b.value - a.value); 357 + chartTip.innerHTML = '<div class="ct-time">' + time + '</div>' 358 + + tipRows.map(r => '<div class="ct-row"><span class="ct-dot" style="background:' + r.color + '"></span> ' + fmtNum(r.value) + ' ' + r.label + '</div>').join(''); 359 + chartTip.style.display = 'block'; 360 + const tx = clientX + 14, ty = clientY - 10; 361 + chartTip.style.left = (tx + chartTip.offsetWidth > window.innerWidth ? clientX - chartTip.offsetWidth - 10 : tx) + 'px'; 362 + chartTip.style.top = ty + 'px'; 363 + } 364 + 365 + if (canvas) { 366 + drawChart(null); 367 + window.addEventListener('resize', () => drawChart(null)); 368 + 369 + canvas.addEventListener('mousemove', e => chartHover(e.clientX, e.clientY)); 370 + canvas.addEventListener('mouseleave', () => { 371 + chartTip.style.display = 'none'; 372 + drawChart(null); 373 + }); 374 + canvas.addEventListener('touchmove', e => { 375 + e.preventDefault(); 376 + const t = e.touches[0]; 377 + chartHover(t.clientX, t.clientY); 378 + }); 379 + canvas.addEventListener('touchend', () => { 380 + chartTip.style.display = 'none'; 381 + drawChart(null); 382 + }); 383 + } 384 + 385 + /* --- traffic sources pie chart --- */ 386 + const PIE_COLORS = ['#4a9', '#58a6ff', '#bc8cff', '#e5a04b', '#e06c75', '#56b6c2', '#c678dd', '#98c379', '#d19a66']; 387 + const traffic = ${trafficJson}; 388 + const pieCanvas = document.getElementById('pie'); 389 + const pieTip = document.getElementById('pie-tip'); 390 + 391 + if (pieCanvas && traffic.length > 0) { 392 + const pieTotal = traffic.reduce((s, r) => s + r.hits, 0); 393 + 394 + const SPECIAL = { unknown: '#555', homepage: '#666' }; 395 + function getColor(i) { 396 + return SPECIAL[traffic[i].domain] || PIE_COLORS[i % PIE_COLORS.length]; 397 + } 398 + 399 + // build segments: { start, end, color, domain, hits } 400 + const segments = []; 401 + let angle = -Math.PI / 2; 402 + for (let i = 0; i < traffic.length; i++) { 403 + const sweep = (traffic[i].hits / pieTotal) * Math.PI * 2; 404 + segments.push({ start: angle, end: angle + sweep, color: getColor(i), domain: traffic[i].domain, hits: traffic[i].hits }); 405 + angle += sweep; 406 + } 407 + 408 + let animProgress = 0; 409 + let hoverIdx = -1; 410 + const animStart = performance.now(); 411 + const animDur = 800; 412 + 413 + function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } 414 + 415 + function drawPie() { 416 + const pctx = pieCanvas.getContext('2d'); 417 + const dpr = window.devicePixelRatio || 1; 418 + const W = pieCanvas.clientWidth, H = pieCanvas.clientHeight; 419 + pieCanvas.width = W * dpr; pieCanvas.height = H * dpr; 420 + pctx.scale(dpr, dpr); 421 + pctx.clearRect(0, 0, W, H); 422 + 423 + const cx = W / 2, cy = H / 2; 424 + const outerR = Math.min(W, H) / 2 - 8; 425 + const innerR = outerR * 0.6; 426 + const maxAngle = -Math.PI / 2 + animProgress * Math.PI * 2; 427 + 428 + for (let i = 0; i < segments.length; i++) { 429 + const seg = segments[i]; 430 + const drawStart = seg.start; 431 + const drawEnd = Math.min(seg.end, maxAngle); 432 + if (drawEnd <= drawStart) continue; 433 + 434 + const grow = hoverIdx === i ? 4 : 0; 435 + const alpha = hoverIdx >= 0 && hoverIdx !== i ? 0.4 : 1; 436 + 437 + pctx.globalAlpha = alpha; 438 + pctx.beginPath(); 439 + pctx.arc(cx, cy, outerR + grow, drawStart, drawEnd); 440 + pctx.arc(cx, cy, innerR - (grow ? 2 : 0), drawEnd, drawStart, true); 441 + pctx.closePath(); 442 + pctx.fillStyle = seg.color; 443 + pctx.fill(); 444 + } 445 + 446 + // center text 447 + pctx.globalAlpha = 1; 448 + pctx.fillStyle = '#888'; 449 + pctx.font = '600 ' + (outerR * 0.22) + 'px system-ui, sans-serif'; 450 + pctx.textAlign = 'center'; 451 + pctx.textBaseline = 'middle'; 452 + pctx.fillText(fmtNum(pieTotal), cx, cy - outerR * 0.06); 453 + pctx.font = (outerR * 0.11) + 'px system-ui, sans-serif'; 454 + pctx.fillStyle = '#555'; 455 + pctx.fillText('hits', cx, cy + outerR * 0.14); 456 + } 457 + 458 + function animLoop(now) { 459 + const t = Math.min((now - animStart) / animDur, 1); 460 + animProgress = easeOutCubic(t); 461 + drawPie(); 462 + if (t < 1) requestAnimationFrame(animLoop); 463 + } 464 + requestAnimationFrame(animLoop); 465 + 466 + function hitTest(clientX, clientY) { 467 + const rect = pieCanvas.getBoundingClientRect(); 468 + const mx = clientX - rect.left - rect.width / 2; 469 + const my = clientY - rect.top - rect.height / 2; 470 + const dist = Math.sqrt(mx * mx + my * my); 471 + const outerR = Math.min(rect.width, rect.height) / 2 - 8; 472 + const innerR = outerR * 0.6; 473 + if (dist < innerR || dist > outerR + 8) return -1; 474 + let a = Math.atan2(my, mx); 475 + if (a < -Math.PI / 2) a += Math.PI * 2; 476 + for (let i = 0; i < segments.length; i++) { 477 + if (a >= segments[i].start && a < segments[i].end) return i; 478 + } 479 + return -1; 480 + } 481 + 482 + function pieHover(clientX, clientY) { 483 + const idx = hitTest(clientX, clientY); 484 + if (idx !== hoverIdx) { 485 + hoverIdx = idx; 486 + drawPie(); 487 + } 488 + if (idx >= 0) { 489 + const seg = segments[idx]; 490 + const pct = ((seg.hits / pieTotal) * 100).toFixed(1); 491 + pieTip.innerHTML = '<strong>' + seg.domain + '</strong><br>' + seg.hits.toLocaleString() + (seg.hits === 1 ? ' hit' : ' hits') + ' (' + pct + '%)'; 492 + pieTip.style.display = 'block'; 493 + const tx = clientX + 14, ty = clientY - 10; 494 + pieTip.style.left = (tx + pieTip.offsetWidth > window.innerWidth ? clientX - pieTip.offsetWidth - 10 : tx) + 'px'; 495 + pieTip.style.top = ty + 'px'; 496 + } else { 497 + pieTip.style.display = 'none'; 498 + } 499 + } 500 + 501 + pieCanvas.addEventListener('mousemove', e => pieHover(e.clientX, e.clientY)); 502 + pieCanvas.addEventListener('mouseleave', () => { hoverIdx = -1; drawPie(); pieTip.style.display = 'none'; }); 503 + pieCanvas.addEventListener('touchmove', e => { e.preventDefault(); const t = e.touches[0]; pieHover(t.clientX, t.clientY); }); 504 + pieCanvas.addEventListener('touchend', () => { hoverIdx = -1; drawPie(); pieTip.style.display = 'none'; }); 505 + window.addEventListener('resize', () => drawPie()); 506 + } 507 + 508 + /* --- search sparkline --- */ 509 + const sparkData = ${sparkJson}; 510 + const spark = document.getElementById('spark'); 511 + const sparkTip = document.getElementById('spark-tip'); 512 + if (spark) { 513 + spark.addEventListener('mousemove', e => { 514 + const rect = spark.getBoundingClientRect(); 515 + const x = e.clientX - rect.left; 516 + const idx = Math.min(Math.round(x / rect.width * (sparkData.length - 1)), sparkData.length - 1); 517 + if (idx >= 0 && sparkData[idx]) { 518 + const sc = sparkData[idx].searches; 519 + sparkTip.textContent = sparkData[idx].time + ' — ' + sc + (sc === 1 ? ' search' : ' searches'); 520 + sparkTip.style.display = 'block'; 521 + sparkTip.style.left = (e.clientX + 12) + 'px'; 522 + sparkTip.style.top = (e.clientY - 28) + 'px'; 523 + } 524 + }); 525 + spark.addEventListener('mouseleave', () => { sparkTip.style.display = 'none'; }); 526 + } 527 + </script> 528 + </body> 529 + </html>`; 530 + }
+46
src/types.ts
··· 1 + export interface Env { 2 + KV: KVNamespace; 3 + ADMIN_SECRET: string; 4 + RATE_LIMITER: RateLimit; 5 + RATE_LIMITER_STRICT: RateLimit; 6 + TURSO_URL: string; 7 + TURSO_AUTH_TOKEN: string; 8 + } 9 + 10 + export interface ActorRow { 11 + did: string; 12 + handle: string; 13 + display_name: string; 14 + avatar_url: string; 15 + labels: string; 16 + } 17 + 18 + export interface IngestEvent { 19 + did: string; 20 + handle?: string; 21 + display_name?: string; 22 + avatar_cid?: string; 23 + } 24 + 25 + export interface SlingshotResponse { 26 + did: string; 27 + handle: string; 28 + pds: string; 29 + } 30 + 31 + export const CORS_HEADERS = { 32 + "Access-Control-Allow-Origin": "*", 33 + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", 34 + "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Client", 35 + }; 36 + 37 + export const SLINGSHOT_URL = 38 + "https://slingshot.microcosm.blue/xrpc/blue.microcosm.identity.resolveMiniDoc"; 39 + 40 + export const BSKY_TYPEAHEAD_URL = 41 + "https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead"; 42 + 43 + export const BSKY_GET_PROFILES_URL = 44 + "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles"; 45 + 46 + export const FAVICON = `<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='13' cy='13' r='9' fill='none' stroke='%2344aa99' stroke-width='2.5' opacity='0.8'/%3E%3Cline x1='20' y1='20' x2='28' y2='28' stroke='%2344aa99' stroke-width='2.5' stroke-linecap='round' opacity='0.8'/%3E%3C/svg%3E">`;
+35
src/utils.ts
··· 1 + import { CORS_HEADERS } from "./types"; 2 + 3 + export function clientIP(request: Request): string { 4 + return request.headers.get("CF-Connecting-IP") || "unknown"; 5 + } 6 + 7 + export function escHtml(s: string): string { 8 + return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); 9 + } 10 + 11 + export function json(data: unknown, status = 200): Response { 12 + return Response.json(data, { status, headers: CORS_HEADERS }); 13 + } 14 + 15 + export function avatarUrl(did: string, cidOrUrl: string): string { 16 + if (cidOrUrl.startsWith("https://")) return cidOrUrl; 17 + return `https://cdn.bsky.app/img/avatar/plain/${did}/${cidOrUrl}@jpeg`; 18 + } 19 + 20 + export function extractAvatarCid(url: string): string { 21 + const match = url.match(/\/([^/]+)@jpeg$/); 22 + return match?.[1] ?? ''; 23 + } 24 + 25 + /** strip anything that could break FTS5 syntax, preserving unicode letters/digits */ 26 + export function sanitize(q: string): string { 27 + return q.replace(/[^\p{L}\p{N}\s.-]/gu, "").trim(); 28 + } 29 + 30 + export function html(body: string, status = 200): Response { 31 + return new Response(body, { 32 + status, 33 + headers: { "Content-Type": "text/html; charset=utf-8", ...CORS_HEADERS }, 34 + }); 35 + }