Increase default timeout in HttpClient to 60 seconds for pub/sub operations; simplify WSClient by removing unused configuration options and enhancing connection handling with open and close event handlers; refactor PubSubClient to improve WebSocket subscription management and message handling.

This commit is contained in:
anonpenguin23 2025-10-28 10:25:10 +02:00
parent 2de0cb1983
commit c6dfb0bfed
3 changed files with 178 additions and 159 deletions

View File

@ -19,7 +19,7 @@ export class HttpClient {
constructor(config: HttpClientConfig) { constructor(config: HttpClientConfig) {
this.baseURL = config.baseURL.replace(/\/$/, ""); this.baseURL = config.baseURL.replace(/\/$/, "");
this.timeout = config.timeout ?? 30000; this.timeout = config.timeout ?? 60000; // Increased from 30s to 60s for pub/sub operations
this.maxRetries = config.maxRetries ?? 3; this.maxRetries = config.maxRetries ?? 3;
this.retryDelayMs = config.retryDelayMs ?? 1000; this.retryDelayMs = config.retryDelayMs ?? 1000;
this.fetch = config.fetch ?? globalThis.fetch; this.fetch = config.fetch ?? globalThis.fetch;

View File

@ -4,10 +4,6 @@ import { SDKError } from "../errors";
export interface WSClientConfig { export interface WSClientConfig {
wsURL: string; wsURL: string;
timeout?: number; timeout?: number;
maxReconnectAttempts?: number;
reconnectDelayMs?: number;
heartbeatIntervalMs?: number;
authMode?: "header" | "query";
authToken?: string; authToken?: string;
WebSocket?: typeof WebSocket; WebSocket?: typeof WebSocket;
} }
@ -15,44 +11,41 @@ export interface WSClientConfig {
export type WSMessageHandler = (data: string) => void; export type WSMessageHandler = (data: string) => void;
export type WSErrorHandler = (error: Error) => void; export type WSErrorHandler = (error: Error) => void;
export type WSCloseHandler = () => void; export type WSCloseHandler = () => void;
export type WSOpenHandler = () => void;
/**
* Simple WebSocket client with minimal abstractions
* No complex reconnection, no heartbeats - keep it simple
*/
export class WSClient { export class WSClient {
private url: string; private url: string;
private timeout: number; private timeout: number;
private maxReconnectAttempts: number;
private reconnectDelayMs: number;
private heartbeatIntervalMs: number;
private authMode: "header" | "query";
private authToken?: string; private authToken?: string;
private WebSocketClass: typeof WebSocket; private WebSocketClass: typeof WebSocket;
private ws?: WebSocket; private ws?: WebSocket;
private reconnectAttempts = 0;
private heartbeatInterval?: NodeJS.Timeout;
private messageHandlers: Set<WSMessageHandler> = new Set(); private messageHandlers: Set<WSMessageHandler> = new Set();
private errorHandlers: Set<WSErrorHandler> = new Set(); private errorHandlers: Set<WSErrorHandler> = new Set();
private closeHandlers: Set<WSCloseHandler> = new Set(); private closeHandlers: Set<WSCloseHandler> = new Set();
private isManuallyClosed = false; private openHandlers: Set<WSOpenHandler> = new Set();
private isClosed = false;
constructor(config: WSClientConfig) { constructor(config: WSClientConfig) {
this.url = config.wsURL; this.url = config.wsURL;
this.timeout = config.timeout ?? 30000; this.timeout = config.timeout ?? 30000;
this.maxReconnectAttempts = config.maxReconnectAttempts ?? 5;
this.reconnectDelayMs = config.reconnectDelayMs ?? 1000;
this.heartbeatIntervalMs = config.heartbeatIntervalMs ?? 30000;
this.authMode = config.authMode ?? "header";
this.authToken = config.authToken; this.authToken = config.authToken;
this.WebSocketClass = config.WebSocket ?? WebSocket; this.WebSocketClass = config.WebSocket ?? WebSocket;
} }
/**
* Connect to WebSocket server
*/
connect(): Promise<void> { connect(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const wsUrl = this.buildWSUrl(); const wsUrl = this.buildWSUrl();
this.ws = new this.WebSocketClass(wsUrl); this.ws = new this.WebSocketClass(wsUrl);
this.isClosed = false;
// Note: Custom headers via ws library in Node.js are not sent with WebSocket upgrade requests
// so we rely on query parameters for authentication
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
this.ws?.close(); this.ws?.close();
@ -63,8 +56,8 @@ export class WSClient {
this.ws.addEventListener("open", () => { this.ws.addEventListener("open", () => {
clearTimeout(timeout); clearTimeout(timeout);
this.reconnectAttempts = 0; console.log("[WSClient] Connected to", this.url);
this.startHeartbeat(); this.openHandlers.forEach((handler) => handler());
resolve(); resolve();
}); });
@ -82,12 +75,8 @@ export class WSClient {
this.ws.addEventListener("close", () => { this.ws.addEventListener("close", () => {
clearTimeout(timeout); clearTimeout(timeout);
this.stopHeartbeat(); console.log("[WSClient] Connection closed");
if (!this.isManuallyClosed) {
this.attemptReconnect();
} else {
this.closeHandlers.forEach((handler) => handler()); this.closeHandlers.forEach((handler) => handler());
}
}); });
} catch (error) { } catch (error) {
reject(error); reject(error);
@ -95,11 +84,12 @@ export class WSClient {
}); });
} }
/**
* Build WebSocket URL with auth token
*/
private buildWSUrl(): string { private buildWSUrl(): string {
let url = this.url; let url = this.url;
// Always append auth token as query parameter for compatibility
// Works in both Node.js and browser environments
if (this.authToken) { if (this.authToken) {
const separator = url.includes("?") ? "&" : "?"; const separator = url.includes("?") ? "&" : "?";
const paramName = this.authToken.startsWith("ak_") ? "api_key" : "token"; const paramName = this.authToken.startsWith("ak_") ? "api_key" : "token";
@ -109,68 +99,91 @@ export class WSClient {
return url; return url;
} }
private startHeartbeat() { /**
this.heartbeatInterval = setInterval(() => { * Register message handler
if (this.ws?.readyState === WebSocket.OPEN) { */
this.ws.send(JSON.stringify({ type: "ping" })); onMessage(handler: WSMessageHandler): () => void {
}
}, this.heartbeatIntervalMs);
}
private stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = undefined;
}
}
private attemptReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delayMs = this.reconnectDelayMs * this.reconnectAttempts;
setTimeout(() => {
this.connect().catch((error) => {
this.errorHandlers.forEach((handler) => handler(error));
});
}, delayMs);
} else {
this.closeHandlers.forEach((handler) => handler());
}
}
onMessage(handler: WSMessageHandler) {
this.messageHandlers.add(handler); this.messageHandlers.add(handler);
return () => this.messageHandlers.delete(handler); return () => this.messageHandlers.delete(handler);
} }
onError(handler: WSErrorHandler) { /**
* Unregister message handler
*/
offMessage(handler: WSMessageHandler): void {
this.messageHandlers.delete(handler);
}
/**
* Register error handler
*/
onError(handler: WSErrorHandler): () => void {
this.errorHandlers.add(handler); this.errorHandlers.add(handler);
return () => this.errorHandlers.delete(handler); return () => this.errorHandlers.delete(handler);
} }
onClose(handler: WSCloseHandler) { /**
* Unregister error handler
*/
offError(handler: WSErrorHandler): void {
this.errorHandlers.delete(handler);
}
/**
* Register close handler
*/
onClose(handler: WSCloseHandler): () => void {
this.closeHandlers.add(handler); this.closeHandlers.add(handler);
return () => this.closeHandlers.delete(handler); return () => this.closeHandlers.delete(handler);
} }
send(data: string) { /**
* Unregister close handler
*/
offClose(handler: WSCloseHandler): void {
this.closeHandlers.delete(handler);
}
/**
* Register open handler
*/
onOpen(handler: WSOpenHandler): () => void {
this.openHandlers.add(handler);
return () => this.openHandlers.delete(handler);
}
/**
* Send data through WebSocket
*/
send(data: string): void {
if (this.ws?.readyState !== WebSocket.OPEN) { if (this.ws?.readyState !== WebSocket.OPEN) {
throw new SDKError("WebSocket is not connected", 500, "WS_NOT_CONNECTED"); throw new SDKError("WebSocket is not connected", 500, "WS_NOT_CONNECTED");
} }
this.ws.send(data); this.ws.send(data);
} }
close() { /**
this.isManuallyClosed = true; * Close WebSocket connection
this.stopHeartbeat(); */
close(): void {
if (this.isClosed) {
return;
}
this.isClosed = true;
this.ws?.close(); this.ws?.close();
} }
/**
* Check if WebSocket is connected
*/
isConnected(): boolean { isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN; return !this.isClosed && this.ws?.readyState === WebSocket.OPEN;
} }
setAuthToken(token?: string) { /**
* Update auth token
*/
setAuthToken(token?: string): void {
this.authToken = token; this.authToken = token;
} }
} }

View File

@ -16,10 +16,8 @@ export interface RawEnvelope {
// Cross-platform base64 encoding/decoding utilities // Cross-platform base64 encoding/decoding utilities
function base64Encode(str: string): string { function base64Encode(str: string): string {
if (typeof Buffer !== "undefined") { if (typeof Buffer !== "undefined") {
// Node.js environment
return Buffer.from(str).toString("base64"); return Buffer.from(str).toString("base64");
} else if (typeof btoa !== "undefined") { } else if (typeof btoa !== "undefined") {
// Browser/React Native environment
return btoa( return btoa(
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) => encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) =>
String.fromCharCode(parseInt(p1, 16)) String.fromCharCode(parseInt(p1, 16))
@ -31,10 +29,8 @@ function base64Encode(str: string): string {
function base64EncodeBytes(bytes: Uint8Array): string { function base64EncodeBytes(bytes: Uint8Array): string {
if (typeof Buffer !== "undefined") { if (typeof Buffer !== "undefined") {
// Node.js environment
return Buffer.from(bytes).toString("base64"); return Buffer.from(bytes).toString("base64");
} else if (typeof btoa !== "undefined") { } else if (typeof btoa !== "undefined") {
// Browser/React Native environment
let binary = ""; let binary = "";
for (let i = 0; i < bytes.length; i++) { for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]); binary += String.fromCharCode(bytes[i]);
@ -46,10 +42,8 @@ function base64EncodeBytes(bytes: Uint8Array): string {
function base64Decode(b64: string): string { function base64Decode(b64: string): string {
if (typeof Buffer !== "undefined") { if (typeof Buffer !== "undefined") {
// Node.js environment
return Buffer.from(b64, "base64").toString("utf-8"); return Buffer.from(b64, "base64").toString("utf-8");
} else if (typeof atob !== "undefined") { } else if (typeof atob !== "undefined") {
// Browser/React Native environment
const binary = atob(b64); const binary = atob(b64);
const bytes = new Uint8Array(binary.length); const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) { for (let i = 0; i < binary.length; i++) {
@ -63,8 +57,11 @@ function base64Decode(b64: string): string {
export type MessageHandler = (message: Message) => void; export type MessageHandler = (message: Message) => void;
export type ErrorHandler = (error: Error) => void; export type ErrorHandler = (error: Error) => void;
export type CloseHandler = () => void; export type CloseHandler = () => void;
export type RawMessageHandler = (envelope: RawEnvelope) => void;
/**
* Simple PubSub client - one WebSocket connection per topic
* No connection pooling, no reference counting - keep it simple
*/
export class PubSubClient { export class PubSubClient {
private httpClient: HttpClient; private httpClient: HttpClient;
private wsConfig: Partial<WSClientConfig>; private wsConfig: Partial<WSClientConfig>;
@ -75,23 +72,18 @@ export class PubSubClient {
} }
/** /**
* Publish a message to a topic. * Publish a message to a topic via HTTP
*/ */
async publish(topic: string, data: string | Uint8Array): Promise<void> { async publish(topic: string, data: string | Uint8Array): Promise<void> {
let dataBase64: string; let dataBase64: string;
if (typeof data === "string") { if (typeof data === "string") {
dataBase64 = base64Encode(data); dataBase64 = base64Encode(data);
} else { } else {
// Encode bytes directly to preserve binary data
dataBase64 = base64EncodeBytes(data); dataBase64 = base64EncodeBytes(data);
} }
console.log("[PubSubClient] Publishing message:", { console.log("[PubSubClient] Publishing to topic:", topic);
topic,
data: typeof data === "string" ? data : `<${data.length} bytes>`,
});
// Use longer timeout for pub/sub operations (60s instead of default 30s)
await this.httpClient.post( await this.httpClient.post(
"/v1/pubsub/publish", "/v1/pubsub/publish",
{ {
@ -99,13 +91,13 @@ export class PubSubClient {
data_base64: dataBase64, data_base64: dataBase64,
}, },
{ {
timeout: 60000, // 60 seconds timeout: 30000,
} }
); );
} }
/** /**
* List active topics in the current namespace. * List active topics in the current namespace
*/ */
async topics(): Promise<string[]> { async topics(): Promise<string[]> {
const response = await this.httpClient.get<{ topics: string[] }>( const response = await this.httpClient.get<{ topics: string[] }>(
@ -115,8 +107,8 @@ export class PubSubClient {
} }
/** /**
* Subscribe to a topic via WebSocket. * Subscribe to a topic via WebSocket
* Returns a subscription object with event handlers. * Creates one WebSocket connection per topic
*/ */
async subscribe( async subscribe(
topic: string, topic: string,
@ -124,19 +116,24 @@ export class PubSubClient {
onMessage?: MessageHandler; onMessage?: MessageHandler;
onError?: ErrorHandler; onError?: ErrorHandler;
onClose?: CloseHandler; onClose?: CloseHandler;
onRaw?: RawMessageHandler;
} = {} } = {}
): Promise<Subscription> { ): Promise<Subscription> {
// Build WebSocket URL for this topic
const wsUrl = new URL(this.wsConfig.wsURL || "ws://localhost:6001"); const wsUrl = new URL(this.wsConfig.wsURL || "ws://localhost:6001");
wsUrl.pathname = "/v1/pubsub/ws"; wsUrl.pathname = "/v1/pubsub/ws";
wsUrl.searchParams.set("topic", topic); wsUrl.searchParams.set("topic", topic);
// Create WebSocket client
const wsClient = new WSClient({ const wsClient = new WSClient({
...this.wsConfig, ...this.wsConfig,
wsURL: wsUrl.toString(), wsURL: wsUrl.toString(),
authToken: this.httpClient.getToken(), authToken: this.httpClient.getToken(),
}); });
console.log("[PubSubClient] Connecting to topic:", topic);
await wsClient.connect();
// Create subscription wrapper
const subscription = new Subscription(wsClient, topic); const subscription = new Subscription(wsClient, topic);
if (handlers.onMessage) { if (handlers.onMessage) {
@ -148,33 +145,34 @@ export class PubSubClient {
if (handlers.onClose) { if (handlers.onClose) {
subscription.onClose(handlers.onClose); subscription.onClose(handlers.onClose);
} }
if (handlers.onRaw) {
subscription.onRaw(handlers.onRaw);
}
await wsClient.connect();
return subscription; return subscription;
} }
} }
/**
* Subscription represents an active WebSocket subscription to a topic
*/
export class Subscription { export class Subscription {
private wsClient: WSClient; private wsClient: WSClient;
private topic: string; private topic: string;
private messageHandlers: Set<MessageHandler> = new Set(); private messageHandlers: Set<MessageHandler> = new Set();
private errorHandlers: Set<ErrorHandler> = new Set(); private errorHandlers: Set<ErrorHandler> = new Set();
private closeHandlers: Set<CloseHandler> = new Set(); private closeHandlers: Set<CloseHandler> = new Set();
private rawHandlers: Set<RawMessageHandler> = new Set(); private isClosed = false;
private wsMessageHandler: ((data: string) => void) | null = null;
private wsErrorHandler: ((error: Error) => void) | null = null;
private wsCloseHandler: (() => void) | null = null;
constructor(wsClient: WSClient, topic: string) { constructor(wsClient: WSClient, topic: string) {
this.wsClient = wsClient; this.wsClient = wsClient;
this.topic = topic; this.topic = topic;
this.wsClient.onMessage((data) => { // Register message handler
this.wsMessageHandler = (data) => {
try { try {
// Parse gateway JSON envelope: {data: base64String, timestamp, topic} // Parse gateway JSON envelope: {data: base64String, timestamp, topic}
let envelope: RawEnvelope; const envelope: RawEnvelope = JSON.parse(data);
try {
envelope = JSON.parse(data);
// Validate envelope structure // Validate envelope structure
if (!envelope || typeof envelope !== "object") { if (!envelope || typeof envelope !== "object") {
@ -192,49 +190,16 @@ export class Subscription {
); );
} }
// Validate topic matches subscription
if (envelope.topic !== this.topic) {
console.warn(
`[Subscription] Topic mismatch: expected ${this.topic}, got ${envelope.topic}`
);
}
} catch (parseError) {
console.error("[Subscription] Failed to parse envelope:", parseError);
this.errorHandlers.forEach((handler) =>
handler(
parseError instanceof Error
? parseError
: new Error(String(parseError))
)
);
return;
}
// Call raw handlers for debugging
this.rawHandlers.forEach((handler) => handler(envelope));
// Decode base64 data // Decode base64 data
let messageData: string; const messageData = base64Decode(envelope.data);
try {
messageData = base64Decode(envelope.data);
} catch (decodeError) {
console.error("[Subscription] Base64 decode failed:", decodeError);
this.errorHandlers.forEach((handler) =>
handler(
decodeError instanceof Error
? decodeError
: new Error(String(decodeError))
)
);
return;
}
const message: Message = { const message: Message = {
topic: envelope.topic, topic: envelope.topic,
data: messageData, data: messageData,
timestamp: envelope.timestamp, timestamp: envelope.timestamp,
}; };
console.log("[Subscription] Received message:", message);
console.log("[Subscription] Received message on topic:", this.topic);
this.messageHandlers.forEach((handler) => handler(message)); this.messageHandlers.forEach((handler) => handler(message));
} catch (error) { } catch (error) {
console.error("[Subscription] Error processing message:", error); console.error("[Subscription] Error processing message:", error);
@ -242,42 +207,83 @@ export class Subscription {
handler(error instanceof Error ? error : new Error(String(error))) handler(error instanceof Error ? error : new Error(String(error)))
); );
} }
}); };
this.wsClient.onError((error) => { this.wsClient.onMessage(this.wsMessageHandler);
// Register error handler
this.wsErrorHandler = (error) => {
this.errorHandlers.forEach((handler) => handler(error)); this.errorHandlers.forEach((handler) => handler(error));
}); };
this.wsClient.onError(this.wsErrorHandler);
this.wsClient.onClose(() => { // Register close handler
this.wsCloseHandler = () => {
this.closeHandlers.forEach((handler) => handler()); this.closeHandlers.forEach((handler) => handler());
}); };
this.wsClient.onClose(this.wsCloseHandler);
} }
onMessage(handler: MessageHandler) { /**
* Register message handler
*/
onMessage(handler: MessageHandler): () => void {
this.messageHandlers.add(handler); this.messageHandlers.add(handler);
return () => this.messageHandlers.delete(handler); return () => this.messageHandlers.delete(handler);
} }
onError(handler: ErrorHandler) { /**
* Register error handler
*/
onError(handler: ErrorHandler): () => void {
this.errorHandlers.add(handler); this.errorHandlers.add(handler);
return () => this.errorHandlers.delete(handler); return () => this.errorHandlers.delete(handler);
} }
onClose(handler: CloseHandler) { /**
* Register close handler
*/
onClose(handler: CloseHandler): () => void {
this.closeHandlers.add(handler); this.closeHandlers.add(handler);
return () => this.closeHandlers.delete(handler); return () => this.closeHandlers.delete(handler);
} }
onRaw(handler: RawMessageHandler) { /**
this.rawHandlers.add(handler); * Close subscription and underlying WebSocket
return () => this.rawHandlers.delete(handler); */
close(): void {
if (this.isClosed) {
return;
}
this.isClosed = true;
// Remove handlers from WSClient
if (this.wsMessageHandler) {
this.wsClient.offMessage(this.wsMessageHandler);
this.wsMessageHandler = null;
}
if (this.wsErrorHandler) {
this.wsClient.offError(this.wsErrorHandler);
this.wsErrorHandler = null;
}
if (this.wsCloseHandler) {
this.wsClient.offClose(this.wsCloseHandler);
this.wsCloseHandler = null;
} }
close() { // Clear all local handlers
this.messageHandlers.clear();
this.errorHandlers.clear();
this.closeHandlers.clear();
// Close WebSocket connection
this.wsClient.close(); this.wsClient.close();
} }
/**
* Check if subscription is active
*/
isConnected(): boolean { isConnected(): boolean {
return this.wsClient.isConnected(); return !this.isClosed && this.wsClient.isConnected();
} }
} }