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 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,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 start fresh,`);
231
- console.error(`or just reuse the tunnel URL above instead:\n`);
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
- 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('');
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
- removeLockfile(port, process.pid);
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.2.4",
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",