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