This commit is contained in:
elpresidank 2026-04-05 22:44:45 -05:00
parent c386f68743
commit b6536eca38
100 changed files with 17680 additions and 377 deletions

View file

@ -0,0 +1,172 @@
import { RequestMessage } from "../models/messages.js";
import { WS_OPEN, WS_CONNECTING, type IsomorphicWebSocket } from "./websocket-adapter.js";
// Constant defining the delay before attempting to reconnect a WebSocket
// (2 seconds)
export const SOCKET_RECONNECTION_TIMEOUT = 2000;
// Forward declare Socket type to avoid circular dependency
// Using a minimal interface that matches what BaseApi provides
interface Socket {
ws?: IsomorphicWebSocket;
inflight: { [key: string]: ServiceCallMulti };
reopen: () => void;
getNextId?: () => string;
user?: string;
}
export class ServiceCallMulti {
constructor(
mid: string,
msg: RequestMessage,
success: (resp: unknown) => void,
error: (err: object | string) => void,
timeout: number,
retries: number,
socket: Socket,
receiver: (resp: unknown) => boolean,
) {
this.mid = mid;
this.msg = msg;
this.success = success;
this.error = error;
this.timeout = timeout;
this.retries = retries;
this.socket = socket;
this.complete = false;
this.receiver = receiver;
}
mid: string;
msg: RequestMessage;
success: (resp: unknown) => void;
error: (err: object | string) => void;
receiver: (resp: unknown) => boolean;
timeoutId?: ReturnType<typeof setTimeout>;
timeout: number;
retries: number;
socket: Socket;
complete: boolean;
start() {
this.socket.inflight[this.mid] = this;
this.attempt();
}
onReceived(resp: object) {
if (this.complete == true)
console.log(this.mid, "should not happen, request is already complete");
const fin = this.receiver(resp);
if (fin) {
this.complete = true;
// console.log("Received for", this.mid);
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
delete this.socket.inflight[this.mid];
this.success(resp);
}
}
/**
* Called when socket connects - immediately retry if we were waiting
*/
retryNow() {
if (this.complete) return;
// Clear any pending backoff timer
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
// Restore retry count since we didn't actually fail
this.retries++;
// Attempt immediately
this.attempt();
}
onTimeout() {
if (this.complete == true)
console.log(
this.mid,
"timeout should not happen, request is already complete",
);
console.log("Request", this.mid, "timed out");
clearTimeout(this.timeoutId);
this.attempt();
}
attempt() {
// console.log("attempt:", this.mid);
if (this.complete == true)
console.log(
this.mid,
"attempt should not be called, request is already complete",
);
this.retries--;
if (this.retries < 0) {
console.log("Request", this.mid, "ran out of retries");
clearTimeout(this.timeoutId);
delete this.socket.inflight[this.mid];
this.error("Ran out of retries");
return; // Exit early - no more attempts
}
// Check if WebSocket connection is available and ready
if (this.socket.ws && this.socket.ws.readyState === WS_OPEN) {
try {
this.socket.ws.send(JSON.stringify(this.msg));
this.timeoutId = setTimeout(this.onTimeout.bind(this), this.timeout);
return;
} catch (e) {
console.log("Error:", e);
console.log("Message send failure, retry...");
// Calculate backoff delay with jitter
const backoffDelay = Math.min(
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) +
Math.random() * 1000,
30000, // Max 30 seconds
);
this.timeoutId = setTimeout(this.attempt.bind(this), backoffDelay);
console.log("Reopen...");
// Attempt to reopen the WebSocket connection
this.socket.reopen();
}
} else {
// No WebSocket connection available or not ready
// Check if socket is connecting
if (
this.socket.ws &&
this.socket.ws.readyState === WS_CONNECTING
) {
// Wait a bit longer for connection to establish
setTimeout(this.attempt.bind(this), 500);
} else {
// Socket is closed or closing, trigger reopen
console.log("Socket not ready, reopening...");
this.socket.reopen();
// Calculate backoff delay
const backoffDelay = Math.min(
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) +
Math.random() * 1000,
30000,
);
setTimeout(this.attempt.bind(this), backoffDelay);
}
}
}
}

View file

@ -0,0 +1,240 @@
import { RequestMessage } from "../models/messages.js";
import { WS_OPEN, type IsomorphicWebSocket } from "./websocket-adapter.js";
// Constant defining the delay before attempting to reconnect a WebSocket
// (2 seconds)
export const SOCKET_RECONNECTION_TIMEOUT = 2000;
// Forward declare Socket type to avoid circular dependency
// Using a minimal interface that matches what BaseApi provides
interface Socket {
ws?: IsomorphicWebSocket;
inflight: { [key: string]: ServiceCall };
reopen: () => void;
getNextId?: () => string;
user?: string;
}
/**
* ServiceCall represents a single request/response cycle over a WebSocket
* connection with built-in retry logic, timeout handling, and completion
* tracking.
*
* This class manages the lifecycle of a service call including:
* - Sending the initial request
* - Handling timeouts and retries
* - Managing completion state
* - Cleaning up resources
*/
export class ServiceCall {
constructor(
mid: string, // Message ID - unique identifier for this request
msg: RequestMessage, // The actual message/request to send
success: (resp: unknown) => void, // Callback function called on
// successful response
error: (err: object | string) => void, // Callback function called on error/failure
timeout: number, // Timeout duration in milliseconds
retries: number, // Number of retry attempts allowed
socket: Socket, // WebSocket instance to send the message through
) {
this.mid = mid;
this.msg = msg;
this.success = success;
this.error = error;
this.timeout = timeout;
this.retries = retries;
this.socket = socket;
this.complete = false; // Track if this request has completed
}
// Properties
mid: string; // Message identifier
msg: RequestMessage; // The request message
success: (resp: unknown) => void; // Success callback
error: (err: object | string) => void; // Error callback
timeoutId?: ReturnType<typeof setTimeout>; // Reference to the active timeout timer
timeout: number; // Timeout duration in milliseconds
retries: number; // Remaining retry attempts
socket: Socket; // WebSocket connection reference
complete: boolean; // Flag indicating if request is complete
/**
* Initiates the service call by registering it with the socket's inflight
* requests and making the first attempt to send the message
*/
start() {
// Register this request as "in-flight" so responses can be matched to it
this.socket.inflight[this.mid] = this;
// Make the first attempt to send the message
this.attempt();
}
/**
* Called when a response is received for this request
* Handles cleanup and calls the success or error callback based on response
*
* @param resp - The response object received from the server
*/
onReceived(resp: object) {
// Defensive check - this shouldn't happen but log if it does
if (this.complete == true)
console.log(this.mid, "should not happen, request is already complete");
// Mark as complete to prevent duplicate processing
this.complete = true;
// Clean up timeout timer
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
// Remove from inflight requests tracker
delete this.socket.inflight[this.mid];
// Check if the response contains an error (error can be directly in resp or nested under response)
let errorToHandle: unknown = null;
// Check for direct error in response
if (resp && typeof resp === "object" && "error" in resp) {
errorToHandle = (resp as Record<string, unknown>).error;
}
// Check for nested error under response property
else if (resp && typeof resp === "object" && "response" in resp) {
const response = (resp as Record<string, unknown>).response;
if (response && typeof response === "object" && "error" in response) {
errorToHandle = (response as Record<string, unknown>).error;
}
}
if (errorToHandle) {
// Response contains an error - call error callback
const errorObj = errorToHandle as Record<string, unknown>;
const errorMessage =
(typeof errorObj.message === "string" ? errorObj.message : null) ||
(typeof errorObj.type === "string" ? errorObj.type : null) ||
"Unknown error";
console.log(
"ServiceCall: API error detected in response:",
errorMessage,
"Full error:",
errorToHandle,
);
this.error(new Error(errorMessage));
return;
}
// Extract the response field from the message object
// The resp parameter is the full message: {id, response, complete}
// We need to pass just the response field to the success callback
const responseData = (resp as { response?: unknown }).response;
this.success(responseData);
}
/**
* Called when socket connects - immediately retry if we were waiting
*/
retryNow() {
if (this.complete) return;
// Clear any pending backoff timer
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
// Restore retry count since we didn't actually fail
this.retries++;
// Attempt immediately
this.attempt();
}
/**
* Called when the request times out
* Triggers another attempt if retries are available
*/
onTimeout() {
// Defensive check - this shouldn't happen but log if it does
if (this.complete == true)
console.log(
this.mid,
"timeout should not happen, request is already complete",
);
console.log("Request", this.mid, "timed out");
// Clear the current timeout
clearTimeout(this.timeoutId);
// Try again (this will check retry count)
this.attempt();
}
/**
* Calculates exponential backoff delay with jitter
* @returns backoff delay in milliseconds
*/
calculateBackoff() {
return Math.min(
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) +
Math.random() * 1000,
30000, // Max 30 seconds
);
}
/**
* Core retry logic - attempts to send the message over the WebSocket
* Handles retries and waits for BaseApi to handle reconnection
*/
attempt() {
// Defensive check - this shouldn't be called on completed requests
if (this.complete == true)
console.log(
this.mid,
"attempt should not be called, request is already complete",
);
// Decrement retry counter
this.retries--;
// Check if we've exhausted all retries
if (this.retries < 0) {
console.log("Request", this.mid, "ran out of retries");
// Clean up and call error callback
clearTimeout(this.timeoutId);
delete this.socket.inflight[this.mid];
this.error("Ran out of retries");
return; // Exit early - no more attempts
}
// Check if WebSocket connection is available and ready
if (this.socket.ws && this.socket.ws.readyState === WS_OPEN) {
try {
// Attempt to send the message as JSON
this.socket.ws.send(JSON.stringify(this.msg));
// Set up timeout for this attempt
this.timeoutId = setTimeout(this.onTimeout.bind(this), this.timeout);
return; // Success - message sent, waiting for response or timeout
} catch (e) {
// Handle send failure - wait for BaseApi to handle reconnection
console.log("Error:", e);
console.log(
"Message send failure, waiting for socket reconnection...",
);
// Schedule retry with backoff - let BaseApi handle the reconnection
this.timeoutId = setTimeout(
this.attempt.bind(this),
this.calculateBackoff(),
);
}
} else {
// No WebSocket connection available or not ready
// Let BaseApi handle reconnection, just wait and retry
console.log("Request", this.mid, "waiting for socket reconnection...");
// Use consistent backoff for all waiting scenarios
setTimeout(this.attempt.bind(this), this.calculateBackoff());
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,133 @@
/**
* Isomorphic WebSocket adapter for browser and Node.js environments.
*
* In browsers, uses the native globalThis.WebSocket.
* In Node.js, dynamically requires the 'ws' package.
*
* Provides its own minimal type definitions for the WebSocket API surface
* we actually use, so the package does not require DOM lib types.
*/
// ---------------------------------------------------------------------------
// WebSocket readyState constants (identical in browser WebSocket and 'ws')
// ---------------------------------------------------------------------------
export const WS_CONNECTING = 0;
export const WS_OPEN = 1;
export const WS_CLOSING = 2;
export const WS_CLOSED = 3;
// ---------------------------------------------------------------------------
// Minimal WebSocket type surface used by this package
// ---------------------------------------------------------------------------
/** Minimal event type compatible with both browser Event and ws events. */
export interface WsEvent {
type: string;
[key: string]: unknown;
}
/** Minimal MessageEvent-compatible shape. */
export interface WsMessageEvent {
data: unknown;
type: string;
[key: string]: unknown;
}
/** Minimal CloseEvent-compatible shape. */
export interface WsCloseEvent {
code: number;
reason: string;
wasClean: boolean;
type: string;
[key: string]: unknown;
}
/**
* Minimal interface covering the WebSocket instance methods and properties
* used by this package. Compatible with both browser `WebSocket` and the
* `ws` npm package.
*/
export interface IsomorphicWebSocket {
readonly readyState: number;
send(data: string): void;
close(code?: number, reason?: string): void;
addEventListener(type: "message", listener: (event: WsMessageEvent) => void): void;
addEventListener(type: "close", listener: (event: WsCloseEvent) => void): void;
addEventListener(type: "open", listener: (event: WsEvent) => void): void;
addEventListener(type: "error", listener: (event: WsEvent) => void): void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
removeEventListener(type: string, listener: (...args: any[]) => void): void;
}
/** Constructor signature for an isomorphic WebSocket implementation. */
export interface IsomorphicWebSocketConstructor {
new (url: string): IsomorphicWebSocket;
}
// ---------------------------------------------------------------------------
// Runtime helpers
// ---------------------------------------------------------------------------
/**
* Returns the WebSocket constructor appropriate for the current environment.
*
* - Browser: uses `globalThis.WebSocket` (native)
* - Node.js: dynamically `require`s the `ws` npm package
*
* @throws Error if no WebSocket implementation is available
*/
export function getWebSocketConstructor(): IsomorphicWebSocketConstructor {
// Browser environment (or Deno, Bun, etc. where WebSocket is global)
if (typeof globalThis !== "undefined" && "WebSocket" in globalThis) {
return (globalThis as unknown as { WebSocket: IsomorphicWebSocketConstructor }).WebSocket;
}
// Node.js environment — dynamically require 'ws'
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const ws = require("ws");
return ws as IsomorphicWebSocketConstructor;
} catch {
throw new Error(
'WebSocket is not available. In Node.js, install the "ws" package: npm install ws',
);
}
}
/**
* Returns the default WebSocket URL for the current environment.
*
* - Browser: returns the relative path `"/api/socket"` (resolved by the
* browser against the current page origin).
* - Node.js: returns a full URL `"ws://localhost:8088/api/v1/socket"` since
* relative URLs are not meaningful outside a browser.
*/
export function getDefaultSocketUrl(): string {
if (typeof window !== "undefined") {
return "/api/socket";
}
return "ws://localhost:8088/api/v1/socket";
}
/**
* Isomorphic `getRandomValues` that works in both browser and Node.js.
*
* - Browser / Node.js 19+: uses `globalThis.crypto.getRandomValues`
* - Older Node.js: falls back to `node:crypto.randomFillSync`
*/
export function getRandomValues(array: Uint32Array): Uint32Array {
if (typeof globalThis.crypto?.getRandomValues === "function") {
return globalThis.crypto.getRandomValues(array);
}
// Node.js fallback for versions < 19 where globalThis.crypto may not exist
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { randomFillSync } = require("node:crypto");
return randomFillSync(array) as Uint32Array;
} catch {
throw new Error(
"No cryptographic random source available. " +
"Upgrade to Node.js 19+ or ensure the 'crypto' module is available.",
);
}
}