Files
cnb/src/workspace/keep-live.ts

252 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// WebSocket Keep-Alive Client Library
// 运行时检测Bun 使用原生 WebSocketNode.js 使用 ws 库
let WebSocketModule: any;
if (typeof Bun !== 'undefined') {
// Bun 环境:使用原生 WebSocket
WebSocketModule = { WebSocket: globalThis.WebSocket };
} else {
// Node.js 环境:使用 ws 库
WebSocketModule = await import('ws');
}
const WebSocket = WebSocketModule.WebSocket;
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;
onExit?: (code?: number) => 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;
private readonly isBun: boolean;
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 ?? (() => { }),
onExit: config.onExit ?? (() => { }),
debug: config.debug ?? false,
};
this.url = new URL(this.config.wsUrl);
this.isBun = typeof Bun !== 'undefined';
}
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",
}
});
if (this.isBun) {
// Bun 环境:使用标准 Web API
const ws = this.ws as any;
ws.onopen = () => {
debug && this.log("Connected!");
this.reconnectAttempts = 0;
this.config.onConnect();
this.startPing();
};
ws.onmessage = async (event: MessageEvent) => {
let data: Buffer | string;
if (event.data instanceof Blob) {
data = Buffer.from(await event.data.arrayBuffer());
} else if (event.data instanceof ArrayBuffer) {
data = Buffer.from(event.data);
} else if (typeof event.data === 'string') {
data = event.data;
} else {
data = Buffer.from(event.data);
}
this.handleMessage(data);
};
ws.onclose = (event: CloseEvent) => {
debug && this.log(`Disconnected (code: ${event.code})`);
this.stopPing();
this.config.onDisconnect(event.code);
this.handleReconnect();
};
ws.onerror = (event: Event) => {
debug && this.log(`Error: ${event}`);
this.config.onError(new Error("WebSocket error"));
};
} else {
// Node.js (ws 库):使用 EventEmitter 模式
const ws = this.ws as any;
ws.on("open", () => {
debug && this.log("Connected!");
this.reconnectAttempts = 0;
this.config.onConnect();
this.startPing();
});
ws.on("message", (data: any) => {
this.handleMessage(data);
});
ws.on("close", (code: number) => {
debug && this.log(`Disconnected (code: ${code})`);
this.stopPing();
this.config.onDisconnect(code);
this.handleReconnect();
});
ws.on("error", (err: Error) => {
debug && this.log(`Error: ${err.message}`);
this.config.onError(err);
});
}
}
// 统一的消息处理方法
private handleMessage(data: Buffer | string) {
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);
}
}
private startPing() {
this.stopPing();
this.pingTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
// 使用 JSON 格式的 ping 消息,兼容 Bun 和 Node.js
this.ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
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.`);
this.config.onExit(1);
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.config.onExit(0);
this.ws = null;
}
}
}
// 便捷函数:快速创建并启动
export function createKeepAlive(config: KeepAliveConfig): WSKeepAlive {
const client = new WSKeepAlive(config);
client.connect();
return client;
}