@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
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview TunnelManager - Main orchestrator for tunnel services
|
|
3
|
+
*
|
|
4
|
+
* Provides a unified interface for creating tunnels via multiple providers
|
|
5
|
+
* with automatic fallback support. KĀDI is the default primary service.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { EventEmitter } from 'events';
|
|
9
|
+
import createDebug from 'debug';
|
|
10
|
+
import { TunnelService } from './TunnelService.js';
|
|
11
|
+
import { TransientTunnelError, ConfigurationError, ServiceUnavailableError } from './errors.js';
|
|
12
|
+
|
|
13
|
+
const debug = createDebug('kadi:tunnel:manager');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* TunnelManager - Main orchestrator for tunnel services
|
|
17
|
+
*
|
|
18
|
+
* @extends EventEmitter
|
|
19
|
+
* @example
|
|
20
|
+
* const manager = new TunnelManager(); // Uses 'kadi' by default
|
|
21
|
+
* await manager.initialize();
|
|
22
|
+
* const tunnel = await manager.createTunnel(3000);
|
|
23
|
+
* console.log(tunnel.publicUrl);
|
|
24
|
+
*/
|
|
25
|
+
export class TunnelManager extends EventEmitter {
|
|
26
|
+
/**
|
|
27
|
+
* Create a new TunnelManager
|
|
28
|
+
* @param {Object} [config={}] - Manager configuration
|
|
29
|
+
* @param {string} [config.primaryService='kadi'] - Primary tunnel service
|
|
30
|
+
* @param {string[]|string} [config.fallbackServices] - Fallback services
|
|
31
|
+
* @param {boolean} [config.autoFallback=true] - Enable automatic fallback
|
|
32
|
+
* @param {string} [config.ngrokAuthToken] - Ngrok auth token
|
|
33
|
+
* @param {string} [config.ngrokRegion] - Ngrok region
|
|
34
|
+
* @param {string} [config.kadiServer] - KĀDI server address
|
|
35
|
+
* @param {number} [config.kadiPort] - KĀDI frpc port
|
|
36
|
+
* @param {number} [config.kadiSshPort] - KĀDI SSH port
|
|
37
|
+
* @param {string} [config.kadiToken] - KĀDI auth token
|
|
38
|
+
* @param {string} [config.kadiDomain] - KĀDI tunnel domain
|
|
39
|
+
* @param {string} [config.kadiMode] - KĀDI connection mode
|
|
40
|
+
* @param {string} [config.kadiAgentId] - KĀDI agent identifier
|
|
41
|
+
* @param {number} [config.maxConcurrentTunnels=10] - Max concurrent tunnels
|
|
42
|
+
* @param {number} [config.connectionTimeout=30000] - Default connection timeout
|
|
43
|
+
*/
|
|
44
|
+
constructor(config = {}) {
|
|
45
|
+
super();
|
|
46
|
+
this.config = config;
|
|
47
|
+
|
|
48
|
+
// Service configuration (KĀDI is the default primary service)
|
|
49
|
+
this.primaryService = config.primaryService || config.service || 'kadi';
|
|
50
|
+
this.fallbackServices = this._parseFallbackServices(
|
|
51
|
+
config.fallbackServices || 'serveo,ngrok,localtunnel,pinggy'
|
|
52
|
+
);
|
|
53
|
+
this.autoFallback = config.autoFallback !== false;
|
|
54
|
+
|
|
55
|
+
// Limits
|
|
56
|
+
this.maxConcurrentTunnels = config.maxConcurrentTunnels || 10;
|
|
57
|
+
this.connectionTimeout = config.connectionTimeout || 30000;
|
|
58
|
+
|
|
59
|
+
// State
|
|
60
|
+
this.tunnelService = new TunnelService(config);
|
|
61
|
+
this.activeTunnels = new Map();
|
|
62
|
+
this.isInitialized = false;
|
|
63
|
+
|
|
64
|
+
// Service-specific config (passed through to services)
|
|
65
|
+
this.serviceConfigs = {
|
|
66
|
+
ngrok: {
|
|
67
|
+
authToken: config.ngrokAuthToken || config.authToken,
|
|
68
|
+
region: config.ngrokRegion || config.region || 'us'
|
|
69
|
+
},
|
|
70
|
+
serveo: {
|
|
71
|
+
subdomain: config.serveoSubdomain || config.subdomain
|
|
72
|
+
},
|
|
73
|
+
localtunnel: {
|
|
74
|
+
subdomain: config.localtunnelSubdomain || config.subdomain
|
|
75
|
+
},
|
|
76
|
+
pinggy: {
|
|
77
|
+
subdomain: config.pinggySubdomain || config.subdomain
|
|
78
|
+
},
|
|
79
|
+
'localhost.run': {},
|
|
80
|
+
kadi: {
|
|
81
|
+
server: config.kadiServer || 'broker.kadi.build',
|
|
82
|
+
port: config.kadiPort || 7000,
|
|
83
|
+
sshPort: config.kadiSshPort || 2200,
|
|
84
|
+
token: config.kadiToken,
|
|
85
|
+
domain: config.kadiDomain || 'tunnel.kadi.build',
|
|
86
|
+
mode: config.kadiMode || 'auto',
|
|
87
|
+
agentId: config.kadiAgentId || 'kadi'
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Initialize the tunnel service (discovers available services)
|
|
94
|
+
* @returns {Promise<TunnelManager>} This manager instance
|
|
95
|
+
*/
|
|
96
|
+
async initialize() {
|
|
97
|
+
if (this.isInitialized) return this;
|
|
98
|
+
|
|
99
|
+
await this.tunnelService.initialize();
|
|
100
|
+
this._setupEventForwarding();
|
|
101
|
+
this.isInitialized = true;
|
|
102
|
+
|
|
103
|
+
debug('TunnelManager initialized with services: %s', this.tunnelService.getAvailableServices().join(', '));
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Create a tunnel to expose a local port
|
|
109
|
+
* @param {number} port - Local port to expose
|
|
110
|
+
* @param {Object} [options={}] - Tunnel options
|
|
111
|
+
* @param {string} [options.service] - Specific service to use
|
|
112
|
+
* @param {string} [options.subdomain] - Requested subdomain
|
|
113
|
+
* @param {string} [options.region] - Preferred region
|
|
114
|
+
* @param {number} [options.timeout] - Connection timeout
|
|
115
|
+
* @param {Object} [options.metadata] - Custom metadata to attach
|
|
116
|
+
* @returns {Promise<Object>} Tunnel information
|
|
117
|
+
*/
|
|
118
|
+
async createTunnel(port, options = {}) {
|
|
119
|
+
await this._ensureInitialized();
|
|
120
|
+
|
|
121
|
+
if (this.activeTunnels.size >= this.maxConcurrentTunnels) {
|
|
122
|
+
throw new ConfigurationError(`Maximum concurrent tunnels (${this.maxConcurrentTunnels}) reached`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const serviceName = options.service || this.primaryService;
|
|
126
|
+
const servicesToTry = this._getServicesToTry(serviceName);
|
|
127
|
+
|
|
128
|
+
debug(`Creating tunnel for port ${port}, services to try: ${servicesToTry.join(', ')}`);
|
|
129
|
+
|
|
130
|
+
let lastError = null;
|
|
131
|
+
|
|
132
|
+
for (const service of servicesToTry) {
|
|
133
|
+
try {
|
|
134
|
+
const tunnel = await this._createTunnelWithService(port, service, options);
|
|
135
|
+
|
|
136
|
+
// Normalize tunnel info to a consistent format
|
|
137
|
+
const tunnelInfo = {
|
|
138
|
+
id: tunnel.tunnelId || tunnel.id || `tunnel-${Date.now()}`,
|
|
139
|
+
publicUrl: tunnel.url || tunnel.publicUrl,
|
|
140
|
+
localPort: tunnel.localPort || port,
|
|
141
|
+
service: tunnel.service || service,
|
|
142
|
+
subdomain: tunnel.subdomain,
|
|
143
|
+
createdAt: tunnel.createdAt || new Date(),
|
|
144
|
+
metadata: { ...tunnel, ...(options.metadata || {}) }
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Store active tunnel
|
|
148
|
+
this.activeTunnels.set(tunnelInfo.id, tunnelInfo);
|
|
149
|
+
|
|
150
|
+
this.emit('tunnelCreated', tunnelInfo);
|
|
151
|
+
debug(`Tunnel created: ${tunnelInfo.publicUrl} via ${tunnelInfo.service}`);
|
|
152
|
+
return tunnelInfo;
|
|
153
|
+
|
|
154
|
+
} catch (error) {
|
|
155
|
+
debug(`Service ${service} failed: ${error.message}`);
|
|
156
|
+
lastError = error;
|
|
157
|
+
|
|
158
|
+
// Only continue to fallback for transient errors or when autoFallback is enabled
|
|
159
|
+
if (!this.autoFallback && !(error instanceof TransientTunnelError)) {
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.emit('serviceFailed', { service, error: error.message });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// All services failed
|
|
168
|
+
throw lastError || new Error('All tunnel services failed');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Create tunnel with a specific service
|
|
173
|
+
* @private
|
|
174
|
+
*/
|
|
175
|
+
async _createTunnelWithService(port, serviceName, options) {
|
|
176
|
+
let service;
|
|
177
|
+
try {
|
|
178
|
+
service = this.tunnelService.getService(serviceName);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (error instanceof ServiceUnavailableError) {
|
|
181
|
+
throw new TransientTunnelError(`Service '${serviceName}' not available`);
|
|
182
|
+
}
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const serviceConfig = this.serviceConfigs[serviceName] || {};
|
|
187
|
+
|
|
188
|
+
const tunnelOptions = {
|
|
189
|
+
port,
|
|
190
|
+
subdomain: options.subdomain || serviceConfig.subdomain,
|
|
191
|
+
region: options.region || serviceConfig.region,
|
|
192
|
+
timeout: options.timeout || this.connectionTimeout,
|
|
193
|
+
...serviceConfig,
|
|
194
|
+
...options
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
debug(`Connecting via ${serviceName} on port ${port}`);
|
|
198
|
+
|
|
199
|
+
return await service.connect(tunnelOptions);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Close a specific tunnel
|
|
204
|
+
* @param {string} tunnelId - Tunnel ID to close
|
|
205
|
+
* @returns {Promise<void>}
|
|
206
|
+
*/
|
|
207
|
+
async closeTunnel(tunnelId) {
|
|
208
|
+
const tunnel = this.activeTunnels.get(tunnelId);
|
|
209
|
+
if (!tunnel) {
|
|
210
|
+
throw new Error(`Tunnel ${tunnelId} not found`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const service = this.tunnelService.getService(tunnel.service);
|
|
215
|
+
await service.disconnect(tunnelId);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
debug(`Error during service disconnect for ${tunnelId}: ${error.message}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
this.activeTunnels.delete(tunnelId);
|
|
221
|
+
this.emit('tunnelClosed', { id: tunnelId });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Close all active tunnels
|
|
226
|
+
* @returns {Promise<void>}
|
|
227
|
+
*/
|
|
228
|
+
async closeAllTunnels() {
|
|
229
|
+
const tunnelIds = Array.from(this.activeTunnels.keys());
|
|
230
|
+
|
|
231
|
+
for (const id of tunnelIds) {
|
|
232
|
+
try {
|
|
233
|
+
await this.closeTunnel(id);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
debug(`Error closing tunnel ${id}: ${error.message}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get status of all active tunnels and services
|
|
242
|
+
* @returns {Object} Status information
|
|
243
|
+
*/
|
|
244
|
+
getStatus() {
|
|
245
|
+
return {
|
|
246
|
+
isInitialized: this.isInitialized,
|
|
247
|
+
primaryService: this.primaryService,
|
|
248
|
+
availableServices: this.isInitialized ? this.tunnelService.getAvailableServices() : [],
|
|
249
|
+
activeTunnels: Array.from(this.activeTunnels.values()).map(t => ({
|
|
250
|
+
id: t.id,
|
|
251
|
+
publicUrl: t.publicUrl,
|
|
252
|
+
localPort: t.localPort,
|
|
253
|
+
service: t.service,
|
|
254
|
+
createdAt: t.createdAt
|
|
255
|
+
}))
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Get list of available tunnel services
|
|
261
|
+
* @returns {string[]} Array of service names
|
|
262
|
+
*/
|
|
263
|
+
getAvailableServices() {
|
|
264
|
+
if (!this.isInitialized) return [];
|
|
265
|
+
return this.tunnelService.getAvailableServices();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Test if a specific service is available
|
|
270
|
+
* @param {string} serviceName - Service to test
|
|
271
|
+
* @returns {Promise<Object>} Test result
|
|
272
|
+
*/
|
|
273
|
+
async testService(serviceName) {
|
|
274
|
+
await this._ensureInitialized();
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const service = this.tunnelService.getService(serviceName);
|
|
278
|
+
const status = service.getStatus();
|
|
279
|
+
return { available: true, status };
|
|
280
|
+
} catch (error) {
|
|
281
|
+
return { available: false, reason: error.message };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Run diagnostics on all services
|
|
287
|
+
* @returns {Promise<Object>} Diagnostics results
|
|
288
|
+
*/
|
|
289
|
+
async runDiagnostics() {
|
|
290
|
+
await this._ensureInitialized();
|
|
291
|
+
|
|
292
|
+
const results = {};
|
|
293
|
+
const services = this.getAvailableServices();
|
|
294
|
+
|
|
295
|
+
for (const serviceName of services) {
|
|
296
|
+
results[serviceName] = await this.testService(serviceName);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return results;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Cleanup all resources
|
|
304
|
+
* @returns {Promise<void>}
|
|
305
|
+
*/
|
|
306
|
+
async cleanup() {
|
|
307
|
+
await this.closeAllTunnels();
|
|
308
|
+
if (this.isInitialized) {
|
|
309
|
+
await this.tunnelService.shutdown();
|
|
310
|
+
this.isInitialized = false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ============================================================================
|
|
315
|
+
// HELPER METHODS
|
|
316
|
+
// ============================================================================
|
|
317
|
+
|
|
318
|
+
_parseFallbackServices(fallbackString) {
|
|
319
|
+
if (Array.isArray(fallbackString)) {
|
|
320
|
+
return fallbackString;
|
|
321
|
+
}
|
|
322
|
+
return fallbackString.split(',').map(s => s.trim()).filter(Boolean);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
_getServicesToTry(primaryService) {
|
|
326
|
+
if (primaryService === 'auto') {
|
|
327
|
+
return ['kadi', ...this.fallbackServices.filter(s => s !== 'kadi')];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (this.autoFallback) {
|
|
331
|
+
return [primaryService, ...this.fallbackServices.filter(s => s !== primaryService)];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return [primaryService];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async _ensureInitialized() {
|
|
338
|
+
if (!this.isInitialized) {
|
|
339
|
+
await this.initialize();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
_setupEventForwarding() {
|
|
344
|
+
const services = this.tunnelService.getAvailableServices();
|
|
345
|
+
|
|
346
|
+
for (const serviceName of services) {
|
|
347
|
+
try {
|
|
348
|
+
const service = this.tunnelService.getService(serviceName);
|
|
349
|
+
|
|
350
|
+
service.on('tunnelProgress', (data) => {
|
|
351
|
+
this.emit('progress', { service: serviceName, ...data });
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
service.on('tunnelError', (data) => {
|
|
355
|
+
this.emit('serviceError', { service: serviceName, ...data });
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
service.on('tunnelDestroyed', (data) => {
|
|
359
|
+
this.emit('tunnelDisconnected', { service: serviceName, ...data });
|
|
360
|
+
|
|
361
|
+
// Clean up our tracking if tunnel was destroyed externally
|
|
362
|
+
const tunnelId = data.tunnelId;
|
|
363
|
+
if (tunnelId && this.activeTunnels.has(tunnelId)) {
|
|
364
|
+
this.activeTunnels.delete(tunnelId);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
} catch (error) {
|
|
368
|
+
debug(`Could not set up event forwarding for ${serviceName}: ${error.message}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Service registration and management interface for tunnel services
|
|
3
|
+
* Based on TunnelManagerSpec.md Section 3.3 specification
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { promises as fs } from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import createDebug from 'debug';
|
|
10
|
+
import { BaseTunnelService } from './BaseTunnelService.js';
|
|
11
|
+
import { ConfigurationError, ServiceUnavailableError } from './errors.js';
|
|
12
|
+
|
|
13
|
+
const debug = createDebug('kadi:tunnel');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* TunnelService class for managing and registering tunnel service implementations.
|
|
17
|
+
* This class provides service discovery, registration, and validation capabilities.
|
|
18
|
+
*/
|
|
19
|
+
export class TunnelService {
|
|
20
|
+
/**
|
|
21
|
+
* Creates a new TunnelService instance
|
|
22
|
+
* @param {Object} config - Global tunnel configuration
|
|
23
|
+
*/
|
|
24
|
+
constructor(config = {}) {
|
|
25
|
+
this.config = config;
|
|
26
|
+
this.services = new Map();
|
|
27
|
+
this.serviceInstances = new Map();
|
|
28
|
+
this.isInitialized = false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Initializes the service by discovering and loading all available services
|
|
33
|
+
* @param {string} [servicesDir] - Directory to scan for service files (optional)
|
|
34
|
+
* @returns {Promise<void>}
|
|
35
|
+
*/
|
|
36
|
+
async initialize(servicesDir = null) {
|
|
37
|
+
if (this.isInitialized) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Default to the services directory relative to this file
|
|
42
|
+
if (!servicesDir) {
|
|
43
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
44
|
+
const __dirname = path.dirname(__filename);
|
|
45
|
+
servicesDir = path.join(__dirname, 'services');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await this.discoverServices(servicesDir);
|
|
49
|
+
this.isInitialized = true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Discovers and loads all service modules from the specified directory
|
|
54
|
+
* @param {string} servicesDir - Directory to scan for service files
|
|
55
|
+
* @returns {Promise<void>}
|
|
56
|
+
*/
|
|
57
|
+
async discoverServices(servicesDir) {
|
|
58
|
+
try {
|
|
59
|
+
// Check if services directory exists
|
|
60
|
+
const stats = await fs.stat(servicesDir);
|
|
61
|
+
if (!stats.isDirectory()) {
|
|
62
|
+
debug(`Services directory ${servicesDir} is not a directory`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Read all files in the services directory
|
|
67
|
+
const files = await fs.readdir(servicesDir);
|
|
68
|
+
|
|
69
|
+
// Filter for JavaScript files that likely contain services
|
|
70
|
+
const serviceFiles = files.filter(file =>
|
|
71
|
+
file.endsWith('.js') &&
|
|
72
|
+
file.includes('Service') &&
|
|
73
|
+
!file.includes('.test.') &&
|
|
74
|
+
!file.includes('.spec.')
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
debug(`🔍 Discovering tunnel services in ${servicesDir}`);
|
|
78
|
+
debug(` Found ${serviceFiles.length} potential service files: ${serviceFiles.join(', ')}`);
|
|
79
|
+
|
|
80
|
+
// Load each service module
|
|
81
|
+
for (const file of serviceFiles) {
|
|
82
|
+
try {
|
|
83
|
+
await this.loadService(path.join(servicesDir, file));
|
|
84
|
+
} catch (error) {
|
|
85
|
+
debug(`⚠️ Failed to load service from ${file}: ${error.message}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
debug(`✅ Loaded ${this.services.size} tunnel services: ${Array.from(this.services.keys()).join(', ')}`);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
debug(`Warning: Could not access services directory ${servicesDir}: ${error.message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Loads a single service module and registers it
|
|
97
|
+
* @param {string} servicePath - Full path to the service module file
|
|
98
|
+
* @returns {Promise<void>}
|
|
99
|
+
*/
|
|
100
|
+
async loadService(servicePath) {
|
|
101
|
+
try {
|
|
102
|
+
// Dynamic import of the service module
|
|
103
|
+
const absolutePath = path.isAbsolute(servicePath) ? servicePath : path.resolve(servicePath);
|
|
104
|
+
const importPath = `file://${absolutePath}`;
|
|
105
|
+
const serviceModule = await import(importPath);
|
|
106
|
+
|
|
107
|
+
// Look for the default export or a named export that extends BaseTunnelService
|
|
108
|
+
let ServiceClass = null;
|
|
109
|
+
|
|
110
|
+
if (serviceModule.default && this.isValidServiceClass(serviceModule.default)) {
|
|
111
|
+
ServiceClass = serviceModule.default;
|
|
112
|
+
} else {
|
|
113
|
+
// Look for named exports that extend BaseTunnelService
|
|
114
|
+
for (const [name, exportedClass] of Object.entries(serviceModule)) {
|
|
115
|
+
if (this.isValidServiceClass(exportedClass)) {
|
|
116
|
+
ServiceClass = exportedClass;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!ServiceClass) {
|
|
123
|
+
throw new Error(`No valid service class found in ${servicePath}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Register the service class
|
|
127
|
+
this.registerService(ServiceClass);
|
|
128
|
+
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw new Error(`Failed to load service from ${servicePath}: ${error.message}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Validates that a class is a proper tunnel service implementation
|
|
136
|
+
* @param {Function} ServiceClass - Class to validate
|
|
137
|
+
* @returns {boolean} True if class is a valid service
|
|
138
|
+
*/
|
|
139
|
+
isValidServiceClass(ServiceClass) {
|
|
140
|
+
// Must be a function (class constructor)
|
|
141
|
+
if (typeof ServiceClass !== 'function') {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Must extend BaseTunnelService
|
|
146
|
+
if (!ServiceClass.prototype || !(ServiceClass.prototype instanceof BaseTunnelService)) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Registers a service class for use
|
|
155
|
+
* @param {Function|BaseTunnelService} serviceOrClass - Service class or instance
|
|
156
|
+
* @throws {ConfigurationError} If service class is invalid
|
|
157
|
+
*/
|
|
158
|
+
registerService(serviceOrClass) {
|
|
159
|
+
// Allow registering instances directly
|
|
160
|
+
if (serviceOrClass instanceof BaseTunnelService) {
|
|
161
|
+
const serviceName = serviceOrClass.name;
|
|
162
|
+
if (!serviceName || typeof serviceName !== 'string') {
|
|
163
|
+
throw new ConfigurationError('Service name must be a non-empty string');
|
|
164
|
+
}
|
|
165
|
+
this.serviceInstances.set(serviceName, serviceOrClass);
|
|
166
|
+
// Also register a "class" entry so hasService() works
|
|
167
|
+
this.services.set(serviceName, serviceOrClass.constructor);
|
|
168
|
+
debug(`📝 Registered tunnel service instance: ${serviceName}`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!this.isValidServiceClass(serviceOrClass)) {
|
|
173
|
+
throw new ConfigurationError('Service class must extend BaseTunnelService');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Create a temporary instance to get the service name
|
|
177
|
+
let serviceName;
|
|
178
|
+
try {
|
|
179
|
+
const tempInstance = new serviceOrClass(this.config);
|
|
180
|
+
serviceName = tempInstance.name;
|
|
181
|
+
|
|
182
|
+
// Validate that name is a non-empty string
|
|
183
|
+
if (!serviceName || typeof serviceName !== 'string') {
|
|
184
|
+
throw new ConfigurationError('Service name must be a non-empty string');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Clean up temporary instance
|
|
188
|
+
tempInstance.removeAllListeners();
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error instanceof ConfigurationError) throw error;
|
|
191
|
+
throw new ConfigurationError(`Failed to instantiate service for registration: ${error.message}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Register the service class
|
|
195
|
+
this.services.set(serviceName, serviceOrClass);
|
|
196
|
+
debug(`📝 Registered tunnel service: ${serviceName}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Gets a service instance by name, creating it if necessary
|
|
201
|
+
* @param {string} serviceName - Name of the service to get
|
|
202
|
+
* @returns {BaseTunnelService} Service instance
|
|
203
|
+
* @throws {ServiceUnavailableError} If service is not found
|
|
204
|
+
*/
|
|
205
|
+
getService(serviceName) {
|
|
206
|
+
if (!this.services.has(serviceName)) {
|
|
207
|
+
throw new ServiceUnavailableError(serviceName, `Service '${serviceName}' is not registered`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Return existing instance if available
|
|
211
|
+
if (this.serviceInstances.has(serviceName)) {
|
|
212
|
+
return this.serviceInstances.get(serviceName);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Create new instance
|
|
216
|
+
const ServiceClass = this.services.get(serviceName);
|
|
217
|
+
const instance = new ServiceClass(this.config);
|
|
218
|
+
|
|
219
|
+
this.serviceInstances.set(serviceName, instance);
|
|
220
|
+
return instance;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Checks if a service is available
|
|
225
|
+
* @param {string} serviceName - Name of the service to check
|
|
226
|
+
* @returns {boolean} True if service is available
|
|
227
|
+
*/
|
|
228
|
+
hasService(serviceName) {
|
|
229
|
+
return this.services.has(serviceName);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Gets a list of all registered service names
|
|
234
|
+
* @returns {string[]} Array of service names
|
|
235
|
+
*/
|
|
236
|
+
getAvailableServices() {
|
|
237
|
+
return Array.from(this.services.keys());
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Gets status information for all services
|
|
242
|
+
* @returns {Object} Status information keyed by service name
|
|
243
|
+
*/
|
|
244
|
+
getServicesStatus() {
|
|
245
|
+
const status = {};
|
|
246
|
+
|
|
247
|
+
for (const serviceName of this.services.keys()) {
|
|
248
|
+
try {
|
|
249
|
+
if (this.serviceInstances.has(serviceName)) {
|
|
250
|
+
const instance = this.serviceInstances.get(serviceName);
|
|
251
|
+
const serviceStatus = instance.getStatus();
|
|
252
|
+
status[serviceName] = {
|
|
253
|
+
...serviceStatus,
|
|
254
|
+
loaded: true
|
|
255
|
+
};
|
|
256
|
+
} else {
|
|
257
|
+
status[serviceName] = {
|
|
258
|
+
serviceName,
|
|
259
|
+
available: true,
|
|
260
|
+
loaded: false,
|
|
261
|
+
activeTunnels: 0
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
} catch (error) {
|
|
265
|
+
status[serviceName] = {
|
|
266
|
+
serviceName,
|
|
267
|
+
available: false,
|
|
268
|
+
error: error.message,
|
|
269
|
+
loaded: this.serviceInstances.has(serviceName)
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return status;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Validates a service configuration
|
|
279
|
+
* @param {string} serviceName - Name of the service
|
|
280
|
+
* @param {Object} config - Configuration to validate
|
|
281
|
+
* @throws {ServiceUnavailableError} If service is not found
|
|
282
|
+
* @throws {ConfigurationError} If configuration is invalid
|
|
283
|
+
*/
|
|
284
|
+
validateServiceConfig(serviceName, config) {
|
|
285
|
+
if (!this.hasService(serviceName)) {
|
|
286
|
+
throw new ServiceUnavailableError(serviceName);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const service = this.getService(serviceName);
|
|
290
|
+
service.validateConfig(config);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Shuts down all service instances and cleans up resources
|
|
295
|
+
* @returns {Promise<void>}
|
|
296
|
+
*/
|
|
297
|
+
async shutdown() {
|
|
298
|
+
debug('🔄 Shutting down tunnel services...');
|
|
299
|
+
|
|
300
|
+
// Shutdown all service instances
|
|
301
|
+
const shutdownPromises = Array.from(this.serviceInstances.values()).map(instance =>
|
|
302
|
+
instance.shutdown().catch(error => {
|
|
303
|
+
debug(`Warning: Failed to shutdown service ${instance.name}:`, error.message);
|
|
304
|
+
})
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
await Promise.allSettled(shutdownPromises);
|
|
308
|
+
|
|
309
|
+
// Clear all registrations
|
|
310
|
+
this.services.clear();
|
|
311
|
+
this.serviceInstances.clear();
|
|
312
|
+
this.isInitialized = false;
|
|
313
|
+
|
|
314
|
+
debug('✅ All tunnel services shut down');
|
|
315
|
+
}
|
|
316
|
+
}
|