@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.
@@ -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
+ }