feat: 重构 WebSocket Keep-Alive 客户端,添加连接和消息处理功能,更新依赖版本,增加 keep.ts 文件

This commit is contained in:
2026-01-30 21:16:06 +08:00
parent 1d4a27d1b2
commit d7a4bcf58f
6 changed files with 270 additions and 37 deletions

View File

@@ -1,19 +1,23 @@
import { resolvePath } from '@kevisual/use-config';
import { execSync } from 'node:child_process';
const entry = 'agent/opencode.ts';
const naming = 'opencode';
const external: string[] = ["bun"];
await Bun.build({
target: 'node',
format: 'esm',
entrypoints: [resolvePath(entry, { meta: import.meta })],
outdir: resolvePath('./dist', { meta: import.meta }),
naming: {
entry: `${naming}.js`,
},
external,
});
const buildFn = async (opts: { entry?: string, naming?: string }) => {
const entry = opts.entry || 'agent/opencode.ts';
const naming = opts.naming || 'opencode';
const external: string[] = ["bun"];
await Bun.build({
target: 'node',
format: 'esm',
entrypoints: [resolvePath(entry, { meta: import.meta })],
outdir: resolvePath('./dist', { meta: import.meta }),
naming: {
entry: `${naming}.js`,
},
external,
});
const cmd = `dts -i ${entry} -o ${naming}.d.ts`;
execSync(cmd);
};
const cmd = 'dts -i agent/opencode.ts -o opencode.d.ts';
execSync(cmd);
await buildFn({ naming: 'opencode', entry: 'agent/opencode.ts' });
await buildFn({ naming: 'keep', entry: 'src/keep.ts' });

View File

@@ -1,6 +1,6 @@
{
"name": "@kevisual/cnb",
"version": "0.0.7",
"version": "0.0.8",
"description": "",
"main": "index.js",
"scripts": {
@@ -21,10 +21,12 @@
"@kevisual/ai": "^0.0.24",
"@kevisual/context": "^0.0.4",
"@kevisual/types": "^0.0.12",
"@opencode-ai/plugin": "^1.1.39",
"@types/bun": "^1.3.7",
"@opencode-ai/plugin": "^1.1.44",
"@types/bun": "^1.3.8",
"@types/node": "^25.1.0",
"dotenv": "^17.2.3"
"@types/ws": "^8.18.1",
"dotenv": "^17.2.3",
"ws": "npm:@kevisual/ws"
},
"publishConfig": {
"access": "public"
@@ -40,6 +42,7 @@
"exports": {
".": "./mod.ts",
"./opencode": "./dist/opencode.js",
"./keep": "./dist/keep.js",
"./src/*": "./src/*",
"./agent/*": "./agent/*"
}

55
pnpm-lock.yaml generated
View File

@@ -37,17 +37,23 @@ importers:
specifier: ^0.0.12
version: 0.0.12
'@opencode-ai/plugin':
specifier: ^1.1.39
version: 1.1.39
specifier: ^1.1.44
version: 1.1.44
'@types/bun':
specifier: ^1.3.7
version: 1.3.7
specifier: ^1.3.8
version: 1.3.8
'@types/node':
specifier: ^25.1.0
version: 25.1.0
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
dotenv:
specifier: ^17.2.3
version: 17.2.3
ws:
specifier: npm:@kevisual/ws
version: '@kevisual/ws@8.19.0'
packages:
@@ -95,11 +101,15 @@ packages:
peerDependencies:
dotenv: ^17
'@opencode-ai/plugin@1.1.39':
resolution: {integrity: sha512-PAdVYNZeRW9pCi4+t+Dp88hoPgEIiS5uJT31hUXLhZEfBvgaLNP38WhXwRNWbwSaNXudWOu/a5DzYNbU84uvHQ==}
'@kevisual/ws@8.19.0':
resolution: {integrity: sha512-jLsL80wBBKkrJZrfk3SQpJ9JA/zREdlUROj7eCkmzqduAWKSI0wVcXuCKf+mLFCHB0Q0Tkh2rgzjSlurt3JQgw==}
engines: {node: '>=10.0.0'}
'@opencode-ai/sdk@1.1.39':
resolution: {integrity: sha512-EUYBZAci0bzG9+a7JVINmqAqis71ipG2/D3juvmvvKFyu0YBIT/6b+g3+p82Eb5CU2dujxpPdJJCaexZ1389eQ==}
'@opencode-ai/plugin@1.1.44':
resolution: {integrity: sha512-5w66Dq2Fugwgr2yrd8obvnlIEjBOuya82UgfR/3z3EzlyNDi2sitQSYbz7CcOtwd89eZ0n/tH/JX2KDGVuzxTQ==}
'@opencode-ai/sdk@1.1.44':
resolution: {integrity: sha512-coQgtSSCbY46/GY+M5zG0rChiLSJWSjPERRt5L1hbjvDWvErelVV0ILPbd1+3CwJLFTedBYgotby2TcO8U0IfQ==}
'@rollup/plugin-commonjs@28.0.9':
resolution: {integrity: sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==}
@@ -279,8 +289,8 @@ packages:
cpu: [x64]
os: [win32]
'@types/bun@1.3.7':
resolution: {integrity: sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA==}
'@types/bun@1.3.8':
resolution: {integrity: sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -291,8 +301,11 @@ packages:
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
bun-types@1.3.7:
resolution: {integrity: sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
bun-types@1.3.8:
resolution: {integrity: sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q==}
commondir@1.0.1:
resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
@@ -467,12 +480,14 @@ snapshots:
'@kevisual/load': 0.0.6
dotenv: 17.2.3
'@opencode-ai/plugin@1.1.39':
'@kevisual/ws@8.19.0': {}
'@opencode-ai/plugin@1.1.44':
dependencies:
'@opencode-ai/sdk': 1.1.39
'@opencode-ai/sdk': 1.1.44
zod: 4.1.8
'@opencode-ai/sdk@1.1.39': {}
'@opencode-ai/sdk@1.1.44': {}
'@rollup/plugin-commonjs@28.0.9(rollup@4.57.0)':
dependencies:
@@ -588,9 +603,9 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.57.0':
optional: true
'@types/bun@1.3.7':
'@types/bun@1.3.8':
dependencies:
bun-types: 1.3.7
bun-types: 1.3.8
'@types/estree@1.0.8': {}
@@ -600,7 +615,11 @@ snapshots:
'@types/resolve@1.20.2': {}
bun-types@1.3.7:
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.1.0
bun-types@1.3.8:
dependencies:
'@types/node': 25.1.0

1
src/keep.ts Normal file
View File

@@ -0,0 +1 @@
export * from './workspace/keep-live.ts';

187
src/workspace/keep-live.ts Normal file
View File

@@ -0,0 +1,187 @@
// WebSocket Keep-Alive Client Library
import WebSocket from "ws";
export interface KeepAliveConfig {
wsUrl: string;
cookie: string;
reconnectInterval?: number;
maxReconnectAttempts?: number;
pingInterval?: number;
onMessage?: (data: Buffer | string) => void;
onConnect?: () => void;
onDisconnect?: (code: number) => void;
onError?: (error: Error) => void;
onSign?: (data: { type: string; data: string; signedData: string }) => void;
debug?: boolean;
}
export interface ParsedMessage {
type: string;
raw: Buffer;
payload?: any;
}
type MessageHandler = (msg: ParsedMessage) => void;
export class WSKeepAlive {
private ws: WebSocket | null = null;
private config: Required<KeepAliveConfig>;
private reconnectAttempts = 0;
private pingTimer: NodeJS.Timeout | null = null;
private messageHandlers: Set<MessageHandler> = new Set();
private url: URL;
constructor(config: KeepAliveConfig) {
this.config = {
wsUrl: config.wsUrl,
cookie: config.cookie,
reconnectInterval: config.reconnectInterval ?? 5000,
maxReconnectAttempts: config.maxReconnectAttempts ?? 3,
pingInterval: config.pingInterval ?? 30000,
onMessage: config.onMessage ?? (() => {}),
onConnect: config.onConnect ?? (() => {}),
onDisconnect: config.onDisconnect ?? (() => {}),
onError: config.onError ?? (() => {}),
onSign: config.onSign ?? (() => {}),
debug: config.debug ?? false,
};
this.url = new URL(this.config.wsUrl);
}
private log(message: string) {
if (!this.config.debug) return;
const timestamp = new Date().toISOString();
const msg = `[${timestamp}] ${message}`;
console.log(msg);
}
private parseMessage(data: Buffer): ParsedMessage | null {
const result: ParsedMessage = { type: "unknown", raw: data };
if (data.length < 14) {
result.type = "raw";
return result;
}
const prefix = data.slice(0, 13);
const msgType = prefix[0];
const jsonStart = data.indexOf(0x71); // 0x71 = 'q'
if (jsonStart !== -1) {
try {
const jsonStr = data.slice(jsonStart + 1).toString();
const payload = JSON.parse(jsonStr);
result.type = `binary(0x${msgType.toString(16)})`;
result.payload = payload;
// 特殊处理 sign 类型
if (payload.type === "sign" && this.config.onSign) {
this.config.onSign(payload);
}
return result;
} catch {
result.type = "binary(json-parse-error)";
return result;
}
}
result.type = "raw";
return result;
}
connect() {
const { wsUrl, cookie, debug } = this.config;
this.log(`Connecting to ${wsUrl}...`);
this.ws = new WebSocket(wsUrl, {
headers: {
"Origin": this.url.origin,
"Cookie": cookie,
"Cache-Control": "no-cache",
"Accept-Language": "zh-CN,zh;q=0.9",
"Pragma": "no-cache",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Sec-WebSocket-Extensions": "permessage-deflate",
}
});
this.ws.on("open", () => {
debug && this.log("Connected!");
this.reconnectAttempts = 0;
this.config.onConnect();
this.startPing();
});
this.ws.on("message", (data: any) => {
if (Buffer.isBuffer(data)) {
const parsed = this.parseMessage(data);
this.config.onMessage(parsed?.raw ?? data);
this.messageHandlers.forEach(handler => {
if (parsed) handler(parsed);
});
} else {
this.config.onMessage(data);
}
});
this.ws.on("close", (code: number) => {
debug && this.log(`Disconnected (code: ${code})`);
this.stopPing();
this.config.onDisconnect(code);
this.handleReconnect();
});
this.ws.on("error", (err: Error) => {
debug && this.log(`Error: ${err.message}`);
this.config.onError(err);
});
}
private startPing() {
this.stopPing();
this.pingTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.ping();
this.log("Sent ping");
}
}, this.config.pingInterval);
}
private stopPing() {
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
private handleReconnect() {
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
this.log(`Max reconnect attempts (${this.config.maxReconnectAttempts}) reached. Giving up.`);
return;
}
this.reconnectAttempts++;
this.log(`Reconnecting in ${this.config.reconnectInterval}ms... (attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts})`);
setTimeout(() => this.connect(), this.config.reconnectInterval);
}
onMessage(handler: MessageHandler) {
this.messageHandlers.add(handler);
return () => this.messageHandlers.delete(handler);
}
disconnect() {
this.stopPing();
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
}
// 便捷函数:快速创建并启动
export function createKeepAlive(config: KeepAliveConfig): WSKeepAlive {
const client = new WSKeepAlive(config);
client.connect();
return client;
}

19
test/keep.ts Normal file
View File

@@ -0,0 +1,19 @@
// WebSocket Keep-Alive Client with node+ws
import { createKeepAlive } from "../src/workspace/keep-live.ts";
const WS_URL = "wss://cnb-l6o-1jg7aoevl-001.cnb.space/stable-3c0b449c6e6e37b44a8a7938c0d8a3049926a64c?reconnectionToken=a6517530-9911-406b-a65f-0d9d4b3f0d6f&reconnection=false&skipWebSocketFrames=false";
const COOKIE = "orange:workspace:cookie-session:cnb-l6o-1jg7aoevl-001=1ba3d696-1805-4c6b-b109-222738be570f";
// 使用库创建客户端
const client = createKeepAlive({
wsUrl: WS_URL,
cookie: COOKIE,
debug: true,
});
// 监听解析后的消息
client.onMessage((msg) => {
console.log(`[Received] ${msg.raw.length} bytes`);
});
console.log("开始激活 WebSocket 连接...");