agentic-flow 1.9.4 → 1.10.0
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/CHANGELOG.md +246 -0
- package/dist/proxy/adaptive-proxy.js +224 -0
- package/dist/proxy/anthropic-to-gemini.js +2 -2
- package/dist/proxy/http2-proxy-optimized.js +191 -0
- package/dist/proxy/http2-proxy.js +381 -0
- package/dist/proxy/http3-proxy-old.js +331 -0
- package/dist/proxy/http3-proxy.js +51 -0
- package/dist/proxy/websocket-proxy.js +406 -0
- package/dist/utils/auth.js +52 -0
- package/dist/utils/compression-middleware.js +149 -0
- package/dist/utils/connection-pool.js +184 -0
- package/dist/utils/rate-limiter.js +48 -0
- package/dist/utils/response-cache.js +211 -0
- package/dist/utils/streaming-optimizer.js +141 -0
- package/docs/.claude-flow/metrics/performance.json +3 -3
- package/docs/.claude-flow/metrics/task-metrics.json +3 -3
- package/docs/ISSUE-55-VALIDATION.md +152 -0
- package/docs/OPTIMIZATIONS.md +460 -0
- package/docs/README.md +217 -0
- package/docs/issues/ISSUE-xenova-transformers-dependency.md +380 -0
- package/package.json +1 -1
- package/scripts/claude +31 -0
- package/validation/test-gemini-exclusiveMinimum-fix.ts +142 -0
- package/validation/validate-v1.10.0-docker.sh +296 -0
- package/wasm/reasoningbank/reasoningbank_wasm_bg.js +2 -2
- package/wasm/reasoningbank/reasoningbank_wasm_bg.wasm +0 -0
- package/docs/INDEX.md +0 -279
- package/docs/guides/.claude-flow/metrics/agent-metrics.json +0 -1
- package/docs/guides/.claude-flow/metrics/performance.json +0 -9
- package/docs/guides/.claude-flow/metrics/task-metrics.json +0 -10
- package/docs/router/.claude-flow/metrics/agent-metrics.json +0 -1
- package/docs/router/.claude-flow/metrics/performance.json +0 -9
- package/docs/router/.claude-flow/metrics/task-metrics.json +0 -10
- /package/docs/{TEST-V1.7.8.Dockerfile → docker-tests/TEST-V1.7.8.Dockerfile} +0 -0
- /package/docs/{TEST-V1.7.9-NODE20.Dockerfile → docker-tests/TEST-V1.7.9-NODE20.Dockerfile} +0 -0
- /package/docs/{TEST-V1.7.9.Dockerfile → docker-tests/TEST-V1.7.9.Dockerfile} +0 -0
- /package/docs/{v1.7.1-QUICK-START.md → guides/QUICK-START-v1.7.1.md} +0 -0
- /package/docs/{INTEGRATION-COMPLETE.md → integration-docs/INTEGRATION-COMPLETE.md} +0 -0
- /package/docs/{LANDING-PAGE-PROVIDER-CONTENT.md → providers/LANDING-PAGE-PROVIDER-CONTENT.md} +0 -0
- /package/docs/{PROVIDER-FALLBACK-GUIDE.md → providers/PROVIDER-FALLBACK-GUIDE.md} +0 -0
- /package/docs/{PROVIDER-FALLBACK-SUMMARY.md → providers/PROVIDER-FALLBACK-SUMMARY.md} +0 -0
- /package/docs/{QUIC_FINAL_STATUS.md → quic/QUIC_FINAL_STATUS.md} +0 -0
- /package/docs/{README_QUIC_PHASE1.md → quic/README_QUIC_PHASE1.md} +0 -0
- /package/docs/{AGENTDB_TESTING.md → testing/AGENTDB_TESTING.md} +0 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Proxy for LLM Streaming
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Bidirectional: Full-duplex communication
|
|
6
|
+
* - Mobile-friendly: Better for unstable connections
|
|
7
|
+
* - Lower overhead: No HTTP headers per message
|
|
8
|
+
* - Reconnection: Automatic retry on disconnect
|
|
9
|
+
* - Universal support: Works everywhere (browsers, mobile, desktop)
|
|
10
|
+
*
|
|
11
|
+
* Use Case: Fallback for unreliable connections (mobile, poor WiFi)
|
|
12
|
+
*/
|
|
13
|
+
import { WebSocketServer } from 'ws';
|
|
14
|
+
import { createServer } from 'http';
|
|
15
|
+
import { logger } from '../utils/logger.js';
|
|
16
|
+
export class WebSocketProxy {
|
|
17
|
+
wss;
|
|
18
|
+
server;
|
|
19
|
+
config;
|
|
20
|
+
clients = new Map();
|
|
21
|
+
pingInterval;
|
|
22
|
+
activeConnections = 0;
|
|
23
|
+
constructor(config) {
|
|
24
|
+
this.config = config;
|
|
25
|
+
this.server = createServer();
|
|
26
|
+
this.wss = new WebSocketServer({ server: this.server });
|
|
27
|
+
this.setupHandlers();
|
|
28
|
+
this.setupHeartbeat();
|
|
29
|
+
logger.info('WebSocket proxy created', {
|
|
30
|
+
port: config.port,
|
|
31
|
+
pingInterval: config.pingInterval || 30000
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
setupHandlers() {
|
|
35
|
+
this.wss.on('connection', (ws, req) => {
|
|
36
|
+
const clientIp = req.socket.remoteAddress;
|
|
37
|
+
// Check connection limit (DoS protection)
|
|
38
|
+
const maxConnections = this.config.maxConnections || 1000;
|
|
39
|
+
if (this.activeConnections >= maxConnections) {
|
|
40
|
+
logger.warn('Connection limit reached', {
|
|
41
|
+
activeConnections: this.activeConnections,
|
|
42
|
+
maxConnections
|
|
43
|
+
});
|
|
44
|
+
ws.close(1008, 'Server at capacity');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
this.activeConnections++;
|
|
48
|
+
logger.info('WebSocket client connected', {
|
|
49
|
+
clientIp,
|
|
50
|
+
activeConnections: this.activeConnections
|
|
51
|
+
});
|
|
52
|
+
// Initialize client state
|
|
53
|
+
this.clients.set(ws, {
|
|
54
|
+
ws,
|
|
55
|
+
isAlive: true,
|
|
56
|
+
lastPing: new Date(),
|
|
57
|
+
requests: 0
|
|
58
|
+
});
|
|
59
|
+
// Handle incoming messages
|
|
60
|
+
ws.on('message', async (data) => {
|
|
61
|
+
const client = this.clients.get(ws);
|
|
62
|
+
if (!client)
|
|
63
|
+
return;
|
|
64
|
+
client.requests++;
|
|
65
|
+
try {
|
|
66
|
+
const message = JSON.parse(data.toString());
|
|
67
|
+
logger.debug('WebSocket message received', { type: message.type });
|
|
68
|
+
if (message.type === 'ping') {
|
|
69
|
+
// Respond to ping
|
|
70
|
+
ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
|
|
71
|
+
client.isAlive = true;
|
|
72
|
+
client.lastPing = new Date();
|
|
73
|
+
}
|
|
74
|
+
else if (message.type === 'streaming_request') {
|
|
75
|
+
// Handle LLM streaming request
|
|
76
|
+
await this.handleStreamingRequest(ws, message.data);
|
|
77
|
+
}
|
|
78
|
+
else if (message.type === 'non_streaming_request') {
|
|
79
|
+
// Handle non-streaming request
|
|
80
|
+
await this.handleNonStreamingRequest(ws, message.data);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
ws.send(JSON.stringify({
|
|
84
|
+
type: 'error',
|
|
85
|
+
error: `Unknown message type: ${message.type}`
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
logger.error('WebSocket message error', { error: error.message });
|
|
91
|
+
ws.send(JSON.stringify({
|
|
92
|
+
type: 'error',
|
|
93
|
+
error: error.message
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
// Handle pong responses
|
|
98
|
+
ws.on('pong', () => {
|
|
99
|
+
const client = this.clients.get(ws);
|
|
100
|
+
if (client) {
|
|
101
|
+
client.isAlive = true;
|
|
102
|
+
client.lastPing = new Date();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
// Set connection timeout
|
|
106
|
+
const connectionTimeout = this.config.connectionTimeout || 300000; // 5 minutes
|
|
107
|
+
const timeoutHandle = setTimeout(() => {
|
|
108
|
+
logger.warn('Connection timeout', { clientIp });
|
|
109
|
+
ws.close(1000, 'Connection timeout');
|
|
110
|
+
}, connectionTimeout);
|
|
111
|
+
// Handle close
|
|
112
|
+
ws.on('close', () => {
|
|
113
|
+
logger.info('WebSocket client disconnected', { clientIp });
|
|
114
|
+
this.clients.delete(ws);
|
|
115
|
+
this.activeConnections--;
|
|
116
|
+
clearTimeout(timeoutHandle);
|
|
117
|
+
});
|
|
118
|
+
// Handle errors
|
|
119
|
+
ws.on('error', (error) => {
|
|
120
|
+
logger.error('WebSocket error', { clientIp, error: error.message });
|
|
121
|
+
this.clients.delete(ws);
|
|
122
|
+
this.activeConnections--;
|
|
123
|
+
});
|
|
124
|
+
// Send initial handshake
|
|
125
|
+
ws.send(JSON.stringify({
|
|
126
|
+
type: 'connected',
|
|
127
|
+
protocols: ['anthropic-messages-v1'],
|
|
128
|
+
features: ['streaming', 'non-streaming', 'ping-pong']
|
|
129
|
+
}));
|
|
130
|
+
});
|
|
131
|
+
this.wss.on('error', (error) => {
|
|
132
|
+
logger.error('WebSocket server error', { error: error.message });
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
setupHeartbeat() {
|
|
136
|
+
const interval = this.config.pingInterval || 30000; // 30 seconds
|
|
137
|
+
const timeout = this.config.pingTimeout || 60000; // 60 seconds
|
|
138
|
+
this.pingInterval = setInterval(() => {
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
this.clients.forEach((client, ws) => {
|
|
141
|
+
// Check if client responded to last ping
|
|
142
|
+
if (!client.isAlive) {
|
|
143
|
+
const timeSinceLastPing = now - client.lastPing.getTime();
|
|
144
|
+
if (timeSinceLastPing > timeout) {
|
|
145
|
+
logger.warn('Client did not respond to ping, terminating', {
|
|
146
|
+
timeSinceLastPing
|
|
147
|
+
});
|
|
148
|
+
ws.terminate();
|
|
149
|
+
this.clients.delete(ws);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Send ping
|
|
154
|
+
client.isAlive = false;
|
|
155
|
+
ws.ping();
|
|
156
|
+
});
|
|
157
|
+
}, interval);
|
|
158
|
+
}
|
|
159
|
+
async handleStreamingRequest(ws, anthropicReq) {
|
|
160
|
+
try {
|
|
161
|
+
logger.info('WebSocket streaming request', {
|
|
162
|
+
model: anthropicReq.model,
|
|
163
|
+
messageCount: anthropicReq.messages?.length
|
|
164
|
+
});
|
|
165
|
+
// Convert to Gemini format
|
|
166
|
+
const geminiReq = this.convertAnthropicToGemini(anthropicReq);
|
|
167
|
+
// Send streaming start event
|
|
168
|
+
ws.send(JSON.stringify({
|
|
169
|
+
type: 'message_start',
|
|
170
|
+
message: {
|
|
171
|
+
id: `msg_${Date.now()}`,
|
|
172
|
+
role: 'assistant',
|
|
173
|
+
model: anthropicReq.model || 'gemini-2.0-flash-exp'
|
|
174
|
+
}
|
|
175
|
+
}));
|
|
176
|
+
// Forward to Gemini
|
|
177
|
+
const geminiBaseUrl = this.config.geminiBaseUrl || 'https://generativelanguage.googleapis.com/v1beta';
|
|
178
|
+
const url = `${geminiBaseUrl}/models/gemini-2.0-flash-exp:streamGenerateContent?key=${this.config.geminiApiKey}&alt=sse`;
|
|
179
|
+
const response = await fetch(url, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: { 'Content-Type': 'application/json' },
|
|
182
|
+
body: JSON.stringify(geminiReq)
|
|
183
|
+
});
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
const error = await response.text();
|
|
186
|
+
throw new Error(`Gemini API error: ${error}`);
|
|
187
|
+
}
|
|
188
|
+
const reader = response.body?.getReader();
|
|
189
|
+
if (!reader) {
|
|
190
|
+
throw new Error('No response body');
|
|
191
|
+
}
|
|
192
|
+
const decoder = new TextDecoder();
|
|
193
|
+
let chunkCount = 0;
|
|
194
|
+
while (true) {
|
|
195
|
+
const { done, value } = await reader.read();
|
|
196
|
+
if (done)
|
|
197
|
+
break;
|
|
198
|
+
const chunk = decoder.decode(value);
|
|
199
|
+
chunkCount++;
|
|
200
|
+
// Parse Gemini SSE and send as WebSocket messages
|
|
201
|
+
const lines = chunk.split('\n').filter(line => line.trim());
|
|
202
|
+
for (const line of lines) {
|
|
203
|
+
if (line.startsWith('data: ')) {
|
|
204
|
+
try {
|
|
205
|
+
const jsonStr = line.substring(6);
|
|
206
|
+
const parsed = JSON.parse(jsonStr);
|
|
207
|
+
const candidate = parsed.candidates?.[0];
|
|
208
|
+
const text = candidate?.content?.parts?.[0]?.text;
|
|
209
|
+
if (text) {
|
|
210
|
+
// Send text delta
|
|
211
|
+
ws.send(JSON.stringify({
|
|
212
|
+
type: 'content_block_delta',
|
|
213
|
+
delta: { type: 'text_delta', text }
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
216
|
+
if (candidate?.finishReason) {
|
|
217
|
+
// Send completion
|
|
218
|
+
ws.send(JSON.stringify({
|
|
219
|
+
type: 'message_stop',
|
|
220
|
+
stop_reason: 'end_turn'
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
logger.debug('Failed to parse stream chunk', { line });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
logger.info('WebSocket stream complete', { totalChunks: chunkCount });
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
logger.error('WebSocket streaming error', { error: error.message });
|
|
234
|
+
ws.send(JSON.stringify({
|
|
235
|
+
type: 'error',
|
|
236
|
+
error: error.message
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async handleNonStreamingRequest(ws, anthropicReq) {
|
|
241
|
+
try {
|
|
242
|
+
logger.info('WebSocket non-streaming request', {
|
|
243
|
+
model: anthropicReq.model,
|
|
244
|
+
messageCount: anthropicReq.messages?.length
|
|
245
|
+
});
|
|
246
|
+
// Convert to Gemini format
|
|
247
|
+
const geminiReq = this.convertAnthropicToGemini(anthropicReq);
|
|
248
|
+
// Forward to Gemini
|
|
249
|
+
const geminiBaseUrl = this.config.geminiBaseUrl || 'https://generativelanguage.googleapis.com/v1beta';
|
|
250
|
+
const url = `${geminiBaseUrl}/models/gemini-2.0-flash-exp:generateContent?key=${this.config.geminiApiKey}`;
|
|
251
|
+
const response = await fetch(url, {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
headers: { 'Content-Type': 'application/json' },
|
|
254
|
+
body: JSON.stringify(geminiReq)
|
|
255
|
+
});
|
|
256
|
+
if (!response.ok) {
|
|
257
|
+
const error = await response.text();
|
|
258
|
+
throw new Error(`Gemini API error: ${error}`);
|
|
259
|
+
}
|
|
260
|
+
const geminiRes = await response.json();
|
|
261
|
+
const anthropicRes = this.convertGeminiToAnthropic(geminiRes);
|
|
262
|
+
// Send complete response
|
|
263
|
+
ws.send(JSON.stringify({
|
|
264
|
+
type: 'message_complete',
|
|
265
|
+
message: anthropicRes
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
logger.error('WebSocket non-streaming error', { error: error.message });
|
|
270
|
+
ws.send(JSON.stringify({
|
|
271
|
+
type: 'error',
|
|
272
|
+
error: error.message
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
convertAnthropicToGemini(anthropicReq) {
|
|
277
|
+
const contents = [];
|
|
278
|
+
let systemPrefix = '';
|
|
279
|
+
if (anthropicReq.system) {
|
|
280
|
+
systemPrefix = `System: ${anthropicReq.system}\n\n`;
|
|
281
|
+
}
|
|
282
|
+
for (let i = 0; i < anthropicReq.messages.length; i++) {
|
|
283
|
+
const msg = anthropicReq.messages[i];
|
|
284
|
+
let text;
|
|
285
|
+
if (typeof msg.content === 'string') {
|
|
286
|
+
text = msg.content;
|
|
287
|
+
}
|
|
288
|
+
else if (Array.isArray(msg.content)) {
|
|
289
|
+
text = msg.content
|
|
290
|
+
.filter((block) => block.type === 'text')
|
|
291
|
+
.map((block) => block.text)
|
|
292
|
+
.join('\n');
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
text = '';
|
|
296
|
+
}
|
|
297
|
+
if (i === 0 && msg.role === 'user' && systemPrefix) {
|
|
298
|
+
text = systemPrefix + text;
|
|
299
|
+
}
|
|
300
|
+
contents.push({
|
|
301
|
+
role: msg.role === 'assistant' ? 'model' : 'user',
|
|
302
|
+
parts: [{ text }]
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
const geminiReq = { contents };
|
|
306
|
+
if (anthropicReq.temperature !== undefined || anthropicReq.max_tokens !== undefined) {
|
|
307
|
+
geminiReq.generationConfig = {};
|
|
308
|
+
if (anthropicReq.temperature !== undefined) {
|
|
309
|
+
geminiReq.generationConfig.temperature = anthropicReq.temperature;
|
|
310
|
+
}
|
|
311
|
+
if (anthropicReq.max_tokens !== undefined) {
|
|
312
|
+
geminiReq.generationConfig.maxOutputTokens = anthropicReq.max_tokens;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return geminiReq;
|
|
316
|
+
}
|
|
317
|
+
convertGeminiToAnthropic(geminiRes) {
|
|
318
|
+
const candidate = geminiRes.candidates?.[0];
|
|
319
|
+
if (!candidate) {
|
|
320
|
+
throw new Error('No candidates in Gemini response');
|
|
321
|
+
}
|
|
322
|
+
const content = candidate.content;
|
|
323
|
+
const parts = content?.parts || [];
|
|
324
|
+
let rawText = '';
|
|
325
|
+
for (const part of parts) {
|
|
326
|
+
if (part.text) {
|
|
327
|
+
rawText += part.text;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
id: `msg_${Date.now()}`,
|
|
332
|
+
type: 'message',
|
|
333
|
+
role: 'assistant',
|
|
334
|
+
model: 'gemini-2.0-flash-exp',
|
|
335
|
+
content: [
|
|
336
|
+
{
|
|
337
|
+
type: 'text',
|
|
338
|
+
text: rawText
|
|
339
|
+
}
|
|
340
|
+
],
|
|
341
|
+
stop_reason: 'end_turn',
|
|
342
|
+
usage: {
|
|
343
|
+
input_tokens: geminiRes.usageMetadata?.promptTokenCount || 0,
|
|
344
|
+
output_tokens: geminiRes.usageMetadata?.candidatesTokenCount || 0
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
start() {
|
|
349
|
+
return new Promise((resolve) => {
|
|
350
|
+
this.server.listen(this.config.port, () => {
|
|
351
|
+
logger.info('WebSocket proxy started', {
|
|
352
|
+
port: this.config.port,
|
|
353
|
+
url: `ws://localhost:${this.config.port}`
|
|
354
|
+
});
|
|
355
|
+
console.log(`\n✅ WebSocket Proxy running at ws://localhost:${this.config.port}`);
|
|
356
|
+
console.log(` Protocol: WebSocket (fallback for unreliable connections)`);
|
|
357
|
+
console.log(` Features: Bidirectional, Mobile-friendly, Auto-reconnect\n`);
|
|
358
|
+
resolve();
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
stop() {
|
|
363
|
+
return new Promise((resolve) => {
|
|
364
|
+
// Clear ping interval
|
|
365
|
+
if (this.pingInterval) {
|
|
366
|
+
clearInterval(this.pingInterval);
|
|
367
|
+
}
|
|
368
|
+
// Close all client connections
|
|
369
|
+
this.clients.forEach((client, ws) => {
|
|
370
|
+
ws.close(1000, 'Server shutting down');
|
|
371
|
+
});
|
|
372
|
+
this.clients.clear();
|
|
373
|
+
// Close WebSocket server
|
|
374
|
+
this.wss.close(() => {
|
|
375
|
+
// Close HTTP server
|
|
376
|
+
this.server.close(() => {
|
|
377
|
+
logger.info('WebSocket proxy stopped');
|
|
378
|
+
resolve();
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// CLI entry point
|
|
385
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
386
|
+
const port = parseInt(process.env.PORT || '8080');
|
|
387
|
+
const geminiApiKey = process.env.GOOGLE_GEMINI_API_KEY;
|
|
388
|
+
if (!geminiApiKey) {
|
|
389
|
+
console.error('❌ Error: GOOGLE_GEMINI_API_KEY environment variable required');
|
|
390
|
+
process.exit(1);
|
|
391
|
+
}
|
|
392
|
+
const proxy = new WebSocketProxy({
|
|
393
|
+
port,
|
|
394
|
+
geminiApiKey,
|
|
395
|
+
geminiBaseUrl: process.env.GEMINI_BASE_URL,
|
|
396
|
+
pingInterval: 30000,
|
|
397
|
+
pingTimeout: 60000
|
|
398
|
+
});
|
|
399
|
+
proxy.start();
|
|
400
|
+
// Graceful shutdown
|
|
401
|
+
process.on('SIGINT', async () => {
|
|
402
|
+
console.log('\n🛑 Shutting down WebSocket proxy...');
|
|
403
|
+
await proxy.stop();
|
|
404
|
+
process.exit(0);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple API key authentication for proxy
|
|
3
|
+
*/
|
|
4
|
+
export class AuthManager {
|
|
5
|
+
validKeys;
|
|
6
|
+
constructor(apiKeys) {
|
|
7
|
+
// Load from environment or provided keys
|
|
8
|
+
const envKeys = process.env.PROXY_API_KEYS?.split(',').map(k => k.trim()).filter(Boolean) || [];
|
|
9
|
+
const allKeys = [...(apiKeys || []), ...envKeys];
|
|
10
|
+
this.validKeys = new Set(allKeys);
|
|
11
|
+
if (this.validKeys.size === 0) {
|
|
12
|
+
console.warn('⚠️ Warning: No API keys configured for authentication');
|
|
13
|
+
console.warn(' Set PROXY_API_KEYS environment variable or pass keys to constructor');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
authenticate(headers) {
|
|
17
|
+
// If no keys configured, allow all (development mode)
|
|
18
|
+
if (this.validKeys.size === 0) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
// Check x-api-key header
|
|
22
|
+
const apiKey = this.extractApiKey(headers);
|
|
23
|
+
if (!apiKey) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return this.validKeys.has(apiKey);
|
|
27
|
+
}
|
|
28
|
+
extractApiKey(headers) {
|
|
29
|
+
// Try x-api-key header
|
|
30
|
+
let key = headers['x-api-key'];
|
|
31
|
+
// Try authorization header (Bearer token)
|
|
32
|
+
if (!key) {
|
|
33
|
+
const auth = headers['authorization'];
|
|
34
|
+
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
|
|
35
|
+
key = auth.substring(7);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(key)) {
|
|
39
|
+
key = key[0];
|
|
40
|
+
}
|
|
41
|
+
return (typeof key === 'string' && key.length > 0) ? key : null;
|
|
42
|
+
}
|
|
43
|
+
addKey(key) {
|
|
44
|
+
this.validKeys.add(key);
|
|
45
|
+
}
|
|
46
|
+
removeKey(key) {
|
|
47
|
+
this.validKeys.delete(key);
|
|
48
|
+
}
|
|
49
|
+
hasKeys() {
|
|
50
|
+
return this.validKeys.size > 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compression Middleware for Proxy Responses
|
|
3
|
+
* Provides 30-70% bandwidth reduction with Brotli/Gzip
|
|
4
|
+
*/
|
|
5
|
+
import { brotliCompress, gzip, constants } from 'zlib';
|
|
6
|
+
import { promisify } from 'util';
|
|
7
|
+
import { logger } from './logger.js';
|
|
8
|
+
const brotliCompressAsync = promisify(brotliCompress);
|
|
9
|
+
const gzipAsync = promisify(gzip);
|
|
10
|
+
export class CompressionMiddleware {
|
|
11
|
+
config;
|
|
12
|
+
constructor(config = {}) {
|
|
13
|
+
this.config = {
|
|
14
|
+
minSize: config.minSize || 1024, // 1KB minimum
|
|
15
|
+
level: config.level ?? constants.BROTLI_DEFAULT_QUALITY,
|
|
16
|
+
preferredEncoding: config.preferredEncoding || 'br',
|
|
17
|
+
enableBrotli: config.enableBrotli ?? true,
|
|
18
|
+
enableGzip: config.enableGzip ?? true
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Compress data using best available algorithm
|
|
23
|
+
*/
|
|
24
|
+
async compress(data, acceptedEncodings) {
|
|
25
|
+
const startTime = Date.now();
|
|
26
|
+
const originalSize = data.length;
|
|
27
|
+
// Skip compression for small payloads
|
|
28
|
+
if (originalSize < this.config.minSize) {
|
|
29
|
+
return {
|
|
30
|
+
compressed: data,
|
|
31
|
+
encoding: 'identity',
|
|
32
|
+
originalSize,
|
|
33
|
+
compressedSize: originalSize,
|
|
34
|
+
ratio: 1.0,
|
|
35
|
+
duration: 0
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// Determine encoding based on accept-encoding header
|
|
39
|
+
const encoding = this.selectEncoding(acceptedEncodings);
|
|
40
|
+
let compressed;
|
|
41
|
+
try {
|
|
42
|
+
switch (encoding) {
|
|
43
|
+
case 'br':
|
|
44
|
+
compressed = await this.compressBrotli(data);
|
|
45
|
+
break;
|
|
46
|
+
case 'gzip':
|
|
47
|
+
compressed = await this.compressGzip(data);
|
|
48
|
+
break;
|
|
49
|
+
default:
|
|
50
|
+
compressed = data;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
logger.error('Compression failed, using uncompressed', {
|
|
55
|
+
error: error.message
|
|
56
|
+
});
|
|
57
|
+
compressed = data;
|
|
58
|
+
}
|
|
59
|
+
const duration = Date.now() - startTime;
|
|
60
|
+
const compressedSize = compressed.length;
|
|
61
|
+
const ratio = compressedSize / originalSize;
|
|
62
|
+
logger.debug('Compression complete', {
|
|
63
|
+
encoding,
|
|
64
|
+
originalSize,
|
|
65
|
+
compressedSize,
|
|
66
|
+
ratio: `${(ratio * 100).toFixed(2)}%`,
|
|
67
|
+
savings: `${((1 - ratio) * 100).toFixed(2)}%`,
|
|
68
|
+
duration
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
compressed,
|
|
72
|
+
encoding,
|
|
73
|
+
originalSize,
|
|
74
|
+
compressedSize,
|
|
75
|
+
ratio,
|
|
76
|
+
duration
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Compress using Brotli (best compression ratio)
|
|
81
|
+
*/
|
|
82
|
+
async compressBrotli(data) {
|
|
83
|
+
return brotliCompressAsync(data, {
|
|
84
|
+
params: {
|
|
85
|
+
[constants.BROTLI_PARAM_QUALITY]: this.config.level,
|
|
86
|
+
[constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_TEXT
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Compress using Gzip (faster, broader support)
|
|
92
|
+
*/
|
|
93
|
+
async compressGzip(data) {
|
|
94
|
+
return gzipAsync(data, {
|
|
95
|
+
level: Math.min(this.config.level, 9) // Gzip max level is 9
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Select best encoding based on client support
|
|
100
|
+
*/
|
|
101
|
+
selectEncoding(acceptedEncodings) {
|
|
102
|
+
if (!acceptedEncodings) {
|
|
103
|
+
return this.config.preferredEncoding === 'br' && this.config.enableBrotli
|
|
104
|
+
? 'br'
|
|
105
|
+
: this.config.enableGzip
|
|
106
|
+
? 'gzip'
|
|
107
|
+
: 'identity';
|
|
108
|
+
}
|
|
109
|
+
const encodings = acceptedEncodings.toLowerCase().split(',').map(e => e.trim());
|
|
110
|
+
// Prefer Brotli if supported and enabled
|
|
111
|
+
if (encodings.includes('br') && this.config.enableBrotli) {
|
|
112
|
+
return 'br';
|
|
113
|
+
}
|
|
114
|
+
// Fall back to Gzip if supported and enabled
|
|
115
|
+
if (encodings.includes('gzip') && this.config.enableGzip) {
|
|
116
|
+
return 'gzip';
|
|
117
|
+
}
|
|
118
|
+
return 'identity';
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Check if compression is recommended for content type
|
|
122
|
+
*/
|
|
123
|
+
shouldCompress(contentType) {
|
|
124
|
+
if (!contentType)
|
|
125
|
+
return true;
|
|
126
|
+
const type = contentType.toLowerCase();
|
|
127
|
+
// Compressible types
|
|
128
|
+
const compressible = [
|
|
129
|
+
'text/',
|
|
130
|
+
'application/json',
|
|
131
|
+
'application/javascript',
|
|
132
|
+
'application/xml',
|
|
133
|
+
'application/x-www-form-urlencoded'
|
|
134
|
+
];
|
|
135
|
+
return compressible.some(prefix => type.includes(prefix));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Get compression statistics
|
|
139
|
+
*/
|
|
140
|
+
getStats() {
|
|
141
|
+
return {
|
|
142
|
+
config: this.config,
|
|
143
|
+
capabilities: {
|
|
144
|
+
brotli: this.config.enableBrotli,
|
|
145
|
+
gzip: this.config.enableGzip
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|