Files
k 4c3c8c6f87 KOL count: 29 → 25 across marketing/SEO copy
Backend KOL_FEEDS trimmed from 29 to 25 (dead feeds removed).
Sync all hardcoded count mentions:
- layout.tsx JSON-LD, page.tsx (metric + comparison + copy)
- kol/page.tsx, KolPageClient.tsx ("and 26 more" → "and 22 more")
- glossary/page.tsx, opengraph-image.tsx
- public/llms.txt, llms-full.txt
- drop removed KOLs (Dragonfly Capital, Nic Carter) from named lists

Bundles other in-flight frontend work already in the working tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:55:27 +08:00

149 lines
5.0 KiB
TypeScript

'use client'
/**
* Singleton WebSocket provider.
*
* The entire app shares ONE connection to /ws/prices. Components subscribe
* to specific message types via `useWsSubscribe` — no component owns the socket.
*
* Why: previously DashboardClient (via usePriceSocket) and SignalMonitor each
* opened their own WebSocket, giving the server two connections per page load
* and causing each component to receive every broadcast twice.
*/
import {
createContext,
useContext,
useEffect,
useRef,
type ReactNode,
} from 'react'
type Handler = (msg: unknown) => void
interface WsContextValue {
subscribe: (type: string, fn: Handler) => () => void
}
const WsContext = createContext<WsContextValue | null>(null)
// In production, NEXT_PUBLIC_WS_URL must be set (e.g. wss://api.trumpsignal.com).
// Fallback auto-upgrades ws→wss when the page is served over HTTPS to avoid
// mixed-content blocks, and falls back to ws:// for local dev.
const _wsEnv = process.env.NEXT_PUBLIC_WS_URL
const WS_URL = _wsEnv || (
typeof window !== 'undefined' && window.location.protocol === 'https:'
? 'wss://localhost:8000'
: 'ws://localhost:8000'
)
// Exponential backoff: 2s → 4s → 8s → … capped at 60s, plus ±30% jitter
// so a server restart doesn't get hit by all clients at the same instant.
const WS_RETRY_BASE_MS = 2_000
const WS_RETRY_MAX_MS = 60_000
function retryDelay(attempt: number): number {
const exp = Math.min(WS_RETRY_BASE_MS * 2 ** attempt, WS_RETRY_MAX_MS)
return exp * (0.7 + Math.random() * 0.6) // ±30% jitter
}
export function WsProvider({ children }: { children: ReactNode }) {
// Stable map: message-type → set of handlers. Never replaced, only mutated.
const subs = useRef<Map<string, Set<Handler>>>(new Map())
useEffect(() => {
let dead = false
let retryTimer: ReturnType<typeof setTimeout> | null = null
let attempt = 0
let socket: WebSocket | null = null
function connect() {
if (dead) return
socket = new WebSocket(`${WS_URL}/ws/prices`)
const ws = socket // local alias for handler closures
ws.onopen = () => {
attempt = 0 // reset backoff on successful connection
}
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data) as { type?: string }
const t = msg.type ?? '__unknown__'
subs.current.get(t)?.forEach((fn) => fn(msg))
} catch {
// ignore malformed frames
}
}
ws.onclose = () => {
if (!dead) {
const delay = retryDelay(attempt++)
retryTimer = setTimeout(connect, delay)
}
}
ws.onerror = () => ws.close()
}
// Reconnect immediately when the tab comes back to the foreground
// (the socket may have silently died while the tab was hidden).
function onVisible() {
if (document.visibilityState !== 'visible') return
if (socket && socket.readyState === WebSocket.OPEN) return
// Cancel any pending retry — we're reconnecting now.
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
attempt = 0
if (socket && socket.readyState < WebSocket.CLOSING) {
socket.onclose = null
socket.close()
}
connect()
}
document.addEventListener('visibilitychange', onVisible)
connect()
return () => {
dead = true
document.removeEventListener('visibilitychange', onVisible)
if (retryTimer) clearTimeout(retryTimer)
// Actively close any live socket so the OS-level connection releases
// immediately on unmount instead of waiting for the next server keepalive.
if (socket && socket.readyState <= WebSocket.OPEN) {
socket.onclose = null // suppress the auto-reconnect we'd otherwise queue
socket.close()
}
socket = null
}
}, [])
function subscribe(type: string, fn: Handler): () => void {
if (!subs.current.has(type)) subs.current.set(type, new Set())
subs.current.get(type)!.add(fn)
return () => subs.current.get(type)?.delete(fn)
}
return <WsContext.Provider value={{ subscribe }}>{children}</WsContext.Provider>
}
/**
* Subscribe to a specific WebSocket message type from the shared connection.
*
* @param type The `msg.type` string to listen for (e.g. 'price', 'new_post', 'funding_signal')
* @param handler Called with the full parsed message object on every matching frame.
* Always receives the latest handler reference — no stale-closure issues.
*
* Usage:
* useWsSubscribe('price', (msg) => { ... })
*/
export function useWsSubscribe(type: string, handler: Handler): void {
const ctx = useContext(WsContext)
// Keep a ref so the subscription closure never goes stale even if handler
// is an inline function that changes every render.
const handlerRef = useRef<Handler>(handler)
handlerRef.current = handler
useEffect(() => {
if (!ctx) return
return ctx.subscribe(type, (msg) => handlerRef.current(msg))
}, [ctx, type]) // type is stable in practice; ctx never changes
}