98 lines
3.4 KiB
TypeScript
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 }
|
|
}
|