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
JavaScript
'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