@kadi.build/tunnel-services 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +343 -0
- package/package.json +58 -0
- package/src/BaseTunnelService.js +300 -0
- package/src/TunnelManager.js +372 -0
- package/src/TunnelService.js +316 -0
- package/src/errors.js +115 -0
- package/src/index.js +78 -0
- package/src/services/KadiTunnelService.js +421 -0
- package/src/services/LocalTunnelService.js +188 -0
- package/src/services/LocalhostRunTunnelService.js +241 -0
- package/src/services/NgrokTunnelService.js +322 -0
- package/src/services/PinggyTunnelService.js +262 -0
- package/src/services/ServeoTunnelService.js +293 -0
- package/src/utils/ProcessManager.js +175 -0
- package/src/utils/TunnelDiagnosticTool.js +282 -0
package/README.md
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# @kadi.build/tunnel-services
|
|
2
|
+
|
|
3
|
+
> Unified tunnel service manager for exposing local ports to the internet. Supports 6 tunnel providers with automatic failover, KĀDI as the default primary service.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 🚇 **6 Tunnel Providers** — KĀDI, Serveo, ngrok, LocalTunnel, Pinggy, localhost.run
|
|
8
|
+
- 🔄 **Automatic Fallback** — Seamlessly switches to the next provider on failure
|
|
9
|
+
- 🎯 **KĀDI-First** — KĀDI is the default primary tunnel service
|
|
10
|
+
- 📡 **Event-Driven** — Rich event system for monitoring tunnel lifecycle
|
|
11
|
+
- 🔧 **Diagnostic Tools** — Built-in diagnostics for troubleshooting
|
|
12
|
+
- 🧩 **Pluggable** — Register custom tunnel services easily
|
|
13
|
+
- 📦 **Zero Config** — Works out of the box with sensible defaults
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @kadi.build/tunnel-services
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Optional Dependencies
|
|
22
|
+
|
|
23
|
+
Install based on which providers you want to use:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# For ngrok support
|
|
27
|
+
npm install @ngrok/ngrok
|
|
28
|
+
|
|
29
|
+
# For localtunnel support
|
|
30
|
+
npm install localtunnel
|
|
31
|
+
|
|
32
|
+
# SSH-based providers (serveo, pinggy, localhost.run, kadi)
|
|
33
|
+
# require `ssh` on your PATH — no extra packages needed
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
### One-Liner
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
import { expose } from '@kadi.build/tunnel-services';
|
|
42
|
+
|
|
43
|
+
// Expose port 3000 to the internet
|
|
44
|
+
const url = await expose(3000);
|
|
45
|
+
console.log(`Public URL: ${url}`);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### With Tunnel Management
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
import { createTunnel } from '@kadi.build/tunnel-services';
|
|
52
|
+
|
|
53
|
+
const tunnel = await createTunnel(3000, {
|
|
54
|
+
service: 'kadi', // or 'serveo', 'ngrok', 'localtunnel', 'pinggy', 'localhost.run'
|
|
55
|
+
subdomain: 'my-app', // optional
|
|
56
|
+
autoFallback: true // try next provider on failure
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
console.log(`Public URL: ${tunnel.publicUrl}`);
|
|
60
|
+
console.log(`Local Port: ${tunnel.localPort}`);
|
|
61
|
+
console.log(`Service: ${tunnel.service}`);
|
|
62
|
+
|
|
63
|
+
// When done
|
|
64
|
+
await tunnel.close();
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Full Control with TunnelManager
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
import { TunnelManager } from '@kadi.build/tunnel-services';
|
|
71
|
+
|
|
72
|
+
const manager = new TunnelManager({
|
|
73
|
+
primaryService: 'kadi',
|
|
74
|
+
fallbackServices: ['serveo', 'ngrok', 'localtunnel', 'pinggy'],
|
|
75
|
+
autoFallback: true,
|
|
76
|
+
maxConcurrentTunnels: 10,
|
|
77
|
+
connectionTimeout: 30000
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await manager.initialize();
|
|
81
|
+
|
|
82
|
+
// Listen for events
|
|
83
|
+
manager.on('tunnelCreated', ({ id, publicUrl, service }) => {
|
|
84
|
+
console.log(`Tunnel ${id} created on ${service}: ${publicUrl}`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
manager.on('tunnelDestroyed', ({ id }) => {
|
|
88
|
+
console.log(`Tunnel ${id} closed`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
manager.on('serviceFailed', ({ service, error }) => {
|
|
92
|
+
console.warn(`${service} failed: ${error.message}, trying next...`);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Create tunnels
|
|
96
|
+
const tunnel1 = await manager.createTunnel(3000);
|
|
97
|
+
const tunnel2 = await manager.createTunnel(8080, { service: 'ngrok' });
|
|
98
|
+
|
|
99
|
+
// Check status
|
|
100
|
+
console.log(manager.getStatus());
|
|
101
|
+
|
|
102
|
+
// Close specific tunnel
|
|
103
|
+
await manager.closeTunnel(tunnel1.id);
|
|
104
|
+
|
|
105
|
+
// Cleanup everything
|
|
106
|
+
await manager.cleanup();
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## API Reference
|
|
110
|
+
|
|
111
|
+
### Factory Functions
|
|
112
|
+
|
|
113
|
+
#### `expose(port, service?)` → `Promise<string>`
|
|
114
|
+
|
|
115
|
+
Returns a public URL string. Simplest way to expose a port.
|
|
116
|
+
|
|
117
|
+
| Parameter | Type | Default | Description |
|
|
118
|
+
|-----------|------|---------|-------------|
|
|
119
|
+
| `port` | `number` | — | Local port to expose |
|
|
120
|
+
| `service` | `string` | `'kadi'` | Tunnel service to use |
|
|
121
|
+
|
|
122
|
+
#### `createTunnel(port, options?)` → `Promise<TunnelInfo>`
|
|
123
|
+
|
|
124
|
+
Creates a tunnel and returns a `TunnelInfo` object with a `close()` method.
|
|
125
|
+
|
|
126
|
+
| Parameter | Type | Default | Description |
|
|
127
|
+
|-----------|------|---------|-------------|
|
|
128
|
+
| `port` | `number` | — | Local port to expose |
|
|
129
|
+
| `options.service` | `string` | `'kadi'` | Preferred service |
|
|
130
|
+
| `options.subdomain` | `string` | — | Requested subdomain |
|
|
131
|
+
| `options.autoFallback` | `boolean` | `true` | Enable fallback |
|
|
132
|
+
|
|
133
|
+
### TunnelManager
|
|
134
|
+
|
|
135
|
+
The main orchestrator class. Extends `EventEmitter`.
|
|
136
|
+
|
|
137
|
+
#### Constructor Options
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
new TunnelManager({
|
|
141
|
+
primaryService: 'kadi', // Default primary service
|
|
142
|
+
fallbackServices: ['serveo', 'ngrok', 'localtunnel', 'pinggy'],
|
|
143
|
+
autoFallback: true, // Auto-fallback on failure
|
|
144
|
+
maxConcurrentTunnels: 10, // Max simultaneous tunnels
|
|
145
|
+
connectionTimeout: 30000, // Connection timeout (ms)
|
|
146
|
+
ngrokAuthToken: '', // Ngrok auth token
|
|
147
|
+
kadiServer: '', // KĀDI tunnel server
|
|
148
|
+
kadiMode: 'ssh' // 'ssh' or 'frpc'
|
|
149
|
+
})
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
#### Methods
|
|
153
|
+
|
|
154
|
+
| Method | Returns | Description |
|
|
155
|
+
|--------|---------|-------------|
|
|
156
|
+
| `initialize()` | `Promise<void>` | Discover and load services |
|
|
157
|
+
| `createTunnel(port, options?)` | `Promise<TunnelInfo>` | Create a new tunnel |
|
|
158
|
+
| `closeTunnel(id)` | `Promise<void>` | Close specific tunnel |
|
|
159
|
+
| `closeAllTunnels()` | `Promise<void>` | Close all active tunnels |
|
|
160
|
+
| `getStatus()` | `object` | Manager status + active tunnels |
|
|
161
|
+
| `getAvailableServices()` | `string[]` | List discovered services |
|
|
162
|
+
| `testService(name)` | `Promise<object>` | Test if a service is available |
|
|
163
|
+
| `runDiagnostics()` | `Promise<object>` | Run diagnostics on all services |
|
|
164
|
+
| `cleanup()` | `Promise<void>` | Full shutdown |
|
|
165
|
+
|
|
166
|
+
#### Events
|
|
167
|
+
|
|
168
|
+
| Event | Payload | Description |
|
|
169
|
+
|-------|---------|-------------|
|
|
170
|
+
| `tunnelCreated` | `TunnelInfo` | Tunnel successfully created |
|
|
171
|
+
| `tunnelDestroyed` | `{ id }` | Tunnel closed |
|
|
172
|
+
| `serviceFailed` | `{ service, error }` | Service failed, falling back |
|
|
173
|
+
| `error` | `Error` | Unrecoverable error |
|
|
174
|
+
|
|
175
|
+
### TunnelInfo Object
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
{
|
|
179
|
+
id: 'tunnel-abc123', // Unique tunnel ID
|
|
180
|
+
publicUrl: 'https://...', // Public URL
|
|
181
|
+
localPort: 3000, // Local port being exposed
|
|
182
|
+
service: 'kadi', // Service that created it
|
|
183
|
+
subdomain: 'my-app', // Subdomain (if requested)
|
|
184
|
+
createdAt: Date, // Creation timestamp
|
|
185
|
+
metadata: {} // Service-specific metadata
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### BaseTunnelService
|
|
190
|
+
|
|
191
|
+
Abstract base class for all tunnel services. Extend this to create custom providers.
|
|
192
|
+
|
|
193
|
+
```js
|
|
194
|
+
import { BaseTunnelService } from '@kadi.build/tunnel-services';
|
|
195
|
+
|
|
196
|
+
class MyTunnelService extends BaseTunnelService {
|
|
197
|
+
get name() { return 'my-tunnel'; }
|
|
198
|
+
|
|
199
|
+
async connect(port, options = {}) {
|
|
200
|
+
// Connect and return { url, port, ... }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async disconnect() {
|
|
204
|
+
// Clean up
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
getStatus() {
|
|
208
|
+
return { connected: this._connected, service: this.name };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Error Classes
|
|
214
|
+
|
|
215
|
+
| Error | Fallback? | Description |
|
|
216
|
+
|-------|-----------|-------------|
|
|
217
|
+
| `TunnelError` | — | Base tunnel error |
|
|
218
|
+
| `TransientTunnelError` | ✅ Yes | Temporary failure, try next service |
|
|
219
|
+
| `PermanentTunnelError` | ❌ No | Permanent failure, do not fallback |
|
|
220
|
+
| `CriticalTunnelError` | ❌ No | Critical system-level failure |
|
|
221
|
+
| `ConfigurationError` | ❌ No | Invalid configuration |
|
|
222
|
+
| `ServiceUnavailableError` | ✅ Yes | Service not available/installed |
|
|
223
|
+
| `ConnectionTimeoutError` | ✅ Yes | Connection timed out |
|
|
224
|
+
| `SSHUnavailableError` | ✅ Yes | SSH binary not found |
|
|
225
|
+
| `AuthenticationFailedError` | ❌ No | Authentication failed |
|
|
226
|
+
|
|
227
|
+
## Tunnel Providers
|
|
228
|
+
|
|
229
|
+
### KĀDI (default)
|
|
230
|
+
|
|
231
|
+
SSH or frpc-based tunnel to KĀDI infrastructure.
|
|
232
|
+
|
|
233
|
+
```js
|
|
234
|
+
const manager = new TunnelManager({
|
|
235
|
+
primaryService: 'kadi',
|
|
236
|
+
kadiServer: 'tunnel.kadi.build',
|
|
237
|
+
kadiMode: 'ssh' // or 'frpc'
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
**Environment Variables:** `KADI_TUNNEL_SERVER`, `KADI_TUNNEL_PORT`
|
|
242
|
+
|
|
243
|
+
### Serveo
|
|
244
|
+
|
|
245
|
+
Free SSH-based tunneling via serveo.net. No account required.
|
|
246
|
+
|
|
247
|
+
```js
|
|
248
|
+
await manager.createTunnel(3000, { service: 'serveo' });
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**Requires:** `ssh` on PATH
|
|
252
|
+
|
|
253
|
+
### ngrok
|
|
254
|
+
|
|
255
|
+
Industry-standard tunnel service. Requires auth token for extended use.
|
|
256
|
+
|
|
257
|
+
```js
|
|
258
|
+
const manager = new TunnelManager({
|
|
259
|
+
primaryService: 'ngrok',
|
|
260
|
+
ngrokAuthToken: process.env.NGROK_AUTH_TOKEN
|
|
261
|
+
});
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**Requires:** `@ngrok/ngrok` or legacy `ngrok` package
|
|
265
|
+
**Environment Variables:** `NGROK_AUTH_TOKEN`
|
|
266
|
+
|
|
267
|
+
### LocalTunnel
|
|
268
|
+
|
|
269
|
+
Open-source tunneling via localtunnel.me.
|
|
270
|
+
|
|
271
|
+
```js
|
|
272
|
+
await manager.createTunnel(3000, { service: 'localtunnel' });
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**Requires:** `localtunnel` npm package
|
|
276
|
+
|
|
277
|
+
### Pinggy
|
|
278
|
+
|
|
279
|
+
SSH-based tunneling via pinggy.io.
|
|
280
|
+
|
|
281
|
+
```js
|
|
282
|
+
await manager.createTunnel(3000, { service: 'pinggy' });
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**Requires:** `ssh` on PATH
|
|
286
|
+
|
|
287
|
+
### localhost.run
|
|
288
|
+
|
|
289
|
+
SSH-based tunneling via localhost.run. No account required.
|
|
290
|
+
|
|
291
|
+
```js
|
|
292
|
+
await manager.createTunnel(3000, { service: 'localhost.run' });
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
**Requires:** `ssh` on PATH
|
|
296
|
+
|
|
297
|
+
## Fallback Order
|
|
298
|
+
|
|
299
|
+
When `autoFallback: true` (default), the manager tries services in this order:
|
|
300
|
+
|
|
301
|
+
1. **KĀDI** (primary)
|
|
302
|
+
2. **Serveo**
|
|
303
|
+
3. **ngrok**
|
|
304
|
+
4. **LocalTunnel**
|
|
305
|
+
5. **Pinggy**
|
|
306
|
+
|
|
307
|
+
If a service throws a `TransientTunnelError` or `ServiceUnavailableError`, the next service in the chain is attempted. `PermanentTunnelError` stops the fallback chain.
|
|
308
|
+
|
|
309
|
+
## Testing
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
# Run all tests
|
|
313
|
+
npm test
|
|
314
|
+
|
|
315
|
+
# Run specific test suite
|
|
316
|
+
npm run test:manager
|
|
317
|
+
npm run test:kadi
|
|
318
|
+
npm run test:ngrok
|
|
319
|
+
npm run test:serveo
|
|
320
|
+
|
|
321
|
+
# Integration tests (require credentials/tools)
|
|
322
|
+
NGROK_AUTH_TOKEN=xxx npm run test:ngrok
|
|
323
|
+
KADI_TUNNEL_SERVER=tunnel.kadi.build npm run test:kadi
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
## Environment Variables
|
|
327
|
+
|
|
328
|
+
| Variable | Used By | Description |
|
|
329
|
+
|----------|---------|-------------|
|
|
330
|
+
| `NGROK_AUTH_TOKEN` | ngrok | Authentication token |
|
|
331
|
+
| `KADI_TUNNEL_SERVER` | KĀDI | Tunnel server hostname |
|
|
332
|
+
| `KADI_TUNNEL_PORT` | KĀDI | Tunnel server port |
|
|
333
|
+
| `DEBUG` | all | Debug logging (e.g., `kadi:tunnel:*`) |
|
|
334
|
+
|
|
335
|
+
## Requirements
|
|
336
|
+
|
|
337
|
+
- Node.js >= 18.0.0
|
|
338
|
+
- SSH on PATH (for serveo, pinggy, localhost.run, kadi)
|
|
339
|
+
- Optional: `@ngrok/ngrok`, `localtunnel` npm packages
|
|
340
|
+
|
|
341
|
+
## License
|
|
342
|
+
|
|
343
|
+
MIT © KĀDI
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kadi.build/tunnel-services",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Unified tunnel management for exposing local ports via ngrok, serveo, localtunnel, KĀDI, and more",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./src/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src/",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node tests/run-all-tests.js",
|
|
18
|
+
"test:integration": "node tests/test-kadi-integration.js",
|
|
19
|
+
"test:manager": "node tests/test-tunnel-manager.js",
|
|
20
|
+
"test:ngrok": "node tests/test-ngrok-tunnel.js",
|
|
21
|
+
"test:serveo": "node tests/test-serveo-tunnel.js",
|
|
22
|
+
"test:localtunnel": "node tests/test-localtunnel.js",
|
|
23
|
+
"test:pinggy": "node tests/test-pinggy-tunnel.js",
|
|
24
|
+
"test:kadi": "node tests/test-kadi-tunnel.js",
|
|
25
|
+
"test:all-services": "node tests/test-all-tunnels.js"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"tunnel",
|
|
29
|
+
"ngrok",
|
|
30
|
+
"serveo",
|
|
31
|
+
"localtunnel",
|
|
32
|
+
"pinggy",
|
|
33
|
+
"kadi",
|
|
34
|
+
"localhost",
|
|
35
|
+
"expose",
|
|
36
|
+
"port-forwarding"
|
|
37
|
+
],
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@kadi.build/tunnel-client": "^0.3.0",
|
|
40
|
+
"debug": "^4.3.4"
|
|
41
|
+
},
|
|
42
|
+
"optionalDependencies": {
|
|
43
|
+
"@ngrok/ngrok": "^1.4.1",
|
|
44
|
+
"localtunnel": "^2.0.2"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"ssh2": "^1.15.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"ssh2": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=18.0.0"
|
|
56
|
+
},
|
|
57
|
+
"license": "MIT"
|
|
58
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Abstract base class for all tunnel services
|
|
3
|
+
* Based on TunnelManagerSpec.md Section 3.2 specification
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import EventEmitter from 'events';
|
|
7
|
+
import {
|
|
8
|
+
TransientTunnelError,
|
|
9
|
+
PermanentTunnelError,
|
|
10
|
+
CriticalTunnelError,
|
|
11
|
+
ConfigurationError,
|
|
12
|
+
SSHUnavailableError,
|
|
13
|
+
ConnectionTimeoutError,
|
|
14
|
+
AuthenticationFailedError
|
|
15
|
+
} from './errors.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Abstract base class that defines the contract for all tunnel service implementations.
|
|
19
|
+
* This class MUST NOT be instantiated directly - it serves as an interface specification.
|
|
20
|
+
*
|
|
21
|
+
* All concrete tunnel services must extend this class and implement its abstract methods.
|
|
22
|
+
*
|
|
23
|
+
* @abstract
|
|
24
|
+
* @extends EventEmitter
|
|
25
|
+
*/
|
|
26
|
+
export class BaseTunnelService extends EventEmitter {
|
|
27
|
+
/**
|
|
28
|
+
* Constructor for the base tunnel service
|
|
29
|
+
* @param {Object} config - Global tunnel configuration object
|
|
30
|
+
* @throws {Error} If instantiated directly (must be subclassed)
|
|
31
|
+
*/
|
|
32
|
+
constructor(config) {
|
|
33
|
+
super();
|
|
34
|
+
|
|
35
|
+
// Prevent direct instantiation of abstract class
|
|
36
|
+
if (this.constructor === BaseTunnelService) {
|
|
37
|
+
throw new Error('BaseTunnelService is abstract and cannot be instantiated directly');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
this.config = config || {};
|
|
41
|
+
this.activeTunnels = new Map();
|
|
42
|
+
this.isShuttingDown = false;
|
|
43
|
+
|
|
44
|
+
// Validate that subclass implements required abstract methods
|
|
45
|
+
this._validateImplementation();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Validates that the subclass properly implements all abstract methods
|
|
50
|
+
* @private
|
|
51
|
+
*/
|
|
52
|
+
_validateImplementation() {
|
|
53
|
+
const requiredMethods = ['name', 'connect', 'disconnect', 'getStatus'];
|
|
54
|
+
|
|
55
|
+
for (const method of requiredMethods) {
|
|
56
|
+
if (method === 'name') {
|
|
57
|
+
// Check getter exists and returns string
|
|
58
|
+
const descriptor = Object.getOwnPropertyDescriptor(this.constructor.prototype, method);
|
|
59
|
+
if (!descriptor || !descriptor.get) {
|
|
60
|
+
throw new Error(`Subclass must implement getter '${method}'`);
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
// Check method exists and is not the abstract implementation
|
|
64
|
+
if (typeof this[method] !== 'function' || this[method] === BaseTunnelService.prototype[method]) {
|
|
65
|
+
throw new Error(`Subclass must implement method '${method}'`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Abstract getter that returns the unique service name identifier
|
|
73
|
+
* @abstract
|
|
74
|
+
* @returns {string} The service name (e.g., 'serveo', 'pinggy', 'localtunnel')
|
|
75
|
+
* @throws {Error} Must be implemented by subclass
|
|
76
|
+
*/
|
|
77
|
+
get name() {
|
|
78
|
+
throw new Error('Abstract getter "name" must be implemented by subclass');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Abstract method to establish a tunnel connection
|
|
83
|
+
* @abstract
|
|
84
|
+
* @param {Object} options - Connection options specific to the service
|
|
85
|
+
* @param {number} options.port - Local port to tunnel
|
|
86
|
+
* @param {string} [options.subdomain] - Requested subdomain (if supported)
|
|
87
|
+
* @param {string} [options.region] - Preferred region (if supported)
|
|
88
|
+
* @param {number} [options.timeout=30000] - Connection timeout in milliseconds
|
|
89
|
+
* @returns {Promise<Object>} Promise that resolves with tunnel information
|
|
90
|
+
* @throws {TransientTunnelError} For temporary failures that should trigger fallback
|
|
91
|
+
* @throws {PermanentTunnelError} For permanent failures that should not trigger fallback
|
|
92
|
+
* @throws {CriticalTunnelError} For critical failures that should stop all operations
|
|
93
|
+
*/
|
|
94
|
+
async connect(options) {
|
|
95
|
+
throw new Error('Abstract method "connect" must be implemented by subclass');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Abstract method to disconnect and destroy a tunnel
|
|
100
|
+
* @abstract
|
|
101
|
+
* @param {string} tunnelId - Unique identifier of the tunnel to destroy
|
|
102
|
+
* @returns {Promise<void>} Promise that resolves when tunnel is destroyed
|
|
103
|
+
* @throws {Error} If tunnel ID is not found or destruction fails
|
|
104
|
+
*/
|
|
105
|
+
async disconnect(tunnelId) {
|
|
106
|
+
throw new Error('Abstract method "disconnect" must be implemented by subclass');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Abstract method to get current service status
|
|
111
|
+
* @abstract
|
|
112
|
+
* @returns {Object} Current status of the service
|
|
113
|
+
*/
|
|
114
|
+
getStatus() {
|
|
115
|
+
throw new Error('Abstract method "getStatus" must be implemented by subclass');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Shutdown method for cleanup when service is no longer needed
|
|
120
|
+
* Can be overridden by subclasses for specific cleanup logic
|
|
121
|
+
* @returns {Promise<void>} Promise that resolves when shutdown is complete
|
|
122
|
+
*/
|
|
123
|
+
async shutdown() {
|
|
124
|
+
this.isShuttingDown = true;
|
|
125
|
+
|
|
126
|
+
// Disconnect all active tunnels
|
|
127
|
+
const disconnectPromises = Array.from(this.activeTunnels.keys()).map(tunnelId =>
|
|
128
|
+
this.disconnect(tunnelId).catch(error => {
|
|
129
|
+
// Silently handle shutdown errors
|
|
130
|
+
})
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
await Promise.allSettled(disconnectPromises);
|
|
134
|
+
this.activeTunnels.clear();
|
|
135
|
+
this.removeAllListeners();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Validates configuration object for the service
|
|
140
|
+
* Can be overridden by subclasses for service-specific validation
|
|
141
|
+
* @param {Object} config - Configuration to validate
|
|
142
|
+
* @throws {ConfigurationError} If configuration is invalid
|
|
143
|
+
*/
|
|
144
|
+
validateConfig(config) {
|
|
145
|
+
if (!config || typeof config !== 'object') {
|
|
146
|
+
throw new ConfigurationError('Configuration must be an object');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Base validation - subclasses can override for specific requirements
|
|
150
|
+
if (config.timeout !== undefined && (typeof config.timeout !== 'number' || config.timeout <= 0)) {
|
|
151
|
+
throw new ConfigurationError('Timeout must be a positive number', 'timeout', config.timeout);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Determines if an error is transient (should trigger fallback)
|
|
157
|
+
* @param {Error} error - Error to categorize
|
|
158
|
+
* @returns {boolean} True if error is transient
|
|
159
|
+
*/
|
|
160
|
+
isTransientError(error) {
|
|
161
|
+
if (error instanceof TransientTunnelError) return true;
|
|
162
|
+
if (error instanceof PermanentTunnelError || error instanceof CriticalTunnelError) return false;
|
|
163
|
+
|
|
164
|
+
const message = error.message ? error.message.toLowerCase() : '';
|
|
165
|
+
const transientPatterns = [
|
|
166
|
+
'timeout',
|
|
167
|
+
'connection refused',
|
|
168
|
+
'network unreachable',
|
|
169
|
+
'host unreachable',
|
|
170
|
+
'temporarily unavailable',
|
|
171
|
+
'service unavailable',
|
|
172
|
+
'econnrefused',
|
|
173
|
+
'enotfound',
|
|
174
|
+
'etimedout'
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
return transientPatterns.some(pattern => message.includes(pattern));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Determines if an error is permanent (should not trigger fallback)
|
|
182
|
+
* @param {Error} error - Error to categorize
|
|
183
|
+
* @returns {boolean} True if error is permanent
|
|
184
|
+
*/
|
|
185
|
+
isPermanentError(error) {
|
|
186
|
+
if (error instanceof PermanentTunnelError) return true;
|
|
187
|
+
if (error instanceof TransientTunnelError) return false;
|
|
188
|
+
|
|
189
|
+
const message = error.message ? error.message.toLowerCase() : '';
|
|
190
|
+
const permanentPatterns = [
|
|
191
|
+
'ssh: command not found',
|
|
192
|
+
'permission denied',
|
|
193
|
+
'authentication failed',
|
|
194
|
+
'invalid configuration',
|
|
195
|
+
'command not found',
|
|
196
|
+
'access denied',
|
|
197
|
+
'unauthorized',
|
|
198
|
+
'forbidden',
|
|
199
|
+
'invalid subdomain',
|
|
200
|
+
'malformed',
|
|
201
|
+
'eacces'
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
return permanentPatterns.some(pattern => message.includes(pattern));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Determines if an error is critical (should stop all operations)
|
|
209
|
+
* @param {Error} error - Error to categorize
|
|
210
|
+
* @returns {boolean} True if error is critical
|
|
211
|
+
*/
|
|
212
|
+
isCriticalError(error) {
|
|
213
|
+
if (error instanceof CriticalTunnelError) return true;
|
|
214
|
+
|
|
215
|
+
const message = error.message ? error.message.toLowerCase() : '';
|
|
216
|
+
const criticalPatterns = [
|
|
217
|
+
'out of memory',
|
|
218
|
+
'file descriptor',
|
|
219
|
+
'resource exhaustion',
|
|
220
|
+
'security violation',
|
|
221
|
+
'corrupted',
|
|
222
|
+
'system error',
|
|
223
|
+
'emfile',
|
|
224
|
+
'enomem'
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
return criticalPatterns.some(pattern => message.includes(pattern));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Generates a unique tunnel ID
|
|
232
|
+
* @protected
|
|
233
|
+
* @returns {string} Unique tunnel identifier
|
|
234
|
+
*/
|
|
235
|
+
_generateTunnelId() {
|
|
236
|
+
return `tunnel_${this.name}_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Emits a standardized progress event
|
|
241
|
+
* @protected
|
|
242
|
+
* @param {string} status - Status identifier (e.g., 'connecting', 'connected', 'disconnecting')
|
|
243
|
+
* @param {string} message - Human-readable progress message
|
|
244
|
+
* @param {string} [tunnelId] - Associated tunnel ID if applicable
|
|
245
|
+
*/
|
|
246
|
+
_emitProgress(status, message, tunnelId = null) {
|
|
247
|
+
this.emit('tunnelProgress', {
|
|
248
|
+
service: this.name,
|
|
249
|
+
status,
|
|
250
|
+
message,
|
|
251
|
+
tunnelId,
|
|
252
|
+
timestamp: new Date().toISOString()
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Emits a standardized error event
|
|
258
|
+
* @protected
|
|
259
|
+
* @param {Error} error - Error that occurred
|
|
260
|
+
* @param {string} [tunnelId] - Associated tunnel ID if applicable
|
|
261
|
+
*/
|
|
262
|
+
_emitError(error, tunnelId = null) {
|
|
263
|
+
this.emit('tunnelError', {
|
|
264
|
+
service: this.name,
|
|
265
|
+
error: error.message || String(error),
|
|
266
|
+
type: error.constructor ? error.constructor.name : 'Error',
|
|
267
|
+
tunnelId,
|
|
268
|
+
timestamp: new Date().toISOString(),
|
|
269
|
+
isTransient: this.isTransientError(error instanceof Error ? error : new Error(String(error))),
|
|
270
|
+
isPermanent: this.isPermanentError(error instanceof Error ? error : new Error(String(error))),
|
|
271
|
+
isCritical: this.isCriticalError(error instanceof Error ? error : new Error(String(error)))
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Emits a tunnel created event
|
|
277
|
+
* @protected
|
|
278
|
+
* @param {Object} tunnelInfo - Information about the created tunnel
|
|
279
|
+
*/
|
|
280
|
+
_emitTunnelCreated(tunnelInfo) {
|
|
281
|
+
this.emit('tunnelCreated', {
|
|
282
|
+
...tunnelInfo,
|
|
283
|
+
service: this.name,
|
|
284
|
+
timestamp: new Date().toISOString()
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Emits a tunnel destroyed event
|
|
290
|
+
* @protected
|
|
291
|
+
* @param {string} tunnelId - ID of the destroyed tunnel
|
|
292
|
+
*/
|
|
293
|
+
_emitTunnelDestroyed(tunnelId) {
|
|
294
|
+
this.emit('tunnelDestroyed', {
|
|
295
|
+
service: this.name,
|
|
296
|
+
tunnelId,
|
|
297
|
+
timestamp: new Date().toISOString()
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|