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