| 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; |
| }; |
|
|
| 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; |
| connectDelayMs?: number; |
| tickWatchMinIntervalMs?: 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; |
| |
| private lastTick: number | null = null; |
| private tickIntervalMs = 30_000; |
| private tickTimer: NodeJS.Timeout | null = null; |
|
|
| constructor(opts: GatewayClientOptions) { |
| this.opts = { |
| ...opts, |
| deviceIdentity: |
| opts.deviceIdentity === null |
| ? undefined |
| : (opts.deviceIdentity ?? loadOrCreateDeviceIdentity()), |
| }; |
| } |
|
|
| 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"; |
| |
| |
| |
| if (!isSecureWebSocketUrl(url, { allowPrivateWs })) { |
| |
| let displayHost = url; |
| try { |
| displayHost = new URL(url).hostname || url; |
| } catch { |
| |
| } |
| 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; |
| } |
| |
| 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; |
| |
| }) 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; |
| |
| |
| |
| 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 (!this.isTrustedDeviceRetryEndpoint()) { |
| return true; |
| } |
| |
| 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); |
| |
| |
| 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; |
| } |
| |
| const payload = parsed.payload as { status?: unknown } | undefined; |
| const status = payload?.status; |
| if (pending.expectFinal && status === "accepted") { |
| return; |
| } |
| this.pending.delete(parsed.id); |
| 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) { |
| 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 }, |
| ): 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 p = new Promise<T>((resolve, reject) => { |
| this.pending.set(id, { |
| resolve: (value) => resolve(value as T), |
| reject, |
| expectFinal, |
| }); |
| }); |
| this.ws.send(JSON.stringify(frame)); |
| return p; |
| } |
| } |
|
|