@clawchatsai/connector 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/gateway-bridge.d.ts +53 -0
- package/dist/gateway-bridge.js +183 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.js +544 -0
- package/dist/migrate.d.ts +16 -0
- package/dist/migrate.js +114 -0
- package/dist/shim.d.ts +61 -0
- package/dist/shim.js +154 -0
- package/dist/signaling-client.d.ts +74 -0
- package/dist/signaling-client.js +322 -0
- package/dist/updater.d.ts +21 -0
- package/dist/updater.js +64 -0
- package/dist/webrtc-peer.d.ts +127 -0
- package/dist/webrtc-peer.js +257 -0
- package/openclaw.plugin.json +12 -0
- package/package.json +37 -0
- package/server.js +4058 -0
package/dist/shim.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Shim — translates DataChannel RPC messages into fake req/res
|
|
3
|
+
* objects compatible with server.js handleRequest().
|
|
4
|
+
*
|
|
5
|
+
* Per spec section 6.3.1:
|
|
6
|
+
* - Fake req: Readable stream with .url, .method, .headers
|
|
7
|
+
* - Fake res: Writable stream (required for pipe() in handleServeFile)
|
|
8
|
+
*/
|
|
9
|
+
import { Readable, Writable } from 'node:stream';
|
|
10
|
+
import type { IncomingHttpHeaders } from 'node:http';
|
|
11
|
+
export interface RpcRequest {
|
|
12
|
+
id: string;
|
|
13
|
+
method: string;
|
|
14
|
+
url: string;
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
body?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface RpcResponse {
|
|
19
|
+
id: string;
|
|
20
|
+
status: number;
|
|
21
|
+
headers: Record<string, string>;
|
|
22
|
+
body: string;
|
|
23
|
+
}
|
|
24
|
+
type HandleRequestFn = (req: FakeReq, res: FakeRes) => void | Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Fake IncomingMessage — a Readable stream with HTTP-like properties.
|
|
27
|
+
*/
|
|
28
|
+
declare class FakeReq extends Readable {
|
|
29
|
+
url: string;
|
|
30
|
+
method: string;
|
|
31
|
+
headers: IncomingHttpHeaders;
|
|
32
|
+
constructor(rpc: RpcRequest);
|
|
33
|
+
_read(): void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Fake ServerResponse — a Writable stream that buffers output.
|
|
37
|
+
*
|
|
38
|
+
* Extends Writable so that fs.createReadStream(...).pipe(res) works
|
|
39
|
+
* (handleServeFile uses pipe()).
|
|
40
|
+
*/
|
|
41
|
+
declare class FakeRes extends Writable {
|
|
42
|
+
statusCode: number;
|
|
43
|
+
private _headers;
|
|
44
|
+
private _chunks;
|
|
45
|
+
private _resolvePromise;
|
|
46
|
+
readonly finished: Promise<{
|
|
47
|
+
status: number;
|
|
48
|
+
headers: Record<string, string>;
|
|
49
|
+
body: Buffer;
|
|
50
|
+
}>;
|
|
51
|
+
constructor();
|
|
52
|
+
_write(chunk: Buffer | string, _encoding: string, callback: (error?: Error | null) => void): void;
|
|
53
|
+
setHeader(name: string, value: string | number): void;
|
|
54
|
+
writeHead(statusCode: number, headers?: Record<string, string | number>): this;
|
|
55
|
+
end(chunk?: unknown, encoding?: unknown, _cb?: unknown): this;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Dispatch an RPC request through handleRequest and return the response.
|
|
59
|
+
*/
|
|
60
|
+
export declare function dispatchRpc(rpc: RpcRequest, handleRequest: HandleRequestFn): Promise<RpcResponse>;
|
|
61
|
+
export {};
|
package/dist/shim.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Shim — translates DataChannel RPC messages into fake req/res
|
|
3
|
+
* objects compatible with server.js handleRequest().
|
|
4
|
+
*
|
|
5
|
+
* Per spec section 6.3.1:
|
|
6
|
+
* - Fake req: Readable stream with .url, .method, .headers
|
|
7
|
+
* - Fake res: Writable stream (required for pipe() in handleServeFile)
|
|
8
|
+
*/
|
|
9
|
+
import { Readable, Writable } from 'node:stream';
|
|
10
|
+
/**
|
|
11
|
+
* Fake IncomingMessage — a Readable stream with HTTP-like properties.
|
|
12
|
+
*/
|
|
13
|
+
class FakeReq extends Readable {
|
|
14
|
+
url;
|
|
15
|
+
method;
|
|
16
|
+
headers;
|
|
17
|
+
constructor(rpc) {
|
|
18
|
+
super();
|
|
19
|
+
this.url = rpc.url;
|
|
20
|
+
this.method = rpc.method;
|
|
21
|
+
// Lowercase header keys to match Node's IncomingHttpHeaders convention.
|
|
22
|
+
// Browsers send 'Authorization' but Node expects 'authorization'.
|
|
23
|
+
const raw = rpc.headers ?? {};
|
|
24
|
+
const lowered = {};
|
|
25
|
+
for (const [k, v] of Object.entries(raw))
|
|
26
|
+
lowered[k.toLowerCase()] = v;
|
|
27
|
+
this.headers = lowered;
|
|
28
|
+
// Push body (if any) and signal end — reconstruct binary data from
|
|
29
|
+
// _blob / _multipart envelope sent by the browser's transport.js
|
|
30
|
+
if (rpc.body) {
|
|
31
|
+
let parsed = null;
|
|
32
|
+
try {
|
|
33
|
+
parsed = JSON.parse(rpc.body);
|
|
34
|
+
}
|
|
35
|
+
catch { /* not JSON — treat as raw string */ }
|
|
36
|
+
if (parsed && parsed['_blob']) {
|
|
37
|
+
// Binary blob: { _blob: true, contentType: "audio/webm", data: "<base64>" }
|
|
38
|
+
const buf = Buffer.from(parsed['data'], 'base64');
|
|
39
|
+
this.headers['content-type'] = parsed['contentType'] || 'application/octet-stream';
|
|
40
|
+
this.headers['content-length'] = String(buf.length);
|
|
41
|
+
this.push(buf);
|
|
42
|
+
}
|
|
43
|
+
else if (parsed && parsed['_text']) {
|
|
44
|
+
// Raw text string (e.g. file content) — push as-is without JSON wrapping
|
|
45
|
+
this.push(parsed['data']);
|
|
46
|
+
}
|
|
47
|
+
else if (parsed && parsed['_multipart']) {
|
|
48
|
+
// Multipart form data: { _multipart: true, fields: { key: string | { filename, contentType, data } } }
|
|
49
|
+
const boundary = '----ShellChatBoundary' + Date.now();
|
|
50
|
+
this.headers['content-type'] = `multipart/form-data; boundary=${boundary}`;
|
|
51
|
+
const fields = parsed['fields'] || {};
|
|
52
|
+
const parts = [];
|
|
53
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
54
|
+
if (value && typeof value === 'object' && value['filename']) {
|
|
55
|
+
const f = value;
|
|
56
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${key}"; filename="${f.filename}"\r\nContent-Type: ${f.contentType}\r\n\r\n`));
|
|
57
|
+
parts.push(Buffer.from(f.data, 'base64'));
|
|
58
|
+
parts.push(Buffer.from('\r\n'));
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${String(value)}\r\n`));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
parts.push(Buffer.from(`--${boundary}--\r\n`));
|
|
65
|
+
const multipartBuf = Buffer.concat(parts);
|
|
66
|
+
this.headers['content-length'] = String(multipartBuf.length);
|
|
67
|
+
this.push(multipartBuf);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
this.push(rpc.body);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
this.push(null);
|
|
74
|
+
}
|
|
75
|
+
_read() {
|
|
76
|
+
// No-op — data already pushed in constructor
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Fake ServerResponse — a Writable stream that buffers output.
|
|
81
|
+
*
|
|
82
|
+
* Extends Writable so that fs.createReadStream(...).pipe(res) works
|
|
83
|
+
* (handleServeFile uses pipe()).
|
|
84
|
+
*/
|
|
85
|
+
class FakeRes extends Writable {
|
|
86
|
+
statusCode = 200;
|
|
87
|
+
_headers = {};
|
|
88
|
+
_chunks = [];
|
|
89
|
+
_resolvePromise = null;
|
|
90
|
+
finished;
|
|
91
|
+
constructor() {
|
|
92
|
+
super();
|
|
93
|
+
this.finished = new Promise((resolve) => {
|
|
94
|
+
this._resolvePromise = resolve;
|
|
95
|
+
});
|
|
96
|
+
// When the Writable stream finishes, resolve the promise
|
|
97
|
+
this.on('finish', () => {
|
|
98
|
+
this._resolvePromise?.({
|
|
99
|
+
status: this.statusCode,
|
|
100
|
+
headers: { ...this._headers },
|
|
101
|
+
body: Buffer.concat(this._chunks),
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
// Required by Writable
|
|
106
|
+
_write(chunk, _encoding, callback) {
|
|
107
|
+
this._chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
108
|
+
callback();
|
|
109
|
+
}
|
|
110
|
+
setHeader(name, value) {
|
|
111
|
+
this._headers[name.toLowerCase()] = String(value);
|
|
112
|
+
}
|
|
113
|
+
writeHead(statusCode, headers) {
|
|
114
|
+
this.statusCode = statusCode;
|
|
115
|
+
if (headers) {
|
|
116
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
117
|
+
this._headers[k.toLowerCase()] = String(v);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return this;
|
|
121
|
+
}
|
|
122
|
+
// Override end() to handle the (data, encoding) signature used by send()
|
|
123
|
+
end(chunk, encoding, _cb) {
|
|
124
|
+
if (chunk != null && typeof chunk !== 'function') {
|
|
125
|
+
this._chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
126
|
+
}
|
|
127
|
+
// Signal Writable stream finish
|
|
128
|
+
super.end();
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function tryJsonParse(buf) {
|
|
133
|
+
try {
|
|
134
|
+
return buf.toString('utf8');
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return buf.toString('base64');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Dispatch an RPC request through handleRequest and return the response.
|
|
142
|
+
*/
|
|
143
|
+
export async function dispatchRpc(rpc, handleRequest) {
|
|
144
|
+
const req = new FakeReq(rpc);
|
|
145
|
+
const res = new FakeRes();
|
|
146
|
+
await handleRequest(req, res);
|
|
147
|
+
const result = await res.finished;
|
|
148
|
+
return {
|
|
149
|
+
id: rpc.id,
|
|
150
|
+
status: result.status,
|
|
151
|
+
headers: result.headers,
|
|
152
|
+
body: tryJsonParse(result.body),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SignalingClient — persistent WSS connection to the ShellChat signaling server.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Authenticate with the signaling server on connect (gateway-auth)
|
|
6
|
+
* - Relay ICE offers/answers for WebRTC negotiation
|
|
7
|
+
* - Report active DataChannel connection count
|
|
8
|
+
* - Handle server push messages (force-update, account-suspended, device-limit-updated)
|
|
9
|
+
* - Reconnect with exponential backoff on unintentional disconnects
|
|
10
|
+
*
|
|
11
|
+
* Per spec: signaling server sends WS pings every 30s; the `ws` library
|
|
12
|
+
* handles pong automatically. If no ping is received for 90s (3 missed),
|
|
13
|
+
* the connection is considered dead and we reconnect.
|
|
14
|
+
*/
|
|
15
|
+
import { EventEmitter } from 'node:events';
|
|
16
|
+
export declare class SignalingClient extends EventEmitter {
|
|
17
|
+
private readonly serverUrl;
|
|
18
|
+
private readonly userId;
|
|
19
|
+
private readonly apiKey;
|
|
20
|
+
private ws;
|
|
21
|
+
/** True only after gateway-auth-ok has been received. */
|
|
22
|
+
private _connected;
|
|
23
|
+
/**
|
|
24
|
+
* Set to true by disconnect() or after an auth rejection.
|
|
25
|
+
* Prevents reconnect loops when the close is intentional.
|
|
26
|
+
*/
|
|
27
|
+
private intentionalClose;
|
|
28
|
+
/** Number of consecutive reconnect attempts (resets on successful auth). */
|
|
29
|
+
private reconnectAttempts;
|
|
30
|
+
/** Timer handle for the scheduled reconnect. */
|
|
31
|
+
private reconnectTimer;
|
|
32
|
+
/** Timer handle for ping-timeout watchdog. */
|
|
33
|
+
private pingWatchdog;
|
|
34
|
+
constructor(serverUrl: string, userId: string, apiKey: string);
|
|
35
|
+
/** Returns true when gateway-auth-ok has been received on the current socket. */
|
|
36
|
+
get isConnected(): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Open the WebSocket connection and perform the gateway-auth handshake.
|
|
39
|
+
* Resolves once the socket is open (not necessarily authenticated yet).
|
|
40
|
+
* Authentication outcome is signalled via 'connected' / 'auth-rejected' /
|
|
41
|
+
* 'version-rejected' events.
|
|
42
|
+
*/
|
|
43
|
+
connect(): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Intentionally close the connection. Suppresses reconnection.
|
|
46
|
+
*/
|
|
47
|
+
disconnect(): void;
|
|
48
|
+
/**
|
|
49
|
+
* Send a connection-count report to the signaling server.
|
|
50
|
+
* Call whenever a DataChannel opens or closes.
|
|
51
|
+
*/
|
|
52
|
+
reportConnectionCount(active: number): void;
|
|
53
|
+
/**
|
|
54
|
+
* Send an ICE answer back to the signaling server in response to an
|
|
55
|
+
* ice-offer that was forwarded to us by the server.
|
|
56
|
+
*/
|
|
57
|
+
sendIceAnswer(connectionId: string, sdp: string, candidates: unknown[]): void;
|
|
58
|
+
/**
|
|
59
|
+
* Send a trickle ICE candidate from the plugin to a browser via signaling.
|
|
60
|
+
*/
|
|
61
|
+
sendIceCandidate(connectionId: string, candidate: unknown): void;
|
|
62
|
+
private _openSocket;
|
|
63
|
+
private _handleMessage;
|
|
64
|
+
private _send;
|
|
65
|
+
private _scheduleReconnect;
|
|
66
|
+
private _clearReconnectTimer;
|
|
67
|
+
/**
|
|
68
|
+
* Restart the 90-second watchdog timer. Called on gateway-auth-ok and on
|
|
69
|
+
* every received ping frame. If the timer fires, the connection is dead
|
|
70
|
+
* and we force a reconnect.
|
|
71
|
+
*/
|
|
72
|
+
private _resetPingWatchdog;
|
|
73
|
+
private _clearPingWatchdog;
|
|
74
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SignalingClient — persistent WSS connection to the ShellChat signaling server.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Authenticate with the signaling server on connect (gateway-auth)
|
|
6
|
+
* - Relay ICE offers/answers for WebRTC negotiation
|
|
7
|
+
* - Report active DataChannel connection count
|
|
8
|
+
* - Handle server push messages (force-update, account-suspended, device-limit-updated)
|
|
9
|
+
* - Reconnect with exponential backoff on unintentional disconnects
|
|
10
|
+
*
|
|
11
|
+
* Per spec: signaling server sends WS pings every 30s; the `ws` library
|
|
12
|
+
* handles pong automatically. If no ping is received for 90s (3 missed),
|
|
13
|
+
* the connection is considered dead and we reconnect.
|
|
14
|
+
*/
|
|
15
|
+
import { EventEmitter } from 'node:events';
|
|
16
|
+
import { WebSocket } from 'ws';
|
|
17
|
+
import { PLUGIN_VERSION } from './index.js';
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Constants
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
/** Backoff delays in ms: 1s, 2s, 4s, 8s, 16s, capped at 30s */
|
|
22
|
+
const BACKOFF_BASE_MS = 1_000;
|
|
23
|
+
const BACKOFF_MAX_MS = 30_000;
|
|
24
|
+
/**
|
|
25
|
+
* How long to wait without receiving a WS ping before declaring the
|
|
26
|
+
* connection dead. Signaling server pings every 30s; three missed = 90s.
|
|
27
|
+
*/
|
|
28
|
+
const PING_TIMEOUT_MS = 90_000;
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// SignalingClient
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
export class SignalingClient extends EventEmitter {
|
|
33
|
+
serverUrl;
|
|
34
|
+
userId;
|
|
35
|
+
apiKey;
|
|
36
|
+
ws = null;
|
|
37
|
+
/** True only after gateway-auth-ok has been received. */
|
|
38
|
+
_connected = false;
|
|
39
|
+
/**
|
|
40
|
+
* Set to true by disconnect() or after an auth rejection.
|
|
41
|
+
* Prevents reconnect loops when the close is intentional.
|
|
42
|
+
*/
|
|
43
|
+
intentionalClose = false;
|
|
44
|
+
/** Number of consecutive reconnect attempts (resets on successful auth). */
|
|
45
|
+
reconnectAttempts = 0;
|
|
46
|
+
/** Timer handle for the scheduled reconnect. */
|
|
47
|
+
reconnectTimer = null;
|
|
48
|
+
/** Timer handle for ping-timeout watchdog. */
|
|
49
|
+
pingWatchdog = null;
|
|
50
|
+
constructor(serverUrl, userId, apiKey) {
|
|
51
|
+
super();
|
|
52
|
+
this.serverUrl = serverUrl;
|
|
53
|
+
this.userId = userId;
|
|
54
|
+
this.apiKey = apiKey;
|
|
55
|
+
}
|
|
56
|
+
// -------------------------------------------------------------------------
|
|
57
|
+
// Public API
|
|
58
|
+
// -------------------------------------------------------------------------
|
|
59
|
+
/** Returns true when gateway-auth-ok has been received on the current socket. */
|
|
60
|
+
get isConnected() {
|
|
61
|
+
return this._connected;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Open the WebSocket connection and perform the gateway-auth handshake.
|
|
65
|
+
* Resolves once the socket is open (not necessarily authenticated yet).
|
|
66
|
+
* Authentication outcome is signalled via 'connected' / 'auth-rejected' /
|
|
67
|
+
* 'version-rejected' events.
|
|
68
|
+
*/
|
|
69
|
+
connect() {
|
|
70
|
+
this.intentionalClose = false;
|
|
71
|
+
return this._openSocket();
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Intentionally close the connection. Suppresses reconnection.
|
|
75
|
+
*/
|
|
76
|
+
disconnect() {
|
|
77
|
+
this.intentionalClose = true;
|
|
78
|
+
this._clearReconnectTimer();
|
|
79
|
+
this._clearPingWatchdog();
|
|
80
|
+
this._connected = false;
|
|
81
|
+
if (this.ws) {
|
|
82
|
+
this.ws.close();
|
|
83
|
+
this.ws = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Send a connection-count report to the signaling server.
|
|
88
|
+
* Call whenever a DataChannel opens or closes.
|
|
89
|
+
*/
|
|
90
|
+
reportConnectionCount(active) {
|
|
91
|
+
this._send({ type: 'connection-count', active });
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Send an ICE answer back to the signaling server in response to an
|
|
95
|
+
* ice-offer that was forwarded to us by the server.
|
|
96
|
+
*/
|
|
97
|
+
sendIceAnswer(connectionId, sdp, candidates) {
|
|
98
|
+
this._send({ type: 'ice-answer', connectionId, sdp, candidates });
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Send a trickle ICE candidate from the plugin to a browser via signaling.
|
|
102
|
+
*/
|
|
103
|
+
sendIceCandidate(connectionId, candidate) {
|
|
104
|
+
this._send({ type: 'ice-candidate', connectionId, candidate });
|
|
105
|
+
}
|
|
106
|
+
// -------------------------------------------------------------------------
|
|
107
|
+
// Internal — socket lifecycle
|
|
108
|
+
// -------------------------------------------------------------------------
|
|
109
|
+
_openSocket() {
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
// Guard: never open two sockets simultaneously
|
|
112
|
+
if (this.ws) {
|
|
113
|
+
this.ws.removeAllListeners();
|
|
114
|
+
this.ws.close();
|
|
115
|
+
this.ws = null;
|
|
116
|
+
}
|
|
117
|
+
let settled = false;
|
|
118
|
+
const settle = (err) => {
|
|
119
|
+
if (settled)
|
|
120
|
+
return;
|
|
121
|
+
settled = true;
|
|
122
|
+
if (err)
|
|
123
|
+
reject(err);
|
|
124
|
+
else
|
|
125
|
+
resolve();
|
|
126
|
+
};
|
|
127
|
+
const socket = new WebSocket(this.serverUrl);
|
|
128
|
+
this.ws = socket;
|
|
129
|
+
socket.on('open', () => {
|
|
130
|
+
// Send gateway-auth as the first message
|
|
131
|
+
this._send({
|
|
132
|
+
type: 'gateway-auth',
|
|
133
|
+
userId: this.userId,
|
|
134
|
+
apiKey: this.apiKey,
|
|
135
|
+
pluginVersion: PLUGIN_VERSION,
|
|
136
|
+
});
|
|
137
|
+
// Resolve the connect() promise: the socket is open and auth is in flight
|
|
138
|
+
settle();
|
|
139
|
+
});
|
|
140
|
+
socket.on('ping', () => {
|
|
141
|
+
// ws library handles sending the pong automatically.
|
|
142
|
+
// We just need to reset our watchdog timer.
|
|
143
|
+
this._resetPingWatchdog();
|
|
144
|
+
});
|
|
145
|
+
socket.on('message', (raw) => {
|
|
146
|
+
this._handleMessage(raw);
|
|
147
|
+
});
|
|
148
|
+
socket.on('error', (err) => {
|
|
149
|
+
// Reject connect() if we haven't resolved yet; otherwise log only
|
|
150
|
+
if (!settled) {
|
|
151
|
+
settle(err);
|
|
152
|
+
}
|
|
153
|
+
// The 'close' event will fire after 'error', so reconnection is
|
|
154
|
+
// handled there — no duplicate reconnect scheduling needed here.
|
|
155
|
+
});
|
|
156
|
+
socket.on('close', (_code, _reason) => {
|
|
157
|
+
this._connected = false;
|
|
158
|
+
this._clearPingWatchdog();
|
|
159
|
+
this.ws = null;
|
|
160
|
+
this.emit('disconnected');
|
|
161
|
+
if (!settled) {
|
|
162
|
+
// connect() is still pending — reject it
|
|
163
|
+
settle(new Error('WebSocket closed before open'));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (!this.intentionalClose) {
|
|
167
|
+
this._scheduleReconnect();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
// -------------------------------------------------------------------------
|
|
173
|
+
// Internal — message handling
|
|
174
|
+
// -------------------------------------------------------------------------
|
|
175
|
+
_handleMessage(raw) {
|
|
176
|
+
let msg;
|
|
177
|
+
try {
|
|
178
|
+
msg = JSON.parse(raw.toString());
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
console.error('[SignalingClient] Received non-JSON message, ignoring');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const type = msg['type'];
|
|
185
|
+
switch (type) {
|
|
186
|
+
case 'gateway-auth-ok': {
|
|
187
|
+
this._connected = true;
|
|
188
|
+
this.reconnectAttempts = 0;
|
|
189
|
+
this._resetPingWatchdog();
|
|
190
|
+
this.emit('connected');
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case 'gateway-auth-rejected': {
|
|
194
|
+
const reason = msg['reason'] ?? 'unknown';
|
|
195
|
+
console.error(`[SignalingClient] Auth rejected: ${reason}`);
|
|
196
|
+
// Do not reconnect after auth rejection — the API key is invalid
|
|
197
|
+
this.intentionalClose = true;
|
|
198
|
+
this.emit('auth-rejected', reason);
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
case 'version-rejected': {
|
|
202
|
+
const current = msg['current'] ?? PLUGIN_VERSION;
|
|
203
|
+
const minimum = msg['minimum'] ?? '';
|
|
204
|
+
console.error(`[SignalingClient] Version rejected: current=${current}, minimum=${minimum}`);
|
|
205
|
+
// Do not reconnect — must upgrade first; auto-update logic is in updater.ts
|
|
206
|
+
this.intentionalClose = true;
|
|
207
|
+
this.emit('version-rejected', current, minimum);
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
case 'ice-offer': {
|
|
211
|
+
const offer = {
|
|
212
|
+
connectionId: msg['connectionId'] ?? '',
|
|
213
|
+
sdp: msg['sdp'] ?? '',
|
|
214
|
+
candidates: msg['candidates'] ?? [],
|
|
215
|
+
};
|
|
216
|
+
this.emit('ice-offer', offer);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case 'ice-servers': {
|
|
220
|
+
// ICE server config (STUN/TURN) arrives before the offer for a connection.
|
|
221
|
+
const connectionId = msg['connectionId'] ?? '';
|
|
222
|
+
const iceServers = msg['iceServers'] ?? [];
|
|
223
|
+
this.emit('ice-servers', { connectionId, iceServers });
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case 'ice-candidate': {
|
|
227
|
+
// Trickle ICE candidate from browser, relayed by signaling server.
|
|
228
|
+
const connectionId = msg['connectionId'] ?? '';
|
|
229
|
+
const candidate = msg['candidate'] ?? null;
|
|
230
|
+
this.emit('ice-candidate', { connectionId, candidate });
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
case 'force-update': {
|
|
234
|
+
const targetVersion = msg['targetVersion'] ?? '';
|
|
235
|
+
const reason = msg['reason'] ?? '';
|
|
236
|
+
this.emit('force-update', targetVersion, reason);
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
case 'account-suspended': {
|
|
240
|
+
const reason = msg['reason'] ?? '';
|
|
241
|
+
// Stop reconnecting — account is suspended
|
|
242
|
+
this.intentionalClose = true;
|
|
243
|
+
this.emit('account-suspended', reason);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case 'device-limit-updated': {
|
|
247
|
+
const deviceLimit = msg['deviceLimit'] ?? 0;
|
|
248
|
+
this.emit('device-limit-updated', deviceLimit);
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
default: {
|
|
252
|
+
// Unknown messages are silently ignored to allow forward compatibility
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// -------------------------------------------------------------------------
|
|
258
|
+
// Internal — send helper
|
|
259
|
+
// -------------------------------------------------------------------------
|
|
260
|
+
_send(payload) {
|
|
261
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
this.ws.send(JSON.stringify(payload));
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
console.error('[SignalingClient] Send error:', err);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// -------------------------------------------------------------------------
|
|
272
|
+
// Internal — reconnection
|
|
273
|
+
// -------------------------------------------------------------------------
|
|
274
|
+
_scheduleReconnect() {
|
|
275
|
+
if (this.intentionalClose)
|
|
276
|
+
return;
|
|
277
|
+
// Exponential backoff: 1s * 2^attempt, capped at BACKOFF_MAX_MS
|
|
278
|
+
const delay = Math.min(BACKOFF_BASE_MS * Math.pow(2, this.reconnectAttempts), BACKOFF_MAX_MS);
|
|
279
|
+
this.reconnectAttempts++;
|
|
280
|
+
console.log(`[SignalingClient] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
281
|
+
this.reconnectTimer = setTimeout(() => {
|
|
282
|
+
this.reconnectTimer = null;
|
|
283
|
+
if (this.intentionalClose)
|
|
284
|
+
return;
|
|
285
|
+
this._openSocket().catch((err) => {
|
|
286
|
+
console.error('[SignalingClient] Reconnect failed:', err);
|
|
287
|
+
// The 'close' event will have already scheduled the next attempt
|
|
288
|
+
});
|
|
289
|
+
}, delay);
|
|
290
|
+
}
|
|
291
|
+
_clearReconnectTimer() {
|
|
292
|
+
if (this.reconnectTimer !== null) {
|
|
293
|
+
clearTimeout(this.reconnectTimer);
|
|
294
|
+
this.reconnectTimer = null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// -------------------------------------------------------------------------
|
|
298
|
+
// Internal — ping watchdog
|
|
299
|
+
// -------------------------------------------------------------------------
|
|
300
|
+
/**
|
|
301
|
+
* Restart the 90-second watchdog timer. Called on gateway-auth-ok and on
|
|
302
|
+
* every received ping frame. If the timer fires, the connection is dead
|
|
303
|
+
* and we force a reconnect.
|
|
304
|
+
*/
|
|
305
|
+
_resetPingWatchdog() {
|
|
306
|
+
this._clearPingWatchdog();
|
|
307
|
+
this.pingWatchdog = setTimeout(() => {
|
|
308
|
+
console.warn('[SignalingClient] No ping received for 90s — connection presumed dead, reconnecting');
|
|
309
|
+
// Force-close the socket; the 'close' handler will schedule reconnect
|
|
310
|
+
if (this.ws) {
|
|
311
|
+
this.ws.terminate();
|
|
312
|
+
this.ws = null;
|
|
313
|
+
}
|
|
314
|
+
}, PING_TIMEOUT_MS);
|
|
315
|
+
}
|
|
316
|
+
_clearPingWatchdog() {
|
|
317
|
+
if (this.pingWatchdog !== null) {
|
|
318
|
+
clearTimeout(this.pingWatchdog);
|
|
319
|
+
this.pingWatchdog = null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-update checker for the @clawchatsai/connector plugin.
|
|
3
|
+
*
|
|
4
|
+
* Checks the npm registry for newer versions and can trigger
|
|
5
|
+
* an in-place update via the OpenClaw plugin CLI.
|
|
6
|
+
*/
|
|
7
|
+
export interface UpdateInfo {
|
|
8
|
+
current: string;
|
|
9
|
+
latest: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Check npm registry for updates.
|
|
13
|
+
* Returns UpdateInfo if a newer version is available, null otherwise.
|
|
14
|
+
* Silently returns null on any network or parse error.
|
|
15
|
+
*/
|
|
16
|
+
export declare function checkForUpdates(): Promise<UpdateInfo | null>;
|
|
17
|
+
/**
|
|
18
|
+
* Run the OpenClaw plugin update command.
|
|
19
|
+
* Throws an Error if the command exits with a non-zero code or times out.
|
|
20
|
+
*/
|
|
21
|
+
export declare function performUpdate(): Promise<void>;
|