diff --git a/bun.config.ts b/bun.config.ts index 14802cc..65458b7 100644 --- a/bun.config.ts +++ b/bun.config.ts @@ -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); \ No newline at end of file +await buildFn({ naming: 'opencode', entry: 'agent/opencode.ts' }); +await buildFn({ naming: 'keep', entry: 'src/keep.ts' }); \ No newline at end of file diff --git a/package.json b/package.json index 6f082c0..ba4c5a2 100644 --- a/package.json +++ b/package.json @@ -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/*" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 962372d..b2969a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/keep.ts b/src/keep.ts new file mode 100644 index 0000000..3f5974e --- /dev/null +++ b/src/keep.ts @@ -0,0 +1 @@ +export * from './workspace/keep-live.ts'; \ No newline at end of file diff --git a/src/workspace/keep-live.ts b/src/workspace/keep-live.ts new file mode 100644 index 0000000..95d52bc --- /dev/null +++ b/src/workspace/keep-live.ts @@ -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; + private reconnectAttempts = 0; + private pingTimer: NodeJS.Timeout | null = null; + private messageHandlers: Set = 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; +} diff --git a/test/keep.ts b/test/keep.ts new file mode 100644 index 0000000..d658549 --- /dev/null +++ b/test/keep.ts @@ -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 连接...");