@phronesis-io/openclaw-eigenflux 0.0.1 → 0.0.2
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/README.md +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/notifier.d.ts +1 -17
- package/dist/notifier.d.ts.map +1 -1
- package/dist/notifier.js +1 -94
- package/dist/notifier.js.map +1 -1
- package/openclaw.plugin.json +4 -4
- package/package.json +1 -2
- package/src/agent-prompt-templates.ts +0 -91
- package/src/config.test.ts +0 -188
- package/src/config.ts +0 -410
- package/src/credentials-loader.test.ts +0 -78
- package/src/credentials-loader.ts +0 -121
- package/src/gateway-rpc-client.test.ts +0 -190
- package/src/gateway-rpc-client.ts +0 -373
- package/src/index.integration.test.ts +0 -437
- package/src/index.test.ts +0 -454
- package/src/index.ts +0 -758
- package/src/logger.ts +0 -27
- package/src/notification-route-resolver.test.ts +0 -136
- package/src/notification-route-resolver.ts +0 -430
- package/src/notifier.test.ts +0 -374
- package/src/notifier.ts +0 -558
- package/src/openclaw-plugin-sdk.d.ts +0 -121
- package/src/pm-polling-client.test.ts +0 -390
- package/src/pm-polling-client.ts +0 -257
- package/src/polling-client.test.ts +0 -279
- package/src/polling-client.ts +0 -283
- package/src/session-route-memory.ts +0 -106
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Credentials loader for the EigenFlux auth token.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import * as fs from 'fs';
|
|
6
|
-
import * as path from 'path';
|
|
7
|
-
import { Logger } from './logger';
|
|
8
|
-
import { PLUGIN_CONFIG } from './config';
|
|
9
|
-
|
|
10
|
-
interface EigenFluxCredentials {
|
|
11
|
-
access_token: string;
|
|
12
|
-
email?: string;
|
|
13
|
-
expires_at?: number;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export type AuthState =
|
|
17
|
-
| {
|
|
18
|
-
status: 'available';
|
|
19
|
-
accessToken: string;
|
|
20
|
-
source: 'file';
|
|
21
|
-
credentialsPath: string;
|
|
22
|
-
expiresAt?: number;
|
|
23
|
-
email?: string;
|
|
24
|
-
}
|
|
25
|
-
| {
|
|
26
|
-
status: 'missing' | 'expired';
|
|
27
|
-
source?: 'file';
|
|
28
|
-
credentialsPath: string;
|
|
29
|
-
expiresAt?: number;
|
|
30
|
-
email?: string;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export class CredentialsLoader {
|
|
34
|
-
private readonly logger: Logger;
|
|
35
|
-
private readonly workdir: string;
|
|
36
|
-
|
|
37
|
-
constructor(logger: Logger, workdir: string) {
|
|
38
|
-
this.logger = logger;
|
|
39
|
-
this.workdir = workdir;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
loadAccessToken(): string | null {
|
|
43
|
-
const authState = this.loadAuthState();
|
|
44
|
-
if (authState.status !== 'available') {
|
|
45
|
-
if (authState.status === 'missing') {
|
|
46
|
-
this.logger.error(`No access token found in ${authState.credentialsPath}`);
|
|
47
|
-
}
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
return authState.accessToken;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
loadAuthState(): AuthState {
|
|
54
|
-
const credentialsPath = this.resolveCredentialsPath();
|
|
55
|
-
|
|
56
|
-
if (fs.existsSync(credentialsPath)) {
|
|
57
|
-
try {
|
|
58
|
-
const content = fs.readFileSync(credentialsPath, 'utf-8');
|
|
59
|
-
const credentials: EigenFluxCredentials = JSON.parse(content);
|
|
60
|
-
|
|
61
|
-
if (credentials.access_token) {
|
|
62
|
-
if (credentials.expires_at) {
|
|
63
|
-
const now = Date.now();
|
|
64
|
-
if (now >= credentials.expires_at) {
|
|
65
|
-
this.logger.warn('Access token has expired');
|
|
66
|
-
return {
|
|
67
|
-
status: 'expired',
|
|
68
|
-
source: 'file',
|
|
69
|
-
credentialsPath,
|
|
70
|
-
expiresAt: credentials.expires_at,
|
|
71
|
-
email: credentials.email,
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
this.logger.info(`Loaded access token from ${credentialsPath}`);
|
|
77
|
-
return {
|
|
78
|
-
status: 'available',
|
|
79
|
-
accessToken: credentials.access_token,
|
|
80
|
-
source: 'file',
|
|
81
|
-
credentialsPath,
|
|
82
|
-
expiresAt: credentials.expires_at,
|
|
83
|
-
email: credentials.email,
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
} catch (error) {
|
|
87
|
-
this.logger.error(`Failed to read credentials file: ${credentialsPath}`, error);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return {
|
|
92
|
-
status: 'missing',
|
|
93
|
-
credentialsPath,
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
saveAccessToken(token: string, email?: string, expiresAt?: number): void {
|
|
98
|
-
const credentialsPath = this.resolveCredentialsPath();
|
|
99
|
-
|
|
100
|
-
if (!fs.existsSync(this.workdir)) {
|
|
101
|
-
fs.mkdirSync(this.workdir, { recursive: true });
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const credentials: EigenFluxCredentials = {
|
|
105
|
-
access_token: token,
|
|
106
|
-
email,
|
|
107
|
-
expires_at: expiresAt,
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
try {
|
|
111
|
-
fs.writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2), 'utf-8');
|
|
112
|
-
this.logger.info(`Saved access token to ${credentialsPath}`);
|
|
113
|
-
} catch (error) {
|
|
114
|
-
this.logger.error('Failed to save credentials file', error);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
private resolveCredentialsPath(): string {
|
|
119
|
-
return path.join(this.workdir, PLUGIN_CONFIG.CREDENTIALS_FILE);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
import http from 'http';
|
|
2
|
-
import { WebSocketServer } from 'ws';
|
|
3
|
-
import { OpenClawGatewayRpcClient } from './gateway-rpc-client';
|
|
4
|
-
import { Logger } from './logger';
|
|
5
|
-
|
|
6
|
-
describe('OpenClawGatewayRpcClient', () => {
|
|
7
|
-
let server: http.Server;
|
|
8
|
-
let wss: WebSocketServer;
|
|
9
|
-
let port: number;
|
|
10
|
-
|
|
11
|
-
beforeEach(async () => {
|
|
12
|
-
server = http.createServer();
|
|
13
|
-
wss = new WebSocketServer({ server });
|
|
14
|
-
await new Promise<void>((resolve) => {
|
|
15
|
-
server.listen(0, '127.0.0.1', () => {
|
|
16
|
-
port = (server.address() as any).port;
|
|
17
|
-
resolve();
|
|
18
|
-
});
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
afterEach(async () => {
|
|
23
|
-
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
|
24
|
-
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test('uses configured session key and sends agent directly', async () => {
|
|
28
|
-
const methods: string[] = [];
|
|
29
|
-
let agentParams: any = null;
|
|
30
|
-
|
|
31
|
-
wss.on('connection', (socket) => {
|
|
32
|
-
socket.send(
|
|
33
|
-
JSON.stringify({
|
|
34
|
-
type: 'event',
|
|
35
|
-
event: 'connect.challenge',
|
|
36
|
-
payload: { nonce: 'nonce-test-1' },
|
|
37
|
-
})
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
socket.on('message', (raw) => {
|
|
41
|
-
const frame = JSON.parse(raw.toString());
|
|
42
|
-
methods.push(String(frame.method || ''));
|
|
43
|
-
|
|
44
|
-
if (frame.method === 'connect') {
|
|
45
|
-
socket.send(
|
|
46
|
-
JSON.stringify({
|
|
47
|
-
type: 'res',
|
|
48
|
-
id: frame.id,
|
|
49
|
-
ok: true,
|
|
50
|
-
payload: {
|
|
51
|
-
protocol: 3,
|
|
52
|
-
server: { version: 'test', connId: 'conn-1' },
|
|
53
|
-
features: { methods: ['agent'], events: [] },
|
|
54
|
-
snapshot: { ts: Date.now() },
|
|
55
|
-
policy: { maxPayload: 1000000, maxBufferedBytes: 1000000, tickIntervalMs: 30000 },
|
|
56
|
-
},
|
|
57
|
-
})
|
|
58
|
-
);
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (frame.method === 'agent') {
|
|
63
|
-
agentParams = frame.params;
|
|
64
|
-
socket.send(
|
|
65
|
-
JSON.stringify({
|
|
66
|
-
type: 'res',
|
|
67
|
-
id: frame.id,
|
|
68
|
-
ok: true,
|
|
69
|
-
payload: { status: 'started', runId: 'run-1' },
|
|
70
|
-
})
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
const client = new OpenClawGatewayRpcClient({
|
|
77
|
-
gatewayUrl: `ws://127.0.0.1:${port}`,
|
|
78
|
-
gatewayToken: 'gw_token_1',
|
|
79
|
-
sessionKey: 'agent:test:feishu:direct:ou_123',
|
|
80
|
-
agentId: 'test',
|
|
81
|
-
replyChannel: 'feishu',
|
|
82
|
-
replyTo: 'ou_123',
|
|
83
|
-
logger: new Logger({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }),
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
const result = await client.sendAgentMessage('[EIGENFLUX_TEST] first payload');
|
|
87
|
-
|
|
88
|
-
expect(result).toEqual({
|
|
89
|
-
sessionKey: 'agent:test:feishu:direct:ou_123',
|
|
90
|
-
runId: 'run-1',
|
|
91
|
-
});
|
|
92
|
-
expect(methods).toEqual(['connect', 'agent']);
|
|
93
|
-
expect(agentParams).toEqual(
|
|
94
|
-
expect.objectContaining({
|
|
95
|
-
sessionKey: 'agent:test:feishu:direct:ou_123',
|
|
96
|
-
agentId: 'test',
|
|
97
|
-
message: '[EIGENFLUX_TEST] first payload',
|
|
98
|
-
deliver: true,
|
|
99
|
-
replyChannel: 'feishu',
|
|
100
|
-
replyTo: 'ou_123',
|
|
101
|
-
})
|
|
102
|
-
);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test('resolves session key from sessions.list when not configured', async () => {
|
|
106
|
-
const methods: string[] = [];
|
|
107
|
-
let agentParams: any = null;
|
|
108
|
-
|
|
109
|
-
wss.on('connection', (socket) => {
|
|
110
|
-
socket.send(
|
|
111
|
-
JSON.stringify({
|
|
112
|
-
type: 'event',
|
|
113
|
-
event: 'connect.challenge',
|
|
114
|
-
payload: { nonce: 'nonce-test-2' },
|
|
115
|
-
})
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
socket.on('message', (raw) => {
|
|
119
|
-
const frame = JSON.parse(raw.toString());
|
|
120
|
-
methods.push(String(frame.method || ''));
|
|
121
|
-
|
|
122
|
-
if (frame.method === 'connect') {
|
|
123
|
-
socket.send(
|
|
124
|
-
JSON.stringify({
|
|
125
|
-
type: 'res',
|
|
126
|
-
id: frame.id,
|
|
127
|
-
ok: true,
|
|
128
|
-
payload: {
|
|
129
|
-
protocol: 3,
|
|
130
|
-
server: { version: 'test', connId: 'conn-2' },
|
|
131
|
-
features: { methods: ['sessions.list', 'agent'], events: [] },
|
|
132
|
-
snapshot: { ts: Date.now() },
|
|
133
|
-
policy: { maxPayload: 1000000, maxBufferedBytes: 1000000, tickIntervalMs: 30000 },
|
|
134
|
-
},
|
|
135
|
-
})
|
|
136
|
-
);
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (frame.method === 'sessions.list') {
|
|
141
|
-
socket.send(
|
|
142
|
-
JSON.stringify({
|
|
143
|
-
type: 'res',
|
|
144
|
-
id: frame.id,
|
|
145
|
-
ok: true,
|
|
146
|
-
payload: {
|
|
147
|
-
sessions: [{ key: 'agent:foo:main' }, { key: 'agent:foo:feishu:direct:ou_999', active: true }],
|
|
148
|
-
},
|
|
149
|
-
})
|
|
150
|
-
);
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (frame.method === 'agent') {
|
|
155
|
-
agentParams = frame.params;
|
|
156
|
-
socket.send(
|
|
157
|
-
JSON.stringify({
|
|
158
|
-
type: 'res',
|
|
159
|
-
id: frame.id,
|
|
160
|
-
ok: true,
|
|
161
|
-
payload: { status: 'started', runId: 'run-2' },
|
|
162
|
-
})
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
const client = new OpenClawGatewayRpcClient({
|
|
169
|
-
gatewayUrl: `ws://127.0.0.1:${port}`,
|
|
170
|
-
gatewayToken: 'gw_token_2',
|
|
171
|
-
logger: new Logger({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }),
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
const result = await client.sendAgentMessage('[EIGENFLUX_TEST] second payload');
|
|
175
|
-
|
|
176
|
-
expect(result).toEqual({
|
|
177
|
-
sessionKey: 'agent:foo:feishu:direct:ou_999',
|
|
178
|
-
runId: 'run-2',
|
|
179
|
-
});
|
|
180
|
-
expect(methods).toEqual(['connect', 'sessions.list', 'agent']);
|
|
181
|
-
expect(agentParams).toEqual(
|
|
182
|
-
expect.objectContaining({
|
|
183
|
-
sessionKey: 'agent:foo:feishu:direct:ou_999',
|
|
184
|
-
agentId: 'foo',
|
|
185
|
-
message: '[EIGENFLUX_TEST] second payload',
|
|
186
|
-
deliver: true,
|
|
187
|
-
})
|
|
188
|
-
);
|
|
189
|
-
});
|
|
190
|
-
});
|
|
@@ -1,373 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import WebSocket from 'ws';
|
|
3
|
-
import { Logger } from './logger';
|
|
4
|
-
|
|
5
|
-
const GATEWAY_PROTOCOL_VERSION = 3;
|
|
6
|
-
const DEFAULT_CONNECT_TIMEOUT_MS = 8000;
|
|
7
|
-
const DEFAULT_REQUEST_TIMEOUT_MS = 10000;
|
|
8
|
-
const DEFAULT_SESSION_KEY = 'main';
|
|
9
|
-
const DEFAULT_AGENT_ID = 'main';
|
|
10
|
-
|
|
11
|
-
type GatewayRequest = {
|
|
12
|
-
type: 'req';
|
|
13
|
-
id: string;
|
|
14
|
-
method: string;
|
|
15
|
-
params?: unknown;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
type GatewayResponse = {
|
|
19
|
-
type: 'res';
|
|
20
|
-
id: string;
|
|
21
|
-
ok: boolean;
|
|
22
|
-
payload?: any;
|
|
23
|
-
error?: {
|
|
24
|
-
code?: string;
|
|
25
|
-
message?: string;
|
|
26
|
-
};
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
type GatewayEvent = {
|
|
30
|
-
type: 'event';
|
|
31
|
-
event: string;
|
|
32
|
-
payload?: any;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
type PendingRequest = {
|
|
36
|
-
timer: NodeJS.Timeout;
|
|
37
|
-
resolve: (payload: any) => void;
|
|
38
|
-
reject: (error: Error) => void;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
export type OpenClawGatewayRpcClientOptions = {
|
|
42
|
-
gatewayUrl: string;
|
|
43
|
-
gatewayToken?: string;
|
|
44
|
-
sessionKey?: string;
|
|
45
|
-
agentId?: string;
|
|
46
|
-
replyChannel?: string;
|
|
47
|
-
replyTo?: string;
|
|
48
|
-
replyAccountId?: string;
|
|
49
|
-
logger: Logger;
|
|
50
|
-
connectTimeoutMs?: number;
|
|
51
|
-
requestTimeoutMs?: number;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
export class OpenClawGatewayRpcClient {
|
|
55
|
-
private readonly options: OpenClawGatewayRpcClientOptions;
|
|
56
|
-
|
|
57
|
-
constructor(options: OpenClawGatewayRpcClientOptions) {
|
|
58
|
-
this.options = options;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async sendAgentMessage(message: string): Promise<{ sessionKey: string; runId: string }> {
|
|
62
|
-
return this.withConnection(async (conn) => {
|
|
63
|
-
const sessionKey = this.options.sessionKey || (await this.resolveSessionKey(conn));
|
|
64
|
-
const agentId = this.options.agentId?.trim() || this.resolveAgentIdFromSessionKey(sessionKey);
|
|
65
|
-
const idempotencyKey = randomUUID();
|
|
66
|
-
|
|
67
|
-
const response = await conn.request('agent', {
|
|
68
|
-
sessionKey,
|
|
69
|
-
agentId,
|
|
70
|
-
message,
|
|
71
|
-
deliver: true,
|
|
72
|
-
...(this.options.replyChannel ? { replyChannel: this.options.replyChannel } : {}),
|
|
73
|
-
...(this.options.replyTo ? { replyTo: this.options.replyTo } : {}),
|
|
74
|
-
...(this.options.replyAccountId
|
|
75
|
-
? { replyAccountId: this.options.replyAccountId }
|
|
76
|
-
: {}),
|
|
77
|
-
idempotencyKey,
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
const runId = String(response?.runId || idempotencyKey);
|
|
81
|
-
return { sessionKey, runId };
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
private resolveAgentIdFromSessionKey(sessionKey: string | undefined): string {
|
|
86
|
-
const trimmed = sessionKey?.trim() || '';
|
|
87
|
-
const parts = trimmed.split(':').filter((part) => part.length > 0);
|
|
88
|
-
if (parts[0]?.toLowerCase() === 'agent' && typeof parts[1] === 'string' && parts[1].trim()) {
|
|
89
|
-
return parts[1].trim().toLowerCase();
|
|
90
|
-
}
|
|
91
|
-
return DEFAULT_AGENT_ID;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
private async resolveSessionKey(conn: GatewayConnection): Promise<string> {
|
|
95
|
-
try {
|
|
96
|
-
const response = await conn.request('sessions.list', {
|
|
97
|
-
limit: 20,
|
|
98
|
-
includeGlobal: true,
|
|
99
|
-
includeUnknown: true,
|
|
100
|
-
includeLastMessage: false,
|
|
101
|
-
});
|
|
102
|
-
const sessions = Array.isArray(response?.sessions)
|
|
103
|
-
? (response.sessions as Array<{ key?: unknown; active?: unknown; kind?: unknown }>)
|
|
104
|
-
: [];
|
|
105
|
-
const byMainKey = sessions.find((entry) => entry && entry.key === DEFAULT_SESSION_KEY);
|
|
106
|
-
if (byMainKey && typeof byMainKey.key === 'string') {
|
|
107
|
-
return DEFAULT_SESSION_KEY;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const byMainKind = sessions.find(
|
|
111
|
-
(entry) => entry && typeof entry.key === 'string' && String(entry.kind || '').toLowerCase() === 'main'
|
|
112
|
-
);
|
|
113
|
-
if (byMainKind && typeof byMainKind.key === 'string') {
|
|
114
|
-
return byMainKind.key;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const byActive = sessions.find(
|
|
118
|
-
(entry) => entry && typeof entry.key === 'string' && entry.active === true
|
|
119
|
-
);
|
|
120
|
-
if (byActive && typeof byActive.key === 'string') {
|
|
121
|
-
return byActive.key;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const first = sessions.find((entry) => entry && typeof entry.key === 'string');
|
|
125
|
-
if (first && typeof first.key === 'string') {
|
|
126
|
-
return first.key;
|
|
127
|
-
}
|
|
128
|
-
} catch (error) {
|
|
129
|
-
this.options.logger.warn(
|
|
130
|
-
`sessions.list failed, fallback to "${DEFAULT_SESSION_KEY}": ${this.formatError(error)}`
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
return DEFAULT_SESSION_KEY;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
private async withConnection<T>(fn: (conn: GatewayConnection) => Promise<T>): Promise<T> {
|
|
137
|
-
const conn = new GatewayConnection({
|
|
138
|
-
gatewayUrl: this.options.gatewayUrl,
|
|
139
|
-
gatewayToken: this.options.gatewayToken,
|
|
140
|
-
logger: this.options.logger,
|
|
141
|
-
connectTimeoutMs: this.options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
|
142
|
-
requestTimeoutMs: this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
await conn.connect();
|
|
147
|
-
return await fn(conn);
|
|
148
|
-
} finally {
|
|
149
|
-
await conn.close();
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
private formatError(error: unknown): string {
|
|
154
|
-
if (error instanceof Error) {
|
|
155
|
-
return `${error.name}: ${error.message}`;
|
|
156
|
-
}
|
|
157
|
-
return String(error);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
type GatewayConnectionOptions = {
|
|
162
|
-
gatewayUrl: string;
|
|
163
|
-
gatewayToken?: string;
|
|
164
|
-
logger: Logger;
|
|
165
|
-
connectTimeoutMs: number;
|
|
166
|
-
requestTimeoutMs: number;
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
class GatewayConnection {
|
|
170
|
-
private readonly options: GatewayConnectionOptions;
|
|
171
|
-
private ws: WebSocket | null = null;
|
|
172
|
-
private connectNonce: string | null = null;
|
|
173
|
-
private connected = false;
|
|
174
|
-
private pending = new Map<string, PendingRequest>();
|
|
175
|
-
|
|
176
|
-
constructor(options: GatewayConnectionOptions) {
|
|
177
|
-
this.options = options;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
async connect(): Promise<void> {
|
|
181
|
-
if (this.connected) {
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
this.ws = new WebSocket(this.options.gatewayUrl, {
|
|
186
|
-
maxPayload: 25 * 1024 * 1024,
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
return new Promise<void>((resolve, reject) => {
|
|
190
|
-
let settled = false;
|
|
191
|
-
let connectRequested = false;
|
|
192
|
-
const connectTimer = setTimeout(() => {
|
|
193
|
-
onConnectError(new Error(`Gateway connect timeout after ${this.options.connectTimeoutMs}ms`));
|
|
194
|
-
}, this.options.connectTimeoutMs);
|
|
195
|
-
|
|
196
|
-
const cleanup = () => {
|
|
197
|
-
clearTimeout(connectTimer);
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
const settle = (fn: () => void) => {
|
|
201
|
-
if (settled) {
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
settled = true;
|
|
205
|
-
cleanup();
|
|
206
|
-
fn();
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
const onConnectError = (error: unknown) => {
|
|
210
|
-
settle(() => {
|
|
211
|
-
reject(error instanceof Error ? error : new Error(String(error)));
|
|
212
|
-
void this.close();
|
|
213
|
-
});
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
this.ws?.on('error', onConnectError);
|
|
217
|
-
this.ws?.on('close', () => {
|
|
218
|
-
if (!this.connected) {
|
|
219
|
-
onConnectError(new Error('Gateway closed before connect'));
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
this.rejectAllPending(new Error('Gateway connection closed'));
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
this.ws?.on('message', (data) => {
|
|
226
|
-
const raw = data.toString();
|
|
227
|
-
let frame: GatewayEvent | GatewayResponse | null = null;
|
|
228
|
-
try {
|
|
229
|
-
frame = JSON.parse(raw);
|
|
230
|
-
} catch {
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
if (!frame || typeof frame !== 'object') {
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
if (frame.type === 'event') {
|
|
237
|
-
try {
|
|
238
|
-
this.handleEventFrame(frame);
|
|
239
|
-
} catch (error) {
|
|
240
|
-
onConnectError(error);
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
if (frame.event === 'connect.challenge' && !connectRequested) {
|
|
244
|
-
connectRequested = true;
|
|
245
|
-
void this.request('connect', this.buildConnectParams())
|
|
246
|
-
.then(() => {
|
|
247
|
-
this.connected = true;
|
|
248
|
-
settle(() => resolve());
|
|
249
|
-
})
|
|
250
|
-
.catch(onConnectError);
|
|
251
|
-
}
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
if (frame.type === 'res') {
|
|
255
|
-
try {
|
|
256
|
-
this.handleResponseFrame(frame);
|
|
257
|
-
} catch (error) {
|
|
258
|
-
onConnectError(error);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
async close(): Promise<void> {
|
|
266
|
-
this.rejectAllPending(new Error('Gateway request cancelled: connection closed'));
|
|
267
|
-
if (!this.ws) {
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
const ws = this.ws;
|
|
271
|
-
this.ws = null;
|
|
272
|
-
this.connected = false;
|
|
273
|
-
await new Promise<void>((resolve) => {
|
|
274
|
-
ws.once('close', () => resolve());
|
|
275
|
-
ws.close(1000);
|
|
276
|
-
setTimeout(() => resolve(), 1000);
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
async request(method: string, params?: unknown): Promise<any> {
|
|
281
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
282
|
-
throw new Error(`Gateway request failed (${method}): websocket is not open`);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
const id = randomUUID();
|
|
286
|
-
const frame: GatewayRequest = {
|
|
287
|
-
type: 'req',
|
|
288
|
-
id,
|
|
289
|
-
method,
|
|
290
|
-
params,
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
return new Promise<any>((resolve, reject) => {
|
|
294
|
-
const timer = setTimeout(() => {
|
|
295
|
-
this.pending.delete(id);
|
|
296
|
-
reject(new Error(`Gateway request timeout (${method}) after ${this.options.requestTimeoutMs}ms`));
|
|
297
|
-
}, this.options.requestTimeoutMs);
|
|
298
|
-
|
|
299
|
-
this.pending.set(id, { timer, resolve, reject });
|
|
300
|
-
this.ws?.send(JSON.stringify(frame), (error) => {
|
|
301
|
-
if (!error) {
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
clearTimeout(timer);
|
|
305
|
-
this.pending.delete(id);
|
|
306
|
-
reject(error instanceof Error ? error : new Error(String(error)));
|
|
307
|
-
});
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
private handleEventFrame(frame: GatewayEvent): void {
|
|
312
|
-
if (frame.event !== 'connect.challenge') {
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
const nonce = frame.payload?.nonce;
|
|
316
|
-
if (typeof nonce !== 'string' || nonce.trim().length === 0) {
|
|
317
|
-
throw new Error('Gateway connect.challenge missing nonce');
|
|
318
|
-
}
|
|
319
|
-
this.connectNonce = nonce.trim();
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
private handleResponseFrame(frame: GatewayResponse): void {
|
|
323
|
-
const pending = this.pending.get(frame.id);
|
|
324
|
-
if (!pending) {
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
327
|
-
clearTimeout(pending.timer);
|
|
328
|
-
this.pending.delete(frame.id);
|
|
329
|
-
if (frame.ok) {
|
|
330
|
-
pending.resolve(frame.payload);
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
const message = frame.error?.message || 'unknown gateway error';
|
|
334
|
-
pending.reject(new Error(message));
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
private buildConnectParams(): Record<string, unknown> {
|
|
338
|
-
if (!this.connectNonce) {
|
|
339
|
-
throw new Error('Gateway connect failed: missing challenge nonce');
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const authToken =
|
|
343
|
-
typeof this.options.gatewayToken === 'string' ? this.options.gatewayToken.trim() : '';
|
|
344
|
-
|
|
345
|
-
const params: Record<string, unknown> = {
|
|
346
|
-
minProtocol: GATEWAY_PROTOCOL_VERSION,
|
|
347
|
-
maxProtocol: GATEWAY_PROTOCOL_VERSION,
|
|
348
|
-
client: {
|
|
349
|
-
id: 'eigenflux-gateway-client',
|
|
350
|
-
displayName: 'eigenflux',
|
|
351
|
-
version: '1.0.0',
|
|
352
|
-
platform: process.platform,
|
|
353
|
-
mode: 'backend',
|
|
354
|
-
},
|
|
355
|
-
role: 'operator',
|
|
356
|
-
scopes: ['operator.admin'],
|
|
357
|
-
};
|
|
358
|
-
|
|
359
|
-
if (authToken) {
|
|
360
|
-
params.auth = { token: authToken };
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
return params;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
private rejectAllPending(error: Error): void {
|
|
367
|
-
for (const [id, pending] of this.pending.entries()) {
|
|
368
|
-
clearTimeout(pending.timer);
|
|
369
|
-
this.pending.delete(id);
|
|
370
|
-
pending.reject(error);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
}
|