traforo 0.2.4 → 0.3.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 +23 -3
- package/dist/cli.js +21 -17
- package/dist/client.d.ts +1 -1
- package/dist/run-tunnel.d.ts +4 -3
- package/dist/run-tunnel.js +119 -14
- package/package.json +4 -4
package/README
CHANGED
|
@@ -14,15 +14,21 @@ Expose a local server:
|
|
|
14
14
|
|
|
15
15
|
traforo -p 3000
|
|
16
16
|
|
|
17
|
+
Or let traforo auto-detect the port from a dev server command:
|
|
18
|
+
|
|
19
|
+
traforo -- pnpm dev
|
|
20
|
+
traforo -- next start
|
|
21
|
+
|
|
17
22
|
With a custom tunnel ID (only for services safe to expose publicly):
|
|
18
23
|
|
|
19
24
|
traforo -p 3000 -t my-app
|
|
20
25
|
|
|
21
26
|
Run a command and tunnel it:
|
|
22
27
|
|
|
23
|
-
traforo
|
|
24
|
-
traforo
|
|
28
|
+
traforo -- next start
|
|
29
|
+
traforo -- pnpm dev
|
|
25
30
|
traforo -p 5173 -- vite
|
|
31
|
+
traforo -p 3000 -- next start # explicit port overrides auto-detection
|
|
26
32
|
|
|
27
33
|
The tunnel URL will be:
|
|
28
34
|
|
|
@@ -30,7 +36,7 @@ The tunnel URL will be:
|
|
|
30
36
|
|
|
31
37
|
## OPTIONS
|
|
32
38
|
|
|
33
|
-
-p, --port <port> Local port to expose (
|
|
39
|
+
-p, --port <port> Local port to expose (optional with -- command)
|
|
34
40
|
-t, --tunnel-id [id] Custom tunnel ID (prefer random default)
|
|
35
41
|
-c, --cache [key] Enable edge caching (optional partition key)
|
|
36
42
|
--password <password> Protect the tunnel with a password
|
|
@@ -39,6 +45,20 @@ The tunnel URL will be:
|
|
|
39
45
|
--help Show help
|
|
40
46
|
--version Show version
|
|
41
47
|
|
|
48
|
+
## AUTO PORT DETECTION
|
|
49
|
+
|
|
50
|
+
When you pass a command after `--`, traforo can detect the local port from the
|
|
51
|
+
process output. It watches stdout and stderr for addresses like these:
|
|
52
|
+
|
|
53
|
+
http://localhost:3000
|
|
54
|
+
localhost:5173
|
|
55
|
+
127.0.0.1:8080
|
|
56
|
+
0.0.0.0:4321
|
|
57
|
+
|
|
58
|
+
This works well with common dev servers that print their local URL when they start.
|
|
59
|
+
|
|
60
|
+
If you also pass `-p`, traforo uses that explicit port instead of auto-detecting.
|
|
61
|
+
|
|
42
62
|
## EDGE CACHING
|
|
43
63
|
|
|
44
64
|
Cache responses at Cloudflare's edge so repeat requests never hit your
|
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ const { command, argv } = parseCommandFromArgv(process.argv);
|
|
|
5
5
|
const cli = goke(CLI_NAME);
|
|
6
6
|
cli
|
|
7
7
|
.command('', 'Expose a local port via tunnel')
|
|
8
|
-
.option('-p, --port <port>', 'Local port to expose (
|
|
8
|
+
.option('-p, --port <port>', 'Local port to expose (optional when using -- command)')
|
|
9
9
|
.option('-t, --tunnel-id [id]', 'Custom tunnel ID (only for services safe to expose publicly; prefer random default)')
|
|
10
10
|
.option('-h, --host [host]', 'Local host (default: localhost)')
|
|
11
11
|
.option('-s, --server [url]', 'Tunnel server URL')
|
|
@@ -13,33 +13,37 @@ cli
|
|
|
13
13
|
.option('--password <password>', 'Protect the tunnel with a password (visitors must enter it to access)')
|
|
14
14
|
.option('-k, --kill', 'Kill any existing process on the port before starting')
|
|
15
15
|
.example(`${CLI_NAME} -p 3000`)
|
|
16
|
+
.example(`${CLI_NAME} -- next start`)
|
|
17
|
+
.example(`${CLI_NAME} -- pnpm dev`)
|
|
16
18
|
.example(`${CLI_NAME} -p 3000 -- next start`)
|
|
17
|
-
.example(`${CLI_NAME} -p 3000 -- pnpm dev`)
|
|
18
19
|
.example(`${CLI_NAME} -p 5173 -t my-app -- vite`)
|
|
19
20
|
.example(`${CLI_NAME} -p 3000 --cache`)
|
|
20
21
|
.example(`${CLI_NAME} -p 3000 --cache v2`)
|
|
21
22
|
.action(async (options) => {
|
|
22
|
-
if (!options.port) {
|
|
23
|
-
console.error('Error: --port is required');
|
|
24
|
-
console.error(`\nUsage: ${CLI_NAME} -p <port> [-- command]`);
|
|
23
|
+
if (!options.port && command.length === 0) {
|
|
24
|
+
console.error('Error: --port is required unless a command is provided after --');
|
|
25
|
+
console.error(`\nUsage: ${CLI_NAME} [-p <port>] [-- command]`);
|
|
25
26
|
process.exit(1);
|
|
26
27
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
console.error(`Error: Invalid port number: ${options.port}`);
|
|
28
|
+
if (options.kill && !options.port) {
|
|
29
|
+
console.error('Error: --kill requires --port');
|
|
30
30
|
process.exit(1);
|
|
31
31
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
:
|
|
37
|
-
|
|
32
|
+
let port;
|
|
33
|
+
if (options.port) {
|
|
34
|
+
port = parseInt(options.port, 10);
|
|
35
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
36
|
+
console.error(`Error: Invalid port number: ${options.port}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// --cache bare (`''`) → 'default', --cache v2 → 'v2', omitted → undefined
|
|
41
|
+
const cacheKey = options.cache === '' ? 'default' : options.cache || undefined;
|
|
38
42
|
await runTunnel({
|
|
39
43
|
port,
|
|
40
|
-
tunnelId: options.tunnelId,
|
|
41
|
-
localHost: options.host,
|
|
42
|
-
serverUrl: options.server,
|
|
44
|
+
tunnelId: options.tunnelId || undefined,
|
|
45
|
+
localHost: options.host || undefined,
|
|
46
|
+
serverUrl: options.server || undefined,
|
|
43
47
|
command: command.length > 0 ? command : undefined,
|
|
44
48
|
cacheKey,
|
|
45
49
|
password: options.password,
|
package/dist/client.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ type TunnelClientOptions = {
|
|
|
6
6
|
localPort: number;
|
|
7
7
|
/** Local host (default: localhost) */
|
|
8
8
|
localHost?: string;
|
|
9
|
-
/** Base domain for tunnel URLs (default: kimaki.
|
|
9
|
+
/** Base domain for tunnel URLs (default: kimaki.dev) */
|
|
10
10
|
baseDomain?: string;
|
|
11
11
|
/** Tunnel server URL (default: wss://{tunnelId}-tunnel.{baseDomain}) */
|
|
12
12
|
serverUrl?: string;
|
package/dist/run-tunnel.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export declare const CLI_NAME = "traforo";
|
|
2
|
-
export declare function createRandomTunnelId({ port }
|
|
3
|
-
port
|
|
2
|
+
export declare function createRandomTunnelId({ port }?: {
|
|
3
|
+
port?: number;
|
|
4
4
|
}): string;
|
|
5
5
|
export type RunTunnelOptions = {
|
|
6
|
-
port
|
|
6
|
+
port?: number;
|
|
7
7
|
tunnelId?: string;
|
|
8
8
|
localHost?: string;
|
|
9
9
|
baseDomain?: string;
|
|
@@ -16,6 +16,7 @@ export type RunTunnelOptions = {
|
|
|
16
16
|
/** Kill any existing process on the port before starting */
|
|
17
17
|
kill?: boolean;
|
|
18
18
|
};
|
|
19
|
+
export declare function detectPortFromText(text: string): number | null;
|
|
19
20
|
/**
|
|
20
21
|
* Parse argv to extract command after `--` separator.
|
|
21
22
|
* Returns the command array and remaining argv without the command.
|
package/dist/run-tunnel.js
CHANGED
|
@@ -24,8 +24,27 @@ function shellQuote(args) {
|
|
|
24
24
|
.join(' ');
|
|
25
25
|
}
|
|
26
26
|
const DEFAULT_TUNNEL_ID_BYTES = 10;
|
|
27
|
-
export function createRandomTunnelId({ port }) {
|
|
28
|
-
|
|
27
|
+
export function createRandomTunnelId({ port } = {}) {
|
|
28
|
+
const randomId = crypto.randomBytes(DEFAULT_TUNNEL_ID_BYTES).toString('hex');
|
|
29
|
+
if (!port) {
|
|
30
|
+
return randomId;
|
|
31
|
+
}
|
|
32
|
+
return `${randomId}-${port}`;
|
|
33
|
+
}
|
|
34
|
+
const LOCAL_PORT_PATTERNS = [
|
|
35
|
+
/(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::\]):(\d{1,5})/i,
|
|
36
|
+
/\blistening(?:\s+at|\s+on)?\s+(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::\]):(\d{1,5})/i,
|
|
37
|
+
/\bport\s+(\d{1,5})\b/i,
|
|
38
|
+
];
|
|
39
|
+
export function detectPortFromText(text) {
|
|
40
|
+
for (const pattern of LOCAL_PORT_PATTERNS) {
|
|
41
|
+
const match = text.match(pattern);
|
|
42
|
+
const port = Number(match?.[1]);
|
|
43
|
+
if (port >= 1 && port <= 65535) {
|
|
44
|
+
return port;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
29
48
|
}
|
|
30
49
|
/**
|
|
31
50
|
* Wait for a port to be available (accepting connections).
|
|
@@ -54,6 +73,62 @@ async function waitForPort(port, host = 'localhost', timeoutMs = 60_000) {
|
|
|
54
73
|
check();
|
|
55
74
|
});
|
|
56
75
|
}
|
|
76
|
+
async function detectPortFromProcessOutput(child, timeoutMs = 60_000) {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
let settled = false;
|
|
79
|
+
let outputBuffer = '';
|
|
80
|
+
const cleanup = () => {
|
|
81
|
+
clearTimeout(timeout);
|
|
82
|
+
child.off('exit', handleExit);
|
|
83
|
+
child.off('error', handleError);
|
|
84
|
+
child.stdout?.off('data', onStdout);
|
|
85
|
+
child.stderr?.off('data', onStderr);
|
|
86
|
+
};
|
|
87
|
+
const finish = (value) => {
|
|
88
|
+
if (settled)
|
|
89
|
+
return;
|
|
90
|
+
settled = true;
|
|
91
|
+
cleanup();
|
|
92
|
+
resolve(value);
|
|
93
|
+
};
|
|
94
|
+
const fail = (error) => {
|
|
95
|
+
if (settled)
|
|
96
|
+
return;
|
|
97
|
+
settled = true;
|
|
98
|
+
cleanup();
|
|
99
|
+
reject(error);
|
|
100
|
+
};
|
|
101
|
+
const scanChunk = (chunk) => {
|
|
102
|
+
outputBuffer += chunk.toString();
|
|
103
|
+
if (outputBuffer.length > 8_000) {
|
|
104
|
+
outputBuffer = outputBuffer.slice(-8_000);
|
|
105
|
+
}
|
|
106
|
+
const detectedPort = detectPortFromText(outputBuffer);
|
|
107
|
+
if (detectedPort) {
|
|
108
|
+
finish(detectedPort);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const handleExit = (code) => {
|
|
112
|
+
fail(new Error(`Command exited with code ${code} before a local port was detected`));
|
|
113
|
+
};
|
|
114
|
+
const handleError = (error) => {
|
|
115
|
+
fail(error);
|
|
116
|
+
};
|
|
117
|
+
const onStdout = (chunk) => {
|
|
118
|
+
scanChunk(chunk);
|
|
119
|
+
};
|
|
120
|
+
const onStderr = (chunk) => {
|
|
121
|
+
scanChunk(chunk);
|
|
122
|
+
};
|
|
123
|
+
child.stdout?.on('data', onStdout);
|
|
124
|
+
child.stderr?.on('data', onStderr);
|
|
125
|
+
const timeout = setTimeout(() => {
|
|
126
|
+
fail(new Error('Timeout waiting for command output to reveal a local port'));
|
|
127
|
+
}, timeoutMs);
|
|
128
|
+
child.on('exit', handleExit);
|
|
129
|
+
child.on('error', handleError);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
57
132
|
/**
|
|
58
133
|
* Check if a port is currently in use (something is listening).
|
|
59
134
|
*/
|
|
@@ -180,10 +255,18 @@ export function parseCommandFromArgv(argv) {
|
|
|
180
255
|
*/
|
|
181
256
|
export async function runTunnel(options) {
|
|
182
257
|
const localHost = options.localHost || 'localhost';
|
|
183
|
-
|
|
258
|
+
if (!options.port && !options.command?.length) {
|
|
259
|
+
console.error('Error: --port is required unless a command is provided after --');
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
if (options.kill && !options.port) {
|
|
263
|
+
console.error('Error: --kill requires --port');
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
let port = options.port;
|
|
184
267
|
const tunnelId = options.tunnelId || createRandomTunnelId({ port });
|
|
185
268
|
// Kill existing process on port if requested
|
|
186
|
-
if (options.kill) {
|
|
269
|
+
if (options.kill && port) {
|
|
187
270
|
await killProcessOnPort(port);
|
|
188
271
|
// Verify the port actually freed up before removing the lockfile
|
|
189
272
|
if (await isPortInUse(port, localHost)) {
|
|
@@ -194,13 +277,14 @@ export async function runTunnel(options) {
|
|
|
194
277
|
removeLockfile(port); // no ownership check — --kill is intentional
|
|
195
278
|
}
|
|
196
279
|
// Pre-flight: detect port conflict before spawning the child process
|
|
197
|
-
if (options.command && options.command.length > 0 && !options.kill) {
|
|
280
|
+
if (port && options.command && options.command.length > 0 && !options.kill) {
|
|
198
281
|
const portBusy = await isPortInUse(port, localHost);
|
|
199
282
|
if (portBusy) {
|
|
200
283
|
const lock = readLockfile(port);
|
|
201
284
|
if (lock && !isLockfileStale(lock)) {
|
|
202
285
|
const currentCwd = process.cwd();
|
|
203
286
|
const currentCmd = options.command;
|
|
287
|
+
const restartCommand = `${CLI_NAME} -p ${port} -t ${lock.tunnelId} --kill -- ${shellQuote(currentCmd)}`;
|
|
204
288
|
const sameCwd = lock.cwd === currentCwd;
|
|
205
289
|
const sameCmd = lock.command &&
|
|
206
290
|
lock.command.length === currentCmd.length &&
|
|
@@ -216,6 +300,8 @@ export async function runTunnel(options) {
|
|
|
216
300
|
console.error(` Started: ${lock.startedAt}\n`);
|
|
217
301
|
console.error(`The same command in the same directory is already tunneled.`);
|
|
218
302
|
console.error(`Reuse the tunnel URL above instead of creating a new one.`);
|
|
303
|
+
console.error(`If you want to restart it without changing the tunnel URL for existing consumers, run:`);
|
|
304
|
+
console.error(` ${restartCommand}`);
|
|
219
305
|
process.exit(1);
|
|
220
306
|
}
|
|
221
307
|
else {
|
|
@@ -227,9 +313,8 @@ export async function runTunnel(options) {
|
|
|
227
313
|
console.error(` Dir: ${lock.cwd}`);
|
|
228
314
|
console.error(` PID: ${lock.tunnelPid}`);
|
|
229
315
|
console.error(` Started: ${lock.startedAt}\n`);
|
|
230
|
-
console.error(`Use --kill to terminate the existing process and
|
|
231
|
-
console.error(`
|
|
232
|
-
console.error(` traforo -p ${port} --kill -- ${shellQuote(options.command)}`);
|
|
316
|
+
console.error(`Use --kill to terminate the existing process and reuse the tunnel URL:`);
|
|
317
|
+
console.error(` ${restartCommand}`);
|
|
233
318
|
process.exit(1);
|
|
234
319
|
}
|
|
235
320
|
}
|
|
@@ -250,12 +335,18 @@ export async function runTunnel(options) {
|
|
|
250
335
|
const cmd = options.command[0];
|
|
251
336
|
const args = options.command.slice(1);
|
|
252
337
|
console.log(`Starting: ${shellQuote(options.command)}`);
|
|
253
|
-
|
|
338
|
+
if (port) {
|
|
339
|
+
console.log(`PORT=${port}`);
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
console.log('Waiting for command output to reveal the local port...');
|
|
343
|
+
}
|
|
344
|
+
console.log('');
|
|
254
345
|
const spawnedChild = spawn(cmd, args, {
|
|
255
|
-
stdio: 'inherit',
|
|
346
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
256
347
|
env: {
|
|
257
348
|
...process.env,
|
|
258
|
-
PORT: String(port),
|
|
349
|
+
...(port ? { PORT: String(port) } : {}),
|
|
259
350
|
// Disable clear/animations for common tools without lying about CI
|
|
260
351
|
FORCE_COLOR: '1',
|
|
261
352
|
VITE_CLS: 'false',
|
|
@@ -263,18 +354,28 @@ export async function runTunnel(options) {
|
|
|
263
354
|
},
|
|
264
355
|
});
|
|
265
356
|
child = spawnedChild;
|
|
357
|
+
spawnedChild.stdout?.pipe(process.stdout);
|
|
358
|
+
spawnedChild.stderr?.pipe(process.stderr);
|
|
266
359
|
spawnedChild.on('error', (err) => {
|
|
267
360
|
console.error(`Failed to start command: ${err.message}`);
|
|
268
361
|
process.exit(1);
|
|
269
362
|
});
|
|
270
363
|
spawnedChild.on('exit', (code) => {
|
|
271
364
|
console.log(`\nCommand exited with code ${code}`);
|
|
272
|
-
|
|
365
|
+
if (port) {
|
|
366
|
+
removeLockfile(port, process.pid);
|
|
367
|
+
}
|
|
273
368
|
process.exit(code || 0);
|
|
274
369
|
});
|
|
275
|
-
// Wait for port to be available before connecting tunnel
|
|
276
|
-
console.log(`Waiting for port ${port}...`);
|
|
277
370
|
try {
|
|
371
|
+
if (!port) {
|
|
372
|
+
port = await detectPortFromProcessOutput(spawnedChild);
|
|
373
|
+
console.log(`\nDetected local port ${port}`);
|
|
374
|
+
}
|
|
375
|
+
if (!port) {
|
|
376
|
+
throw new Error('Failed to determine local port');
|
|
377
|
+
}
|
|
378
|
+
console.log(`Waiting for port ${port}...`);
|
|
278
379
|
await waitForPort(port, localHost);
|
|
279
380
|
console.log(`Port ${port} is ready!\n`);
|
|
280
381
|
}
|
|
@@ -284,6 +385,10 @@ export async function runTunnel(options) {
|
|
|
284
385
|
process.exit(1);
|
|
285
386
|
}
|
|
286
387
|
}
|
|
388
|
+
if (!port) {
|
|
389
|
+
console.error('Error: Failed to determine local port');
|
|
390
|
+
process.exit(1);
|
|
391
|
+
}
|
|
287
392
|
const client = new TunnelClient({
|
|
288
393
|
localPort: port,
|
|
289
394
|
tunnelId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "traforo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "HTTP tunnel via Cloudflare Durable Objects and WebSockets. Edge caching and password protection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"wrangler": "^4.24.3"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"goke": "^6.
|
|
44
|
+
"goke": "^6.8.0",
|
|
45
45
|
"http-cache-semantics": "^4.2.0",
|
|
46
46
|
"string-dedent": "^3.0.2",
|
|
47
47
|
"ws": "^8.19.0"
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
"scripts": {
|
|
50
50
|
"build": "tsc -p tsconfig.client.json && chmod +x dist/cli.js",
|
|
51
51
|
"dev": "wrangler dev",
|
|
52
|
-
"deploy": "wrangler deploy",
|
|
53
|
-
"deploy:
|
|
52
|
+
"deploy": "wrangler deploy --env preview",
|
|
53
|
+
"deploy:prod": "wrangler deploy # only run if user asks specifically!",
|
|
54
54
|
"typecheck": "tsc --noEmit",
|
|
55
55
|
"typecheck:client": "tsc --noEmit -p tsconfig.client.json",
|
|
56
56
|
"typecheck:test": "tsc --noEmit -p tsconfig.test.json",
|