@ns_nitin/devtunnel-cli 1.0.1
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/package.json +26 -0
- package/src/commands/status.js +30 -0
- package/src/commands/tunnel.js +253 -0
- package/src/index.js +425 -0
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ns_nitin/devtunnel-cli",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "DevTunnel+ CLI - Connect your local server to the internet",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"devtunnel": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node src/index.js",
|
|
11
|
+
"dev": "node --watch src/index.js",
|
|
12
|
+
"test": "jest --passWithNoTests"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@devtunnel/shared": "*",
|
|
16
|
+
"commander": "^11.1.0",
|
|
17
|
+
"ws": "^8.16.0",
|
|
18
|
+
"chalk": "^4.1.2",
|
|
19
|
+
"ora": "^5.4.1",
|
|
20
|
+
"boxen": "^5.1.2",
|
|
21
|
+
"axios": "^1.6.5"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"jest": "^29.7.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status Command - Check gateway server status
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const axios = require('axios');
|
|
6
|
+
const chalk = require('chalk');
|
|
7
|
+
const ora = require('ora');
|
|
8
|
+
|
|
9
|
+
async function execute(options) {
|
|
10
|
+
const spinner = ora('Checking gateway status...').start();
|
|
11
|
+
const url = `http://${options.host}:${options.port}/health`;
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const response = await axios.get(url, { timeout: 5000 });
|
|
15
|
+
const data = response.data;
|
|
16
|
+
|
|
17
|
+
spinner.succeed('Gateway is online');
|
|
18
|
+
console.log('');
|
|
19
|
+
console.log(chalk.cyan(' Status: ') + chalk.green(data.status));
|
|
20
|
+
console.log(chalk.cyan(' Tunnels: ') + chalk.white(data.tunnels));
|
|
21
|
+
console.log(chalk.cyan(' Uptime: ') + chalk.white(`${Math.floor(data.uptime)}s`));
|
|
22
|
+
console.log('');
|
|
23
|
+
} catch (error) {
|
|
24
|
+
spinner.fail('Gateway is offline or unreachable');
|
|
25
|
+
console.log(chalk.red(` Error: ${error.message}`));
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { execute };
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tunnel Command Module
|
|
3
|
+
*
|
|
4
|
+
* This file re-exports the TunnelClient for programmatic use
|
|
5
|
+
* while the main CLI is in index.js
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const WebSocket = require('ws');
|
|
9
|
+
const chalk = require('chalk');
|
|
10
|
+
const ora = require('ora');
|
|
11
|
+
const boxen = require('boxen');
|
|
12
|
+
const http = require('http');
|
|
13
|
+
const {
|
|
14
|
+
createTunnelRegisterMessage,
|
|
15
|
+
createHttpResponseMessage,
|
|
16
|
+
createHttpErrorMessage,
|
|
17
|
+
parseMessage,
|
|
18
|
+
serializeMessage,
|
|
19
|
+
decodeBody,
|
|
20
|
+
MessageType,
|
|
21
|
+
} = require('@devtunnel/shared');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* TunnelClient - Programmatic API for creating tunnels
|
|
25
|
+
*/
|
|
26
|
+
class TunnelClient {
|
|
27
|
+
constructor(localPort, options = {}) {
|
|
28
|
+
this.localPort = parseInt(localPort, 10);
|
|
29
|
+
this.localHost = options.localHost || 'localhost';
|
|
30
|
+
this.gatewayHost = options.host || 'localhost';
|
|
31
|
+
this.gatewayPort = options.gatewayPort || 3001;
|
|
32
|
+
this.subdomain = options.subdomain || null;
|
|
33
|
+
|
|
34
|
+
this.ws = null;
|
|
35
|
+
this.tunnelId = null;
|
|
36
|
+
this.publicUrl = null;
|
|
37
|
+
this.isConnected = false;
|
|
38
|
+
this.shouldReconnect = true;
|
|
39
|
+
this.reconnectAttempts = 0;
|
|
40
|
+
this.maxReconnectAttempts = 10;
|
|
41
|
+
|
|
42
|
+
this.requestCount = 0;
|
|
43
|
+
this.successCount = 0;
|
|
44
|
+
this.errorCount = 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Connects to gateway and starts tunnel
|
|
49
|
+
*/
|
|
50
|
+
async connect() {
|
|
51
|
+
const spinner = ora('Connecting to gateway...').start();
|
|
52
|
+
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const wsUrl = `ws://${this.gatewayHost}:${this.gatewayPort}`;
|
|
55
|
+
this.ws = new WebSocket(wsUrl);
|
|
56
|
+
|
|
57
|
+
this.ws.on('open', () => {
|
|
58
|
+
spinner.text = 'Registering tunnel...';
|
|
59
|
+
this.ws.send(serializeMessage(createTunnelRegisterMessage({
|
|
60
|
+
subdomain: this.subdomain,
|
|
61
|
+
localPort: this.localPort,
|
|
62
|
+
})));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
this.ws.on('message', (data) => {
|
|
66
|
+
const message = parseMessage(data);
|
|
67
|
+
if (!message) return;
|
|
68
|
+
|
|
69
|
+
if (message.type === MessageType.TUNNEL_REGISTERED) {
|
|
70
|
+
this.tunnelId = message.payload.tunnelId;
|
|
71
|
+
this.publicUrl = message.payload.publicUrl;
|
|
72
|
+
this.subdomain = message.payload.subdomain;
|
|
73
|
+
this.isConnected = true;
|
|
74
|
+
spinner.succeed('Tunnel established!');
|
|
75
|
+
this.printBanner();
|
|
76
|
+
resolve();
|
|
77
|
+
} else if (message.type === MessageType.HTTP_REQUEST) {
|
|
78
|
+
this.handleHttpRequest(message.payload);
|
|
79
|
+
} else if (message.type === MessageType.ERROR) {
|
|
80
|
+
spinner.fail(`Error: ${message.payload.error}`);
|
|
81
|
+
reject(new Error(message.payload.error));
|
|
82
|
+
} else if (message.type === MessageType.PING) {
|
|
83
|
+
this.ws.send(serializeMessage({ type: MessageType.PONG, payload: { timestamp: Date.now() } }));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
this.ws.on('error', (error) => {
|
|
88
|
+
if (spinner.isSpinning) spinner.fail(`Connection failed: ${error.message}`);
|
|
89
|
+
reject(error);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
this.ws.on('close', () => {
|
|
93
|
+
this.isConnected = false;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
setTimeout(() => {
|
|
97
|
+
if (!this.isConnected) {
|
|
98
|
+
spinner.fail('Connection timeout');
|
|
99
|
+
this.ws.close();
|
|
100
|
+
reject(new Error('Connection timeout'));
|
|
101
|
+
}
|
|
102
|
+
}, 10000);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
printBanner() {
|
|
107
|
+
const info = [
|
|
108
|
+
'',
|
|
109
|
+
chalk.bold.green(' DevTunnel+ is running!'),
|
|
110
|
+
'',
|
|
111
|
+
` ${chalk.cyan('Public URL:')} ${chalk.bold(this.publicUrl)}`,
|
|
112
|
+
` ${chalk.cyan('Subdomain:')} ${this.subdomain}`,
|
|
113
|
+
` ${chalk.cyan('Forwarding to:')} http://${this.localHost}:${this.localPort}`,
|
|
114
|
+
` ${chalk.cyan('Tunnel ID:')} ${this.tunnelId}`,
|
|
115
|
+
'',
|
|
116
|
+
chalk.gray(' Press Ctrl+C to stop'),
|
|
117
|
+
'',
|
|
118
|
+
].join('\n');
|
|
119
|
+
|
|
120
|
+
console.log(boxen(info, {
|
|
121
|
+
padding: 1,
|
|
122
|
+
margin: 1,
|
|
123
|
+
borderStyle: 'round',
|
|
124
|
+
borderColor: 'cyan',
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
console.log(chalk.gray(' Waiting for requests...\n'));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async handleHttpRequest(payload) {
|
|
131
|
+
const { requestId, method, path, headers, body, bodyEncoding } = payload;
|
|
132
|
+
const startTime = Date.now();
|
|
133
|
+
this.requestCount++;
|
|
134
|
+
|
|
135
|
+
console.log(
|
|
136
|
+
chalk.gray(`[${new Date().toLocaleTimeString()}]`) +
|
|
137
|
+
` ${this.colorMethod(method)} ${path}`
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const requestBody = body ? decodeBody(body, bodyEncoding || 'base64') : null;
|
|
142
|
+
const response = await this.forwardToLocal({ method, path, headers, body: requestBody });
|
|
143
|
+
const duration = Date.now() - startTime;
|
|
144
|
+
this.successCount++;
|
|
145
|
+
|
|
146
|
+
this.ws.send(serializeMessage(createHttpResponseMessage({
|
|
147
|
+
requestId,
|
|
148
|
+
statusCode: response.statusCode,
|
|
149
|
+
headers: response.headers,
|
|
150
|
+
body: response.body,
|
|
151
|
+
})));
|
|
152
|
+
|
|
153
|
+
console.log(
|
|
154
|
+
chalk.gray(`[${new Date().toLocaleTimeString()}]`) +
|
|
155
|
+
` ${this.colorStatus(response.statusCode)} ${chalk.gray(`${duration}ms`)}`
|
|
156
|
+
);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
const duration = Date.now() - startTime;
|
|
159
|
+
this.errorCount++;
|
|
160
|
+
|
|
161
|
+
this.ws.send(serializeMessage(createHttpErrorMessage({
|
|
162
|
+
requestId,
|
|
163
|
+
error: error.message,
|
|
164
|
+
code: error.code === 'ECONNREFUSED' ? 'CONNECTION_REFUSED' : 'LOCAL_SERVER_ERROR',
|
|
165
|
+
statusCode: error.code === 'ECONNREFUSED' ? 503 : 502,
|
|
166
|
+
})));
|
|
167
|
+
|
|
168
|
+
console.log(
|
|
169
|
+
chalk.gray(`[${new Date().toLocaleTimeString()}]`) +
|
|
170
|
+
` ${chalk.red('ERR')} ${error.message} ${chalk.gray(`${duration}ms`)}`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
forwardToLocal({ method, path, headers, body }) {
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const url = new URL(path, `http://${this.localHost}:${this.localPort}`);
|
|
178
|
+
const localHeaders = { ...headers, host: `${this.localHost}:${this.localPort}` };
|
|
179
|
+
delete localHeaders['connection'];
|
|
180
|
+
if (body) localHeaders['content-length'] = String(body.length);
|
|
181
|
+
|
|
182
|
+
const req = http.request({
|
|
183
|
+
hostname: this.localHost,
|
|
184
|
+
port: this.localPort,
|
|
185
|
+
path: url.pathname + url.search,
|
|
186
|
+
method,
|
|
187
|
+
headers: localHeaders,
|
|
188
|
+
}, (res) => {
|
|
189
|
+
const chunks = [];
|
|
190
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
191
|
+
res.on('end', () => {
|
|
192
|
+
resolve({
|
|
193
|
+
statusCode: res.statusCode,
|
|
194
|
+
headers: res.headers,
|
|
195
|
+
body: Buffer.concat(chunks).toString('base64'),
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
req.on('error', reject);
|
|
201
|
+
req.setTimeout(30000, () => {
|
|
202
|
+
req.destroy();
|
|
203
|
+
reject(Object.assign(new Error('Timeout'), { code: 'ETIMEDOUT' }));
|
|
204
|
+
});
|
|
205
|
+
if (body) req.write(body);
|
|
206
|
+
req.end();
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
colorMethod(method) {
|
|
211
|
+
const colors = { GET: chalk.green, POST: chalk.blue, PUT: chalk.yellow, DELETE: chalk.red };
|
|
212
|
+
return (colors[method] || chalk.white)(method.padEnd(7));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
colorStatus(status) {
|
|
216
|
+
if (status >= 500) return chalk.red(status);
|
|
217
|
+
if (status >= 400) return chalk.yellow(status);
|
|
218
|
+
if (status >= 200) return chalk.green(status);
|
|
219
|
+
return chalk.white(status);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
close() {
|
|
223
|
+
this.shouldReconnect = false;
|
|
224
|
+
if (this.ws) this.ws.close();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
getStats() {
|
|
228
|
+
return {
|
|
229
|
+
tunnelId: this.tunnelId,
|
|
230
|
+
publicUrl: this.publicUrl,
|
|
231
|
+
requestCount: this.requestCount,
|
|
232
|
+
successCount: this.successCount,
|
|
233
|
+
errorCount: this.errorCount,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Execute tunnel (for backward compatibility with http command)
|
|
240
|
+
*/
|
|
241
|
+
async function execute(port, options = {}) {
|
|
242
|
+
const client = new TunnelClient(port, options);
|
|
243
|
+
|
|
244
|
+
process.on('SIGINT', () => {
|
|
245
|
+
console.log(chalk.yellow('\nShutting down tunnel...'));
|
|
246
|
+
client.close();
|
|
247
|
+
process.exit(0);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await client.connect();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = { execute, TunnelClient };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DevTunnel+ CLI
|
|
5
|
+
*
|
|
6
|
+
* Usage: devtunnel start <port> [options]
|
|
7
|
+
*
|
|
8
|
+
* Creates a tunnel from public URL to localhost:<port>
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { program } = require('commander');
|
|
12
|
+
const WebSocket = require('ws');
|
|
13
|
+
const chalk = require('chalk');
|
|
14
|
+
const ora = require('ora');
|
|
15
|
+
const boxen = require('boxen');
|
|
16
|
+
const http = require('http');
|
|
17
|
+
const https = require('https');
|
|
18
|
+
const {
|
|
19
|
+
createTunnelRegisterMessage,
|
|
20
|
+
createHttpResponseMessage,
|
|
21
|
+
createHttpErrorMessage,
|
|
22
|
+
parseMessage,
|
|
23
|
+
serializeMessage,
|
|
24
|
+
decodeBody,
|
|
25
|
+
MessageType,
|
|
26
|
+
} = require('@devtunnel/shared');
|
|
27
|
+
|
|
28
|
+
// Package info
|
|
29
|
+
const pkg = require('../package.json');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* TunnelClient - Real tunneling client with reconnect support
|
|
33
|
+
*/
|
|
34
|
+
class TunnelClient {
|
|
35
|
+
constructor(port, options = {}) {
|
|
36
|
+
this.localPort = parseInt(port, 10);
|
|
37
|
+
this.localHost = options.localHost || 'localhost';
|
|
38
|
+
this.gatewayHost = options.host || 'localhost';
|
|
39
|
+
this.gatewayPort = options.gatewayPort || 3001;
|
|
40
|
+
this.subdomain = options.subdomain || null;
|
|
41
|
+
|
|
42
|
+
this.ws = null;
|
|
43
|
+
this.tunnelId = null;
|
|
44
|
+
this.publicUrl = null;
|
|
45
|
+
this.isConnected = false;
|
|
46
|
+
this.shouldReconnect = true;
|
|
47
|
+
this.reconnectAttempts = 0;
|
|
48
|
+
this.maxReconnectAttempts = 10;
|
|
49
|
+
this.reconnectDelay = 1000;
|
|
50
|
+
|
|
51
|
+
// Stats
|
|
52
|
+
this.requestCount = 0;
|
|
53
|
+
this.successCount = 0;
|
|
54
|
+
this.errorCount = 0;
|
|
55
|
+
this.startTime = Date.now();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Starts the tunnel with auto-reconnect
|
|
60
|
+
*/
|
|
61
|
+
async start() {
|
|
62
|
+
console.log(chalk.cyan('\nš DevTunnel+ Starting...\n'));
|
|
63
|
+
|
|
64
|
+
while (this.shouldReconnect) {
|
|
65
|
+
try {
|
|
66
|
+
await this.connect();
|
|
67
|
+
// Connection closed, will retry if shouldReconnect is true
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (!this.shouldReconnect) break;
|
|
70
|
+
|
|
71
|
+
this.reconnectAttempts++;
|
|
72
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
73
|
+
console.error(chalk.red(`\nā Failed after ${this.maxReconnectAttempts} attempts. Giving up.`));
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000);
|
|
78
|
+
console.log(chalk.yellow(`\nā ļø Reconnecting in ${delay / 1000}s (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`));
|
|
79
|
+
await this.sleep(delay);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Connects to gateway and registers tunnel
|
|
86
|
+
*/
|
|
87
|
+
connect() {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const spinner = ora('Connecting to gateway...').start();
|
|
90
|
+
const wsUrl = `ws://${this.gatewayHost}:${this.gatewayPort}`;
|
|
91
|
+
|
|
92
|
+
this.ws = new WebSocket(wsUrl);
|
|
93
|
+
|
|
94
|
+
this.ws.on('open', () => {
|
|
95
|
+
spinner.text = 'Registering tunnel...';
|
|
96
|
+
this.ws.send(serializeMessage(createTunnelRegisterMessage({
|
|
97
|
+
subdomain: this.subdomain,
|
|
98
|
+
localPort: this.localPort,
|
|
99
|
+
})));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
this.ws.on('message', (data) => {
|
|
103
|
+
const message = parseMessage(data);
|
|
104
|
+
if (!message) return;
|
|
105
|
+
|
|
106
|
+
if (message.type === MessageType.TUNNEL_REGISTERED) {
|
|
107
|
+
spinner.succeed('Tunnel established!');
|
|
108
|
+
this.handleRegistered(message.payload);
|
|
109
|
+
this.reconnectAttempts = 0; // Reset on successful connection
|
|
110
|
+
} else if (message.type === MessageType.HTTP_REQUEST) {
|
|
111
|
+
this.handleHttpRequest(message.payload);
|
|
112
|
+
} else if (message.type === MessageType.ERROR) {
|
|
113
|
+
spinner.fail(`Error: ${message.payload.error}`);
|
|
114
|
+
reject(new Error(message.payload.error));
|
|
115
|
+
} else if (message.type === MessageType.PING) {
|
|
116
|
+
this.ws.send(serializeMessage({ type: MessageType.PONG, payload: { timestamp: Date.now() } }));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
this.ws.on('error', (error) => {
|
|
121
|
+
if (spinner.isSpinning) {
|
|
122
|
+
spinner.fail(`Connection failed: ${error.message}`);
|
|
123
|
+
}
|
|
124
|
+
this.isConnected = false;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
this.ws.on('close', (code) => {
|
|
128
|
+
this.isConnected = false;
|
|
129
|
+
if (this.tunnelId && this.shouldReconnect) {
|
|
130
|
+
console.log(chalk.yellow(`\nā ļø Connection lost (code: ${code})`));
|
|
131
|
+
}
|
|
132
|
+
resolve(); // Resolve to allow reconnect loop
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Connection timeout
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
if (!this.isConnected && spinner.isSpinning) {
|
|
138
|
+
spinner.fail('Connection timeout');
|
|
139
|
+
this.ws.close();
|
|
140
|
+
reject(new Error('Connection timeout'));
|
|
141
|
+
}
|
|
142
|
+
}, 10000);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Handles successful tunnel registration
|
|
148
|
+
*/
|
|
149
|
+
handleRegistered(payload) {
|
|
150
|
+
this.tunnelId = payload.tunnelId;
|
|
151
|
+
this.publicUrl = payload.publicUrl;
|
|
152
|
+
this.subdomain = payload.subdomain;
|
|
153
|
+
this.isConnected = true;
|
|
154
|
+
|
|
155
|
+
this.printBanner();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Prints the tunnel info banner
|
|
160
|
+
*/
|
|
161
|
+
printBanner() {
|
|
162
|
+
const info = [
|
|
163
|
+
'',
|
|
164
|
+
chalk.bold.green(' ā Tunnel is live!'),
|
|
165
|
+
'',
|
|
166
|
+
` ${chalk.gray('Public URL ā')} ${chalk.bold.cyan(this.publicUrl)}`,
|
|
167
|
+
` ${chalk.gray('Forwarding ā')} ${chalk.white(`http://${this.localHost}:${this.localPort}`)}`,
|
|
168
|
+
'',
|
|
169
|
+
` ${chalk.gray('Tunnel ID:')} ${chalk.dim(this.tunnelId)}`,
|
|
170
|
+
'',
|
|
171
|
+
].join('\n');
|
|
172
|
+
|
|
173
|
+
console.log(boxen(info, {
|
|
174
|
+
padding: 1,
|
|
175
|
+
margin: { top: 1, bottom: 1, left: 2, right: 2 },
|
|
176
|
+
borderStyle: 'round',
|
|
177
|
+
borderColor: 'green',
|
|
178
|
+
}));
|
|
179
|
+
|
|
180
|
+
console.log(chalk.gray(' Waiting for requests...\n'));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Handles incoming HTTP request from gateway
|
|
185
|
+
*/
|
|
186
|
+
async handleHttpRequest(payload) {
|
|
187
|
+
const { requestId, method, path, headers, body, bodyEncoding } = payload;
|
|
188
|
+
const startTime = Date.now();
|
|
189
|
+
this.requestCount++;
|
|
190
|
+
|
|
191
|
+
// Log incoming request
|
|
192
|
+
process.stdout.write(
|
|
193
|
+
chalk.gray(` ${this.formatTime()} `) +
|
|
194
|
+
this.colorMethod(method) +
|
|
195
|
+
chalk.white(` ${this.truncate(path, 50)}`)
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
// Decode request body
|
|
200
|
+
const requestBody = body ? decodeBody(body, bodyEncoding || 'base64') : null;
|
|
201
|
+
|
|
202
|
+
// Forward to local server
|
|
203
|
+
const response = await this.forwardToLocal({ method, path, headers, body: requestBody });
|
|
204
|
+
const duration = Date.now() - startTime;
|
|
205
|
+
this.successCount++;
|
|
206
|
+
|
|
207
|
+
// Send response back through WebSocket
|
|
208
|
+
this.ws.send(serializeMessage(createHttpResponseMessage({
|
|
209
|
+
requestId,
|
|
210
|
+
statusCode: response.statusCode,
|
|
211
|
+
headers: response.headers,
|
|
212
|
+
body: response.body,
|
|
213
|
+
})));
|
|
214
|
+
|
|
215
|
+
// Log response
|
|
216
|
+
console.log(` ā ${this.colorStatus(response.statusCode)} ${chalk.gray(`${duration}ms`)}`);
|
|
217
|
+
|
|
218
|
+
} catch (error) {
|
|
219
|
+
const duration = Date.now() - startTime;
|
|
220
|
+
this.errorCount++;
|
|
221
|
+
|
|
222
|
+
// Determine error code
|
|
223
|
+
let statusCode = 502;
|
|
224
|
+
let errorCode = 'LOCAL_SERVER_ERROR';
|
|
225
|
+
|
|
226
|
+
if (error.code === 'ECONNREFUSED') {
|
|
227
|
+
statusCode = 503;
|
|
228
|
+
errorCode = 'CONNECTION_REFUSED';
|
|
229
|
+
} else if (error.code === 'ETIMEDOUT') {
|
|
230
|
+
statusCode = 504;
|
|
231
|
+
errorCode = 'TIMEOUT';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Send error response
|
|
235
|
+
this.ws.send(serializeMessage(createHttpErrorMessage({
|
|
236
|
+
requestId,
|
|
237
|
+
error: error.message,
|
|
238
|
+
code: errorCode,
|
|
239
|
+
statusCode,
|
|
240
|
+
})));
|
|
241
|
+
|
|
242
|
+
// Log error
|
|
243
|
+
console.log(` ā ${chalk.red('ERR')} ${chalk.gray(error.code || error.message)} ${chalk.gray(`${duration}ms`)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Makes real HTTP request to local server
|
|
249
|
+
*/
|
|
250
|
+
forwardToLocal({ method, path, headers, body }) {
|
|
251
|
+
return new Promise((resolve, reject) => {
|
|
252
|
+
const url = new URL(path, `http://${this.localHost}:${this.localPort}`);
|
|
253
|
+
|
|
254
|
+
// Prepare headers
|
|
255
|
+
const localHeaders = { ...headers };
|
|
256
|
+
localHeaders['host'] = `${this.localHost}:${this.localPort}`;
|
|
257
|
+
delete localHeaders['connection'];
|
|
258
|
+
|
|
259
|
+
if (body) {
|
|
260
|
+
localHeaders['content-length'] = String(body.length);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const options = {
|
|
264
|
+
hostname: this.localHost,
|
|
265
|
+
port: this.localPort,
|
|
266
|
+
path: url.pathname + url.search,
|
|
267
|
+
method,
|
|
268
|
+
headers: localHeaders,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const req = http.request(options, (res) => {
|
|
272
|
+
const chunks = [];
|
|
273
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
274
|
+
res.on('end', () => {
|
|
275
|
+
const responseBody = Buffer.concat(chunks);
|
|
276
|
+
resolve({
|
|
277
|
+
statusCode: res.statusCode,
|
|
278
|
+
headers: res.headers,
|
|
279
|
+
body: responseBody.toString('base64'),
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
res.on('error', reject);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
req.on('error', reject);
|
|
286
|
+
req.setTimeout(30000, () => {
|
|
287
|
+
req.destroy();
|
|
288
|
+
const err = new Error('Request timeout');
|
|
289
|
+
err.code = 'ETIMEDOUT';
|
|
290
|
+
reject(err);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
if (body) req.write(body);
|
|
294
|
+
req.end();
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Utility methods
|
|
299
|
+
formatTime() {
|
|
300
|
+
return new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
truncate(str, len) {
|
|
304
|
+
return str.length > len ? str.substring(0, len - 3) + '...' : str;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
colorMethod(method) {
|
|
308
|
+
const colors = {
|
|
309
|
+
GET: chalk.green,
|
|
310
|
+
POST: chalk.blue,
|
|
311
|
+
PUT: chalk.yellow,
|
|
312
|
+
PATCH: chalk.yellow,
|
|
313
|
+
DELETE: chalk.red,
|
|
314
|
+
HEAD: chalk.cyan,
|
|
315
|
+
OPTIONS: chalk.magenta,
|
|
316
|
+
};
|
|
317
|
+
return (colors[method] || chalk.white)(method.padEnd(7));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
colorStatus(status) {
|
|
321
|
+
if (status >= 500) return chalk.red(status);
|
|
322
|
+
if (status >= 400) return chalk.yellow(status);
|
|
323
|
+
if (status >= 300) return chalk.cyan(status);
|
|
324
|
+
if (status >= 200) return chalk.green(status);
|
|
325
|
+
return chalk.white(status);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
sleep(ms) {
|
|
329
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Stops the tunnel
|
|
334
|
+
*/
|
|
335
|
+
stop() {
|
|
336
|
+
this.shouldReconnect = false;
|
|
337
|
+
if (this.ws) {
|
|
338
|
+
this.ws.close();
|
|
339
|
+
}
|
|
340
|
+
this.printStats();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Prints session statistics
|
|
345
|
+
*/
|
|
346
|
+
printStats() {
|
|
347
|
+
const uptime = Math.floor((Date.now() - this.startTime) / 1000);
|
|
348
|
+
console.log(chalk.cyan('\nš Session Stats:'));
|
|
349
|
+
console.log(chalk.gray(` Uptime: ${Math.floor(uptime / 60)}m ${uptime % 60}s`));
|
|
350
|
+
console.log(chalk.gray(` Requests: ${this.requestCount} (${this.successCount} ok, ${this.errorCount} failed)`));
|
|
351
|
+
console.log('');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// CLI Setup
|
|
356
|
+
program
|
|
357
|
+
.name('devtunnel')
|
|
358
|
+
.description('DevTunnel+ - Expose your localhost to the world')
|
|
359
|
+
.version(pkg.version);
|
|
360
|
+
|
|
361
|
+
program
|
|
362
|
+
.command('start <port>')
|
|
363
|
+
.description('Start a tunnel to localhost:<port>')
|
|
364
|
+
.option('-s, --subdomain <name>', 'Request a specific subdomain')
|
|
365
|
+
.option('-h, --host <host>', 'Gateway host', 'localhost')
|
|
366
|
+
.option('-p, --gateway-port <port>', 'Gateway WebSocket port', '3001')
|
|
367
|
+
.option('-l, --local-host <host>', 'Local host to forward to', 'localhost')
|
|
368
|
+
.action(async (port, options) => {
|
|
369
|
+
// Validate port
|
|
370
|
+
const portNum = parseInt(port, 10);
|
|
371
|
+
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
|
372
|
+
console.error(chalk.red(`Error: Invalid port "${port}". Must be 1-65535.`));
|
|
373
|
+
process.exit(1);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const client = new TunnelClient(portNum, {
|
|
377
|
+
subdomain: options.subdomain,
|
|
378
|
+
host: options.host,
|
|
379
|
+
gatewayPort: parseInt(options.gatewayPort, 10),
|
|
380
|
+
localHost: options.localHost,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Graceful shutdown
|
|
384
|
+
const shutdown = () => {
|
|
385
|
+
console.log(chalk.yellow('\n\nš Shutting down tunnel...'));
|
|
386
|
+
client.stop();
|
|
387
|
+
process.exit(0);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
process.on('SIGINT', shutdown);
|
|
391
|
+
process.on('SIGTERM', shutdown);
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
await client.start();
|
|
395
|
+
} catch (error) {
|
|
396
|
+
console.error(chalk.red(`\nā Fatal error: ${error.message}`));
|
|
397
|
+
process.exit(1);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
program
|
|
402
|
+
.command('status')
|
|
403
|
+
.description('Check gateway status')
|
|
404
|
+
.option('-h, --host <host>', 'Gateway host', 'localhost')
|
|
405
|
+
.option('-p, --port <port>', 'Gateway HTTP port', '3000')
|
|
406
|
+
.action(async (options) => {
|
|
407
|
+
try {
|
|
408
|
+
const res = await fetch(`http://${options.host}:${options.port}/health`);
|
|
409
|
+
const data = await res.json();
|
|
410
|
+
console.log(chalk.green('ā Gateway is running'));
|
|
411
|
+
console.log(chalk.gray(` Tunnels: ${data.tunnels}`));
|
|
412
|
+
console.log(chalk.gray(` Uptime: ${Math.floor(data.uptime)}s`));
|
|
413
|
+
} catch (error) {
|
|
414
|
+
console.error(chalk.red('ā Gateway is not reachable'));
|
|
415
|
+
console.error(chalk.gray(` ${error.message}`));
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// Parse arguments
|
|
420
|
+
program.parse();
|
|
421
|
+
|
|
422
|
+
// If no command provided, show help
|
|
423
|
+
if (!process.argv.slice(2).length) {
|
|
424
|
+
program.outputHelp();
|
|
425
|
+
}
|