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.

add actors-indexed trend chart, hourly snapshots, fix rate limit UX

replace the 4-metric grid on /stats with a canvas line chart showing
total actors, with handles, and with avatars over time. chart uses
multi-layered glow rendering inspired by relay-eval, with hover
crosshair + tooltip and touch support for mobile.

new snapshots table records actor counts hourly (first uncached search
per hour triggers a snapshot via KV flag). live counts appended as the
latest point so the chart always extends to now.

also fixes /request-indexing rate limiting: returns HTML with a friendly
message instead of raw JSON, and uses the standard rate limiter (60/min)
with a namespaced key instead of the strict limiter.

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

+285 -61
+7
schema.sql
··· 37 37 searches INTEGER NOT NULL DEFAULT 0, 38 38 total_ms REAL NOT NULL DEFAULT 0 39 39 ); 40 + 41 + CREATE TABLE IF NOT EXISTS snapshots ( 42 + hour INTEGER PRIMARY KEY, 43 + total INTEGER NOT NULL DEFAULT 0, 44 + with_handles INTEGER NOT NULL DEFAULT 0, 45 + with_avatars INTEGER NOT NULL DEFAULT 0 46 + );
+278 -61
src/index.ts
··· 127 127 ) 128 128 .bind(hour, ms) 129 129 .run(); 130 + 131 + // hourly actor count snapshot (first search of each hour wins) 132 + const snapKey = `snap:${hour}`; 133 + if (!(await env.KV.get(snapKey))) { 134 + const row = await env.DB.prepare( 135 + `SELECT COUNT(*) AS total, 136 + SUM(CASE WHEN handle != '' THEN 1 ELSE 0 END) AS with_handles, 137 + SUM(CASE WHEN avatar_url != '' THEN 1 ELSE 0 END) AS with_avatars 138 + FROM actors` 139 + ).first<{ total: number; with_handles: number; with_avatars: number }>(); 140 + if (row) { 141 + await env.DB.prepare( 142 + `INSERT OR IGNORE INTO snapshots (hour, total, with_handles, with_avatars) 143 + VALUES (?1, ?2, ?3, ?4)` 144 + ) 145 + .bind(hour, row.total, row.with_handles, row.with_avatars) 146 + .run(); 147 + } 148 + await env.KV.put(snapKey, "1", { expirationTtl: 7200 }); 149 + } 130 150 } 131 151 132 152 async function handleSearch( ··· 380 400 } 381 401 382 402 async function handleStats(env: Env): Promise<Response> { 383 - const [totalRes, handlesRes, avatarsRes, updatedRes, metricsRes] = 403 + const [totalRes, handlesRes, avatarsRes, metricsRes, snapshotRes] = 384 404 await env.DB.batch([ 385 405 env.DB.prepare("SELECT COUNT(*) AS cnt FROM actors"), 386 406 env.DB.prepare("SELECT COUNT(*) AS cnt FROM actors WHERE handle != ''"), 387 407 env.DB.prepare("SELECT COUNT(*) AS cnt FROM actors WHERE avatar_url != ''"), 388 - env.DB.prepare("SELECT MAX(updated_at) AS ts FROM actors"), 389 408 env.DB.prepare( 390 409 "SELECT hour, searches, total_ms FROM metrics ORDER BY hour DESC LIMIT 168" 391 410 ), 411 + env.DB.prepare( 412 + "SELECT hour, total, with_handles, with_avatars FROM snapshots ORDER BY hour ASC LIMIT 2000" 413 + ), 392 414 ]); 393 415 394 416 const total = (totalRes.results[0] as any)?.cnt ?? 0; 395 417 const withHandles = (handlesRes.results[0] as any)?.cnt ?? 0; 396 418 const withAvatars = (avatarsRes.results[0] as any)?.cnt ?? 0; 397 - const lastUpdated = (updatedRes.results[0] as any)?.ts ?? null; 398 419 const rows = (metricsRes.results ?? []) as { 399 420 hour: number; 400 421 searches: number; 401 422 total_ms: number; 402 423 }[]; 424 + const snapshots = (snapshotRes.results ?? []) as SnapshotPoint[]; 425 + 426 + // append live counts as the latest point 427 + const liveHour = Math.floor(Date.now() / 3_600_000); 428 + if (snapshots.length === 0 || snapshots[snapshots.length - 1].hour < liveHour) { 429 + snapshots.push({ hour: liveHour, total, with_handles: withHandles, with_avatars: withAvatars }); 430 + } 403 431 404 432 const totalSearches = rows.reduce((s, r) => s + r.searches, 0); 405 433 const totalMs = rows.reduce((s, r) => s + r.total_ms, 0); ··· 407 435 const handlePct = total > 0 ? ((withHandles / total) * 100).toFixed(1) : "0"; 408 436 const avatarPct = total > 0 ? ((withAvatars / total) * 100).toFixed(1) : "0"; 409 437 410 - const lastUpdatedStr = lastUpdated 411 - ? new Date(lastUpdated * 1000).toISOString().replace("T", " ").slice(0, 19) + " UTC" 412 - : "never"; 438 + return html(statsPage({ total, rows, totalSearches, avgLatency, handlePct, avatarPct, snapshots })); 439 + } 413 440 414 - return html(statsPage({ total, withHandles, withAvatars, lastUpdatedStr, rows, totalSearches, avgLatency, handlePct, avatarPct })); 441 + interface SnapshotPoint { 442 + hour: number; 443 + total: number; 444 + with_handles: number; 445 + with_avatars: number; 415 446 } 416 447 417 448 interface StatsData { 418 449 total: number; 419 - withHandles: number; 420 - withAvatars: number; 421 - lastUpdatedStr: string; 422 450 rows: { hour: number; searches: number; total_ms: number }[]; 423 451 totalSearches: number; 424 452 avgLatency: number; 425 453 handlePct: string; 426 454 avatarPct: string; 455 + snapshots: SnapshotPoint[]; 427 456 } 428 457 429 458 function statsPage(d: StatsData): string { 430 - // build sparkline data (oldest first) 459 + // search sparkline data (oldest first) 431 460 const sorted = [...d.rows].reverse(); 432 461 const counts = sorted.map((r) => r.searches); 433 - const max = Math.max(...counts, 1); 434 - const w = 600; 435 - const h = 80; 436 - const step = counts.length > 1 ? w / (counts.length - 1) : 0; 437 - const points = counts 438 - .map((c, i) => `${(i * step).toFixed(1)},${(h - (c / max) * h).toFixed(1)}`) 462 + const sparkMax = Math.max(...counts, 1); 463 + const sw = 600, sh = 80; 464 + const step = counts.length > 1 ? sw / (counts.length - 1) : 0; 465 + const sparkPoints = counts 466 + .map((c, i) => `${(i * step).toFixed(1)},${(sh - (c / sparkMax) * sh).toFixed(1)}`) 439 467 .join(" "); 440 - const jsonData = JSON.stringify( 468 + const sparkJson = JSON.stringify( 441 469 sorted.map((r) => ({ 442 470 hour: new Date(r.hour * 3_600_000).toISOString().slice(0, 13) + ":00Z", 443 471 searches: r.searches, 444 472 })) 445 473 ); 474 + 475 + const snapshotJson = JSON.stringify(d.snapshots); 446 476 447 477 return `<!doctype html> 448 478 <html> ··· 453 483 <style> 454 484 * { margin: 0; padding: 0; box-sizing: border-box; } 455 485 body { font-family: system-ui, sans-serif; background: #0a0a0a; color: #e0e0e0; 456 - display: flex; justify-content: center; align-items: center; min-height: 100vh; } 457 - .container { max-width: 620px; width: 100%; padding: 2rem; } 486 + display: flex; justify-content: center; padding: 2rem 1rem; min-height: 100vh; } 487 + .container { max-width: 620px; width: 100%; } 458 488 .header { display: flex; align-items: baseline; gap: 0.4rem; margin-bottom: 0.4rem; } 459 489 h1 { font-size: 1.1rem; font-weight: 400; color: #888; } 460 490 h1 strong { color: #e0e0e0; } 461 491 .subtitle { font-size: 0.8rem; color: #555; margin-bottom: 1.5rem; } 462 - .metrics { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.8rem; margin-bottom: 1.5rem; } 492 + 493 + .chart-wrap { background: #111; border: 1px solid #222; border-radius: 6px; 494 + padding: 0.9rem; margin-bottom: 1.5rem; position: relative; } 495 + .chart-wrap h2 { font-size: 0.75rem; font-weight: 400; color: #555; margin-bottom: 0.6rem; } 496 + #trend { width: 100%; height: 200px; display: block; touch-action: none; } 497 + .legend { display: flex; gap: 1.2rem; margin-top: 0.6rem; font-size: 0.7rem; color: #888; } 498 + .legend span { display: flex; align-items: center; gap: 0.35rem; } 499 + .ldot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } 500 + .chart-tip { display: none; position: fixed; background: rgba(17, 17, 17, 0.95); 501 + border: 1px solid #333; border-radius: 6px; padding: 0.5rem 0.7rem; 502 + font-size: 0.72rem; color: #ccc; pointer-events: none; z-index: 50; 503 + line-height: 1.6; white-space: nowrap; 504 + box-shadow: 0 4px 12px rgba(0,0,0,0.5); } 505 + .ct-time { color: #666; font-size: 0.65rem; } 506 + .ct-row { display: flex; align-items: center; gap: 0.35rem; } 507 + .ct-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; flex-shrink: 0; } 508 + 509 + .sparkline-wrap { background: #111; border: 1px solid #222; border-radius: 6px; 510 + padding: 0.9rem; margin-bottom: 1.5rem; } 511 + .sparkline-wrap h2 { font-size: 0.75rem; font-weight: 400; color: #555; margin-bottom: 0.6rem; } 512 + svg { width: 100%; height: auto; } 513 + polyline { fill: none; stroke: #4a9; stroke-width: 1.5; } 514 + 515 + .summary { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.8rem; margin-bottom: 1.5rem; } 463 516 .metric { background: #111; border: 1px solid #222; border-radius: 6px; padding: 0.7rem 0.9rem; } 464 517 .metric .label { font-size: 0.7rem; color: #555; margin-bottom: 0.2rem; } 465 518 .metric .label[data-tip] { cursor: default; position: relative; border-bottom: 1px dotted #444; display: inline-block; } ··· 469 522 font-size: 0.95em; color: #999; line-height: 1.4; z-index: 20; white-space: normal; 470 523 } 471 524 .metric .value { font-size: 1.1rem; color: #ccc; } 472 - .sparkline-wrap { background: #111; border: 1px solid #222; border-radius: 6px; 473 - padding: 0.9rem; margin-bottom: 1.5rem; } 474 - .sparkline-wrap h2 { font-size: 0.75rem; font-weight: 400; color: #555; margin-bottom: 0.6rem; } 475 - svg { width: 100%; height: auto; } 476 - polyline { fill: none; stroke: #4a9; stroke-width: 1.5; } 477 - .summary { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.8rem; margin-bottom: 1.5rem; } 478 - .tooltip { display: none; position: fixed; background: #1a1a1a; border: 1px solid #333; 479 - border-radius: 4px; padding: 0.4rem 0.6rem; font-size: 0.75rem; color: #ccc; 480 - pointer-events: none; z-index: 50; } 525 + .spark-tip { display: none; position: fixed; background: #1a1a1a; border: 1px solid #333; 526 + border-radius: 4px; padding: 0.4rem 0.6rem; font-size: 0.75rem; color: #ccc; 527 + pointer-events: none; z-index: 50; } 528 + 481 529 footer { padding-top: 1.5rem; border-top: 1px solid #1a1a1a; font-size: 0.7rem; 482 530 color: #444; display: flex; justify-content: center; gap: 0.4rem; } 483 531 footer a { color: #555; text-decoration: none; } 484 532 footer a:hover { color: #888; } 533 + 534 + @media (max-width: 640px) { 535 + body { padding: 1.5rem 0.75rem; } 536 + #trend { height: 160px; } 537 + .legend { gap: 0.8rem; font-size: 0.65rem; flex-wrap: wrap; } 538 + .summary { grid-template-columns: 1fr 1fr; } 539 + } 485 540 </style> 486 541 </head> 487 542 <body> ··· 491 546 </div> 492 547 <p class="subtitle">index health and search activity</p> 493 548 494 - <div class="metrics"> 495 - <div class="metric"> 496 - <div class="label">actors indexed</div> 497 - <div class="value">${d.total.toLocaleString()}</div> 498 - </div> 499 - <div class="metric"> 500 - <div class="label">with handles</div> 501 - <div class="value">${d.withHandles.toLocaleString()}</div> 502 - </div> 503 - <div class="metric"> 504 - <div class="label">with avatars</div> 505 - <div class="value">${d.withAvatars.toLocaleString()}</div> 506 - </div> 507 - <div class="metric"> 508 - <div class="label">last indexed</div> 509 - <div class="value" style="font-size:0.8rem">${escHtml(d.lastUpdatedStr)}</div> 549 + <div class="chart-wrap"> 550 + <h2>actors indexed</h2> 551 + ${d.snapshots.length > 1 552 + ? `<canvas id="trend"></canvas>` 553 + : `<div style="color:#444;font-size:0.8rem;padding:2rem 0;text-align:center">collecting data — check back soon</div>`} 554 + <div class="legend"> 555 + <span><span class="ldot" style="background:#4a9"></span> total (${d.total.toLocaleString()})</span> 556 + <span><span class="ldot" style="background:#58a6ff"></span> with handles</span> 557 + <span><span class="ldot" style="background:#bc8cff"></span> with avatars</span> 510 558 </div> 511 559 </div> 560 + <div class="chart-tip" id="chart-tip"></div> 512 561 513 562 <div class="sparkline-wrap"> 514 563 <h2>searches / hour (7 days)</h2> 515 564 ${counts.length > 1 516 - ? `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" id="spark"> 517 - <polyline points="${points}" /> 565 + ? `<svg viewBox="0 0 ${sw} ${sh}" preserveAspectRatio="none" id="spark"> 566 + <polyline points="${sparkPoints}" /> 518 567 </svg>` 519 568 : `<div style="color:#444;font-size:0.8rem;padding:1rem 0;text-align:center">no data yet</div>`} 520 569 </div> ··· 542 591 <a href="/">&larr; home</a> 543 592 </footer> 544 593 </div> 545 - <div class="tooltip" id="tip"></div> 594 + <div class="spark-tip" id="spark-tip"></div> 546 595 <script> 547 - const data = ${jsonData}; 596 + const COLORS = { total: '#4a9', handles: '#58a6ff', avatars: '#bc8cff' }; 597 + 598 + function fmtNum(n) { 599 + if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; 600 + if (n >= 1e3) return (n / 1e3).toFixed(0) + 'k'; 601 + return n.toLocaleString(); 602 + } 603 + 604 + /* --- actors indexed trend chart --- */ 605 + const snaps = ${snapshotJson}; 606 + const canvas = document.getElementById('trend'); 607 + const chartTip = document.getElementById('chart-tip'); 608 + 609 + function drawChart(hoverIdx) { 610 + if (!canvas || snaps.length < 2) return; 611 + const ctx = canvas.getContext('2d'); 612 + const dpr = window.devicePixelRatio || 1; 613 + const W = canvas.clientWidth, H = canvas.clientHeight; 614 + canvas.width = W * dpr; canvas.height = H * dpr; 615 + ctx.scale(dpr, dpr); 616 + ctx.clearRect(0, 0, W, H); 617 + 618 + const pad = { t: 16, r: 12, b: 24, l: 12 }; 619 + const cw = W - pad.l - pad.r, ch = H - pad.t - pad.b; 620 + const n = snaps.length; 621 + 622 + let yMax = 0; 623 + for (const s of snaps) { if (s.total > yMax) yMax = s.total; } 624 + yMax = yMax * 1.08 || 1; 625 + 626 + const toX = i => pad.l + (i / (n - 1)) * cw; 627 + const toY = v => pad.t + ch - (v / yMax) * ch; 628 + 629 + const series = [ 630 + { key: 'with_avatars', color: COLORS.avatars }, 631 + { key: 'with_handles', color: COLORS.handles }, 632 + { key: 'total', color: COLORS.total }, 633 + ]; 634 + 635 + for (const s of series) { 636 + const pts = snaps.map((d, i) => [toX(i), toY(d[s.key])]); 637 + const trace = () => { 638 + ctx.beginPath(); 639 + ctx.moveTo(pts[0][0], pts[0][1]); 640 + for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); 641 + }; 642 + 643 + // glow 644 + ctx.save(); trace(); 645 + ctx.shadowColor = s.color; ctx.shadowBlur = 18; 646 + ctx.strokeStyle = s.color; ctx.lineWidth = 2; ctx.globalAlpha = 0.07; 647 + ctx.stroke(); ctx.restore(); 648 + 649 + // medium glow 650 + ctx.save(); trace(); 651 + ctx.shadowColor = s.color; ctx.shadowBlur = 6; 652 + ctx.strokeStyle = s.color; ctx.lineWidth = 1; ctx.globalAlpha = 0.15; 653 + ctx.stroke(); ctx.restore(); 654 + 655 + // core line 656 + trace(); 657 + ctx.strokeStyle = s.color; 658 + ctx.lineWidth = 1.2; ctx.globalAlpha = 0.55; 659 + ctx.stroke(); ctx.globalAlpha = 1; 660 + } 661 + 662 + // x-axis 663 + { 664 + const axisY = H - pad.b; 665 + ctx.beginPath(); ctx.moveTo(pad.l, axisY); ctx.lineTo(W - pad.r, axisY); 666 + ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; 667 + ctx.stroke(); 668 + 669 + ctx.font = '10px system-ui, sans-serif'; 670 + ctx.fillStyle = '#555'; ctx.textAlign = 'center'; 671 + const t0ms = snaps[0].hour * 3600000; 672 + const tNms = snaps[n - 1].hour * 3600000; 673 + const spanDays = (tNms - t0ms) / 86400000; 674 + 675 + // walk midnights 676 + const d = new Date(t0ms); 677 + d.setUTCMinutes(0, 0, 0); 678 + d.setUTCHours(0); 679 + d.setUTCDate(d.getUTCDate() + 1); 680 + const minGap = W < 500 ? 65 : 50; 681 + let prevX = -Infinity; 682 + 683 + while (d.getTime() <= tNms) { 684 + const frac = (d.getTime() - t0ms) / (tNms - t0ms); 685 + const x = pad.l + frac * cw; 686 + if (x >= pad.l && x <= W - pad.r && x - prevX >= minGap) { 687 + ctx.beginPath(); ctx.moveTo(x, axisY); ctx.lineTo(x, axisY + 4); 688 + ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 1; ctx.stroke(); 689 + const label = spanDays > 14 690 + ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) 691 + : d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); 692 + ctx.globalAlpha = 0.4; 693 + ctx.fillText(label, x, axisY + 15); 694 + ctx.globalAlpha = 1; 695 + prevX = x; 696 + } 697 + d.setUTCDate(d.getUTCDate() + 1); 698 + } 699 + } 700 + 701 + // hover crosshair + dots 702 + if (hoverIdx != null && hoverIdx >= 0 && hoverIdx < n) { 703 + const x = toX(hoverIdx); 704 + ctx.beginPath(); ctx.moveTo(x, pad.t); ctx.lineTo(x, H - pad.b); 705 + ctx.strokeStyle = 'rgba(255,255,255,0.1)'; 706 + ctx.lineWidth = 1; ctx.setLineDash([3, 3]); ctx.stroke(); ctx.setLineDash([]); 707 + 708 + const snap = snaps[hoverIdx]; 709 + for (const s of series) { 710 + const y = toY(snap[s.key]); 711 + ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); 712 + ctx.fillStyle = '#fff'; ctx.globalAlpha = 0.9; ctx.fill(); 713 + ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); 714 + ctx.strokeStyle = s.color; ctx.lineWidth = 1.5; ctx.globalAlpha = 0.8; 715 + ctx.stroke(); ctx.globalAlpha = 1; 716 + } 717 + } 718 + } 719 + 720 + function chartHover(clientX, clientY) { 721 + if (!canvas || snaps.length < 2) return; 722 + const rect = canvas.getBoundingClientRect(); 723 + const mx = clientX - rect.left; 724 + const pad = { l: 12, r: 12 }; 725 + const cw = rect.width - pad.l - pad.r; 726 + const frac = (mx - pad.l) / cw; 727 + const idx = Math.max(0, Math.min(snaps.length - 1, Math.round(frac * (snaps.length - 1)))); 728 + drawChart(idx); 729 + 730 + const snap = snaps[idx]; 731 + const t = new Date(snap.hour * 3600000); 732 + const time = t.toISOString().replace('T', ' ').slice(0, 16) + ' UTC'; 733 + chartTip.innerHTML = 734 + '<div class="ct-time">' + time + '</div>' 735 + + '<div class="ct-row"><span class="ct-dot" style="background:' + COLORS.total + '"></span> ' + fmtNum(snap.total) + ' actors</div>' 736 + + '<div class="ct-row"><span class="ct-dot" style="background:' + COLORS.handles + '"></span> ' + fmtNum(snap.with_handles) + ' with handles</div>' 737 + + '<div class="ct-row"><span class="ct-dot" style="background:' + COLORS.avatars + '"></span> ' + fmtNum(snap.with_avatars) + ' with avatars</div>'; 738 + chartTip.style.display = 'block'; 739 + const tx = clientX + 14, ty = clientY - 10; 740 + chartTip.style.left = (tx + chartTip.offsetWidth > window.innerWidth ? clientX - chartTip.offsetWidth - 10 : tx) + 'px'; 741 + chartTip.style.top = ty + 'px'; 742 + } 743 + 744 + if (canvas) { 745 + drawChart(null); 746 + window.addEventListener('resize', () => drawChart(null)); 747 + 748 + canvas.addEventListener('mousemove', e => chartHover(e.clientX, e.clientY)); 749 + canvas.addEventListener('mouseleave', () => { 750 + chartTip.style.display = 'none'; 751 + drawChart(null); 752 + }); 753 + canvas.addEventListener('touchmove', e => { 754 + e.preventDefault(); 755 + const t = e.touches[0]; 756 + chartHover(t.clientX, t.clientY); 757 + }); 758 + canvas.addEventListener('touchend', () => { 759 + chartTip.style.display = 'none'; 760 + drawChart(null); 761 + }); 762 + } 763 + 764 + /* --- search sparkline --- */ 765 + const sparkData = ${sparkJson}; 548 766 const spark = document.getElementById('spark'); 549 - const tip = document.getElementById('tip'); 767 + const sparkTip = document.getElementById('spark-tip'); 550 768 if (spark) { 551 769 spark.addEventListener('mousemove', e => { 552 770 const rect = spark.getBoundingClientRect(); 553 771 const x = e.clientX - rect.left; 554 - const idx = Math.min(Math.round(x / rect.width * (data.length - 1)), data.length - 1); 555 - if (idx >= 0 && data[idx]) { 556 - tip.textContent = data[idx].hour + ' — ' + data[idx].searches + ' searches'; 557 - tip.style.display = 'block'; 558 - tip.style.left = (e.clientX + 12) + 'px'; 559 - tip.style.top = (e.clientY - 28) + 'px'; 772 + const idx = Math.min(Math.round(x / rect.width * (sparkData.length - 1)), sparkData.length - 1); 773 + if (idx >= 0 && sparkData[idx]) { 774 + sparkTip.textContent = sparkData[idx].hour + ' — ' + sparkData[idx].searches + ' searches'; 775 + sparkTip.style.display = 'block'; 776 + sparkTip.style.left = (e.clientX + 12) + 'px'; 777 + sparkTip.style.top = (e.clientY - 28) + 'px'; 560 778 } 561 779 }); 562 - spark.addEventListener('mouseleave', () => { tip.style.display = 'none'; }); 780 + spark.addEventListener('mouseleave', () => { sparkTip.style.display = 'none'; }); 563 781 } 564 782 </script> 565 783 </body> ··· 728 946 729 947 if (pathname === "/request-indexing" && request.method === "GET") { 730 948 const ip = clientIP(request); 731 - // stricter limit — this endpoint makes outbound fetches 732 - const { success } = await env.RATE_LIMITER_STRICT.limit({ key: ip }); 949 + const { success } = await env.RATE_LIMITER.limit({ key: `index:${ip}` }); 733 950 if (!success) { 734 951 console.log(JSON.stringify({ event: "rate_limited", endpoint: "/request-indexing", ip })); 735 - return json({ error: "rate limited" }, 429); 952 + return html(indexPage("slow down — try again in a minute."), 429); 736 953 } 737 954 return handleRequestIndexing(request, env); 738 955 }