mission-control/src/lib/websocket-utils.ts

98 lines
3.4 KiB
TypeScript

/**
* Pure utility functions extracted from the WebSocket module for testability.
*/
/** Known gateway connect error detail codes (structured error codes sent by newer gateways) */
export const ConnectErrorDetailCodes = {
AUTH_TOKEN_MISSING: 'AUTH_TOKEN_MISSING',
AUTH_PASSWORD_MISSING: 'AUTH_PASSWORD_MISSING',
AUTH_PASSWORD_MISMATCH: 'AUTH_PASSWORD_MISMATCH',
AUTH_RATE_LIMITED: 'AUTH_RATE_LIMITED',
AUTH_TOKEN_MISMATCH: 'AUTH_TOKEN_MISMATCH',
ORIGIN_NOT_ALLOWED: 'ORIGIN_NOT_ALLOWED',
DEVICE_SIGNATURE_INVALID: 'DEVICE_SIGNATURE_INVALID',
} as const
/** Error detail shape from gateway frames. */
export interface GatewayErrorDetail {
message?: string
code?: string
details?: { code?: string; [key: string]: any }
[key: string]: any
}
/** Extract structured error code from a gateway error frame, if present. */
export function readErrorDetailCode(error: GatewayErrorDetail | null | undefined): string | null {
if (!error || typeof error !== 'object') return null
// Newer gateways include a structured details object with a code field
const detailsCode = error.details?.code
if (typeof detailsCode === 'string' && detailsCode.length > 0) return detailsCode
// Some frames carry code at the top level
const topCode = error.code
if (typeof topCode === 'string' && topCode.length > 0) return topCode
return null
}
/** Error codes that should never trigger auto-reconnect. */
export const NON_RETRYABLE_ERROR_CODES = new Set<string>([
ConnectErrorDetailCodes.AUTH_TOKEN_MISSING,
ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING,
ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH,
ConnectErrorDetailCodes.AUTH_RATE_LIMITED,
ConnectErrorDetailCodes.ORIGIN_NOT_ALLOWED,
ConnectErrorDetailCodes.DEVICE_SIGNATURE_INVALID,
])
/** Check whether a given error code is non-retryable. */
export function isNonRetryableErrorCode(code: string): boolean {
return NON_RETRYABLE_ERROR_CODES.has(code)
}
/**
* Retry once without browser device identity when a valid gateway auth token
* exists but the browser's cached device credentials appear invalid.
*/
export function shouldRetryWithoutDeviceIdentity(
message: string,
error: GatewayErrorDetail | null | undefined,
hasAuthToken: boolean,
alreadyRetried: boolean,
): boolean {
if (!hasAuthToken || alreadyRetried) return false
const code = readErrorDetailCode(error)
if (code === ConnectErrorDetailCodes.DEVICE_SIGNATURE_INVALID) return true
const normalized = message.toLowerCase()
return (
normalized.includes('device_auth_signature_invalid') ||
normalized.includes('device signature invalid') ||
normalized.includes('invalid device token') ||
normalized.includes('device token invalid')
)
}
/**
* Calculate exponential backoff delay for reconnect attempts.
* Uses base * 1.7^attempt, capped at 15000ms.
* Returns only the deterministic base (without jitter) for testability.
*/
export function calculateBackoff(attempt: number): number {
return Math.min(1000 * Math.pow(1.7, attempt), 15000)
}
/**
* Detect a gap in event sequence numbers.
* Returns info about the gap, or null if there is no gap.
*/
export function detectSequenceGap(
lastSeq: number | null,
currentSeq: number,
): { from: number; to: number; count: number } | null {
if (lastSeq === null) return null
if (currentSeq <= lastSeq + 1) return null
const from = lastSeq + 1
const to = currentSeq - 1
return { from, to, count: to - from + 1 }
}