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 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
@@ -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 (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
  }
@@ -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
- console.log(`PORT=${port}\n`);
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
- removeLockfile(port, process.pid);
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.2.5",
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.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",