From 01181d4385306d7bdba3d05d74fd95efa319058b Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Thu, 12 Mar 2026 12:51:12 +0800 Subject: [PATCH] feat: add openclaw pairing bridge --- .gitignore | 1 + README.md | 53 +++++- src/app/api/integrations/probe/route.ts | 38 +++- src/app/api/openclaw/assistant/route.ts | 47 ++++- src/server/openclaw/device-store.ts | 184 +++++++++++++++++++ src/server/openclaw/gateway-client.ts | 229 ++++++++++++++++++++---- 6 files changed, 505 insertions(+), 47 deletions(-) create mode 100644 src/server/openclaw/device-store.ts diff --git a/.gitignore b/.gitignore index 52a21ea..5e10ad2 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ public/_build/ public/dl-index/ .contentlayer/ .dev-logs/ +.console-state/ # Contentlayer cache ui/docs/.contentlayer/ diff --git a/README.md b/README.md index cf9efd6..8b7fbf0 100644 --- a/README.md +++ b/README.md @@ -36,30 +36,64 @@ yarn dev cp .env.example .env ``` -AI 助手与集成页使用以下环境变量做服务端预填,不在前端 UI 中硬编码: +如果你的工作区同时包含 `openclaw-deploy-example`,建议参考 `../openclaw-deploy-example/.env` 填写 AI 助手联调配置,并同时查看 `docs/getting-started/installation.md`。 -- `OPENCLAW_GATEWAY_REMOTE_URL` -- `OPENCLAW_GATEWAY_TOKEN` -- `VAULT_SERVER_URL` -- `VAULT_NAMESPACE` -- `VAULT_TOKEN` -- `APISIX_AI_GATEWAY_URL` -- `AI_GATEWAY_ACCESS_TOKEN` +## 主要入口 (Key Routes) -建议参考 `/Users/shenlan/workspaces/cloud-neutral-toolkit/openclaw-deploy-example/.env` 填写,并同时查看 `docs/getting-started/installation.md`。 +- `/services`:服务导航页,保留现有控制台布局。 +- `/services/openclaw`:原生 Next.js 的 OpenClaw 助手工作区。 +- `/panel/api`:融合设置与集成页,用于配置和探测 OpenClaw Gateway、Vault Server、APISIX AI Gateway。 + +## AI 助手与集成能力 (Assistant & Integrations) + +当前主页 AI 辅助功能已经基于本仓库原生实现,核心行为如下: + +- 侧栏助手模式保留现有交互方式,但底层改为对接 OpenClaw gateway。 +- 最大化助手页面统一收敛到 `/services/openclaw`,不再继续使用旧的 control UI 套壳。 +- 页面截图通过 assistant chat 附件模式发送,而不是单独的浏览器控制壳。 +- `/panel/api` 提供 OpenClaw、Vault、APISIX 三类集成的默认值预填与连通性探测。 +- 网关地址与令牌从服务端环境变量读取,前端组件不硬编码敏感配置。 + +## 环境变量 (Environment Variables) + +以下变量用于主页 AI 助手和集成页的服务端默认值预填: + +| 变量 | 用途 | +|---|---| +| `OPENCLAW_GATEWAY_REMOTE_URL` | OpenClaw gateway 远端 WebSocket 地址 | +| `OPENCLAW_GATEWAY_TOKEN` | OpenClaw gateway 访问令牌 | +| `VAULT_SERVER_URL` | Vault 服务地址 | +| `VAULT_NAMESPACE` | Vault namespace,可选 | +| `VAULT_TOKEN` | Vault 探测令牌 | +| `APISIX_AI_GATEWAY_URL` | APISIX AI Gateway 地址 | +| `AI_GATEWAY_ACCESS_TOKEN` | APISIX AI Gateway 探测令牌 | + +更多说明见 `docs/getting-started/installation.md` 和 `.env.example`。 ## 核心特性 & 技术栈 (Features & Tech Stack) 核心特性: * 统一控制面:汇聚 Cloud Neutral Toolkit 各微服务入口 +* 原生 AI 助手工作区:OpenClaw gateway 驱动的聊天、截图附件与会话体验 +* 融合集成设置:在 `/panel/api` 统一管理 OpenClaw、Vault、APISIX AI Gateway * 文档与内容系统:Contentlayer 驱动的 docs/content pipeline * 可扩展集成:OIDC、Cloudflare Web Analytics 等 技术栈: * Next.js + TypeScript * Tailwind CSS + Radix UI +* Zustand * Contentlayer +## 开发命令 (Useful Commands) + +```bash +yarn dev +yarn build +yarn typecheck +./node_modules/.bin/eslint . --no-eslintrc --config .eslintrc.json --resolve-plugins-relative-to . +``` + ## 说明文档 (Docs) 入口: @@ -70,6 +104,7 @@ AI 助手与集成页使用以下环境变量做服务端预填,不在前端 U * OIDC: `docs/integrations/oidc-auth.md` * Cloudflare Web Analytics: `docs/integrations/cloudflare-web-analytics.md` * Assistant / Integrations env setup: `docs/getting-started/installation.md` +* Chinese installation guide: `docs/zh/getting-started/installation.md` 其他: * Agent rules: `AGENTS.md` diff --git a/src/app/api/integrations/probe/route.ts b/src/app/api/integrations/probe/route.ts index a01d4cd..93e4b71 100644 --- a/src/app/api/integrations/probe/route.ts +++ b/src/app/api/integrations/probe/route.ts @@ -24,6 +24,39 @@ function jsonError(message: string, status = 400): Response { return Response.json({ error: message }, { status }) } +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined +} + +function formatGatewayError(error: OpenClawGatewayError | null, client: OpenClawGatewayClient): string { + if (!error) { + return 'Failed to probe OpenClaw gateway.' + } + + const details = asRecord(error.details) + const detailCode = stringValue(details.code) + if (detailCode === 'PAIRING_REQUIRED') { + const requestId = stringValue(details.requestId) + const reason = stringValue(details.reason) + return [ + '需要先在 OpenClaw 网关审批该设备配对请求。', + requestId ? `requestId: ${requestId}` : '', + client.deviceId ? `deviceId: ${client.deviceId}` : '', + reason ? `reason: ${reason}` : '', + ] + .filter(Boolean) + .join(' ') + } + + return error.message +} + async function probeOpenClaw(body: ProbeBody): Promise { const config = resolveOpenClawGatewayConfig({ gatewayUrl: body.gatewayUrl, @@ -40,6 +73,7 @@ async function probeOpenClaw(body: ProbeBody): Promise { await client.connect({ gatewayUrl: config.gatewayUrl, gatewayToken: config.gatewayToken, + clientLabel: 'console.svc.plus Probe', }) const [statusPayload, healthPayload] = await Promise.all([client.status(), client.health()]) @@ -60,8 +94,10 @@ async function probeOpenClaw(body: ProbeBody): Promise { target: 'openclaw', gatewayUrl: config.gatewayUrl, tokenSource: config.tokenSource, - error: gatewayError?.message ?? 'Failed to probe OpenClaw gateway.', + error: formatGatewayError(gatewayError, client), code: gatewayError?.code, + details: gatewayError?.details ?? null, + deviceId: client.deviceId || undefined, }, { status: 502 }, ) diff --git a/src/app/api/openclaw/assistant/route.ts b/src/app/api/openclaw/assistant/route.ts index d78af49..9e25015 100644 --- a/src/app/api/openclaw/assistant/route.ts +++ b/src/app/api/openclaw/assistant/route.ts @@ -34,16 +34,57 @@ type SendBody = { attachments?: GatewayChatAttachmentPayload[] } -function jsonError(message: string, status = 400, code?: string): Response { +function jsonError( + message: string, + status = 400, + code?: string, + details?: Record | null, + deviceId?: string, +): Response { return Response.json( { error: message, code, + details: details ?? null, + ...(deviceId ? { deviceId } : {}), }, { status }, ) } +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined +} + +function formatGatewayError(error: OpenClawGatewayError | null, client: OpenClawGatewayClient): string { + if (!error) { + return 'Failed to connect to OpenClaw gateway.' + } + + const details = asRecord(error.details) + const detailCode = stringValue(details.code) + if (detailCode === 'PAIRING_REQUIRED') { + const requestId = stringValue(details.requestId) + const reason = stringValue(details.reason) + return [ + '需要先在 OpenClaw 网关审批该设备配对请求。', + requestId ? `requestId: ${requestId}` : '', + client.deviceId ? `deviceId: ${client.deviceId}` : '', + reason ? `reason: ${reason}` : '', + ] + .filter(Boolean) + .join(' ') + } + + return error.message +} + function resolveSessionKey(params: { sessionKey?: string agentId?: string @@ -107,9 +148,11 @@ async function handleBootstrap(body: BootstrapBody): Promise { } catch (error) { const gatewayError = error instanceof OpenClawGatewayError ? error : null return jsonError( - gatewayError?.message ?? 'Failed to connect to OpenClaw gateway.', + formatGatewayError(gatewayError, client), gatewayError?.code === 'OFFLINE' ? 503 : 502, gatewayError?.code, + gatewayError?.details ?? null, + client.deviceId || undefined, ) } finally { await client.close() diff --git a/src/server/openclaw/device-store.ts b/src/server/openclaw/device-store.ts new file mode 100644 index 0000000..28d73a3 --- /dev/null +++ b/src/server/openclaw/device-store.ts @@ -0,0 +1,184 @@ +import 'server-only' + +import { createHash, createPrivateKey, generateKeyPairSync, sign as signPayload } from 'node:crypto' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' + +type StoredDeviceIdentity = { + deviceId: string + publicKeyBase64Url: string + privateKeyBase64Url: string + createdAtMs: number +} + +const OPENCLAW_STATE_DIR = path.join(process.cwd(), '.console-state', 'openclaw') +const DEVICE_IDENTITY_FILE = path.join(OPENCLAW_STATE_DIR, 'gateway-device-identity.json') +let deviceIdentityPromise: Promise | null = null + +function asStoredDeviceIdentity(value: unknown): StoredDeviceIdentity | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + + const entry = value as Record + const deviceId = typeof entry.deviceId === 'string' ? entry.deviceId.trim() : '' + const publicKeyBase64Url = + typeof entry.publicKeyBase64Url === 'string' ? entry.publicKeyBase64Url.trim() : '' + const privateKeyBase64Url = + typeof entry.privateKeyBase64Url === 'string' ? entry.privateKeyBase64Url.trim() : '' + const createdAtMs = + typeof entry.createdAtMs === 'number' && Number.isFinite(entry.createdAtMs) + ? entry.createdAtMs + : Date.now() + + if (!deviceId || !publicKeyBase64Url || !privateKeyBase64Url) { + return null + } + + return { + deviceId, + publicKeyBase64Url, + privateKeyBase64Url, + createdAtMs, + } +} + +function normalizeMetadataForAuth(value?: string): string { + return value?.trim().toLowerCase() ?? '' +} + +function deriveDeviceId(publicKeyBase64Url: string): string { + const publicKeyBytes = Buffer.from(publicKeyBase64Url, 'base64url') + return createHash('sha256').update(publicKeyBytes).digest('hex') +} + +function deviceTokenFile(deviceId: string, role = 'operator'): string { + const safeRole = role.trim() || 'operator' + return path.join(OPENCLAW_STATE_DIR, `gateway-device-token.${deviceId}.${safeRole}.txt`) +} + +async function ensureStateDirectory(): Promise { + await mkdir(OPENCLAW_STATE_DIR, { recursive: true }) +} + +async function writeIdentity(identity: StoredDeviceIdentity): Promise { + await ensureStateDirectory() + await writeFile(DEVICE_IDENTITY_FILE, `${JSON.stringify(identity, null, 2)}\n`, 'utf8') +} + +export async function loadOrCreateOpenClawDeviceIdentity(): Promise { + if (!deviceIdentityPromise) { + deviceIdentityPromise = (async () => { + try { + const raw = await readFile(DEVICE_IDENTITY_FILE, 'utf8') + const parsed = asStoredDeviceIdentity(JSON.parse(raw)) + if (parsed) { + return parsed + } + } catch { + // Fall through to generating a new identity. + } + + const { publicKey, privateKey } = generateKeyPairSync('ed25519') + const publicJwk = publicKey.export({ format: 'jwk' }) as { x?: string } + const privateJwk = privateKey.export({ format: 'jwk' }) as { d?: string } + + if (!publicJwk.x || !privateJwk.d) { + throw new Error('Failed to generate OpenClaw device identity.') + } + + const identity: StoredDeviceIdentity = { + deviceId: deriveDeviceId(publicJwk.x), + publicKeyBase64Url: publicJwk.x, + privateKeyBase64Url: privateJwk.d, + createdAtMs: Date.now(), + } + + await writeIdentity(identity) + return identity + })() + } + + try { + return await deviceIdentityPromise + } catch (error) { + deviceIdentityPromise = null + throw error + } +} + +export async function loadOpenClawDeviceToken(params: { + deviceId: string + role?: string +}): Promise { + try { + const value = await readFile(deviceTokenFile(params.deviceId, params.role), 'utf8') + return value.trim() + } catch { + return '' + } +} + +export async function saveOpenClawDeviceToken(params: { + deviceId: string + role?: string + token: string +}): Promise { + await ensureStateDirectory() + await writeFile(deviceTokenFile(params.deviceId, params.role), `${params.token.trim()}\n`, 'utf8') +} + +export async function clearOpenClawDeviceToken(params: { + deviceId: string + role?: string +}): Promise { + try { + await rm(deviceTokenFile(params.deviceId, params.role), { force: true }) + } catch { + // Ignore missing files. + } +} + +export function buildOpenClawDeviceAuthPayloadV3(params: { + deviceId: string + clientId: string + clientMode: string + role: string + scopes: string[] + signedAtMs: number + token: string + nonce: string + platform: string + deviceFamily: string +}): string { + return [ + 'v3', + params.deviceId, + params.clientId, + params.clientMode, + params.role, + params.scopes.join(','), + String(params.signedAtMs), + params.token, + params.nonce, + normalizeMetadataForAuth(params.platform), + normalizeMetadataForAuth(params.deviceFamily), + ].join('|') +} + +export function signOpenClawDevicePayload(params: { + identity: StoredDeviceIdentity + payload: string +}): string { + const privateKey = createPrivateKey({ + format: 'jwk', + key: { + kty: 'OKP', + crv: 'Ed25519', + x: params.identity.publicKeyBase64Url, + d: params.identity.privateKeyBase64Url, + }, + }) + + return signPayload(null, Buffer.from(params.payload, 'utf8'), privateKey).toString('base64url') +} diff --git a/src/server/openclaw/gateway-client.ts b/src/server/openclaw/gateway-client.ts index 16af0a4..3f472b0 100644 --- a/src/server/openclaw/gateway-client.ts +++ b/src/server/openclaw/gateway-client.ts @@ -10,8 +10,25 @@ import { type GatewayChatMessage, type GatewaySessionSummary, } from '@/lib/openclaw/types' +import { + buildOpenClawDeviceAuthPayloadV3, + clearOpenClawDeviceToken, + loadOpenClawDeviceToken, + loadOrCreateOpenClawDeviceIdentity, + saveOpenClawDeviceToken, + signOpenClawDevicePayload, +} from '@/server/openclaw/device-store' const OPENCLAW_PROTOCOL_VERSION = 3 +const OPENCLAW_CLIENT_IDS = { + assistant: 'webchat-ui', +} as const +const OPENCLAW_CLIENT_MODES = { + assistant: 'ui', +} as const +const OPENCLAW_CLIENT_PLATFORM = 'web' +const OPENCLAW_CLIENT_DEVICE_FAMILY = 'console' +const OPENCLAW_CLIENT_MODEL = 'nextjs' const DEFAULT_OPERATOR_SCOPES = [ 'operator.admin', 'operator.read', @@ -107,16 +124,20 @@ function resolveGatewayUrl(urlRaw: string): string { export class OpenClawGatewayError extends Error { readonly code?: string + readonly details?: Record - constructor(message: string, code?: string) { + constructor(message: string, code?: string, details?: Record) { super(message) this.name = 'OpenClawGatewayError' this.code = code + this.details = details } } export class OpenClawGatewayClient { private socket: WebSocket | null = null + private currentDeviceId = '' + private connectChallengeNonce: string | null = null private pending = new Map() private listeners = new Set<(event: GatewayEventFrame) => void>() private handleMessageRef = (event: MessageEvent) => { @@ -133,11 +154,17 @@ export class OpenClawGatewayClient { gatewayUrl: string gatewayToken: string clientId?: string + clientMode?: string clientLabel?: string - }): Promise<{ mainSessionKey: string }> { + }): Promise<{ mainSessionKey: string; deviceId: string }> { const url = resolveGatewayUrl(params.gatewayUrl) const socket = new WebSocket(url) this.socket = socket + this.connectChallengeNonce = null + + socket.addEventListener('message', this.handleMessageRef) + socket.addEventListener('close', this.handleCloseRef) + socket.addEventListener('error', this.handleErrorRef) await new Promise((resolve, reject) => { const timeout = setTimeout(() => { @@ -163,45 +190,166 @@ export class OpenClawGatewayClient { ) }) - socket.addEventListener('message', this.handleMessageRef) - socket.addEventListener('close', this.handleCloseRef) - socket.addEventListener('error', this.handleErrorRef) + const clientId = params.clientId ?? OPENCLAW_CLIENT_IDS.assistant + const clientMode = params.clientMode ?? OPENCLAW_CLIENT_MODES.assistant + const identity = await loadOrCreateOpenClawDeviceIdentity() + this.currentDeviceId = identity.deviceId + const storedDeviceToken = await loadOpenClawDeviceToken({ + deviceId: identity.deviceId, + role: 'operator', + }) + const sharedGatewayToken = params.gatewayToken.trim() + const authToken = sharedGatewayToken || storedDeviceToken + const authDeviceToken = storedDeviceToken - const payload = asRecord( - await this.request('connect', { - minProtocol: OPENCLAW_PROTOCOL_VERSION, - maxProtocol: OPENCLAW_PROTOCOL_VERSION, - client: { - id: params.clientId ?? 'console-openclaw-proxy', - displayName: params.clientLabel ?? 'console.svc.plus Assistant', - version: '1.0.0', - platform: 'node', - mode: 'ui', - instanceId: `console-${randomUUID().slice(0, 8)}`, - }, - locale: 'zh-CN', - userAgent: 'console.svc.plus/openclaw', + try { + const nonce = await this.waitForConnectChallenge(socket) + + const signedAtMs = Date.now() + const signaturePayload = buildOpenClawDeviceAuthPayloadV3({ + deviceId: identity.deviceId, + clientId, + clientMode, role: 'operator', - scopes: DEFAULT_OPERATOR_SCOPES, - caps: ['tool-events'], - ...(params.gatewayToken.trim() - ? { - auth: { - token: params.gatewayToken.trim(), - }, - } - : {}), - }, 12000), - ) + scopes: [...DEFAULT_OPERATOR_SCOPES], + signedAtMs, + token: authToken, + nonce, + platform: OPENCLAW_CLIENT_PLATFORM, + deviceFamily: OPENCLAW_CLIENT_DEVICE_FAMILY, + }) - const snapshot = asRecord(payload.snapshot) - const sessionDefaults = asRecord(snapshot.sessionDefaults) + const payload = asRecord( + await this.request('connect', { + minProtocol: OPENCLAW_PROTOCOL_VERSION, + maxProtocol: OPENCLAW_PROTOCOL_VERSION, + client: { + id: clientId, + displayName: params.clientLabel ?? 'console.svc.plus Assistant', + version: '1.0.0', + platform: OPENCLAW_CLIENT_PLATFORM, + deviceFamily: OPENCLAW_CLIENT_DEVICE_FAMILY, + modelIdentifier: OPENCLAW_CLIENT_MODEL, + mode: clientMode, + instanceId: `${clientId}-${identity.deviceId.slice(0, 8)}`, + }, + locale: 'zh-CN', + userAgent: 'console.svc.plus/openclaw', + role: 'operator', + scopes: DEFAULT_OPERATOR_SCOPES, + caps: ['tool-events'], + commands: [], + permissions: {}, + ...((authToken || authDeviceToken) + ? { + auth: { + ...(authToken ? { token: authToken } : {}), + ...(authDeviceToken ? { deviceToken: authDeviceToken } : {}), + }, + } + : {}), + device: { + id: identity.deviceId, + publicKey: identity.publicKeyBase64Url, + signature: signOpenClawDevicePayload({ + identity, + payload: signaturePayload, + }), + signedAt: signedAtMs, + nonce, + }, + }, 12000), + ) - return { - mainSessionKey: normalizeMainSessionKey(stringValue(sessionDefaults.mainSessionKey)), + const snapshot = asRecord(payload.snapshot) + const sessionDefaults = asRecord(snapshot.sessionDefaults) + const auth = asRecord(payload.auth) + const returnedDeviceToken = stringValue(auth.deviceToken) + + if (returnedDeviceToken) { + await saveOpenClawDeviceToken({ + deviceId: identity.deviceId, + role: stringValue(auth.role) ?? 'operator', + token: returnedDeviceToken, + }) + } + + return { + mainSessionKey: normalizeMainSessionKey(stringValue(sessionDefaults.mainSessionKey)), + deviceId: identity.deviceId, + } + } catch (error) { + const gatewayError = error instanceof OpenClawGatewayError ? error : null + const detailCode = stringValue(asRecord(gatewayError?.details).code) + + if (detailCode === 'AUTH_DEVICE_TOKEN_MISMATCH' && !sharedGatewayToken && authDeviceToken) { + await clearOpenClawDeviceToken({ + deviceId: identity.deviceId, + role: 'operator', + }) + } + + throw error } } + get deviceId(): string { + return this.currentDeviceId + } + + private async waitForConnectChallenge(socket: WebSocket): Promise { + if (this.connectChallengeNonce) { + return this.connectChallengeNonce + } + + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup() + reject(new OpenClawGatewayError('Gateway connect challenge timeout', 'CHALLENGE_TIMEOUT')) + }, 4000) + + const stopListening = this.onEvent((event) => { + if (event.event !== 'connect.challenge') { + return + } + + const payload = asRecord(event.payload) + const nonce = stringValue(payload.nonce) + if (!nonce) { + cleanup() + reject( + new OpenClawGatewayError('Gateway connect challenge missing nonce', 'CHALLENGE_NONCE'), + ) + return + } + + this.connectChallengeNonce = nonce + cleanup() + resolve(nonce) + }) + + const onClose = () => { + cleanup() + reject(new OpenClawGatewayError('Gateway closed before connect challenge', 'SOCKET_CLOSED')) + } + + const onError = () => { + cleanup() + reject(new OpenClawGatewayError('Gateway error before connect challenge', 'SOCKET_ERROR')) + } + + const cleanup = () => { + clearTimeout(timeout) + stopListening() + socket.removeEventListener('close', onClose) + socket.removeEventListener('error', onError) + } + + socket.addEventListener('close', onClose, { once: true }) + socket.addEventListener('error', onError, { once: true }) + }) + } + onEvent(listener: (event: GatewayEventFrame) => void): () => void { this.listeners.add(listener) return () => { @@ -383,9 +531,18 @@ export class OpenClawGatewayClient { const type = stringValue(payload.type) if (type === 'event') { + const eventName = stringValue(payload.event) ?? '' + if (eventName === 'connect.challenge') { + const challengePayload = asRecord(payload.payload) + const nonce = stringValue(challengePayload.nonce) + if (nonce) { + this.connectChallengeNonce = nonce + } + } + const frame = { type: 'event' as const, - event: stringValue(payload.event) ?? '', + event: eventName, seq: numberValue(payload.seq), payload: payload.payload, } @@ -421,6 +578,7 @@ export class OpenClawGatewayClient { new OpenClawGatewayError( stringValue(error.message) ?? 'Gateway request failed', stringValue(error.code), + asRecord(error.details), ), ) return @@ -430,6 +588,7 @@ export class OpenClawGatewayClient { } private failPending(error: OpenClawGatewayError): void { + this.connectChallengeNonce = null for (const [id, pending] of this.pending.entries()) { clearTimeout(pending.timeout) pending.reject(error)