traforo 0.2.5 → 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 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 -p 3000 -- next start
24
- traforo -p 3000 -- pnpm dev
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 (required)
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 (required)')
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
- const port = parseInt(options.port, 10);
28
- if (isNaN(port) || port < 1 || port > 65535) {
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
- // --cache with no value comes as boolean true, --cache v2 comes as string "v2"
33
- const cacheKey = options.cache
34
- ? typeof options.cache === 'string'
35
- ? options.cache
36
- : 'default'
37
- : undefined;
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.xyz) */
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;
@@ -1,9 +1,9 @@
1
1
  export declare const CLI_NAME = "traforo";
2
- export declare function createRandomTunnelId({ port }: {
3
- port: number;
2
+ export declare function createRandomTunnelId({ port }?: {
3
+ port?: number;
4
4
  }): string;
5
5
  export type RunTunnelOptions = {
6
- port: number;
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.
@@ -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
- return `${crypto.randomBytes(DEFAULT_TUNNEL_ID_BYTES).toString('hex')}-${port}`;
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
- const port = options.port;
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 start fresh while keeping the same tunnel URL,`);
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
  }
@@ -253,12 +335,18 @@ export async function runTunnel(options) {
253
335
  const cmd = options.command[0];
254
336
  const args = options.command.slice(1);
255
337
  console.log(`Starting: ${shellQuote(options.command)}`);
256
- console.log(`PORT=${port}\n`);
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('');
257
345
  const spawnedChild = spawn(cmd, args, {
258
- stdio: 'inherit',
346
+ stdio: ['inherit', 'pipe', 'pipe'],
259
347
  env: {
260
348
  ...process.env,
261
- PORT: String(port),
349
+ ...(port ? { PORT: String(port) } : {}),
262
350
  // Disable clear/animations for common tools without lying about CI
263
351
  FORCE_COLOR: '1',
264
352
  VITE_CLS: 'false',
@@ -266,18 +354,28 @@ export async function runTunnel(options) {
266
354
  },
267
355
  });
268
356
  child = spawnedChild;
357
+ spawnedChild.stdout?.pipe(process.stdout);
358
+ spawnedChild.stderr?.pipe(process.stderr);
269
359
  spawnedChild.on('error', (err) => {
270
360
  console.error(`Failed to start command: ${err.message}`);
271
361
  process.exit(1);
272
362
  });
273
363
  spawnedChild.on('exit', (code) => {
274
364
  console.log(`\nCommand exited with code ${code}`);
275
- removeLockfile(port, process.pid);
365
+ if (port) {
366
+ removeLockfile(port, process.pid);
367
+ }
276
368
  process.exit(code || 0);
277
369
  });
278
- // Wait for port to be available before connecting tunnel
279
- console.log(`Waiting for port ${port}...`);
280
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}...`);
281
379
  await waitForPort(port, localHost);
282
380
  console.log(`Port ${port} is ready!\n`);
283
381
  }
@@ -287,6 +385,10 @@ export async function runTunnel(options) {
287
385
  process.exit(1);
288
386
  }
289
387
  }
388
+ if (!port) {
389
+ console.error('Error: Failed to determine local port');
390
+ process.exit(1);
391
+ }
290
392
  const client = new TunnelClient({
291
393
  localPort: port,
292
394
  tunnelId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "traforo",
3
- "version": "0.2.5",
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.3.0",
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:preview": "wrangler deploy --env preview",
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",