@openserv-labs/sdk 1.8.2 → 2.0.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/README.md +217 -5
- package/dist/agent.d.ts +3 -2
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +43 -7
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +18 -4
- package/dist/run.d.ts +58 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +108 -0
- package/dist/tunnel.d.ts +189 -0
- package/dist/tunnel.d.ts.map +1 -0
- package/dist/tunnel.js +803 -0
- package/package.json +5 -1
package/dist/tunnel.js
ADDED
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.OpenServTunnel = void 0;
|
|
7
|
+
const ws_1 = __importDefault(require("ws"));
|
|
8
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
9
|
+
const logger_1 = require("./logger");
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Constants
|
|
12
|
+
// ============================================================================
|
|
13
|
+
const DEFAULT_PROXY_URL = process.env.OPENSERV_PROXY_URL || 'https://agents-proxy.openserv.ai';
|
|
14
|
+
const MAX_RECONNECTION_ATTEMPTS = 10;
|
|
15
|
+
const MAX_RESPONSE_SIZE = 100 * 1024 * 1024; // 100MB max response body
|
|
16
|
+
const WS_TERMINATE_TIMEOUT = 3000; // 3 seconds to wait before force-terminating WebSocket
|
|
17
|
+
// Reusable HTTP agent for local forwarding (keep-alive to avoid TCP connection overhead)
|
|
18
|
+
const localAgent = new node_http_1.default.Agent({ keepAlive: true, maxSockets: 64 });
|
|
19
|
+
/**
|
|
20
|
+
* Valid state transitions. Maps current state to events and their target states.
|
|
21
|
+
*/
|
|
22
|
+
const VALID_TRANSITIONS = {
|
|
23
|
+
idle: {
|
|
24
|
+
START: 'starting',
|
|
25
|
+
STOP: 'stopped'
|
|
26
|
+
},
|
|
27
|
+
starting: {
|
|
28
|
+
SETUP_COMPLETE: 'connecting',
|
|
29
|
+
SETUP_FAILED: 'failed',
|
|
30
|
+
STOP: 'stopping'
|
|
31
|
+
},
|
|
32
|
+
connecting: {
|
|
33
|
+
WS_OPEN: 'authenticating',
|
|
34
|
+
WS_CLOSE: 'reconnect_delay',
|
|
35
|
+
WS_ERROR: 'reconnect_delay', // Error triggers reconnect with backoff
|
|
36
|
+
STOP: 'stopping'
|
|
37
|
+
},
|
|
38
|
+
authenticating: {
|
|
39
|
+
AUTH_SUCCESS: 'connected',
|
|
40
|
+
AUTH_ERROR: 'failed', // Auth errors are fatal, no retry
|
|
41
|
+
WS_CLOSE: 'reconnect_delay',
|
|
42
|
+
WS_ERROR: 'reconnect_delay', // Error triggers reconnect with backoff
|
|
43
|
+
STOP: 'stopping'
|
|
44
|
+
},
|
|
45
|
+
connected: {
|
|
46
|
+
WS_CLOSE: 'reconnect_delay',
|
|
47
|
+
WS_ERROR: 'reconnect_delay', // Error triggers reconnect with backoff
|
|
48
|
+
GRACEFUL_RECONNECT: 'awaiting_reconnect_ack',
|
|
49
|
+
STOP: 'stopping'
|
|
50
|
+
},
|
|
51
|
+
awaiting_reconnect_ack: {
|
|
52
|
+
RECONNECT_ACK: 'reconnect_delay',
|
|
53
|
+
ACK_TIMEOUT: 'reconnect_delay',
|
|
54
|
+
WS_CLOSE: 'reconnect_delay',
|
|
55
|
+
WS_ERROR: 'reconnect_delay', // Error triggers reconnect with backoff
|
|
56
|
+
STOP: 'stopping'
|
|
57
|
+
},
|
|
58
|
+
reconnect_delay: {
|
|
59
|
+
DELAY_COMPLETE: 'connecting',
|
|
60
|
+
MAX_RETRIES: 'failed',
|
|
61
|
+
STOP: 'stopping'
|
|
62
|
+
},
|
|
63
|
+
stopping: {
|
|
64
|
+
CLEANUP_COMPLETE: 'stopped'
|
|
65
|
+
},
|
|
66
|
+
failed: {
|
|
67
|
+
STOP: 'stopped',
|
|
68
|
+
START: 'starting' // Allow restart after failure
|
|
69
|
+
},
|
|
70
|
+
stopped: {
|
|
71
|
+
START: 'starting' // Allow restart after stop
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
// ============================================================================
|
|
75
|
+
// Helper Functions
|
|
76
|
+
// ============================================================================
|
|
77
|
+
async function forwardToLocalAgent(localPort, requestData) {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const { method, path, headers, body } = requestData;
|
|
80
|
+
// Remove hop-by-hop headers that shouldn't be forwarded
|
|
81
|
+
const cleanHeaders = { ...headers };
|
|
82
|
+
delete cleanHeaders.connection;
|
|
83
|
+
delete cleanHeaders.upgrade;
|
|
84
|
+
delete cleanHeaders['proxy-connection'];
|
|
85
|
+
delete cleanHeaders['transfer-encoding'];
|
|
86
|
+
delete cleanHeaders.host; // Don't forward original host - can confuse local routing
|
|
87
|
+
delete cleanHeaders['content-length']; // Will be set based on actual body bytes
|
|
88
|
+
delete cleanHeaders['keep-alive'];
|
|
89
|
+
delete cleanHeaders.te;
|
|
90
|
+
delete cleanHeaders.trailer;
|
|
91
|
+
// Handle request body - support binary (base64) encoding from proxy
|
|
92
|
+
// Headers are case-insensitive, so check common variations
|
|
93
|
+
const encoding = headers['x-openserv-encoding'] ??
|
|
94
|
+
headers['X-OpenServ-Encoding'] ??
|
|
95
|
+
headers['X-Openserv-Encoding'];
|
|
96
|
+
// Handle body: undefined means no body, null/empty string means empty body
|
|
97
|
+
const bodyBuffer = body === undefined
|
|
98
|
+
? undefined
|
|
99
|
+
: encoding === 'base64'
|
|
100
|
+
? Buffer.from(body, 'base64')
|
|
101
|
+
: Buffer.from(body, 'utf8');
|
|
102
|
+
// Always set content-length when body field exists (even if empty)
|
|
103
|
+
// This ensures POST/PUT with empty body get Content-Length: 0
|
|
104
|
+
if (bodyBuffer !== undefined) {
|
|
105
|
+
cleanHeaders['content-length'] = String(bodyBuffer.length);
|
|
106
|
+
}
|
|
107
|
+
// Remove encoding headers after processing - they're not for the local agent
|
|
108
|
+
delete cleanHeaders['x-openserv-encoding'];
|
|
109
|
+
delete cleanHeaders['X-OpenServ-Encoding'];
|
|
110
|
+
delete cleanHeaders['X-Openserv-Encoding'];
|
|
111
|
+
const options = {
|
|
112
|
+
hostname: 'localhost',
|
|
113
|
+
port: localPort,
|
|
114
|
+
path: path,
|
|
115
|
+
method: method,
|
|
116
|
+
headers: cleanHeaders,
|
|
117
|
+
agent: localAgent
|
|
118
|
+
};
|
|
119
|
+
const req = node_http_1.default.request(options, res => {
|
|
120
|
+
const chunks = [];
|
|
121
|
+
let totalSize = 0;
|
|
122
|
+
let responseTooLarge = false;
|
|
123
|
+
res.on('data', (chunk) => {
|
|
124
|
+
if (responseTooLarge)
|
|
125
|
+
return;
|
|
126
|
+
totalSize += chunk.length;
|
|
127
|
+
if (totalSize > MAX_RESPONSE_SIZE) {
|
|
128
|
+
responseTooLarge = true;
|
|
129
|
+
req.destroy();
|
|
130
|
+
reject(new Error(`Response too large (exceeds ${MAX_RESPONSE_SIZE / 1024 / 1024}MB limit)`));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
chunks.push(chunk);
|
|
134
|
+
});
|
|
135
|
+
res.on('error', (error) => {
|
|
136
|
+
reject(new Error(`Response stream error: ${error.message}`));
|
|
137
|
+
});
|
|
138
|
+
res.on('end', () => {
|
|
139
|
+
if (!responseTooLarge) {
|
|
140
|
+
const responseBuffer = Buffer.concat(chunks);
|
|
141
|
+
const contentType = res.headers['content-type'] || '';
|
|
142
|
+
// Check if content is text-based (can be safely converted to string)
|
|
143
|
+
const isTextContent = contentType.includes('text/') ||
|
|
144
|
+
contentType.includes('application/json') ||
|
|
145
|
+
contentType.includes('application/xml') ||
|
|
146
|
+
contentType.includes('application/javascript') ||
|
|
147
|
+
contentType.includes('+json') ||
|
|
148
|
+
contentType.includes('+xml');
|
|
149
|
+
// For text content, convert to UTF-8 string; for binary, use base64
|
|
150
|
+
const responseBody = isTextContent
|
|
151
|
+
? responseBuffer.toString('utf8')
|
|
152
|
+
: responseBuffer.toString('base64');
|
|
153
|
+
// Build clean response headers - strip hop-by-hop headers
|
|
154
|
+
const responseHeaders = {
|
|
155
|
+
...res.headers
|
|
156
|
+
};
|
|
157
|
+
// Remove hop-by-hop headers that shouldn't be forwarded back
|
|
158
|
+
delete responseHeaders.connection;
|
|
159
|
+
delete responseHeaders['keep-alive'];
|
|
160
|
+
delete responseHeaders['transfer-encoding'];
|
|
161
|
+
delete responseHeaders.te;
|
|
162
|
+
delete responseHeaders.trailer;
|
|
163
|
+
delete responseHeaders.upgrade;
|
|
164
|
+
// Remove original content-length - will be recalculated based on actual body
|
|
165
|
+
delete responseHeaders['content-length'];
|
|
166
|
+
// Set correct content-length based on the (possibly re-encoded) body
|
|
167
|
+
const bodyBytes = Buffer.byteLength(responseBody, 'utf8');
|
|
168
|
+
responseHeaders['content-length'] = String(bodyBytes);
|
|
169
|
+
// Add encoding header for binary responses
|
|
170
|
+
if (!isTextContent) {
|
|
171
|
+
responseHeaders['x-openserv-encoding'] = 'base64';
|
|
172
|
+
}
|
|
173
|
+
resolve({
|
|
174
|
+
status: res.statusCode || 500,
|
|
175
|
+
headers: responseHeaders,
|
|
176
|
+
body: responseBody
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
req.on('error', (error) => {
|
|
182
|
+
reject(new Error(`Local agent connection failed: ${error.message}`));
|
|
183
|
+
});
|
|
184
|
+
// Use explicit setTimeout for more reliable timeout handling
|
|
185
|
+
// Node's options.timeout behavior can be subtle
|
|
186
|
+
req.setTimeout(120000, () => {
|
|
187
|
+
req.destroy(new Error('Request to local agent timed out'));
|
|
188
|
+
});
|
|
189
|
+
if (bodyBuffer !== undefined) {
|
|
190
|
+
req.write(bodyBuffer);
|
|
191
|
+
}
|
|
192
|
+
req.end();
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
// ============================================================================
|
|
196
|
+
// OpenServ Tunnel
|
|
197
|
+
// ============================================================================
|
|
198
|
+
/**
|
|
199
|
+
* OpenServ Tunnel
|
|
200
|
+
*
|
|
201
|
+
* Connects local agent servers to the OpenServ proxy service.
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* ```typescript
|
|
205
|
+
* const tunnel = new OpenServTunnel({
|
|
206
|
+
* apiKey: process.env.OPENSERV_API_KEY
|
|
207
|
+
* })
|
|
208
|
+
*
|
|
209
|
+
* await tunnel.start(7378)
|
|
210
|
+
* console.log('Tunnel connected')
|
|
211
|
+
*
|
|
212
|
+
* // Later...
|
|
213
|
+
* await tunnel.stop()
|
|
214
|
+
* ```
|
|
215
|
+
*/
|
|
216
|
+
class OpenServTunnel {
|
|
217
|
+
// Configuration
|
|
218
|
+
proxyUrl;
|
|
219
|
+
apiKey;
|
|
220
|
+
localPort = 0;
|
|
221
|
+
// WebSocket connection
|
|
222
|
+
ws = null;
|
|
223
|
+
// State machine
|
|
224
|
+
state = 'idle';
|
|
225
|
+
context = {
|
|
226
|
+
reconnectAttempts: 0,
|
|
227
|
+
disconnectedAt: null,
|
|
228
|
+
hasConnectedOnce: false,
|
|
229
|
+
lastError: null
|
|
230
|
+
};
|
|
231
|
+
// Timers (managed by state machine)
|
|
232
|
+
delayTimeoutId = null;
|
|
233
|
+
ackTimeoutId = null;
|
|
234
|
+
// Pending promises for async operations
|
|
235
|
+
pendingStart = null;
|
|
236
|
+
pendingGracefulReconnect = null;
|
|
237
|
+
pendingStop = null;
|
|
238
|
+
// Callbacks
|
|
239
|
+
onConnected;
|
|
240
|
+
onRequest;
|
|
241
|
+
onError;
|
|
242
|
+
constructor(options = {}) {
|
|
243
|
+
this.proxyUrl = options.proxyUrl || DEFAULT_PROXY_URL;
|
|
244
|
+
this.apiKey = options.apiKey || process.env.OPENSERV_API_KEY || '';
|
|
245
|
+
this.onConnected = options.onConnected;
|
|
246
|
+
this.onRequest = options.onRequest;
|
|
247
|
+
this.onError = options.onError;
|
|
248
|
+
}
|
|
249
|
+
// ============================================================================
|
|
250
|
+
// State Machine Core
|
|
251
|
+
// ============================================================================
|
|
252
|
+
/**
|
|
253
|
+
* Get the current state of the tunnel.
|
|
254
|
+
*/
|
|
255
|
+
getState() {
|
|
256
|
+
return this.state;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Attempt to transition to a new state based on an event.
|
|
260
|
+
* Returns true if the transition was valid and executed, false otherwise.
|
|
261
|
+
*/
|
|
262
|
+
transition(event) {
|
|
263
|
+
const validTransitions = VALID_TRANSITIONS[this.state];
|
|
264
|
+
const nextState = validTransitions[event];
|
|
265
|
+
if (!nextState) {
|
|
266
|
+
// Invalid transition - log and ignore
|
|
267
|
+
logger_1.logger.warn(`Invalid transition: ${this.state} + ${event} (no valid target state)`);
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
const previousState = this.state;
|
|
271
|
+
logger_1.logger.debug(`State transition: ${previousState} -> ${nextState} (${event})`);
|
|
272
|
+
// Exit current state
|
|
273
|
+
this.exitState(previousState);
|
|
274
|
+
// Update state
|
|
275
|
+
this.state = nextState;
|
|
276
|
+
// Enter new state
|
|
277
|
+
this.enterState(nextState, event, previousState);
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Actions to perform when exiting a state.
|
|
282
|
+
*/
|
|
283
|
+
exitState(state) {
|
|
284
|
+
switch (state) {
|
|
285
|
+
case 'reconnect_delay':
|
|
286
|
+
// Clear delay timer when leaving reconnect_delay state
|
|
287
|
+
if (this.delayTimeoutId) {
|
|
288
|
+
clearTimeout(this.delayTimeoutId);
|
|
289
|
+
this.delayTimeoutId = null;
|
|
290
|
+
}
|
|
291
|
+
break;
|
|
292
|
+
case 'awaiting_reconnect_ack':
|
|
293
|
+
// Clear ack timeout when leaving awaiting_reconnect_ack state
|
|
294
|
+
if (this.ackTimeoutId) {
|
|
295
|
+
clearTimeout(this.ackTimeoutId);
|
|
296
|
+
this.ackTimeoutId = null;
|
|
297
|
+
}
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Actions to perform when entering a state.
|
|
303
|
+
*/
|
|
304
|
+
enterState(state, _event, previousState) {
|
|
305
|
+
switch (state) {
|
|
306
|
+
case 'starting':
|
|
307
|
+
this.doStarting();
|
|
308
|
+
break;
|
|
309
|
+
case 'connecting':
|
|
310
|
+
this.doConnect();
|
|
311
|
+
break;
|
|
312
|
+
case 'authenticating':
|
|
313
|
+
this.doAuthenticate();
|
|
314
|
+
break;
|
|
315
|
+
case 'connected':
|
|
316
|
+
void this.doConnected();
|
|
317
|
+
break;
|
|
318
|
+
case 'awaiting_reconnect_ack':
|
|
319
|
+
this.doAwaitReconnectAck();
|
|
320
|
+
break;
|
|
321
|
+
case 'reconnect_delay':
|
|
322
|
+
this.doReconnectDelay(previousState);
|
|
323
|
+
break;
|
|
324
|
+
case 'stopping':
|
|
325
|
+
this.doStopping();
|
|
326
|
+
break;
|
|
327
|
+
case 'failed':
|
|
328
|
+
this.doFailed();
|
|
329
|
+
break;
|
|
330
|
+
case 'stopped':
|
|
331
|
+
this.doStopped();
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// ============================================================================
|
|
336
|
+
// State Entry Actions
|
|
337
|
+
// ============================================================================
|
|
338
|
+
/**
|
|
339
|
+
* Entry action for 'starting' state: validate and initialize.
|
|
340
|
+
* Resets context and validates API key before proceeding to connect.
|
|
341
|
+
*/
|
|
342
|
+
doStarting() {
|
|
343
|
+
// Reset context for fresh start
|
|
344
|
+
this.context = {
|
|
345
|
+
reconnectAttempts: 0,
|
|
346
|
+
disconnectedAt: null,
|
|
347
|
+
hasConnectedOnce: false,
|
|
348
|
+
lastError: null
|
|
349
|
+
};
|
|
350
|
+
// Validate API key
|
|
351
|
+
if (!this.apiKey) {
|
|
352
|
+
logger_1.logger.error('API key is required. Set OPENSERV_API_KEY environment variable or pass apiKey option.');
|
|
353
|
+
this.context.lastError = new Error('API key is required');
|
|
354
|
+
this.transition('SETUP_FAILED');
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// Proceed to connecting
|
|
358
|
+
this.transition('SETUP_COMPLETE');
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Entry action for 'connecting' state: create WebSocket connection.
|
|
362
|
+
*/
|
|
363
|
+
doConnect() {
|
|
364
|
+
this.setupWebSocket();
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Entry action for 'authenticating' state: send auth message.
|
|
368
|
+
*/
|
|
369
|
+
doAuthenticate() {
|
|
370
|
+
const authMessage = {
|
|
371
|
+
type: 'auth',
|
|
372
|
+
apiKey: this.apiKey,
|
|
373
|
+
localPort: this.localPort
|
|
374
|
+
};
|
|
375
|
+
this.ws?.send(JSON.stringify(authMessage));
|
|
376
|
+
logger_1.logger.info('Authenticating...');
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Entry action for 'connected' state: resolve promises, call callbacks.
|
|
380
|
+
*/
|
|
381
|
+
async doConnected() {
|
|
382
|
+
// Reset reconnect attempts on successful connection
|
|
383
|
+
this.context.reconnectAttempts = 0;
|
|
384
|
+
const isReconnect = this.context.hasConnectedOnce;
|
|
385
|
+
if (isReconnect) {
|
|
386
|
+
if (this.context.disconnectedAt) {
|
|
387
|
+
const duration = ((Date.now() - this.context.disconnectedAt) / 1000).toFixed(2);
|
|
388
|
+
logger_1.logger.info(`Tunnel reconnected (${duration}s)`);
|
|
389
|
+
this.context.disconnectedAt = null;
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
logger_1.logger.info('Tunnel reconnected');
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
logger_1.logger.info('Tunnel connected');
|
|
397
|
+
this.context.hasConnectedOnce = true;
|
|
398
|
+
}
|
|
399
|
+
// Resolve pending promises
|
|
400
|
+
this.pendingStart?.resolve();
|
|
401
|
+
this.pendingStart = null;
|
|
402
|
+
this.pendingGracefulReconnect?.resolve();
|
|
403
|
+
this.pendingGracefulReconnect = null;
|
|
404
|
+
// Call user callback
|
|
405
|
+
await this.onConnected?.(isReconnect);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Entry action for 'awaiting_reconnect_ack' state: send will-reconnect and start timeout.
|
|
409
|
+
*/
|
|
410
|
+
doAwaitReconnectAck() {
|
|
411
|
+
if (!this.ws || this.ws.readyState !== ws_1.default.OPEN) {
|
|
412
|
+
// WebSocket not available, go directly to reconnect_delay
|
|
413
|
+
this.transition('ACK_TIMEOUT');
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
logger_1.logger.info('Initiating graceful reconnection...');
|
|
417
|
+
this.ws.send(JSON.stringify({ type: 'will-reconnect' }));
|
|
418
|
+
logger_1.logger.info('Sent will-reconnect to server...');
|
|
419
|
+
// Start timeout for ack
|
|
420
|
+
this.ackTimeoutId = setTimeout(() => {
|
|
421
|
+
logger_1.logger.info('Will-reconnect ack timeout, proceeding with reconnection');
|
|
422
|
+
this.transition('ACK_TIMEOUT');
|
|
423
|
+
}, 2000);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Entry action for 'reconnect_delay' state: calculate backoff and start timer.
|
|
427
|
+
*/
|
|
428
|
+
doReconnectDelay(previousState) {
|
|
429
|
+
// Track when we disconnected
|
|
430
|
+
if (!this.context.disconnectedAt) {
|
|
431
|
+
this.context.disconnectedAt = Date.now();
|
|
432
|
+
}
|
|
433
|
+
// Increment reconnect attempts
|
|
434
|
+
this.context.reconnectAttempts++;
|
|
435
|
+
// Check if max retries reached
|
|
436
|
+
if (this.context.reconnectAttempts > MAX_RECONNECTION_ATTEMPTS) {
|
|
437
|
+
this.context.lastError = new Error('Max reconnection attempts reached');
|
|
438
|
+
logger_1.logger.error(this.context.lastError.message);
|
|
439
|
+
// Transition to failed via proper state machine
|
|
440
|
+
this.transition('MAX_RETRIES');
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
// Calculate backoff delay: first attempt is instant, then exponential
|
|
444
|
+
const delay = this.context.reconnectAttempts === 1
|
|
445
|
+
? 0
|
|
446
|
+
: Math.min(1000 * 2 ** (this.context.reconnectAttempts - 2), 30000);
|
|
447
|
+
if (delay === 0) {
|
|
448
|
+
logger_1.logger.info(`Reconnection attempt ${this.context.reconnectAttempts}/${MAX_RECONNECTION_ATTEMPTS} (instant)...`);
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
logger_1.logger.info(`Reconnection attempt ${this.context.reconnectAttempts}/${MAX_RECONNECTION_ATTEMPTS} in ${delay / 1000}s...`);
|
|
452
|
+
}
|
|
453
|
+
// Close WebSocket if still open (graceful reconnect case)
|
|
454
|
+
if (previousState === 'awaiting_reconnect_ack' && this.ws) {
|
|
455
|
+
this.ws.removeAllListeners();
|
|
456
|
+
this.ws.close();
|
|
457
|
+
this.ws = null;
|
|
458
|
+
}
|
|
459
|
+
// Start delay timer
|
|
460
|
+
this.delayTimeoutId = setTimeout(() => {
|
|
461
|
+
this.transition('DELAY_COMPLETE');
|
|
462
|
+
}, delay);
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Entry action for 'stopping' state: clean up resources.
|
|
466
|
+
* Handles async WebSocket cleanup and transitions to 'stopped' when done.
|
|
467
|
+
*/
|
|
468
|
+
doStopping() {
|
|
469
|
+
// Clear any pending timers
|
|
470
|
+
if (this.delayTimeoutId) {
|
|
471
|
+
clearTimeout(this.delayTimeoutId);
|
|
472
|
+
this.delayTimeoutId = null;
|
|
473
|
+
}
|
|
474
|
+
if (this.ackTimeoutId) {
|
|
475
|
+
clearTimeout(this.ackTimeoutId);
|
|
476
|
+
this.ackTimeoutId = null;
|
|
477
|
+
}
|
|
478
|
+
// If no WebSocket, cleanup is instant
|
|
479
|
+
if (!this.ws) {
|
|
480
|
+
this.transition('CLEANUP_COMPLETE');
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
// Async WebSocket cleanup
|
|
484
|
+
this.ws.removeAllListeners();
|
|
485
|
+
const ws = this.ws;
|
|
486
|
+
this.ws = null;
|
|
487
|
+
ws.close();
|
|
488
|
+
const onCleanupComplete = () => {
|
|
489
|
+
clearTimeout(terminateTimeout);
|
|
490
|
+
this.transition('CLEANUP_COMPLETE');
|
|
491
|
+
};
|
|
492
|
+
const terminateTimeout = setTimeout(() => {
|
|
493
|
+
if (ws.readyState !== ws_1.default.CLOSED) {
|
|
494
|
+
logger_1.logger.warn('WebSocket close timed out, forcing termination');
|
|
495
|
+
ws.terminate();
|
|
496
|
+
}
|
|
497
|
+
onCleanupComplete();
|
|
498
|
+
}, WS_TERMINATE_TIMEOUT);
|
|
499
|
+
ws.once('close', onCleanupComplete);
|
|
500
|
+
ws.once('error', onCleanupComplete);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Entry action for 'failed' state: reject promises, call error callback.
|
|
504
|
+
*/
|
|
505
|
+
doFailed() {
|
|
506
|
+
const error = this.context.lastError || new Error('Tunnel failed');
|
|
507
|
+
// Close WebSocket if still open (e.g., auth failure case)
|
|
508
|
+
if (this.ws) {
|
|
509
|
+
this.ws.removeAllListeners();
|
|
510
|
+
this.ws.close();
|
|
511
|
+
this.ws = null;
|
|
512
|
+
}
|
|
513
|
+
// Reject pending promises
|
|
514
|
+
this.pendingStart?.reject(error);
|
|
515
|
+
this.pendingStart = null;
|
|
516
|
+
this.pendingGracefulReconnect?.reject(error);
|
|
517
|
+
this.pendingGracefulReconnect = null;
|
|
518
|
+
// Call error callback
|
|
519
|
+
this.onError?.(error);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Entry action for 'stopped' state: resolve/reject pending promises.
|
|
523
|
+
* Cleanup has already been done in 'stopping' state.
|
|
524
|
+
*/
|
|
525
|
+
doStopped() {
|
|
526
|
+
// Resolve graceful reconnect (intentional stop)
|
|
527
|
+
this.pendingGracefulReconnect?.resolve();
|
|
528
|
+
this.pendingGracefulReconnect = null;
|
|
529
|
+
// Reject pending start with clear message
|
|
530
|
+
if (this.pendingStart) {
|
|
531
|
+
this.pendingStart.reject(new Error('Tunnel stopped'));
|
|
532
|
+
this.pendingStart = null;
|
|
533
|
+
}
|
|
534
|
+
// Resolve pendingStop
|
|
535
|
+
this.pendingStop?.resolve();
|
|
536
|
+
this.pendingStop = null;
|
|
537
|
+
logger_1.logger.info('Tunnel stopped');
|
|
538
|
+
}
|
|
539
|
+
// ============================================================================
|
|
540
|
+
// Public API
|
|
541
|
+
// ============================================================================
|
|
542
|
+
/**
|
|
543
|
+
* Start the tunnel and connect to the proxy.
|
|
544
|
+
* @param port - The local port to expose
|
|
545
|
+
*/
|
|
546
|
+
async start(port) {
|
|
547
|
+
this.localPort = port;
|
|
548
|
+
return new Promise((resolve, reject) => {
|
|
549
|
+
// Set pendingStart BEFORE transition so doFailed() can reject it
|
|
550
|
+
// (transition is synchronous and may reach 'failed' state immediately)
|
|
551
|
+
this.pendingStart = { resolve, reject };
|
|
552
|
+
if (!this.transition('START')) {
|
|
553
|
+
this.pendingStart = null;
|
|
554
|
+
reject(new Error(`Cannot start tunnel from state: ${this.state}`));
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Stop the tunnel and clean up resources.
|
|
560
|
+
* Returns a promise that resolves when cleanup is complete.
|
|
561
|
+
*/
|
|
562
|
+
async stop() {
|
|
563
|
+
// If already stopped, return immediately
|
|
564
|
+
if (this.state === 'stopped') {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
// If already stopping, wait for existing stop to complete
|
|
568
|
+
if (this.state === 'stopping') {
|
|
569
|
+
return new Promise(resolve => {
|
|
570
|
+
// Chain onto existing pendingStop
|
|
571
|
+
const existing = this.pendingStop;
|
|
572
|
+
if (existing) {
|
|
573
|
+
const originalResolve = existing.resolve;
|
|
574
|
+
existing.resolve = () => {
|
|
575
|
+
originalResolve();
|
|
576
|
+
resolve();
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
// Shouldn't happen, but handle gracefully
|
|
581
|
+
resolve();
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
return new Promise(resolve => {
|
|
586
|
+
// Set pendingStop BEFORE transition so doStopped() can resolve it
|
|
587
|
+
// (transition may reach 'stopped' synchronously from idle or when no WebSocket)
|
|
588
|
+
this.pendingStop = { resolve };
|
|
589
|
+
if (!this.transition('STOP')) {
|
|
590
|
+
this.pendingStop = null;
|
|
591
|
+
resolve();
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Check if the tunnel is currently connected and authenticated.
|
|
597
|
+
* Returns true only after the tunnel has successfully registered with the proxy.
|
|
598
|
+
*/
|
|
599
|
+
isConnected() {
|
|
600
|
+
return this.state === 'connected' && this.ws?.readyState === ws_1.default.OPEN;
|
|
601
|
+
}
|
|
602
|
+
// ============================================================================
|
|
603
|
+
// WebSocket Setup and Event Handlers
|
|
604
|
+
// ============================================================================
|
|
605
|
+
setupWebSocket() {
|
|
606
|
+
// Clean up old WebSocket if it exists (defensive - shouldn't happen with proper state machine)
|
|
607
|
+
if (this.ws) {
|
|
608
|
+
this.ws.removeAllListeners();
|
|
609
|
+
this.ws.close();
|
|
610
|
+
this.ws = null;
|
|
611
|
+
}
|
|
612
|
+
const wsUrl = this.proxyUrl.replace(/^http/, 'ws') + '/ws';
|
|
613
|
+
const wsOptions = {
|
|
614
|
+
headers: {
|
|
615
|
+
'User-Agent': 'OpenServ-Tunnel-Client/2.0.0'
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
this.ws = new ws_1.default(wsUrl, wsOptions);
|
|
619
|
+
this.ws.on('open', () => this.handleWsOpen());
|
|
620
|
+
this.ws.on('ping', (data) => this.ws?.pong(data));
|
|
621
|
+
this.ws.on('message', (data) => this.handleWsMessage(data));
|
|
622
|
+
this.ws.on('close', (code, reason) => this.handleWsClose(code, reason));
|
|
623
|
+
this.ws.on('error', (error) => this.handleWsError(error));
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Handle WebSocket 'open' event - dispatch WS_OPEN event.
|
|
627
|
+
*/
|
|
628
|
+
handleWsOpen() {
|
|
629
|
+
this.transition('WS_OPEN');
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Handle WebSocket 'message' event - dispatch appropriate events based on message type.
|
|
633
|
+
*/
|
|
634
|
+
async handleWsMessage(data) {
|
|
635
|
+
try {
|
|
636
|
+
const message = JSON.parse(data.toString());
|
|
637
|
+
switch (message.type) {
|
|
638
|
+
case 'error':
|
|
639
|
+
this.handleProtocolError(message);
|
|
640
|
+
break;
|
|
641
|
+
case 'registered':
|
|
642
|
+
this.transition('AUTH_SUCCESS');
|
|
643
|
+
break;
|
|
644
|
+
case 'will-reconnect-ack':
|
|
645
|
+
this.handleReconnectAck(message.data);
|
|
646
|
+
break;
|
|
647
|
+
case 'request':
|
|
648
|
+
await this.handleRequest(message.data);
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
logger_1.logger.error(`Error processing message: ${error.message}`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Handle protocol-level error message from server.
|
|
658
|
+
*/
|
|
659
|
+
handleProtocolError(message) {
|
|
660
|
+
const errorMessage = message.message || 'Unknown tunnel error';
|
|
661
|
+
this.logTunnelError(message);
|
|
662
|
+
this.context.lastError = new Error(errorMessage);
|
|
663
|
+
this.transition('AUTH_ERROR');
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Handle will-reconnect-ack message from server.
|
|
667
|
+
*/
|
|
668
|
+
handleReconnectAck(data) {
|
|
669
|
+
logger_1.logger.info(`Server acknowledged will-reconnect, buffer timeout: ${data.bufferTimeout}ms`);
|
|
670
|
+
this.transition('RECONNECT_ACK');
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Handle incoming request through the tunnel.
|
|
674
|
+
*/
|
|
675
|
+
async handleRequest(requestData) {
|
|
676
|
+
this.onRequest?.(requestData.method, requestData.path);
|
|
677
|
+
await this.forwardRequest(requestData);
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Handle WebSocket 'close' event - dispatch WS_CLOSE event.
|
|
681
|
+
*/
|
|
682
|
+
handleWsClose(code, reason) {
|
|
683
|
+
logger_1.logger.info(`Disconnected: ${code} ${reason.toString()}`);
|
|
684
|
+
this.ws = null;
|
|
685
|
+
this.transition('WS_CLOSE');
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Handle WebSocket 'error' event - dispatch WS_ERROR event (informational).
|
|
689
|
+
*/
|
|
690
|
+
handleWsError(error) {
|
|
691
|
+
logger_1.logger.error(`Connection error: ${error.message}`);
|
|
692
|
+
this.context.lastError = error;
|
|
693
|
+
if (!this.context.disconnectedAt) {
|
|
694
|
+
this.context.disconnectedAt = Date.now();
|
|
695
|
+
}
|
|
696
|
+
this.logConnectionErrorHint(error);
|
|
697
|
+
this.onError?.(error);
|
|
698
|
+
// Dispatch event (close will follow, which handles actual state transition)
|
|
699
|
+
this.transition('WS_ERROR');
|
|
700
|
+
}
|
|
701
|
+
logTunnelError(message) {
|
|
702
|
+
if (message.error === 'AUTH_TIMEOUT' || message.error === 'AUTH_REQUIRED') {
|
|
703
|
+
logger_1.logger.error(`Authentication error: ${message.message}`);
|
|
704
|
+
}
|
|
705
|
+
else if (message.error === 'AUTHENTICATION_FAILED' || message.error === 'AUTH_FAILED') {
|
|
706
|
+
logger_1.logger.error(`Authentication failed: ${message.message}`);
|
|
707
|
+
logger_1.logger.info('Check your API key');
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
logger_1.logger.error(`Tunnel error: ${message.message}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
logConnectionErrorHint(error) {
|
|
714
|
+
if (error.message.includes('protocol error')) {
|
|
715
|
+
logger_1.logger.info('This may be a temporary issue. Retrying...');
|
|
716
|
+
}
|
|
717
|
+
else if (error.message.includes('upstream connect error')) {
|
|
718
|
+
logger_1.logger.info('The proxy server may be starting up. Retrying...');
|
|
719
|
+
}
|
|
720
|
+
else if (error.message.includes('400')) {
|
|
721
|
+
logger_1.logger.info('Server rejected connection. Check your proxy URL.');
|
|
722
|
+
}
|
|
723
|
+
else if (error.code === 'ENOTFOUND') {
|
|
724
|
+
logger_1.logger.info('DNS resolution failed. Check your proxy URL.');
|
|
725
|
+
}
|
|
726
|
+
else if (error.code === 'ECONNREFUSED') {
|
|
727
|
+
logger_1.logger.info('Connection refused. Is the proxy server running?');
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
// ============================================================================
|
|
731
|
+
// Request Forwarding
|
|
732
|
+
// ============================================================================
|
|
733
|
+
async forwardRequest(requestData) {
|
|
734
|
+
const sendResponse = (responseData) => {
|
|
735
|
+
if (this.ws?.readyState === ws_1.default.OPEN) {
|
|
736
|
+
this.ws.send(JSON.stringify({
|
|
737
|
+
type: 'response',
|
|
738
|
+
data: responseData
|
|
739
|
+
}));
|
|
740
|
+
}
|
|
741
|
+
else {
|
|
742
|
+
logger_1.logger.warn(`Cannot send response for request ${responseData.id}: WebSocket not open`);
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
try {
|
|
746
|
+
const response = await forwardToLocalAgent(this.localPort, requestData);
|
|
747
|
+
sendResponse({
|
|
748
|
+
id: requestData.id,
|
|
749
|
+
status: response.status,
|
|
750
|
+
headers: response.headers,
|
|
751
|
+
body: response.body
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
catch (error) {
|
|
755
|
+
logger_1.logger.error(`Error forwarding request: ${error.message}`);
|
|
756
|
+
sendResponse({
|
|
757
|
+
id: requestData.id,
|
|
758
|
+
status: 502,
|
|
759
|
+
headers: { 'content-type': 'application/json' },
|
|
760
|
+
body: JSON.stringify({
|
|
761
|
+
error: 'Bad Gateway',
|
|
762
|
+
message: error.message
|
|
763
|
+
})
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Initiate a graceful reconnection to the proxy server.
|
|
769
|
+
* This notifies the server before disconnecting, allowing it to buffer requests.
|
|
770
|
+
* Returns a promise that resolves when the reconnection is complete.
|
|
771
|
+
*/
|
|
772
|
+
async gracefulReconnect() {
|
|
773
|
+
// Only valid from connected state
|
|
774
|
+
if (this.state !== 'connected') {
|
|
775
|
+
logger_1.logger.warn(`Cannot graceful reconnect from state: ${this.state}`);
|
|
776
|
+
return Promise.resolve();
|
|
777
|
+
}
|
|
778
|
+
// If there's already a graceful reconnect in progress, wait for it
|
|
779
|
+
if (this.pendingGracefulReconnect) {
|
|
780
|
+
return new Promise((resolve, reject) => {
|
|
781
|
+
const existing = this.pendingGracefulReconnect;
|
|
782
|
+
const originalResolve = existing.resolve;
|
|
783
|
+
const originalReject = existing.reject;
|
|
784
|
+
existing.resolve = () => {
|
|
785
|
+
originalResolve();
|
|
786
|
+
resolve();
|
|
787
|
+
};
|
|
788
|
+
existing.reject = (error) => {
|
|
789
|
+
originalReject(error);
|
|
790
|
+
reject(error);
|
|
791
|
+
};
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
// Reset reconnect attempts for fresh graceful reconnect
|
|
795
|
+
this.context.reconnectAttempts = 0;
|
|
796
|
+
// Create promise that will be resolved when reconnected
|
|
797
|
+
return new Promise((resolve, reject) => {
|
|
798
|
+
this.pendingGracefulReconnect = { resolve, reject };
|
|
799
|
+
this.transition('GRACEFUL_RECONNECT');
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
exports.OpenServTunnel = OpenServTunnel;
|