@push.rocks/smartproxy 3.28.6 → 3.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,10 @@ export interface INetworkProxyOptions {
16
16
  allowHeaders?: string;
17
17
  maxAge?: number;
18
18
  };
19
+
20
+ // New settings for PortProxy integration
21
+ connectionPoolSize?: number; // Maximum connections to maintain in the pool to each backend
22
+ portProxyIntegration?: boolean; // Flag to indicate this proxy is used by PortProxy
19
23
  }
20
24
 
21
25
  interface IWebSocketWithHeartbeat extends plugins.wsDefault {
@@ -42,13 +46,25 @@ export class NetworkProxy {
42
46
  public requestsServed: number = 0;
43
47
  public failedRequests: number = 0;
44
48
 
49
+ // New tracking for PortProxy integration
50
+ private portProxyConnections: number = 0;
51
+ private tlsTerminatedConnections: number = 0;
52
+
45
53
  // Timers and intervals
46
54
  private heartbeatInterval: NodeJS.Timeout;
47
55
  private metricsInterval: NodeJS.Timeout;
56
+ private connectionPoolCleanupInterval: NodeJS.Timeout;
48
57
 
49
58
  // Certificates
50
59
  private defaultCertificates: { key: string; cert: string };
51
60
  private certificateCache: Map<string, { key: string; cert: string; expires?: Date }> = new Map();
61
+
62
+ // New connection pool for backend connections
63
+ private connectionPool: Map<string, Array<{
64
+ socket: plugins.net.Socket;
65
+ lastUsed: number;
66
+ isIdle: boolean;
67
+ }>> = new Map();
52
68
 
53
69
  /**
54
70
  * Creates a new NetworkProxy instance
@@ -66,7 +82,10 @@ export class NetworkProxy {
66
82
  allowMethods: 'GET, POST, PUT, DELETE, OPTIONS',
67
83
  allowHeaders: 'Content-Type, Authorization',
68
84
  maxAge: 86400
69
- }
85
+ },
86
+ // New defaults for PortProxy integration
87
+ connectionPoolSize: optionsArg.connectionPoolSize || 50,
88
+ portProxyIntegration: optionsArg.portProxyIntegration || false
70
89
  };
71
90
 
72
91
  this.loadDefaultCertificates();
@@ -104,6 +123,213 @@ export class NetworkProxy {
104
123
  }
105
124
  }
106
125
 
126
+ /**
127
+ * Returns the port number this NetworkProxy is listening on
128
+ * Useful for PortProxy to determine where to forward connections
129
+ */
130
+ public getListeningPort(): number {
131
+ return this.options.port;
132
+ }
133
+
134
+ /**
135
+ * Updates the server capacity settings
136
+ * @param maxConnections Maximum number of simultaneous connections
137
+ * @param keepAliveTimeout Keep-alive timeout in milliseconds
138
+ * @param connectionPoolSize Size of the connection pool per backend
139
+ */
140
+ public updateCapacity(maxConnections?: number, keepAliveTimeout?: number, connectionPoolSize?: number): void {
141
+ if (maxConnections !== undefined) {
142
+ this.options.maxConnections = maxConnections;
143
+ this.log('info', `Updated max connections to ${maxConnections}`);
144
+ }
145
+
146
+ if (keepAliveTimeout !== undefined) {
147
+ this.options.keepAliveTimeout = keepAliveTimeout;
148
+
149
+ if (this.httpsServer) {
150
+ this.httpsServer.keepAliveTimeout = keepAliveTimeout;
151
+ this.log('info', `Updated keep-alive timeout to ${keepAliveTimeout}ms`);
152
+ }
153
+ }
154
+
155
+ if (connectionPoolSize !== undefined) {
156
+ this.options.connectionPoolSize = connectionPoolSize;
157
+ this.log('info', `Updated connection pool size to ${connectionPoolSize}`);
158
+
159
+ // Cleanup excess connections in the pool if the size was reduced
160
+ this.cleanupConnectionPool();
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Returns current server metrics
166
+ * Useful for PortProxy to determine which NetworkProxy to use for load balancing
167
+ */
168
+ public getMetrics(): any {
169
+ return {
170
+ activeConnections: this.connectedClients,
171
+ totalRequests: this.requestsServed,
172
+ failedRequests: this.failedRequests,
173
+ portProxyConnections: this.portProxyConnections,
174
+ tlsTerminatedConnections: this.tlsTerminatedConnections,
175
+ connectionPoolSize: Array.from(this.connectionPool.entries()).reduce((acc, [host, connections]) => {
176
+ acc[host] = connections.length;
177
+ return acc;
178
+ }, {} as Record<string, number>),
179
+ uptime: Math.floor((Date.now() - this.startTime) / 1000),
180
+ memoryUsage: process.memoryUsage(),
181
+ activeWebSockets: this.wsServer?.clients.size || 0
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Cleanup the connection pool by removing idle connections
187
+ * or reducing pool size if it exceeds the configured maximum
188
+ */
189
+ private cleanupConnectionPool(): void {
190
+ const now = Date.now();
191
+ const idleTimeout = this.options.keepAliveTimeout || 120000; // 2 minutes default
192
+
193
+ for (const [host, connections] of this.connectionPool.entries()) {
194
+ // Sort by last used time (oldest first)
195
+ connections.sort((a, b) => a.lastUsed - b.lastUsed);
196
+
197
+ // Remove idle connections older than the idle timeout
198
+ let removed = 0;
199
+ while (connections.length > 0) {
200
+ const connection = connections[0];
201
+
202
+ // Remove if idle and exceeds timeout, or if pool is too large
203
+ if ((connection.isIdle && now - connection.lastUsed > idleTimeout) ||
204
+ connections.length > this.options.connectionPoolSize!) {
205
+
206
+ try {
207
+ if (!connection.socket.destroyed) {
208
+ connection.socket.end();
209
+ connection.socket.destroy();
210
+ }
211
+ } catch (err) {
212
+ this.log('error', `Error destroying pooled connection to ${host}`, err);
213
+ }
214
+
215
+ connections.shift(); // Remove from pool
216
+ removed++;
217
+ } else {
218
+ break; // Stop removing if we've reached active or recent connections
219
+ }
220
+ }
221
+
222
+ if (removed > 0) {
223
+ this.log('debug', `Removed ${removed} idle connections from pool for ${host}, ${connections.length} remaining`);
224
+ }
225
+
226
+ // Update the pool with the remaining connections
227
+ if (connections.length === 0) {
228
+ this.connectionPool.delete(host);
229
+ } else {
230
+ this.connectionPool.set(host, connections);
231
+ }
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Get a connection from the pool or create a new one
237
+ */
238
+ private getConnectionFromPool(host: string, port: number): Promise<plugins.net.Socket> {
239
+ return new Promise((resolve, reject) => {
240
+ const poolKey = `${host}:${port}`;
241
+ const connectionList = this.connectionPool.get(poolKey) || [];
242
+
243
+ // Look for an idle connection
244
+ const idleConnectionIndex = connectionList.findIndex(c => c.isIdle);
245
+
246
+ if (idleConnectionIndex >= 0) {
247
+ // Get existing connection from pool
248
+ const connection = connectionList[idleConnectionIndex];
249
+ connection.isIdle = false;
250
+ connection.lastUsed = Date.now();
251
+ this.log('debug', `Reusing connection from pool for ${poolKey}`);
252
+
253
+ // Update the pool
254
+ this.connectionPool.set(poolKey, connectionList);
255
+
256
+ resolve(connection.socket);
257
+ return;
258
+ }
259
+
260
+ // No idle connection available, create a new one if pool isn't full
261
+ if (connectionList.length < this.options.connectionPoolSize!) {
262
+ this.log('debug', `Creating new connection to ${host}:${port}`);
263
+
264
+ try {
265
+ const socket = plugins.net.connect({
266
+ host,
267
+ port,
268
+ keepAlive: true,
269
+ keepAliveInitialDelay: 30000 // 30 seconds
270
+ });
271
+
272
+ socket.once('connect', () => {
273
+ // Add to connection pool
274
+ const connection = {
275
+ socket,
276
+ lastUsed: Date.now(),
277
+ isIdle: false
278
+ };
279
+
280
+ connectionList.push(connection);
281
+ this.connectionPool.set(poolKey, connectionList);
282
+
283
+ // Setup cleanup when the connection is closed
284
+ socket.once('close', () => {
285
+ const idx = connectionList.findIndex(c => c.socket === socket);
286
+ if (idx >= 0) {
287
+ connectionList.splice(idx, 1);
288
+ this.connectionPool.set(poolKey, connectionList);
289
+ this.log('debug', `Removed closed connection from pool for ${poolKey}`);
290
+ }
291
+ });
292
+
293
+ resolve(socket);
294
+ });
295
+
296
+ socket.once('error', (err) => {
297
+ this.log('error', `Error creating connection to ${host}:${port}`, err);
298
+ reject(err);
299
+ });
300
+ } catch (err) {
301
+ this.log('error', `Failed to create connection to ${host}:${port}`, err);
302
+ reject(err);
303
+ }
304
+ } else {
305
+ // Pool is full, wait for an idle connection or reject
306
+ this.log('warn', `Connection pool for ${poolKey} is full (${connectionList.length})`);
307
+ reject(new Error(`Connection pool for ${poolKey} is full`));
308
+ }
309
+ });
310
+ }
311
+
312
+ /**
313
+ * Return a connection to the pool for reuse
314
+ */
315
+ private returnConnectionToPool(socket: plugins.net.Socket, host: string, port: number): void {
316
+ const poolKey = `${host}:${port}`;
317
+ const connectionList = this.connectionPool.get(poolKey) || [];
318
+
319
+ // Find this connection in the pool
320
+ const connectionIndex = connectionList.findIndex(c => c.socket === socket);
321
+
322
+ if (connectionIndex >= 0) {
323
+ // Mark as idle and update last used time
324
+ connectionList[connectionIndex].isIdle = true;
325
+ connectionList[connectionIndex].lastUsed = Date.now();
326
+
327
+ this.log('debug', `Returned connection to pool for ${poolKey}`);
328
+ } else {
329
+ this.log('warn', `Attempted to return unknown connection to pool for ${poolKey}`);
330
+ }
331
+ }
332
+
107
333
  /**
108
334
  * Starts the proxy server
109
335
  */
@@ -131,6 +357,9 @@ export class NetworkProxy {
131
357
 
132
358
  // Start metrics collection
133
359
  this.setupMetricsCollection();
360
+
361
+ // Setup connection pool cleanup interval
362
+ this.setupConnectionPoolCleanup();
134
363
 
135
364
  // Start the server
136
365
  return new Promise((resolve) => {
@@ -156,13 +385,31 @@ export class NetworkProxy {
156
385
  // Add connection to tracking
157
386
  this.socketMap.add(connection);
158
387
  this.connectedClients = this.socketMap.getArray().length;
159
- this.log('debug', `New connection. Currently ${this.connectedClients} active connections`);
388
+
389
+ // Check for connection from PortProxy by inspecting the source port
390
+ // This is a heuristic - in a production environment you might use a more robust method
391
+ const localPort = connection.localPort;
392
+ const remotePort = connection.remotePort;
393
+
394
+ // If this connection is from a PortProxy (usually indicated by it coming from localhost)
395
+ if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) {
396
+ this.portProxyConnections++;
397
+ this.log('debug', `New connection from PortProxy (local: ${localPort}, remote: ${remotePort})`);
398
+ } else {
399
+ this.log('debug', `New direct connection (local: ${localPort}, remote: ${remotePort})`);
400
+ }
160
401
 
161
402
  // Setup connection cleanup handlers
162
403
  const cleanupConnection = () => {
163
404
  if (this.socketMap.checkForObject(connection)) {
164
405
  this.socketMap.remove(connection);
165
406
  this.connectedClients = this.socketMap.getArray().length;
407
+
408
+ // If this was a PortProxy connection, decrement the counter
409
+ if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) {
410
+ this.portProxyConnections--;
411
+ }
412
+
166
413
  this.log('debug', `Connection closed. ${this.connectedClients} connections remaining`);
167
414
  }
168
415
  };
@@ -178,6 +425,12 @@ export class NetworkProxy {
178
425
  cleanupConnection();
179
426
  });
180
427
  });
428
+
429
+ // Track TLS handshake completions
430
+ this.httpsServer.on('secureConnection', (tlsSocket) => {
431
+ this.tlsTerminatedConnections++;
432
+ this.log('debug', 'TLS handshake completed, connection secured');
433
+ });
181
434
  }
182
435
 
183
436
  /**
@@ -228,14 +481,35 @@ export class NetworkProxy {
228
481
  activeConnections: this.connectedClients,
229
482
  totalRequests: this.requestsServed,
230
483
  failedRequests: this.failedRequests,
484
+ portProxyConnections: this.portProxyConnections,
485
+ tlsTerminatedConnections: this.tlsTerminatedConnections,
231
486
  activeWebSockets: this.wsServer?.clients.size || 0,
232
487
  memoryUsage: process.memoryUsage(),
233
- activeContexts: Array.from(this.activeContexts)
488
+ activeContexts: Array.from(this.activeContexts),
489
+ connectionPool: Object.fromEntries(
490
+ Array.from(this.connectionPool.entries()).map(([host, connections]) => [
491
+ host,
492
+ {
493
+ total: connections.length,
494
+ idle: connections.filter(c => c.isIdle).length
495
+ }
496
+ ])
497
+ )
234
498
  };
235
499
 
236
500
  this.log('debug', 'Proxy metrics', metrics);
237
501
  }, 60000); // Log metrics every minute
238
502
  }
503
+
504
+ /**
505
+ * Sets up connection pool cleanup
506
+ */
507
+ private setupConnectionPoolCleanup(): void {
508
+ // Clean up idle connections every minute
509
+ this.connectionPoolCleanupInterval = setInterval(() => {
510
+ this.cleanupConnectionPool();
511
+ }, 60000); // 1 minute
512
+ }
239
513
 
240
514
  /**
241
515
  * Handles an incoming WebSocket connection
@@ -410,12 +684,27 @@ export class NetworkProxy {
410
684
  }
411
685
  }
412
686
 
687
+ // Determine if we should use connection pooling
688
+ const useConnectionPool = this.options.portProxyIntegration &&
689
+ originRequest.socket.remoteAddress?.includes('127.0.0.1');
690
+
413
691
  // Construct destination URL
414
692
  const destinationUrl = `http://${destinationConfig.destinationIp}:${destinationConfig.destinationPort}${originRequest.url}`;
415
- this.log('debug', `[${reqId}] Proxying to ${destinationUrl}`);
416
693
 
417
- // Forward the request
418
- await this.forwardRequest(reqId, originRequest, originResponse, destinationUrl);
694
+ if (useConnectionPool) {
695
+ this.log('debug', `[${reqId}] Proxying to ${destinationUrl} (using connection pool)`);
696
+ await this.forwardRequestUsingConnectionPool(
697
+ reqId,
698
+ originRequest,
699
+ originResponse,
700
+ destinationConfig.destinationIp,
701
+ destinationConfig.destinationPort,
702
+ originRequest.url
703
+ );
704
+ } else {
705
+ this.log('debug', `[${reqId}] Proxying to ${destinationUrl}`);
706
+ await this.forwardRequest(reqId, originRequest, originResponse, destinationUrl);
707
+ }
419
708
 
420
709
  const processingTime = Date.now() - startTime;
421
710
  this.log('debug', `[${reqId}] Request completed in ${processingTime}ms`);
@@ -488,7 +777,105 @@ export class NetworkProxy {
488
777
  }
489
778
 
490
779
  /**
491
- * Forwards a request to the destination
780
+ * Forwards a request to the destination using connection pool
781
+ * for optimized connection reuse from PortProxy
782
+ */
783
+ private async forwardRequestUsingConnectionPool(
784
+ reqId: string,
785
+ originRequest: plugins.http.IncomingMessage,
786
+ originResponse: plugins.http.ServerResponse,
787
+ host: string,
788
+ port: number,
789
+ path: string
790
+ ): Promise<void> {
791
+ try {
792
+ // Try to get a connection from the pool
793
+ const socket = await this.getConnectionFromPool(host, port);
794
+
795
+ // Create an HTTP client request using the pooled socket
796
+ const reqOptions = {
797
+ createConnection: () => socket,
798
+ host,
799
+ port,
800
+ path,
801
+ method: originRequest.method,
802
+ headers: this.prepareForwardHeaders(originRequest),
803
+ timeout: 30000 // 30 second timeout
804
+ };
805
+
806
+ const proxyReq = plugins.http.request(reqOptions);
807
+
808
+ // Handle timeouts
809
+ proxyReq.on('timeout', () => {
810
+ this.log('warn', `[${reqId}] Request to ${host}:${port}${path} timed out`);
811
+ proxyReq.destroy();
812
+ });
813
+
814
+ // Handle errors
815
+ proxyReq.on('error', (err) => {
816
+ this.log('error', `[${reqId}] Error in proxy request to ${host}:${port}${path}`, err);
817
+
818
+ // Check if the client response is still writable
819
+ if (!originResponse.writableEnded) {
820
+ this.sendErrorResponse(originResponse, 502, 'Bad Gateway: Error communicating with upstream server');
821
+ }
822
+
823
+ // Don't return the socket to the pool on error
824
+ try {
825
+ if (!socket.destroyed) {
826
+ socket.destroy();
827
+ }
828
+ } catch (socketErr) {
829
+ this.log('error', `[${reqId}] Error destroying socket after request error`, socketErr);
830
+ }
831
+ });
832
+
833
+ // Forward request body
834
+ originRequest.pipe(proxyReq);
835
+
836
+ // Handle response
837
+ proxyReq.on('response', (proxyRes) => {
838
+ // Copy status and headers
839
+ originResponse.statusCode = proxyRes.statusCode;
840
+
841
+ for (const [name, value] of Object.entries(proxyRes.headers)) {
842
+ if (value !== undefined) {
843
+ originResponse.setHeader(name, value);
844
+ }
845
+ }
846
+
847
+ // Forward the response body
848
+ proxyRes.pipe(originResponse);
849
+
850
+ // Return connection to pool when the response completes
851
+ proxyRes.on('end', () => {
852
+ if (!socket.destroyed) {
853
+ this.returnConnectionToPool(socket, host, port);
854
+ }
855
+ });
856
+
857
+ proxyRes.on('error', (err) => {
858
+ this.log('error', `[${reqId}] Error in proxy response from ${host}:${port}${path}`, err);
859
+
860
+ // Don't return the socket to the pool on error
861
+ try {
862
+ if (!socket.destroyed) {
863
+ socket.destroy();
864
+ }
865
+ } catch (socketErr) {
866
+ this.log('error', `[${reqId}] Error destroying socket after response error`, socketErr);
867
+ }
868
+ });
869
+ });
870
+ } catch (error) {
871
+ this.log('error', `[${reqId}] Error setting up pooled connection to ${host}:${port}`, error);
872
+ this.sendErrorResponse(originResponse, 502, 'Bad Gateway: Unable to reach upstream server');
873
+ throw error;
874
+ }
875
+ }
876
+
877
+ /**
878
+ * Forwards a request to the destination (standard method)
492
879
  */
493
880
  private async forwardRequest(
494
881
  reqId: string,
@@ -532,6 +919,11 @@ export class NetworkProxy {
532
919
  // Add proxy-specific headers
533
920
  safeHeaders['X-Proxy-Id'] = `NetworkProxy-${this.options.port}`;
534
921
 
922
+ // If this is coming from PortProxy, add a header to indicate that
923
+ if (this.options.portProxyIntegration && req.socket.remoteAddress?.includes('127.0.0.1')) {
924
+ safeHeaders['X-PortProxy-Forwarded'] = 'true';
925
+ }
926
+
535
927
  // Remove sensitive headers we don't want to forward
536
928
  const sensitiveHeaders = ['connection', 'upgrade', 'http2-settings'];
537
929
  for (const header of sensitiveHeaders) {
@@ -778,6 +1170,10 @@ export class NetworkProxy {
778
1170
  clearInterval(this.metricsInterval);
779
1171
  }
780
1172
 
1173
+ if (this.connectionPoolCleanupInterval) {
1174
+ clearInterval(this.connectionPoolCleanupInterval);
1175
+ }
1176
+
781
1177
  // Close WebSocket server if exists
782
1178
  if (this.wsServer) {
783
1179
  for (const client of this.wsServer.clients) {
@@ -798,6 +1194,20 @@ export class NetworkProxy {
798
1194
  }
799
1195
  }
800
1196
 
1197
+ // Close all connection pool connections
1198
+ for (const [host, connections] of this.connectionPool.entries()) {
1199
+ for (const connection of connections) {
1200
+ try {
1201
+ if (!connection.socket.destroyed) {
1202
+ connection.socket.destroy();
1203
+ }
1204
+ } catch (error) {
1205
+ this.log('error', `Error destroying pooled connection to ${host}`, error);
1206
+ }
1207
+ }
1208
+ }
1209
+ this.connectionPool.clear();
1210
+
801
1211
  // Close the HTTPS server
802
1212
  return new Promise((resolve) => {
803
1213
  this.httpsServer.close(() => {