UNPKG

plug-n-play-ws

Version:

A plug-and-play WebSocket layer on top of Socket.IO with full TypeScript support, zero manual wiring, and production-ready features

485 lines (481 loc) 14.5 kB
'use strict'; var react = require('react'); var zod = require('zod'); var socket_ioClient = require('socket.io-client'); var eventemitter3 = require('eventemitter3'); // src/react/index.ts var ConsoleLogger = class { debug(message, meta) { console.debug(`[DEBUG] ${message}`, meta || ""); } info(message, meta) { console.info(`[INFO] ${message}`, meta || ""); } warn(message, meta) { console.warn(`[WARN] ${message}`, meta || ""); } error(message, meta) { console.error(`[ERROR] ${message}`, meta || ""); } }; zod.z.object({ id: zod.z.string(), userId: zod.z.string().optional(), tabId: zod.z.string().optional(), userAgent: zod.z.string().optional(), ip: zod.z.string().optional(), connectedAt: zod.z.date(), lastSeenAt: zod.z.date(), metadata: zod.z.record(zod.z.unknown()).optional() }); var SearchQuerySchema = zod.z.object({ query: zod.z.string().min(1), limit: zod.z.number().int().positive().max(1e3).default(10), offset: zod.z.number().int().min(0).default(0), filters: zod.z.record(zod.z.unknown()).optional(), streaming: zod.z.boolean().default(false) }); var SearchResultSchema = zod.z.object({ id: zod.z.string(), score: zod.z.number(), data: zod.z.unknown(), highlights: zod.z.array(zod.z.string()).optional() }); zod.z.object({ query: zod.z.string(), results: zod.z.array(SearchResultSchema), total: zod.z.number().int().min(0), took: zod.z.number().min(0), hasMore: zod.z.boolean().optional() }); var PlugNPlayClient = class { constructor(config) { this.config = config; this.emitter = new eventemitter3.EventEmitter(); this.logger = config.logger || new ConsoleLogger(); if (config.autoConnect !== false) { void this.connect(); } } socket; emitter; logger; status = "disconnected" /* DISCONNECTED */; sessionId; sessionMetadata; reconnectAttempts = 0; heartbeatInterval; lastPongTime = 0; // Event emitter methods on(event, listener) { this.emitter.on(event, listener); return this; } off(event, listener) { this.emitter.off(event, listener); return this; } emit(event, data) { return this.emitter.emit(event, data); } once(event, listener) { this.emitter.once(event, listener); return this; } removeAllListeners(event) { this.emitter.removeAllListeners(event); return this; } /** * Connect to the WebSocket server */ async connect() { if (this.socket?.connected) { return; } this.setStatus("connecting" /* CONNECTING */); this.logger.info("Connecting to WebSocket server", { url: this.config.url }); return new Promise((resolve, reject) => { this.socket = socket_ioClient.io(this.config.url, { autoConnect: false, reconnection: this.config.reconnection !== false, reconnectionAttempts: this.config.reconnectionAttempts || 5, reconnectionDelay: this.config.reconnectionDelay || 1e3, reconnectionDelayMax: this.config.reconnectionDelayMax || 5e3, timeout: this.config.timeout || 2e4, forceNew: this.config.forceNew || false, auth: this.config.auth || {} }); this.setupSocketHandlers(resolve, reject); this.socket.connect(); }); } /** * Disconnect from the WebSocket server */ disconnect() { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); delete this.heartbeatInterval; } if (this.socket) { this.socket.disconnect(); this.socket = void 0; } this.setStatus("disconnected" /* DISCONNECTED */); this.reconnectAttempts = 0; } /** * Completely clear session and disconnect */ clearSession() { this.disconnect(); delete this.sessionId; delete this.sessionMetadata; } /** * Send a typed message to the server */ send(event, data) { if (!this.socket?.connected) { this.logger.warn("Cannot send message: not connected", { event }); return false; } this.socket.emit(event, data); return true; } /** * Perform a search query */ async search(query) { if (!this.socket?.connected) { this.logger.warn("Cannot search: not connected"); return null; } try { const validatedQuery = SearchQuerySchema.parse(query); return new Promise((resolve) => { const timeout = setTimeout(() => { this.socket?.off("search-result", handleResult); this.socket?.off("error", handleError); resolve(null); }, this.config.searchTimeout ?? 3e4); const handleResult = (result) => { clearTimeout(timeout); this.socket?.off("search-result", handleResult); this.socket?.off("error", handleError); resolve(result); }; const handleError = (error) => { clearTimeout(timeout); this.socket?.off("search-result", handleResult); this.socket?.off("error", handleError); this.logger.error("Search failed", { error: error.error.message }); resolve(null); }; this.socket?.once("search-result", handleResult); this.socket?.once("error", handleError); this.socket?.emit("search", validatedQuery); }); } catch (error) { this.logger.error("Invalid search query", { error: error instanceof Error ? error.message : "Unknown validation error" }); return null; } } /** * Get current connection status */ getStatus() { return this.status; } /** * Get current session information */ getSession() { return { ...this.sessionId && { id: this.sessionId }, ...this.sessionMetadata && { metadata: this.sessionMetadata } }; } /** * Check if client is connected */ isConnected() { return this.status === "connected" /* CONNECTED */ && !!this.socket?.connected; } /** * Get connection statistics */ getStats() { return { status: this.status, sessionId: this.sessionId, reconnectAttempts: this.reconnectAttempts, lastPongTime: this.lastPongTime, connected: this.isConnected() }; } setupSocketHandlers(resolve, reject) { if (!this.socket) return; this.socket.on("connect", () => { this.setStatus("connected" /* CONNECTED */); this.reconnectAttempts = 0; this.logger.info("Connected to WebSocket server"); this.startHeartbeat(); resolve(); }); this.socket.on("session", (data) => { this.sessionId = data.sessionId; this.sessionMetadata = data.metadata; this.emit("connect", data); }); this.socket.on("connect_error", (error) => { this.setStatus("error" /* ERROR */); this.logger.error("Connection error", { error: error.message }); if (this.reconnectAttempts === 0) { reject(error); } }); this.socket.on("disconnect", (reason) => { this.setStatus("disconnected" /* DISCONNECTED */); this.logger.info("Disconnected from WebSocket server", { reason }); if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); delete this.heartbeatInterval; } this.emit("disconnect", { sessionId: this.sessionId || "", reason }); }); this.socket.on("reconnect_attempt", (attemptNumber) => { this.setStatus("reconnecting" /* RECONNECTING */); this.reconnectAttempts = attemptNumber; this.logger.info("Reconnection attempt", { attempt: attemptNumber }); }); this.socket.on("reconnect", (attemptNumber) => { this.setStatus("connected" /* CONNECTED */); this.logger.info("Reconnected to WebSocket server", { attempts: attemptNumber }); this.startHeartbeat(); }); this.socket.on("pong", (data) => { this.lastPongTime = data.timestamp; }); this.socket.on("search-stream", (data) => { this.emit("search-stream", data); }); this.socket.onAny((event, data) => { if (!["connect", "connect_error", "disconnect", "reconnect_attempt", "reconnect", "pong", "search-stream"].includes(event)) { this.emit(event, data); } }); } setStatus(status) { if (this.status !== status) { const oldStatus = this.status; this.status = status; this.logger.debug("Status changed", { from: oldStatus, to: status }); } } startHeartbeat() { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); } this.heartbeatInterval = setInterval(() => { if (this.socket?.connected) { this.socket.emit("ping", { timestamp: Date.now() }); } }, 3e4); } }; // src/react/index.ts function usePlugNPlayWs(options) { const clientRef = react.useRef(void 0); const [status, setStatus] = react.useState("disconnected" /* DISCONNECTED */); const [sessionId, setSessionId] = react.useState(); const [sessionMetadata, setSessionMetadata] = react.useState(); const [stats, setStats] = react.useState({ status: "disconnected" /* DISCONNECTED */, reconnectAttempts: 0, lastPongTime: 0, connected: false }); react.useEffect(() => { const client = new PlugNPlayClient({ ...options, autoConnect: false // We control connection manually }); clientRef.current = client; const updateStats = () => { const currentStats = client.getStats(); const normalizedStats = { status: currentStats.status, reconnectAttempts: currentStats.reconnectAttempts, lastPongTime: currentStats.lastPongTime, connected: currentStats.connected, ...currentStats.sessionId && { sessionId: currentStats.sessionId } }; setStats(normalizedStats); }; const handleConnect = (data) => { setSessionId(data.sessionId); setSessionMetadata(data.metadata); setStatus("connected" /* CONNECTED */); updateStats(); options.onConnect?.(data); options.onStatusChange?.("connected" /* CONNECTED */); }; const handleDisconnect = (data) => { setSessionId(void 0); setSessionMetadata(void 0); setStatus("disconnected" /* DISCONNECTED */); updateStats(); options.onDisconnect?.(data); options.onStatusChange?.("disconnected" /* DISCONNECTED */); }; const handleError = (error) => { options.onError?.(error.error); }; const handleReconnectAttempt = () => { setStatus("reconnecting" /* RECONNECTING */); updateStats(); options.onStatusChange?.("reconnecting" /* RECONNECTING */); }; const handlePong = () => { updateStats(); }; client.on("connect", handleConnect); client.on("disconnect", handleDisconnect); client.on("error", handleError); client.on("reconnect_attempt", handleReconnectAttempt); client.on("pong", handlePong); if (options.autoConnect !== false) { setStatus("connecting" /* CONNECTING */); client.connect().catch((error) => { options.onError?.(error instanceof Error ? error : new Error("Connection failed")); }); } return () => { client.off("connect", handleConnect); client.off("disconnect", handleDisconnect); client.off("error", handleError); client.off("reconnect_attempt", handleReconnectAttempt); client.off("pong", handlePong); client.disconnect(); }; }, [options.url]); const connect = react.useCallback(async () => { if (clientRef.current) { try { setStatus("connecting" /* CONNECTING */); await clientRef.current.connect(); } catch (error) { options.onError?.(error instanceof Error ? error : new Error("Connection failed")); throw error; } } }, [options.onError]); const disconnect = react.useCallback(() => { if (clientRef.current) { clientRef.current.disconnect(); setStatus("disconnected" /* DISCONNECTED */); } }, []); const send = react.useCallback((event, data) => { if (clientRef.current) { return clientRef.current.send(event, data); } return false; }, []); const search = react.useCallback(async (query) => { if (clientRef.current) { return clientRef.current.search(query); } return null; }, []); const on = react.useCallback((event, listener) => { if (clientRef.current) { clientRef.current.on(event, listener); } }, []); const off = react.useCallback((event, listener) => { if (clientRef.current) { clientRef.current.off(event, listener); } }, []); const returnValue = { // State status, isConnected: status === "connected" /* CONNECTED */, // Methods connect, disconnect, send, search, on, off, // Stats stats }; if (sessionId) { returnValue.sessionId = sessionId; } if (sessionMetadata) { returnValue.sessionMetadata = sessionMetadata; } return returnValue; } function usePlugNPlaySearch(client) { const [isSearching, setIsSearching] = react.useState(false); const [results, setResults] = react.useState(null); const [streamingResults, setStreamingResults] = react.useState([]); const [error, setError] = react.useState(null); const search = react.useCallback(async (query) => { setIsSearching(true); setError(null); if (query.streaming) { setStreamingResults([]); const handleStream = (data) => { setStreamingResults((prev) => [...prev, data.chunk]); if (data.isLast) { setIsSearching(false); } }; client.on("search-stream", handleStream); try { await client.search(query); } catch (err) { setError(err instanceof Error ? err.message : "Search failed"); setIsSearching(false); } finally { client.off("search-stream", handleStream); } } else { try { const result = await client.search(query); setResults(result); } catch (err) { setError(err instanceof Error ? err.message : "Search failed"); } finally { setIsSearching(false); } } }, [client]); const clearResults = react.useCallback(() => { setResults(null); setStreamingResults([]); setError(null); }, []); return { search, clearResults, isSearching, results, streamingResults, error }; } exports.usePlugNPlaySearch = usePlugNPlaySearch; exports.usePlugNPlayWs = usePlugNPlayWs; //# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map