Compare commits
22 Commits
Author | SHA1 | Date | |
---|---|---|---|
705a59413d | |||
e9723a8af9 | |||
300ab1a077 | |||
900942a263 | |||
d45485985a | |||
9fdc2d5069 | |||
37c87e8450 | |||
92b2f230ef | |||
e7ebf57ce1 | |||
ad80798210 | |||
265b80ee04 | |||
726d40b9a5 | |||
cacc88797a | |||
bed1a76537 | |||
eb2e67fecc | |||
c7c325a7d8 | |||
a2affcd93e | |||
e0f3e8a0ec | |||
96c4de0f8a | |||
829ae0d6a3 | |||
7b81186bb3 | |||
02603c3b07 |
@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiryDate": "2025-08-27T14:28:53.471Z",
|
||||
"issueDate": "2025-05-29T14:28:53.471Z",
|
||||
"savedAt": "2025-05-29T14:28:53.473Z"
|
||||
"expiryDate": "2025-08-30T08:11:10.101Z",
|
||||
"issueDate": "2025-06-01T08:11:10.101Z",
|
||||
"savedAt": "2025-06-01T08:11:10.102Z"
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "19.5.4",
|
||||
"version": "19.5.13",
|
||||
"private": false,
|
||||
"description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.",
|
||||
"main": "dist_ts/index.js",
|
||||
@ -18,7 +18,7 @@
|
||||
"@git.zone/tsbuild": "^2.6.4",
|
||||
"@git.zone/tsrun": "^1.2.44",
|
||||
"@git.zone/tstest": "^2.3.1",
|
||||
"@types/node": "^22.15.24",
|
||||
"@types/node": "^22.15.29",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
|
69
pnpm-lock.yaml
generated
69
pnpm-lock.yaml
generated
@ -70,8 +70,8 @@ importers:
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1(@aws-sdk/credential-providers@3.798.0)(socks@2.8.4)(typescript@5.8.3)
|
||||
'@types/node':
|
||||
specifier: ^22.15.24
|
||||
version: 22.15.24
|
||||
specifier: ^22.15.29
|
||||
version: 22.15.29
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.8.3
|
||||
@ -1635,11 +1635,11 @@ packages:
|
||||
'@types/node-forge@1.3.11':
|
||||
resolution: {integrity: sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==}
|
||||
|
||||
'@types/node@18.19.105':
|
||||
resolution: {integrity: sha512-a+DrwD2VyzqQR2W0EVF8EaCh6Em4ilQAYLEPZnMNkQHXR7ziWW7RUhZMWZAgRpkDDAdUIcJOXSPJT/zBEwz3sA==}
|
||||
'@types/node@18.19.110':
|
||||
resolution: {integrity: sha512-WW2o4gTmREtSnqKty9nhqF/vA0GKd0V/rbC0OyjSk9Bz6bzlsXKT+i7WDdS/a0z74rfT2PO4dArVCSnapNLA5Q==}
|
||||
|
||||
'@types/node@22.15.24':
|
||||
resolution: {integrity: sha512-w9CZGm9RDjzTh/D+hFwlBJ3ziUaVw7oufKA3vOFSOZlzmW9AkZnfjPb+DLnrV6qtgL/LNmP0/2zBNCFHL3F0ng==}
|
||||
'@types/node@22.15.29':
|
||||
resolution: {integrity: sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==}
|
||||
|
||||
'@types/ping@0.4.4':
|
||||
resolution: {integrity: sha512-ifvo6w2f5eJYlXm+HiVx67iJe8WZp87sfa683nlqED5Vnt9Z93onkokNoWqOG21EaE8fMxyKPobE+mkPEyxsdw==}
|
||||
@ -5708,6 +5708,7 @@ snapshots:
|
||||
- '@aws-sdk/credential-providers'
|
||||
- '@mongodb-js/zstd'
|
||||
- '@nuxt/kit'
|
||||
- aws-crt
|
||||
- encoding
|
||||
- gcp-metadata
|
||||
- kerberos
|
||||
@ -7097,27 +7098,27 @@ snapshots:
|
||||
|
||||
'@types/bn.js@5.1.6':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/body-parser@1.19.5':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/buffer-json@2.0.3': {}
|
||||
|
||||
'@types/clean-css@4.2.11':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
source-map: 0.6.1
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/cors@2.8.18':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/debug@4.1.12':
|
||||
dependencies:
|
||||
@ -7129,7 +7130,7 @@ snapshots:
|
||||
|
||||
'@types/dns-packet@5.6.5':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/elliptic@6.4.18':
|
||||
dependencies:
|
||||
@ -7137,7 +7138,7 @@ snapshots:
|
||||
|
||||
'@types/express-serve-static-core@5.0.6':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
'@types/qs': 6.9.18
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 0.17.4
|
||||
@ -7154,30 +7155,30 @@ snapshots:
|
||||
|
||||
'@types/from2@2.3.5':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/fs-extra@11.0.4':
|
||||
dependencies:
|
||||
'@types/jsonfile': 6.1.4
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/fs-extra@9.0.13':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/glob@7.2.0':
|
||||
dependencies:
|
||||
'@types/minimatch': 5.1.2
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/glob@8.1.0':
|
||||
dependencies:
|
||||
'@types/minimatch': 5.1.2
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/gunzip-maybe@1.4.2':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
dependencies:
|
||||
@ -7199,7 +7200,7 @@ snapshots:
|
||||
|
||||
'@types/jsonfile@6.1.4':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
dependencies:
|
||||
@ -7217,18 +7218,18 @@ snapshots:
|
||||
|
||||
'@types/node-fetch@2.6.12':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
form-data: 4.0.2
|
||||
|
||||
'@types/node-forge@1.3.11':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/node@18.19.105':
|
||||
'@types/node@18.19.110':
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
'@types/node@22.15.24':
|
||||
'@types/node@22.15.29':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
@ -7244,30 +7245,30 @@ snapshots:
|
||||
|
||||
'@types/s3rver@3.7.4':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/semver@7.7.0': {}
|
||||
|
||||
'@types/send@0.17.4':
|
||||
dependencies:
|
||||
'@types/mime': 1.3.5
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/serve-static@1.15.7':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.4
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
'@types/send': 0.17.4
|
||||
|
||||
'@types/symbol-tree@3.2.5': {}
|
||||
|
||||
'@types/tar-stream@2.2.3':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/through2@2.0.41':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/triple-beam@1.3.5': {}
|
||||
|
||||
@ -7291,18 +7292,18 @@ snapshots:
|
||||
|
||||
'@types/whatwg-url@8.2.2':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
'@types/webidl-conversions': 7.0.3
|
||||
|
||||
'@types/which@3.0.4': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
optional: true
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
@ -7582,7 +7583,7 @@ snapshots:
|
||||
|
||||
cloudflare@4.2.0:
|
||||
dependencies:
|
||||
'@types/node': 18.19.105
|
||||
'@types/node': 18.19.110
|
||||
'@types/node-fetch': 2.6.12
|
||||
abort-controller: 3.0.0
|
||||
agentkeepalive: 4.6.0
|
||||
@ -7835,7 +7836,7 @@ snapshots:
|
||||
engine.io@6.6.4:
|
||||
dependencies:
|
||||
'@types/cors': 2.8.18
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
accepts: 1.3.8
|
||||
base64id: 2.0.0
|
||||
cookie: 0.7.2
|
||||
|
148
readme.hints.md
148
readme.hints.md
@ -318,4 +318,150 @@ const routes: IRouteConfig[] = [{
|
||||
- Authentication requires TLS termination (cannot be enforced on passthrough/direct connections)
|
||||
- Per-route connection limits are not yet implemented
|
||||
- Security is defined at the route level (route.security), not in the action
|
||||
- Route matching is based solely on match criteria; security is enforced after matching
|
||||
- Route matching is based solely on match criteria; security is enforced after matching
|
||||
|
||||
## Performance Issues Investigation (v19.5.3+)
|
||||
|
||||
### Critical Blocking Operations Found
|
||||
1. **Busy Wait Loop** in `ts/proxies/nftables-proxy/nftables-proxy.ts:235-238`
|
||||
- Blocks entire event loop with `while (Date.now() < waitUntil) {}`
|
||||
- Should use `await new Promise(resolve => setTimeout(resolve, delay))`
|
||||
|
||||
2. **Synchronous Filesystem Operations**
|
||||
- Certificate management uses `fs.existsSync()`, `fs.mkdirSync()`, `fs.readFileSync()`
|
||||
- NFTables proxy uses `execSync()` for system commands
|
||||
- Certificate store uses `ensureDirSync()`, `fileExistsSync()`, `removeManySync()`
|
||||
|
||||
3. **Memory Leak Risks**
|
||||
- Several `setInterval()` calls without storing references for cleanup
|
||||
- Event listeners added without proper cleanup in error paths
|
||||
- Missing `removeAllListeners()` calls in some connection cleanup scenarios
|
||||
|
||||
### Performance Recommendations
|
||||
- Replace all sync filesystem operations with async alternatives
|
||||
- Fix the busy wait loop immediately (critical event loop blocker)
|
||||
- Add proper cleanup for all timers and event listeners
|
||||
- Consider worker threads for CPU-intensive operations
|
||||
- See `readme.problems.md` for detailed analysis and recommendations
|
||||
|
||||
## Performance Optimizations Implemented (Phase 1 - v19.6.0)
|
||||
|
||||
### 1. Async Utilities Created (`ts/core/utils/async-utils.ts`)
|
||||
- **delay()**: Non-blocking alternative to busy wait loops
|
||||
- **retryWithBackoff()**: Retry operations with exponential backoff
|
||||
- **withTimeout()**: Execute operations with timeout protection
|
||||
- **parallelLimit()**: Run async operations with concurrency control
|
||||
- **debounceAsync()**: Debounce async functions
|
||||
- **AsyncMutex**: Ensure exclusive access to resources
|
||||
- **CircuitBreaker**: Protect against cascading failures
|
||||
|
||||
### 2. Filesystem Utilities Created (`ts/core/utils/fs-utils.ts`)
|
||||
- **AsyncFileSystem**: Complete async filesystem operations
|
||||
- exists(), ensureDir(), readFile(), writeFile()
|
||||
- readJSON(), writeJSON() with proper error handling
|
||||
- copyFile(), moveFile(), removeDir()
|
||||
- Stream creation and file listing utilities
|
||||
|
||||
### 3. Critical Fixes Applied
|
||||
|
||||
#### Busy Wait Loop Fixed
|
||||
- **Location**: `ts/proxies/nftables-proxy/nftables-proxy.ts:235-238`
|
||||
- **Fix**: Replaced `while (Date.now() < waitUntil) {}` with `await delay(ms)`
|
||||
- **Impact**: Unblocks event loop, massive performance improvement
|
||||
|
||||
#### Certificate Manager Migration
|
||||
- **File**: `ts/proxies/http-proxy/certificate-manager.ts`
|
||||
- Added async initialization method
|
||||
- Kept sync methods for backward compatibility with deprecation warnings
|
||||
- Added `loadDefaultCertificatesAsync()` method
|
||||
|
||||
#### Certificate Store Migration
|
||||
- **File**: `ts/proxies/smart-proxy/cert-store.ts`
|
||||
- Replaced all `fileExistsSync`, `ensureDirSync`, `removeManySync`
|
||||
- Used parallel operations with `Promise.all()` for better performance
|
||||
- Improved error handling and async JSON operations
|
||||
|
||||
#### NFTables Proxy Improvements
|
||||
- Added deprecation warnings to sync methods
|
||||
- Created `executeWithTempFile()` helper for common pattern
|
||||
- Started migration of sync filesystem operations to async
|
||||
- Added import for delay and AsyncFileSystem utilities
|
||||
|
||||
### 4. Backward Compatibility Maintained
|
||||
- All sync methods retained with deprecation warnings
|
||||
- Existing APIs unchanged, new async methods added alongside
|
||||
- Feature flags prepared for gradual rollout
|
||||
|
||||
### 5. Phase 1 Completion Status
|
||||
✅ **Phase 1 COMPLETE** - All critical performance fixes have been implemented:
|
||||
- ✅ Fixed busy wait loop in nftables-proxy.ts
|
||||
- ✅ Created async utilities (delay, retry, timeout, parallelLimit, mutex, circuit breaker)
|
||||
- ✅ Created filesystem utilities (AsyncFileSystem with full async operations)
|
||||
- ✅ Migrated all certificate management to async operations
|
||||
- ✅ Migrated nftables-proxy filesystem operations to async (except stopSync for exit handlers)
|
||||
- ✅ All tests passing for new utilities
|
||||
|
||||
### 6. Phase 2 Progress Status
|
||||
🔨 **Phase 2 IN PROGRESS** - Resource Lifecycle Management:
|
||||
- ✅ Created LifecycleComponent base class for automatic resource cleanup
|
||||
- ✅ Created BinaryHeap data structure for priority queue operations
|
||||
- ✅ Created EnhancedConnectionPool with backpressure and health checks
|
||||
- ✅ Cleaned up legacy code (removed ts/common/, event-utils.ts, event-system.ts)
|
||||
- 📋 TODO: Migrate existing components to extend LifecycleComponent
|
||||
- 📋 TODO: Add integration tests for resource management
|
||||
|
||||
### 7. Next Steps (Remaining Work)
|
||||
- **Phase 2 (cont)**: Migrate components to use LifecycleComponent
|
||||
- **Phase 3**: Add worker threads for CPU-intensive operations
|
||||
- **Phase 4**: Performance monitoring dashboard
|
||||
|
||||
## Socket Error Handling Fix (v19.5.11+)
|
||||
|
||||
### Issue
|
||||
Server crashed with unhandled 'error' event when backend connections failed (ECONNREFUSED). Also caused memory leak with rising active connection count as failed connections weren't cleaned up properly.
|
||||
|
||||
### Root Cause
|
||||
1. **Race Condition**: In forwarding handlers, sockets were created with `net.connect()` but error handlers were attached later, creating a window where errors could crash the server
|
||||
2. **Incomplete Cleanup**: When server connections failed, client sockets weren't properly cleaned up, leaving connection records in memory
|
||||
|
||||
### Solution
|
||||
Created `createSocketWithErrorHandler()` utility that attaches error handlers immediately:
|
||||
```typescript
|
||||
// Before (race condition):
|
||||
const socket = net.connect(port, host);
|
||||
// ... other code ...
|
||||
socket.on('error', handler); // Too late!
|
||||
|
||||
// After (safe):
|
||||
const socket = createSocketWithErrorHandler({
|
||||
port, host,
|
||||
onError: (error) => {
|
||||
// Handle error immediately
|
||||
clientSocket.destroy();
|
||||
},
|
||||
onConnect: () => {
|
||||
// Set up forwarding
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Changes Made
|
||||
1. **New Utility**: `ts/core/utils/socket-utils.ts` - Added `createSocketWithErrorHandler()`
|
||||
2. **Updated Handlers**:
|
||||
- `https-passthrough-handler.ts` - Uses safe socket creation
|
||||
- `https-terminate-to-http-handler.ts` - Uses safe socket creation
|
||||
3. **Connection Cleanup**: Client sockets destroyed immediately on server connection failure
|
||||
|
||||
### Test Coverage
|
||||
- `test/test.socket-error-handling.node.ts` - Verifies server doesn't crash on ECONNREFUSED
|
||||
- `test/test.forwarding-error-fix.node.ts` - Tests forwarding handlers handle errors gracefully
|
||||
|
||||
### Configuration
|
||||
No configuration changes needed. The fix is transparent to users.
|
||||
|
||||
### Important Note
|
||||
The fix was applied in two places:
|
||||
1. **ForwardingHandler classes** (`https-passthrough-handler.ts`, etc.) - These are standalone forwarding utilities
|
||||
2. **SmartProxy route-connection-handler** (`route-connection-handler.ts`) - This is where the actual SmartProxy connection handling happens
|
||||
|
||||
The critical fix for SmartProxy was in `setupDirectConnection()` method in route-connection-handler.ts, which now uses `createSocketWithErrorHandler()` to properly handle connection failures and clean up connection records.
|
437
readme.plan.md
437
readme.plan.md
@ -1,316 +1,165 @@
|
||||
# SmartProxy Development Plan
|
||||
# SmartProxy Socket Handling Fix Plan
|
||||
|
||||
## Implementation Plan: Socket Handler Function Support (Simplified) ✅ COMPLETED
|
||||
Reread CLAUDE.md file for guidelines
|
||||
|
||||
### Overview
|
||||
Add support for custom socket handler functions with the simplest possible API - just pass a function that receives the socket.
|
||||
## Implementation Summary (COMPLETED)
|
||||
|
||||
### User Experience Goal
|
||||
The critical socket handling issues have been fixed:
|
||||
|
||||
1. **Prevented Server Crashes**: Created `createSocketWithErrorHandler()` utility that attaches error handlers immediately upon socket creation, preventing unhandled ECONNREFUSED errors from crashing the server.
|
||||
|
||||
2. **Fixed Memory Leaks**: Updated forwarding handlers to properly clean up client sockets when server connections fail, ensuring connection records are removed from tracking.
|
||||
|
||||
3. **Key Changes Made**:
|
||||
- Added `createSocketWithErrorHandler()` in `socket-utils.ts`
|
||||
- Updated `https-passthrough-handler.ts` to use safe socket creation
|
||||
- Updated `https-terminate-to-http-handler.ts` to use safe socket creation
|
||||
- Ensured client sockets are destroyed when server connections fail
|
||||
- Connection cleanup now triggered by socket close events
|
||||
|
||||
4. **Test Results**: Server no longer crashes on ECONNREFUSED errors, and connections are properly cleaned up.
|
||||
|
||||
## Problem Summary
|
||||
|
||||
The SmartProxy server is experiencing critical issues:
|
||||
1. **Server crashes** due to unhandled socket connection errors (ECONNREFUSED)
|
||||
2. **Memory leak** with steadily rising active connection count
|
||||
3. **Race conditions** between socket creation and error handler attachment
|
||||
4. **Orphaned sockets** when server connections fail
|
||||
|
||||
## Root Causes
|
||||
|
||||
### 1. Delayed Error Handler Attachment
|
||||
- Sockets created without immediate error handlers
|
||||
- Error events can fire before handlers attached
|
||||
- Causes uncaught exceptions and server crashes
|
||||
|
||||
### 2. Incomplete Cleanup Logic
|
||||
- Client sockets not cleaned up when server connection fails
|
||||
- Connection counter only decrements after BOTH sockets close
|
||||
- Failed server connections leave orphaned client sockets
|
||||
|
||||
### 3. Missing Global Error Handlers
|
||||
- No process-level uncaughtException handler
|
||||
- No process-level unhandledRejection handler
|
||||
- Any unhandled error crashes entire server
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Prevent Server Crashes (Critical)
|
||||
|
||||
#### 1.1 Add Global Error Handlers
|
||||
- [x] ~~Add global error handlers in main entry point~~ (Removed per user request - no global handlers)
|
||||
- [x] Log errors with context
|
||||
- [x] ~~Implement graceful shutdown sequence~~ (Removed - handled locally)
|
||||
|
||||
#### 1.2 Fix Socket Creation Race Condition
|
||||
- [x] Modify socket creation to attach error handlers immediately
|
||||
- [x] Update all forwarding handlers (https-passthrough, http, etc.)
|
||||
- [x] Ensure error handlers attached in same tick as socket creation
|
||||
|
||||
### Phase 2: Fix Memory Leaks (High Priority)
|
||||
|
||||
#### 2.1 Fix Connection Cleanup Logic
|
||||
- [x] Clean up client socket immediately if server connection fails
|
||||
- [x] Decrement connection counter on any socket failure (handled by socket close events)
|
||||
- [x] Implement proper cleanup for half-open connections
|
||||
|
||||
#### 2.2 Improve Socket Utils
|
||||
- [x] Create new utility function for safe socket creation with immediate error handling
|
||||
- [x] Update createIndependentSocketHandlers to handle immediate failures
|
||||
- [ ] Add connection tracking debug utilities
|
||||
|
||||
### Phase 3: Comprehensive Testing (Important)
|
||||
|
||||
#### 3.1 Create Test Cases
|
||||
- [x] Test ECONNREFUSED scenario
|
||||
- [ ] Test timeout handling
|
||||
- [ ] Test half-open connections
|
||||
- [ ] Test rapid connect/disconnect cycles
|
||||
|
||||
#### 3.2 Add Monitoring
|
||||
- [ ] Add connection leak detection
|
||||
- [ ] Add metrics for connection lifecycle
|
||||
- [ ] Add debug logging for socket state transitions
|
||||
|
||||
## Detailed Implementation Steps
|
||||
|
||||
### Step 1: Global Error Handlers (ts/proxies/smart-proxy/smart-proxy.ts)
|
||||
```typescript
|
||||
const proxy = new SmartProxy({
|
||||
routes: [{
|
||||
name: 'my-custom-protocol',
|
||||
match: { ports: 9000, domains: 'custom.example.com' },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: (socket) => {
|
||||
// User has full control of the socket
|
||||
socket.write('Welcome!\n');
|
||||
socket.on('data', (data) => {
|
||||
socket.write(`Echo: ${data}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}]
|
||||
// Add in constructor or start method
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.log('error', 'Uncaught exception', { error });
|
||||
// Graceful shutdown
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
logger.log('error', 'Unhandled rejection', { reason, promise });
|
||||
});
|
||||
```
|
||||
|
||||
That's it. Simple and powerful.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Minimal Type Changes
|
||||
|
||||
### 1.1 Add Socket Handler Action Type
|
||||
**File:** `ts/proxies/smart-proxy/models/route-types.ts`
|
||||
|
||||
### Step 2: Safe Socket Creation Utility (ts/core/utils/socket-utils.ts)
|
||||
```typescript
|
||||
// Update action type
|
||||
export type TRouteActionType = 'forward' | 'redirect' | 'block' | 'static' | 'socket-handler';
|
||||
|
||||
// Add simple socket handler type
|
||||
export type TSocketHandler = (socket: net.Socket) => void | Promise<void>;
|
||||
|
||||
// Extend IRouteAction
|
||||
export interface IRouteAction {
|
||||
// ... existing properties
|
||||
|
||||
// Socket handler function (when type is 'socket-handler')
|
||||
socketHandler?: TSocketHandler;
|
||||
export function createSocketWithErrorHandler(
|
||||
options: net.NetConnectOpts,
|
||||
onError: (err: Error) => void
|
||||
): net.Socket {
|
||||
const socket = net.connect(options);
|
||||
socket.on('error', onError);
|
||||
return socket;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
### Step 3: Fix HttpsPassthroughHandler (ts/forwarding/handlers/https-passthrough-handler.ts)
|
||||
- Replace direct socket creation with safe creation
|
||||
- Handle server connection failures immediately
|
||||
- Clean up client socket on server connection failure
|
||||
|
||||
## Phase 2: Simple Implementation
|
||||
### Step 4: Fix Connection Counting
|
||||
- Decrement on ANY socket close, not just when both close
|
||||
- Track failed connections separately
|
||||
- Add connection state tracking
|
||||
|
||||
### 2.1 Update Route Connection Handler
|
||||
**File:** `ts/proxies/smart-proxy/route-connection-handler.ts`
|
||||
|
||||
In the `handleConnection` method, add handling for socket-handler:
|
||||
|
||||
```typescript
|
||||
// After route matching...
|
||||
if (matchedRoute) {
|
||||
const action = matchedRoute.action;
|
||||
|
||||
if (action.type === 'socket-handler') {
|
||||
if (!action.socketHandler) {
|
||||
logger.error('socket-handler action missing socketHandler function');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Simply call the handler with the socket
|
||||
const result = action.socketHandler(socket);
|
||||
|
||||
// If it returns a promise, handle errors
|
||||
if (result instanceof Promise) {
|
||||
result.catch(error => {
|
||||
logger.error('Socket handler error:', error);
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Socket handler error:', error);
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
return; // Done - user has control now
|
||||
}
|
||||
|
||||
// ... rest of existing action handling
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Optional Context (If Needed)
|
||||
|
||||
If users need more info, we can optionally pass a minimal context as a second parameter:
|
||||
|
||||
```typescript
|
||||
export type TSocketHandler = (
|
||||
socket: net.Socket,
|
||||
context?: {
|
||||
route: IRouteConfig;
|
||||
clientIp: string;
|
||||
localPort: number;
|
||||
}
|
||||
) => void | Promise<void>;
|
||||
```
|
||||
|
||||
Usage:
|
||||
```typescript
|
||||
socketHandler: (socket, context) => {
|
||||
console.log(`Connection from ${context.clientIp} to port ${context.localPort}`);
|
||||
// Handle socket...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Helper Utilities (Optional)
|
||||
|
||||
### 4.1 Common Patterns
|
||||
**File:** `ts/proxies/smart-proxy/utils/route-helpers.ts`
|
||||
|
||||
```typescript
|
||||
// Simple helper to create socket handler routes
|
||||
export function createSocketHandlerRoute(
|
||||
domains: string | string[],
|
||||
ports: TPortRange,
|
||||
handler: TSocketHandler,
|
||||
options?: { name?: string; priority?: number }
|
||||
): IRouteConfig {
|
||||
return {
|
||||
name: options?.name || 'socket-handler-route',
|
||||
priority: options?.priority || 50,
|
||||
match: { domains, ports },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: handler
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Pre-built handlers for common cases
|
||||
export const SocketHandlers = {
|
||||
// Simple echo server
|
||||
echo: (socket: net.Socket) => {
|
||||
socket.on('data', data => socket.write(data));
|
||||
},
|
||||
|
||||
// TCP proxy
|
||||
proxy: (targetHost: string, targetPort: number) => (socket: net.Socket) => {
|
||||
const target = net.connect(targetPort, targetHost);
|
||||
socket.pipe(target);
|
||||
target.pipe(socket);
|
||||
socket.on('close', () => target.destroy());
|
||||
target.on('close', () => socket.destroy());
|
||||
},
|
||||
|
||||
// Line-based protocol
|
||||
lineProtocol: (handler: (line: string, socket: net.Socket) => void) => (socket: net.Socket) => {
|
||||
let buffer = '';
|
||||
socket.on('data', (data) => {
|
||||
buffer += data.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
lines.forEach(line => handler(line, socket));
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: Custom Protocol
|
||||
```typescript
|
||||
{
|
||||
name: 'custom-protocol',
|
||||
match: { ports: 9000 },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: (socket) => {
|
||||
socket.write('READY\n');
|
||||
socket.on('data', (data) => {
|
||||
const cmd = data.toString().trim();
|
||||
if (cmd === 'PING') socket.write('PONG\n');
|
||||
else if (cmd === 'QUIT') socket.end();
|
||||
else socket.write('ERROR: Unknown command\n');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example 2: Simple TCP Proxy
|
||||
```typescript
|
||||
{
|
||||
name: 'tcp-proxy',
|
||||
match: { ports: 8080, domains: 'proxy.example.com' },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.proxy('backend.local', 3000)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example 3: WebSocket with Custom Auth
|
||||
```typescript
|
||||
{
|
||||
name: 'custom-websocket',
|
||||
match: { ports: [80, 443], path: '/ws' },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: async (socket) => {
|
||||
// Read HTTP headers
|
||||
const headers = await readHttpHeaders(socket);
|
||||
|
||||
// Custom auth check
|
||||
if (!headers.authorization || !validateToken(headers.authorization)) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Proceed with WebSocket upgrade
|
||||
const ws = new WebSocket(socket, headers);
|
||||
// ... handle WebSocket
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits of This Approach
|
||||
|
||||
1. **Dead Simple API**: Just pass a function that gets the socket
|
||||
2. **No New Classes**: No ForwardingHandler subclass needed
|
||||
3. **Minimal Changes**: Only touches type definitions and one handler method
|
||||
4. **Full Power**: Users have complete control over the socket
|
||||
5. **Backward Compatible**: No changes to existing functionality
|
||||
6. **Easy to Test**: Just test the socket handler functions directly
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add `'socket-handler'` to `TRouteActionType` (5 minutes)
|
||||
2. Add `socketHandler?: TSocketHandler` to `IRouteAction` (5 minutes)
|
||||
3. Add socket-handler case in `RouteConnectionHandler.handleConnection()` (15 minutes)
|
||||
4. Add helper functions (optional, 30 minutes)
|
||||
5. Write tests (2 hours)
|
||||
6. Update documentation (1 hour)
|
||||
|
||||
**Total implementation time: ~4 hours** (vs 6 weeks for the complex version)
|
||||
|
||||
---
|
||||
|
||||
## What We're NOT Doing
|
||||
|
||||
- ❌ Creating new ForwardingHandler classes
|
||||
- ❌ Complex context objects with utils
|
||||
- ❌ HTTP request handling for socket handlers
|
||||
- ❌ Complex protocol detection mechanisms
|
||||
- ❌ Middleware patterns
|
||||
- ❌ Lifecycle hooks
|
||||
|
||||
Keep it simple. The user just wants to handle a socket.
|
||||
|
||||
---
|
||||
### Step 5: Update All Handlers
|
||||
- [ ] https-passthrough-handler.ts
|
||||
- [ ] http-handler.ts
|
||||
- [ ] https-terminate-to-http-handler.ts
|
||||
- [ ] https-terminate-to-https-handler.ts
|
||||
- [ ] route-connection-handler.ts
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- ✅ Users can define a route with `type: 'socket-handler'`
|
||||
- ✅ Users can provide a function that receives the socket
|
||||
- ✅ The function is called when a connection matches the route
|
||||
- ✅ Error handling prevents crashes
|
||||
- ✅ No performance impact on existing routes
|
||||
- ✅ Clean, simple API that's easy to understand
|
||||
1. **No server crashes** on ECONNREFUSED or other socket errors
|
||||
2. **Active connections** remain stable (no steady increase)
|
||||
3. **All sockets** properly cleaned up on errors
|
||||
4. **Memory usage** remains stable under load
|
||||
5. **Graceful handling** of all error scenarios
|
||||
|
||||
---
|
||||
## Testing Plan
|
||||
|
||||
## Implementation Notes (Completed)
|
||||
1. Simulate ECONNREFUSED by targeting closed ports
|
||||
2. Monitor active connection count over time
|
||||
3. Stress test with rapid connections
|
||||
4. Test with unreachable hosts
|
||||
5. Test with slow/timing out connections
|
||||
|
||||
### What Was Implemented
|
||||
1. **Type Definitions** - Added 'socket-handler' to TRouteActionType and TSocketHandler type
|
||||
2. **Route Handler** - Added socket-handler case in RouteConnectionHandler switch statement
|
||||
3. **Error Handling** - Both sync and async errors are caught and logged
|
||||
4. **Initial Data Handling** - Initial chunks are re-emitted to handler's listeners
|
||||
5. **Helper Functions** - Added createSocketHandlerRoute and pre-built handlers (echo, proxy, etc.)
|
||||
6. **Full Test Coverage** - All test cases pass including async handlers and error handling
|
||||
## Rollback Plan
|
||||
|
||||
### Key Implementation Details
|
||||
- Socket handlers require initial data from client to trigger routing (not TLS handshake)
|
||||
- The handler receives the raw socket after route matching
|
||||
- Both sync and async handlers are supported
|
||||
- Errors in handlers terminate the connection gracefully
|
||||
- Helper utilities provide common patterns (echo server, TCP proxy, line protocol)
|
||||
If issues arise:
|
||||
1. Revert socket creation changes
|
||||
2. Keep global error handlers (they add safety)
|
||||
3. Add more detailed logging for debugging
|
||||
4. Implement fixes incrementally
|
||||
|
||||
### Usage Notes
|
||||
- Clients must send initial data to trigger the handler (even just a newline)
|
||||
- The socket is passed directly to the handler function
|
||||
- Handler has complete control over the socket lifecycle
|
||||
- No special context object needed - keeps it simple
|
||||
## Timeline
|
||||
|
||||
**Total implementation time: ~3 hours**
|
||||
- Phase 1: Immediate (prevents crashes)
|
||||
- Phase 2: Within 24 hours (fixes leaks)
|
||||
- Phase 3: Within 48 hours (ensures stability)
|
||||
|
||||
## Notes
|
||||
|
||||
- The race condition is the most critical issue
|
||||
- Connection counting logic needs complete overhaul
|
||||
- Consider using a connection state machine for clarity
|
||||
- Add connection lifecycle events for debugging
|
764
readme.plan2.md
764
readme.plan2.md
@ -1,764 +0,0 @@
|
||||
# SmartProxy Simplification Plan: Unify Action Types
|
||||
|
||||
## Summary
|
||||
Complete removal of 'redirect', 'block', and 'static' action types, leaving only 'forward' and 'socket-handler'. All old code will be deleted entirely - no migration paths or backwards compatibility. Socket handlers will be enhanced to receive IRouteContext as a second parameter.
|
||||
|
||||
## Goal
|
||||
Create a dramatically simpler SmartProxy with only two action types, where everything is either proxied (forward) or handled by custom code (socket-handler).
|
||||
|
||||
## Current State
|
||||
```typescript
|
||||
export type TRouteActionType = 'forward' | 'redirect' | 'block' | 'static' | 'socket-handler';
|
||||
export type TSocketHandler = (socket: plugins.net.Socket) => void | Promise<void>;
|
||||
```
|
||||
|
||||
## Target State
|
||||
```typescript
|
||||
export type TRouteActionType = 'forward' | 'socket-handler';
|
||||
export type TSocketHandler = (socket: plugins.net.Socket, context: IRouteContext) => void | Promise<void>;
|
||||
```
|
||||
|
||||
## Benefits
|
||||
1. **Simpler API** - Only two action types to understand
|
||||
2. **Unified handling** - Everything is either forwarding or custom socket handling
|
||||
3. **More flexible** - Socket handlers can do anything the old types did and more
|
||||
4. **Less code** - Remove specialized handlers and their dependencies
|
||||
5. **Context aware** - Socket handlers get access to route context (domain, port, clientIp, etc.)
|
||||
6. **Clean codebase** - No legacy code or migration paths
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Code to Remove
|
||||
|
||||
### 1.1 Action Type Handlers
|
||||
- `RouteConnectionHandler.handleRedirectAction()`
|
||||
- `RouteConnectionHandler.handleBlockAction()`
|
||||
- `RouteConnectionHandler.handleStaticAction()`
|
||||
|
||||
### 1.2 Handler Classes
|
||||
- `RedirectHandler` class (http-proxy/handlers/)
|
||||
- `StaticHandler` class (http-proxy/handlers/)
|
||||
|
||||
### 1.3 Type Definitions
|
||||
- 'redirect', 'block', 'static' from TRouteActionType
|
||||
- IRouteRedirect interface
|
||||
- IRouteStatic interface
|
||||
- Related properties in IRouteAction
|
||||
|
||||
### 1.4 Helper Functions
|
||||
- `createStaticFileRoute()`
|
||||
- Any other helpers that create redirect/block/static routes
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Create Predefined Socket Handlers
|
||||
|
||||
### 2.1 Block Handler
|
||||
```typescript
|
||||
export const SocketHandlers = {
|
||||
// ... existing handlers
|
||||
|
||||
/**
|
||||
* Block connection immediately
|
||||
*/
|
||||
block: (message?: string) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||
// Can use context for logging or custom messages
|
||||
const finalMessage = message || `Connection blocked from ${context.clientIp}`;
|
||||
if (finalMessage) {
|
||||
socket.write(finalMessage);
|
||||
}
|
||||
socket.end();
|
||||
},
|
||||
|
||||
/**
|
||||
* HTTP block response
|
||||
*/
|
||||
httpBlock: (statusCode: number = 403, message?: string) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||
// Can customize message based on context
|
||||
const defaultMessage = `Access forbidden for ${context.domain || context.clientIp}`;
|
||||
const finalMessage = message || defaultMessage;
|
||||
|
||||
const response = [
|
||||
`HTTP/1.1 ${statusCode} ${finalMessage}`,
|
||||
'Content-Type: text/plain',
|
||||
`Content-Length: ${finalMessage.length}`,
|
||||
'Connection: close',
|
||||
'',
|
||||
finalMessage
|
||||
].join('\r\n');
|
||||
|
||||
socket.write(response);
|
||||
socket.end();
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 2.2 Redirect Handler
|
||||
```typescript
|
||||
export const SocketHandlers = {
|
||||
// ... existing handlers
|
||||
|
||||
/**
|
||||
* HTTP redirect handler
|
||||
*/
|
||||
httpRedirect: (locationTemplate: string, statusCode: number = 301) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||
let buffer = '';
|
||||
|
||||
socket.once('data', (data) => {
|
||||
buffer += data.toString();
|
||||
|
||||
// Parse HTTP request
|
||||
const lines = buffer.split('\r\n');
|
||||
const requestLine = lines[0];
|
||||
const [method, path] = requestLine.split(' ');
|
||||
|
||||
// Use domain from context (more reliable than Host header)
|
||||
const domain = context.domain || 'localhost';
|
||||
const port = context.port;
|
||||
|
||||
// Replace placeholders in location using context
|
||||
let finalLocation = locationTemplate
|
||||
.replace('{domain}', domain)
|
||||
.replace('{port}', String(port))
|
||||
.replace('{path}', path)
|
||||
.replace('{clientIp}', context.clientIp);
|
||||
|
||||
const message = `Redirecting to ${finalLocation}`;
|
||||
const response = [
|
||||
`HTTP/1.1 ${statusCode} ${statusCode === 301 ? 'Moved Permanently' : 'Found'}`,
|
||||
`Location: ${finalLocation}`,
|
||||
'Content-Type: text/plain',
|
||||
`Content-Length: ${message.length}`,
|
||||
'Connection: close',
|
||||
'',
|
||||
message
|
||||
].join('\r\n');
|
||||
|
||||
socket.write(response);
|
||||
socket.end();
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 2.3 Benefits of Context in Socket Handlers
|
||||
With routeContext as a second parameter, socket handlers can:
|
||||
- Access client IP for logging or rate limiting
|
||||
- Use domain information for multi-tenant handling
|
||||
- Check if connection is TLS and what version
|
||||
- Access route name/ID for metrics
|
||||
- Build more intelligent responses based on context
|
||||
|
||||
Example advanced handler:
|
||||
```typescript
|
||||
const rateLimitHandler = (maxRequests: number) => {
|
||||
const ipCounts = new Map<string, number>();
|
||||
|
||||
return (socket: net.Socket, context: IRouteContext) => {
|
||||
const count = (ipCounts.get(context.clientIp) || 0) + 1;
|
||||
ipCounts.set(context.clientIp, count);
|
||||
|
||||
if (count > maxRequests) {
|
||||
socket.write(`Rate limit exceeded for ${context.clientIp}\n`);
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Process request...
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Update Helper Functions
|
||||
|
||||
### 3.1 Update createHttpToHttpsRedirect
|
||||
```typescript
|
||||
export function createHttpToHttpsRedirect(
|
||||
domains: string | string[],
|
||||
httpsPort: number = 443,
|
||||
options: Partial<IRouteConfig> = {}
|
||||
): IRouteConfig {
|
||||
return {
|
||||
name: options.name || `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||
match: {
|
||||
ports: options.match?.ports || 80,
|
||||
domains
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpRedirect(`https://{domain}:${httpsPort}{path}`, 301)
|
||||
},
|
||||
...options
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Update createSocketHandlerRoute
|
||||
```typescript
|
||||
export function createSocketHandlerRoute(
|
||||
domains: string | string[],
|
||||
ports: TPortRange,
|
||||
handler: TSocketHandler,
|
||||
options: { name?: string; priority?: number; path?: string } = {}
|
||||
): IRouteConfig {
|
||||
return {
|
||||
name: options.name || 'socket-handler-route',
|
||||
priority: options.priority !== undefined ? options.priority : 50,
|
||||
match: {
|
||||
domains,
|
||||
ports,
|
||||
...(options.path && { path: options.path })
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: handler
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Core Implementation Changes
|
||||
|
||||
### 4.1 Update Route Connection Handler
|
||||
```typescript
|
||||
// Remove these methods:
|
||||
// - handleRedirectAction()
|
||||
// - handleBlockAction()
|
||||
// - handleStaticAction()
|
||||
|
||||
// Update switch statement to only have:
|
||||
switch (route.action.type) {
|
||||
case 'forward':
|
||||
return this.handleForwardAction(socket, record, route, initialChunk);
|
||||
|
||||
case 'socket-handler':
|
||||
this.handleSocketHandlerAction(socket, record, route, initialChunk);
|
||||
return;
|
||||
|
||||
default:
|
||||
logger.log('error', `Unknown action type '${(route.action as any).type}'`);
|
||||
socket.end();
|
||||
this.connectionManager.cleanupConnection(record, 'unknown_action');
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Update Socket Handler to Pass Context
|
||||
```typescript
|
||||
private async handleSocketHandlerAction(
|
||||
socket: plugins.net.Socket,
|
||||
record: IConnectionRecord,
|
||||
route: IRouteConfig,
|
||||
initialChunk?: Buffer
|
||||
): Promise<void> {
|
||||
const connectionId = record.id;
|
||||
|
||||
// Create route context for the handler
|
||||
const routeContext = this.createRouteContext({
|
||||
connectionId: record.id,
|
||||
port: record.localPort,
|
||||
domain: record.lockedDomain,
|
||||
clientIp: record.remoteIP,
|
||||
serverIp: socket.localAddress || '',
|
||||
isTls: record.isTLS || false,
|
||||
tlsVersion: record.tlsVersion,
|
||||
routeName: route.name,
|
||||
routeId: route.id,
|
||||
});
|
||||
|
||||
try {
|
||||
// Call the handler with socket AND context
|
||||
const result = route.action.socketHandler(socket, routeContext);
|
||||
|
||||
// Rest of implementation stays the same...
|
||||
} catch (error) {
|
||||
// Error handling...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Clean Up Imports and Exports
|
||||
- Remove imports of deleted handler classes
|
||||
- Update index.ts files to remove exports
|
||||
- Clean up any unused imports
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Test Updates
|
||||
|
||||
### 5.1 Remove Old Tests
|
||||
- Delete tests for redirect action type
|
||||
- Delete tests for block action type
|
||||
- Delete tests for static action type
|
||||
|
||||
### 5.2 Add New Socket Handler Tests
|
||||
- Test block socket handler with context
|
||||
- Test HTTP redirect socket handler with context
|
||||
- Test that context is properly passed to all handlers
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Documentation Updates
|
||||
|
||||
### 6.1 Update README.md
|
||||
- Remove documentation for redirect, block, static action types
|
||||
- Document the two remaining action types: forward and socket-handler
|
||||
- Add examples using socket handlers with context
|
||||
|
||||
### 6.2 Update Type Documentation
|
||||
```typescript
|
||||
/**
|
||||
* Route action types
|
||||
* - 'forward': Proxy the connection to a target host:port
|
||||
* - 'socket-handler': Pass the socket to a custom handler function
|
||||
*/
|
||||
export type TRouteActionType = 'forward' | 'socket-handler';
|
||||
|
||||
/**
|
||||
* Socket handler function
|
||||
* @param socket - The incoming socket connection
|
||||
* @param context - Route context with connection information
|
||||
*/
|
||||
export type TSocketHandler = (socket: net.Socket, context: IRouteContext) => void | Promise<void>;
|
||||
```
|
||||
|
||||
### 6.3 Example Documentation
|
||||
```typescript
|
||||
// Example: Block connections from specific IPs
|
||||
const ipBlocker = (socket: net.Socket, context: IRouteContext) => {
|
||||
if (context.clientIp.startsWith('192.168.')) {
|
||||
socket.write('Internal IPs not allowed\n');
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
// Forward to backend...
|
||||
};
|
||||
|
||||
// Example: Domain-based routing
|
||||
const domainRouter = (socket: net.Socket, context: IRouteContext) => {
|
||||
const backend = context.domain === 'api.example.com' ? 'api-server' : 'web-server';
|
||||
// Forward to appropriate backend...
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Update TSocketHandler type** (15 minutes)
|
||||
- Add IRouteContext as second parameter
|
||||
- Update type definition in route-types.ts
|
||||
|
||||
2. **Update socket handler implementation** (30 minutes)
|
||||
- Create routeContext in handleSocketHandlerAction
|
||||
- Pass context to socket handler function
|
||||
- Update all existing socket handlers in route-helpers.ts
|
||||
|
||||
3. **Remove old action types** (30 minutes)
|
||||
- Remove 'redirect', 'block', 'static' from TRouteActionType
|
||||
- Remove IRouteRedirect, IRouteStatic interfaces
|
||||
- Clean up IRouteAction interface
|
||||
|
||||
4. **Delete old handlers** (45 minutes)
|
||||
- Delete handleRedirectAction, handleBlockAction, handleStaticAction methods
|
||||
- Delete RedirectHandler and StaticHandler classes
|
||||
- Remove imports and exports
|
||||
|
||||
5. **Update route connection handler** (30 minutes)
|
||||
- Simplify switch statement to only handle 'forward' and 'socket-handler'
|
||||
- Remove all references to deleted action types
|
||||
|
||||
6. **Create new socket handlers** (30 minutes)
|
||||
- Implement SocketHandlers.block() with context
|
||||
- Implement SocketHandlers.httpBlock() with context
|
||||
- Implement SocketHandlers.httpRedirect() with context
|
||||
|
||||
7. **Update helper functions** (30 minutes)
|
||||
- Update createHttpToHttpsRedirect to use socket handler
|
||||
- Delete createStaticFileRoute entirely
|
||||
- Update any other affected helpers
|
||||
|
||||
8. **Clean up tests** (1.5 hours)
|
||||
- Delete all tests for removed action types
|
||||
- Update socket handler tests to verify context parameter
|
||||
- Add new tests for block/redirect socket handlers
|
||||
|
||||
9. **Update documentation** (30 minutes)
|
||||
- Update README.md
|
||||
- Update type documentation
|
||||
- Add examples of context usage
|
||||
|
||||
**Total estimated time: ~5 hours**
|
||||
|
||||
---
|
||||
|
||||
## Considerations
|
||||
|
||||
### Benefits
|
||||
- **Dramatically simpler API** - Only 2 action types instead of 5
|
||||
- **Consistent handling model** - Everything is either forwarding or custom handling
|
||||
- **More powerful** - Socket handlers with context can do much more than old static types
|
||||
- **Less code to maintain** - Removing hundreds of lines of specialized handler code
|
||||
- **Better extensibility** - Easy to add new socket handlers for any use case
|
||||
- **Context awareness** - All handlers get full connection context
|
||||
|
||||
### Trade-offs
|
||||
- Static file serving removed (users should use nginx/apache behind proxy)
|
||||
- HTTP-specific logic (redirects) now in socket handlers (but more flexible)
|
||||
- Slightly more verbose configuration for simple blocks/redirects
|
||||
|
||||
### Why This Approach
|
||||
1. **Simplicity wins** - Two concepts are easier to understand than five
|
||||
2. **Power through context** - Socket handlers with context are more capable
|
||||
3. **Clean break** - No migration paths means cleaner code
|
||||
4. **Future proof** - Easy to add new handlers without changing core
|
||||
|
||||
---
|
||||
|
||||
## Code Examples: Before and After
|
||||
|
||||
### Block Action
|
||||
```typescript
|
||||
// BEFORE
|
||||
{
|
||||
action: { type: 'block' }
|
||||
}
|
||||
|
||||
// AFTER
|
||||
{
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.block()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP Redirect
|
||||
```typescript
|
||||
// BEFORE
|
||||
{
|
||||
action: {
|
||||
type: 'redirect',
|
||||
redirect: {
|
||||
to: 'https://{domain}:443{path}',
|
||||
status: 301
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AFTER
|
||||
{
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Handler with Context
|
||||
```typescript
|
||||
// NEW CAPABILITY - Access to full context
|
||||
{
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: (socket, context) => {
|
||||
console.log(`Connection from ${context.clientIp} to ${context.domain}:${context.port}`);
|
||||
// Custom handling based on context...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Detailed Implementation Tasks
|
||||
|
||||
### Step 1: Update TSocketHandler Type (15 minutes)
|
||||
- [x] Open `ts/proxies/smart-proxy/models/route-types.ts`
|
||||
- [x] Find line 14: `export type TSocketHandler = (socket: plugins.net.Socket) => void | Promise<void>;`
|
||||
- [x] Import IRouteContext at top of file: `import type { IRouteContext } from '../../../core/models/route-context.js';`
|
||||
- [x] Update TSocketHandler to: `export type TSocketHandler = (socket: plugins.net.Socket, context: IRouteContext) => void | Promise<void>;`
|
||||
- [x] Save file
|
||||
|
||||
### Step 2: Update Socket Handler Implementation (30 minutes)
|
||||
- [x] Open `ts/proxies/smart-proxy/route-connection-handler.ts`
|
||||
- [x] Find `handleSocketHandlerAction` method (around line 790)
|
||||
- [x] Add route context creation after line 809:
|
||||
```typescript
|
||||
// Create route context for the handler
|
||||
const routeContext = this.createRouteContext({
|
||||
connectionId: record.id,
|
||||
port: record.localPort,
|
||||
domain: record.lockedDomain,
|
||||
clientIp: record.remoteIP,
|
||||
serverIp: socket.localAddress || '',
|
||||
isTls: record.isTLS || false,
|
||||
tlsVersion: record.tlsVersion,
|
||||
routeName: route.name,
|
||||
routeId: route.id,
|
||||
});
|
||||
```
|
||||
- [x] Update line 812 from `const result = route.action.socketHandler(socket);`
|
||||
- [x] To: `const result = route.action.socketHandler(socket, routeContext);`
|
||||
- [x] Save file
|
||||
|
||||
### Step 3: Update Existing Socket Handlers in route-helpers.ts (20 minutes)
|
||||
- [x] Open `ts/proxies/smart-proxy/utils/route-helpers.ts`
|
||||
- [x] Update `echo` handler (line 856):
|
||||
- From: `echo: (socket: plugins.net.Socket) => {`
|
||||
- To: `echo: (socket: plugins.net.Socket, context: IRouteContext) => {`
|
||||
- [x] Update `proxy` handler (line 864):
|
||||
- From: `proxy: (targetHost: string, targetPort: number) => (socket: plugins.net.Socket) => {`
|
||||
- To: `proxy: (targetHost: string, targetPort: number) => (socket: plugins.net.Socket, context: IRouteContext) => {`
|
||||
- [x] Update `lineProtocol` handler (line 879):
|
||||
- From: `lineProtocol: (handler: (line: string, socket: plugins.net.Socket) => void) => (socket: plugins.net.Socket) => {`
|
||||
- To: `lineProtocol: (handler: (line: string, socket: plugins.net.Socket) => void) => (socket: plugins.net.Socket, context: IRouteContext) => {`
|
||||
- [ ] Update `httpResponse` handler (line 896):
|
||||
- From: `httpResponse: (statusCode: number, body: string) => (socket: plugins.net.Socket) => {`
|
||||
- To: `httpResponse: (statusCode: number, body: string) => (socket: plugins.net.Socket, context: IRouteContext) => {`
|
||||
- [ ] Save file
|
||||
|
||||
### Step 4: Remove Old Action Types from Type Definitions (15 minutes)
|
||||
- [ ] Open `ts/proxies/smart-proxy/models/route-types.ts`
|
||||
- [ ] Find line with TRouteActionType (around line 10)
|
||||
- [ ] Change from: `export type TRouteActionType = 'forward' | 'redirect' | 'block' | 'static' | 'socket-handler';`
|
||||
- [ ] To: `export type TRouteActionType = 'forward' | 'socket-handler';`
|
||||
- [ ] Find and delete IRouteRedirect interface (around line 123-126)
|
||||
- [ ] Find and delete IRouteStatic interface (if exists)
|
||||
- [ ] Find IRouteAction interface
|
||||
- [ ] Remove these properties:
|
||||
- `redirect?: IRouteRedirect;`
|
||||
- `static?: IRouteStatic;`
|
||||
- [ ] Save file
|
||||
|
||||
### Step 5: Delete Handler Classes (15 minutes)
|
||||
- [ ] Delete file: `ts/proxies/http-proxy/handlers/redirect-handler.ts`
|
||||
- [ ] Delete file: `ts/proxies/http-proxy/handlers/static-handler.ts`
|
||||
- [ ] Open `ts/proxies/http-proxy/handlers/index.ts`
|
||||
- [ ] Delete all content (the file only exports RedirectHandler and StaticHandler)
|
||||
- [ ] Save empty file or delete it
|
||||
|
||||
### Step 6: Remove Handler Methods from RouteConnectionHandler (30 minutes)
|
||||
- [ ] Open `ts/proxies/smart-proxy/route-connection-handler.ts`
|
||||
- [ ] Find and delete entire `handleRedirectAction` method (around line 723)
|
||||
- [ ] Find and delete entire `handleBlockAction` method (around line 750)
|
||||
- [ ] Find and delete entire `handleStaticAction` method (around line 773)
|
||||
- [ ] Remove imports at top:
|
||||
- `import { RedirectHandler, StaticHandler } from '../http-proxy/handlers/index.js';`
|
||||
- [ ] Save file
|
||||
|
||||
### Step 7: Update Switch Statement (15 minutes)
|
||||
- [ ] Still in `route-connection-handler.ts`
|
||||
- [ ] Find switch statement (around line 388)
|
||||
- [ ] Remove these cases:
|
||||
- `case 'redirect': return this.handleRedirectAction(...)`
|
||||
- `case 'block': return this.handleBlockAction(...)`
|
||||
- `case 'static': this.handleStaticAction(...); return;`
|
||||
- [ ] Verify only 'forward' and 'socket-handler' cases remain
|
||||
- [ ] Save file
|
||||
|
||||
### Step 8: Add New Socket Handlers to route-helpers.ts (30 minutes)
|
||||
- [ ] Open `ts/proxies/smart-proxy/utils/route-helpers.ts`
|
||||
- [ ] Add import at top: `import type { IRouteContext } from '../../../core/models/route-context.js';`
|
||||
- [ ] Add to SocketHandlers object:
|
||||
```typescript
|
||||
/**
|
||||
* Block connection immediately
|
||||
*/
|
||||
block: (message?: string) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||
const finalMessage = message || `Connection blocked from ${context.clientIp}`;
|
||||
if (finalMessage) {
|
||||
socket.write(finalMessage);
|
||||
}
|
||||
socket.end();
|
||||
},
|
||||
|
||||
/**
|
||||
* HTTP block response
|
||||
*/
|
||||
httpBlock: (statusCode: number = 403, message?: string) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||
const defaultMessage = `Access forbidden for ${context.domain || context.clientIp}`;
|
||||
const finalMessage = message || defaultMessage;
|
||||
|
||||
const response = [
|
||||
`HTTP/1.1 ${statusCode} ${finalMessage}`,
|
||||
'Content-Type: text/plain',
|
||||
`Content-Length: ${finalMessage.length}`,
|
||||
'Connection: close',
|
||||
'',
|
||||
finalMessage
|
||||
].join('\r\n');
|
||||
|
||||
socket.write(response);
|
||||
socket.end();
|
||||
},
|
||||
|
||||
/**
|
||||
* HTTP redirect handler
|
||||
*/
|
||||
httpRedirect: (locationTemplate: string, statusCode: number = 301) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||
let buffer = '';
|
||||
|
||||
socket.once('data', (data) => {
|
||||
buffer += data.toString();
|
||||
|
||||
const lines = buffer.split('\r\n');
|
||||
const requestLine = lines[0];
|
||||
const [method, path] = requestLine.split(' ');
|
||||
|
||||
const domain = context.domain || 'localhost';
|
||||
const port = context.port;
|
||||
|
||||
let finalLocation = locationTemplate
|
||||
.replace('{domain}', domain)
|
||||
.replace('{port}', String(port))
|
||||
.replace('{path}', path)
|
||||
.replace('{clientIp}', context.clientIp);
|
||||
|
||||
const message = `Redirecting to ${finalLocation}`;
|
||||
const response = [
|
||||
`HTTP/1.1 ${statusCode} ${statusCode === 301 ? 'Moved Permanently' : 'Found'}`,
|
||||
`Location: ${finalLocation}`,
|
||||
'Content-Type: text/plain',
|
||||
`Content-Length: ${message.length}`,
|
||||
'Connection: close',
|
||||
'',
|
||||
message
|
||||
].join('\r\n');
|
||||
|
||||
socket.write(response);
|
||||
socket.end();
|
||||
});
|
||||
}
|
||||
```
|
||||
- [x] Save file
|
||||
|
||||
### Step 9: Update Helper Functions (20 minutes)
|
||||
- [x] Still in `route-helpers.ts`
|
||||
- [x] Update `createHttpToHttpsRedirect` function (around line 109):
|
||||
- Change the action to use socket handler:
|
||||
```typescript
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpRedirect(`https://{domain}:${httpsPort}{path}`, 301)
|
||||
}
|
||||
```
|
||||
- [x] Delete entire `createStaticFileRoute` function (lines 277-322)
|
||||
- [x] Save file
|
||||
|
||||
### Step 10: Update Test Files (1.5 hours)
|
||||
#### 10.1 Update Socket Handler Tests
|
||||
- [x] Open `test/test.socket-handler.ts`
|
||||
- [x] Update all handler functions to accept context parameter
|
||||
- [x] Open `test/test.socket-handler.simple.ts`
|
||||
- [x] Update handler to accept context parameter
|
||||
- [x] Open `test/test.socket-handler-race.ts`
|
||||
- [x] Update handler to accept context parameter
|
||||
|
||||
#### 10.2 Find and Update/Delete Redirect Tests
|
||||
- [x] Search for files containing `type: 'redirect'` in test directory
|
||||
- [x] For each file:
|
||||
- [x] If it's a redirect-specific test, delete the file
|
||||
- [x] If it's a mixed test, update redirect actions to use socket handlers
|
||||
- [x] Files to check:
|
||||
- [x] `test/test.route-redirects.ts` - deleted entire file
|
||||
- [x] `test/test.forwarding.ts` - update any redirect tests
|
||||
- [x] `test/test.forwarding.examples.ts` - update any redirect tests
|
||||
- [x] `test/test.route-config.ts` - update any redirect tests
|
||||
|
||||
#### 10.3 Find and Update/Delete Block Tests
|
||||
- [x] Search for files containing `type: 'block'` in test directory
|
||||
- [x] Update or delete as appropriate
|
||||
|
||||
#### 10.4 Find and Delete Static Tests
|
||||
- [x] Search for files containing `type: 'static'` in test directory
|
||||
- [x] Delete static-specific test files
|
||||
- [x] Remove static tests from mixed test files
|
||||
|
||||
### Step 11: Clean Up Imports and Exports (20 minutes)
|
||||
- [x] Open `ts/proxies/smart-proxy/utils/index.ts`
|
||||
- [x] Ensure route-helpers.ts is exported
|
||||
- [x] Remove any exports of deleted functions
|
||||
- [x] Open `ts/index.ts`
|
||||
- [x] Remove any exports of deleted types/interfaces
|
||||
- [x] Search for any remaining imports of RedirectHandler or StaticHandler
|
||||
- [x] Remove any found imports
|
||||
|
||||
### Step 12: Documentation Updates (30 minutes)
|
||||
- [x] Update README.md:
|
||||
- [x] Remove any mention of redirect, block, static action types
|
||||
- [x] Add examples of socket handlers with context
|
||||
- [x] Document the two action types: forward and socket-handler
|
||||
- [x] Update any JSDoc comments in modified files
|
||||
- [x] Add examples showing context usage
|
||||
|
||||
### Step 13: Final Verification (15 minutes)
|
||||
- [x] Run build: `pnpm build`
|
||||
- [x] Fix any compilation errors
|
||||
- [x] Run tests: `pnpm test`
|
||||
- [x] Fix any failing tests
|
||||
- [x] Search codebase for any remaining references to:
|
||||
- [x] 'redirect' action type
|
||||
- [x] 'block' action type
|
||||
- [x] 'static' action type
|
||||
- [x] RedirectHandler
|
||||
- [x] StaticHandler
|
||||
- [x] IRouteRedirect
|
||||
- [x] IRouteStatic
|
||||
|
||||
### Step 14: Test New Functionality (30 minutes)
|
||||
- [x] Create test for block socket handler with context
|
||||
- [x] Create test for httpBlock socket handler with context
|
||||
- [x] Create test for httpRedirect socket handler with context
|
||||
- [x] Verify context is properly passed in all scenarios
|
||||
|
||||
---
|
||||
|
||||
## Files to be Modified/Deleted
|
||||
|
||||
### Files to Modify:
|
||||
1. `ts/proxies/smart-proxy/models/route-types.ts` - Update types
|
||||
2. `ts/proxies/smart-proxy/route-connection-handler.ts` - Remove handlers, update switch
|
||||
3. `ts/proxies/smart-proxy/utils/route-helpers.ts` - Update handlers, add new ones
|
||||
4. `ts/proxies/http-proxy/handlers/index.ts` - Remove exports
|
||||
5. Various test files - Update to use socket handlers
|
||||
|
||||
### Files to Delete:
|
||||
1. `ts/proxies/http-proxy/handlers/redirect-handler.ts`
|
||||
2. `ts/proxies/http-proxy/handlers/static-handler.ts`
|
||||
3. `test/test.route-redirects.ts` (likely)
|
||||
4. Any static-specific test files
|
||||
|
||||
### Test Files Requiring Updates (15 files found):
|
||||
- test/test.acme-http01-challenge.ts
|
||||
- test/test.logger-error-handling.ts
|
||||
- test/test.port80-management.node.ts
|
||||
- test/test.route-update-callback.node.ts
|
||||
- test/test.acme-state-manager.node.ts
|
||||
- test/test.acme-route-creation.ts
|
||||
- test/test.forwarding.ts
|
||||
- test/test.route-redirects.ts
|
||||
- test/test.forwarding.examples.ts
|
||||
- test/test.acme-simple.ts
|
||||
- test/test.acme-http-challenge.ts
|
||||
- test/test.certificate-provisioning.ts
|
||||
- test/test.route-config.ts
|
||||
- test/test.route-utils.ts
|
||||
- test/test.certificate-simple.ts
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
- ✅ Only 'forward' and 'socket-handler' action types remain
|
||||
- ✅ Socket handlers receive IRouteContext as second parameter
|
||||
- ✅ All old handler code completely removed
|
||||
- ✅ Redirect functionality works via context-aware socket handlers
|
||||
- ✅ Block functionality works via context-aware socket handlers
|
||||
- ✅ All tests updated and passing
|
||||
- ✅ Documentation updated with new examples
|
||||
- ✅ No performance regression
|
||||
- ✅ Cleaner, simpler codebase
|
@ -1,86 +1,170 @@
|
||||
# SmartProxy Module Problems
|
||||
# SmartProxy Performance Issues Report
|
||||
|
||||
Based on test analysis, the following potential issues have been identified in the SmartProxy module:
|
||||
## Executive Summary
|
||||
This report identifies performance issues and blocking operations in the SmartProxy codebase that could impact scalability and responsiveness under high load.
|
||||
|
||||
## 1. HttpProxy Route Configuration Issue
|
||||
**Location**: `ts/proxies/http-proxy/http-proxy.ts:380`
|
||||
**Problem**: The HttpProxy is trying to read the 'type' property of an undefined object when updating route configurations.
|
||||
**Evidence**: `test.http-forwarding-fix.ts` fails with:
|
||||
```
|
||||
TypeError: Cannot read properties of undefined (reading 'type')
|
||||
at HttpProxy.updateRouteConfigs (/mnt/data/lossless/push.rocks/smartproxy/ts/proxies/http-proxy/http-proxy.ts:380:24)
|
||||
```
|
||||
**Impact**: Routes with `useHttpProxy` configuration may not work properly.
|
||||
## Critical Issues
|
||||
|
||||
## 2. Connection Forwarding Issues
|
||||
**Problem**: Basic TCP forwarding appears to not be working correctly after the simplification to just 'forward' and 'socket-handler' action types.
|
||||
**Evidence**: Multiple forwarding tests timeout waiting for data to be forwarded:
|
||||
- `test.forwarding-fix-verification.ts` - times out waiting for forwarded data
|
||||
- `test.connection-forwarding.ts` - times out on SNI-based forwarding
|
||||
**Impact**: The 'forward' action type may not be properly forwarding connections to target servers.
|
||||
### 1. **Synchronous Filesystem Operations**
|
||||
These operations block the event loop and should be replaced with async alternatives:
|
||||
|
||||
## 3. Missing Certificate Manager Methods
|
||||
**Problem**: Tests expect `provisionAllCertificates` method on certificate manager but it may not exist or may not be properly initialized.
|
||||
**Evidence**: Multiple tests fail with "this.certManager.provisionAllCertificates is not a function"
|
||||
**Impact**: Certificate provisioning may not work as expected.
|
||||
#### Certificate Management
|
||||
- `ts/proxies/http-proxy/certificate-manager.ts:29`: `fs.existsSync()`
|
||||
- `ts/proxies/http-proxy/certificate-manager.ts:30`: `fs.mkdirSync()`
|
||||
- `ts/proxies/http-proxy/certificate-manager.ts:49-50`: `fs.readFileSync()` for loading certificates
|
||||
|
||||
## 4. Route Update Mechanism
|
||||
**Problem**: The route update mechanism may have issues preserving certificate manager callbacks and other state.
|
||||
**Evidence**: Tests specifically designed to verify callback preservation after route updates.
|
||||
**Impact**: Dynamic route updates might break certificate management functionality.
|
||||
#### NFTables Proxy
|
||||
- `ts/proxies/nftables-proxy/nftables-proxy.ts`: Multiple uses of `execSync()` for system commands
|
||||
- `ts/proxies/nftables-proxy/nftables-proxy.ts`: Multiple `fs.writeFileSync()` and `fs.unlinkSync()` operations
|
||||
|
||||
## 5. Route-Specific Security Not Fully Implemented
|
||||
**Problem**: While the route definitions support security configurations (ipAllowList, ipBlockList, authentication), these are not being enforced at the route level.
|
||||
**Evidence**:
|
||||
- SecurityManager has methods like `isIPAuthorized` for route-specific security
|
||||
- Route connection handler only checks global IP validation, not route-specific security rules
|
||||
- No evidence of route.action.security being checked when handling connections
|
||||
**Impact**: Route-specific security rules defined in configuration are not enforced, potentially allowing unauthorized access.
|
||||
**Status**: ✅ FIXED - Route-specific IP allow/block lists are now enforced when a route is matched. Authentication is logged as not enforceable for non-terminated connections.
|
||||
**Additional Fix**: Removed security checks from route matching logic - security is now properly enforced AFTER a route is matched, not during matching.
|
||||
#### Certificate Store
|
||||
- `ts/proxies/smart-proxy/cert-store.ts:8`: `ensureDirSync()`
|
||||
- `ts/proxies/smart-proxy/cert-store.ts:15,31,76`: `fileExistsSync()`
|
||||
- `ts/proxies/smart-proxy/cert-store.ts:77`: `removeManySync()`
|
||||
|
||||
## 6. Security Property Location Consolidation
|
||||
**Problem**: Security was defined in two places - route.security and route.action.security - causing confusion.
|
||||
**Status**: ✅ FIXED - Consolidated to only route.security. Removed action.security from types and updated all references.
|
||||
### 2. **Event Loop Blocking Operations**
|
||||
|
||||
#### Busy Wait Loop
|
||||
- `ts/proxies/nftables-proxy/nftables-proxy.ts:235-238`:
|
||||
```typescript
|
||||
const waitUntil = Date.now() + retryDelayMs;
|
||||
while (Date.now() < waitUntil) {
|
||||
// busy wait - blocks event loop completely
|
||||
}
|
||||
```
|
||||
This is extremely problematic as it blocks the entire Node.js event loop.
|
||||
|
||||
### 3. **Potential Memory Leaks**
|
||||
|
||||
#### Timer Management Issues
|
||||
Several timers are created without proper cleanup:
|
||||
- `ts/proxies/http-proxy/function-cache.ts`: `setInterval()` without storing reference for cleanup
|
||||
- `ts/proxies/http-proxy/request-handler.ts`: `setInterval()` for rate limit cleanup without cleanup
|
||||
- `ts/core/utils/shared-security-manager.ts`: `cleanupInterval` stored but no cleanup method
|
||||
|
||||
#### Event Listener Accumulation
|
||||
- Multiple instances of event listeners being added without corresponding cleanup
|
||||
- Connection handlers add listeners without always removing them on connection close
|
||||
|
||||
### 4. **Connection Pool Management**
|
||||
|
||||
#### ConnectionPool (ts/proxies/http-proxy/connection-pool.ts)
|
||||
**Good practices observed:**
|
||||
- Proper connection lifecycle management
|
||||
- Periodic cleanup of idle connections
|
||||
- Connection limits enforcement
|
||||
|
||||
**Potential issues:**
|
||||
- No backpressure mechanism when pool is full
|
||||
- Synchronous sorting operation in `cleanupConnectionPool()` could be slow with many connections
|
||||
|
||||
### 5. **Resource Management Issues**
|
||||
|
||||
#### Socket Cleanup
|
||||
- Some error paths don't properly clean up sockets
|
||||
- Missing `removeAllListeners()` in some error scenarios could lead to memory leaks
|
||||
|
||||
#### Timeout Management
|
||||
- Inconsistent timeout handling across different components
|
||||
- Some sockets created without timeout settings
|
||||
|
||||
### 6. **JSON Operations on Large Objects**
|
||||
- `ts/proxies/smart-proxy/cert-store.ts:21`: `JSON.parse()` on certificate metadata
|
||||
- `ts/proxies/smart-proxy/cert-store.ts:71`: `JSON.stringify()` with pretty printing
|
||||
- `ts/proxies/http-proxy/function-cache.ts:76`: `JSON.stringify()` for cache keys (called frequently)
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Verify Forward Action Implementation**: Check that the 'forward' action type properly establishes bidirectional data flow between client and target server. ✅ FIXED - Basic forwarding now works correctly.
|
||||
### Immediate Actions (High Priority)
|
||||
|
||||
2. **Fix HttpProxy Route Handling**: Ensure that route objects passed to HttpProxy.updateRouteConfigs have the expected structure with all required properties. ✅ FIXED - Routes now preserve their structure.
|
||||
1. **Replace Synchronous Operations**
|
||||
```typescript
|
||||
// Instead of:
|
||||
if (fs.existsSync(path)) { ... }
|
||||
|
||||
// Use:
|
||||
try {
|
||||
await fs.promises.access(path);
|
||||
// file exists
|
||||
} catch {
|
||||
// file doesn't exist
|
||||
}
|
||||
```
|
||||
|
||||
3. **Review Certificate Manager API**: Ensure all expected methods exist and are properly documented.
|
||||
2. **Fix Busy Wait Loop**
|
||||
```typescript
|
||||
// Instead of:
|
||||
while (Date.now() < waitUntil) { }
|
||||
|
||||
// Use:
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
|
||||
```
|
||||
|
||||
4. **Add Integration Tests**: Many unit tests are testing internal implementation details. Consider adding more integration tests that test the public API.
|
||||
3. **Add Timer Cleanup**
|
||||
```typescript
|
||||
class Component {
|
||||
private cleanupTimer?: NodeJS.Timeout;
|
||||
|
||||
start() {
|
||||
this.cleanupTimer = setInterval(() => { ... }, 60000);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. **Implement Route-Specific Security**: Add security checks when a route is matched to enforce route-specific IP allow/block lists and authentication rules. ✅ FIXED - IP allow/block lists are now enforced at the route level.
|
||||
### Medium Priority
|
||||
|
||||
6. **Fix TLS Detection Logic**: The connection handler was treating all connections as TLS. This has been partially fixed but needs proper testing for all TLS modes.
|
||||
1. **Optimize JSON Operations**
|
||||
- Cache JSON.stringify results for frequently used objects
|
||||
- Consider using faster hashing for cache keys (e.g., crypto.createHash)
|
||||
- Use streaming JSON parsers for large objects
|
||||
|
||||
## 7. HTTP Domain Matching Issue
|
||||
**Problem**: Routes with domain restrictions fail to match HTTP connections because domain information is only available after HTTP headers are received, but route matching happens immediately upon connection.
|
||||
**Evidence**:
|
||||
- `test.http-port8080-forwarding.ts` - "No route found for connection on port 8080" despite having a matching route
|
||||
- HTTP connections provide domain info via the Host header, which arrives after the initial TCP connection
|
||||
- Route matching in `handleInitialData` happens before HTTP headers are parsed
|
||||
**Impact**: HTTP routes with domain restrictions cannot be matched, forcing users to remove domain restrictions for HTTP routes.
|
||||
**Root Cause**: For non-TLS connections, SmartProxy attempts to match routes immediately, but the domain information needed for matching is only available after parsing HTTP headers.
|
||||
**Status**: ✅ FIXED - Added skipDomainCheck parameter to route matching for HTTP proxy ports. When a port is configured with useHttpProxy and the connection is not TLS, domain validation is skipped at the initial route matching stage, allowing the HttpProxy to handle domain-based routing after headers are received.
|
||||
2. **Improve Connection Pool**
|
||||
- Implement backpressure/queueing when pool is full
|
||||
- Use a heap or priority queue for connection management instead of sorting
|
||||
|
||||
## 8. HttpProxy Plain HTTP Forwarding Issue
|
||||
**Problem**: HttpProxy is an HTTPS server but SmartProxy forwards plain HTTP connections to it via `useHttpProxy` configuration.
|
||||
**Evidence**:
|
||||
- `test.http-port8080-forwarding.ts` - Connection immediately closed after forwarding to HttpProxy
|
||||
- HttpProxy is created with `http2.createSecureServer` expecting TLS connections
|
||||
- SmartProxy forwards raw HTTP data to HttpProxy's HTTPS port
|
||||
**Impact**: Plain HTTP connections cannot be handled by HttpProxy, despite `useHttpProxy` configuration suggesting this should work.
|
||||
**Root Cause**: Design mismatch - HttpProxy is designed for HTTPS/TLS termination, not plain HTTP forwarding.
|
||||
**Status**: Documented. The `useHttpProxy` configuration should only be used for ports that receive TLS connections requiring termination. For plain HTTP forwarding, use direct forwarding without HttpProxy.
|
||||
3. **Standardize Resource Cleanup**
|
||||
- Create a base class for components with lifecycle management
|
||||
- Ensure all event listeners are removed on cleanup
|
||||
- Add abort controllers for better cancellation support
|
||||
|
||||
## 9. Route Security Configuration Location Issue
|
||||
**Problem**: Tests were placing security configuration in `route.action.security` instead of `route.security`.
|
||||
**Evidence**:
|
||||
- `test.route-security.ts` - IP block list test failing because security was in wrong location
|
||||
- IRouteConfig interface defines security at route level, not inside action
|
||||
**Impact**: Security rules defined in action.security were ignored, causing tests to fail.
|
||||
**Status**: ✅ FIXED - Updated tests to place security configuration at the correct location (route.security).
|
||||
### Long-term Improvements
|
||||
|
||||
1. **Worker Threads**
|
||||
- Move CPU-intensive operations to worker threads
|
||||
- Consider using worker pools for NFTables operations
|
||||
|
||||
2. **Monitoring and Metrics**
|
||||
- Add performance monitoring for event loop lag
|
||||
- Track connection pool utilization
|
||||
- Monitor memory usage patterns
|
||||
|
||||
3. **Graceful Degradation**
|
||||
- Implement circuit breakers for backend connections
|
||||
- Add request queuing with overflow protection
|
||||
- Implement adaptive timeout strategies
|
||||
|
||||
## Impact Assessment
|
||||
|
||||
These issues primarily affect:
|
||||
- **Scalability**: Blocking operations limit concurrent connection handling
|
||||
- **Responsiveness**: Event loop blocking causes latency spikes
|
||||
- **Stability**: Memory leaks could cause crashes under sustained load
|
||||
- **Resource Usage**: Inefficient resource management increases memory/CPU usage
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
1. Load test with high connection counts (10k+ concurrent)
|
||||
2. Monitor event loop lag under stress
|
||||
3. Test long-running scenarios to detect memory leaks
|
||||
4. Benchmark with async vs sync operations to measure improvement
|
||||
|
||||
## Conclusion
|
||||
|
||||
While SmartProxy has good architectural design and many best practices, the identified blocking operations and resource management issues could significantly impact performance under high load. The most critical issues (busy wait loop and synchronous filesystem operations) should be addressed immediately.
|
200
test/core/utils/test.async-utils.ts
Normal file
200
test/core/utils/test.async-utils.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
delay,
|
||||
retryWithBackoff,
|
||||
withTimeout,
|
||||
parallelLimit,
|
||||
debounceAsync,
|
||||
AsyncMutex,
|
||||
CircuitBreaker
|
||||
} from '../../../ts/core/utils/async-utils.js';
|
||||
|
||||
tap.test('delay should pause execution for specified milliseconds', async () => {
|
||||
const startTime = Date.now();
|
||||
await delay(100);
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// Allow some tolerance for timing
|
||||
expect(elapsed).toBeGreaterThan(90);
|
||||
expect(elapsed).toBeLessThan(150);
|
||||
});
|
||||
|
||||
tap.test('retryWithBackoff should retry failed operations', async () => {
|
||||
let attempts = 0;
|
||||
const operation = async () => {
|
||||
attempts++;
|
||||
if (attempts < 3) {
|
||||
throw new Error('Test error');
|
||||
}
|
||||
return 'success';
|
||||
};
|
||||
|
||||
const result = await retryWithBackoff(operation, {
|
||||
maxAttempts: 3,
|
||||
initialDelay: 10
|
||||
});
|
||||
|
||||
expect(result).toEqual('success');
|
||||
expect(attempts).toEqual(3);
|
||||
});
|
||||
|
||||
tap.test('retryWithBackoff should throw after max attempts', async () => {
|
||||
let attempts = 0;
|
||||
const operation = async () => {
|
||||
attempts++;
|
||||
throw new Error('Always fails');
|
||||
};
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await retryWithBackoff(operation, {
|
||||
maxAttempts: 2,
|
||||
initialDelay: 10
|
||||
});
|
||||
} catch (e: any) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toEqual('Always fails');
|
||||
expect(attempts).toEqual(2);
|
||||
});
|
||||
|
||||
tap.test('withTimeout should complete operations within timeout', async () => {
|
||||
const operation = async () => {
|
||||
await delay(50);
|
||||
return 'completed';
|
||||
};
|
||||
|
||||
const result = await withTimeout(operation, 100);
|
||||
expect(result).toEqual('completed');
|
||||
});
|
||||
|
||||
tap.test('withTimeout should throw on timeout', async () => {
|
||||
const operation = async () => {
|
||||
await delay(200);
|
||||
return 'never happens';
|
||||
};
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await withTimeout(operation, 50);
|
||||
} catch (e: any) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toContain('timed out');
|
||||
});
|
||||
|
||||
tap.test('parallelLimit should respect concurrency limit', async () => {
|
||||
let concurrent = 0;
|
||||
let maxConcurrent = 0;
|
||||
|
||||
const items = [1, 2, 3, 4, 5, 6];
|
||||
const operation = async (item: number) => {
|
||||
concurrent++;
|
||||
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
||||
await delay(50);
|
||||
concurrent--;
|
||||
return item * 2;
|
||||
};
|
||||
|
||||
const results = await parallelLimit(items, operation, 2);
|
||||
|
||||
expect(results).toEqual([2, 4, 6, 8, 10, 12]);
|
||||
expect(maxConcurrent).toBeLessThan(3);
|
||||
expect(maxConcurrent).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('debounceAsync should debounce function calls', async () => {
|
||||
let callCount = 0;
|
||||
const fn = async (value: string) => {
|
||||
callCount++;
|
||||
return value;
|
||||
};
|
||||
|
||||
const debounced = debounceAsync(fn, 50);
|
||||
|
||||
// Make multiple calls quickly
|
||||
debounced('a');
|
||||
debounced('b');
|
||||
debounced('c');
|
||||
const result = await debounced('d');
|
||||
|
||||
// Wait a bit to ensure no more calls
|
||||
await delay(100);
|
||||
|
||||
expect(result).toEqual('d');
|
||||
expect(callCount).toEqual(1); // Only the last call should execute
|
||||
});
|
||||
|
||||
tap.test('AsyncMutex should ensure exclusive access', async () => {
|
||||
const mutex = new AsyncMutex();
|
||||
const results: number[] = [];
|
||||
|
||||
const operation = async (value: number) => {
|
||||
await mutex.runExclusive(async () => {
|
||||
results.push(value);
|
||||
await delay(10);
|
||||
results.push(value * 10);
|
||||
});
|
||||
};
|
||||
|
||||
// Run operations concurrently
|
||||
await Promise.all([
|
||||
operation(1),
|
||||
operation(2),
|
||||
operation(3)
|
||||
]);
|
||||
|
||||
// Results should show sequential execution
|
||||
expect(results).toEqual([1, 10, 2, 20, 3, 30]);
|
||||
});
|
||||
|
||||
tap.test('CircuitBreaker should open after failures', async () => {
|
||||
const breaker = new CircuitBreaker({
|
||||
failureThreshold: 2,
|
||||
resetTimeout: 100
|
||||
});
|
||||
|
||||
let attempt = 0;
|
||||
const failingOperation = async () => {
|
||||
attempt++;
|
||||
throw new Error('Test failure');
|
||||
};
|
||||
|
||||
// First two failures
|
||||
for (let i = 0; i < 2; i++) {
|
||||
try {
|
||||
await breaker.execute(failingOperation);
|
||||
} catch (e) {
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
|
||||
expect(breaker.isOpen()).toBeTrue();
|
||||
|
||||
// Next attempt should fail immediately
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await breaker.execute(failingOperation);
|
||||
} catch (e: any) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error?.message).toEqual('Circuit breaker is open');
|
||||
expect(attempt).toEqual(2); // Operation not called when circuit is open
|
||||
|
||||
// Wait for reset timeout
|
||||
await delay(150);
|
||||
|
||||
// Circuit should be half-open now, allowing one attempt
|
||||
const successOperation = async () => 'success';
|
||||
const result = await breaker.execute(successOperation);
|
||||
|
||||
expect(result).toEqual('success');
|
||||
expect(breaker.getState()).toEqual('closed');
|
||||
});
|
||||
|
||||
tap.start();
|
206
test/core/utils/test.binary-heap.ts
Normal file
206
test/core/utils/test.binary-heap.ts
Normal file
@ -0,0 +1,206 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { BinaryHeap } from '../../../ts/core/utils/binary-heap.js';
|
||||
|
||||
interface TestItem {
|
||||
id: string;
|
||||
priority: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
tap.test('should create empty heap', async () => {
|
||||
const heap = new BinaryHeap<number>((a, b) => a - b);
|
||||
|
||||
expect(heap.size).toEqual(0);
|
||||
expect(heap.isEmpty()).toBeTrue();
|
||||
expect(heap.peek()).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should insert and extract in correct order', async () => {
|
||||
const heap = new BinaryHeap<number>((a, b) => a - b);
|
||||
|
||||
heap.insert(5);
|
||||
heap.insert(3);
|
||||
heap.insert(7);
|
||||
heap.insert(1);
|
||||
heap.insert(9);
|
||||
heap.insert(4);
|
||||
|
||||
expect(heap.size).toEqual(6);
|
||||
|
||||
// Extract in ascending order
|
||||
expect(heap.extract()).toEqual(1);
|
||||
expect(heap.extract()).toEqual(3);
|
||||
expect(heap.extract()).toEqual(4);
|
||||
expect(heap.extract()).toEqual(5);
|
||||
expect(heap.extract()).toEqual(7);
|
||||
expect(heap.extract()).toEqual(9);
|
||||
expect(heap.extract()).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should work with custom objects and comparator', async () => {
|
||||
const heap = new BinaryHeap<TestItem>(
|
||||
(a, b) => a.priority - b.priority,
|
||||
(item) => item.id
|
||||
);
|
||||
|
||||
heap.insert({ id: 'a', priority: 5, value: 'five' });
|
||||
heap.insert({ id: 'b', priority: 2, value: 'two' });
|
||||
heap.insert({ id: 'c', priority: 8, value: 'eight' });
|
||||
heap.insert({ id: 'd', priority: 1, value: 'one' });
|
||||
|
||||
const first = heap.extract();
|
||||
expect(first?.priority).toEqual(1);
|
||||
expect(first?.value).toEqual('one');
|
||||
|
||||
const second = heap.extract();
|
||||
expect(second?.priority).toEqual(2);
|
||||
expect(second?.value).toEqual('two');
|
||||
});
|
||||
|
||||
tap.test('should support reverse order (max heap)', async () => {
|
||||
const heap = new BinaryHeap<number>((a, b) => b - a);
|
||||
|
||||
heap.insert(5);
|
||||
heap.insert(3);
|
||||
heap.insert(7);
|
||||
heap.insert(1);
|
||||
heap.insert(9);
|
||||
|
||||
// Extract in descending order
|
||||
expect(heap.extract()).toEqual(9);
|
||||
expect(heap.extract()).toEqual(7);
|
||||
expect(heap.extract()).toEqual(5);
|
||||
});
|
||||
|
||||
tap.test('should extract by predicate', async () => {
|
||||
const heap = new BinaryHeap<TestItem>((a, b) => a.priority - b.priority);
|
||||
|
||||
heap.insert({ id: 'a', priority: 5, value: 'five' });
|
||||
heap.insert({ id: 'b', priority: 2, value: 'two' });
|
||||
heap.insert({ id: 'c', priority: 8, value: 'eight' });
|
||||
|
||||
const extracted = heap.extractIf(item => item.id === 'b');
|
||||
expect(extracted?.id).toEqual('b');
|
||||
expect(heap.size).toEqual(2);
|
||||
|
||||
// Should not find it again
|
||||
const notFound = heap.extractIf(item => item.id === 'b');
|
||||
expect(notFound).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should extract by key', async () => {
|
||||
const heap = new BinaryHeap<TestItem>(
|
||||
(a, b) => a.priority - b.priority,
|
||||
(item) => item.id
|
||||
);
|
||||
|
||||
heap.insert({ id: 'a', priority: 5, value: 'five' });
|
||||
heap.insert({ id: 'b', priority: 2, value: 'two' });
|
||||
heap.insert({ id: 'c', priority: 8, value: 'eight' });
|
||||
|
||||
expect(heap.hasKey('b')).toBeTrue();
|
||||
|
||||
const extracted = heap.extractByKey('b');
|
||||
expect(extracted?.id).toEqual('b');
|
||||
expect(heap.size).toEqual(2);
|
||||
expect(heap.hasKey('b')).toBeFalse();
|
||||
|
||||
// Should not find it again
|
||||
const notFound = heap.extractByKey('b');
|
||||
expect(notFound).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should throw when using key operations without extractKey', async () => {
|
||||
const heap = new BinaryHeap<TestItem>((a, b) => a.priority - b.priority);
|
||||
|
||||
heap.insert({ id: 'a', priority: 5, value: 'five' });
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
heap.extractByKey('a');
|
||||
} catch (e: any) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toContain('extractKey function must be provided');
|
||||
});
|
||||
|
||||
tap.test('should handle duplicates correctly', async () => {
|
||||
const heap = new BinaryHeap<number>((a, b) => a - b);
|
||||
|
||||
heap.insert(5);
|
||||
heap.insert(5);
|
||||
heap.insert(5);
|
||||
heap.insert(3);
|
||||
heap.insert(7);
|
||||
|
||||
expect(heap.size).toEqual(5);
|
||||
expect(heap.extract()).toEqual(3);
|
||||
expect(heap.extract()).toEqual(5);
|
||||
expect(heap.extract()).toEqual(5);
|
||||
expect(heap.extract()).toEqual(5);
|
||||
expect(heap.extract()).toEqual(7);
|
||||
});
|
||||
|
||||
tap.test('should convert to array without modifying heap', async () => {
|
||||
const heap = new BinaryHeap<number>((a, b) => a - b);
|
||||
|
||||
heap.insert(5);
|
||||
heap.insert(3);
|
||||
heap.insert(7);
|
||||
|
||||
const array = heap.toArray();
|
||||
expect(array).toContain(3);
|
||||
expect(array).toContain(5);
|
||||
expect(array).toContain(7);
|
||||
expect(array.length).toEqual(3);
|
||||
|
||||
// Heap should still be intact
|
||||
expect(heap.size).toEqual(3);
|
||||
expect(heap.extract()).toEqual(3);
|
||||
});
|
||||
|
||||
tap.test('should clear the heap', async () => {
|
||||
const heap = new BinaryHeap<TestItem>(
|
||||
(a, b) => a.priority - b.priority,
|
||||
(item) => item.id
|
||||
);
|
||||
|
||||
heap.insert({ id: 'a', priority: 5, value: 'five' });
|
||||
heap.insert({ id: 'b', priority: 2, value: 'two' });
|
||||
|
||||
expect(heap.size).toEqual(2);
|
||||
expect(heap.hasKey('a')).toBeTrue();
|
||||
|
||||
heap.clear();
|
||||
|
||||
expect(heap.size).toEqual(0);
|
||||
expect(heap.isEmpty()).toBeTrue();
|
||||
expect(heap.hasKey('a')).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should handle complex extraction patterns', async () => {
|
||||
const heap = new BinaryHeap<number>((a, b) => a - b);
|
||||
|
||||
// Insert numbers 1-10 in random order
|
||||
[8, 3, 5, 9, 1, 7, 4, 10, 2, 6].forEach(n => heap.insert(n));
|
||||
|
||||
// Extract some in order
|
||||
expect(heap.extract()).toEqual(1);
|
||||
expect(heap.extract()).toEqual(2);
|
||||
|
||||
// Insert more
|
||||
heap.insert(0);
|
||||
heap.insert(1.5);
|
||||
|
||||
// Continue extracting
|
||||
expect(heap.extract()).toEqual(0);
|
||||
expect(heap.extract()).toEqual(1.5);
|
||||
expect(heap.extract()).toEqual(3);
|
||||
|
||||
// Verify remaining size (10 - 2 extracted + 2 inserted - 3 extracted = 7)
|
||||
expect(heap.size).toEqual(7);
|
||||
});
|
||||
|
||||
tap.start();
|
@ -1,207 +0,0 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
EventSystem,
|
||||
ProxyEvents,
|
||||
ComponentType
|
||||
} from '../../../ts/core/utils/event-system.js';
|
||||
|
||||
// Setup function for creating a new event system
|
||||
function setupEventSystem(): { eventSystem: EventSystem, receivedEvents: any[] } {
|
||||
const eventSystem = new EventSystem(ComponentType.SMART_PROXY, 'test-id');
|
||||
const receivedEvents: any[] = [];
|
||||
return { eventSystem, receivedEvents };
|
||||
}
|
||||
|
||||
tap.test('Event System - certificate events with correct structure', async () => {
|
||||
const { eventSystem, receivedEvents } = setupEventSystem();
|
||||
|
||||
// Set up listeners
|
||||
eventSystem.on(ProxyEvents.CERTIFICATE_ISSUED, (data) => {
|
||||
receivedEvents.push({
|
||||
type: 'issued',
|
||||
data
|
||||
});
|
||||
});
|
||||
|
||||
eventSystem.on(ProxyEvents.CERTIFICATE_RENEWED, (data) => {
|
||||
receivedEvents.push({
|
||||
type: 'renewed',
|
||||
data
|
||||
});
|
||||
});
|
||||
|
||||
// Emit events
|
||||
eventSystem.emitCertificateIssued({
|
||||
domain: 'example.com',
|
||||
certificate: 'cert-content',
|
||||
privateKey: 'key-content',
|
||||
expiryDate: new Date('2025-01-01')
|
||||
});
|
||||
|
||||
eventSystem.emitCertificateRenewed({
|
||||
domain: 'example.com',
|
||||
certificate: 'new-cert-content',
|
||||
privateKey: 'new-key-content',
|
||||
expiryDate: new Date('2026-01-01'),
|
||||
isRenewal: true
|
||||
});
|
||||
|
||||
// Verify events
|
||||
expect(receivedEvents.length).toEqual(2);
|
||||
|
||||
// Check issuance event
|
||||
expect(receivedEvents[0].type).toEqual('issued');
|
||||
expect(receivedEvents[0].data.domain).toEqual('example.com');
|
||||
expect(receivedEvents[0].data.certificate).toEqual('cert-content');
|
||||
expect(receivedEvents[0].data.componentType).toEqual(ComponentType.SMART_PROXY);
|
||||
expect(receivedEvents[0].data.componentId).toEqual('test-id');
|
||||
expect(typeof receivedEvents[0].data.timestamp).toEqual('number');
|
||||
|
||||
// Check renewal event
|
||||
expect(receivedEvents[1].type).toEqual('renewed');
|
||||
expect(receivedEvents[1].data.domain).toEqual('example.com');
|
||||
expect(receivedEvents[1].data.isRenewal).toEqual(true);
|
||||
expect(receivedEvents[1].data.expiryDate).toEqual(new Date('2026-01-01'));
|
||||
});
|
||||
|
||||
tap.test('Event System - component lifecycle events', async () => {
|
||||
const { eventSystem, receivedEvents } = setupEventSystem();
|
||||
|
||||
// Set up listeners
|
||||
eventSystem.on(ProxyEvents.COMPONENT_STARTED, (data) => {
|
||||
receivedEvents.push({
|
||||
type: 'started',
|
||||
data
|
||||
});
|
||||
});
|
||||
|
||||
eventSystem.on(ProxyEvents.COMPONENT_STOPPED, (data) => {
|
||||
receivedEvents.push({
|
||||
type: 'stopped',
|
||||
data
|
||||
});
|
||||
});
|
||||
|
||||
// Emit events
|
||||
eventSystem.emitComponentStarted('TestComponent', '1.0.0');
|
||||
eventSystem.emitComponentStopped('TestComponent');
|
||||
|
||||
// Verify events
|
||||
expect(receivedEvents.length).toEqual(2);
|
||||
|
||||
// Check started event
|
||||
expect(receivedEvents[0].type).toEqual('started');
|
||||
expect(receivedEvents[0].data.name).toEqual('TestComponent');
|
||||
expect(receivedEvents[0].data.version).toEqual('1.0.0');
|
||||
|
||||
// Check stopped event
|
||||
expect(receivedEvents[1].type).toEqual('stopped');
|
||||
expect(receivedEvents[1].data.name).toEqual('TestComponent');
|
||||
});
|
||||
|
||||
tap.test('Event System - connection events', async () => {
|
||||
const { eventSystem, receivedEvents } = setupEventSystem();
|
||||
|
||||
// Set up listeners
|
||||
eventSystem.on(ProxyEvents.CONNECTION_ESTABLISHED, (data) => {
|
||||
receivedEvents.push({
|
||||
type: 'established',
|
||||
data
|
||||
});
|
||||
});
|
||||
|
||||
eventSystem.on(ProxyEvents.CONNECTION_CLOSED, (data) => {
|
||||
receivedEvents.push({
|
||||
type: 'closed',
|
||||
data
|
||||
});
|
||||
});
|
||||
|
||||
// Emit events
|
||||
eventSystem.emitConnectionEstablished({
|
||||
connectionId: 'conn-123',
|
||||
clientIp: '192.168.1.1',
|
||||
port: 443,
|
||||
isTls: true,
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
eventSystem.emitConnectionClosed({
|
||||
connectionId: 'conn-123',
|
||||
clientIp: '192.168.1.1',
|
||||
port: 443
|
||||
});
|
||||
|
||||
// Verify events
|
||||
expect(receivedEvents.length).toEqual(2);
|
||||
|
||||
// Check established event
|
||||
expect(receivedEvents[0].type).toEqual('established');
|
||||
expect(receivedEvents[0].data.connectionId).toEqual('conn-123');
|
||||
expect(receivedEvents[0].data.clientIp).toEqual('192.168.1.1');
|
||||
expect(receivedEvents[0].data.port).toEqual(443);
|
||||
expect(receivedEvents[0].data.isTls).toEqual(true);
|
||||
|
||||
// Check closed event
|
||||
expect(receivedEvents[1].type).toEqual('closed');
|
||||
expect(receivedEvents[1].data.connectionId).toEqual('conn-123');
|
||||
});
|
||||
|
||||
tap.test('Event System - once and off subscription methods', async () => {
|
||||
const { eventSystem, receivedEvents } = setupEventSystem();
|
||||
|
||||
// Set up a listener that should fire only once
|
||||
eventSystem.once(ProxyEvents.CONNECTION_ESTABLISHED, (data) => {
|
||||
receivedEvents.push({
|
||||
type: 'once',
|
||||
data
|
||||
});
|
||||
});
|
||||
|
||||
// Set up a persistent listener
|
||||
const persistentHandler = (data: any) => {
|
||||
receivedEvents.push({
|
||||
type: 'persistent',
|
||||
data
|
||||
});
|
||||
};
|
||||
|
||||
eventSystem.on(ProxyEvents.CONNECTION_ESTABLISHED, persistentHandler);
|
||||
|
||||
// First event should trigger both listeners
|
||||
eventSystem.emitConnectionEstablished({
|
||||
connectionId: 'conn-1',
|
||||
clientIp: '192.168.1.1',
|
||||
port: 443
|
||||
});
|
||||
|
||||
// Second event should only trigger the persistent listener
|
||||
eventSystem.emitConnectionEstablished({
|
||||
connectionId: 'conn-2',
|
||||
clientIp: '192.168.1.1',
|
||||
port: 443
|
||||
});
|
||||
|
||||
// Unsubscribe the persistent listener
|
||||
eventSystem.off(ProxyEvents.CONNECTION_ESTABLISHED, persistentHandler);
|
||||
|
||||
// Third event should not trigger any listeners
|
||||
eventSystem.emitConnectionEstablished({
|
||||
connectionId: 'conn-3',
|
||||
clientIp: '192.168.1.1',
|
||||
port: 443
|
||||
});
|
||||
|
||||
// Verify events
|
||||
expect(receivedEvents.length).toEqual(3);
|
||||
expect(receivedEvents[0].type).toEqual('once');
|
||||
expect(receivedEvents[0].data.connectionId).toEqual('conn-1');
|
||||
|
||||
expect(receivedEvents[1].type).toEqual('persistent');
|
||||
expect(receivedEvents[1].data.connectionId).toEqual('conn-1');
|
||||
|
||||
expect(receivedEvents[2].type).toEqual('persistent');
|
||||
expect(receivedEvents[2].data.connectionId).toEqual('conn-2');
|
||||
});
|
||||
|
||||
export default tap.start();
|
185
test/core/utils/test.fs-utils.ts
Normal file
185
test/core/utils/test.fs-utils.ts
Normal file
@ -0,0 +1,185 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as path from 'path';
|
||||
import { AsyncFileSystem } from '../../../ts/core/utils/fs-utils.js';
|
||||
|
||||
// Use a temporary directory for tests
|
||||
const testDir = path.join(process.cwd(), '.nogit', 'test-fs-utils');
|
||||
const testFile = path.join(testDir, 'test.txt');
|
||||
const testJsonFile = path.join(testDir, 'test.json');
|
||||
|
||||
tap.test('should create and check directory existence', async () => {
|
||||
// Ensure directory
|
||||
await AsyncFileSystem.ensureDir(testDir);
|
||||
|
||||
// Check it exists
|
||||
const exists = await AsyncFileSystem.exists(testDir);
|
||||
expect(exists).toBeTrue();
|
||||
|
||||
// Check it's a directory
|
||||
const isDir = await AsyncFileSystem.isDirectory(testDir);
|
||||
expect(isDir).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should write and read text files', async () => {
|
||||
const testContent = 'Hello, async filesystem!';
|
||||
|
||||
// Write file
|
||||
await AsyncFileSystem.writeFile(testFile, testContent);
|
||||
|
||||
// Check file exists
|
||||
const exists = await AsyncFileSystem.exists(testFile);
|
||||
expect(exists).toBeTrue();
|
||||
|
||||
// Read file
|
||||
const content = await AsyncFileSystem.readFile(testFile);
|
||||
expect(content).toEqual(testContent);
|
||||
|
||||
// Check it's a file
|
||||
const isFile = await AsyncFileSystem.isFile(testFile);
|
||||
expect(isFile).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should write and read JSON files', async () => {
|
||||
const testData = {
|
||||
name: 'Test',
|
||||
value: 42,
|
||||
nested: {
|
||||
array: [1, 2, 3]
|
||||
}
|
||||
};
|
||||
|
||||
// Write JSON
|
||||
await AsyncFileSystem.writeJSON(testJsonFile, testData);
|
||||
|
||||
// Read JSON
|
||||
const readData = await AsyncFileSystem.readJSON(testJsonFile);
|
||||
expect(readData).toEqual(testData);
|
||||
});
|
||||
|
||||
tap.test('should copy files', async () => {
|
||||
const copyFile = path.join(testDir, 'copy.txt');
|
||||
|
||||
// Copy file
|
||||
await AsyncFileSystem.copyFile(testFile, copyFile);
|
||||
|
||||
// Check copy exists
|
||||
const exists = await AsyncFileSystem.exists(copyFile);
|
||||
expect(exists).toBeTrue();
|
||||
|
||||
// Check content matches
|
||||
const content = await AsyncFileSystem.readFile(copyFile);
|
||||
const originalContent = await AsyncFileSystem.readFile(testFile);
|
||||
expect(content).toEqual(originalContent);
|
||||
});
|
||||
|
||||
tap.test('should move files', async () => {
|
||||
const moveFile = path.join(testDir, 'moved.txt');
|
||||
const copyFile = path.join(testDir, 'copy.txt');
|
||||
|
||||
// Move file
|
||||
await AsyncFileSystem.moveFile(copyFile, moveFile);
|
||||
|
||||
// Check moved file exists
|
||||
const movedExists = await AsyncFileSystem.exists(moveFile);
|
||||
expect(movedExists).toBeTrue();
|
||||
|
||||
// Check original doesn't exist
|
||||
const originalExists = await AsyncFileSystem.exists(copyFile);
|
||||
expect(originalExists).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should list files in directory', async () => {
|
||||
const files = await AsyncFileSystem.listFiles(testDir);
|
||||
|
||||
expect(files).toContain('test.txt');
|
||||
expect(files).toContain('test.json');
|
||||
expect(files).toContain('moved.txt');
|
||||
});
|
||||
|
||||
tap.test('should list files with full paths', async () => {
|
||||
const files = await AsyncFileSystem.listFilesFullPath(testDir);
|
||||
|
||||
const fileNames = files.map(f => path.basename(f));
|
||||
expect(fileNames).toContain('test.txt');
|
||||
expect(fileNames).toContain('test.json');
|
||||
|
||||
// All paths should be absolute
|
||||
files.forEach(file => {
|
||||
expect(path.isAbsolute(file)).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('should get file stats', async () => {
|
||||
const stats = await AsyncFileSystem.getStats(testFile);
|
||||
|
||||
expect(stats).not.toBeNull();
|
||||
expect(stats?.isFile()).toBeTrue();
|
||||
expect(stats?.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('should handle non-existent files gracefully', async () => {
|
||||
const nonExistent = path.join(testDir, 'does-not-exist.txt');
|
||||
|
||||
// Check existence
|
||||
const exists = await AsyncFileSystem.exists(nonExistent);
|
||||
expect(exists).toBeFalse();
|
||||
|
||||
// Get stats should return null
|
||||
const stats = await AsyncFileSystem.getStats(nonExistent);
|
||||
expect(stats).toBeNull();
|
||||
|
||||
// Remove should not throw
|
||||
await AsyncFileSystem.remove(nonExistent);
|
||||
});
|
||||
|
||||
tap.test('should remove files', async () => {
|
||||
// Remove a file
|
||||
await AsyncFileSystem.remove(testFile);
|
||||
|
||||
// Check it's gone
|
||||
const exists = await AsyncFileSystem.exists(testFile);
|
||||
expect(exists).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should ensure file exists', async () => {
|
||||
const ensureFile = path.join(testDir, 'ensure.txt');
|
||||
|
||||
// Ensure file
|
||||
await AsyncFileSystem.ensureFile(ensureFile);
|
||||
|
||||
// Check it exists
|
||||
const exists = await AsyncFileSystem.exists(ensureFile);
|
||||
expect(exists).toBeTrue();
|
||||
|
||||
// Check it's empty
|
||||
const content = await AsyncFileSystem.readFile(ensureFile);
|
||||
expect(content).toEqual('');
|
||||
});
|
||||
|
||||
tap.test('should recursively list files', async () => {
|
||||
// Create subdirectory with file
|
||||
const subDir = path.join(testDir, 'subdir');
|
||||
const subFile = path.join(subDir, 'nested.txt');
|
||||
|
||||
await AsyncFileSystem.ensureDir(subDir);
|
||||
await AsyncFileSystem.writeFile(subFile, 'nested content');
|
||||
|
||||
// List recursively
|
||||
const files = await AsyncFileSystem.listFilesRecursive(testDir);
|
||||
|
||||
// Should include files from subdirectory
|
||||
const fileNames = files.map(f => path.relative(testDir, f));
|
||||
expect(fileNames).toContain('test.json');
|
||||
expect(fileNames).toContain(path.join('subdir', 'nested.txt'));
|
||||
});
|
||||
|
||||
tap.test('should clean up test directory', async () => {
|
||||
// Remove entire test directory
|
||||
await AsyncFileSystem.removeDir(testDir);
|
||||
|
||||
// Check it's gone
|
||||
const exists = await AsyncFileSystem.exists(testDir);
|
||||
expect(exists).toBeFalse();
|
||||
});
|
||||
|
||||
tap.start();
|
252
test/core/utils/test.lifecycle-component.ts
Normal file
252
test/core/utils/test.lifecycle-component.ts
Normal file
@ -0,0 +1,252 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { LifecycleComponent } from '../../../ts/core/utils/lifecycle-component.js';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// Test implementation of LifecycleComponent
|
||||
class TestComponent extends LifecycleComponent {
|
||||
public timerCallCount = 0;
|
||||
public intervalCallCount = 0;
|
||||
public cleanupCalled = false;
|
||||
public testEmitter = new EventEmitter();
|
||||
public listenerCallCount = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setupTimers();
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
private setupTimers() {
|
||||
// Set up a timeout
|
||||
this.setTimeout(() => {
|
||||
this.timerCallCount++;
|
||||
}, 100);
|
||||
|
||||
// Set up an interval
|
||||
this.setInterval(() => {
|
||||
this.intervalCallCount++;
|
||||
}, 50);
|
||||
}
|
||||
|
||||
private setupListeners() {
|
||||
this.addEventListener(this.testEmitter, 'test-event', () => {
|
||||
this.listenerCallCount++;
|
||||
});
|
||||
}
|
||||
|
||||
protected async onCleanup(): Promise<void> {
|
||||
this.cleanupCalled = true;
|
||||
}
|
||||
|
||||
// Expose protected methods for testing
|
||||
public testSetTimeout(handler: Function, timeout: number): NodeJS.Timeout {
|
||||
return this.setTimeout(handler, timeout);
|
||||
}
|
||||
|
||||
public testSetInterval(handler: Function, interval: number): NodeJS.Timeout {
|
||||
return this.setInterval(handler, interval);
|
||||
}
|
||||
|
||||
public testClearTimeout(timer: NodeJS.Timeout): void {
|
||||
return this.clearTimeout(timer);
|
||||
}
|
||||
|
||||
public testClearInterval(timer: NodeJS.Timeout): void {
|
||||
return this.clearInterval(timer);
|
||||
}
|
||||
|
||||
public testAddEventListener(target: any, event: string, handler: Function, options?: { once?: boolean }): void {
|
||||
return this.addEventListener(target, event, handler, options);
|
||||
}
|
||||
|
||||
public testIsShuttingDown(): boolean {
|
||||
return this.isShuttingDownState();
|
||||
}
|
||||
}
|
||||
|
||||
tap.test('should manage timers properly', async () => {
|
||||
const component = new TestComponent();
|
||||
|
||||
// Wait for timers to fire
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
expect(component.timerCallCount).toEqual(1);
|
||||
expect(component.intervalCallCount).toBeGreaterThan(2);
|
||||
|
||||
await component.cleanup();
|
||||
});
|
||||
|
||||
tap.test('should manage event listeners properly', async () => {
|
||||
const component = new TestComponent();
|
||||
|
||||
// Emit events
|
||||
component.testEmitter.emit('test-event');
|
||||
component.testEmitter.emit('test-event');
|
||||
|
||||
expect(component.listenerCallCount).toEqual(2);
|
||||
|
||||
// Cleanup and verify listeners are removed
|
||||
await component.cleanup();
|
||||
|
||||
component.testEmitter.emit('test-event');
|
||||
expect(component.listenerCallCount).toEqual(2); // Should not increase
|
||||
});
|
||||
|
||||
tap.test('should prevent timer execution after cleanup', async () => {
|
||||
const component = new TestComponent();
|
||||
|
||||
let laterCallCount = 0;
|
||||
component.testSetTimeout(() => {
|
||||
laterCallCount++;
|
||||
}, 100);
|
||||
|
||||
// Cleanup immediately
|
||||
await component.cleanup();
|
||||
|
||||
// Wait for timer that would have fired
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
expect(laterCallCount).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('should handle child components', async () => {
|
||||
class ParentComponent extends LifecycleComponent {
|
||||
public child: TestComponent;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.child = new TestComponent();
|
||||
this.registerChildComponent(this.child);
|
||||
}
|
||||
}
|
||||
|
||||
const parent = new ParentComponent();
|
||||
|
||||
// Wait for child timers
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(parent.child.timerCallCount).toEqual(1);
|
||||
|
||||
// Cleanup parent should cleanup child
|
||||
await parent.cleanup();
|
||||
|
||||
expect(parent.child.cleanupCalled).toBeTrue();
|
||||
expect(parent.child.testIsShuttingDown()).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should handle multiple cleanup calls gracefully', async () => {
|
||||
const component = new TestComponent();
|
||||
|
||||
// Call cleanup multiple times
|
||||
const promises = [
|
||||
component.cleanup(),
|
||||
component.cleanup(),
|
||||
component.cleanup()
|
||||
];
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Should only clean up once
|
||||
expect(component.cleanupCalled).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should clear specific timers', async () => {
|
||||
const component = new TestComponent();
|
||||
|
||||
let callCount = 0;
|
||||
const timer = component.testSetTimeout(() => {
|
||||
callCount++;
|
||||
}, 100);
|
||||
|
||||
// Clear the timer
|
||||
component.testClearTimeout(timer);
|
||||
|
||||
// Wait and verify it didn't fire
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
expect(callCount).toEqual(0);
|
||||
|
||||
await component.cleanup();
|
||||
});
|
||||
|
||||
tap.test('should clear specific intervals', async () => {
|
||||
const component = new TestComponent();
|
||||
|
||||
let callCount = 0;
|
||||
const interval = component.testSetInterval(() => {
|
||||
callCount++;
|
||||
}, 50);
|
||||
|
||||
// Let it run a bit
|
||||
await new Promise(resolve => setTimeout(resolve, 120));
|
||||
|
||||
const countBeforeClear = callCount;
|
||||
expect(countBeforeClear).toBeGreaterThan(1);
|
||||
|
||||
// Clear the interval
|
||||
component.testClearInterval(interval);
|
||||
|
||||
// Wait and verify it stopped
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(callCount).toEqual(countBeforeClear);
|
||||
|
||||
await component.cleanup();
|
||||
});
|
||||
|
||||
tap.test('should handle once event listeners', async () => {
|
||||
const component = new TestComponent();
|
||||
const emitter = new EventEmitter();
|
||||
|
||||
let callCount = 0;
|
||||
const handler = () => {
|
||||
callCount++;
|
||||
};
|
||||
|
||||
component.testAddEventListener(emitter, 'once-event', handler, { once: true });
|
||||
|
||||
// Check listener count before emit
|
||||
const beforeCount = emitter.listenerCount('once-event');
|
||||
expect(beforeCount).toEqual(1);
|
||||
|
||||
// Emit once - the listener should fire and auto-remove
|
||||
emitter.emit('once-event');
|
||||
expect(callCount).toEqual(1);
|
||||
|
||||
// Check listener was auto-removed
|
||||
const afterCount = emitter.listenerCount('once-event');
|
||||
expect(afterCount).toEqual(0);
|
||||
|
||||
// Emit again - should not increase count
|
||||
emitter.emit('once-event');
|
||||
expect(callCount).toEqual(1);
|
||||
|
||||
await component.cleanup();
|
||||
});
|
||||
|
||||
tap.test('should not create timers when shutting down', async () => {
|
||||
const component = new TestComponent();
|
||||
|
||||
// Start cleanup
|
||||
const cleanupPromise = component.cleanup();
|
||||
|
||||
// Try to create timers during shutdown
|
||||
let timerFired = false;
|
||||
let intervalFired = false;
|
||||
|
||||
component.testSetTimeout(() => {
|
||||
timerFired = true;
|
||||
}, 10);
|
||||
|
||||
component.testSetInterval(() => {
|
||||
intervalFired = true;
|
||||
}, 10);
|
||||
|
||||
await cleanupPromise;
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
expect(timerFired).toBeFalse();
|
||||
expect(intervalFired).toBeFalse();
|
||||
});
|
||||
|
||||
export default tap.start();
|
@ -13,8 +13,11 @@ tap.test('AcmeStateManager should track challenge routes correctly', async (tool
|
||||
path: '/.well-known/acme-challenge/*'
|
||||
},
|
||||
action: {
|
||||
type: 'static',
|
||||
handler: async () => ({ status: 200, body: 'challenge' })
|
||||
type: 'socket-handler',
|
||||
socketHandler: async (socket, context) => {
|
||||
// Mock handler that would write the challenge response
|
||||
socket.end('challenge response');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -46,7 +49,7 @@ tap.test('AcmeStateManager should track port allocations', async (tools) => {
|
||||
path: '/.well-known/acme-challenge/*'
|
||||
},
|
||||
action: {
|
||||
type: 'static'
|
||||
type: 'socket-handler'
|
||||
}
|
||||
};
|
||||
|
||||
@ -58,7 +61,7 @@ tap.test('AcmeStateManager should track port allocations', async (tools) => {
|
||||
path: '/.well-known/acme-challenge/*'
|
||||
},
|
||||
action: {
|
||||
type: 'static'
|
||||
type: 'socket-handler'
|
||||
}
|
||||
};
|
||||
|
||||
@ -97,7 +100,7 @@ tap.test('AcmeStateManager should select primary route by priority', async (tool
|
||||
ports: 80
|
||||
},
|
||||
action: {
|
||||
type: 'static'
|
||||
type: 'socket-handler'
|
||||
}
|
||||
};
|
||||
|
||||
@ -108,7 +111,7 @@ tap.test('AcmeStateManager should select primary route by priority', async (tool
|
||||
ports: 80
|
||||
},
|
||||
action: {
|
||||
type: 'static'
|
||||
type: 'socket-handler'
|
||||
}
|
||||
};
|
||||
|
||||
@ -119,7 +122,7 @@ tap.test('AcmeStateManager should select primary route by priority', async (tool
|
||||
ports: 80
|
||||
},
|
||||
action: {
|
||||
type: 'static'
|
||||
type: 'socket-handler'
|
||||
}
|
||||
};
|
||||
|
||||
@ -149,7 +152,7 @@ tap.test('AcmeStateManager should handle clear operation', async (tools) => {
|
||||
ports: [80, 443]
|
||||
},
|
||||
action: {
|
||||
type: 'static'
|
||||
type: 'socket-handler'
|
||||
}
|
||||
};
|
||||
|
||||
@ -159,7 +162,7 @@ tap.test('AcmeStateManager should handle clear operation', async (tools) => {
|
||||
ports: 8080
|
||||
},
|
||||
action: {
|
||||
type: 'static'
|
||||
type: 'socket-handler'
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -57,7 +57,14 @@ tap.test('should detect and forward non-TLS connections on useHttpProxy ports',
|
||||
getAllRoutes: () => mockSettings.routes,
|
||||
getRoutesForPort: (port: number) => mockSettings.routes.filter(r => {
|
||||
const ports = Array.isArray(r.match.ports) ? r.match.ports : [r.match.ports];
|
||||
return ports.includes(port);
|
||||
return ports.some(p => {
|
||||
if (typeof p === 'number') {
|
||||
return p === port;
|
||||
} else if (p && typeof p === 'object' && 'from' in p && 'to' in p) {
|
||||
return port >= p.from && port <= p.to;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
@ -101,6 +108,8 @@ tap.test('should detect and forward non-TLS connections on useHttpProxy ports',
|
||||
resume: () => {},
|
||||
removeListener: function() { return this; },
|
||||
emit: () => {},
|
||||
setNoDelay: () => {},
|
||||
setKeepAlive: () => {},
|
||||
_dataHandler: null as any
|
||||
} as any;
|
||||
|
||||
@ -176,7 +185,14 @@ tap.test('should handle TLS connections normally', async (tapTest) => {
|
||||
getAllRoutes: () => mockSettings.routes,
|
||||
getRoutesForPort: (port: number) => mockSettings.routes.filter(r => {
|
||||
const ports = Array.isArray(r.match.ports) ? r.match.ports : [r.match.ports];
|
||||
return ports.includes(port);
|
||||
return ports.some(p => {
|
||||
if (typeof p === 'number') {
|
||||
return p === port;
|
||||
} else if (p && typeof p === 'object' && 'from' in p && 'to' in p) {
|
||||
return port >= p.from && port <= p.to;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
@ -211,6 +227,8 @@ tap.test('should handle TLS connections normally', async (tapTest) => {
|
||||
resume: () => {},
|
||||
removeListener: function() { return this; },
|
||||
emit: () => {},
|
||||
setNoDelay: () => {},
|
||||
setKeepAlive: () => {},
|
||||
_dataHandler: null as any
|
||||
} as any;
|
||||
|
||||
|
@ -26,7 +26,6 @@ tap.test('should detect and forward non-TLS connections on HttpProxy ports', asy
|
||||
proxy.settings.enableDetailedLogging = true;
|
||||
|
||||
// Override the HttpProxy initialization to avoid actual HttpProxy setup
|
||||
const mockHttpProxy = { available: true };
|
||||
proxy['httpProxyBridge'].initialize = async () => {
|
||||
console.log('Mock: HttpProxyBridge initialized');
|
||||
};
|
||||
@ -49,11 +48,7 @@ tap.test('should detect and forward non-TLS connections on HttpProxy ports', asy
|
||||
args[1].end(); // socket.end()
|
||||
};
|
||||
|
||||
const originalGetHttpProxy = proxy['httpProxyBridge'].getHttpProxy;
|
||||
proxy['httpProxyBridge'].getHttpProxy = () => {
|
||||
console.log('Mock: getHttpProxy called, returning:', mockHttpProxy);
|
||||
return mockHttpProxy;
|
||||
};
|
||||
// No need to mock getHttpProxy - the bridge already handles HttpProxy availability
|
||||
|
||||
// Make a connection to port 8080
|
||||
const client = new net.Socket();
|
||||
|
@ -591,13 +591,6 @@ tap.test('cleanup', async () => {
|
||||
|
||||
// Exit handler removed to prevent interference with test cleanup
|
||||
|
||||
// Add a post-hook to force exit after tap completion
|
||||
tap.test('teardown', async () => {
|
||||
// Force exit after all tests complete
|
||||
setTimeout(() => {
|
||||
console.log('[TEST] Force exit after tap completion');
|
||||
process.exit(0);
|
||||
}, 1000);
|
||||
});
|
||||
// Teardown test removed - let tap handle proper cleanup
|
||||
|
||||
export default tap.start();
|
192
test/test.long-lived-connections.ts
Normal file
192
test/test.long-lived-connections.ts
Normal file
@ -0,0 +1,192 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import * as tls from 'tls';
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
|
||||
let testProxy: SmartProxy;
|
||||
let targetServer: net.Server;
|
||||
|
||||
// Create a simple echo server as target
|
||||
tap.test('setup test environment', async () => {
|
||||
// Create target server that echoes data back
|
||||
targetServer = net.createServer((socket) => {
|
||||
console.log('Target server: client connected');
|
||||
|
||||
// Echo data back
|
||||
socket.on('data', (data) => {
|
||||
console.log(`Target server received: ${data.toString().trim()}`);
|
||||
socket.write(data);
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
console.log('Target server: client disconnected');
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
targetServer.listen(9876, () => {
|
||||
console.log('Target server listening on port 9876');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Create proxy with simple TCP forwarding (no TLS)
|
||||
testProxy = new SmartProxy({
|
||||
routes: [{
|
||||
name: 'tcp-forward-test',
|
||||
match: {
|
||||
ports: 8888 // Plain TCP port
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 9876
|
||||
}
|
||||
// No TLS configuration - just plain TCP forwarding
|
||||
}
|
||||
}],
|
||||
defaults: {
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 9876
|
||||
}
|
||||
},
|
||||
enableDetailedLogging: true,
|
||||
keepAliveTreatment: 'extended', // Allow long-lived connections
|
||||
inactivityTimeout: 3600000, // 1 hour
|
||||
socketTimeout: 3600000, // 1 hour
|
||||
keepAlive: true,
|
||||
keepAliveInitialDelay: 1000
|
||||
});
|
||||
|
||||
await testProxy.start();
|
||||
});
|
||||
|
||||
tap.test('should keep WebSocket-like connection open for extended period', async (tools) => {
|
||||
tools.timeout(65000); // 65 second test timeout
|
||||
|
||||
const client = new net.Socket();
|
||||
let messagesReceived = 0;
|
||||
let connectionClosed = false;
|
||||
|
||||
// Connect to proxy
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.connect(8888, 'localhost', () => {
|
||||
console.log('Client connected to proxy');
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on('error', reject);
|
||||
});
|
||||
|
||||
// Set up data handler
|
||||
client.on('data', (data) => {
|
||||
console.log(`Client received: ${data.toString().trim()}`);
|
||||
messagesReceived++;
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
console.log('Client connection closed');
|
||||
connectionClosed = true;
|
||||
});
|
||||
|
||||
// Send initial handshake-like data
|
||||
client.write('HELLO\n');
|
||||
|
||||
// Wait for response
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(messagesReceived).toEqual(1);
|
||||
|
||||
// Simulate WebSocket-like keep-alive pattern
|
||||
// Send periodic messages over 60 seconds
|
||||
const startTime = Date.now();
|
||||
const pingInterval = setInterval(() => {
|
||||
if (!connectionClosed && Date.now() - startTime < 60000) {
|
||||
console.log('Sending ping...');
|
||||
client.write('PING\n');
|
||||
} else {
|
||||
clearInterval(pingInterval);
|
||||
}
|
||||
}, 10000); // Every 10 seconds
|
||||
|
||||
// Wait for 61 seconds
|
||||
await new Promise(resolve => setTimeout(resolve, 61000));
|
||||
|
||||
// Clean up interval
|
||||
clearInterval(pingInterval);
|
||||
|
||||
// Connection should still be open
|
||||
expect(connectionClosed).toEqual(false);
|
||||
|
||||
// Should have received responses (1 hello + 6 pings)
|
||||
expect(messagesReceived).toBeGreaterThan(5);
|
||||
|
||||
// Close connection gracefully
|
||||
client.end();
|
||||
|
||||
// Wait for close
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(connectionClosed).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should support half-open connections', async () => {
|
||||
const client = new net.Socket();
|
||||
const serverSocket = await new Promise<net.Socket>((resolve) => {
|
||||
targetServer.once('connection', resolve);
|
||||
client.connect(8888, 'localhost');
|
||||
});
|
||||
|
||||
let clientClosed = false;
|
||||
let serverClosed = false;
|
||||
let serverReceivedData = false;
|
||||
|
||||
client.on('close', () => {
|
||||
clientClosed = true;
|
||||
});
|
||||
|
||||
serverSocket.on('close', () => {
|
||||
serverClosed = true;
|
||||
});
|
||||
|
||||
serverSocket.on('data', () => {
|
||||
serverReceivedData = true;
|
||||
});
|
||||
|
||||
// Client sends data then closes write side
|
||||
client.write('HALF-OPEN TEST\n');
|
||||
client.end(); // Close write side only
|
||||
|
||||
// Wait a bit
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Server should still be able to send data
|
||||
expect(serverClosed).toEqual(false);
|
||||
serverSocket.write('RESPONSE\n');
|
||||
|
||||
// Wait for data
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Now close server side
|
||||
serverSocket.end();
|
||||
|
||||
// Wait for full close
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
expect(clientClosed).toEqual(true);
|
||||
expect(serverClosed).toEqual(true);
|
||||
expect(serverReceivedData).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
await testProxy.stop();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
targetServer.close(() => {
|
||||
console.log('Target server closed');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export default tap.start();
|
@ -1,34 +0,0 @@
|
||||
// Port80Handler removed - use SmartCertManager instead
|
||||
import { Port80HandlerEvents } from './types.js';
|
||||
import type { ICertificateData, ICertificateFailure, ICertificateExpiring } from './types.js';
|
||||
|
||||
/**
|
||||
* Subscribers callback definitions for Port80Handler events
|
||||
*/
|
||||
export interface Port80HandlerSubscribers {
|
||||
onCertificateIssued?: (data: ICertificateData) => void;
|
||||
onCertificateRenewed?: (data: ICertificateData) => void;
|
||||
onCertificateFailed?: (data: ICertificateFailure) => void;
|
||||
onCertificateExpiring?: (data: ICertificateExpiring) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to Port80Handler events based on provided callbacks
|
||||
*/
|
||||
export function subscribeToPort80Handler(
|
||||
handler: any,
|
||||
subscribers: Port80HandlerSubscribers
|
||||
): void {
|
||||
if (subscribers.onCertificateIssued) {
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, subscribers.onCertificateIssued);
|
||||
}
|
||||
if (subscribers.onCertificateRenewed) {
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, subscribers.onCertificateRenewed);
|
||||
}
|
||||
if (subscribers.onCertificateFailed) {
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, subscribers.onCertificateFailed);
|
||||
}
|
||||
if (subscribers.onCertificateExpiring) {
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, subscribers.onCertificateExpiring);
|
||||
}
|
||||
}
|
@ -1,91 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* Shared types for certificate management and domain options
|
||||
*/
|
||||
|
||||
/**
|
||||
* Domain forwarding configuration
|
||||
*/
|
||||
export interface IForwardConfig {
|
||||
ip: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain configuration options
|
||||
*/
|
||||
export interface IDomainOptions {
|
||||
domainName: string;
|
||||
sslRedirect: boolean; // if true redirects the request to port 443
|
||||
acmeMaintenance: boolean; // tries to always have a valid cert for this domain
|
||||
forward?: IForwardConfig; // forwards all http requests to that target
|
||||
acmeForward?: IForwardConfig; // forwards letsencrypt requests to this config
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate data that can be emitted via events or set from outside
|
||||
*/
|
||||
export interface ICertificateData {
|
||||
domain: string;
|
||||
certificate: string;
|
||||
privateKey: string;
|
||||
expiryDate: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Events emitted by the Port80Handler
|
||||
*/
|
||||
export enum Port80HandlerEvents {
|
||||
CERTIFICATE_ISSUED = 'certificate-issued',
|
||||
CERTIFICATE_RENEWED = 'certificate-renewed',
|
||||
CERTIFICATE_FAILED = 'certificate-failed',
|
||||
CERTIFICATE_EXPIRING = 'certificate-expiring',
|
||||
MANAGER_STARTED = 'manager-started',
|
||||
MANAGER_STOPPED = 'manager-stopped',
|
||||
REQUEST_FORWARDED = 'request-forwarded',
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate failure payload type
|
||||
*/
|
||||
export interface ICertificateFailure {
|
||||
domain: string;
|
||||
error: string;
|
||||
isRenewal: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate expiry payload type
|
||||
*/
|
||||
export interface ICertificateExpiring {
|
||||
domain: string;
|
||||
expiryDate: Date;
|
||||
daysRemaining: number;
|
||||
}
|
||||
/**
|
||||
* Forwarding configuration for specific domains in ACME setup
|
||||
*/
|
||||
export interface IDomainForwardConfig {
|
||||
domain: string;
|
||||
forwardConfig?: IForwardConfig;
|
||||
acmeForwardConfig?: IForwardConfig;
|
||||
sslRedirect?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified ACME configuration options used across proxies and handlers
|
||||
*/
|
||||
export interface IAcmeOptions {
|
||||
accountEmail?: string; // Email for Let's Encrypt account
|
||||
enabled?: boolean; // Whether ACME is enabled
|
||||
port?: number; // Port to listen on for ACME challenges (default: 80)
|
||||
useProduction?: boolean; // Use production environment (default: staging)
|
||||
httpsRedirectPort?: number; // Port to redirect HTTP requests to HTTPS (default: 443)
|
||||
renewThresholdDays?: number; // Days before expiry to renew certificates
|
||||
renewCheckIntervalHours?: number; // How often to check for renewals (in hours)
|
||||
autoRenew?: boolean; // Whether to automatically renew certificates
|
||||
certificateStore?: string; // Directory to store certificates
|
||||
skipConfiguredCerts?: boolean; // Skip domains with existing certificates
|
||||
domainForwards?: IDomainForwardConfig[]; // Domain-specific forwarding configs
|
||||
}
|
275
ts/core/utils/async-utils.ts
Normal file
275
ts/core/utils/async-utils.ts
Normal file
@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Async utility functions for SmartProxy
|
||||
* Provides non-blocking alternatives to synchronous operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Delays execution for the specified number of milliseconds
|
||||
* Non-blocking alternative to busy wait loops
|
||||
* @param ms - Number of milliseconds to delay
|
||||
* @returns Promise that resolves after the delay
|
||||
*/
|
||||
export async function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry an async operation with exponential backoff
|
||||
* @param fn - The async function to retry
|
||||
* @param options - Retry options
|
||||
* @returns The result of the function or throws the last error
|
||||
*/
|
||||
export async function retryWithBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: {
|
||||
maxAttempts?: number;
|
||||
initialDelay?: number;
|
||||
maxDelay?: number;
|
||||
factor?: number;
|
||||
onRetry?: (attempt: number, error: Error) => void;
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxAttempts = 3,
|
||||
initialDelay = 100,
|
||||
maxDelay = 10000,
|
||||
factor = 2,
|
||||
onRetry
|
||||
} = options;
|
||||
|
||||
let lastError: Error | null = null;
|
||||
let currentDelay = initialDelay;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt === maxAttempts) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (onRetry) {
|
||||
onRetry(attempt, error);
|
||||
}
|
||||
|
||||
await delay(currentDelay);
|
||||
currentDelay = Math.min(currentDelay * factor, maxDelay);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Retry failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an async operation with a timeout
|
||||
* @param fn - The async function to execute
|
||||
* @param timeoutMs - Timeout in milliseconds
|
||||
* @param timeoutError - Optional custom timeout error
|
||||
* @returns The result of the function or throws timeout error
|
||||
*/
|
||||
export async function withTimeout<T>(
|
||||
fn: () => Promise<T>,
|
||||
timeoutMs: number,
|
||||
timeoutError?: Error
|
||||
): Promise<T> {
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(timeoutError || new Error(`Operation timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
return Promise.race([fn(), timeoutPromise]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run multiple async operations in parallel with a concurrency limit
|
||||
* @param items - Array of items to process
|
||||
* @param fn - Async function to run for each item
|
||||
* @param concurrency - Maximum number of concurrent operations
|
||||
* @returns Array of results in the same order as input
|
||||
*/
|
||||
export async function parallelLimit<T, R>(
|
||||
items: T[],
|
||||
fn: (item: T, index: number) => Promise<R>,
|
||||
concurrency: number
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array(items.length);
|
||||
const executing: Set<Promise<void>> = new Set();
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const promise = fn(items[i], i).then(result => {
|
||||
results[i] = result;
|
||||
executing.delete(promise);
|
||||
});
|
||||
|
||||
executing.add(promise);
|
||||
|
||||
if (executing.size >= concurrency) {
|
||||
await Promise.race(executing);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(executing);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce an async function
|
||||
* @param fn - The async function to debounce
|
||||
* @param delayMs - Delay in milliseconds
|
||||
* @returns Debounced function with cancel method
|
||||
*/
|
||||
export function debounceAsync<T extends (...args: any[]) => Promise<any>>(
|
||||
fn: T,
|
||||
delayMs: number
|
||||
): T & { cancel: () => void } {
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
let lastPromise: Promise<any> | null = null;
|
||||
|
||||
const debounced = ((...args: Parameters<T>) => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
lastPromise = new Promise((resolve, reject) => {
|
||||
timeoutId = setTimeout(async () => {
|
||||
timeoutId = null;
|
||||
try {
|
||||
const result = await fn(...args);
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}, delayMs);
|
||||
});
|
||||
|
||||
return lastPromise;
|
||||
}) as any;
|
||||
|
||||
debounced.cancel = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
};
|
||||
|
||||
return debounced as T & { cancel: () => void };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mutex for ensuring exclusive access to a resource
|
||||
*/
|
||||
export class AsyncMutex {
|
||||
private queue: Array<() => void> = [];
|
||||
private locked = false;
|
||||
|
||||
async acquire(): Promise<() => void> {
|
||||
if (!this.locked) {
|
||||
this.locked = true;
|
||||
return () => this.release();
|
||||
}
|
||||
|
||||
return new Promise<() => void>(resolve => {
|
||||
this.queue.push(() => {
|
||||
resolve(() => this.release());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private release(): void {
|
||||
const next = this.queue.shift();
|
||||
if (next) {
|
||||
next();
|
||||
} else {
|
||||
this.locked = false;
|
||||
}
|
||||
}
|
||||
|
||||
async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const release = await this.acquire();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit breaker for protecting against cascading failures
|
||||
*/
|
||||
export class CircuitBreaker {
|
||||
private failureCount = 0;
|
||||
private lastFailureTime = 0;
|
||||
private state: 'closed' | 'open' | 'half-open' = 'closed';
|
||||
|
||||
constructor(
|
||||
private options: {
|
||||
failureThreshold: number;
|
||||
resetTimeout: number;
|
||||
onStateChange?: (state: 'closed' | 'open' | 'half-open') => void;
|
||||
}
|
||||
) {}
|
||||
|
||||
async execute<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (this.state === 'open') {
|
||||
if (Date.now() - this.lastFailureTime > this.options.resetTimeout) {
|
||||
this.setState('half-open');
|
||||
} else {
|
||||
throw new Error('Circuit breaker is open');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
this.onSuccess();
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.onFailure();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private onSuccess(): void {
|
||||
this.failureCount = 0;
|
||||
if (this.state !== 'closed') {
|
||||
this.setState('closed');
|
||||
}
|
||||
}
|
||||
|
||||
private onFailure(): void {
|
||||
this.failureCount++;
|
||||
this.lastFailureTime = Date.now();
|
||||
|
||||
if (this.failureCount >= this.options.failureThreshold) {
|
||||
this.setState('open');
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: 'closed' | 'open' | 'half-open'): void {
|
||||
if (this.state !== state) {
|
||||
this.state = state;
|
||||
if (this.options.onStateChange) {
|
||||
this.options.onStateChange(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return this.state === 'open';
|
||||
}
|
||||
|
||||
getState(): 'closed' | 'open' | 'half-open' {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
recordSuccess(): void {
|
||||
this.onSuccess();
|
||||
}
|
||||
|
||||
recordFailure(): void {
|
||||
this.onFailure();
|
||||
}
|
||||
}
|
225
ts/core/utils/binary-heap.ts
Normal file
225
ts/core/utils/binary-heap.ts
Normal file
@ -0,0 +1,225 @@
|
||||
/**
|
||||
* A binary heap implementation for efficient priority queue operations
|
||||
* Supports O(log n) insert and extract operations
|
||||
*/
|
||||
export class BinaryHeap<T> {
|
||||
private heap: T[] = [];
|
||||
private keyMap?: Map<string, number>; // For efficient key-based lookups
|
||||
|
||||
constructor(
|
||||
private compareFn: (a: T, b: T) => number,
|
||||
private extractKey?: (item: T) => string
|
||||
) {
|
||||
if (extractKey) {
|
||||
this.keyMap = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current size of the heap
|
||||
*/
|
||||
public get size(): number {
|
||||
return this.heap.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the heap is empty
|
||||
*/
|
||||
public isEmpty(): boolean {
|
||||
return this.heap.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Peek at the top element without removing it
|
||||
*/
|
||||
public peek(): T | undefined {
|
||||
return this.heap[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new item into the heap
|
||||
* O(log n) time complexity
|
||||
*/
|
||||
public insert(item: T): void {
|
||||
const index = this.heap.length;
|
||||
this.heap.push(item);
|
||||
|
||||
if (this.keyMap && this.extractKey) {
|
||||
const key = this.extractKey(item);
|
||||
this.keyMap.set(key, index);
|
||||
}
|
||||
|
||||
this.bubbleUp(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the top element from the heap
|
||||
* O(log n) time complexity
|
||||
*/
|
||||
public extract(): T | undefined {
|
||||
if (this.heap.length === 0) return undefined;
|
||||
if (this.heap.length === 1) {
|
||||
const item = this.heap.pop()!;
|
||||
if (this.keyMap && this.extractKey) {
|
||||
this.keyMap.delete(this.extractKey(item));
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
const result = this.heap[0];
|
||||
const lastItem = this.heap.pop()!;
|
||||
this.heap[0] = lastItem;
|
||||
|
||||
if (this.keyMap && this.extractKey) {
|
||||
this.keyMap.delete(this.extractKey(result));
|
||||
this.keyMap.set(this.extractKey(lastItem), 0);
|
||||
}
|
||||
|
||||
this.bubbleDown(0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an element that matches the predicate
|
||||
* O(n) time complexity for search, O(log n) for extraction
|
||||
*/
|
||||
public extractIf(predicate: (item: T) => boolean): T | undefined {
|
||||
const index = this.heap.findIndex(predicate);
|
||||
if (index === -1) return undefined;
|
||||
|
||||
return this.extractAt(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an element by its key (if extractKey was provided)
|
||||
* O(log n) time complexity
|
||||
*/
|
||||
public extractByKey(key: string): T | undefined {
|
||||
if (!this.keyMap || !this.extractKey) {
|
||||
throw new Error('extractKey function must be provided to use key-based extraction');
|
||||
}
|
||||
|
||||
const index = this.keyMap.get(key);
|
||||
if (index === undefined) return undefined;
|
||||
|
||||
return this.extractAt(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key exists in the heap
|
||||
* O(1) time complexity
|
||||
*/
|
||||
public hasKey(key: string): boolean {
|
||||
if (!this.keyMap) return false;
|
||||
return this.keyMap.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all elements as an array (does not modify heap)
|
||||
* O(n) time complexity
|
||||
*/
|
||||
public toArray(): T[] {
|
||||
return [...this.heap];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the heap
|
||||
*/
|
||||
public clear(): void {
|
||||
this.heap = [];
|
||||
if (this.keyMap) {
|
||||
this.keyMap.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract element at specific index
|
||||
*/
|
||||
private extractAt(index: number): T {
|
||||
const item = this.heap[index];
|
||||
|
||||
if (this.keyMap && this.extractKey) {
|
||||
this.keyMap.delete(this.extractKey(item));
|
||||
}
|
||||
|
||||
if (index === this.heap.length - 1) {
|
||||
this.heap.pop();
|
||||
return item;
|
||||
}
|
||||
|
||||
const lastItem = this.heap.pop()!;
|
||||
this.heap[index] = lastItem;
|
||||
|
||||
if (this.keyMap && this.extractKey) {
|
||||
this.keyMap.set(this.extractKey(lastItem), index);
|
||||
}
|
||||
|
||||
// Try bubbling up first
|
||||
const parentIndex = Math.floor((index - 1) / 2);
|
||||
if (parentIndex >= 0 && this.compareFn(this.heap[index], this.heap[parentIndex]) < 0) {
|
||||
this.bubbleUp(index);
|
||||
} else {
|
||||
this.bubbleDown(index);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bubble up element at given index to maintain heap property
|
||||
*/
|
||||
private bubbleUp(index: number): void {
|
||||
while (index > 0) {
|
||||
const parentIndex = Math.floor((index - 1) / 2);
|
||||
|
||||
if (this.compareFn(this.heap[index], this.heap[parentIndex]) >= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
this.swap(index, parentIndex);
|
||||
index = parentIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bubble down element at given index to maintain heap property
|
||||
*/
|
||||
private bubbleDown(index: number): void {
|
||||
const length = this.heap.length;
|
||||
|
||||
while (true) {
|
||||
const leftChild = 2 * index + 1;
|
||||
const rightChild = 2 * index + 2;
|
||||
let smallest = index;
|
||||
|
||||
if (leftChild < length &&
|
||||
this.compareFn(this.heap[leftChild], this.heap[smallest]) < 0) {
|
||||
smallest = leftChild;
|
||||
}
|
||||
|
||||
if (rightChild < length &&
|
||||
this.compareFn(this.heap[rightChild], this.heap[smallest]) < 0) {
|
||||
smallest = rightChild;
|
||||
}
|
||||
|
||||
if (smallest === index) break;
|
||||
|
||||
this.swap(index, smallest);
|
||||
index = smallest;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap two elements in the heap
|
||||
*/
|
||||
private swap(i: number, j: number): void {
|
||||
const temp = this.heap[i];
|
||||
this.heap[i] = this.heap[j];
|
||||
this.heap[j] = temp;
|
||||
|
||||
if (this.keyMap && this.extractKey) {
|
||||
this.keyMap.set(this.extractKey(this.heap[i]), i);
|
||||
this.keyMap.set(this.extractKey(this.heap[j]), j);
|
||||
}
|
||||
}
|
||||
}
|
425
ts/core/utils/enhanced-connection-pool.ts
Normal file
425
ts/core/utils/enhanced-connection-pool.ts
Normal file
@ -0,0 +1,425 @@
|
||||
import { LifecycleComponent } from './lifecycle-component.js';
|
||||
import { BinaryHeap } from './binary-heap.js';
|
||||
import { AsyncMutex } from './async-utils.js';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
/**
|
||||
* Interface for pooled connection
|
||||
*/
|
||||
export interface IPooledConnection<T> {
|
||||
id: string;
|
||||
connection: T;
|
||||
createdAt: number;
|
||||
lastUsedAt: number;
|
||||
useCount: number;
|
||||
inUse: boolean;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for the connection pool
|
||||
*/
|
||||
export interface IConnectionPoolOptions<T> {
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
acquireTimeout?: number;
|
||||
idleTimeout?: number;
|
||||
maxUseCount?: number;
|
||||
validateOnAcquire?: boolean;
|
||||
validateOnReturn?: boolean;
|
||||
queueTimeout?: number;
|
||||
connectionFactory: () => Promise<T>;
|
||||
connectionValidator?: (connection: T) => Promise<boolean>;
|
||||
connectionDestroyer?: (connection: T) => Promise<void>;
|
||||
onConnectionError?: (error: Error, connection?: T) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for queued acquire request
|
||||
*/
|
||||
interface IAcquireRequest<T> {
|
||||
id: string;
|
||||
priority: number;
|
||||
timestamp: number;
|
||||
resolve: (connection: IPooledConnection<T>) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutHandle?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced connection pool with priority queue, backpressure, and lifecycle management
|
||||
*/
|
||||
export class EnhancedConnectionPool<T> extends LifecycleComponent {
|
||||
private readonly options: Required<Omit<IConnectionPoolOptions<T>, 'connectionValidator' | 'connectionDestroyer' | 'onConnectionError'>> & Pick<IConnectionPoolOptions<T>, 'connectionValidator' | 'connectionDestroyer' | 'onConnectionError'>;
|
||||
private readonly availableConnections: IPooledConnection<T>[] = [];
|
||||
private readonly activeConnections: Map<string, IPooledConnection<T>> = new Map();
|
||||
private readonly waitQueue: BinaryHeap<IAcquireRequest<T>>;
|
||||
private readonly mutex = new AsyncMutex();
|
||||
private readonly eventEmitter = new EventEmitter();
|
||||
|
||||
private connectionIdCounter = 0;
|
||||
private requestIdCounter = 0;
|
||||
private isClosing = false;
|
||||
|
||||
// Metrics
|
||||
private metrics = {
|
||||
connectionsCreated: 0,
|
||||
connectionsDestroyed: 0,
|
||||
connectionsAcquired: 0,
|
||||
connectionsReleased: 0,
|
||||
acquireTimeouts: 0,
|
||||
validationFailures: 0,
|
||||
queueHighWaterMark: 0,
|
||||
};
|
||||
|
||||
constructor(options: IConnectionPoolOptions<T>) {
|
||||
super();
|
||||
|
||||
this.options = {
|
||||
minSize: 0,
|
||||
maxSize: 10,
|
||||
acquireTimeout: 30000,
|
||||
idleTimeout: 300000, // 5 minutes
|
||||
maxUseCount: Infinity,
|
||||
validateOnAcquire: true,
|
||||
validateOnReturn: false,
|
||||
queueTimeout: 60000,
|
||||
...options,
|
||||
};
|
||||
|
||||
// Initialize priority queue (higher priority = extracted first)
|
||||
this.waitQueue = new BinaryHeap<IAcquireRequest<T>>(
|
||||
(a, b) => b.priority - a.priority || a.timestamp - b.timestamp,
|
||||
(item) => item.id
|
||||
);
|
||||
|
||||
// Start maintenance cycle
|
||||
this.startMaintenance();
|
||||
|
||||
// Initialize minimum connections
|
||||
this.initializeMinConnections();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize minimum number of connections
|
||||
*/
|
||||
private async initializeMinConnections(): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (let i = 0; i < this.options.minSize; i++) {
|
||||
promises.push(
|
||||
this.createConnection()
|
||||
.then(conn => {
|
||||
this.availableConnections.push(conn);
|
||||
})
|
||||
.catch(err => {
|
||||
if (this.options.onConnectionError) {
|
||||
this.options.onConnectionError(err);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start maintenance timer for idle connection cleanup
|
||||
*/
|
||||
private startMaintenance(): void {
|
||||
this.setInterval(() => {
|
||||
this.performMaintenance();
|
||||
}, 30000); // Every 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform maintenance tasks
|
||||
*/
|
||||
private async performMaintenance(): Promise<void> {
|
||||
await this.mutex.runExclusive(async () => {
|
||||
const now = Date.now();
|
||||
const toRemove: IPooledConnection<T>[] = [];
|
||||
|
||||
// Check for idle connections beyond minimum size
|
||||
for (let i = this.availableConnections.length - 1; i >= 0; i--) {
|
||||
const conn = this.availableConnections[i];
|
||||
|
||||
// Keep minimum connections
|
||||
if (this.availableConnections.length <= this.options.minSize) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Remove idle connections
|
||||
if (now - conn.lastUsedAt > this.options.idleTimeout) {
|
||||
toRemove.push(conn);
|
||||
this.availableConnections.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy idle connections
|
||||
for (const conn of toRemove) {
|
||||
await this.destroyConnection(conn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a connection from the pool
|
||||
*/
|
||||
public async acquire(priority: number = 0, timeout?: number): Promise<IPooledConnection<T>> {
|
||||
if (this.isClosing) {
|
||||
throw new Error('Connection pool is closing');
|
||||
}
|
||||
|
||||
return this.mutex.runExclusive(async () => {
|
||||
// Try to get an available connection
|
||||
const connection = await this.tryAcquireConnection();
|
||||
if (connection) {
|
||||
return connection;
|
||||
}
|
||||
|
||||
// Check if we can create a new connection
|
||||
const totalConnections = this.availableConnections.length + this.activeConnections.size;
|
||||
if (totalConnections < this.options.maxSize) {
|
||||
try {
|
||||
const newConnection = await this.createConnection();
|
||||
return this.checkoutConnection(newConnection);
|
||||
} catch (err) {
|
||||
// Fall through to queue if creation fails
|
||||
}
|
||||
}
|
||||
|
||||
// Add to wait queue
|
||||
return this.queueAcquireRequest(priority, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to acquire an available connection
|
||||
*/
|
||||
private async tryAcquireConnection(): Promise<IPooledConnection<T> | null> {
|
||||
while (this.availableConnections.length > 0) {
|
||||
const connection = this.availableConnections.shift()!;
|
||||
|
||||
// Check if connection exceeded max use count
|
||||
if (connection.useCount >= this.options.maxUseCount) {
|
||||
await this.destroyConnection(connection);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate connection if required
|
||||
if (this.options.validateOnAcquire && this.options.connectionValidator) {
|
||||
try {
|
||||
const isValid = await this.options.connectionValidator(connection.connection);
|
||||
if (!isValid) {
|
||||
this.metrics.validationFailures++;
|
||||
await this.destroyConnection(connection);
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
this.metrics.validationFailures++;
|
||||
await this.destroyConnection(connection);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return this.checkoutConnection(connection);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout a connection for use
|
||||
*/
|
||||
private checkoutConnection(connection: IPooledConnection<T>): IPooledConnection<T> {
|
||||
connection.inUse = true;
|
||||
connection.lastUsedAt = Date.now();
|
||||
connection.useCount++;
|
||||
|
||||
this.activeConnections.set(connection.id, connection);
|
||||
this.metrics.connectionsAcquired++;
|
||||
|
||||
this.eventEmitter.emit('acquire', connection);
|
||||
return connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue an acquire request
|
||||
*/
|
||||
private queueAcquireRequest(priority: number, timeout?: number): Promise<IPooledConnection<T>> {
|
||||
return new Promise<IPooledConnection<T>>((resolve, reject) => {
|
||||
const request: IAcquireRequest<T> = {
|
||||
id: `req-${this.requestIdCounter++}`,
|
||||
priority,
|
||||
timestamp: Date.now(),
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
|
||||
// Set timeout
|
||||
const timeoutMs = timeout || this.options.queueTimeout;
|
||||
request.timeoutHandle = this.setTimeout(() => {
|
||||
if (this.waitQueue.extractByKey(request.id)) {
|
||||
this.metrics.acquireTimeouts++;
|
||||
reject(new Error(`Connection acquire timeout after ${timeoutMs}ms`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
this.waitQueue.insert(request);
|
||||
this.metrics.queueHighWaterMark = Math.max(
|
||||
this.metrics.queueHighWaterMark,
|
||||
this.waitQueue.size
|
||||
);
|
||||
|
||||
this.eventEmitter.emit('enqueue', { queueSize: this.waitQueue.size });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a connection back to the pool
|
||||
*/
|
||||
public async release(connection: IPooledConnection<T>): Promise<void> {
|
||||
return this.mutex.runExclusive(async () => {
|
||||
if (!connection.inUse || !this.activeConnections.has(connection.id)) {
|
||||
throw new Error('Connection is not active');
|
||||
}
|
||||
|
||||
this.activeConnections.delete(connection.id);
|
||||
connection.inUse = false;
|
||||
connection.lastUsedAt = Date.now();
|
||||
this.metrics.connectionsReleased++;
|
||||
|
||||
// Check if connection should be destroyed
|
||||
if (connection.useCount >= this.options.maxUseCount) {
|
||||
await this.destroyConnection(connection);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate on return if required
|
||||
if (this.options.validateOnReturn && this.options.connectionValidator) {
|
||||
try {
|
||||
const isValid = await this.options.connectionValidator(connection.connection);
|
||||
if (!isValid) {
|
||||
await this.destroyConnection(connection);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
await this.destroyConnection(connection);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there are waiting requests
|
||||
const request = this.waitQueue.extract();
|
||||
if (request) {
|
||||
this.clearTimeout(request.timeoutHandle!);
|
||||
request.resolve(this.checkoutConnection(connection));
|
||||
this.eventEmitter.emit('dequeue', { queueSize: this.waitQueue.size });
|
||||
} else {
|
||||
// Return to available pool
|
||||
this.availableConnections.push(connection);
|
||||
this.eventEmitter.emit('release', connection);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new connection
|
||||
*/
|
||||
private async createConnection(): Promise<IPooledConnection<T>> {
|
||||
const rawConnection = await this.options.connectionFactory();
|
||||
|
||||
const connection: IPooledConnection<T> = {
|
||||
id: `conn-${this.connectionIdCounter++}`,
|
||||
connection: rawConnection,
|
||||
createdAt: Date.now(),
|
||||
lastUsedAt: Date.now(),
|
||||
useCount: 0,
|
||||
inUse: false,
|
||||
};
|
||||
|
||||
this.metrics.connectionsCreated++;
|
||||
this.eventEmitter.emit('create', connection);
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy a connection
|
||||
*/
|
||||
private async destroyConnection(connection: IPooledConnection<T>): Promise<void> {
|
||||
try {
|
||||
if (this.options.connectionDestroyer) {
|
||||
await this.options.connectionDestroyer(connection.connection);
|
||||
}
|
||||
|
||||
this.metrics.connectionsDestroyed++;
|
||||
this.eventEmitter.emit('destroy', connection);
|
||||
} catch (err) {
|
||||
if (this.options.onConnectionError) {
|
||||
this.options.onConnectionError(err as Error, connection.connection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current pool statistics
|
||||
*/
|
||||
public getStats() {
|
||||
return {
|
||||
available: this.availableConnections.length,
|
||||
active: this.activeConnections.size,
|
||||
waiting: this.waitQueue.size,
|
||||
total: this.availableConnections.length + this.activeConnections.size,
|
||||
...this.metrics,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to pool events
|
||||
*/
|
||||
public on(event: string, listener: Function): void {
|
||||
this.addEventListener(this.eventEmitter, event, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the pool and cleanup resources
|
||||
*/
|
||||
protected async onCleanup(): Promise<void> {
|
||||
this.isClosing = true;
|
||||
|
||||
// Clear the wait queue
|
||||
while (!this.waitQueue.isEmpty()) {
|
||||
const request = this.waitQueue.extract();
|
||||
if (request) {
|
||||
this.clearTimeout(request.timeoutHandle!);
|
||||
request.reject(new Error('Connection pool is closing'));
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for active connections to be released (with timeout)
|
||||
const timeout = 30000;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (this.activeConnections.size > 0 && Date.now() - startTime < timeout) {
|
||||
await new Promise(resolve => {
|
||||
const timer = setTimeout(resolve, 100);
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Destroy all connections
|
||||
const allConnections = [
|
||||
...this.availableConnections,
|
||||
...this.activeConnections.values(),
|
||||
];
|
||||
|
||||
await Promise.all(allConnections.map(conn => this.destroyConnection(conn)));
|
||||
|
||||
this.availableConnections.length = 0;
|
||||
this.activeConnections.clear();
|
||||
}
|
||||
}
|
@ -1,376 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type {
|
||||
ICertificateData,
|
||||
ICertificateFailure,
|
||||
ICertificateExpiring
|
||||
} from '../models/common-types.js';
|
||||
import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js';
|
||||
import { Port80HandlerEvents } from '../models/common-types.js';
|
||||
|
||||
/**
|
||||
* Standardized event names used throughout the system
|
||||
*/
|
||||
export enum ProxyEvents {
|
||||
// Certificate events
|
||||
CERTIFICATE_ISSUED = 'certificate:issued',
|
||||
CERTIFICATE_RENEWED = 'certificate:renewed',
|
||||
CERTIFICATE_FAILED = 'certificate:failed',
|
||||
CERTIFICATE_EXPIRING = 'certificate:expiring',
|
||||
|
||||
// Component lifecycle events
|
||||
COMPONENT_STARTED = 'component:started',
|
||||
COMPONENT_STOPPED = 'component:stopped',
|
||||
|
||||
// Connection events
|
||||
CONNECTION_ESTABLISHED = 'connection:established',
|
||||
CONNECTION_CLOSED = 'connection:closed',
|
||||
CONNECTION_ERROR = 'connection:error',
|
||||
|
||||
// Request events
|
||||
REQUEST_RECEIVED = 'request:received',
|
||||
REQUEST_COMPLETED = 'request:completed',
|
||||
REQUEST_ERROR = 'request:error',
|
||||
|
||||
// Route events
|
||||
ROUTE_MATCHED = 'route:matched',
|
||||
ROUTE_UPDATED = 'route:updated',
|
||||
ROUTE_ERROR = 'route:error',
|
||||
|
||||
// Security events
|
||||
SECURITY_BLOCKED = 'security:blocked',
|
||||
SECURITY_BREACH_ATTEMPT = 'security:breach-attempt',
|
||||
|
||||
// TLS events
|
||||
TLS_HANDSHAKE_STARTED = 'tls:handshake-started',
|
||||
TLS_HANDSHAKE_COMPLETED = 'tls:handshake-completed',
|
||||
TLS_HANDSHAKE_FAILED = 'tls:handshake-failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Component types for event metadata
|
||||
*/
|
||||
export enum ComponentType {
|
||||
SMART_PROXY = 'smart-proxy',
|
||||
NETWORK_PROXY = 'network-proxy',
|
||||
NFTABLES_PROXY = 'nftables-proxy',
|
||||
PORT80_HANDLER = 'port80-handler',
|
||||
CERTIFICATE_MANAGER = 'certificate-manager',
|
||||
ROUTE_MANAGER = 'route-manager',
|
||||
CONNECTION_MANAGER = 'connection-manager',
|
||||
TLS_MANAGER = 'tls-manager',
|
||||
SECURITY_MANAGER = 'security-manager'
|
||||
}
|
||||
|
||||
/**
|
||||
* Base event data interface
|
||||
*/
|
||||
export interface IEventData {
|
||||
timestamp: number;
|
||||
componentType: ComponentType;
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate event data
|
||||
*/
|
||||
export interface ICertificateEventData extends IEventData, ICertificateData {
|
||||
isRenewal?: boolean;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate failure event data
|
||||
*/
|
||||
export interface ICertificateFailureEventData extends IEventData, ICertificateFailure {}
|
||||
|
||||
/**
|
||||
* Certificate expiring event data
|
||||
*/
|
||||
export interface ICertificateExpiringEventData extends IEventData, ICertificateExpiring {}
|
||||
|
||||
/**
|
||||
* Component lifecycle event data
|
||||
*/
|
||||
export interface IComponentEventData extends IEventData {
|
||||
name: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection event data
|
||||
*/
|
||||
export interface IConnectionEventData extends IEventData {
|
||||
connectionId: string;
|
||||
clientIp: string;
|
||||
serverIp?: string;
|
||||
port: number;
|
||||
isTls?: boolean;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request event data
|
||||
*/
|
||||
export interface IRequestEventData extends IEventData {
|
||||
connectionId: string;
|
||||
requestId: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
statusCode?: number;
|
||||
duration?: number;
|
||||
routeId?: string;
|
||||
routeName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route event data
|
||||
*/
|
||||
export interface IRouteEventData extends IEventData {
|
||||
route: IRouteConfig;
|
||||
context?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security event data
|
||||
*/
|
||||
export interface ISecurityEventData extends IEventData {
|
||||
clientIp: string;
|
||||
reason: string;
|
||||
routeId?: string;
|
||||
routeName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TLS event data
|
||||
*/
|
||||
export interface ITlsEventData extends IEventData {
|
||||
connectionId: string;
|
||||
domain?: string;
|
||||
clientIp: string;
|
||||
tlsVersion?: string;
|
||||
cipherSuite?: string;
|
||||
sniHostname?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger interface for event system
|
||||
*/
|
||||
export interface IEventLogger {
|
||||
info: (message: string, ...args: any[]) => void;
|
||||
warn: (message: string, ...args: any[]) => void;
|
||||
error: (message: string, ...args: any[]) => void;
|
||||
debug?: (message: string, ...args: any[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler type
|
||||
*/
|
||||
export type EventHandler<T> = (data: T) => void;
|
||||
|
||||
/**
|
||||
* Helper class to standardize event emission and handling
|
||||
* across all system components
|
||||
*/
|
||||
export class EventSystem {
|
||||
private emitter: plugins.EventEmitter;
|
||||
private componentType: ComponentType;
|
||||
private componentId: string;
|
||||
private logger?: IEventLogger;
|
||||
|
||||
constructor(
|
||||
componentType: ComponentType,
|
||||
componentId: string = '',
|
||||
logger?: IEventLogger
|
||||
) {
|
||||
this.emitter = new plugins.EventEmitter();
|
||||
this.componentType = componentType;
|
||||
this.componentId = componentId;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a certificate issued event
|
||||
*/
|
||||
public emitCertificateIssued(data: Omit<ICertificateEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: ICertificateEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.info?.(`Certificate issued for ${data.domain}`);
|
||||
this.emitter.emit(ProxyEvents.CERTIFICATE_ISSUED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a certificate renewed event
|
||||
*/
|
||||
public emitCertificateRenewed(data: Omit<ICertificateEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: ICertificateEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.info?.(`Certificate renewed for ${data.domain}`);
|
||||
this.emitter.emit(ProxyEvents.CERTIFICATE_RENEWED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a certificate failed event
|
||||
*/
|
||||
public emitCertificateFailed(data: Omit<ICertificateFailureEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: ICertificateFailureEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.error?.(`Certificate issuance failed for ${data.domain}: ${data.error}`);
|
||||
this.emitter.emit(ProxyEvents.CERTIFICATE_FAILED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a certificate expiring event
|
||||
*/
|
||||
public emitCertificateExpiring(data: Omit<ICertificateExpiringEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: ICertificateExpiringEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.warn?.(`Certificate expiring for ${data.domain} in ${data.daysRemaining} days`);
|
||||
this.emitter.emit(ProxyEvents.CERTIFICATE_EXPIRING, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a component started event
|
||||
*/
|
||||
public emitComponentStarted(name: string, version?: string): void {
|
||||
const eventData: IComponentEventData = {
|
||||
name,
|
||||
version,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.info?.(`Component ${name} started${version ? ` (v${version})` : ''}`);
|
||||
this.emitter.emit(ProxyEvents.COMPONENT_STARTED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a component stopped event
|
||||
*/
|
||||
public emitComponentStopped(name: string): void {
|
||||
const eventData: IComponentEventData = {
|
||||
name,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.info?.(`Component ${name} stopped`);
|
||||
this.emitter.emit(ProxyEvents.COMPONENT_STOPPED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a connection established event
|
||||
*/
|
||||
public emitConnectionEstablished(data: Omit<IConnectionEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: IConnectionEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.debug?.(`Connection ${data.connectionId} established from ${data.clientIp} on port ${data.port}`);
|
||||
this.emitter.emit(ProxyEvents.CONNECTION_ESTABLISHED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a connection closed event
|
||||
*/
|
||||
public emitConnectionClosed(data: Omit<IConnectionEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: IConnectionEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.debug?.(`Connection ${data.connectionId} closed`);
|
||||
this.emitter.emit(ProxyEvents.CONNECTION_CLOSED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a route matched event
|
||||
*/
|
||||
public emitRouteMatched(data: Omit<IRouteEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: IRouteEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.debug?.(`Route matched: ${data.route.name || data.route.id || 'unnamed'}`);
|
||||
this.emitter.emit(ProxyEvents.ROUTE_MATCHED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event
|
||||
*/
|
||||
public on<T>(event: ProxyEvents, handler: EventHandler<T>): void {
|
||||
this.emitter.on(event, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event once
|
||||
*/
|
||||
public once<T>(event: ProxyEvents, handler: EventHandler<T>): void {
|
||||
this.emitter.once(event, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from an event
|
||||
*/
|
||||
public off<T>(event: ProxyEvents, handler: EventHandler<T>): void {
|
||||
this.emitter.off(event, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Port80Handler events to standard proxy events
|
||||
*/
|
||||
public subscribePort80HandlerEvents(handler: any): void {
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (data: ICertificateData) => {
|
||||
this.emitCertificateIssued({
|
||||
...data,
|
||||
isRenewal: false,
|
||||
source: 'port80handler'
|
||||
});
|
||||
});
|
||||
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (data: ICertificateData) => {
|
||||
this.emitCertificateRenewed({
|
||||
...data,
|
||||
isRenewal: true,
|
||||
source: 'port80handler'
|
||||
});
|
||||
});
|
||||
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, (data: ICertificateFailure) => {
|
||||
this.emitCertificateFailed(data);
|
||||
});
|
||||
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, (data: ICertificateExpiring) => {
|
||||
this.emitCertificateExpiring(data);
|
||||
});
|
||||
}
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
// Port80Handler has been removed - use SmartCertManager instead
|
||||
import { Port80HandlerEvents } from '../models/common-types.js';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export { Port80HandlerEvents };
|
||||
|
||||
/**
|
||||
* @deprecated Use SmartCertManager instead
|
||||
*/
|
||||
export interface IPort80HandlerSubscribers {
|
||||
onCertificateIssued?: (data: any) => void;
|
||||
onCertificateRenewed?: (data: any) => void;
|
||||
onCertificateFailed?: (data: any) => void;
|
||||
onCertificateExpiring?: (data: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use SmartCertManager instead
|
||||
*/
|
||||
export function subscribeToPort80Handler(
|
||||
handler: any,
|
||||
subscribers: IPort80HandlerSubscribers
|
||||
): void {
|
||||
console.warn('subscribeToPort80Handler is deprecated - use SmartCertManager instead');
|
||||
}
|
270
ts/core/utils/fs-utils.ts
Normal file
270
ts/core/utils/fs-utils.ts
Normal file
@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Async filesystem utilities for SmartProxy
|
||||
* Provides non-blocking alternatives to synchronous filesystem operations
|
||||
*/
|
||||
|
||||
import * as plugins from '../../plugins.js';
|
||||
|
||||
export class AsyncFileSystem {
|
||||
/**
|
||||
* Check if a file or directory exists
|
||||
* @param path - Path to check
|
||||
* @returns Promise resolving to true if exists, false otherwise
|
||||
*/
|
||||
static async exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await plugins.fs.promises.access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a directory exists, creating it if necessary
|
||||
* @param dirPath - Directory path to ensure
|
||||
* @returns Promise that resolves when directory is ensured
|
||||
*/
|
||||
static async ensureDir(dirPath: string): Promise<void> {
|
||||
await plugins.fs.promises.mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file as string
|
||||
* @param filePath - Path to the file
|
||||
* @param encoding - File encoding (default: utf8)
|
||||
* @returns Promise resolving to file contents
|
||||
*/
|
||||
static async readFile(filePath: string, encoding: BufferEncoding = 'utf8'): Promise<string> {
|
||||
return plugins.fs.promises.readFile(filePath, encoding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file as buffer
|
||||
* @param filePath - Path to the file
|
||||
* @returns Promise resolving to file buffer
|
||||
*/
|
||||
static async readFileBuffer(filePath: string): Promise<Buffer> {
|
||||
return plugins.fs.promises.readFile(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write string data to a file
|
||||
* @param filePath - Path to the file
|
||||
* @param data - String data to write
|
||||
* @param encoding - File encoding (default: utf8)
|
||||
* @returns Promise that resolves when file is written
|
||||
*/
|
||||
static async writeFile(filePath: string, data: string, encoding: BufferEncoding = 'utf8'): Promise<void> {
|
||||
// Ensure directory exists
|
||||
const dir = plugins.path.dirname(filePath);
|
||||
await this.ensureDir(dir);
|
||||
await plugins.fs.promises.writeFile(filePath, data, encoding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write buffer data to a file
|
||||
* @param filePath - Path to the file
|
||||
* @param data - Buffer data to write
|
||||
* @returns Promise that resolves when file is written
|
||||
*/
|
||||
static async writeFileBuffer(filePath: string, data: Buffer): Promise<void> {
|
||||
const dir = plugins.path.dirname(filePath);
|
||||
await this.ensureDir(dir);
|
||||
await plugins.fs.promises.writeFile(filePath, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file
|
||||
* @param filePath - Path to the file
|
||||
* @returns Promise that resolves when file is removed
|
||||
*/
|
||||
static async remove(filePath: string): Promise<void> {
|
||||
try {
|
||||
await plugins.fs.promises.unlink(filePath);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
// File doesn't exist, which is fine
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a directory and all its contents
|
||||
* @param dirPath - Path to the directory
|
||||
* @returns Promise that resolves when directory is removed
|
||||
*/
|
||||
static async removeDir(dirPath: string): Promise<void> {
|
||||
try {
|
||||
await plugins.fs.promises.rm(dirPath, { recursive: true, force: true });
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read JSON from a file
|
||||
* @param filePath - Path to the JSON file
|
||||
* @returns Promise resolving to parsed JSON
|
||||
*/
|
||||
static async readJSON<T = any>(filePath: string): Promise<T> {
|
||||
const content = await this.readFile(filePath);
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write JSON to a file
|
||||
* @param filePath - Path to the file
|
||||
* @param data - Data to write as JSON
|
||||
* @param pretty - Whether to pretty-print JSON (default: true)
|
||||
* @returns Promise that resolves when file is written
|
||||
*/
|
||||
static async writeJSON(filePath: string, data: any, pretty = true): Promise<void> {
|
||||
const jsonString = pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data);
|
||||
await this.writeFile(filePath, jsonString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a file from source to destination
|
||||
* @param source - Source file path
|
||||
* @param destination - Destination file path
|
||||
* @returns Promise that resolves when file is copied
|
||||
*/
|
||||
static async copyFile(source: string, destination: string): Promise<void> {
|
||||
const destDir = plugins.path.dirname(destination);
|
||||
await this.ensureDir(destDir);
|
||||
await plugins.fs.promises.copyFile(source, destination);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move/rename a file
|
||||
* @param source - Source file path
|
||||
* @param destination - Destination file path
|
||||
* @returns Promise that resolves when file is moved
|
||||
*/
|
||||
static async moveFile(source: string, destination: string): Promise<void> {
|
||||
const destDir = plugins.path.dirname(destination);
|
||||
await this.ensureDir(destDir);
|
||||
await plugins.fs.promises.rename(source, destination);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file stats
|
||||
* @param filePath - Path to the file
|
||||
* @returns Promise resolving to file stats or null if doesn't exist
|
||||
*/
|
||||
static async getStats(filePath: string): Promise<plugins.fs.Stats | null> {
|
||||
try {
|
||||
return await plugins.fs.promises.stat(filePath);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a directory
|
||||
* @param dirPath - Directory path
|
||||
* @returns Promise resolving to array of filenames
|
||||
*/
|
||||
static async listFiles(dirPath: string): Promise<string[]> {
|
||||
try {
|
||||
return await plugins.fs.promises.readdir(dirPath);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a directory with full paths
|
||||
* @param dirPath - Directory path
|
||||
* @returns Promise resolving to array of full file paths
|
||||
*/
|
||||
static async listFilesFullPath(dirPath: string): Promise<string[]> {
|
||||
const files = await this.listFiles(dirPath);
|
||||
return files.map(file => plugins.path.join(dirPath, file));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively list all files in a directory
|
||||
* @param dirPath - Directory path
|
||||
* @param fileList - Accumulator for file list (used internally)
|
||||
* @returns Promise resolving to array of all file paths
|
||||
*/
|
||||
static async listFilesRecursive(dirPath: string, fileList: string[] = []): Promise<string[]> {
|
||||
const files = await this.listFiles(dirPath);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = plugins.path.join(dirPath, file);
|
||||
const stats = await this.getStats(filePath);
|
||||
|
||||
if (stats?.isDirectory()) {
|
||||
await this.listFilesRecursive(filePath, fileList);
|
||||
} else if (stats?.isFile()) {
|
||||
fileList.push(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
return fileList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a read stream for a file
|
||||
* @param filePath - Path to the file
|
||||
* @param options - Stream options
|
||||
* @returns Read stream
|
||||
*/
|
||||
static createReadStream(filePath: string, options?: Parameters<typeof plugins.fs.createReadStream>[1]): plugins.fs.ReadStream {
|
||||
return plugins.fs.createReadStream(filePath, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a write stream for a file
|
||||
* @param filePath - Path to the file
|
||||
* @param options - Stream options
|
||||
* @returns Write stream
|
||||
*/
|
||||
static createWriteStream(filePath: string, options?: Parameters<typeof plugins.fs.createWriteStream>[1]): plugins.fs.WriteStream {
|
||||
return plugins.fs.createWriteStream(filePath, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a file exists, creating an empty file if necessary
|
||||
* @param filePath - Path to the file
|
||||
* @returns Promise that resolves when file is ensured
|
||||
*/
|
||||
static async ensureFile(filePath: string): Promise<void> {
|
||||
const exists = await this.exists(filePath);
|
||||
if (!exists) {
|
||||
await this.writeFile(filePath, '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is a directory
|
||||
* @param path - Path to check
|
||||
* @returns Promise resolving to true if directory, false otherwise
|
||||
*/
|
||||
static async isDirectory(path: string): Promise<boolean> {
|
||||
const stats = await this.getStats(path);
|
||||
return stats?.isDirectory() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is a file
|
||||
* @param path - Path to check
|
||||
* @returns Promise resolving to true if file, false otherwise
|
||||
*/
|
||||
static async isFile(path: string): Promise<boolean> {
|
||||
const stats = await this.getStats(path);
|
||||
return stats?.isFile() ?? false;
|
||||
}
|
||||
}
|
@ -2,7 +2,6 @@
|
||||
* Core utility functions
|
||||
*/
|
||||
|
||||
export * from './event-utils.js';
|
||||
export * from './validation-utils.js';
|
||||
export * from './ip-utils.js';
|
||||
export * from './template-utils.js';
|
||||
@ -10,6 +9,11 @@ export * from './route-manager.js';
|
||||
export * from './route-utils.js';
|
||||
export * from './security-utils.js';
|
||||
export * from './shared-security-manager.js';
|
||||
export * from './event-system.js';
|
||||
export * from './websocket-utils.js';
|
||||
export * from './logger.js';
|
||||
export * from './async-utils.js';
|
||||
export * from './fs-utils.js';
|
||||
export * from './lifecycle-component.js';
|
||||
export * from './binary-heap.js';
|
||||
export * from './enhanced-connection-pool.js';
|
||||
export * from './socket-utils.js';
|
||||
|
251
ts/core/utils/lifecycle-component.ts
Normal file
251
ts/core/utils/lifecycle-component.ts
Normal file
@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Base class for components that need proper resource lifecycle management
|
||||
* Provides automatic cleanup of timers and event listeners to prevent memory leaks
|
||||
*/
|
||||
export abstract class LifecycleComponent {
|
||||
private timers: Set<NodeJS.Timeout> = new Set();
|
||||
private intervals: Set<NodeJS.Timeout> = new Set();
|
||||
private listeners: Array<{
|
||||
target: any;
|
||||
event: string;
|
||||
handler: Function;
|
||||
actualHandler?: Function; // The actual handler registered (may be wrapped)
|
||||
once?: boolean;
|
||||
}> = [];
|
||||
private childComponents: Set<LifecycleComponent> = new Set();
|
||||
protected isShuttingDown = false;
|
||||
private cleanupPromise?: Promise<void>;
|
||||
|
||||
/**
|
||||
* Create a managed setTimeout that will be automatically cleaned up
|
||||
*/
|
||||
protected setTimeout(handler: Function, timeout: number): NodeJS.Timeout {
|
||||
if (this.isShuttingDown) {
|
||||
// Return a dummy timer if shutting down
|
||||
const dummyTimer = setTimeout(() => {}, 0);
|
||||
if (typeof dummyTimer.unref === 'function') {
|
||||
dummyTimer.unref();
|
||||
}
|
||||
return dummyTimer;
|
||||
}
|
||||
|
||||
const wrappedHandler = () => {
|
||||
this.timers.delete(timer);
|
||||
if (!this.isShuttingDown) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(wrappedHandler, timeout);
|
||||
this.timers.add(timer);
|
||||
|
||||
// Allow process to exit even with timer
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
return timer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a managed setInterval that will be automatically cleaned up
|
||||
*/
|
||||
protected setInterval(handler: Function, interval: number): NodeJS.Timeout {
|
||||
if (this.isShuttingDown) {
|
||||
// Return a dummy timer if shutting down
|
||||
const dummyTimer = setInterval(() => {}, interval);
|
||||
if (typeof dummyTimer.unref === 'function') {
|
||||
dummyTimer.unref();
|
||||
}
|
||||
clearInterval(dummyTimer); // Clear immediately since we don't need it
|
||||
return dummyTimer;
|
||||
}
|
||||
|
||||
const wrappedHandler = () => {
|
||||
if (!this.isShuttingDown) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setInterval(wrappedHandler, interval);
|
||||
this.intervals.add(timer);
|
||||
|
||||
// Allow process to exit even with timer
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
return timer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a managed timeout
|
||||
*/
|
||||
protected clearTimeout(timer: NodeJS.Timeout): void {
|
||||
clearTimeout(timer);
|
||||
this.timers.delete(timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a managed interval
|
||||
*/
|
||||
protected clearInterval(timer: NodeJS.Timeout): void {
|
||||
clearInterval(timer);
|
||||
this.intervals.delete(timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a managed event listener that will be automatically removed on cleanup
|
||||
*/
|
||||
protected addEventListener(
|
||||
target: any,
|
||||
event: string,
|
||||
handler: Function,
|
||||
options?: { once?: boolean }
|
||||
): void {
|
||||
if (this.isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For 'once' listeners, we need to wrap the handler to remove it from our tracking
|
||||
let actualHandler = handler;
|
||||
if (options?.once) {
|
||||
actualHandler = (...args: any[]) => {
|
||||
// Call the original handler
|
||||
handler(...args);
|
||||
|
||||
// Remove from our internal tracking
|
||||
const index = this.listeners.findIndex(
|
||||
l => l.target === target && l.event === event && l.handler === handler
|
||||
);
|
||||
if (index !== -1) {
|
||||
this.listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Support both EventEmitter and DOM-style event targets
|
||||
if (typeof target.on === 'function') {
|
||||
if (options?.once) {
|
||||
target.once(event, actualHandler);
|
||||
} else {
|
||||
target.on(event, actualHandler);
|
||||
}
|
||||
} else if (typeof target.addEventListener === 'function') {
|
||||
target.addEventListener(event, actualHandler, options);
|
||||
} else {
|
||||
throw new Error('Target must support on() or addEventListener()');
|
||||
}
|
||||
|
||||
// Store both the original handler and the actual handler registered
|
||||
this.listeners.push({
|
||||
target,
|
||||
event,
|
||||
handler,
|
||||
actualHandler, // The handler that was actually registered (may be wrapped)
|
||||
once: options?.once
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific event listener
|
||||
*/
|
||||
protected removeEventListener(target: any, event: string, handler: Function): void {
|
||||
// Remove from target
|
||||
if (typeof target.removeListener === 'function') {
|
||||
target.removeListener(event, handler);
|
||||
} else if (typeof target.removeEventListener === 'function') {
|
||||
target.removeEventListener(event, handler);
|
||||
}
|
||||
|
||||
// Remove from our tracking
|
||||
const index = this.listeners.findIndex(
|
||||
l => l.target === target && l.event === event && l.handler === handler
|
||||
);
|
||||
if (index !== -1) {
|
||||
this.listeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a child component that should be cleaned up when this component is cleaned up
|
||||
*/
|
||||
protected registerChildComponent(component: LifecycleComponent): void {
|
||||
this.childComponents.add(component);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a child component
|
||||
*/
|
||||
protected unregisterChildComponent(component: LifecycleComponent): void {
|
||||
this.childComponents.delete(component);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this method to implement component-specific cleanup logic
|
||||
*/
|
||||
protected async onCleanup(): Promise<void> {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all managed resources
|
||||
*/
|
||||
public async cleanup(): Promise<void> {
|
||||
// Return existing cleanup promise if already cleaning up
|
||||
if (this.cleanupPromise) {
|
||||
return this.cleanupPromise;
|
||||
}
|
||||
|
||||
this.cleanupPromise = this.performCleanup();
|
||||
return this.cleanupPromise;
|
||||
}
|
||||
|
||||
private async performCleanup(): Promise<void> {
|
||||
this.isShuttingDown = true;
|
||||
|
||||
// First, clean up child components
|
||||
const childCleanupPromises: Promise<void>[] = [];
|
||||
for (const child of this.childComponents) {
|
||||
childCleanupPromises.push(child.cleanup());
|
||||
}
|
||||
await Promise.all(childCleanupPromises);
|
||||
this.childComponents.clear();
|
||||
|
||||
// Clear all timers
|
||||
for (const timer of this.timers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.timers.clear();
|
||||
|
||||
// Clear all intervals
|
||||
for (const timer of this.intervals) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
this.intervals.clear();
|
||||
|
||||
// Remove all event listeners
|
||||
for (const { target, event, handler, actualHandler } of this.listeners) {
|
||||
// Use actualHandler if available (for wrapped handlers), otherwise use the original handler
|
||||
const handlerToRemove = actualHandler || handler;
|
||||
|
||||
// All listeners need to be removed, including 'once' listeners that might not have fired
|
||||
if (typeof target.removeListener === 'function') {
|
||||
target.removeListener(event, handlerToRemove);
|
||||
} else if (typeof target.removeEventListener === 'function') {
|
||||
target.removeEventListener(event, handlerToRemove);
|
||||
}
|
||||
}
|
||||
this.listeners = [];
|
||||
|
||||
// Call subclass cleanup
|
||||
await this.onCleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the component is shutting down
|
||||
*/
|
||||
protected isShuttingDownState(): boolean {
|
||||
return this.isShuttingDown;
|
||||
}
|
||||
}
|
243
ts/core/utils/socket-utils.ts
Normal file
243
ts/core/utils/socket-utils.ts
Normal file
@ -0,0 +1,243 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
|
||||
export interface CleanupOptions {
|
||||
immediate?: boolean; // Force immediate destruction
|
||||
allowDrain?: boolean; // Allow write buffer to drain
|
||||
gracePeriod?: number; // Ms to wait before force close
|
||||
}
|
||||
|
||||
export interface SafeSocketOptions {
|
||||
port: number;
|
||||
host: string;
|
||||
onError?: (error: Error) => void;
|
||||
onConnect?: () => void;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely cleanup a socket by removing all listeners and destroying it
|
||||
* @param socket The socket to cleanup
|
||||
* @param socketName Optional name for logging
|
||||
* @param options Cleanup options
|
||||
*/
|
||||
export function cleanupSocket(
|
||||
socket: plugins.net.Socket | plugins.tls.TLSSocket | null,
|
||||
socketName?: string,
|
||||
options: CleanupOptions = {}
|
||||
): Promise<void> {
|
||||
if (!socket || socket.destroyed) return Promise.resolve();
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const cleanup = () => {
|
||||
try {
|
||||
// Remove all event listeners
|
||||
socket.removeAllListeners();
|
||||
|
||||
// Destroy if not already destroyed
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error cleaning up socket${socketName ? ` (${socketName})` : ''}: ${err}`);
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
if (options.immediate) {
|
||||
// Immediate cleanup (old behavior)
|
||||
socket.unpipe();
|
||||
cleanup();
|
||||
} else if (options.allowDrain && socket.writable) {
|
||||
// Allow pending writes to complete
|
||||
socket.end(() => cleanup());
|
||||
|
||||
// Force cleanup after grace period
|
||||
if (options.gracePeriod) {
|
||||
setTimeout(() => {
|
||||
if (!socket.destroyed) {
|
||||
cleanup();
|
||||
}
|
||||
}, options.gracePeriod);
|
||||
}
|
||||
} else {
|
||||
// Default: immediate cleanup
|
||||
socket.unpipe();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a cleanup handler for paired sockets (client and server)
|
||||
* @param clientSocket The client socket
|
||||
* @param serverSocket The server socket (optional)
|
||||
* @param onCleanup Optional callback when cleanup is done
|
||||
* @returns A cleanup function that can be called multiple times safely
|
||||
* @deprecated Use createIndependentSocketHandlers for better half-open support
|
||||
*/
|
||||
export function createSocketCleanupHandler(
|
||||
clientSocket: plugins.net.Socket | plugins.tls.TLSSocket,
|
||||
serverSocket?: plugins.net.Socket | plugins.tls.TLSSocket | null,
|
||||
onCleanup?: (reason: string) => void
|
||||
): (reason: string) => void {
|
||||
let cleanedUp = false;
|
||||
|
||||
return (reason: string) => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
|
||||
// Cleanup both sockets (old behavior - too aggressive)
|
||||
cleanupSocket(clientSocket, 'client', { immediate: true });
|
||||
if (serverSocket) {
|
||||
cleanupSocket(serverSocket, 'server', { immediate: true });
|
||||
}
|
||||
|
||||
// Call cleanup callback if provided
|
||||
if (onCleanup) {
|
||||
onCleanup(reason);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create independent cleanup handlers for paired sockets that support half-open connections
|
||||
* @param clientSocket The client socket
|
||||
* @param serverSocket The server socket
|
||||
* @param onBothClosed Callback when both sockets are closed
|
||||
* @returns Independent cleanup functions for each socket
|
||||
*/
|
||||
export function createIndependentSocketHandlers(
|
||||
clientSocket: plugins.net.Socket | plugins.tls.TLSSocket,
|
||||
serverSocket: plugins.net.Socket | plugins.tls.TLSSocket,
|
||||
onBothClosed: (reason: string) => void
|
||||
): { cleanupClient: (reason: string) => Promise<void>, cleanupServer: (reason: string) => Promise<void> } {
|
||||
let clientClosed = false;
|
||||
let serverClosed = false;
|
||||
let clientReason = '';
|
||||
let serverReason = '';
|
||||
|
||||
const checkBothClosed = () => {
|
||||
if (clientClosed && serverClosed) {
|
||||
onBothClosed(`client: ${clientReason}, server: ${serverReason}`);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupClient = async (reason: string) => {
|
||||
if (clientClosed) return;
|
||||
clientClosed = true;
|
||||
clientReason = reason;
|
||||
|
||||
// Allow server to continue if still active
|
||||
if (!serverClosed && serverSocket.writable) {
|
||||
// Half-close: stop reading from client, let server finish
|
||||
clientSocket.pause();
|
||||
clientSocket.unpipe(serverSocket);
|
||||
await cleanupSocket(clientSocket, 'client', { allowDrain: true, gracePeriod: 5000 });
|
||||
} else {
|
||||
await cleanupSocket(clientSocket, 'client', { immediate: true });
|
||||
}
|
||||
|
||||
checkBothClosed();
|
||||
};
|
||||
|
||||
const cleanupServer = async (reason: string) => {
|
||||
if (serverClosed) return;
|
||||
serverClosed = true;
|
||||
serverReason = reason;
|
||||
|
||||
// Allow client to continue if still active
|
||||
if (!clientClosed && clientSocket.writable) {
|
||||
// Half-close: stop reading from server, let client finish
|
||||
serverSocket.pause();
|
||||
serverSocket.unpipe(clientSocket);
|
||||
await cleanupSocket(serverSocket, 'server', { allowDrain: true, gracePeriod: 5000 });
|
||||
} else {
|
||||
await cleanupSocket(serverSocket, 'server', { immediate: true });
|
||||
}
|
||||
|
||||
checkBothClosed();
|
||||
};
|
||||
|
||||
return { cleanupClient, cleanupServer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup socket error and close handlers with proper cleanup
|
||||
* @param socket The socket to setup handlers for
|
||||
* @param handleClose The cleanup function to call
|
||||
* @param handleTimeout Optional custom timeout handler
|
||||
* @param errorPrefix Optional prefix for error messages
|
||||
*/
|
||||
export function setupSocketHandlers(
|
||||
socket: plugins.net.Socket | plugins.tls.TLSSocket,
|
||||
handleClose: (reason: string) => void,
|
||||
handleTimeout?: (socket: plugins.net.Socket | plugins.tls.TLSSocket) => void,
|
||||
errorPrefix?: string
|
||||
): void {
|
||||
socket.on('error', (error) => {
|
||||
const prefix = errorPrefix || 'Socket';
|
||||
handleClose(`${prefix}_error: ${error.message}`);
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
const prefix = errorPrefix || 'socket';
|
||||
handleClose(`${prefix}_closed`);
|
||||
});
|
||||
|
||||
socket.on('timeout', () => {
|
||||
if (handleTimeout) {
|
||||
handleTimeout(socket); // Custom timeout handling
|
||||
} else {
|
||||
// Default: just log, don't close
|
||||
console.warn(`Socket timeout: ${errorPrefix || 'socket'}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipe two sockets together with proper cleanup on either end
|
||||
* @param socket1 First socket
|
||||
* @param socket2 Second socket
|
||||
*/
|
||||
export function pipeSockets(
|
||||
socket1: plugins.net.Socket | plugins.tls.TLSSocket,
|
||||
socket2: plugins.net.Socket | plugins.tls.TLSSocket
|
||||
): void {
|
||||
socket1.pipe(socket2);
|
||||
socket2.pipe(socket1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a socket with immediate error handling to prevent crashes
|
||||
* @param options Socket creation options
|
||||
* @returns The created socket
|
||||
*/
|
||||
export function createSocketWithErrorHandler(options: SafeSocketOptions): plugins.net.Socket {
|
||||
const { port, host, onError, onConnect, timeout } = options;
|
||||
|
||||
// Create socket with immediate error handler attachment
|
||||
const socket = new plugins.net.Socket();
|
||||
|
||||
// Attach error handler BEFORE connecting to catch immediate errors
|
||||
socket.on('error', (error) => {
|
||||
console.error(`Socket connection error to ${host}:${port}: ${error.message}`);
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Attach connect handler if provided
|
||||
if (onConnect) {
|
||||
socket.on('connect', onConnect);
|
||||
}
|
||||
|
||||
// Set timeout if provided
|
||||
if (timeout) {
|
||||
socket.setTimeout(timeout);
|
||||
}
|
||||
|
||||
// Now attempt to connect - any immediate errors will be caught
|
||||
socket.connect(port, host);
|
||||
|
||||
return socket;
|
||||
}
|
@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
|
||||
import { ForwardingHandler } from './base-handler.js';
|
||||
import type { IForwardConfig } from '../config/forwarding-types.js';
|
||||
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
|
||||
import { setupSocketHandlers } from '../../core/utils/socket-utils.js';
|
||||
|
||||
/**
|
||||
* Handler for HTTP-only forwarding
|
||||
@ -40,12 +41,20 @@ export class HttpForwardingHandler extends ForwardingHandler {
|
||||
const remoteAddress = socket.remoteAddress || 'unknown';
|
||||
const localPort = socket.localPort || 80;
|
||||
|
||||
socket.on('close', (hadError) => {
|
||||
// Set up socket handlers with proper cleanup
|
||||
const handleClose = (reason: string) => {
|
||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||
remoteAddress,
|
||||
hadError
|
||||
reason
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Use custom timeout handler that doesn't close the socket
|
||||
setupSocketHandlers(socket, handleClose, () => {
|
||||
// For HTTP, we can be more aggressive with timeouts since connections are shorter
|
||||
// But still don't close immediately - let the connection finish naturally
|
||||
console.warn(`HTTP socket timeout from ${remoteAddress}`);
|
||||
}, 'http');
|
||||
|
||||
socket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
|
@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
|
||||
import { ForwardingHandler } from './base-handler.js';
|
||||
import type { IForwardConfig } from '../config/forwarding-types.js';
|
||||
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
|
||||
import { createIndependentSocketHandlers, setupSocketHandlers, createSocketWithErrorHandler } from '../../core/utils/socket-utils.js';
|
||||
|
||||
/**
|
||||
* Handler for HTTPS passthrough (SNI forwarding without termination)
|
||||
@ -47,128 +48,121 @@ export class HttpsPassthroughHandler extends ForwardingHandler {
|
||||
target: `${target.host}:${target.port}`
|
||||
});
|
||||
|
||||
// Create a connection to the target server
|
||||
const serverSocket = plugins.net.connect(target.port, target.host);
|
||||
|
||||
// Handle errors on the server socket
|
||||
serverSocket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: `Target connection error: ${error.message}`
|
||||
});
|
||||
|
||||
// Close the client socket if it's still open
|
||||
if (!clientSocket.destroyed) {
|
||||
clientSocket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle errors on the client socket
|
||||
clientSocket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: `Client connection error: ${error.message}`
|
||||
});
|
||||
|
||||
// Close the server socket if it's still open
|
||||
if (!serverSocket.destroyed) {
|
||||
serverSocket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Track data transfer for logging
|
||||
let bytesSent = 0;
|
||||
let bytesReceived = 0;
|
||||
let serverSocket: plugins.net.Socket | null = null;
|
||||
let cleanupClient: ((reason: string) => Promise<void>) | null = null;
|
||||
let cleanupServer: ((reason: string) => Promise<void>) | null = null;
|
||||
|
||||
// Forward data from client to server
|
||||
clientSocket.on('data', (data) => {
|
||||
bytesSent += data.length;
|
||||
|
||||
// Check if server socket is writable
|
||||
if (serverSocket.writable) {
|
||||
const flushed = serverSocket.write(data);
|
||||
// Create a connection to the target server with immediate error handling
|
||||
serverSocket = createSocketWithErrorHandler({
|
||||
port: target.port,
|
||||
host: target.host,
|
||||
onError: async (error) => {
|
||||
// Server connection failed - clean up client socket immediately
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
error: error.message,
|
||||
code: (error as any).code || 'UNKNOWN',
|
||||
remoteAddress,
|
||||
target: `${target.host}:${target.port}`
|
||||
});
|
||||
|
||||
// Handle backpressure
|
||||
if (!flushed) {
|
||||
clientSocket.pause();
|
||||
serverSocket.once('drain', () => {
|
||||
clientSocket.resume();
|
||||
});
|
||||
// Clean up the client socket since we can't forward
|
||||
if (!clientSocket.destroyed) {
|
||||
clientSocket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
|
||||
direction: 'outbound',
|
||||
bytes: data.length,
|
||||
total: bytesSent
|
||||
});
|
||||
});
|
||||
|
||||
// Forward data from server to client
|
||||
serverSocket.on('data', (data) => {
|
||||
bytesReceived += data.length;
|
||||
|
||||
// Check if client socket is writable
|
||||
if (clientSocket.writable) {
|
||||
const flushed = clientSocket.write(data);
|
||||
|
||||
// Handle backpressure
|
||||
if (!flushed) {
|
||||
serverSocket.pause();
|
||||
clientSocket.once('drain', () => {
|
||||
serverSocket.resume();
|
||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||
remoteAddress,
|
||||
bytesSent: 0,
|
||||
bytesReceived: 0,
|
||||
reason: `server_connection_failed: ${error.message}`
|
||||
});
|
||||
},
|
||||
onConnect: () => {
|
||||
// Connection successful - set up forwarding handlers
|
||||
const handlers = createIndependentSocketHandlers(
|
||||
clientSocket,
|
||||
serverSocket!,
|
||||
(reason) => {
|
||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||
remoteAddress,
|
||||
bytesSent,
|
||||
bytesReceived,
|
||||
reason
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
cleanupClient = handlers.cleanupClient;
|
||||
cleanupServer = handlers.cleanupServer;
|
||||
|
||||
// Setup handlers with custom timeout handling that doesn't close connections
|
||||
const timeout = this.getTimeout();
|
||||
|
||||
setupSocketHandlers(clientSocket, cleanupClient, (socket) => {
|
||||
// Just reset timeout, don't close
|
||||
socket.setTimeout(timeout);
|
||||
}, 'client');
|
||||
|
||||
setupSocketHandlers(serverSocket!, cleanupServer, (socket) => {
|
||||
// Just reset timeout, don't close
|
||||
socket.setTimeout(timeout);
|
||||
}, 'server');
|
||||
|
||||
// Forward data from client to server
|
||||
clientSocket.on('data', (data) => {
|
||||
bytesSent += data.length;
|
||||
|
||||
// Check if server socket is writable
|
||||
if (serverSocket && serverSocket.writable) {
|
||||
const flushed = serverSocket.write(data);
|
||||
|
||||
// Handle backpressure
|
||||
if (!flushed) {
|
||||
clientSocket.pause();
|
||||
serverSocket.once('drain', () => {
|
||||
clientSocket.resume();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
|
||||
direction: 'outbound',
|
||||
bytes: data.length,
|
||||
total: bytesSent
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Forward data from server to client
|
||||
serverSocket!.on('data', (data) => {
|
||||
bytesReceived += data.length;
|
||||
|
||||
// Check if client socket is writable
|
||||
if (clientSocket.writable) {
|
||||
const flushed = clientSocket.write(data);
|
||||
|
||||
// Handle backpressure
|
||||
if (!flushed) {
|
||||
serverSocket!.pause();
|
||||
clientSocket.once('drain', () => {
|
||||
serverSocket!.resume();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
|
||||
direction: 'inbound',
|
||||
bytes: data.length,
|
||||
total: bytesReceived
|
||||
});
|
||||
});
|
||||
|
||||
// Set initial timeouts - they will be reset on each timeout event
|
||||
clientSocket.setTimeout(timeout);
|
||||
serverSocket!.setTimeout(timeout);
|
||||
}
|
||||
|
||||
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
|
||||
direction: 'inbound',
|
||||
bytes: data.length,
|
||||
total: bytesReceived
|
||||
});
|
||||
});
|
||||
|
||||
// Handle connection close
|
||||
const handleClose = () => {
|
||||
if (!clientSocket.destroyed) {
|
||||
clientSocket.destroy();
|
||||
}
|
||||
|
||||
if (!serverSocket.destroyed) {
|
||||
serverSocket.destroy();
|
||||
}
|
||||
|
||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||
remoteAddress,
|
||||
bytesSent,
|
||||
bytesReceived
|
||||
});
|
||||
};
|
||||
|
||||
// Set up close handlers
|
||||
clientSocket.on('close', handleClose);
|
||||
serverSocket.on('close', handleClose);
|
||||
|
||||
// Set timeouts
|
||||
const timeout = this.getTimeout();
|
||||
clientSocket.setTimeout(timeout);
|
||||
serverSocket.setTimeout(timeout);
|
||||
|
||||
// Handle timeouts
|
||||
clientSocket.on('timeout', () => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: 'Client connection timeout'
|
||||
});
|
||||
handleClose();
|
||||
});
|
||||
|
||||
serverSocket.on('timeout', () => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: 'Server connection timeout'
|
||||
});
|
||||
handleClose();
|
||||
});
|
||||
}
|
||||
|
||||
@ -177,7 +171,7 @@ export class HttpsPassthroughHandler extends ForwardingHandler {
|
||||
* @param req The HTTP request
|
||||
* @param res The HTTP response
|
||||
*/
|
||||
public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
||||
public handleHttpRequest(_req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
||||
// HTTPS passthrough doesn't support HTTP requests
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('HTTP not supported for this domain');
|
||||
|
@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
|
||||
import { ForwardingHandler } from './base-handler.js';
|
||||
import type { IForwardConfig } from '../config/forwarding-types.js';
|
||||
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
|
||||
import { createSocketCleanupHandler, setupSocketHandlers, createSocketWithErrorHandler } from '../../core/utils/socket-utils.js';
|
||||
|
||||
/**
|
||||
* Handler for HTTPS termination with HTTP backend
|
||||
@ -95,62 +96,24 @@ export class HttpsTerminateToHttpHandler extends ForwardingHandler {
|
||||
tls: true
|
||||
});
|
||||
|
||||
// Handle TLS errors
|
||||
tlsSocket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: `TLS error: ${error.message}`
|
||||
});
|
||||
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// The TLS socket will now emit HTTP traffic that can be processed
|
||||
// In a real implementation, we would create an HTTP parser and handle
|
||||
// the requests here, but for simplicity, we'll just log the data
|
||||
|
||||
// Variables to track connections
|
||||
let backendSocket: plugins.net.Socket | null = null;
|
||||
let dataBuffer = Buffer.alloc(0);
|
||||
let connectionEstablished = false;
|
||||
|
||||
tlsSocket.on('data', (data) => {
|
||||
// Append to buffer
|
||||
dataBuffer = Buffer.concat([dataBuffer, data]);
|
||||
|
||||
// Very basic HTTP parsing - in a real implementation, use http-parser
|
||||
if (dataBuffer.includes(Buffer.from('\r\n\r\n'))) {
|
||||
const target = this.getTargetFromConfig();
|
||||
|
||||
// Simple example: forward the data to an HTTP server
|
||||
const socket = plugins.net.connect(target.port, target.host, () => {
|
||||
socket.write(dataBuffer);
|
||||
dataBuffer = Buffer.alloc(0);
|
||||
|
||||
// Set up bidirectional data flow
|
||||
tlsSocket.pipe(socket);
|
||||
socket.pipe(tlsSocket);
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: `Target connection error: ${error.message}`
|
||||
});
|
||||
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle close
|
||||
tlsSocket.on('close', () => {
|
||||
// Create cleanup handler for all sockets
|
||||
const handleClose = createSocketCleanupHandler(tlsSocket, backendSocket, (reason) => {
|
||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||
remoteAddress
|
||||
remoteAddress,
|
||||
reason
|
||||
});
|
||||
dataBuffer = Buffer.alloc(0);
|
||||
connectionEstablished = false;
|
||||
});
|
||||
|
||||
// Set up error handling with our cleanup utility
|
||||
setupSocketHandlers(tlsSocket, handleClose, undefined, 'tls');
|
||||
|
||||
// Set timeout
|
||||
const timeout = this.getTimeout();
|
||||
tlsSocket.setTimeout(timeout);
|
||||
@ -160,9 +123,80 @@ export class HttpsTerminateToHttpHandler extends ForwardingHandler {
|
||||
remoteAddress,
|
||||
error: 'TLS connection timeout'
|
||||
});
|
||||
handleClose('timeout');
|
||||
});
|
||||
|
||||
// Handle TLS data
|
||||
tlsSocket.on('data', (data) => {
|
||||
// If backend connection already established, just forward the data
|
||||
if (connectionEstablished && backendSocket && !backendSocket.destroyed) {
|
||||
backendSocket.write(data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
// Append to buffer
|
||||
dataBuffer = Buffer.concat([dataBuffer, data]);
|
||||
|
||||
// Very basic HTTP parsing - in a real implementation, use http-parser
|
||||
if (dataBuffer.includes(Buffer.from('\r\n\r\n')) && !connectionEstablished) {
|
||||
const target = this.getTargetFromConfig();
|
||||
|
||||
// Create backend connection with immediate error handling
|
||||
backendSocket = createSocketWithErrorHandler({
|
||||
port: target.port,
|
||||
host: target.host,
|
||||
onError: (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
error: error.message,
|
||||
code: (error as any).code || 'UNKNOWN',
|
||||
remoteAddress,
|
||||
target: `${target.host}:${target.port}`
|
||||
});
|
||||
|
||||
// Clean up the TLS socket since we can't forward
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
|
||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||
remoteAddress,
|
||||
reason: `backend_connection_failed: ${error.message}`
|
||||
});
|
||||
},
|
||||
onConnect: () => {
|
||||
connectionEstablished = true;
|
||||
|
||||
// Send buffered data
|
||||
if (dataBuffer.length > 0) {
|
||||
backendSocket!.write(dataBuffer);
|
||||
dataBuffer = Buffer.alloc(0);
|
||||
}
|
||||
|
||||
// Set up bidirectional data flow
|
||||
tlsSocket.pipe(backendSocket!);
|
||||
backendSocket!.pipe(tlsSocket);
|
||||
}
|
||||
});
|
||||
|
||||
// Update the cleanup handler with the backend socket
|
||||
const newHandleClose = createSocketCleanupHandler(tlsSocket, backendSocket, (reason) => {
|
||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||
remoteAddress,
|
||||
reason
|
||||
});
|
||||
dataBuffer = Buffer.alloc(0);
|
||||
connectionEstablished = false;
|
||||
});
|
||||
|
||||
// Set up handlers for backend socket
|
||||
setupSocketHandlers(backendSocket, newHandleClose, undefined, 'backend');
|
||||
|
||||
backendSocket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: `Target connection error: ${error.message}`
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
|
||||
import { ForwardingHandler } from './base-handler.js';
|
||||
import type { IForwardConfig } from '../config/forwarding-types.js';
|
||||
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
|
||||
import { createSocketCleanupHandler, setupSocketHandlers, createSocketWithErrorHandler } from '../../core/utils/socket-utils.js';
|
||||
|
||||
/**
|
||||
* Handler for HTTPS termination with HTTPS backend
|
||||
@ -93,28 +94,38 @@ export class HttpsTerminateToHttpsHandler extends ForwardingHandler {
|
||||
tls: true
|
||||
});
|
||||
|
||||
// Handle TLS errors
|
||||
tlsSocket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
// Variable to track backend socket
|
||||
let backendSocket: plugins.tls.TLSSocket | null = null;
|
||||
|
||||
// Create cleanup handler for both sockets
|
||||
const handleClose = createSocketCleanupHandler(tlsSocket, backendSocket, (reason) => {
|
||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||
remoteAddress,
|
||||
error: `TLS error: ${error.message}`
|
||||
reason
|
||||
});
|
||||
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// The TLS socket will now emit HTTP traffic that can be processed
|
||||
// In a real implementation, we would create an HTTP parser and handle
|
||||
// the requests here, but for simplicity, we'll just forward the data
|
||||
// Set up error handling with our cleanup utility
|
||||
setupSocketHandlers(tlsSocket, handleClose, undefined, 'tls');
|
||||
|
||||
// Set timeout
|
||||
const timeout = this.getTimeout();
|
||||
tlsSocket.setTimeout(timeout);
|
||||
|
||||
tlsSocket.on('timeout', () => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: 'TLS connection timeout'
|
||||
});
|
||||
handleClose('timeout');
|
||||
});
|
||||
|
||||
// Get the target from configuration
|
||||
const target = this.getTargetFromConfig();
|
||||
|
||||
// Set up the connection to the HTTPS backend
|
||||
const connectToBackend = () => {
|
||||
const backendSocket = plugins.tls.connect({
|
||||
backendSocket = plugins.tls.connect({
|
||||
host: target.host,
|
||||
port: target.port,
|
||||
// In a real implementation, we would configure TLS options
|
||||
@ -127,30 +138,29 @@ export class HttpsTerminateToHttpsHandler extends ForwardingHandler {
|
||||
});
|
||||
|
||||
// Set up bidirectional data flow
|
||||
tlsSocket.pipe(backendSocket);
|
||||
backendSocket.pipe(tlsSocket);
|
||||
tlsSocket.pipe(backendSocket!);
|
||||
backendSocket!.pipe(tlsSocket);
|
||||
});
|
||||
|
||||
// Update the cleanup handler with the backend socket
|
||||
const newHandleClose = createSocketCleanupHandler(tlsSocket, backendSocket, (reason) => {
|
||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||
remoteAddress,
|
||||
reason
|
||||
});
|
||||
});
|
||||
|
||||
// Set up handlers for backend socket
|
||||
setupSocketHandlers(backendSocket, newHandleClose, undefined, 'backend');
|
||||
|
||||
backendSocket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: `Backend connection error: ${error.message}`
|
||||
});
|
||||
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle close
|
||||
backendSocket.on('close', () => {
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Set timeout
|
||||
const timeout = this.getTimeout();
|
||||
// Set timeout for backend socket
|
||||
backendSocket.setTimeout(timeout);
|
||||
|
||||
backendSocket.on('timeout', () => {
|
||||
@ -158,10 +168,7 @@ export class HttpsTerminateToHttpsHandler extends ForwardingHandler {
|
||||
remoteAddress,
|
||||
error: 'Backend connection timeout'
|
||||
});
|
||||
|
||||
if (!backendSocket.destroyed) {
|
||||
backendSocket.destroy();
|
||||
}
|
||||
newHandleClose('backend_timeout');
|
||||
});
|
||||
};
|
||||
|
||||
@ -169,28 +176,6 @@ export class HttpsTerminateToHttpsHandler extends ForwardingHandler {
|
||||
tlsSocket.on('secure', () => {
|
||||
connectToBackend();
|
||||
});
|
||||
|
||||
// Handle close
|
||||
tlsSocket.on('close', () => {
|
||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||
remoteAddress
|
||||
});
|
||||
});
|
||||
|
||||
// Set timeout
|
||||
const timeout = this.getTimeout();
|
||||
tlsSocket.setTimeout(timeout);
|
||||
|
||||
tlsSocket.on('timeout', () => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: 'TLS connection timeout'
|
||||
});
|
||||
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -4,11 +4,12 @@ import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as net from 'net';
|
||||
import * as path from 'path';
|
||||
import * as tls from 'tls';
|
||||
import * as url from 'url';
|
||||
import * as http2 from 'http2';
|
||||
|
||||
export { EventEmitter, fs, http, https, net, tls, url, http2 };
|
||||
export { EventEmitter, fs, http, https, net, path, tls, url, http2 };
|
||||
|
||||
// tsclass scope
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
|
@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { AsyncFileSystem } from '../../core/utils/fs-utils.js';
|
||||
import { type IHttpProxyOptions, type ICertificateEntry, type ILogger, createLogger } from './models/types.js';
|
||||
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
||||
|
||||
@ -17,6 +18,7 @@ export class CertificateManager {
|
||||
private certificateStoreDir: string;
|
||||
private logger: ILogger;
|
||||
private httpsServer: plugins.https.Server | null = null;
|
||||
private initialized = false;
|
||||
|
||||
constructor(private options: IHttpProxyOptions) {
|
||||
this.certificateStoreDir = path.resolve(options.acme?.certificateStore || './certs');
|
||||
@ -24,6 +26,15 @@ export class CertificateManager {
|
||||
|
||||
this.logger.warn('CertificateManager is deprecated - use SmartCertManager instead');
|
||||
|
||||
// Initialize synchronously for backward compatibility but log warning
|
||||
this.initializeSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous initialization for backward compatibility
|
||||
* @deprecated This uses sync filesystem operations which block the event loop
|
||||
*/
|
||||
private initializeSync(): void {
|
||||
// Ensure certificate store directory exists
|
||||
try {
|
||||
if (!fs.existsSync(this.certificateStoreDir)) {
|
||||
@ -36,9 +47,28 @@ export class CertificateManager {
|
||||
|
||||
this.loadDefaultCertificates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Async initialization - preferred method
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Ensure certificate store directory exists
|
||||
try {
|
||||
await AsyncFileSystem.ensureDir(this.certificateStoreDir);
|
||||
this.logger.info(`Ensured certificate store directory: ${this.certificateStoreDir}`);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to create certificate store directory: ${error}`);
|
||||
}
|
||||
|
||||
await this.loadDefaultCertificatesAsync();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads default certificates from the filesystem
|
||||
* @deprecated This uses sync filesystem operations which block the event loop
|
||||
*/
|
||||
public loadDefaultCertificates(): void {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@ -49,7 +79,28 @@ export class CertificateManager {
|
||||
key: fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8'),
|
||||
cert: fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8')
|
||||
};
|
||||
this.logger.info('Loaded default certificates from filesystem');
|
||||
this.logger.info('Loaded default certificates from filesystem (sync - deprecated)');
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to load default certificates: ${error}`);
|
||||
this.generateSelfSignedCertificate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads default certificates from the filesystem asynchronously
|
||||
*/
|
||||
public async loadDefaultCertificatesAsync(): Promise<void> {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const certPath = path.join(__dirname, '..', '..', '..', 'assets', 'certs');
|
||||
|
||||
try {
|
||||
const [key, cert] = await Promise.all([
|
||||
AsyncFileSystem.readFile(path.join(certPath, 'key.pem')),
|
||||
AsyncFileSystem.readFile(path.join(certPath, 'cert.pem'))
|
||||
]);
|
||||
|
||||
this.defaultCertificates = { key, cert };
|
||||
this.logger.info('Loaded default certificates from filesystem (async)');
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to load default certificates: ${error}`);
|
||||
this.generateSelfSignedCertificate();
|
||||
|
@ -1,5 +1,6 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { type IHttpProxyOptions, type IConnectionEntry, type ILogger, createLogger } from './models/types.js';
|
||||
import { cleanupSocket } from '../../core/utils/socket-utils.js';
|
||||
|
||||
/**
|
||||
* Manages a pool of backend connections for efficient reuse
|
||||
@ -133,14 +134,7 @@ export class ConnectionPool {
|
||||
if ((connection.isIdle && now - connection.lastUsed > idleTimeout) ||
|
||||
connections.length > (this.options.connectionPoolSize || 50)) {
|
||||
|
||||
try {
|
||||
if (!connection.socket.destroyed) {
|
||||
connection.socket.end();
|
||||
connection.socket.destroy();
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Error destroying pooled connection to ${host}`, err);
|
||||
}
|
||||
cleanupSocket(connection.socket, `pool-${host}-idle`, { immediate: true }).catch(() => {});
|
||||
|
||||
connections.shift(); // Remove from pool
|
||||
removed++;
|
||||
@ -170,14 +164,7 @@ export class ConnectionPool {
|
||||
this.logger.debug(`Closing ${connections.length} connections to ${host}`);
|
||||
|
||||
for (const connection of connections) {
|
||||
try {
|
||||
if (!connection.socket.destroyed) {
|
||||
connection.socket.end();
|
||||
connection.socket.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error closing connection to ${host}:`, error);
|
||||
}
|
||||
cleanupSocket(connection.socket, `pool-${host}-close`, { immediate: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -18,6 +18,7 @@ import { RequestHandler, type IMetricsTracker } from './request-handler.js';
|
||||
import { WebSocketHandler } from './websocket-handler.js';
|
||||
import { ProxyRouter } from '../../routing/router/index.js';
|
||||
import { RouteRouter } from '../../routing/router/route-router.js';
|
||||
import { cleanupSocket } from '../../core/utils/socket-utils.js';
|
||||
import { FunctionCache } from './function-cache.js';
|
||||
|
||||
/**
|
||||
@ -519,13 +520,10 @@ export class HttpProxy implements IMetricsTracker {
|
||||
this.webSocketHandler.shutdown();
|
||||
|
||||
// Close all tracked sockets
|
||||
for (const socket of this.socketMap.getArray()) {
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (error) {
|
||||
this.logger.error('Error destroying socket', error);
|
||||
}
|
||||
}
|
||||
const socketCleanupPromises = this.socketMap.getArray().map(socket =>
|
||||
cleanupSocket(socket, 'http-proxy-stop', { immediate: true })
|
||||
);
|
||||
await Promise.all(socketCleanupPromises);
|
||||
|
||||
// Close all connection pool connections
|
||||
this.connectionPool.closeAllConnections();
|
||||
|
@ -3,6 +3,8 @@ import { promisify } from 'util';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { delay } from '../../core/utils/async-utils.js';
|
||||
import { AsyncFileSystem } from '../../core/utils/fs-utils.js';
|
||||
import {
|
||||
NftBaseError,
|
||||
NftValidationError,
|
||||
@ -208,7 +210,7 @@ export class NfTablesProxy {
|
||||
|
||||
// Wait before retry, unless it's the last attempt
|
||||
if (i < maxRetries - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
|
||||
await delay(retryDelayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -218,8 +220,13 @@ export class NfTablesProxy {
|
||||
|
||||
/**
|
||||
* Execute system command synchronously with multiple attempts
|
||||
* @deprecated This method blocks the event loop and should be avoided. Use executeWithRetry instead.
|
||||
* WARNING: This method contains a busy wait loop that will block the entire Node.js event loop!
|
||||
*/
|
||||
private executeWithRetrySync(command: string, maxRetries = 3, retryDelayMs = 1000): string {
|
||||
// Log deprecation warning
|
||||
console.warn('[DEPRECATION WARNING] executeWithRetrySync blocks the event loop and should not be used. Consider using the async executeWithRetry method instead.');
|
||||
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
@ -231,10 +238,12 @@ export class NfTablesProxy {
|
||||
|
||||
// Wait before retry, unless it's the last attempt
|
||||
if (i < maxRetries - 1) {
|
||||
// A naive sleep in sync context
|
||||
// CRITICAL: This busy wait loop blocks the entire event loop!
|
||||
// This is a temporary fallback for sync contexts only.
|
||||
// TODO: Remove this method entirely and make all callers async
|
||||
const waitUntil = Date.now() + retryDelayMs;
|
||||
while (Date.now() < waitUntil) {
|
||||
// busy wait - not great, but this is a fallback method
|
||||
// Busy wait - blocks event loop
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -243,6 +252,26 @@ export class NfTablesProxy {
|
||||
throw new NftExecutionError(`Failed after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute nftables commands with a temporary file
|
||||
* This helper handles the common pattern of writing rules to a temp file,
|
||||
* executing nftables with the file, and cleaning up
|
||||
*/
|
||||
private async executeWithTempFile(rulesetContent: string): Promise<void> {
|
||||
await AsyncFileSystem.writeFile(this.tempFilePath, rulesetContent);
|
||||
|
||||
try {
|
||||
await this.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
);
|
||||
} finally {
|
||||
// Always clean up the temp file
|
||||
await AsyncFileSystem.remove(this.tempFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if nftables is available and the required modules are loaded
|
||||
*/
|
||||
@ -545,15 +574,8 @@ export class NfTablesProxy {
|
||||
|
||||
// Only write and apply if we have rules to add
|
||||
if (rulesetContent) {
|
||||
// Write the ruleset to a temporary file
|
||||
fs.writeFileSync(this.tempFilePath, rulesetContent);
|
||||
|
||||
// Apply the ruleset
|
||||
await this.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
);
|
||||
// Apply the ruleset using the helper
|
||||
await this.executeWithTempFile(rulesetContent);
|
||||
|
||||
this.log('info', `Added source IP filter rules for ${family}`);
|
||||
|
||||
@ -566,9 +588,6 @@ export class NfTablesProxy {
|
||||
await this.verifyRuleApplication(rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the temporary file
|
||||
fs.unlinkSync(this.tempFilePath);
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -663,13 +682,7 @@ export class NfTablesProxy {
|
||||
|
||||
// Apply the rules if we have any
|
||||
if (rulesetContent) {
|
||||
fs.writeFileSync(this.tempFilePath, rulesetContent);
|
||||
|
||||
await this.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
);
|
||||
await this.executeWithTempFile(rulesetContent);
|
||||
|
||||
this.log('info', `Added advanced NAT rules for ${family}`);
|
||||
|
||||
@ -682,9 +695,6 @@ export class NfTablesProxy {
|
||||
await this.verifyRuleApplication(rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the temporary file
|
||||
fs.unlinkSync(this.tempFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
@ -816,15 +826,8 @@ export class NfTablesProxy {
|
||||
|
||||
// Apply the ruleset if we have any rules
|
||||
if (rulesetContent) {
|
||||
// Write to temporary file
|
||||
fs.writeFileSync(this.tempFilePath, rulesetContent);
|
||||
|
||||
// Apply the ruleset
|
||||
await this.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
);
|
||||
// Apply the ruleset using the helper
|
||||
await this.executeWithTempFile(rulesetContent);
|
||||
|
||||
this.log('info', `Added port forwarding rules for ${family}`);
|
||||
|
||||
@ -837,9 +840,6 @@ export class NfTablesProxy {
|
||||
await this.verifyRuleApplication(rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove temporary file
|
||||
fs.unlinkSync(this.tempFilePath);
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -931,15 +931,7 @@ export class NfTablesProxy {
|
||||
|
||||
// Apply the ruleset if we have any rules
|
||||
if (rulesetContent) {
|
||||
// Write to temporary file
|
||||
fs.writeFileSync(this.tempFilePath, rulesetContent);
|
||||
|
||||
// Apply the ruleset
|
||||
await this.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
);
|
||||
await this.executeWithTempFile(rulesetContent);
|
||||
|
||||
this.log('info', `Added port forwarding rules for ${family}`);
|
||||
|
||||
@ -952,9 +944,6 @@ export class NfTablesProxy {
|
||||
await this.verifyRuleApplication(rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove temporary file
|
||||
fs.unlinkSync(this.tempFilePath);
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -1027,15 +1016,8 @@ export class NfTablesProxy {
|
||||
|
||||
// Apply the ruleset if we have any rules
|
||||
if (rulesetContent) {
|
||||
// Write to temporary file
|
||||
fs.writeFileSync(this.tempFilePath, rulesetContent);
|
||||
|
||||
// Apply the ruleset
|
||||
await this.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
);
|
||||
// Apply the ruleset using the helper
|
||||
await this.executeWithTempFile(rulesetContent);
|
||||
|
||||
this.log('info', `Added QoS rules for ${family}`);
|
||||
|
||||
@ -1048,9 +1030,6 @@ export class NfTablesProxy {
|
||||
await this.verifyRuleApplication(rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove temporary file
|
||||
fs.unlinkSync(this.tempFilePath);
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -1615,25 +1594,27 @@ export class NfTablesProxy {
|
||||
// Apply the ruleset if we have any rules to delete
|
||||
if (rulesetContent) {
|
||||
// Write to temporary file
|
||||
fs.writeFileSync(this.tempFilePath, rulesetContent);
|
||||
await AsyncFileSystem.writeFile(this.tempFilePath, rulesetContent);
|
||||
|
||||
// Apply the ruleset
|
||||
await this.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
);
|
||||
|
||||
this.log('info', 'Removed all added rules');
|
||||
|
||||
// Mark all rules as removed
|
||||
this.rules.forEach(rule => {
|
||||
rule.added = false;
|
||||
rule.verified = false;
|
||||
});
|
||||
|
||||
// Remove temporary file
|
||||
fs.unlinkSync(this.tempFilePath);
|
||||
try {
|
||||
// Apply the ruleset
|
||||
await this.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
);
|
||||
|
||||
this.log('info', 'Removed all added rules');
|
||||
|
||||
// Mark all rules as removed
|
||||
this.rules.forEach(rule => {
|
||||
rule.added = false;
|
||||
rule.verified = false;
|
||||
});
|
||||
} finally {
|
||||
// Remove temporary file
|
||||
await AsyncFileSystem.remove(this.tempFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up IP sets if we created any
|
||||
@ -1862,8 +1843,12 @@ export class NfTablesProxy {
|
||||
|
||||
/**
|
||||
* Synchronous version of cleanSlate
|
||||
* @deprecated This method blocks the event loop and should be avoided. Use cleanSlate() instead.
|
||||
* WARNING: This method uses execSync which blocks the entire Node.js event loop!
|
||||
*/
|
||||
public static cleanSlateSync(): void {
|
||||
console.warn('[DEPRECATION WARNING] cleanSlateSync blocks the event loop and should not be used. Consider using the async cleanSlate() method instead.');
|
||||
|
||||
try {
|
||||
// Check for rules with our comment pattern
|
||||
const stdout = execSync(`${NfTablesProxy.NFT_CMD} list ruleset`).toString();
|
||||
|
@ -1,36 +1,34 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { AsyncFileSystem } from '../../core/utils/fs-utils.js';
|
||||
import type { ICertificateData } from './certificate-manager.js';
|
||||
|
||||
export class CertStore {
|
||||
constructor(private certDir: string) {}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
await plugins.smartfile.fs.ensureDirSync(this.certDir);
|
||||
await AsyncFileSystem.ensureDir(this.certDir);
|
||||
}
|
||||
|
||||
public async getCertificate(routeName: string): Promise<ICertificateData | null> {
|
||||
const certPath = this.getCertPath(routeName);
|
||||
const metaPath = `${certPath}/meta.json`;
|
||||
|
||||
if (!await plugins.smartfile.fs.fileExistsSync(metaPath)) {
|
||||
if (!await AsyncFileSystem.exists(metaPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const metaFile = await plugins.smartfile.SmartFile.fromFilePath(metaPath);
|
||||
const meta = JSON.parse(metaFile.contents.toString());
|
||||
const meta = await AsyncFileSystem.readJSON(metaPath);
|
||||
|
||||
const certFile = await plugins.smartfile.SmartFile.fromFilePath(`${certPath}/cert.pem`);
|
||||
const cert = certFile.contents.toString();
|
||||
|
||||
const keyFile = await plugins.smartfile.SmartFile.fromFilePath(`${certPath}/key.pem`);
|
||||
const key = keyFile.contents.toString();
|
||||
const [cert, key] = await Promise.all([
|
||||
AsyncFileSystem.readFile(`${certPath}/cert.pem`),
|
||||
AsyncFileSystem.readFile(`${certPath}/key.pem`)
|
||||
]);
|
||||
|
||||
let ca: string | undefined;
|
||||
const caPath = `${certPath}/ca.pem`;
|
||||
if (await plugins.smartfile.fs.fileExistsSync(caPath)) {
|
||||
const caFile = await plugins.smartfile.SmartFile.fromFilePath(caPath);
|
||||
ca = caFile.contents.toString();
|
||||
if (await AsyncFileSystem.exists(caPath)) {
|
||||
ca = await AsyncFileSystem.readFile(caPath);
|
||||
}
|
||||
|
||||
return {
|
||||
@ -51,14 +49,18 @@ export class CertStore {
|
||||
certData: ICertificateData
|
||||
): Promise<void> {
|
||||
const certPath = this.getCertPath(routeName);
|
||||
await plugins.smartfile.fs.ensureDirSync(certPath);
|
||||
await AsyncFileSystem.ensureDir(certPath);
|
||||
|
||||
// Save certificate files
|
||||
await plugins.smartfile.memory.toFs(certData.cert, `${certPath}/cert.pem`);
|
||||
await plugins.smartfile.memory.toFs(certData.key, `${certPath}/key.pem`);
|
||||
// Save certificate files in parallel
|
||||
const savePromises = [
|
||||
AsyncFileSystem.writeFile(`${certPath}/cert.pem`, certData.cert),
|
||||
AsyncFileSystem.writeFile(`${certPath}/key.pem`, certData.key)
|
||||
];
|
||||
|
||||
if (certData.ca) {
|
||||
await plugins.smartfile.memory.toFs(certData.ca, `${certPath}/ca.pem`);
|
||||
savePromises.push(
|
||||
AsyncFileSystem.writeFile(`${certPath}/ca.pem`, certData.ca)
|
||||
);
|
||||
}
|
||||
|
||||
// Save metadata
|
||||
@ -68,13 +70,17 @@ export class CertStore {
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
await plugins.smartfile.memory.toFs(JSON.stringify(meta, null, 2), `${certPath}/meta.json`);
|
||||
savePromises.push(
|
||||
AsyncFileSystem.writeJSON(`${certPath}/meta.json`, meta)
|
||||
);
|
||||
|
||||
await Promise.all(savePromises);
|
||||
}
|
||||
|
||||
public async deleteCertificate(routeName: string): Promise<void> {
|
||||
const certPath = this.getCertPath(routeName);
|
||||
if (await plugins.smartfile.fs.fileExistsSync(certPath)) {
|
||||
await plugins.smartfile.fs.removeManySync([certPath]);
|
||||
if (await AsyncFileSystem.isDirectory(certPath)) {
|
||||
await AsyncFileSystem.removeDir(certPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,22 +3,45 @@ import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.
|
||||
import { SecurityManager } from './security-manager.js';
|
||||
import { TimeoutManager } from './timeout-manager.js';
|
||||
import { logger } from '../../core/utils/logger.js';
|
||||
import { LifecycleComponent } from '../../core/utils/lifecycle-component.js';
|
||||
import { cleanupSocket } from '../../core/utils/socket-utils.js';
|
||||
|
||||
/**
|
||||
* Manages connection lifecycle, tracking, and cleanup
|
||||
* Manages connection lifecycle, tracking, and cleanup with performance optimizations
|
||||
*/
|
||||
export class ConnectionManager {
|
||||
export class ConnectionManager extends LifecycleComponent {
|
||||
private connectionRecords: Map<string, IConnectionRecord> = new Map();
|
||||
private terminationStats: {
|
||||
incoming: Record<string, number>;
|
||||
outgoing: Record<string, number>;
|
||||
} = { incoming: {}, outgoing: {} };
|
||||
|
||||
// Performance optimization: Track connections needing inactivity check
|
||||
private nextInactivityCheck: Map<string, number> = new Map();
|
||||
|
||||
// Connection limits
|
||||
private readonly maxConnections: number;
|
||||
private readonly cleanupBatchSize: number = 100;
|
||||
|
||||
// Cleanup queue for batched processing
|
||||
private cleanupQueue: Set<string> = new Set();
|
||||
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private settings: ISmartProxyOptions,
|
||||
private securityManager: SecurityManager,
|
||||
private timeoutManager: TimeoutManager
|
||||
) {}
|
||||
) {
|
||||
super();
|
||||
|
||||
// Set reasonable defaults for connection limits
|
||||
this.maxConnections = settings.defaults?.security?.maxConnections || 10000;
|
||||
|
||||
// Start inactivity check timer if not disabled
|
||||
if (!settings.disableInactivityCheck) {
|
||||
this.startInactivityCheckTimer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique connection ID
|
||||
@ -31,17 +54,29 @@ export class ConnectionManager {
|
||||
/**
|
||||
* Create and track a new connection
|
||||
*/
|
||||
public createConnection(socket: plugins.net.Socket): IConnectionRecord {
|
||||
public createConnection(socket: plugins.net.Socket): IConnectionRecord | null {
|
||||
// Enforce connection limit
|
||||
if (this.connectionRecords.size >= this.maxConnections) {
|
||||
logger.log('warn', `Connection limit reached (${this.maxConnections}). Rejecting new connection.`, {
|
||||
currentConnections: this.connectionRecords.size,
|
||||
maxConnections: this.maxConnections,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
socket.destroy();
|
||||
return null;
|
||||
}
|
||||
|
||||
const connectionId = this.generateConnectionId();
|
||||
const remoteIP = socket.remoteAddress || '';
|
||||
const localPort = socket.localPort || 0;
|
||||
const now = Date.now();
|
||||
|
||||
const record: IConnectionRecord = {
|
||||
id: connectionId,
|
||||
incoming: socket,
|
||||
outgoing: null,
|
||||
incomingStartTime: Date.now(),
|
||||
lastActivity: Date.now(),
|
||||
incomingStartTime: now,
|
||||
lastActivity: now,
|
||||
connectionClosed: false,
|
||||
pendingData: [],
|
||||
pendingDataSize: 0,
|
||||
@ -70,6 +105,42 @@ export class ConnectionManager {
|
||||
public trackConnection(connectionId: string, record: IConnectionRecord): void {
|
||||
this.connectionRecords.set(connectionId, record);
|
||||
this.securityManager.trackConnectionByIP(record.remoteIP, connectionId);
|
||||
|
||||
// Schedule inactivity check
|
||||
if (!this.settings.disableInactivityCheck) {
|
||||
this.scheduleInactivityCheck(connectionId, record);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule next inactivity check for a connection
|
||||
*/
|
||||
private scheduleInactivityCheck(connectionId: string, record: IConnectionRecord): void {
|
||||
let timeout = this.settings.inactivityTimeout!;
|
||||
|
||||
if (record.hasKeepAlive) {
|
||||
if (this.settings.keepAliveTreatment === 'immortal') {
|
||||
// Don't schedule check for immortal connections
|
||||
return;
|
||||
} else if (this.settings.keepAliveTreatment === 'extended') {
|
||||
const multiplier = this.settings.keepAliveInactivityMultiplier || 6;
|
||||
timeout = timeout * multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
const checkTime = Date.now() + timeout;
|
||||
this.nextInactivityCheck.set(connectionId, checkTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the inactivity check timer
|
||||
*/
|
||||
private startInactivityCheckTimer(): void {
|
||||
// Check every 30 seconds for connections that need inactivity check
|
||||
this.setInterval(() => {
|
||||
this.performOptimizedInactivityCheck();
|
||||
}, 30000);
|
||||
// Note: LifecycleComponent's setInterval already calls unref()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -98,18 +169,65 @@ export class ConnectionManager {
|
||||
*/
|
||||
public initiateCleanupOnce(record: IConnectionRecord, reason: string = 'normal'): void {
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
logger.log('info', `Connection cleanup initiated`, { connectionId: record.id, remoteIP: record.remoteIP, reason, component: 'connection-manager' });
|
||||
logger.log('info', `Connection cleanup initiated`, {
|
||||
connectionId: record.id,
|
||||
remoteIP: record.remoteIP,
|
||||
reason,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
record.incomingTerminationReason === null ||
|
||||
record.incomingTerminationReason === undefined
|
||||
) {
|
||||
if (record.incomingTerminationReason == null) {
|
||||
record.incomingTerminationReason = reason;
|
||||
this.incrementTerminationStat('incoming', reason);
|
||||
}
|
||||
|
||||
this.cleanupConnection(record, reason);
|
||||
// Add to cleanup queue for batched processing
|
||||
this.queueCleanup(record.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a connection for cleanup
|
||||
*/
|
||||
private queueCleanup(connectionId: string): void {
|
||||
this.cleanupQueue.add(connectionId);
|
||||
|
||||
// Process immediately if queue is getting large
|
||||
if (this.cleanupQueue.size >= this.cleanupBatchSize) {
|
||||
this.processCleanupQueue();
|
||||
} else if (!this.cleanupTimer) {
|
||||
// Otherwise, schedule batch processing
|
||||
this.cleanupTimer = this.setTimeout(() => {
|
||||
this.processCleanupQueue();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the cleanup queue in batches
|
||||
*/
|
||||
private processCleanupQueue(): void {
|
||||
if (this.cleanupTimer) {
|
||||
this.clearTimeout(this.cleanupTimer);
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
|
||||
const toCleanup = Array.from(this.cleanupQueue).slice(0, this.cleanupBatchSize);
|
||||
this.cleanupQueue.clear();
|
||||
|
||||
for (const connectionId of toCleanup) {
|
||||
const record = this.connectionRecords.get(connectionId);
|
||||
if (record) {
|
||||
this.cleanupConnection(record, record.incomingTerminationReason || 'normal');
|
||||
}
|
||||
}
|
||||
|
||||
// If there are more in queue, schedule next batch
|
||||
if (this.cleanupQueue.size > 0) {
|
||||
this.cleanupTimer = this.setTimeout(() => {
|
||||
this.processCleanupQueue();
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -119,6 +237,9 @@ export class ConnectionManager {
|
||||
if (!record.connectionClosed) {
|
||||
record.connectionClosed = true;
|
||||
|
||||
// Remove from inactivity check
|
||||
this.nextInactivityCheck.delete(record.id);
|
||||
|
||||
// Track connection termination
|
||||
this.securityManager.removeConnectionByIP(record.remoteIP, record.id);
|
||||
|
||||
@ -127,30 +248,67 @@ export class ConnectionManager {
|
||||
record.cleanupTimer = undefined;
|
||||
}
|
||||
|
||||
// Detailed logging data
|
||||
// Calculate metrics once
|
||||
const duration = Date.now() - record.incomingStartTime;
|
||||
const bytesReceived = record.bytesReceived;
|
||||
const bytesSent = record.bytesSent;
|
||||
const logData = {
|
||||
connectionId: record.id,
|
||||
remoteIP: record.remoteIP,
|
||||
localPort: record.localPort,
|
||||
reason,
|
||||
duration: plugins.prettyMs(duration),
|
||||
bytes: { in: record.bytesReceived, out: record.bytesSent },
|
||||
tls: record.isTLS,
|
||||
keepAlive: record.hasKeepAlive,
|
||||
usingNetworkProxy: record.usingNetworkProxy,
|
||||
domainSwitches: record.domainSwitches || 0,
|
||||
component: 'connection-manager'
|
||||
};
|
||||
|
||||
// Remove all data handlers to make sure we clean up properly
|
||||
if (record.incoming) {
|
||||
try {
|
||||
// Remove our safe data handler
|
||||
record.incoming.removeAllListeners('data');
|
||||
// Reset the handler references
|
||||
record.renegotiationHandler = undefined;
|
||||
} catch (err) {
|
||||
logger.log('error', `Error removing data handlers for connection ${record.id}: ${err}`, { connectionId: record.id, error: err, component: 'connection-manager' });
|
||||
logger.log('error', `Error removing data handlers: ${err}`, {
|
||||
connectionId: record.id,
|
||||
error: err,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming socket
|
||||
this.cleanupSocket(record, 'incoming', record.incoming);
|
||||
// Handle socket cleanup - check if sockets are still active
|
||||
const cleanupPromises: Promise<void>[] = [];
|
||||
|
||||
// Handle outgoing socket
|
||||
if (record.outgoing) {
|
||||
this.cleanupSocket(record, 'outgoing', record.outgoing);
|
||||
if (record.incoming) {
|
||||
if (!record.incoming.writable || record.incoming.destroyed) {
|
||||
// Socket is not active, clean up immediately
|
||||
cleanupPromises.push(cleanupSocket(record.incoming, `${record.id}-incoming`, { immediate: true }));
|
||||
} else {
|
||||
// Socket is still active, allow graceful cleanup
|
||||
cleanupPromises.push(cleanupSocket(record.incoming, `${record.id}-incoming`, { allowDrain: true, gracePeriod: 5000 }));
|
||||
}
|
||||
}
|
||||
|
||||
if (record.outgoing) {
|
||||
if (!record.outgoing.writable || record.outgoing.destroyed) {
|
||||
// Socket is not active, clean up immediately
|
||||
cleanupPromises.push(cleanupSocket(record.outgoing, `${record.id}-outgoing`, { immediate: true }));
|
||||
} else {
|
||||
// Socket is still active, allow graceful cleanup
|
||||
cleanupPromises.push(cleanupSocket(record.outgoing, `${record.id}-outgoing`, { allowDrain: true, gracePeriod: 5000 }));
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for cleanup to complete
|
||||
Promise.all(cleanupPromises).catch(err => {
|
||||
logger.log('error', `Error during socket cleanup: ${err}`, {
|
||||
connectionId: record.id,
|
||||
error: err,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
});
|
||||
|
||||
// Clear pendingData to avoid memory leaks
|
||||
record.pendingData = [];
|
||||
@ -162,28 +320,13 @@ export class ConnectionManager {
|
||||
// Log connection details
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
logger.log('info',
|
||||
`Connection from ${record.remoteIP} on port ${record.localPort} terminated (${reason}). ` +
|
||||
`Duration: ${plugins.prettyMs(duration)}, Bytes IN: ${bytesReceived}, OUT: ${bytesSent}, ` +
|
||||
`TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${record.hasKeepAlive ? 'Yes' : 'No'}` +
|
||||
`${record.usingNetworkProxy ? ', Using NetworkProxy' : ''}` +
|
||||
`${record.domainSwitches ? `, Domain switches: ${record.domainSwitches}` : ''}`,
|
||||
{
|
||||
connectionId: record.id,
|
||||
remoteIP: record.remoteIP,
|
||||
localPort: record.localPort,
|
||||
reason,
|
||||
duration: plugins.prettyMs(duration),
|
||||
bytes: { in: bytesReceived, out: bytesSent },
|
||||
tls: record.isTLS,
|
||||
keepAlive: record.hasKeepAlive,
|
||||
usingNetworkProxy: record.usingNetworkProxy,
|
||||
domainSwitches: record.domainSwitches || 0,
|
||||
component: 'connection-manager'
|
||||
}
|
||||
`Connection terminated: ${record.remoteIP}:${record.localPort} (${reason}) - ` +
|
||||
`${plugins.prettyMs(duration)}, IN: ${record.bytesReceived}B, OUT: ${record.bytesSent}B`,
|
||||
logData
|
||||
);
|
||||
} else {
|
||||
logger.log('info',
|
||||
`Connection from ${record.remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}`,
|
||||
`Connection terminated: ${record.remoteIP} (${reason}). Active: ${this.connectionRecords.size}`,
|
||||
{
|
||||
connectionId: record.id,
|
||||
remoteIP: record.remoteIP,
|
||||
@ -196,40 +339,6 @@ export class ConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to clean up a socket
|
||||
*/
|
||||
private cleanupSocket(record: IConnectionRecord, side: 'incoming' | 'outgoing', socket: plugins.net.Socket): void {
|
||||
try {
|
||||
if (!socket.destroyed) {
|
||||
// Try graceful shutdown first, then force destroy after a short timeout
|
||||
socket.end();
|
||||
const socketTimeout = setTimeout(() => {
|
||||
try {
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('error', `Error destroying ${side} socket for connection ${record.id}: ${err}`, { connectionId: record.id, side, error: err, component: 'connection-manager' });
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Ensure the timeout doesn't block Node from exiting
|
||||
if (socketTimeout.unref) {
|
||||
socketTimeout.unref();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('error', `Error closing ${side} socket for connection ${record.id}: ${err}`, { connectionId: record.id, side, error: err, component: 'connection-manager' });
|
||||
try {
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
} catch (destroyErr) {
|
||||
logger.log('error', `Error destroying ${side} socket for connection ${record.id}: ${destroyErr}`, { connectionId: record.id, side, error: destroyErr, component: 'connection-manager' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a generic error handler for incoming or outgoing sockets
|
||||
@ -238,49 +347,44 @@ export class ConnectionManager {
|
||||
return (err: Error) => {
|
||||
const code = (err as any).code;
|
||||
let reason = 'error';
|
||||
|
||||
|
||||
const now = Date.now();
|
||||
const connectionDuration = now - record.incomingStartTime;
|
||||
const lastActivityAge = now - record.lastActivity;
|
||||
|
||||
if (code === 'ECONNRESET') {
|
||||
reason = 'econnreset';
|
||||
logger.log('warn', `ECONNRESET on ${side} connection from ${record.remoteIP}. Error: ${err.message}. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)}`, {
|
||||
connectionId: record.id,
|
||||
side,
|
||||
remoteIP: record.remoteIP,
|
||||
error: err.message,
|
||||
duration: plugins.prettyMs(connectionDuration),
|
||||
lastActivity: plugins.prettyMs(lastActivityAge),
|
||||
component: 'connection-manager'
|
||||
});
|
||||
} else if (code === 'ETIMEDOUT') {
|
||||
reason = 'etimedout';
|
||||
logger.log('warn', `ETIMEDOUT on ${side} connection from ${record.remoteIP}. Error: ${err.message}. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)}`, {
|
||||
connectionId: record.id,
|
||||
side,
|
||||
remoteIP: record.remoteIP,
|
||||
error: err.message,
|
||||
duration: plugins.prettyMs(connectionDuration),
|
||||
lastActivity: plugins.prettyMs(lastActivityAge),
|
||||
component: 'connection-manager'
|
||||
});
|
||||
} else {
|
||||
logger.log('error', `Error on ${side} connection from ${record.remoteIP}: ${err.message}. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)}`, {
|
||||
connectionId: record.id,
|
||||
side,
|
||||
remoteIP: record.remoteIP,
|
||||
error: err.message,
|
||||
duration: plugins.prettyMs(connectionDuration),
|
||||
lastActivity: plugins.prettyMs(lastActivityAge),
|
||||
component: 'connection-manager'
|
||||
});
|
||||
// Update activity tracking
|
||||
if (side === 'incoming') {
|
||||
record.lastActivity = now;
|
||||
this.scheduleInactivityCheck(record.id, record);
|
||||
}
|
||||
|
||||
if (side === 'incoming' && record.incomingTerminationReason === null) {
|
||||
const errorData = {
|
||||
connectionId: record.id,
|
||||
side,
|
||||
remoteIP: record.remoteIP,
|
||||
error: err.message,
|
||||
duration: plugins.prettyMs(connectionDuration),
|
||||
lastActivity: plugins.prettyMs(lastActivityAge),
|
||||
component: 'connection-manager'
|
||||
};
|
||||
|
||||
switch (code) {
|
||||
case 'ECONNRESET':
|
||||
reason = 'econnreset';
|
||||
logger.log('warn', `ECONNRESET on ${side}: ${record.remoteIP}`, errorData);
|
||||
break;
|
||||
case 'ETIMEDOUT':
|
||||
reason = 'etimedout';
|
||||
logger.log('warn', `ETIMEDOUT on ${side}: ${record.remoteIP}`, errorData);
|
||||
break;
|
||||
default:
|
||||
logger.log('error', `Error on ${side}: ${record.remoteIP} - ${err.message}`, errorData);
|
||||
}
|
||||
|
||||
if (side === 'incoming' && record.incomingTerminationReason == null) {
|
||||
record.incomingTerminationReason = reason;
|
||||
this.incrementTerminationStat('incoming', reason);
|
||||
} else if (side === 'outgoing' && record.outgoingTerminationReason === null) {
|
||||
} else if (side === 'outgoing' && record.outgoingTerminationReason == null) {
|
||||
record.outgoingTerminationReason = reason;
|
||||
this.incrementTerminationStat('outgoing', reason);
|
||||
}
|
||||
@ -303,13 +407,12 @@ export class ConnectionManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (side === 'incoming' && record.incomingTerminationReason === null) {
|
||||
if (side === 'incoming' && record.incomingTerminationReason == null) {
|
||||
record.incomingTerminationReason = 'normal';
|
||||
this.incrementTerminationStat('incoming', 'normal');
|
||||
} else if (side === 'outgoing' && record.outgoingTerminationReason === null) {
|
||||
} else if (side === 'outgoing' && record.outgoingTerminationReason == null) {
|
||||
record.outgoingTerminationReason = 'normal';
|
||||
this.incrementTerminationStat('outgoing', 'normal');
|
||||
// Record the time when outgoing socket closed.
|
||||
record.outgoingClosedTime = Date.now();
|
||||
}
|
||||
|
||||
@ -332,26 +435,29 @@ export class ConnectionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for stalled/inactive connections
|
||||
* Optimized inactivity check - only checks connections that are due
|
||||
*/
|
||||
public performInactivityCheck(): void {
|
||||
private performOptimizedInactivityCheck(): void {
|
||||
const now = Date.now();
|
||||
const connectionIds = [...this.connectionRecords.keys()];
|
||||
const connectionsToCheck: string[] = [];
|
||||
|
||||
for (const id of connectionIds) {
|
||||
const record = this.connectionRecords.get(id);
|
||||
if (!record) continue;
|
||||
|
||||
// Skip inactivity check if disabled or for immortal keep-alive connections
|
||||
if (
|
||||
this.settings.disableInactivityCheck ||
|
||||
(record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal')
|
||||
) {
|
||||
// Find connections that need checking
|
||||
for (const [connectionId, checkTime] of this.nextInactivityCheck) {
|
||||
if (checkTime <= now) {
|
||||
connectionsToCheck.push(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Process only connections that need checking
|
||||
for (const connectionId of connectionsToCheck) {
|
||||
const record = this.connectionRecords.get(connectionId);
|
||||
if (!record || record.connectionClosed) {
|
||||
this.nextInactivityCheck.delete(connectionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const inactivityTime = now - record.lastActivity;
|
||||
|
||||
|
||||
// Use extended timeout for extended-treatment keep-alive connections
|
||||
let effectiveTimeout = this.settings.inactivityTimeout!;
|
||||
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
|
||||
@ -359,37 +465,37 @@ export class ConnectionManager {
|
||||
effectiveTimeout = effectiveTimeout * multiplier;
|
||||
}
|
||||
|
||||
if (inactivityTime > effectiveTimeout && !record.connectionClosed) {
|
||||
if (inactivityTime > effectiveTimeout) {
|
||||
// For keep-alive connections, issue a warning first
|
||||
if (record.hasKeepAlive && !record.inactivityWarningIssued) {
|
||||
logger.log('warn', `Keep-alive connection ${id} from ${record.remoteIP} inactive for ${plugins.prettyMs(inactivityTime)}. Will close in 10 minutes if no activity.`, {
|
||||
connectionId: id,
|
||||
logger.log('warn', `Keep-alive connection inactive: ${record.remoteIP}`, {
|
||||
connectionId,
|
||||
remoteIP: record.remoteIP,
|
||||
inactiveFor: plugins.prettyMs(inactivityTime),
|
||||
closureWarning: '10 minutes',
|
||||
component: 'connection-manager'
|
||||
});
|
||||
|
||||
// Set warning flag and add grace period
|
||||
record.inactivityWarningIssued = true;
|
||||
record.lastActivity = now - (effectiveTimeout - 600000);
|
||||
|
||||
// Reschedule check for 10 minutes later
|
||||
this.nextInactivityCheck.set(connectionId, now + 600000);
|
||||
|
||||
// Try to stimulate activity with a probe packet
|
||||
if (record.outgoing && !record.outgoing.destroyed) {
|
||||
try {
|
||||
record.outgoing.write(Buffer.alloc(0));
|
||||
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
logger.log('info', `Sent probe packet to test keep-alive connection ${id}`, { connectionId: id, component: 'connection-manager' });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('error', `Error sending probe packet to connection ${id}: ${err}`, { connectionId: id, error: err, component: 'connection-manager' });
|
||||
logger.log('error', `Error sending probe packet: ${err}`, {
|
||||
connectionId,
|
||||
error: err,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For non-keep-alive or after warning, close the connection
|
||||
logger.log('warn', `Closing inactive connection ${id} from ${record.remoteIP} (inactive for ${plugins.prettyMs(inactivityTime)}, keep-alive: ${record.hasKeepAlive ? 'Yes' : 'No'})`, {
|
||||
connectionId: id,
|
||||
// Close the connection
|
||||
logger.log('warn', `Closing inactive connection: ${record.remoteIP}`, {
|
||||
connectionId,
|
||||
remoteIP: record.remoteIP,
|
||||
inactiveFor: plugins.prettyMs(inactivityTime),
|
||||
hasKeepAlive: record.hasKeepAlive,
|
||||
@ -397,97 +503,106 @@ export class ConnectionManager {
|
||||
});
|
||||
this.cleanupConnection(record, 'inactivity');
|
||||
}
|
||||
} else if (inactivityTime <= effectiveTimeout && record.inactivityWarningIssued) {
|
||||
// If activity detected after warning, clear the warning
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
logger.log('info', `Connection ${id} activity detected after inactivity warning`, {
|
||||
connectionId: id,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
}
|
||||
record.inactivityWarningIssued = false;
|
||||
} else {
|
||||
// Reschedule next check
|
||||
this.scheduleInactivityCheck(connectionId, record);
|
||||
}
|
||||
|
||||
// Parity check: if outgoing socket closed and incoming remains active
|
||||
// Increased from 2 minutes to 30 minutes for long-lived connections
|
||||
if (
|
||||
record.outgoingClosedTime &&
|
||||
!record.incoming.destroyed &&
|
||||
!record.connectionClosed &&
|
||||
now - record.outgoingClosedTime > 120000
|
||||
now - record.outgoingClosedTime > 1800000 // 30 minutes
|
||||
) {
|
||||
logger.log('warn', `Parity check: Connection ${id} from ${record.remoteIP} has incoming socket still active ${plugins.prettyMs(now - record.outgoingClosedTime)} after outgoing socket closed`, {
|
||||
connectionId: id,
|
||||
remoteIP: record.remoteIP,
|
||||
timeElapsed: plugins.prettyMs(now - record.outgoingClosedTime),
|
||||
component: 'connection-manager'
|
||||
});
|
||||
this.cleanupConnection(record, 'parity_check');
|
||||
// Only close if no data activity for 10 minutes
|
||||
if (now - record.lastActivity > 600000) {
|
||||
logger.log('warn', `Parity check failed after extended timeout: ${record.remoteIP}`, {
|
||||
connectionId,
|
||||
remoteIP: record.remoteIP,
|
||||
timeElapsed: plugins.prettyMs(now - record.outgoingClosedTime),
|
||||
inactiveFor: plugins.prettyMs(now - record.lastActivity),
|
||||
component: 'connection-manager'
|
||||
});
|
||||
this.cleanupConnection(record, 'parity_check');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method for backward compatibility
|
||||
*/
|
||||
public performInactivityCheck(): void {
|
||||
this.performOptimizedInactivityCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all connections (for shutdown)
|
||||
*/
|
||||
public clearConnections(): void {
|
||||
// Create a copy of the keys to avoid modification during iteration
|
||||
const connectionIds = [...this.connectionRecords.keys()];
|
||||
public async clearConnections(): Promise<void> {
|
||||
// Delegate to LifecycleComponent's cleanup
|
||||
await this.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Override LifecycleComponent's onCleanup method
|
||||
*/
|
||||
protected async onCleanup(): Promise<void> {
|
||||
|
||||
// First pass: End all connections gracefully
|
||||
for (const id of connectionIds) {
|
||||
const record = this.connectionRecords.get(id);
|
||||
if (record) {
|
||||
// Process connections in batches to avoid blocking
|
||||
const connections = Array.from(this.connectionRecords.values());
|
||||
const batchSize = 100;
|
||||
let index = 0;
|
||||
|
||||
const processBatch = () => {
|
||||
const batch = connections.slice(index, index + batchSize);
|
||||
|
||||
for (const record of batch) {
|
||||
try {
|
||||
// Clear any timers
|
||||
if (record.cleanupTimer) {
|
||||
clearTimeout(record.cleanupTimer);
|
||||
record.cleanupTimer = undefined;
|
||||
}
|
||||
|
||||
// End sockets gracefully
|
||||
if (record.incoming && !record.incoming.destroyed) {
|
||||
record.incoming.end();
|
||||
// Immediate destruction using socket-utils
|
||||
const shutdownPromises: Promise<void>[] = [];
|
||||
|
||||
if (record.incoming) {
|
||||
shutdownPromises.push(cleanupSocket(record.incoming, `${record.id}-incoming-shutdown`, { immediate: true }));
|
||||
}
|
||||
|
||||
if (record.outgoing && !record.outgoing.destroyed) {
|
||||
record.outgoing.end();
|
||||
if (record.outgoing) {
|
||||
shutdownPromises.push(cleanupSocket(record.outgoing, `${record.id}-outgoing-shutdown`, { immediate: true }));
|
||||
}
|
||||
|
||||
// Don't wait for shutdown cleanup in this batch processing
|
||||
Promise.all(shutdownPromises).catch(() => {});
|
||||
} catch (err) {
|
||||
logger.log('error', `Error during graceful end of connection ${id}: ${err}`, { connectionId: id, error: err, component: 'connection-manager' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Short delay to allow graceful ends to process
|
||||
setTimeout(() => {
|
||||
// Second pass: Force destroy everything
|
||||
for (const id of connectionIds) {
|
||||
const record = this.connectionRecords.get(id);
|
||||
if (record) {
|
||||
try {
|
||||
// Remove all listeners to prevent memory leaks
|
||||
if (record.incoming) {
|
||||
record.incoming.removeAllListeners();
|
||||
if (!record.incoming.destroyed) {
|
||||
record.incoming.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
if (record.outgoing) {
|
||||
record.outgoing.removeAllListeners();
|
||||
if (!record.outgoing.destroyed) {
|
||||
record.outgoing.destroy();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('error', `Error during forced destruction of connection ${id}: ${err}`, { connectionId: id, error: err, component: 'connection-manager' });
|
||||
}
|
||||
logger.log('error', `Error during connection cleanup: ${err}`, {
|
||||
connectionId: record.id,
|
||||
error: err,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all maps
|
||||
this.connectionRecords.clear();
|
||||
this.terminationStats = { incoming: {}, outgoing: {} };
|
||||
}, 100);
|
||||
index += batchSize;
|
||||
|
||||
// Continue with next batch if needed
|
||||
if (index < connections.length) {
|
||||
setImmediate(processBatch);
|
||||
} else {
|
||||
// Clear all maps
|
||||
this.connectionRecords.clear();
|
||||
this.nextInactivityCheck.clear();
|
||||
this.cleanupQueue.clear();
|
||||
this.terminationStats = { incoming: {}, outgoing: {} };
|
||||
}
|
||||
};
|
||||
|
||||
// Start batch processing
|
||||
setImmediate(processBatch);
|
||||
}
|
||||
}
|
@ -128,10 +128,24 @@ export class HttpProxyBridge {
|
||||
proxySocket.pipe(socket);
|
||||
|
||||
// Handle cleanup
|
||||
let cleanedUp = false;
|
||||
const cleanup = (reason: string) => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
|
||||
// Remove all event listeners to prevent memory leaks
|
||||
socket.removeAllListeners('end');
|
||||
socket.removeAllListeners('error');
|
||||
proxySocket.removeAllListeners('end');
|
||||
proxySocket.removeAllListeners('error');
|
||||
|
||||
socket.unpipe(proxySocket);
|
||||
proxySocket.unpipe(socket);
|
||||
proxySocket.destroy();
|
||||
|
||||
if (!proxySocket.destroyed) {
|
||||
proxySocket.destroy();
|
||||
}
|
||||
|
||||
cleanupCallback(reason);
|
||||
};
|
||||
|
||||
|
@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
|
||||
import type { ISmartProxyOptions } from './models/interfaces.js';
|
||||
import { RouteConnectionHandler } from './route-connection-handler.js';
|
||||
import { logger } from '../../core/utils/logger.js';
|
||||
import { cleanupSocket } from '../../core/utils/socket-utils.js';
|
||||
|
||||
/**
|
||||
* PortManager handles the dynamic creation and removal of port listeners
|
||||
@ -64,8 +65,7 @@ export class PortManager {
|
||||
const server = plugins.net.createServer((socket) => {
|
||||
// Check if shutting down
|
||||
if (this.isShuttingDown) {
|
||||
socket.end();
|
||||
socket.destroy();
|
||||
cleanupSocket(socket, 'port-manager-shutdown', { immediate: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -9,7 +9,7 @@ import { TlsManager } from './tls-manager.js';
|
||||
import { HttpProxyBridge } from './http-proxy-bridge.js';
|
||||
import { TimeoutManager } from './timeout-manager.js';
|
||||
import { RouteManager } from './route-manager.js';
|
||||
import type { ForwardingHandler } from '../../forwarding/handlers/base-handler.js';
|
||||
import { cleanupSocket, createIndependentSocketHandlers, setupSocketHandlers, createSocketWithErrorHandler } from '../../core/utils/socket-utils.js';
|
||||
|
||||
/**
|
||||
* Handles new connection processing and setup logic with support for route-based configuration
|
||||
@ -84,8 +84,7 @@ export class RouteConnectionHandler {
|
||||
const ipValidation = this.securityManager.validateIP(remoteIP);
|
||||
if (!ipValidation.allowed) {
|
||||
logger.log('warn', `Connection rejected`, { remoteIP, reason: ipValidation.reason, component: 'route-handler' });
|
||||
socket.end();
|
||||
socket.destroy();
|
||||
cleanupSocket(socket, `rejected-${ipValidation.reason}`, { immediate: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@ -822,6 +821,38 @@ export class RouteConnectionHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track event listeners added by the handler so we can clean them up
|
||||
const originalOn = socket.on.bind(socket);
|
||||
const originalOnce = socket.once.bind(socket);
|
||||
const trackedListeners: Array<{event: string; listener: (...args: any[]) => void}> = [];
|
||||
|
||||
// Override socket.on to track listeners
|
||||
socket.on = function(event: string, listener: (...args: any[]) => void) {
|
||||
trackedListeners.push({event, listener});
|
||||
return originalOn(event, listener);
|
||||
} as any;
|
||||
|
||||
// Override socket.once to track listeners
|
||||
socket.once = function(event: string, listener: (...args: any[]) => void) {
|
||||
trackedListeners.push({event, listener});
|
||||
return originalOnce(event, listener);
|
||||
} as any;
|
||||
|
||||
// Set up automatic cleanup when socket closes
|
||||
const cleanupHandler = () => {
|
||||
// Remove all tracked listeners
|
||||
for (const {event, listener} of trackedListeners) {
|
||||
socket.removeListener(event, listener);
|
||||
}
|
||||
// Restore original methods
|
||||
socket.on = originalOn;
|
||||
socket.once = originalOnce;
|
||||
};
|
||||
|
||||
// Listen for socket close to trigger cleanup
|
||||
originalOnce('close', cleanupHandler);
|
||||
originalOnce('error', cleanupHandler);
|
||||
|
||||
// Create route context for the handler
|
||||
const routeContext = this.createRouteContext({
|
||||
connectionId: record.id,
|
||||
@ -855,6 +886,8 @@ export class RouteConnectionHandler {
|
||||
error: error.message,
|
||||
component: 'route-handler'
|
||||
});
|
||||
// Remove all event listeners before destroying to prevent memory leaks
|
||||
socket.removeAllListeners();
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
@ -875,6 +908,8 @@ export class RouteConnectionHandler {
|
||||
error: error.message,
|
||||
component: 'route-handler'
|
||||
});
|
||||
// Remove all event listeners before destroying to prevent memory leaks
|
||||
socket.removeAllListeners();
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
@ -1038,13 +1073,52 @@ export class RouteConnectionHandler {
|
||||
record.pendingDataSize = initialChunk.length;
|
||||
}
|
||||
|
||||
// Create the target socket
|
||||
const targetSocket = plugins.net.connect(connectionOptions);
|
||||
record.outgoing = targetSocket;
|
||||
record.outgoingStartTime = Date.now();
|
||||
// Create the target socket with immediate error handling
|
||||
let targetSocket: plugins.net.Socket;
|
||||
|
||||
// Flag to track if initial connection failed
|
||||
let connectionFailed = false;
|
||||
|
||||
targetSocket = createSocketWithErrorHandler({
|
||||
port: finalTargetPort,
|
||||
host: finalTargetHost,
|
||||
onError: (error) => {
|
||||
// Mark connection as failed
|
||||
connectionFailed = true;
|
||||
|
||||
// Connection failed - clean up immediately
|
||||
logger.log('error',
|
||||
`Connection setup error for ${connectionId} to ${finalTargetHost}:${finalTargetPort}: ${error.message} (${(error as any).code})`,
|
||||
{
|
||||
connectionId,
|
||||
targetHost: finalTargetHost,
|
||||
targetPort: finalTargetPort,
|
||||
errorMessage: error.message,
|
||||
errorCode: (error as any).code,
|
||||
component: 'route-handler'
|
||||
}
|
||||
);
|
||||
|
||||
// Resume the incoming socket to prevent it from hanging
|
||||
socket.resume();
|
||||
|
||||
// Clean up the incoming socket
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
// Clean up the connection record
|
||||
this.connectionManager.initiateCleanupOnce(record, `connection_failed_${(error as any).code || 'unknown'}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Only proceed with setup if connection didn't fail immediately
|
||||
if (!connectionFailed) {
|
||||
record.outgoing = targetSocket;
|
||||
record.outgoingStartTime = Date.now();
|
||||
|
||||
// Apply socket optimizations
|
||||
targetSocket.setNoDelay(this.settings.noDelay);
|
||||
// Apply socket optimizations
|
||||
targetSocket.setNoDelay(this.settings.noDelay);
|
||||
|
||||
// Apply keep-alive settings if enabled
|
||||
if (this.settings.keepAlive) {
|
||||
@ -1075,9 +1149,8 @@ export class RouteConnectionHandler {
|
||||
// Setup improved error handling for outgoing connection
|
||||
this.setupOutgoingErrorHandler(connectionId, targetSocket, record, socket, finalTargetHost, finalTargetPort);
|
||||
|
||||
// Setup close handlers
|
||||
targetSocket.on('close', this.connectionManager.handleClose('outgoing', record));
|
||||
socket.on('close', this.connectionManager.handleClose('incoming', record));
|
||||
// Note: Close handlers are managed by independent socket handlers above
|
||||
// We don't register handleClose here to avoid bilateral cleanup
|
||||
|
||||
// Setup error handlers for incoming socket
|
||||
socket.on('error', this.connectionManager.handleError('incoming', record));
|
||||
@ -1190,14 +1263,64 @@ export class RouteConnectionHandler {
|
||||
record.pendingDataSize = 0;
|
||||
}
|
||||
|
||||
// Immediately setup bidirectional piping - much simpler than manual data management
|
||||
socket.pipe(targetSocket);
|
||||
targetSocket.pipe(socket);
|
||||
// Set up independent socket handlers for half-open connection support
|
||||
const { cleanupClient, cleanupServer } = createIndependentSocketHandlers(
|
||||
socket,
|
||||
targetSocket,
|
||||
(reason) => {
|
||||
this.connectionManager.initiateCleanupOnce(record, reason);
|
||||
}
|
||||
);
|
||||
|
||||
// Track incoming data for bytes counting - do this after piping is set up
|
||||
// Setup socket handlers with custom timeout handling
|
||||
setupSocketHandlers(socket, cleanupClient, (sock) => {
|
||||
// Don't close on timeout for keep-alive connections
|
||||
if (record.hasKeepAlive) {
|
||||
sock.setTimeout(this.settings.socketTimeout || 3600000);
|
||||
}
|
||||
}, 'client');
|
||||
|
||||
setupSocketHandlers(targetSocket, cleanupServer, (sock) => {
|
||||
// Don't close on timeout for keep-alive connections
|
||||
if (record.hasKeepAlive) {
|
||||
sock.setTimeout(this.settings.socketTimeout || 3600000);
|
||||
}
|
||||
}, 'server');
|
||||
|
||||
// Forward data from client to target with backpressure handling
|
||||
socket.on('data', (chunk: Buffer) => {
|
||||
record.bytesReceived += chunk.length;
|
||||
this.timeoutManager.updateActivity(record);
|
||||
|
||||
if (targetSocket.writable) {
|
||||
const flushed = targetSocket.write(chunk);
|
||||
|
||||
// Handle backpressure
|
||||
if (!flushed) {
|
||||
socket.pause();
|
||||
targetSocket.once('drain', () => {
|
||||
socket.resume();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Forward data from target to client with backpressure handling
|
||||
targetSocket.on('data', (chunk: Buffer) => {
|
||||
record.bytesSent += chunk.length;
|
||||
this.timeoutManager.updateActivity(record);
|
||||
|
||||
if (socket.writable) {
|
||||
const flushed = socket.write(chunk);
|
||||
|
||||
// Handle backpressure
|
||||
if (!flushed) {
|
||||
targetSocket.pause();
|
||||
socket.once('drain', () => {
|
||||
targetSocket.resume();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Log successful connection
|
||||
@ -1229,7 +1352,7 @@ export class RouteConnectionHandler {
|
||||
connectionId,
|
||||
serverName,
|
||||
connInfo,
|
||||
(connectionId, reason) => this.connectionManager.initiateCleanupOnce(record, reason)
|
||||
(_connectionId, reason) => this.connectionManager.initiateCleanupOnce(record, reason)
|
||||
);
|
||||
|
||||
// Store the handler in the connection record so we can remove it during cleanup
|
||||
@ -1262,5 +1385,6 @@ export class RouteConnectionHandler {
|
||||
record.tlsHandshakeComplete = true;
|
||||
}
|
||||
});
|
||||
} // End of if (!connectionFailed)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user