openclaw/src/gateway/client.ts

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;
}
}