4c3c8c6f87
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>
149 lines
5.0 KiB
TypeScript
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
|
|
}
|