mirror of https://github.com/openclaw/openclaw.git
753 lines
25 KiB
TypeScript
753 lines
25 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { WebSocket, type ClientOptions, type CertMeta } from "ws";
|
|
import {
|
|
clearDeviceAuthToken,
|
|
loadDeviceAuthToken,
|
|
storeDeviceAuthToken,
|
|
} from "../infra/device-auth-store.js";
|
|
import type { DeviceIdentity } from "../infra/device-identity.js";
|
|
import {
|
|
loadOrCreateDeviceIdentity,
|
|
publicKeyRawBase64UrlFromPem,
|
|
signDevicePayload,
|
|
} from "../infra/device-identity.js";
|
|
import { normalizeFingerprint } from "../infra/tls/fingerprint.js";
|
|
import { rawDataToString } from "../infra/ws.js";
|
|
import { logDebug, logError } from "../logger.js";
|
|
import {
|
|
GATEWAY_CLIENT_MODES,
|
|
GATEWAY_CLIENT_NAMES,
|
|
type GatewayClientMode,
|
|
type GatewayClientName,
|
|
} from "../utils/message-channel.js";
|
|
import { VERSION } from "../version.js";
|
|
import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
|
|
import { isLoopbackHost, isSecureWebSocketUrl } from "./net.js";
|
|
import {
|
|
ConnectErrorDetailCodes,
|
|
readConnectErrorDetailCode,
|
|
readConnectErrorRecoveryAdvice,
|
|
type ConnectErrorRecoveryAdvice,
|
|
} from "./protocol/connect-error-details.js";
|
|
import {
|
|
type ConnectParams,
|
|
type EventFrame,
|
|
type HelloOk,
|
|
PROTOCOL_VERSION,
|
|
type RequestFrame,
|
|
validateEventFrame,
|
|
validateRequestFrame,
|
|
validateResponseFrame,
|
|
} from "./protocol/index.js";
|
|
|
|
type Pending = {
|
|
resolve: (value: unknown) => void;
|
|
reject: (err: unknown) => void;
|
|
expectFinal: boolean;
|
|
timeout: NodeJS.Timeout | null;
|
|
};
|
|
|
|
type GatewayClientErrorShape = {
|
|
code?: string;
|
|
message?: string;
|
|
details?: unknown;
|
|
};
|
|
|
|
type SelectedConnectAuth = {
|
|
authToken?: string;
|
|
authBootstrapToken?: string;
|
|
authDeviceToken?: string;
|
|
authPassword?: string;
|
|
signatureToken?: string;
|
|
resolvedDeviceToken?: string;
|
|
storedToken?: string;
|
|
};
|
|
|
|
class GatewayClientRequestError extends Error {
|
|
readonly gatewayCode: string;
|
|
readonly details?: unknown;
|
|
|
|
constructor(error: GatewayClientErrorShape) {
|
|
super(error.message ?? "gateway request failed");
|
|
this.name = "GatewayClientRequestError";
|
|
this.gatewayCode = error.code ?? "UNAVAILABLE";
|
|
this.details = error.details;
|
|
}
|
|
}
|
|
|
|
export type GatewayClientOptions = {
|
|
url?: string; // ws://127.0.0.1:18789
|
|
connectDelayMs?: number;
|
|
tickWatchMinIntervalMs?: number;
|
|
requestTimeoutMs?: number;
|
|
token?: string;
|
|
bootstrapToken?: string;
|
|
deviceToken?: string;
|
|
password?: string;
|
|
instanceId?: string;
|
|
clientName?: GatewayClientName;
|
|
clientDisplayName?: string;
|
|
clientVersion?: string;
|
|
platform?: string;
|
|
deviceFamily?: string;
|
|
mode?: GatewayClientMode;
|
|
role?: string;
|
|
scopes?: string[];
|
|
caps?: string[];
|
|
commands?: string[];
|
|
permissions?: Record<string, boolean>;
|
|
pathEnv?: string;
|
|
deviceIdentity?: DeviceIdentity | null;
|
|
minProtocol?: number;
|
|
maxProtocol?: number;
|
|
tlsFingerprint?: string;
|
|
onEvent?: (evt: EventFrame) => void;
|
|
onHelloOk?: (hello: HelloOk) => void;
|
|
onConnectError?: (err: Error) => void;
|
|
onClose?: (code: number, reason: string) => void;
|
|
onGap?: (info: { expected: number; received: number }) => void;
|
|
};
|
|
|
|
export const GATEWAY_CLOSE_CODE_HINTS: Readonly<Record<number, string>> = {
|
|
1000: "normal closure",
|
|
1006: "abnormal closure (no close frame)",
|
|
1008: "policy violation",
|
|
1012: "service restart",
|
|
};
|
|
|
|
export function describeGatewayCloseCode(code: number): string | undefined {
|
|
return GATEWAY_CLOSE_CODE_HINTS[code];
|
|
}
|
|
|
|
const FORCE_STOP_TERMINATE_GRACE_MS = 250;
|
|
|
|
export class GatewayClient {
|
|
private ws: WebSocket | null = null;
|
|
private opts: GatewayClientOptions;
|
|
private pending = new Map<string, Pending>();
|
|
private backoffMs = 1000;
|
|
private closed = false;
|
|
private lastSeq: number | null = null;
|
|
private connectNonce: string | null = null;
|
|
private connectSent = false;
|
|
private connectTimer: NodeJS.Timeout | null = null;
|
|
private pendingDeviceTokenRetry = false;
|
|
private deviceTokenRetryBudgetUsed = false;
|
|
private pendingConnectErrorDetailCode: string | null = null;
|
|
// Track last tick to detect silent stalls.
|
|
private lastTick: number | null = null;
|
|
private tickIntervalMs = 30_000;
|
|
private tickTimer: NodeJS.Timeout | null = null;
|
|
private readonly requestTimeoutMs: number;
|
|
|
|
constructor(opts: GatewayClientOptions) {
|
|
this.opts = {
|
|
...opts,
|
|
deviceIdentity:
|
|
opts.deviceIdentity === null
|
|
? undefined
|
|
: (opts.deviceIdentity ?? loadOrCreateDeviceIdentity()),
|
|
};
|
|
this.requestTimeoutMs =
|
|
typeof opts.requestTimeoutMs === "number" && Number.isFinite(opts.requestTimeoutMs)
|
|
? Math.max(1, Math.min(Math.floor(opts.requestTimeoutMs), 2_147_483_647))
|
|
: 30_000;
|
|
}
|
|
|
|
start() {
|
|
if (this.closed) {
|
|
return;
|
|
}
|
|
const url = this.opts.url ?? "ws://127.0.0.1:18789";
|
|
if (this.opts.tlsFingerprint && !url.startsWith("wss://")) {
|
|
this.opts.onConnectError?.(new Error("gateway tls fingerprint requires wss:// gateway url"));
|
|
return;
|
|
}
|
|
|
|
const allowPrivateWs = process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1";
|
|
// Security check: block ALL plaintext ws:// to non-loopback addresses (CWE-319, CVSS 9.8)
|
|
// This protects both credentials AND chat/conversation data from MITM attacks.
|
|
// Device tokens may be loaded later in sendConnect(), so we block regardless of hasCredentials.
|
|
if (!isSecureWebSocketUrl(url, { allowPrivateWs })) {
|
|
// Safe hostname extraction - avoid throwing on malformed URLs in error path
|
|
let displayHost = url;
|
|
try {
|
|
displayHost = new URL(url).hostname || url;
|
|
} catch {
|
|
// Use raw URL if parsing fails
|
|
}
|
|
const error = new Error(
|
|
`SECURITY ERROR: Cannot connect to "${displayHost}" over plaintext ws://. ` +
|
|
"Both credentials and chat data would be exposed to network interception. " +
|
|
"Use wss:// for remote URLs. Safe defaults: keep gateway.bind=loopback and connect via SSH tunnel " +
|
|
"(ssh -N -L 18789:127.0.0.1:18789 user@gateway-host), or use Tailscale Serve/Funnel. " +
|
|
(allowPrivateWs
|
|
? ""
|
|
: "Break-glass (trusted private networks only): set OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1. ") +
|
|
"Run `openclaw doctor --fix` for guidance.",
|
|
);
|
|
this.opts.onConnectError?.(error);
|
|
return;
|
|
}
|
|
// Allow node screen snapshots and other large responses.
|
|
const wsOptions: ClientOptions = {
|
|
maxPayload: 25 * 1024 * 1024,
|
|
};
|
|
if (url.startsWith("wss://") && this.opts.tlsFingerprint) {
|
|
wsOptions.rejectUnauthorized = false;
|
|
wsOptions.checkServerIdentity = ((_host: string, cert: CertMeta) => {
|
|
const fingerprintValue =
|
|
typeof cert === "object" && cert && "fingerprint256" in cert
|
|
? ((cert as { fingerprint256?: string }).fingerprint256 ?? "")
|
|
: "";
|
|
const fingerprint = normalizeFingerprint(
|
|
typeof fingerprintValue === "string" ? fingerprintValue : "",
|
|
);
|
|
const expected = normalizeFingerprint(this.opts.tlsFingerprint ?? "");
|
|
if (!expected) {
|
|
return new Error("gateway tls fingerprint missing");
|
|
}
|
|
if (!fingerprint) {
|
|
return new Error("gateway tls fingerprint unavailable");
|
|
}
|
|
if (fingerprint !== expected) {
|
|
return new Error("gateway tls fingerprint mismatch");
|
|
}
|
|
return undefined;
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
}) as any;
|
|
}
|
|
this.ws = new WebSocket(url, wsOptions);
|
|
|
|
this.ws.on("open", () => {
|
|
if (url.startsWith("wss://") && this.opts.tlsFingerprint) {
|
|
const tlsError = this.validateTlsFingerprint();
|
|
if (tlsError) {
|
|
this.opts.onConnectError?.(tlsError);
|
|
this.ws?.close(1008, tlsError.message);
|
|
return;
|
|
}
|
|
}
|
|
this.queueConnect();
|
|
});
|
|
this.ws.on("message", (data) => this.handleMessage(rawDataToString(data)));
|
|
this.ws.on("close", (code, reason) => {
|
|
const reasonText = rawDataToString(reason);
|
|
const connectErrorDetailCode = this.pendingConnectErrorDetailCode;
|
|
this.pendingConnectErrorDetailCode = null;
|
|
this.ws = null;
|
|
// Clear persisted device auth state only when device-token auth was active.
|
|
// Shared token/password failures can return the same close reason but should
|
|
// not erase a valid cached device token.
|
|
if (
|
|
code === 1008 &&
|
|
reasonText.toLowerCase().includes("device token mismatch") &&
|
|
!this.opts.token &&
|
|
!this.opts.password &&
|
|
this.opts.deviceIdentity
|
|
) {
|
|
const deviceId = this.opts.deviceIdentity.deviceId;
|
|
const role = this.opts.role ?? "operator";
|
|
try {
|
|
clearDeviceAuthToken({ deviceId, role });
|
|
logDebug(`cleared stale device-auth token for device ${deviceId}`);
|
|
} catch (err) {
|
|
logDebug(
|
|
`failed clearing stale device-auth token for device ${deviceId}: ${String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
this.flushPendingErrors(new Error(`gateway closed (${code}): ${reasonText}`));
|
|
if (this.shouldPauseReconnectAfterAuthFailure(connectErrorDetailCode)) {
|
|
this.opts.onClose?.(code, reasonText);
|
|
return;
|
|
}
|
|
this.scheduleReconnect();
|
|
this.opts.onClose?.(code, reasonText);
|
|
});
|
|
this.ws.on("error", (err) => {
|
|
logDebug(`gateway client error: ${String(err)}`);
|
|
if (!this.connectSent) {
|
|
this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err)));
|
|
}
|
|
});
|
|
}
|
|
|
|
stop() {
|
|
this.closed = true;
|
|
this.pendingDeviceTokenRetry = false;
|
|
this.deviceTokenRetryBudgetUsed = false;
|
|
this.pendingConnectErrorDetailCode = null;
|
|
if (this.tickTimer) {
|
|
clearInterval(this.tickTimer);
|
|
this.tickTimer = null;
|
|
}
|
|
const ws = this.ws;
|
|
this.ws = null;
|
|
if (ws) {
|
|
ws.close();
|
|
const forceTerminateTimer = setTimeout(() => {
|
|
try {
|
|
ws.terminate();
|
|
} catch {}
|
|
}, FORCE_STOP_TERMINATE_GRACE_MS);
|
|
forceTerminateTimer.unref?.();
|
|
}
|
|
this.flushPendingErrors(new Error("gateway client stopped"));
|
|
}
|
|
|
|
private sendConnect() {
|
|
if (this.connectSent) {
|
|
return;
|
|
}
|
|
const nonce = this.connectNonce?.trim() ?? "";
|
|
if (!nonce) {
|
|
this.opts.onConnectError?.(new Error("gateway connect challenge missing nonce"));
|
|
this.ws?.close(1008, "connect challenge missing nonce");
|
|
return;
|
|
}
|
|
this.connectSent = true;
|
|
if (this.connectTimer) {
|
|
clearTimeout(this.connectTimer);
|
|
this.connectTimer = null;
|
|
}
|
|
const role = this.opts.role ?? "operator";
|
|
const {
|
|
authToken,
|
|
authBootstrapToken,
|
|
authDeviceToken,
|
|
authPassword,
|
|
signatureToken,
|
|
resolvedDeviceToken,
|
|
storedToken,
|
|
} = this.selectConnectAuth(role);
|
|
if (this.pendingDeviceTokenRetry && authDeviceToken) {
|
|
this.pendingDeviceTokenRetry = false;
|
|
}
|
|
const auth =
|
|
authToken || authBootstrapToken || authPassword || resolvedDeviceToken
|
|
? {
|
|
token: authToken,
|
|
bootstrapToken: authBootstrapToken,
|
|
deviceToken: authDeviceToken ?? resolvedDeviceToken,
|
|
password: authPassword,
|
|
}
|
|
: undefined;
|
|
const signedAtMs = Date.now();
|
|
const scopes = this.opts.scopes ?? ["operator.admin"];
|
|
const platform = this.opts.platform ?? process.platform;
|
|
const device = (() => {
|
|
if (!this.opts.deviceIdentity) {
|
|
return undefined;
|
|
}
|
|
const payload = buildDeviceAuthPayloadV3({
|
|
deviceId: this.opts.deviceIdentity.deviceId,
|
|
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
|
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
|
|
role,
|
|
scopes,
|
|
signedAtMs,
|
|
token: signatureToken ?? null,
|
|
nonce,
|
|
platform,
|
|
deviceFamily: this.opts.deviceFamily,
|
|
});
|
|
const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload);
|
|
return {
|
|
id: this.opts.deviceIdentity.deviceId,
|
|
publicKey: publicKeyRawBase64UrlFromPem(this.opts.deviceIdentity.publicKeyPem),
|
|
signature,
|
|
signedAt: signedAtMs,
|
|
nonce,
|
|
};
|
|
})();
|
|
const params: ConnectParams = {
|
|
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
|
|
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
|
|
client: {
|
|
id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
|
displayName: this.opts.clientDisplayName,
|
|
version: this.opts.clientVersion ?? VERSION,
|
|
platform,
|
|
deviceFamily: this.opts.deviceFamily,
|
|
mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
|
|
instanceId: this.opts.instanceId,
|
|
},
|
|
caps: Array.isArray(this.opts.caps) ? this.opts.caps : [],
|
|
commands: Array.isArray(this.opts.commands) ? this.opts.commands : undefined,
|
|
permissions:
|
|
this.opts.permissions && typeof this.opts.permissions === "object"
|
|
? this.opts.permissions
|
|
: undefined,
|
|
pathEnv: this.opts.pathEnv,
|
|
auth,
|
|
role,
|
|
scopes,
|
|
device,
|
|
};
|
|
|
|
void this.request<HelloOk>("connect", params)
|
|
.then((helloOk) => {
|
|
this.pendingDeviceTokenRetry = false;
|
|
this.deviceTokenRetryBudgetUsed = false;
|
|
this.pendingConnectErrorDetailCode = null;
|
|
const authInfo = helloOk?.auth;
|
|
if (authInfo?.deviceToken && this.opts.deviceIdentity) {
|
|
storeDeviceAuthToken({
|
|
deviceId: this.opts.deviceIdentity.deviceId,
|
|
role: authInfo.role ?? role,
|
|
token: authInfo.deviceToken,
|
|
scopes: authInfo.scopes ?? [],
|
|
});
|
|
}
|
|
this.backoffMs = 1000;
|
|
this.tickIntervalMs =
|
|
typeof helloOk.policy?.tickIntervalMs === "number"
|
|
? helloOk.policy.tickIntervalMs
|
|
: 30_000;
|
|
this.lastTick = Date.now();
|
|
this.startTickWatch();
|
|
this.opts.onHelloOk?.(helloOk);
|
|
})
|
|
.catch((err) => {
|
|
this.pendingConnectErrorDetailCode =
|
|
err instanceof GatewayClientRequestError ? readConnectErrorDetailCode(err.details) : null;
|
|
const shouldRetryWithDeviceToken = this.shouldRetryWithStoredDeviceToken({
|
|
error: err,
|
|
explicitGatewayToken: this.opts.token?.trim() || undefined,
|
|
resolvedDeviceToken,
|
|
storedToken: storedToken ?? undefined,
|
|
});
|
|
if (shouldRetryWithDeviceToken) {
|
|
this.pendingDeviceTokenRetry = true;
|
|
this.deviceTokenRetryBudgetUsed = true;
|
|
this.backoffMs = Math.min(this.backoffMs, 250);
|
|
}
|
|
this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err)));
|
|
const msg = `gateway connect failed: ${String(err)}`;
|
|
if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE) {
|
|
logDebug(msg);
|
|
} else {
|
|
logError(msg);
|
|
}
|
|
this.ws?.close(1008, "connect failed");
|
|
});
|
|
}
|
|
|
|
private shouldPauseReconnectAfterAuthFailure(detailCode: string | null): boolean {
|
|
if (!detailCode) {
|
|
return false;
|
|
}
|
|
if (
|
|
detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
|
|
detailCode === ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID ||
|
|
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
|
|
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
|
|
detailCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
|
|
detailCode === ConnectErrorDetailCodes.PAIRING_REQUIRED ||
|
|
detailCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
|
|
detailCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED
|
|
) {
|
|
return true;
|
|
}
|
|
if (detailCode !== ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH) {
|
|
return false;
|
|
}
|
|
if (this.pendingDeviceTokenRetry) {
|
|
return false;
|
|
}
|
|
// If the endpoint is not trusted for retry, mismatch is terminal until operator action.
|
|
if (!this.isTrustedDeviceRetryEndpoint()) {
|
|
return true;
|
|
}
|
|
// Pause mismatch reconnect loops once the one-shot device-token retry is consumed.
|
|
return this.deviceTokenRetryBudgetUsed;
|
|
}
|
|
|
|
private shouldRetryWithStoredDeviceToken(params: {
|
|
error: unknown;
|
|
explicitGatewayToken?: string;
|
|
storedToken?: string;
|
|
resolvedDeviceToken?: string;
|
|
}): boolean {
|
|
if (this.deviceTokenRetryBudgetUsed) {
|
|
return false;
|
|
}
|
|
if (params.resolvedDeviceToken) {
|
|
return false;
|
|
}
|
|
if (!params.explicitGatewayToken || !params.storedToken) {
|
|
return false;
|
|
}
|
|
if (!this.isTrustedDeviceRetryEndpoint()) {
|
|
return false;
|
|
}
|
|
if (!(params.error instanceof GatewayClientRequestError)) {
|
|
return false;
|
|
}
|
|
const detailCode = readConnectErrorDetailCode(params.error.details);
|
|
const advice: ConnectErrorRecoveryAdvice = readConnectErrorRecoveryAdvice(params.error.details);
|
|
const retryWithDeviceTokenRecommended =
|
|
advice.recommendedNextStep === "retry_with_device_token";
|
|
return (
|
|
advice.canRetryWithDeviceToken === true ||
|
|
retryWithDeviceTokenRecommended ||
|
|
detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH
|
|
);
|
|
}
|
|
|
|
private isTrustedDeviceRetryEndpoint(): boolean {
|
|
const rawUrl = this.opts.url ?? "ws://127.0.0.1:18789";
|
|
try {
|
|
const parsed = new URL(rawUrl);
|
|
const protocol =
|
|
parsed.protocol === "https:"
|
|
? "wss:"
|
|
: parsed.protocol === "http:"
|
|
? "ws:"
|
|
: parsed.protocol;
|
|
if (isLoopbackHost(parsed.hostname)) {
|
|
return true;
|
|
}
|
|
return protocol === "wss:" && Boolean(this.opts.tlsFingerprint?.trim());
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private selectConnectAuth(role: string): SelectedConnectAuth {
|
|
const explicitGatewayToken = this.opts.token?.trim() || undefined;
|
|
const explicitBootstrapToken = this.opts.bootstrapToken?.trim() || undefined;
|
|
const explicitDeviceToken = this.opts.deviceToken?.trim() || undefined;
|
|
const authPassword = this.opts.password?.trim() || undefined;
|
|
const storedToken = this.opts.deviceIdentity
|
|
? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token
|
|
: null;
|
|
const shouldUseDeviceRetryToken =
|
|
this.pendingDeviceTokenRetry &&
|
|
!explicitDeviceToken &&
|
|
Boolean(explicitGatewayToken) &&
|
|
Boolean(storedToken) &&
|
|
this.isTrustedDeviceRetryEndpoint();
|
|
const resolvedDeviceToken =
|
|
explicitDeviceToken ??
|
|
(shouldUseDeviceRetryToken ||
|
|
(!(explicitGatewayToken || authPassword) && (!explicitBootstrapToken || Boolean(storedToken)))
|
|
? (storedToken ?? undefined)
|
|
: undefined);
|
|
// Legacy compatibility: keep `auth.token` populated for device-token auth when
|
|
// no explicit shared token is present.
|
|
const authToken = explicitGatewayToken ?? resolvedDeviceToken;
|
|
const authBootstrapToken =
|
|
!explicitGatewayToken && !resolvedDeviceToken ? explicitBootstrapToken : undefined;
|
|
return {
|
|
authToken,
|
|
authBootstrapToken,
|
|
authDeviceToken: shouldUseDeviceRetryToken ? (storedToken ?? undefined) : undefined,
|
|
authPassword,
|
|
signatureToken: authToken ?? authBootstrapToken ?? undefined,
|
|
resolvedDeviceToken,
|
|
storedToken: storedToken ?? undefined,
|
|
};
|
|
}
|
|
|
|
private handleMessage(raw: string) {
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (validateEventFrame(parsed)) {
|
|
const evt = parsed;
|
|
if (evt.event === "connect.challenge") {
|
|
const payload = evt.payload as { nonce?: unknown } | undefined;
|
|
const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
|
|
if (!nonce || nonce.trim().length === 0) {
|
|
this.opts.onConnectError?.(new Error("gateway connect challenge missing nonce"));
|
|
this.ws?.close(1008, "connect challenge missing nonce");
|
|
return;
|
|
}
|
|
this.connectNonce = nonce.trim();
|
|
this.sendConnect();
|
|
return;
|
|
}
|
|
const seq = typeof evt.seq === "number" ? evt.seq : null;
|
|
if (seq !== null) {
|
|
if (this.lastSeq !== null && seq > this.lastSeq + 1) {
|
|
this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq });
|
|
}
|
|
this.lastSeq = seq;
|
|
}
|
|
if (evt.event === "tick") {
|
|
this.lastTick = Date.now();
|
|
}
|
|
this.opts.onEvent?.(evt);
|
|
return;
|
|
}
|
|
if (validateResponseFrame(parsed)) {
|
|
const pending = this.pending.get(parsed.id);
|
|
if (!pending) {
|
|
return;
|
|
}
|
|
// If the payload is an ack with status accepted, keep waiting for final.
|
|
const payload = parsed.payload as { status?: unknown } | undefined;
|
|
const status = payload?.status;
|
|
if (pending.expectFinal && status === "accepted") {
|
|
return;
|
|
}
|
|
this.pending.delete(parsed.id);
|
|
if (pending.timeout) {
|
|
clearTimeout(pending.timeout);
|
|
}
|
|
if (parsed.ok) {
|
|
pending.resolve(parsed.payload);
|
|
} else {
|
|
pending.reject(
|
|
new GatewayClientRequestError({
|
|
code: parsed.error?.code,
|
|
message: parsed.error?.message ?? "unknown error",
|
|
details: parsed.error?.details,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
logDebug(`gateway client parse error: ${String(err)}`);
|
|
}
|
|
}
|
|
|
|
private queueConnect() {
|
|
this.connectNonce = null;
|
|
this.connectSent = false;
|
|
const rawConnectDelayMs = this.opts.connectDelayMs;
|
|
const connectChallengeTimeoutMs =
|
|
typeof rawConnectDelayMs === "number" && Number.isFinite(rawConnectDelayMs)
|
|
? Math.max(250, Math.min(10_000, rawConnectDelayMs))
|
|
: 2_000;
|
|
if (this.connectTimer) {
|
|
clearTimeout(this.connectTimer);
|
|
}
|
|
this.connectTimer = setTimeout(() => {
|
|
if (this.connectSent || this.ws?.readyState !== WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
this.opts.onConnectError?.(new Error("gateway connect challenge timeout"));
|
|
this.ws?.close(1008, "connect challenge timeout");
|
|
}, connectChallengeTimeoutMs);
|
|
}
|
|
|
|
private scheduleReconnect() {
|
|
if (this.closed) {
|
|
return;
|
|
}
|
|
if (this.tickTimer) {
|
|
clearInterval(this.tickTimer);
|
|
this.tickTimer = null;
|
|
}
|
|
const delay = this.backoffMs;
|
|
this.backoffMs = Math.min(this.backoffMs * 2, 30_000);
|
|
setTimeout(() => this.start(), delay).unref();
|
|
}
|
|
|
|
private flushPendingErrors(err: Error) {
|
|
for (const [, p] of this.pending) {
|
|
if (p.timeout) {
|
|
clearTimeout(p.timeout);
|
|
}
|
|
p.reject(err);
|
|
}
|
|
this.pending.clear();
|
|
}
|
|
|
|
private startTickWatch() {
|
|
if (this.tickTimer) {
|
|
clearInterval(this.tickTimer);
|
|
}
|
|
const rawMinInterval = this.opts.tickWatchMinIntervalMs;
|
|
const minInterval =
|
|
typeof rawMinInterval === "number" && Number.isFinite(rawMinInterval)
|
|
? Math.max(1, Math.min(30_000, rawMinInterval))
|
|
: 1000;
|
|
const interval = Math.max(this.tickIntervalMs, minInterval);
|
|
this.tickTimer = setInterval(() => {
|
|
if (this.closed) {
|
|
return;
|
|
}
|
|
if (!this.lastTick) {
|
|
return;
|
|
}
|
|
const gap = Date.now() - this.lastTick;
|
|
if (gap > this.tickIntervalMs * 2) {
|
|
this.ws?.close(4000, "tick timeout");
|
|
}
|
|
}, interval);
|
|
}
|
|
|
|
private validateTlsFingerprint(): Error | null {
|
|
if (!this.opts.tlsFingerprint || !this.ws) {
|
|
return null;
|
|
}
|
|
const expected = normalizeFingerprint(this.opts.tlsFingerprint);
|
|
if (!expected) {
|
|
return new Error("gateway tls fingerprint missing");
|
|
}
|
|
const socket = (
|
|
this.ws as WebSocket & {
|
|
_socket?: { getPeerCertificate?: () => { fingerprint256?: string } };
|
|
}
|
|
)._socket;
|
|
if (!socket || typeof socket.getPeerCertificate !== "function") {
|
|
return new Error("gateway tls fingerprint unavailable");
|
|
}
|
|
const cert = socket.getPeerCertificate();
|
|
const fingerprint = normalizeFingerprint(cert?.fingerprint256 ?? "");
|
|
if (!fingerprint) {
|
|
return new Error("gateway tls fingerprint unavailable");
|
|
}
|
|
if (fingerprint !== expected) {
|
|
return new Error("gateway tls fingerprint mismatch");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async request<T = Record<string, unknown>>(
|
|
method: string,
|
|
params?: unknown,
|
|
opts?: { expectFinal?: boolean; timeoutMs?: number | null },
|
|
): Promise<T> {
|
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
throw new Error("gateway not connected");
|
|
}
|
|
const id = randomUUID();
|
|
const frame: RequestFrame = { type: "req", id, method, params };
|
|
if (!validateRequestFrame(frame)) {
|
|
throw new Error(
|
|
`invalid request frame: ${JSON.stringify(validateRequestFrame.errors, null, 2)}`,
|
|
);
|
|
}
|
|
const expectFinal = opts?.expectFinal === true;
|
|
const timeoutMs =
|
|
opts?.timeoutMs === null
|
|
? null
|
|
: typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
|
|
? Math.max(1, Math.min(Math.floor(opts.timeoutMs), 2_147_483_647))
|
|
: expectFinal
|
|
? null
|
|
: this.requestTimeoutMs;
|
|
const p = new Promise<T>((resolve, reject) => {
|
|
const timeout =
|
|
timeoutMs === null
|
|
? null
|
|
: setTimeout(() => {
|
|
this.pending.delete(id);
|
|
reject(new Error(`gateway request timeout for ${method}`));
|
|
}, timeoutMs);
|
|
this.pending.set(id, {
|
|
resolve: (value) => resolve(value as T),
|
|
reject,
|
|
expectFinal,
|
|
timeout,
|
|
});
|
|
});
|
|
this.ws.send(JSON.stringify(frame));
|
|
return p;
|
|
}
|
|
}
|