traforo 0.0.1 → 0.0.4
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 +82 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +38 -0
- package/dist/client.d.ts +36 -0
- package/dist/client.js +297 -0
- package/dist/run-tunnel.d.ts +20 -0
- package/dist/run-tunnel.js +119 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.js +35 -0
- package/package.json +21 -8
- package/src/cli.ts +0 -52
- package/src/client.ts +0 -326
- package/src/run-tunnel.ts +0 -155
- package/src/tunnel.ts +0 -574
- package/src/types.ts +0 -152
package/README
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
TRAFORO
|
|
2
|
+
=======
|
|
3
|
+
|
|
4
|
+
HTTP tunnel via Cloudflare Durable Objects and WebSockets.
|
|
5
|
+
Expose local servers to the internet with a simple CLI.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
INSTALLATION
|
|
9
|
+
------------
|
|
10
|
+
|
|
11
|
+
npm install -g traforo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
USAGE
|
|
15
|
+
-----
|
|
16
|
+
|
|
17
|
+
Expose a local server:
|
|
18
|
+
|
|
19
|
+
traforo -p 3000
|
|
20
|
+
|
|
21
|
+
With a custom tunnel ID:
|
|
22
|
+
|
|
23
|
+
traforo -p 3000 -t my-app
|
|
24
|
+
|
|
25
|
+
Run a command and tunnel it:
|
|
26
|
+
|
|
27
|
+
traforo -p 3000 -- next start
|
|
28
|
+
traforo -p 3000 -- pnpm dev
|
|
29
|
+
traforo -p 5173 -- vite
|
|
30
|
+
|
|
31
|
+
The tunnel URL will be:
|
|
32
|
+
|
|
33
|
+
https://{tunnel-id}-tunnel.kimaki.xyz
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
OPTIONS
|
|
37
|
+
-------
|
|
38
|
+
|
|
39
|
+
-p, --port <port> Local port to expose (required)
|
|
40
|
+
-t, --tunnel-id [id] Tunnel ID (random if omitted)
|
|
41
|
+
-h, --host [host] Local host (default: localhost)
|
|
42
|
+
-s, --server [url] Custom tunnel server URL
|
|
43
|
+
--help Show help
|
|
44
|
+
--version Show version
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
HOW IT WORKS
|
|
48
|
+
------------
|
|
49
|
+
|
|
50
|
+
1. Local client connects to Cloudflare Durable Object via WebSocket
|
|
51
|
+
2. HTTP requests to tunnel URL are forwarded to the DO
|
|
52
|
+
3. DO sends requests over WebSocket to local client
|
|
53
|
+
4. Local client makes request to localhost and returns response
|
|
54
|
+
5. WebSocket connections from users are also proxied through
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
API ENDPOINTS
|
|
58
|
+
-------------
|
|
59
|
+
|
|
60
|
+
/traforo-status Check if tunnel is online
|
|
61
|
+
/traforo-upstream WebSocket endpoint for local client
|
|
62
|
+
/* All other paths proxied to local server
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
LIBRARY USAGE
|
|
66
|
+
-------------
|
|
67
|
+
|
|
68
|
+
import { TunnelClient } from 'traforo/client'
|
|
69
|
+
import { runTunnel } from 'traforo/run-tunnel'
|
|
70
|
+
|
|
71
|
+
const client = new TunnelClient({
|
|
72
|
+
localPort: 3000,
|
|
73
|
+
tunnelId: 'my-app',
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
await client.connect()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
LICENSE
|
|
80
|
+
-------
|
|
81
|
+
|
|
82
|
+
MIT
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cac } from '@xmorse/cac';
|
|
3
|
+
import { CLI_NAME, runTunnel, parseCommandFromArgv } from './run-tunnel.js';
|
|
4
|
+
const { command, argv } = parseCommandFromArgv(process.argv);
|
|
5
|
+
const cli = cac(CLI_NAME);
|
|
6
|
+
cli
|
|
7
|
+
.command('', 'Expose a local port via tunnel')
|
|
8
|
+
.option('-p, --port <port>', 'Local port to expose (required)')
|
|
9
|
+
.option('-t, --tunnel-id [id]', 'Tunnel ID (random if omitted)')
|
|
10
|
+
.option('-h, --host [host]', 'Local host (default: localhost)')
|
|
11
|
+
.option('-s, --server [url]', 'Tunnel server URL')
|
|
12
|
+
.example(`${CLI_NAME} -p 3000`)
|
|
13
|
+
.example(`${CLI_NAME} -p 3000 -- next start`)
|
|
14
|
+
.example(`${CLI_NAME} -p 3000 -- pnpm dev`)
|
|
15
|
+
.example(`${CLI_NAME} -p 5173 -t my-app -- vite`)
|
|
16
|
+
.action(async (options) => {
|
|
17
|
+
if (!options.port) {
|
|
18
|
+
console.error('Error: --port is required');
|
|
19
|
+
console.error(`\nUsage: ${CLI_NAME} -p <port> [-- command]`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
const port = parseInt(options.port, 10);
|
|
23
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
24
|
+
console.error(`Error: Invalid port number: ${options.port}`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
await runTunnel({
|
|
28
|
+
port,
|
|
29
|
+
tunnelId: options.tunnelId,
|
|
30
|
+
localHost: options.host,
|
|
31
|
+
serverUrl: options.server,
|
|
32
|
+
command: command.length > 0 ? command : undefined,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
cli.help();
|
|
36
|
+
cli.version('0.0.1');
|
|
37
|
+
// Parse the modified argv (without the command after --)
|
|
38
|
+
cli.parse(argv);
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local tunnel client - runs on user's machine to expose a local server.
|
|
3
|
+
*/
|
|
4
|
+
type TunnelClientOptions = {
|
|
5
|
+
/** Local port to proxy to */
|
|
6
|
+
localPort: number;
|
|
7
|
+
/** Local host (default: localhost) */
|
|
8
|
+
localHost?: string;
|
|
9
|
+
/** Tunnel server URL (default: wss://{tunnelId}-tunnel.kimaki.xyz) */
|
|
10
|
+
serverUrl?: string;
|
|
11
|
+
/** Tunnel ID */
|
|
12
|
+
tunnelId: string;
|
|
13
|
+
/** Use HTTPS for local connections */
|
|
14
|
+
localHttps?: boolean;
|
|
15
|
+
/** Reconnect on disconnect */
|
|
16
|
+
autoReconnect?: boolean;
|
|
17
|
+
/** Reconnect delay in ms */
|
|
18
|
+
reconnectDelay?: number;
|
|
19
|
+
};
|
|
20
|
+
export declare class TunnelClient {
|
|
21
|
+
private options;
|
|
22
|
+
private ws;
|
|
23
|
+
private localWsConnections;
|
|
24
|
+
private closed;
|
|
25
|
+
constructor(options: TunnelClientOptions);
|
|
26
|
+
get url(): string;
|
|
27
|
+
connect(): Promise<void>;
|
|
28
|
+
close(): void;
|
|
29
|
+
private handleMessage;
|
|
30
|
+
private handleHttpRequest;
|
|
31
|
+
private handleWsOpen;
|
|
32
|
+
private handleWsFrame;
|
|
33
|
+
private handleWsClose;
|
|
34
|
+
private send;
|
|
35
|
+
}
|
|
36
|
+
export {};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local tunnel client - runs on user's machine to expose a local server.
|
|
3
|
+
*/
|
|
4
|
+
import WebSocket from 'ws';
|
|
5
|
+
export class TunnelClient {
|
|
6
|
+
options;
|
|
7
|
+
ws = null;
|
|
8
|
+
localWsConnections = new Map();
|
|
9
|
+
closed = false;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.options = {
|
|
12
|
+
localHost: 'localhost',
|
|
13
|
+
serverUrl: `wss://${options.tunnelId}-tunnel.kimaki.xyz`,
|
|
14
|
+
localHttps: false,
|
|
15
|
+
autoReconnect: true,
|
|
16
|
+
reconnectDelay: 3000,
|
|
17
|
+
...options,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
get url() {
|
|
21
|
+
return `https://${this.options.tunnelId}-tunnel.kimaki.xyz`;
|
|
22
|
+
}
|
|
23
|
+
async connect() {
|
|
24
|
+
if (this.closed) {
|
|
25
|
+
throw new Error('Client is closed');
|
|
26
|
+
}
|
|
27
|
+
const wsUrl = `${this.options.serverUrl}/traforo-upstream?_tunnelId=${this.options.tunnelId}`;
|
|
28
|
+
// console.log(`Connecting to ${wsUrl}...`)
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
this.ws = new WebSocket(wsUrl);
|
|
31
|
+
this.ws.on('open', () => {
|
|
32
|
+
console.log(`Connected with Traforo! Tunnel URL: ${this.url}`);
|
|
33
|
+
resolve();
|
|
34
|
+
});
|
|
35
|
+
this.ws.on('error', (err) => {
|
|
36
|
+
console.error('WebSocket error:', err.message);
|
|
37
|
+
reject(new Error('WebSocket connection failed'));
|
|
38
|
+
});
|
|
39
|
+
this.ws.on('close', (code, reason) => {
|
|
40
|
+
console.log(`Disconnected: ${code} ${reason.toString()}`);
|
|
41
|
+
this.ws = null;
|
|
42
|
+
// Close all local WS connections
|
|
43
|
+
for (const [, localWs] of this.localWsConnections) {
|
|
44
|
+
try {
|
|
45
|
+
localWs.close();
|
|
46
|
+
}
|
|
47
|
+
catch { }
|
|
48
|
+
}
|
|
49
|
+
this.localWsConnections.clear();
|
|
50
|
+
// Auto-reconnect
|
|
51
|
+
if (this.options.autoReconnect && !this.closed) {
|
|
52
|
+
console.log(`Reconnecting in ${this.options.reconnectDelay}ms...`);
|
|
53
|
+
setTimeout(() => {
|
|
54
|
+
this.connect().catch(console.error);
|
|
55
|
+
}, this.options.reconnectDelay);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
this.ws.on('message', (data) => {
|
|
59
|
+
this.handleMessage(data.toString());
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
close() {
|
|
64
|
+
this.closed = true;
|
|
65
|
+
if (this.ws) {
|
|
66
|
+
this.ws.close();
|
|
67
|
+
this.ws = null;
|
|
68
|
+
}
|
|
69
|
+
for (const [, localWs] of this.localWsConnections) {
|
|
70
|
+
try {
|
|
71
|
+
localWs.close();
|
|
72
|
+
}
|
|
73
|
+
catch { }
|
|
74
|
+
}
|
|
75
|
+
this.localWsConnections.clear();
|
|
76
|
+
}
|
|
77
|
+
handleMessage(rawMessage) {
|
|
78
|
+
let msg;
|
|
79
|
+
try {
|
|
80
|
+
msg = JSON.parse(rawMessage);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
console.error('Failed to parse message:', rawMessage);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
switch (msg.type) {
|
|
87
|
+
case 'http_request':
|
|
88
|
+
this.handleHttpRequest(msg);
|
|
89
|
+
break;
|
|
90
|
+
case 'ws_open':
|
|
91
|
+
this.handleWsOpen(msg);
|
|
92
|
+
break;
|
|
93
|
+
case 'ws_frame':
|
|
94
|
+
this.handleWsFrame(msg);
|
|
95
|
+
break;
|
|
96
|
+
case 'ws_close':
|
|
97
|
+
this.handleWsClose(msg);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async handleHttpRequest(msg) {
|
|
102
|
+
const { localHost, localPort, localHttps } = this.options;
|
|
103
|
+
const protocol = localHttps ? 'https' : 'http';
|
|
104
|
+
const url = `${protocol}://${localHost}:${localPort}${msg.path}`;
|
|
105
|
+
console.log(`HTTP ${msg.method} ${msg.path}`);
|
|
106
|
+
try {
|
|
107
|
+
// Decode body
|
|
108
|
+
let body;
|
|
109
|
+
if (msg.body) {
|
|
110
|
+
body = Buffer.from(msg.body, 'base64');
|
|
111
|
+
}
|
|
112
|
+
// Make local request
|
|
113
|
+
const res = await fetch(url, {
|
|
114
|
+
method: msg.method,
|
|
115
|
+
headers: msg.headers,
|
|
116
|
+
body: msg.method !== 'GET' && msg.method !== 'HEAD' ? body : undefined,
|
|
117
|
+
});
|
|
118
|
+
// Build response headers
|
|
119
|
+
const resHeaders = {};
|
|
120
|
+
res.headers.forEach((value, key) => {
|
|
121
|
+
resHeaders[key] = value;
|
|
122
|
+
});
|
|
123
|
+
// Check if we should stream the response
|
|
124
|
+
const contentType = res.headers.get('content-type') || '';
|
|
125
|
+
const transferEncoding = res.headers.get('transfer-encoding') || '';
|
|
126
|
+
const shouldStream = contentType.includes('text/event-stream') ||
|
|
127
|
+
contentType.includes('application/x-ndjson') ||
|
|
128
|
+
transferEncoding.includes('chunked');
|
|
129
|
+
if (shouldStream && res.body) {
|
|
130
|
+
console.log(`HTTP ${msg.method} ${msg.path} -> streaming response`);
|
|
131
|
+
// Send response start
|
|
132
|
+
const startMsg = {
|
|
133
|
+
type: 'http_response_start',
|
|
134
|
+
id: msg.id,
|
|
135
|
+
status: res.status,
|
|
136
|
+
headers: resHeaders,
|
|
137
|
+
};
|
|
138
|
+
this.send(startMsg);
|
|
139
|
+
// Stream chunks
|
|
140
|
+
const reader = res.body.getReader();
|
|
141
|
+
try {
|
|
142
|
+
while (true) {
|
|
143
|
+
const { done, value } = await reader.read();
|
|
144
|
+
if (done) {
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
const chunkMsg = {
|
|
148
|
+
type: 'http_response_chunk',
|
|
149
|
+
id: msg.id,
|
|
150
|
+
chunk: Buffer.from(value).toString('base64'),
|
|
151
|
+
};
|
|
152
|
+
this.send(chunkMsg);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
reader.releaseLock();
|
|
157
|
+
}
|
|
158
|
+
// Send response end
|
|
159
|
+
const endMsg = {
|
|
160
|
+
type: 'http_response_end',
|
|
161
|
+
id: msg.id,
|
|
162
|
+
};
|
|
163
|
+
this.send(endMsg);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
// Non-streaming: read full body
|
|
167
|
+
const resBuffer = await res.arrayBuffer();
|
|
168
|
+
const resBody = resBuffer.byteLength > 0
|
|
169
|
+
? Buffer.from(resBuffer).toString('base64')
|
|
170
|
+
: null;
|
|
171
|
+
const response = {
|
|
172
|
+
type: 'http_response',
|
|
173
|
+
id: msg.id,
|
|
174
|
+
status: res.status,
|
|
175
|
+
headers: resHeaders,
|
|
176
|
+
body: resBody,
|
|
177
|
+
};
|
|
178
|
+
this.send(response);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
console.error(`HTTP request failed:`, err);
|
|
183
|
+
const errorResponse = {
|
|
184
|
+
type: 'http_error',
|
|
185
|
+
id: msg.id,
|
|
186
|
+
error: err instanceof Error ? err.message : 'Unknown error',
|
|
187
|
+
};
|
|
188
|
+
this.send(errorResponse);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
handleWsOpen(msg) {
|
|
192
|
+
const { localHost, localPort, localHttps } = this.options;
|
|
193
|
+
const protocol = localHttps ? 'wss' : 'ws';
|
|
194
|
+
const url = `${protocol}://${localHost}:${localPort}${msg.path}`;
|
|
195
|
+
console.log(`WS OPEN ${msg.path} (${msg.connId})`);
|
|
196
|
+
try {
|
|
197
|
+
const localWs = new WebSocket(url);
|
|
198
|
+
localWs.on('open', () => {
|
|
199
|
+
console.log(`WS CONNECTED ${msg.connId}`);
|
|
200
|
+
this.localWsConnections.set(msg.connId, localWs);
|
|
201
|
+
const opened = {
|
|
202
|
+
type: 'ws_opened',
|
|
203
|
+
connId: msg.connId,
|
|
204
|
+
};
|
|
205
|
+
this.send(opened);
|
|
206
|
+
});
|
|
207
|
+
localWs.on('error', (err) => {
|
|
208
|
+
console.error(`WS ERROR ${msg.connId}:`, err.message);
|
|
209
|
+
const errorMsg = {
|
|
210
|
+
type: 'ws_error',
|
|
211
|
+
connId: msg.connId,
|
|
212
|
+
error: err.message || 'Connection failed',
|
|
213
|
+
};
|
|
214
|
+
this.send(errorMsg);
|
|
215
|
+
this.localWsConnections.delete(msg.connId);
|
|
216
|
+
});
|
|
217
|
+
localWs.on('close', (code, reason) => {
|
|
218
|
+
console.log(`WS CLOSED ${msg.connId}: ${code} ${reason.toString()}`);
|
|
219
|
+
const closed = {
|
|
220
|
+
type: 'ws_closed',
|
|
221
|
+
connId: msg.connId,
|
|
222
|
+
code,
|
|
223
|
+
reason: reason.toString(),
|
|
224
|
+
};
|
|
225
|
+
this.send(closed);
|
|
226
|
+
this.localWsConnections.delete(msg.connId);
|
|
227
|
+
});
|
|
228
|
+
localWs.on('message', (data, isBinary) => {
|
|
229
|
+
let frameData;
|
|
230
|
+
let binary = false;
|
|
231
|
+
if (isBinary || data instanceof Buffer) {
|
|
232
|
+
frameData = Buffer.isBuffer(data)
|
|
233
|
+
? data.toString('base64')
|
|
234
|
+
: Buffer.from(data).toString('base64');
|
|
235
|
+
binary = true;
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
frameData = data.toString();
|
|
239
|
+
}
|
|
240
|
+
const frame = {
|
|
241
|
+
type: 'ws_frame',
|
|
242
|
+
connId: msg.connId,
|
|
243
|
+
data: frameData,
|
|
244
|
+
binary,
|
|
245
|
+
};
|
|
246
|
+
this.send(frame);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
console.error(`WS OPEN FAILED ${msg.connId}:`, err);
|
|
251
|
+
const errorMsg = {
|
|
252
|
+
type: 'ws_error',
|
|
253
|
+
connId: msg.connId,
|
|
254
|
+
error: err instanceof Error ? err.message : 'Unknown error',
|
|
255
|
+
};
|
|
256
|
+
this.send(errorMsg);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
handleWsFrame(msg) {
|
|
260
|
+
const localWs = this.localWsConnections.get(msg.connId);
|
|
261
|
+
if (!localWs) {
|
|
262
|
+
console.warn(`WS FRAME for unknown connection: ${msg.connId}`);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
if (msg.binary) {
|
|
267
|
+
const buffer = Buffer.from(msg.data, 'base64');
|
|
268
|
+
localWs.send(buffer);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
localWs.send(msg.data);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
console.error(`WS SEND FAILED ${msg.connId}:`, err);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
handleWsClose(msg) {
|
|
279
|
+
const localWs = this.localWsConnections.get(msg.connId);
|
|
280
|
+
if (!localWs) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
console.log(`WS CLOSE ${msg.connId}: ${msg.code} ${msg.reason}`);
|
|
284
|
+
try {
|
|
285
|
+
localWs.close(msg.code, msg.reason);
|
|
286
|
+
}
|
|
287
|
+
catch { }
|
|
288
|
+
this.localWsConnections.delete(msg.connId);
|
|
289
|
+
}
|
|
290
|
+
send(msg) {
|
|
291
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
292
|
+
console.warn('Cannot send: WebSocket not connected');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
this.ws.send(JSON.stringify(msg));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const CLI_NAME = "traforo";
|
|
2
|
+
export type RunTunnelOptions = {
|
|
3
|
+
port: number;
|
|
4
|
+
tunnelId?: string;
|
|
5
|
+
localHost?: string;
|
|
6
|
+
serverUrl?: string;
|
|
7
|
+
command?: string[];
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Parse argv to extract command after `--` separator.
|
|
11
|
+
* Returns the command array and remaining argv without the command.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseCommandFromArgv(argv: string[]): {
|
|
14
|
+
command: string[];
|
|
15
|
+
argv: string[];
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Run the tunnel, optionally spawning a child process first.
|
|
19
|
+
*/
|
|
20
|
+
export declare function runTunnel(options: RunTunnelOptions): Promise<void>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { TunnelClient } from './client.js';
|
|
4
|
+
export const CLI_NAME = 'traforo';
|
|
5
|
+
/**
|
|
6
|
+
* Wait for a port to be available (accepting connections).
|
|
7
|
+
* Used when spawning a child process to wait for the server to start.
|
|
8
|
+
*/
|
|
9
|
+
async function waitForPort(port, host = 'localhost', timeoutMs = 60_000) {
|
|
10
|
+
const start = Date.now();
|
|
11
|
+
const checkInterval = 500;
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const check = () => {
|
|
14
|
+
if (Date.now() - start > timeoutMs) {
|
|
15
|
+
reject(new Error(`Timeout waiting for port ${port} to be available`));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const socket = new net.Socket();
|
|
19
|
+
socket.once('connect', () => {
|
|
20
|
+
socket.destroy();
|
|
21
|
+
resolve();
|
|
22
|
+
});
|
|
23
|
+
socket.once('error', () => {
|
|
24
|
+
socket.destroy();
|
|
25
|
+
setTimeout(check, checkInterval);
|
|
26
|
+
});
|
|
27
|
+
socket.connect(port, host);
|
|
28
|
+
};
|
|
29
|
+
check();
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Parse argv to extract command after `--` separator.
|
|
34
|
+
* Returns the command array and remaining argv without the command.
|
|
35
|
+
*/
|
|
36
|
+
export function parseCommandFromArgv(argv) {
|
|
37
|
+
const dashDashIndex = argv.indexOf('--');
|
|
38
|
+
if (dashDashIndex === -1) {
|
|
39
|
+
return { command: [], argv };
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
command: argv.slice(dashDashIndex + 1),
|
|
43
|
+
argv: argv.slice(0, dashDashIndex),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Run the tunnel, optionally spawning a child process first.
|
|
48
|
+
*/
|
|
49
|
+
export async function runTunnel(options) {
|
|
50
|
+
const tunnelId = options.tunnelId || crypto.randomUUID().slice(0, 8);
|
|
51
|
+
const localHost = options.localHost || 'localhost';
|
|
52
|
+
const port = options.port;
|
|
53
|
+
let child = null;
|
|
54
|
+
// If command provided, spawn child process with PORT env
|
|
55
|
+
if (options.command && options.command.length > 0) {
|
|
56
|
+
const cmd = options.command[0];
|
|
57
|
+
const args = options.command.slice(1);
|
|
58
|
+
console.log(`Starting: ${options.command.join(' ')}`);
|
|
59
|
+
console.log(`PORT=${port}\n`);
|
|
60
|
+
const spawnedChild = spawn(cmd, args, {
|
|
61
|
+
stdio: 'inherit',
|
|
62
|
+
env: {
|
|
63
|
+
...process.env,
|
|
64
|
+
PORT: String(port),
|
|
65
|
+
// Disable clear/animations for common tools without lying about CI
|
|
66
|
+
FORCE_COLOR: '1',
|
|
67
|
+
VITE_CLS: 'false',
|
|
68
|
+
NEXT_TELEMETRY_DISABLED: '1',
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
child = spawnedChild;
|
|
72
|
+
spawnedChild.on('error', (err) => {
|
|
73
|
+
console.error(`Failed to start command: ${err.message}`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
});
|
|
76
|
+
spawnedChild.on('exit', (code) => {
|
|
77
|
+
console.log(`\nCommand exited with code ${code}`);
|
|
78
|
+
process.exit(code || 0);
|
|
79
|
+
});
|
|
80
|
+
// Wait for port to be available before connecting tunnel
|
|
81
|
+
console.log(`Waiting for port ${port}...`);
|
|
82
|
+
try {
|
|
83
|
+
await waitForPort(port, localHost);
|
|
84
|
+
console.log(`Port ${port} is ready!\n`);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
88
|
+
spawnedChild.kill();
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const client = new TunnelClient({
|
|
93
|
+
localPort: port,
|
|
94
|
+
tunnelId,
|
|
95
|
+
localHost,
|
|
96
|
+
...(options.serverUrl && { serverUrl: options.serverUrl }),
|
|
97
|
+
});
|
|
98
|
+
// Handle shutdown
|
|
99
|
+
const cleanup = () => {
|
|
100
|
+
console.log('\nShutting down...');
|
|
101
|
+
client.close();
|
|
102
|
+
if (child) {
|
|
103
|
+
child.kill();
|
|
104
|
+
}
|
|
105
|
+
process.exit(0);
|
|
106
|
+
};
|
|
107
|
+
process.on('SIGINT', cleanup);
|
|
108
|
+
process.on('SIGTERM', cleanup);
|
|
109
|
+
try {
|
|
110
|
+
await client.connect();
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
console.error('Failed to connect:', err instanceof Error ? err.message : String(err));
|
|
114
|
+
if (child) {
|
|
115
|
+
child.kill();
|
|
116
|
+
}
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export type HttpRequestMessage = {
|
|
2
|
+
type: 'http_request';
|
|
3
|
+
id: string;
|
|
4
|
+
method: string;
|
|
5
|
+
path: string;
|
|
6
|
+
headers: Record<string, string>;
|
|
7
|
+
body: string | null;
|
|
8
|
+
};
|
|
9
|
+
export type WsOpenMessage = {
|
|
10
|
+
type: 'ws_open';
|
|
11
|
+
connId: string;
|
|
12
|
+
path: string;
|
|
13
|
+
headers: Record<string, string>;
|
|
14
|
+
};
|
|
15
|
+
export type WsFrameMessage = {
|
|
16
|
+
type: 'ws_frame';
|
|
17
|
+
connId: string;
|
|
18
|
+
data: string;
|
|
19
|
+
binary?: boolean;
|
|
20
|
+
};
|
|
21
|
+
export type WsCloseMessage = {
|
|
22
|
+
type: 'ws_close';
|
|
23
|
+
connId: string;
|
|
24
|
+
code: number;
|
|
25
|
+
reason: string;
|
|
26
|
+
};
|
|
27
|
+
export type UpstreamMessage = HttpRequestMessage | WsOpenMessage | WsFrameMessage | WsCloseMessage;
|
|
28
|
+
export type HttpResponseMessage = {
|
|
29
|
+
type: 'http_response';
|
|
30
|
+
id: string;
|
|
31
|
+
status: number;
|
|
32
|
+
headers: Record<string, string>;
|
|
33
|
+
body: string | null;
|
|
34
|
+
};
|
|
35
|
+
export type HttpResponseStartMessage = {
|
|
36
|
+
type: 'http_response_start';
|
|
37
|
+
id: string;
|
|
38
|
+
status: number;
|
|
39
|
+
headers: Record<string, string>;
|
|
40
|
+
};
|
|
41
|
+
export type HttpResponseChunkMessage = {
|
|
42
|
+
type: 'http_response_chunk';
|
|
43
|
+
id: string;
|
|
44
|
+
chunk: string;
|
|
45
|
+
};
|
|
46
|
+
export type HttpResponseEndMessage = {
|
|
47
|
+
type: 'http_response_end';
|
|
48
|
+
id: string;
|
|
49
|
+
};
|
|
50
|
+
export type HttpErrorMessage = {
|
|
51
|
+
type: 'http_error';
|
|
52
|
+
id: string;
|
|
53
|
+
error: string;
|
|
54
|
+
};
|
|
55
|
+
export type WsOpenedMessage = {
|
|
56
|
+
type: 'ws_opened';
|
|
57
|
+
connId: string;
|
|
58
|
+
};
|
|
59
|
+
export type WsFrameResponseMessage = {
|
|
60
|
+
type: 'ws_frame';
|
|
61
|
+
connId: string;
|
|
62
|
+
data: string;
|
|
63
|
+
binary?: boolean;
|
|
64
|
+
};
|
|
65
|
+
export type WsClosedMessage = {
|
|
66
|
+
type: 'ws_closed';
|
|
67
|
+
connId: string;
|
|
68
|
+
code: number;
|
|
69
|
+
reason: string;
|
|
70
|
+
};
|
|
71
|
+
export type WsErrorMessage = {
|
|
72
|
+
type: 'ws_error';
|
|
73
|
+
connId: string;
|
|
74
|
+
error: string;
|
|
75
|
+
};
|
|
76
|
+
export type DownstreamMessage = HttpResponseMessage | HttpResponseStartMessage | HttpResponseChunkMessage | HttpResponseEndMessage | HttpErrorMessage | WsOpenedMessage | WsFrameResponseMessage | WsClosedMessage | WsErrorMessage;
|
|
77
|
+
export type UpstreamConnectedEvent = {
|
|
78
|
+
event: 'upstream_connected';
|
|
79
|
+
};
|
|
80
|
+
export type UpstreamDisconnectedEvent = {
|
|
81
|
+
event: 'upstream_disconnected';
|
|
82
|
+
};
|
|
83
|
+
export type DownstreamEvent = UpstreamConnectedEvent | UpstreamDisconnectedEvent;
|
|
84
|
+
export declare function createMessage<T extends UpstreamMessage | DownstreamMessage>(msg: T): string;
|
|
85
|
+
export declare function parseUpstreamMessage(data: string): UpstreamMessage | null;
|
|
86
|
+
export declare function parseDownstreamMessage(data: string): DownstreamMessage | null;
|