@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,188 @@
1
+ /**
2
+ * @fileoverview LocalTunnel service implementation
3
+ * Provides tunneling through localtunnel npm package
4
+ */
5
+
6
+ import createDebug from 'debug';
7
+ import { BaseTunnelService } from '../BaseTunnelService.js';
8
+ import {
9
+ TransientTunnelError,
10
+ PermanentTunnelError,
11
+ ConnectionTimeoutError
12
+ } from '../errors.js';
13
+
14
+ const debug = createDebug('kadi:tunnel:localtunnel');
15
+
16
+ // Dynamic import of localtunnel (optional dependency)
17
+ let localtunnel;
18
+ try {
19
+ const ltModule = await import('localtunnel');
20
+ localtunnel = ltModule.default || ltModule;
21
+ } catch {
22
+ localtunnel = null;
23
+ }
24
+
25
+ /**
26
+ * LocalTunnel service implementation
27
+ *
28
+ * Provides tunneling through localtunnel with support for:
29
+ * - Custom subdomain support
30
+ * - Automatic npm package validation
31
+ * - URL extraction from localtunnel output
32
+ * - Process management and cleanup
33
+ * - Comprehensive error handling
34
+ *
35
+ * @extends BaseTunnelService
36
+ */
37
+ export default class LocalTunnelService extends BaseTunnelService {
38
+
39
+ constructor(config) {
40
+ super(config);
41
+ this.DEFAULT_TIMEOUT = 30000;
42
+ }
43
+
44
+ get name() {
45
+ return 'localtunnel';
46
+ }
47
+
48
+ async connect(options) {
49
+ if (!options || typeof options.port !== 'number') {
50
+ throw new PermanentTunnelError('Port number is required for LocalTunnel');
51
+ }
52
+
53
+ if (options.port < 1 || options.port > 65535) {
54
+ throw new PermanentTunnelError(`Invalid port number: ${options.port}. Must be between 1-65535`);
55
+ }
56
+
57
+ if (!localtunnel) {
58
+ throw new PermanentTunnelError(
59
+ 'localtunnel package is not installed. Install with: npm install localtunnel'
60
+ );
61
+ }
62
+
63
+ const tunnelId = this._generateTunnelId();
64
+ const timeout = options.timeout || this.DEFAULT_TIMEOUT;
65
+
66
+ this._emitProgress('connecting', 'Establishing LocalTunnel connection...', tunnelId);
67
+
68
+ try {
69
+ const ltOptions = {
70
+ port: options.port,
71
+ subdomain: options.subdomain || undefined,
72
+ host: 'https://localtunnel.me'
73
+ };
74
+
75
+ debug(`🔗 Creating LocalTunnel: port ${options.port}${options.subdomain ? ` subdomain ${options.subdomain}` : ''}`);
76
+
77
+ const tunnel = await Promise.race([
78
+ localtunnel(ltOptions),
79
+ new Promise((_, reject) =>
80
+ setTimeout(() => reject(new ConnectionTimeoutError(this.name, timeout)), timeout)
81
+ )
82
+ ]);
83
+
84
+ const tunnelInfo = {
85
+ id: tunnelId,
86
+ serviceName: 'localtunnel',
87
+ localPort: options.port,
88
+ subdomain: options.subdomain,
89
+ url: tunnel.url,
90
+ tunnel: tunnel,
91
+ createdAt: new Date(),
92
+ status: 'active'
93
+ };
94
+
95
+ this.activeTunnels.set(tunnelId, tunnelInfo);
96
+
97
+ this._emitProgress('connected', 'LocalTunnel established', tunnelId);
98
+ this._emitTunnelCreated(tunnelInfo);
99
+
100
+ debug(`✅ LocalTunnel established: ${tunnel.url}`);
101
+
102
+ return {
103
+ tunnelId: tunnelId,
104
+ url: tunnel.url,
105
+ subdomain: this._extractSubdomain(tunnel.url),
106
+ localPort: options.port,
107
+ createdAt: new Date(),
108
+ status: 'active',
109
+ service: 'localtunnel'
110
+ };
111
+
112
+ } catch (error) {
113
+ this.activeTunnels.delete(tunnelId);
114
+
115
+ if (error instanceof ConnectionTimeoutError) {
116
+ throw error;
117
+ }
118
+
119
+ const errorMessage = error.message || error.toString();
120
+
121
+ if (errorMessage.includes('subdomain') && errorMessage.includes('not available')) {
122
+ throw new PermanentTunnelError(
123
+ 'Subdomain is not available. Try a different subdomain or remove subdomain requirement.'
124
+ );
125
+ }
126
+
127
+ if (errorMessage.includes('port') && errorMessage.includes('in use')) {
128
+ throw new TransientTunnelError(`Port ${options.port} is already in use`);
129
+ }
130
+
131
+ if (errorMessage.includes('ECONNREFUSED') || errorMessage.includes('network') || errorMessage.includes('timeout')) {
132
+ throw new TransientTunnelError(`Network error: ${errorMessage}`);
133
+ }
134
+
135
+ throw new TransientTunnelError(`LocalTunnel creation failed: ${errorMessage}`);
136
+ }
137
+ }
138
+
139
+ async disconnect(tunnelId) {
140
+ const tunnelInfo = this.activeTunnels.get(tunnelId);
141
+
142
+ if (!tunnelInfo) {
143
+ return false;
144
+ }
145
+
146
+ this._emitProgress('disconnecting', 'Destroying LocalTunnel...', tunnelId);
147
+
148
+ try {
149
+ if (tunnelInfo.tunnel && typeof tunnelInfo.tunnel.close === 'function') {
150
+ tunnelInfo.tunnel.close();
151
+ debug(`🔌 LocalTunnel disconnected: ${tunnelInfo.url}`);
152
+ }
153
+
154
+ this.activeTunnels.delete(tunnelId);
155
+ this._emitProgress('disconnected', 'LocalTunnel destroyed', tunnelId);
156
+ this._emitTunnelDestroyed(tunnelId);
157
+
158
+ debug(`✅ LocalTunnel ${tunnelId} disconnected`);
159
+ return true;
160
+ } catch (error) {
161
+ this.activeTunnels.delete(tunnelId);
162
+ this._emitTunnelDestroyed(tunnelId);
163
+ throw error;
164
+ }
165
+ }
166
+
167
+ getStatus() {
168
+ return {
169
+ serviceName: 'localtunnel',
170
+ isActive: this.activeTunnels.size > 0,
171
+ activeTunnels: this.activeTunnels.size,
172
+ available: !!localtunnel,
173
+ tunnels: Array.from(this.activeTunnels.values()).map(tunnel => ({
174
+ id: tunnel.id,
175
+ url: tunnel.url,
176
+ localPort: tunnel.localPort,
177
+ status: tunnel.status,
178
+ createdAt: tunnel.createdAt
179
+ }))
180
+ };
181
+ }
182
+
183
+ _extractSubdomain(url) {
184
+ if (!url) return null;
185
+ const match = url.match(/https:\/\/([a-zA-Z0-9-]+)\.loca\.lt/);
186
+ return match ? match[1] : null;
187
+ }
188
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * @fileoverview LocalhostRun tunnel service implementation
3
+ * Provides SSH-based tunneling through localhost.run
4
+ */
5
+
6
+ import createDebug from 'debug';
7
+ import { BaseTunnelService } from '../BaseTunnelService.js';
8
+ import {
9
+ TransientTunnelError,
10
+ PermanentTunnelError,
11
+ SSHUnavailableError,
12
+ ConnectionTimeoutError
13
+ } from '../errors.js';
14
+ import { spawn } from 'child_process';
15
+
16
+ const debug = createDebug('kadi:tunnel:localhost.run');
17
+
18
+ /**
19
+ * LocalhostRun tunnel service implementation
20
+ *
21
+ * Provides SSH-based tunneling through localhost.run with support for:
22
+ * - Automatic URL extraction from SSH output
23
+ * - Email verification handling
24
+ * - Process management and cleanup
25
+ * - Comprehensive error handling
26
+ *
27
+ * @extends BaseTunnelService
28
+ */
29
+ export default class LocalhostRunTunnelService extends BaseTunnelService {
30
+
31
+ constructor(config) {
32
+ super(config);
33
+ this.DEFAULT_TIMEOUT = 30000;
34
+ }
35
+
36
+ get name() {
37
+ return 'localhost.run';
38
+ }
39
+
40
+ async connect(options) {
41
+ if (!options || typeof options.port !== 'number' || options.port < 1 || options.port > 65535) {
42
+ throw new PermanentTunnelError('Valid port number (1-65535) is required for LocalhostRun tunnel');
43
+ }
44
+
45
+ const tunnelId = this._generateTunnelId();
46
+ const timeout = options.timeout || this.DEFAULT_TIMEOUT;
47
+
48
+ this._emitProgress('connecting', 'Establishing LocalhostRun tunnel...', tunnelId);
49
+
50
+ return new Promise((resolve, reject) => {
51
+ let resolved = false;
52
+ let sshProcess = null;
53
+ let timeoutHandle = null;
54
+
55
+ const sshArgs = this._buildSSHArgs(options.port);
56
+
57
+ debug(`🔗 Creating localhost.run tunnel: ssh ${sshArgs.join(' ')}`);
58
+
59
+ try {
60
+ sshProcess = spawn('ssh', sshArgs, {
61
+ stdio: ['pipe', 'pipe', 'pipe']
62
+ });
63
+
64
+ const tunnelInfo = {
65
+ id: tunnelId,
66
+ serviceName: 'localhost.run',
67
+ localPort: options.port,
68
+ process: sshProcess,
69
+ createdAt: new Date(),
70
+ status: 'connecting'
71
+ };
72
+
73
+ this.activeTunnels.set(tunnelId, tunnelInfo);
74
+
75
+ let output = '';
76
+ let tunnelUrl = null;
77
+
78
+ const handleOutput = (data) => {
79
+ const text = data.toString();
80
+ output += text;
81
+ debug('LocalhostRun output:', text.trim());
82
+
83
+ tunnelUrl = this._extractLocalhostRunUrl(output);
84
+ if (tunnelUrl && !resolved) {
85
+ resolved = true;
86
+
87
+ if (timeoutHandle) {
88
+ clearTimeout(timeoutHandle);
89
+ }
90
+
91
+ tunnelInfo.status = 'active';
92
+ tunnelInfo.url = tunnelUrl;
93
+
94
+ this._emitProgress('connected', 'LocalhostRun tunnel established', tunnelId);
95
+ this._emitTunnelCreated(tunnelInfo);
96
+
97
+ resolve({
98
+ tunnelId: tunnelId,
99
+ url: tunnelUrl,
100
+ subdomain: this._extractSubdomain(tunnelUrl),
101
+ localPort: options.port,
102
+ createdAt: new Date(),
103
+ status: 'active',
104
+ service: 'localhost.run'
105
+ });
106
+ }
107
+ };
108
+
109
+ sshProcess.stdout.on('data', handleOutput);
110
+ sshProcess.stderr.on('data', handleOutput);
111
+
112
+ sshProcess.on('error', (error) => {
113
+ if (!resolved) {
114
+ resolved = true;
115
+
116
+ if (timeoutHandle) {
117
+ clearTimeout(timeoutHandle);
118
+ }
119
+
120
+ this.activeTunnels.delete(tunnelId);
121
+
122
+ if (error.code === 'ENOENT') {
123
+ reject(new SSHUnavailableError());
124
+ } else {
125
+ reject(new TransientTunnelError(`LocalhostRun tunnel failed: ${error.message}`));
126
+ }
127
+ }
128
+ });
129
+
130
+ sshProcess.on('exit', (code) => {
131
+ if (!resolved) {
132
+ resolved = true;
133
+
134
+ if (timeoutHandle) {
135
+ clearTimeout(timeoutHandle);
136
+ }
137
+
138
+ this.activeTunnels.delete(tunnelId);
139
+
140
+ if (code !== 0) {
141
+ reject(new TransientTunnelError(`LocalhostRun tunnel exited with code ${code}`));
142
+ }
143
+ } else {
144
+ this.activeTunnels.delete(tunnelId);
145
+ this._emitTunnelDestroyed(tunnelId);
146
+ }
147
+ });
148
+
149
+ timeoutHandle = setTimeout(() => {
150
+ if (!resolved) {
151
+ resolved = true;
152
+
153
+ if (sshProcess && !sshProcess.killed) {
154
+ sshProcess.kill();
155
+ }
156
+
157
+ this.activeTunnels.delete(tunnelId);
158
+ reject(new ConnectionTimeoutError('localhost.run', timeout));
159
+ }
160
+ }, timeout);
161
+
162
+ } catch (error) {
163
+ if (!resolved) {
164
+ resolved = true;
165
+ reject(new TransientTunnelError(`Failed to start LocalhostRun tunnel: ${error.message}`));
166
+ }
167
+ }
168
+ });
169
+ }
170
+
171
+ async disconnect(tunnelId) {
172
+ const tunnelInfo = this.activeTunnels.get(tunnelId);
173
+
174
+ if (!tunnelInfo) {
175
+ return false;
176
+ }
177
+
178
+ try {
179
+ if (tunnelInfo.process && !tunnelInfo.process.killed) {
180
+ tunnelInfo.process.kill();
181
+ }
182
+
183
+ this.activeTunnels.delete(tunnelId);
184
+ this._emitTunnelDestroyed(tunnelId);
185
+
186
+ return true;
187
+ } catch (error) {
188
+ this.activeTunnels.delete(tunnelId);
189
+ return false;
190
+ }
191
+ }
192
+
193
+ getStatus() {
194
+ return {
195
+ serviceName: 'localhost.run',
196
+ available: true,
197
+ isActive: this.activeTunnels.size > 0,
198
+ activeTunnels: this.activeTunnels.size,
199
+ tunnels: Array.from(this.activeTunnels.values()).map(tunnel => ({
200
+ id: tunnel.id,
201
+ url: tunnel.url,
202
+ localPort: tunnel.localPort,
203
+ status: tunnel.status,
204
+ createdAt: tunnel.createdAt
205
+ }))
206
+ };
207
+ }
208
+
209
+ _buildSSHArgs(port) {
210
+ return [
211
+ '-R', `80:localhost:${port}`,
212
+ 'ssh.localhost.run',
213
+ '-o', 'StrictHostKeyChecking=no',
214
+ '-o', 'ServerAliveInterval=30',
215
+ '-o', 'ExitOnForwardFailure=yes'
216
+ ];
217
+ }
218
+
219
+ _extractLocalhostRunUrl(output) {
220
+ // localhost.run uses .lhr.life domains for tunnels (previously .localhost.run)
221
+ // Match patterns like: https://abcdef1234.lhr.life or https://xxxx.localhost.run
222
+ // Avoid matching static URLs like admin.localhost.run
223
+ const patterns = [
224
+ /https:\/\/[a-zA-Z0-9-]+\.lhr\.life/,
225
+ /https:\/\/[a-f0-9]{8,}[a-zA-Z0-9-]*\.localhost\.run/
226
+ ];
227
+
228
+ for (const pattern of patterns) {
229
+ const match = output.match(pattern);
230
+ if (match) return match[0];
231
+ }
232
+
233
+ return null;
234
+ }
235
+
236
+ _extractSubdomain(url) {
237
+ if (!url) return null;
238
+ const match = url.match(/https:\/\/([a-zA-Z0-9-]+)\.(?:lhr\.life|localhost\.run)/);
239
+ return match ? match[1] : null;
240
+ }
241
+ }