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

1,481 lines (1,470 loc) 46.8 kB
'use strict'; var zod = require('zod'); var http = require('http'); var socket_io = require('socket.io'); var eventemitter3 = require('eventemitter3'); var socket_ioClient = require('socket.io-client'); var Redis = require('ioredis'); function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; } var Redis__default = /*#__PURE__*/_interopDefault(Redis); // src/types.ts var ConnectionStatus = /* @__PURE__ */ ((ConnectionStatus2) => { ConnectionStatus2["CONNECTING"] = "connecting"; ConnectionStatus2["CONNECTED"] = "connected"; ConnectionStatus2["DISCONNECTED"] = "disconnected"; ConnectionStatus2["RECONNECTING"] = "reconnecting"; ConnectionStatus2["ERROR"] = "error"; return ConnectionStatus2; })(ConnectionStatus || {}); 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 || ""); } }; var SessionMetadataSchema = 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() }); var SearchResponseSchema = 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() }); // src/utils/text-processing.ts function buildNGrams(text, n) { const normalized = text.toLowerCase().replace(/[^\w\s]/g, " ").replace(/\s+/g, " ").trim(); const words = normalized.split(" "); const ngrams = []; for (const word of words) { if (word.length >= n) { for (let i = 0; i <= word.length - n; i++) { ngrams.push(word.substring(i, i + n)); } } } return [...new Set(ngrams)]; } function buildEdgeGrams(text, minGram, maxGram) { if (minGram > maxGram) { return []; } const normalized = text.toLowerCase().replace(/[^\w\s]/g, " ").replace(/\s+/g, " ").trim(); const words = normalized.split(" "); const edgegrams = []; for (const word of words) { for (let len = minGram; len <= Math.min(maxGram, word.length); len++) { edgegrams.push(word.substring(0, len)); } } return [...new Set(edgegrams)]; } function generateHighlights(content, searchTerms, maxHighlights = 3, contextLength = 30) { const highlights = []; const contentLower = content.toLowerCase(); for (const term of searchTerms) { const termLower = term.toLowerCase(); let index = contentLower.indexOf(termLower); while (index !== -1 && highlights.length < maxHighlights) { const start = Math.max(0, index - contextLength); const end = Math.min(content.length, index + term.length + contextLength); const snippet = content.substring(start, end); const highlightedSnippet = snippet.replace( new RegExp(term, "gi"), `<mark>$&</mark>` ); highlights.push( (start > 0 ? "..." : "") + highlightedSnippet + (end < content.length ? "..." : "") ); index = contentLower.indexOf(termLower, index + 1); } } return highlights; } // src/adapters/base-search.ts var DEFAULT_SEARCH_CONFIG = { ngramSize: 3, minEdgegram: 2, maxEdgegram: 10, exactMatchBoost: 100, ngramWeight: 0.5, edgegramWeight: 1, minScore: 0.1 }; var BaseSearchAdapter = class { searchConfig; constructor(searchConfig = {}) { this.searchConfig = { ...DEFAULT_SEARCH_CONFIG, ...searchConfig }; } /** * Generate search terms for indexing a document */ generateSearchTerms(content) { const ngrams = buildNGrams(content, this.searchConfig.ngramSize); const edgegrams = buildEdgeGrams( content, this.searchConfig.minEdgegram, this.searchConfig.maxEdgegram ); return { ngrams, edgegrams }; } /** * Calculate relevance score for a document based on search terms */ calculateRelevanceScore(documentContent, searchTerms, ngramMatches, edgegramMatches) { let score = 0; const contentLower = documentContent.toLowerCase(); const words = contentLower.split(/\s+/); for (const term of searchTerms) { if (words.includes(term.toLowerCase())) { score += this.searchConfig.exactMatchBoost; } } score += ngramMatches * this.searchConfig.ngramWeight; score += edgegramMatches * this.searchConfig.edgegramWeight; return score; } /** * Process search results: score, sort, and paginate */ processSearchResults(query, searchTerms, documentScores, startTime) { const filteredResults = []; for (const [docId, { score, data }] of documentScores.entries()) { if (score < this.searchConfig.minScore) continue; const highlights = generateHighlights(data.content, searchTerms); filteredResults.push({ id: docId, score, data: { content: data.content, ...data.metadata }, highlights }); } filteredResults.sort((a, b) => b.score - a.score); const offset = query.offset || 0; const limit = query.limit || 10; const paginatedResults = filteredResults.slice(offset, offset + limit); return { query: query.query, results: paginatedResults, total: filteredResults.length, took: Date.now() - startTime, hasMore: offset + limit < filteredResults.length }; } /** * Validate and normalize search query */ normalizeSearchQuery(query) { const searchTerms = query.query.toLowerCase().split(/\s+/).filter((term) => term.length > 0); return { searchTerms, isValid: searchTerms.length > 0 }; } /** * Create empty search response for invalid queries */ createEmptyResponse(query, startTime) { return { query: query.query, results: [], total: 0, took: Date.now() - startTime, hasMore: false }; } }; // src/adapters/memory.ts var MemoryAdapter = class extends BaseSearchAdapter { sessions = /* @__PURE__ */ new Map(); documents = /* @__PURE__ */ new Map(); documentAccessOrder = /* @__PURE__ */ new Map(); // Track access order for LRU ngramIndex = /* @__PURE__ */ new Map(); // ngram -> document IDs edgegramIndex = /* @__PURE__ */ new Map(); // edgegram -> document IDs maxDocuments; sessionCleanupHours; accessCounter = 0; // Monotonic counter for access order constructor(config = {}) { super(config.searchConfig); this.maxDocuments = config.maxDocuments || 1e4; this.sessionCleanupHours = config.sessionCleanupInterval || 24; } async setSession(sessionId, metadata) { this.sessions.set(sessionId, { ...metadata }); } async getSession(sessionId) { const session = this.sessions.get(sessionId); return session ? { ...session } : null; } async deleteSession(sessionId) { this.sessions.delete(sessionId); } async getAllSessions() { return Array.from(this.sessions.values()).map((session) => ({ ...session })); } async updateLastSeen(sessionId) { const session = this.sessions.get(sessionId); if (session) { session.lastSeenAt = /* @__PURE__ */ new Date(); } } async indexDocument(id, content, metadata) { await this.removeFromIndex(id); while (this.documents.size >= this.maxDocuments) { const lruDocumentId = this.findLRUDocument(); if (lruDocumentId) { await this.removeDocument(lruDocumentId); } else { break; } } this.documents.set(id, { content, ...metadata && { metadata } }); this.documentAccessOrder.set(id, ++this.accessCounter); const { ngrams, edgegrams } = this.generateSearchTerms(content); for (const ngram of ngrams) { if (!this.ngramIndex.has(ngram)) { this.ngramIndex.set(ngram, /* @__PURE__ */ new Set()); } this.ngramIndex.get(ngram).add(id); } for (const edgegram of edgegrams) { if (!this.edgegramIndex.has(edgegram)) { this.edgegramIndex.set(edgegram, /* @__PURE__ */ new Set()); } this.edgegramIndex.get(edgegram).add(id); } } /** * Find the least recently used document for eviction */ findLRUDocument() { let lruId; let lruAccessTime = Infinity; for (const [docId, accessTime] of this.documentAccessOrder.entries()) { if (accessTime < lruAccessTime) { lruAccessTime = accessTime; lruId = docId; } } return lruId; } /** * Update access time for a document (for LRU tracking) */ updateDocumentAccess(id) { if (this.documents.has(id)) { this.documentAccessOrder.set(id, ++this.accessCounter); } } async removeDocument(id) { this.documents.delete(id); this.documentAccessOrder.delete(id); await this.removeFromIndex(id); } async removeFromIndex(id) { for (const [ngram, docIds] of this.ngramIndex.entries()) { docIds.delete(id); if (docIds.size === 0) { this.ngramIndex.delete(ngram); } } for (const [edgegram, docIds] of this.edgegramIndex.entries()) { docIds.delete(id); if (docIds.size === 0) { this.edgegramIndex.delete(edgegram); } } } async search(query) { const startTime = Date.now(); const { searchTerms, isValid } = this.normalizeSearchQuery(query); if (!isValid) { return this.createEmptyResponse(query, startTime); } const documentScores = /* @__PURE__ */ new Map(); for (const term of searchTerms) { const { ngrams, edgegrams } = this.generateSearchTerms(term); for (const ngram of ngrams) { const matchingDocs = this.ngramIndex.get(ngram); if (matchingDocs) { for (const docId of matchingDocs) { if (!documentScores.has(docId)) { const doc = this.documents.get(docId); if (doc) { documentScores.set(docId, { score: 0, data: doc }); } } const current = documentScores.get(docId); if (current) { current.score += this.searchConfig.ngramWeight; } } } } for (const edgegram of edgegrams) { const matchingDocs = this.edgegramIndex.get(edgegram); if (matchingDocs) { for (const docId of matchingDocs) { if (!documentScores.has(docId)) { const doc = this.documents.get(docId); if (doc) { documentScores.set(docId, { score: 0, data: doc }); } } const current = documentScores.get(docId); if (current) { current.score += this.searchConfig.edgegramWeight; } } } } } for (const [docId, scoreData] of documentScores.entries()) { this.updateDocumentAccess(docId); const finalScore = this.calculateRelevanceScore( scoreData.data.content, searchTerms, scoreData.score, // Use actual calculated match score 0 // No additional boost for memory adapter ); documentScores.set(docId, { ...scoreData, score: finalScore }); } return this.processSearchResults(query, searchTerms, documentScores, startTime); } async cleanup() { const cutoffTime = new Date(Date.now() - this.sessionCleanupHours * 60 * 60 * 1e3); for (const [sessionId, session] of this.sessions.entries()) { if (session.lastSeenAt < cutoffTime) { this.sessions.delete(sessionId); } } } async disconnect() { this.sessions.clear(); this.documents.clear(); this.documentAccessOrder.clear(); this.ngramIndex.clear(); this.edgegramIndex.clear(); this.accessCounter = 0; } }; // src/server/index.ts function generateUUID() { return "xxxx-xxxx-4xxx-yxxx-xxxx".replace(/[xy]/g, (c) => { const r = Math.random() * 16 | 0; const v = c === "x" ? r : r & 3 | 8; return v.toString(16); }); } var PlugNPlayServer = class { constructor(config = {}) { this.config = config; this.emitter = new eventemitter3.EventEmitter(); this.logger = config.logger || new ConsoleLogger(); this.adapter = config.adapter || new MemoryAdapter(); this.maxConnections = config.maxConnections; this.httpServer = http.createServer(); this.io = new socket_io.Server(this.httpServer, { cors: { origin: config.cors?.origin || true, methods: config.cors?.methods || ["GET", "POST"], credentials: config.cors?.credentials || false }, pingTimeout: config.heartbeatTimeout || 6e4, pingInterval: config.heartbeatInterval || 25e3 }); this.setupSocketHandlers(); this.startHeartbeat(); this.startCleanupTask(); } httpServer; io; adapter; logger; emitter; heartbeatInterval; cleanupInterval; isShuttingDown = false; activeSockets = /* @__PURE__ */ new Set(); startTime = Date.now(); maxConnections; // 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; } /** * Start the server on the specified port */ async listen(port) { const serverPort = port || this.config.port || 3001; return new Promise((resolve, reject) => { this.httpServer.listen(serverPort, () => { this.logger.info(`WebSocket server listening on port ${serverPort}`); resolve(); }); this.httpServer.on("error", (error) => { this.logger.error("Server error", { error: error.message }); reject(error); }); }); } /** * Gracefully shutdown the server */ async close() { this.isShuttingDown = true; this.logger.info("Starting graceful shutdown..."); if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); } if (this.cleanupInterval) { clearInterval(this.cleanupInterval); } const closePromises = []; for (const socket of this.activeSockets) { closePromises.push(this.disconnectSocket(socket, "server_shutdown")); } const shutdownTimeout = this.config.gracefulShutdownTimeout || 1e4; await Promise.race([ Promise.all(closePromises), new Promise((resolve) => setTimeout(resolve, shutdownTimeout)) ]); return new Promise((resolve) => { this.io.close(() => { this.httpServer.close(() => { this.logger.info("Server shutdown complete"); resolve(); }); }); }); } /** * Send a typed message to a specific session */ async sendToSession(sessionId, event, data) { const socket = this.findSocketBySessionId(sessionId); if (socket) { socket.emit(event, data); return true; } return false; } /** * Send a typed message to all connected clients */ broadcast(event, data) { this.io.emit(event, data); } /** * Send a typed message to all clients except the sender */ broadcastExcept(senderSessionId, event, data) { const senderSocket = this.findSocketBySessionId(senderSessionId); if (senderSocket) { senderSocket.broadcast.emit(event, data); } } /** * Get all active sessions */ async getActiveSessions() { return this.adapter.getAllSessions(); } /** * Get session by ID */ async getSession(sessionId) { return this.adapter.getSession(sessionId); } /** * Disconnect a session */ async disconnectSession(sessionId, reason = "server_disconnect") { const socket = this.findSocketBySessionId(sessionId); if (socket) { await this.disconnectSocket(socket, reason); return true; } return false; } /** * Index content for search */ async indexContent(id, content, metadata) { await this.adapter.indexDocument(id, content, metadata); } /** * Remove content from search index */ async removeContent(id) { await this.adapter.removeDocument(id); } /** * Perform a search and optionally send streaming results */ async search(query, targetSessionId) { const results = await this.adapter.search(query); if (query.streaming && targetSessionId) { const socket = this.findSocketBySessionId(targetSessionId); if (socket) { for (let i = 0; i < results.results.length; i++) { const chunk = results.results[i]; const isLast = i === results.results.length - 1; socket.emit("search-stream", { chunk, isLast }); if (!isLast) { await new Promise((resolve) => setTimeout(resolve, 10)); } } } } return results; } setupSocketHandlers() { this.io.on("connection", async (socket) => { try { await this.handleConnection(socket); } catch (error) { this.logger.error("Connection handler error", { error: error instanceof Error ? error.message : "Unknown error" }); socket.disconnect(true); } }); } async handleConnection(socket) { if (this.maxConnections && this.activeSockets.size >= this.maxConnections) { this.logger.warn("Connection rejected: maximum connections reached", { current: this.activeSockets.size, limit: this.maxConnections }); socket.emit("error", { error: new Error("Server capacity reached") }); socket.disconnect(true); return; } const sessionId = generateUUID(); this.activeSockets.add(socket); const metadata = { id: sessionId, connectedAt: /* @__PURE__ */ new Date(), lastSeenAt: /* @__PURE__ */ new Date() }; if (socket.handshake.auth?.userId) { metadata.userId = socket.handshake.auth.userId; } if (socket.handshake.auth?.tabId) { metadata.tabId = socket.handshake.auth.tabId; } if (socket.handshake.headers["user-agent"]) { metadata.userAgent = socket.handshake.headers["user-agent"]; } if (socket.handshake.address) { metadata.ip = socket.handshake.address; } if (socket.handshake.auth?.metadata) { metadata.metadata = socket.handshake.auth.metadata; } await this.adapter.setSession(sessionId, metadata); socket.sessionId = sessionId; this.logger.info("Client connected", { sessionId, userId: metadata.userId }); this.emit("connect", { sessionId, metadata }); this.setupSocketEventHandlers(socket, sessionId); socket.emit("session", { sessionId, metadata }); } setupSocketEventHandlers(socket, sessionId) { socket.on("disconnect", async (reason) => { await this.handleDisconnection(socket, sessionId, reason); }); socket.on("ping", async (_data) => { await this.adapter.updateLastSeen(sessionId); socket.emit("pong", { timestamp: Date.now() }); }); socket.on("search", async (query) => { try { const validatedQuery = SearchQuerySchema.parse(query); const results = await this.search(validatedQuery, sessionId); socket.emit("search-result", results); } catch (error) { let errorMessage = error instanceof Error ? error.message : "Unknown error"; let userFriendlyError = "Search failed"; if (error instanceof Error && error.name === "ZodError") { userFriendlyError = "Invalid search query format"; errorMessage = "Search query validation failed"; } else if (error instanceof Error) { if (error.message.includes("timeout")) { userFriendlyError = "Search request timed out"; } else if (error.message.includes("invalid")) { userFriendlyError = "Invalid search query"; } else if (error.message.includes("connection")) { userFriendlyError = "Database connection error"; } } this.logger.error("Search error", { sessionId, query: typeof query === "object" && query && "query" in query && typeof query.query === "string" ? query.query : "invalid", error: errorMessage }); socket.emit("error", { sessionId, error: new Error(userFriendlyError) }); } }); socket.onAny((event, data) => { if (!["disconnect", "ping", "search", "connect"].includes(event)) { this.emit(event, data); } }); } async handleDisconnection(socket, sessionId, reason) { this.activeSockets.delete(socket); this.logger.info("Client disconnected", { sessionId, reason }); await this.adapter.deleteSession(sessionId); this.emit("disconnect", { sessionId, reason }); } async disconnectSocket(socket, reason) { const sessionId = socket.sessionId; if (sessionId) { await this.handleDisconnection(socket, sessionId, reason); } socket.disconnect(true); } findSocketBySessionId(sessionId) { for (const socket of this.activeSockets) { if (socket.sessionId === sessionId) { return socket; } } return void 0; } startHeartbeat() { const interval = this.config.heartbeatInterval || 3e4; this.heartbeatInterval = setInterval(() => { if (this.isShuttingDown) return; this.io.emit("ping", { timestamp: Date.now() }); }, interval); } startCleanupTask() { this.cleanupInterval = setInterval(async () => { if (this.isShuttingDown) return; try { await this.adapter.cleanup(); this.logger.debug("Cleanup task completed"); } catch (error) { this.logger.error("Cleanup task error", { error: error instanceof Error ? error.message : "Unknown error" }); } }, 60 * 60 * 1e3); } /** * Get server statistics */ getStats() { return { connectedClients: this.activeSockets.size, isShuttingDown: this.isShuttingDown, uptime: Date.now() - this.startTime }; } }; 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/adapters/redis.ts var UnifiedRedisAdapter = class extends BaseSearchAdapter { redis; keyPrefix; logger; ttl; constructor(config) { super(config.searchConfig); this.redis = config.redis; this.keyPrefix = config.keyPrefix || "pnp-ws:"; this.logger = config.logger; this.ttl = { session: 24 * 60 * 60, // 24 hours document: 7 * 24 * 60 * 60, // 7 days index: 7 * 24 * 60 * 60, // 7 days ...config.ttl }; } getKey(type, id) { return `${this.keyPrefix}${type}:${id}`; } // Session Management async setSession(sessionId, metadata) { const key = this.getKey("session", sessionId); const flatData = []; flatData.push("id", metadata.id); flatData.push("connectedAt", metadata.connectedAt.toISOString()); flatData.push("lastSeenAt", metadata.lastSeenAt.toISOString()); if (metadata.userId) flatData.push("userId", metadata.userId); if (metadata.tabId) flatData.push("tabId", metadata.tabId); if (metadata.userAgent) flatData.push("userAgent", metadata.userAgent); if (metadata.ip) flatData.push("ip", metadata.ip); if (metadata.metadata) flatData.push("metadata", JSON.stringify(metadata.metadata)); await this.redis.pipeline([ ["HSET", key, ...flatData], ["EXPIRE", key, this.ttl.session.toString()], ["SADD", this.getKey("sessions", "active"), sessionId] ]); } async getSession(sessionId) { const key = this.getKey("session", sessionId); const data = await this.redis.hgetall(key); if (!data || Object.keys(data).length === 0 || !data.id) { return null; } return { id: data.id, ...data.userId && { userId: data.userId }, ...data.tabId && { tabId: data.tabId }, ...data.userAgent && { userAgent: data.userAgent }, ...data.ip && { ip: data.ip }, connectedAt: new Date(data.connectedAt || Date.now()), lastSeenAt: new Date(data.lastSeenAt || Date.now()), ...data.metadata && { metadata: JSON.parse(data.metadata) } }; } async deleteSession(sessionId) { const key = this.getKey("session", sessionId); await this.redis.pipeline([ ["DEL", key], ["SREM", this.getKey("sessions", "active"), sessionId] ]); } async getAllSessions() { const sessionIds = await this.redis.smembers(this.getKey("sessions", "active")); const sessions = []; for (const sessionId of sessionIds) { const session = await this.getSession(sessionId); if (session) { sessions.push(session); } else { await this.redis.srem(this.getKey("sessions", "active"), sessionId); } } return sessions; } async updateLastSeen(sessionId) { const key = this.getKey("session", sessionId); await this.redis.pipeline([ ["HSET", key, "lastSeenAt", (/* @__PURE__ */ new Date()).toISOString()], ["EXPIRE", key, this.ttl.session.toString()] ]); } // Document Indexing async indexDocument(id, content, metadata) { await this.removeDocument(id); const docKey = this.getKey("doc", id); const { ngrams, edgegrams } = this.generateSearchTerms(content); const allTerms = [...ngrams, ...edgegrams]; const docData = [ "content", content, "indexedAt", (/* @__PURE__ */ new Date()).toISOString(), "terms", JSON.stringify(allTerms) // Store terms for efficient removal ]; if (metadata) { docData.push("metadata", JSON.stringify(metadata)); } const commands = [ ["HSET", docKey, ...docData], ["EXPIRE", docKey, this.ttl.document.toString()], ["SADD", this.getKey("docs", "all"), id] ]; for (const ngram of ngrams) { const ngramKey = this.getKey("ngram", ngram); commands.push(["SADD", ngramKey, id]); commands.push(["EXPIRE", ngramKey, this.ttl.index.toString()]); } for (const edgegram of edgegrams) { const edgegramKey = this.getKey("edgegram", edgegram); commands.push(["SADD", edgegramKey, id]); commands.push(["EXPIRE", edgegramKey, this.ttl.index.toString()]); } await this.redis.pipeline(commands); } async removeDocument(id) { const docKey = this.getKey("doc", id); const doc = await this.redis.hgetall(docKey); if (doc && doc.terms) { try { const terms = JSON.parse(doc.terms); const commands = []; for (const term of terms) { const ngramKey = this.getKey("ngram", term); const edgegramKey = this.getKey("edgegram", term); commands.push(["SREM", ngramKey, id]); commands.push(["SREM", edgegramKey, id]); } commands.push(["DEL", docKey]); commands.push(["SREM", this.getKey("docs", "all"), id]); await this.redis.pipeline(commands); } catch (error) { this.logger?.warn?.("Failed to parse indexed terms, using fallback removal", { id, error }); await this.removeDocumentFromIndexesFallback(id); } } else { await this.redis.pipeline([ ["DEL", docKey], ["SREM", this.getKey("docs", "all"), id] ]); } } async removeDocumentFromIndexesFallback(id) { const [ngramKeys, edgegramKeys] = await Promise.all([ this.redis.keys(this.getKey("ngram", "*")), this.redis.keys(this.getKey("edgegram", "*")) ]); const commands = []; for (const key of [...ngramKeys, ...edgegramKeys]) { commands.push(["SREM", key, id]); } commands.push(["DEL", this.getKey("doc", id)]); commands.push(["SREM", this.getKey("docs", "all"), id]); if (commands.length > 0) { await this.redis.pipeline(commands); } } // Search Implementation async search(query) { const startTime = Date.now(); const searchTimeout = 1e4; const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error("Search timeout")), searchTimeout); }); try { const searchPromise = this.performSearch(query, startTime); return await Promise.race([searchPromise, timeoutPromise]); } catch (error) { if (error instanceof Error && error.message === "Search timeout") { this.logger?.error?.("Search timeout exceeded", { query: query.query, timeout: searchTimeout }); } throw error; } } async performSearch(query, startTime) { const { searchTerms, isValid } = this.normalizeSearchQuery(query); if (!isValid) { return this.createEmptyResponse(query, startTime); } const documentScores = /* @__PURE__ */ new Map(); for (const term of searchTerms) { const { ngrams, edgegrams } = this.generateSearchTerms(term); const ngramCommands = ngrams.map( (ngram) => ["SMEMBERS", this.getKey("ngram", ngram)] ); const edgegramCommands = edgegrams.map( (edgegram) => ["SMEMBERS", this.getKey("edgegram", edgegram)] ); const [ngramResults, edgegramResults] = await Promise.all([ this.redis.pipeline(ngramCommands), this.redis.pipeline(edgegramCommands) ]); ngramResults.forEach((matchingDocs) => { for (const docId of matchingDocs) { if (!documentScores.has(docId)) { documentScores.set(docId, { score: 0, data: { content: "" } }); } documentScores.get(docId).score += this.searchConfig.ngramWeight; } }); edgegramResults.forEach((matchingDocs, index2) => { const edgegram = edgegrams[index2]; if (!edgegram) return; const boost = edgegram.length / this.searchConfig.maxEdgegram; for (const docId of matchingDocs) { if (!documentScores.has(docId)) { documentScores.set(docId, { score: 0, data: { content: "" } }); } documentScores.get(docId).score += boost * this.searchConfig.edgegramWeight; } }); } const docCommands = Array.from(documentScores.keys()).map( (docId) => ["HGETALL", this.getKey("doc", docId)] ); if (docCommands.length === 0) { return this.createEmptyResponse(query, startTime); } const docResults = await this.redis.pipeline(docCommands); let index = 0; for (const [docId, scoreData] of documentScores.entries()) { const docData = docResults[index]; index++; if (docData && docData.content) { const documentData = { content: docData.content, ...docData.metadata && { metadata: JSON.parse(docData.metadata) } }; const finalScore = this.calculateRelevanceScore( docData.content, searchTerms, 0, // n-gram matches already counted 0 // edge-gram matches already counted ) + scoreData.score; documentScores.set(docId, { score: finalScore, data: documentData }); } else { documentScores.delete(docId); } } return this.processSearchResults(query, searchTerms, documentScores, startTime); } // Cleanup and Maintenance async cleanup() { const sessionIds = await this.redis.smembers(this.getKey("sessions", "active")); for (const sessionId of sessionIds) { const key = this.getKey("session", sessionId); const exists = await this.redis.exists(key); if (!exists) { await this.redis.srem(this.getKey("sessions", "active"), sessionId); } } } async disconnect() { await this.redis.disconnect(); } }; // src/adapters/redis-clients.ts var IoRedisAdapter = class { constructor(redis) { this.redis = redis; } async hset(key, ...args) { if (args.length === 2 && args[0] && args[1]) { await this.redis.hset(key, args[0], args[1]); } else { await this.redis.hset(key, ...args); } } async hgetall(key) { return this.redis.hgetall(key); } async del(key) { await this.redis.del(key); } async exists(key) { return this.redis.exists(key); } async expire(key, seconds) { await this.redis.expire(key, seconds); } async sadd(key, member) { await this.redis.sadd(key, member); } async smembers(key) { return this.redis.smembers(key); } async srem(key, member) { await this.redis.srem(key, member); } async pipeline(commands) { const pipeline = this.redis.pipeline(); for (const [command, ...args] of commands) { const pipelineMethod = pipeline[command.toLowerCase()]; if (pipelineMethod) { pipelineMethod.call(pipeline, ...args); } } const results = await pipeline.exec(); const processedResults = []; if (results) { for (let i = 0; i < results.length; i++) { const result = results[i]; if (!result) continue; const [error, value] = result; if (error) { let commandInfo = "unknown"; if (i < commands.length) { const cmd = commands[i]; if (cmd && cmd.length > 0) { commandInfo = cmd[0]; } } throw new Error(`Pipeline command ${i} (${commandInfo}) failed: ${error.message}`); } processedResults.push(value); } } return processedResults; } async keys(pattern) { return this.redis.keys(pattern); } async disconnect() { await this.redis.quit(); } }; var UpstashRedisAdapter = class { baseUrl; token; constructor(config) { this.baseUrl = config.url.endsWith("/") ? config.url.slice(0, -1) : config.url; this.token = config.token; } async request(command) { const response = await fetch(`${this.baseUrl}`, { method: "POST", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify(command) }); if (!response.ok) { throw new Error(`Upstash Redis request failed: ${response.statusText}`); } const data = await response.json(); if (data.error) { throw new Error(`Upstash Redis error: ${data.error}`); } return data.result; } async hset(key, ...args) { await this.request(["HSET", key, ...args]); } async hgetall(key) { return await this.request(["HGETALL", key]); } async del(key) { await this.request(["DEL", key]); } async exists(key) { return await this.request(["EXISTS", key]); } async expire(key, seconds) { await this.request(["EXPIRE", key, seconds.toString()]); } async sadd(key, member) { await this.request(["SADD", key, member]); } async smembers(key) { return await this.request(["SMEMBERS", key]); } async srem(key, member) { await this.request(["SREM", key, member]); } async pipeline(commands) { const response = await fetch(`${this.baseUrl}/pipeline`, { method: "POST", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify(commands) }); if (!response.ok) { throw new Error(`Upstash Redis pipeline request failed: ${response.statusText}`); } const data = await response.json(); return data.map((item) => { if (item.error) { throw new Error(`Upstash Redis error: ${item.error}`); } return item.result; }); } async keys(pattern) { return await this.request(["KEYS", pattern]); } async disconnect() { } }; function buildAdapterConfig(redis, config) { const adapterConfig = { redis }; if (config.keyPrefix) { adapterConfig.keyPrefix = config.keyPrefix; } if (config.searchConfig) { adapterConfig.searchConfig = config.searchConfig; } if (config.ttl) { adapterConfig.ttl = { session: config.ttl.session ?? 24 * 60 * 60, document: config.ttl.document ?? 7 * 24 * 60 * 60, index: config.ttl.index ?? 7 * 24 * 60 * 60 }; } return adapterConfig; } function createRedisAdapter(config) { let redis; if (config.url) { redis = new Redis__default.default(config.url); } else { const redisConfig = { host: config.host || "localhost", port: config.port || 6379, db: config.db || 0, maxRetriesPerRequest: 3, lazyConnect: true }; if (config.password) { redisConfig.password = config.password; } redis = new Redis__default.default(redisConfig); } const unifiedRedis = new IoRedisAdapter(redis); const adapterConfig = buildAdapterConfig(unifiedRedis, config); return new UnifiedRedisAdapter(adapterConfig); } function createUpstashRedisAdapter(config) { const upstashRedis = new UpstashRedisAdapter({ url: config.url, token: config.token }); const adapterConfig = buildAdapterConfig(upstashRedis, config); return new UnifiedRedisAdapter(adapterConfig); } function createRedisAdapterFromEnv(keyPrefix, searchConfig) { const upstashUrl = process.env.UPSTASH_REDIS_REST_URL; const upstashToken = process.env.UPSTASH_REDIS_REST_TOKEN; if (upstashUrl && upstashToken) { const config2 = { url: upstashUrl, token: upstashToken, ...keyPrefix && { keyPrefix }, ...searchConfig && { searchConfig } }; return createUpstashRedisAdapter(config2); } const redisUrl = process.env.REDIS_URL; if (redisUrl) { const config2 = { url: redisUrl, ...keyPrefix && { keyPrefix }, ...searchConfig && { searchConfig } }; return createRedisAdapter(config2); } const config = { host: process.env.REDIS_HOST || "localhost", port: parseInt(process.env.REDIS_PORT || "6379"), db: parseInt(process.env.REDIS_DB || "0"), ...process.env.REDIS_PASSWORD && { password: process.env.REDIS_PASSWORD }, ...keyPrefix && { keyPrefix }, ...searchConfig && { searchConfig } }; return createRedisAdapter(config); } exports.BaseSearchAdapter = BaseSearchAdapter; exports.ConnectionStatus = ConnectionStatus; exports.ConsoleLogger = ConsoleLogger; exports.DEFAULT_SEARCH_CONFIG = DEFAULT_SEARCH_CONFIG; exports.IoRedisAdapter = IoRedisAdapter; exports.MemoryAdapter = MemoryAdapter; exports.PlugNPlayClient = PlugNPlayClient; exports.PlugNPlayServer = PlugNPlayServer; exports.SearchQuerySchema = SearchQuerySchema; exports.SearchResponseSchema = SearchResponseSchema; exports.SearchResultSchema = SearchResultSchema; exports.SessionMetadataSchema = SessionMetadataSchema; exports.UnifiedRedisAdapter = UnifiedRedisAdapter; exports.UpstashRedisClient = UpstashRedisAdapter; exports.buildEdgeGrams = buildEdgeGrams; exports.buildNGrams = buildNGrams; exports.createRedisAdapter = createRedisAdapter; exports.createRedisAdapterFromEnv = createRedisAdapterFromEnv; exports.createUpstashRedisAdapter = createUpstashRedisAdapter; exports.generateHighlights = generateHighlights; //# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map