@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/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;