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

718 lines (711 loc) 22.3 kB
'use strict'; var http = require('http'); var socket_io = require('socket.io'); var eventemitter3 = require('eventemitter3'); var zod = require('zod'); // src/server/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() }); // 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 }; } }; exports.PlugNPlayServer = PlugNPlayServer; //# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map