traforo 0.2.5 → 0.4.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 +46 -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 -13
- 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
|
|
@@ -91,6 +111,29 @@ instructions to pass the password as a cookie:
|
|
|
91
111
|
WebSocket upgrade requests without the correct cookie are rejected with
|
|
92
112
|
close code 4013.
|
|
93
113
|
|
|
114
|
+
## TRAFORO_URL ENVIRONMENT VARIABLE
|
|
115
|
+
|
|
116
|
+
When you run a command after `--`, traforo injects `TRAFORO_URL` into the
|
|
117
|
+
child process environment with the full public tunnel URL:
|
|
118
|
+
|
|
119
|
+
TRAFORO_URL=https://{tunnel-id}-tunnel.traforo.dev
|
|
120
|
+
|
|
121
|
+
Your app can read it directly:
|
|
122
|
+
|
|
123
|
+
const baseUrl = process.env.TRAFORO_URL
|
|
124
|
+
|
|
125
|
+
To remap it to a custom env var your app already uses, prefix the command:
|
|
126
|
+
|
|
127
|
+
traforo -p 3000 -- sh -c 'APP_URL=$TRAFORO_URL exec node server.js'
|
|
128
|
+
traforo -p 3000 -- sh -c 'NEXT_PUBLIC_URL=$TRAFORO_URL exec next dev'
|
|
129
|
+
traforo -p 3000 -- sh -c 'VITE_BASE_URL=$TRAFORO_URL exec vite'
|
|
130
|
+
|
|
131
|
+
Or set it in your .env / startup script and let traforo override only
|
|
132
|
+
`TRAFORO_URL`, reading it where needed:
|
|
133
|
+
|
|
134
|
+
// next.config.js
|
|
135
|
+
const baseUrl = process.env.APP_URL || process.env.TRAFORO_URL || 'http://localhost:3000'
|
|
136
|
+
|
|
94
137
|
## HOW IT WORKS
|
|
95
138
|
|
|
96
139
|
1. Local client connects to Cloudflare Durable Object via WebSocket
|
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,7 +277,7 @@ 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);
|
|
@@ -230,8 +313,7 @@ export async function runTunnel(options) {
|
|
|
230
313
|
console.error(` Dir: ${lock.cwd}`);
|
|
231
314
|
console.error(` PID: ${lock.tunnelPid}`);
|
|
232
315
|
console.error(` Started: ${lock.startedAt}\n`);
|
|
233
|
-
console.error(`Use --kill to terminate the existing process and
|
|
234
|
-
console.error(`or just reuse the tunnel URL above instead:\n`);
|
|
316
|
+
console.error(`Use --kill to terminate the existing process and reuse the tunnel URL:`);
|
|
235
317
|
console.error(` ${restartCommand}`);
|
|
236
318
|
process.exit(1);
|
|
237
319
|
}
|
|
@@ -248,17 +330,27 @@ export async function runTunnel(options) {
|
|
|
248
330
|
}
|
|
249
331
|
}
|
|
250
332
|
let child = null;
|
|
333
|
+
// Compute tunnel URL early so it can be injected into the child env
|
|
334
|
+
const baseDomain = options.baseDomain || 'traforo.dev';
|
|
335
|
+
const tunnelUrl = `https://${tunnelId}-tunnel.${baseDomain}`;
|
|
251
336
|
// If command provided, spawn child process with PORT env
|
|
252
337
|
if (options.command && options.command.length > 0) {
|
|
253
338
|
const cmd = options.command[0];
|
|
254
339
|
const args = options.command.slice(1);
|
|
255
340
|
console.log(`Starting: ${shellQuote(options.command)}`);
|
|
256
|
-
|
|
341
|
+
if (port) {
|
|
342
|
+
console.log(`PORT=${port}`);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
console.log('Waiting for command output to reveal the local port...');
|
|
346
|
+
}
|
|
347
|
+
console.log('');
|
|
257
348
|
const spawnedChild = spawn(cmd, args, {
|
|
258
|
-
stdio: 'inherit',
|
|
349
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
259
350
|
env: {
|
|
260
351
|
...process.env,
|
|
261
|
-
PORT: String(port),
|
|
352
|
+
...(port ? { PORT: String(port) } : {}),
|
|
353
|
+
TRAFORO_URL: tunnelUrl,
|
|
262
354
|
// Disable clear/animations for common tools without lying about CI
|
|
263
355
|
FORCE_COLOR: '1',
|
|
264
356
|
VITE_CLS: 'false',
|
|
@@ -266,18 +358,28 @@ export async function runTunnel(options) {
|
|
|
266
358
|
},
|
|
267
359
|
});
|
|
268
360
|
child = spawnedChild;
|
|
361
|
+
spawnedChild.stdout?.pipe(process.stdout);
|
|
362
|
+
spawnedChild.stderr?.pipe(process.stderr);
|
|
269
363
|
spawnedChild.on('error', (err) => {
|
|
270
364
|
console.error(`Failed to start command: ${err.message}`);
|
|
271
365
|
process.exit(1);
|
|
272
366
|
});
|
|
273
367
|
spawnedChild.on('exit', (code) => {
|
|
274
368
|
console.log(`\nCommand exited with code ${code}`);
|
|
275
|
-
|
|
369
|
+
if (port) {
|
|
370
|
+
removeLockfile(port, process.pid);
|
|
371
|
+
}
|
|
276
372
|
process.exit(code || 0);
|
|
277
373
|
});
|
|
278
|
-
// Wait for port to be available before connecting tunnel
|
|
279
|
-
console.log(`Waiting for port ${port}...`);
|
|
280
374
|
try {
|
|
375
|
+
if (!port) {
|
|
376
|
+
port = await detectPortFromProcessOutput(spawnedChild);
|
|
377
|
+
console.log(`\nDetected local port ${port}`);
|
|
378
|
+
}
|
|
379
|
+
if (!port) {
|
|
380
|
+
throw new Error('Failed to determine local port');
|
|
381
|
+
}
|
|
382
|
+
console.log(`Waiting for port ${port}...`);
|
|
281
383
|
await waitForPort(port, localHost);
|
|
282
384
|
console.log(`Port ${port} is ready!\n`);
|
|
283
385
|
}
|
|
@@ -287,6 +389,10 @@ export async function runTunnel(options) {
|
|
|
287
389
|
process.exit(1);
|
|
288
390
|
}
|
|
289
391
|
}
|
|
392
|
+
if (!port) {
|
|
393
|
+
console.error('Error: Failed to determine local port');
|
|
394
|
+
process.exit(1);
|
|
395
|
+
}
|
|
290
396
|
const client = new TunnelClient({
|
|
291
397
|
localPort: port,
|
|
292
398
|
tunnelId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "traforo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|