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