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

Configure Feed

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

add /docs page: switching from bluesky typeahead

explains how to swap the base URL, documents the response field
differences, and includes the plyr.fm migration as a worked example.
linked from homepage footer and stats page pie chart.

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

+179
+179
src/index.ts
··· 815 815 ? `<canvas id="pie"></canvas> 816 816 <div class="pie-legend">${pieLegendHtml}</div>` 817 817 : `<div style="color:#444;font-size:0.8rem;padding:2rem 0;text-align:center">collecting data — check back soon</div>`} 818 + <div style="margin-top:0.6rem;font-size:0.7rem;color:#555;text-align:center"> 819 + want to show up here? <a href="/docs" style="color:#58a6ff;text-decoration:none">switch from bluesky&rsquo;s typeahead &rarr;</a> 820 + </div> 818 821 </div> 819 822 <div class="pie-tip" id="pie-tip"></div> 820 823 ··· 1267 1270 <footer> 1268 1271 by <a href="https://bsky.app/profile/zzstoatzz.io" target="_blank" rel="noopener">@zzstoatzz.io</a> 1269 1272 · <a href="/stats">stats</a> 1273 + · <a href="/docs">docs</a> 1270 1274 </footer> 1271 1275 </div> 1272 1276 <script> ··· 1347 1351 </html>`; 1348 1352 } 1349 1353 1354 + function docsPage(): string { 1355 + return `<!doctype html> 1356 + <html> 1357 + <head> 1358 + <meta charset="utf-8"> 1359 + <meta name="viewport" content="width=device-width, initial-scale=1"> 1360 + <title>typeahead — switching from bluesky</title> 1361 + ${FAVICON} 1362 + <style> 1363 + * { margin: 0; padding: 0; box-sizing: border-box; } 1364 + body { font-family: system-ui, sans-serif; background: #0a0a0a; color: #e0e0e0; 1365 + display: flex; justify-content: center; padding: 2rem 1rem; min-height: 100vh; } 1366 + .container { max-width: 620px; width: 100%; } 1367 + .header { display: flex; align-items: baseline; gap: 0.4rem; margin-bottom: 0.4rem; } 1368 + h1 { font-size: 1.1rem; font-weight: 400; color: #888; } 1369 + h1 strong { color: #e0e0e0; } 1370 + .subtitle { font-size: 0.8rem; color: #555; margin-bottom: 1.5rem; } 1371 + h2 { font-size: 0.85rem; font-weight: 600; color: #ccc; margin: 1.5rem 0 0.6rem; } 1372 + h3 { font-size: 0.8rem; font-weight: 600; color: #aaa; margin: 1.2rem 0 0.5rem; } 1373 + p, li { font-size: 0.8rem; color: #999; line-height: 1.6; } 1374 + p { margin-bottom: 0.7rem; } 1375 + ul { margin: 0 0 0.7rem 1.2rem; } 1376 + li { margin-bottom: 0.3rem; } 1377 + code { font-family: ui-monospace, monospace; font-size: 0.75rem; background: #1a1a1a; 1378 + border: 1px solid #222; border-radius: 3px; padding: 0.1rem 0.35rem; color: #ccc; } 1379 + pre { background: #111; border: 1px solid #222; border-radius: 6px; padding: 0.8rem; 1380 + overflow-x: auto; margin-bottom: 0.7rem; } 1381 + pre code { background: none; border: none; padding: 0; font-size: 0.72rem; color: #bbb; } 1382 + .diff-add { color: #4a9; } 1383 + .diff-del { color: #a55; } 1384 + .callout { background: #111; border: 1px solid #222; border-left: 3px solid #4a9; 1385 + border-radius: 6px; padding: 0.7rem 0.9rem; margin-bottom: 1rem; 1386 + font-size: 0.78rem; color: #999; line-height: 1.6; } 1387 + .callout strong { color: #ccc; } 1388 + .callout.warn { border-left-color: #b98a3e; } 1389 + table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; font-size: 0.75rem; } 1390 + th { text-align: left; color: #666; font-weight: 400; padding: 0.4rem 0.6rem; 1391 + border-bottom: 1px solid #222; } 1392 + td { padding: 0.4rem 0.6rem; border-bottom: 1px solid #1a1a1a; color: #999; } 1393 + td code { font-size: 0.7rem; } 1394 + .yes { color: #4a9; } 1395 + .no { color: #666; } 1396 + footer { padding-top: 1.5rem; border-top: 1px solid #1a1a1a; font-size: 0.7rem; 1397 + color: #444; display: flex; justify-content: center; gap: 0.4rem; } 1398 + footer a { color: #555; text-decoration: none; } 1399 + footer a:hover { color: #888; } 1400 + a { color: #58a6ff; text-decoration: none; } 1401 + a:hover { text-decoration: underline; } 1402 + @media (max-width: 640px) { 1403 + body { padding: 1.5rem 0.75rem; } 1404 + } 1405 + </style> 1406 + </head> 1407 + <body> 1408 + <div class="container"> 1409 + <div class="header"> 1410 + <h1><strong>typeahead</strong> docs</h1> 1411 + </div> 1412 + <p class="subtitle">switching from the bluesky typeahead API</p> 1413 + 1414 + <div class="callout"> 1415 + <strong>tl;dr</strong> — change the base URL. the endpoint path and query params are the same. 1416 + response shape is compatible but slimmer. 1417 + </div> 1418 + 1419 + <h2>what this is</h2> 1420 + <p> 1421 + typeahead is a community-run actor search for <a href="https://atproto.com">atproto</a>. 1422 + it's designed as a drop-in replacement for bluesky's 1423 + <code>app.bsky.actor.searchActorsTypeahead</code> endpoint. 1424 + </p> 1425 + <p> 1426 + the index is populated via <a href="https://docs.bsky.app/blog/jetstream">jetstream</a>, 1427 + so it tracks the full network. searches use FTS5 prefix matching against handles and 1428 + display names, with results edge-cached for 60s. 1429 + </p> 1430 + 1431 + <h2>the change</h2> 1432 + <p>replace the base URL. everything else stays the same:</p> 1433 + <pre><code><span class="diff-del">- https://public.api.bsky.app</span>/xrpc/app.bsky.actor.searchActorsTypeahead?q=...&amp;limit=10 1434 + <span class="diff-add">+ https://typeahead.waow.tech</span>/xrpc/app.bsky.actor.searchActorsTypeahead?q=...&amp;limit=10</code></pre> 1435 + 1436 + <h3>before</h3> 1437 + <pre><code>const response = await fetch( 1438 + \`https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=\${encodeURIComponent(query)}&amp;limit=10\` 1439 + );</code></pre> 1440 + 1441 + <h3>after</h3> 1442 + <pre><code>const TYPEAHEAD_URL = 'https://typeahead.waow.tech'; 1443 + 1444 + const response = await fetch( 1445 + \`\${TYPEAHEAD_URL}/xrpc/app.bsky.actor.searchActorsTypeahead?q=\${encodeURIComponent(query)}&amp;limit=10\` 1446 + );</code></pre> 1447 + 1448 + <p> 1449 + extracting the base URL into a constant (or env var) makes it easy to switch back 1450 + if you ever need to. 1451 + </p> 1452 + 1453 + <h2>response comparison</h2> 1454 + <p> 1455 + both return <code>{ "actors": [...] }</code>. the actor objects differ: 1456 + </p> 1457 + <table> 1458 + <tr><th>field</th><th>bluesky</th><th>typeahead</th></tr> 1459 + <tr><td><code>did</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> 1460 + <tr><td><code>handle</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> 1461 + <tr><td><code>displayName</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> 1462 + <tr><td><code>avatar</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> 1463 + <tr><td><code>associated</code></td><td class="yes">✓</td><td class="no">—</td></tr> 1464 + <tr><td><code>labels</code></td><td class="yes">✓</td><td class="no">—</td></tr> 1465 + <tr><td><code>createdAt</code></td><td class="yes">✓</td><td class="no">—</td></tr> 1466 + <tr><td><code>viewer</code></td><td class="yes">✓</td><td class="no">—</td></tr> 1467 + </table> 1468 + 1469 + <div class="callout warn"> 1470 + <strong>if you depend on <code>labels</code>, <code>associated</code>, <code>viewer</code>, 1471 + or <code>createdAt</code></strong> — this API doesn't return them. most typeahead UIs only 1472 + need did + handle + displayName + avatar, which is exactly what we return. if you need the 1473 + full <code>profileViewBasic</code> surface, you'll need to stick with the bluesky API or 1474 + fetch those fields separately. 1475 + </div> 1476 + 1477 + <h2>other differences</h2> 1478 + <ul> 1479 + <li><strong>no auth required</strong> — public CORS endpoint, no token needed</li> 1480 + <li><strong>rate limited</strong> — 60 req/min per IP (vs bluesky's per-token limits)</li> 1481 + <li><strong>cached</strong> — results are edge-cached for 60s, so very recent profile changes may lag</li> 1482 + <li><strong>limit range</strong> — <code>1–100</code> (bluesky caps at 10 by default)</li> 1483 + <li><strong>moderation</strong> — actors flagged with <code>!hide</code> or <code>!takedown</code> labels are excluded from results</li> 1484 + </ul> 1485 + 1486 + <h2>example: plyr.fm</h2> 1487 + <p> 1488 + <a href="https://tangled.sh/zzstoatzz.io/plyr.fm">plyr.fm</a> switched from a backend proxy 1489 + (which called the bluesky API) to calling typeahead directly from the frontend. the diff was 1490 + roughly: 1491 + </p> 1492 + <pre><code><span class="diff-add">// config.ts</span> 1493 + <span class="diff-add">export const TYPEAHEAD_URL = 'https://typeahead.waow.tech';</span> 1494 + 1495 + <span class="diff-add">// HandleSearch.svelte</span> 1496 + const response = await fetch( 1497 + \`\${TYPEAHEAD_URL}/xrpc/app.bsky.actor.searchActorsTypeahead?q=\${encodeURIComponent(query)}&amp;limit=10\` 1498 + ); 1499 + const data = await response.json(); 1500 + const actors = (data.actors ?? []).map(actor => ({ 1501 + did: actor.did, 1502 + handle: actor.handle, 1503 + display_name: actor.displayName ?? actor.handle, 1504 + avatar_url: actor.avatar ?? null, 1505 + }));</code></pre> 1506 + <p> 1507 + no backend proxy needed. the API supports CORS, so frontend calls work directly. 1508 + </p> 1509 + 1510 + <h2>request indexing</h2> 1511 + <p> 1512 + if someone isn't showing up in results, you (or your users) can request indexing from the 1513 + <a href="/">homepage</a>. newly created accounts are picked up automatically via jetstream, 1514 + but accounts created before the index existed may need a manual nudge. 1515 + </p> 1516 + 1517 + <footer> 1518 + <a href="/">&larr; home</a> 1519 + · <a href="/stats">stats</a> 1520 + </footer> 1521 + </div> 1522 + </body> 1523 + </html>`; 1524 + } 1525 + 1350 1526 function html(body: string, status = 200): Response { 1351 1527 return new Response(body, { 1352 1528 status, ··· 1371 1547 1372 1548 if (pathname === "/" && request.method === "GET") { 1373 1549 return html(indexPage()); 1550 + } 1551 + if (pathname === "/docs" && request.method === "GET") { 1552 + return html(docsPage()); 1374 1553 } 1375 1554 if (pathname === "/admin/cursor" && request.method === "GET") { 1376 1555 return handleCursor(request, env);